Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b6b063e2b | ||
|
|
cf51e0415a | ||
|
|
c062d1cc8b | ||
|
|
0b858bb343 | ||
|
|
a769479a2a | ||
|
|
5e32d7fa55 | ||
|
|
eebfb45f73 | ||
|
|
419f6dca13 | ||
|
|
1a2d1c7825 | ||
|
|
b6e58dbcc4 | ||
|
|
5bacade667 | ||
|
|
451d44af72 | ||
|
|
2d5be044ee | ||
|
|
9baf3b0443 | ||
|
|
dadd861fdd | ||
|
|
a7827ba986 | ||
|
|
0074d09f08 | ||
|
|
b7f44f7a64 | ||
|
|
654d694ff6 | ||
|
|
6152a68b4a | ||
|
|
fbd42e1c3b | ||
|
|
5a2c5f9c55 | ||
|
|
aad137f04a | ||
|
|
560b3c44e4 | ||
|
|
68ea3cd20e | ||
|
|
cb468df7da | ||
|
|
1249c90725 | ||
|
|
6b2fa29710 | ||
|
|
a75c5c9bc5 | ||
|
|
3680218010 | ||
|
|
141f479e4a | ||
|
|
9f95560268 | ||
|
|
240c52c44f | ||
|
|
0782d901a3 | ||
|
|
9af6e0f73a | ||
|
|
00659165b1 | ||
|
|
704fb8cfc9 | ||
|
|
618ebc986f | ||
|
|
c7d596e087 | ||
|
|
630ea7dd7a | ||
|
|
7d781d132e | ||
|
|
2bf45fdf98 | ||
|
|
590f62e968 | ||
|
|
b34e8c73e0 | ||
|
|
b9236dfaf1 | ||
|
|
00992e5c1c | ||
|
|
c9e34b93a2 | ||
|
|
9384b64c42 | ||
|
|
2437b0e863 | ||
|
|
56f64e1cc1 | ||
|
|
eec76ed91e | ||
|
|
6adf11c85a | ||
|
|
73ed06f5f1 | ||
|
|
79969e9d5d | ||
|
|
a1c51d62a0 | ||
|
|
bce11514a9 | ||
|
|
530bb4fca7 | ||
|
|
ae02252a1d | ||
|
|
ad90634a18 | ||
|
|
02887e265b | ||
|
|
c20e71bddc | ||
|
|
e616e80819 | ||
|
|
71f2cd2597 | ||
|
|
1471708f35 | ||
|
|
456e452b30 | ||
|
|
4e6eb32929 | ||
|
|
591e44721a | ||
|
|
2528f6b709 | ||
|
|
ad569d4e6d | ||
|
|
2eb6a6f8bb | ||
|
|
a0be43d76c | ||
|
|
9ae1326be2 | ||
|
|
1ab9ed22c7 | ||
|
|
5e6231493e | ||
|
|
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,28 @@
|
||||
name: Console Release
|
||||
|
||||
# 控制台发布工作流(tag 触发)。
|
||||
#
|
||||
# 为什么不把 `tags` 直接写进 console-windows.yml 的 push 里
|
||||
# ------------------------------------------------
|
||||
# 那个工作流带 `paths` 过滤。`paths` 与 `tags` 同时出现时,tag 推送会被
|
||||
# 路径判定挡掉而**静默不触发** —— 这是 GitHub Actions 的经典坑,
|
||||
# 且失败形态是「什么都没发生」,最难察觉。
|
||||
# 故拆成:日常验证走 console-windows(分支 + paths),发布走本工作流(只认 tag)。
|
||||
#
|
||||
# 交付物去向:本工作流构建两台架构的安装包,再由 console-windows 里的
|
||||
# `publish` job 补挂到 `scripts/release.mjs` 创建的那个 Release 上。
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
# 发布需要写 Release
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
console:
|
||||
uses: ./.github/workflows/console-windows.yml
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -0,0 +1,408 @@
|
||||
name: Console Windows
|
||||
|
||||
# 控制台(apps/console)的 Windows 安装包构建。
|
||||
#
|
||||
# 为什么单独一个工作流、而不塞进 windows-e2e
|
||||
# ----------------------------------------
|
||||
# windows-e2e 是「被控端在 Windows 上的行为验证」,跑一遍约 4 分钟、含安装/卸载真机冒烟;
|
||||
# 而控制台打包要装 Rust 工具链、编 Tauri(首次冷编 10 分钟级)。两者目标不同、
|
||||
# 耗时量级不同,混在一起会让「改一行前端也要等 15 分钟」。
|
||||
#
|
||||
# ⚠️ 在此之前,控制台的 Windows 侧只被验证到 prepare:node 与自举(见 windows-e2e),
|
||||
# **从未真跑过 tauri build**。而 Rust 里那些 `cfg!(windows)` 分支
|
||||
# (taskkill 收进程树、PowerShell 计划任务、node.exe 路径)在 macOS 上
|
||||
# 只被类型检查、从未被编译成 Windows 目标,更没有产物级验证 —— 故本工作流是它们的第一道真闸。
|
||||
#
|
||||
# 架构(为什么必须两套)
|
||||
# --------------------
|
||||
# 被控端发布包曾出过「arm64 包里装的是 x64 node.exe」的事故,后果是
|
||||
# **在 ARM Windows 上能 listen、但 TLS 握手不完成**,且 macOS 侧 100% 复现不出。
|
||||
# 控制台同理:它内含 node 运行时,且要用它**托起本机被控端**(即 TLS 服务端)。
|
||||
# 故 x64 与 arm64 必须各出一套,且都要能装到对应架构的 Windows 上。
|
||||
#
|
||||
# 这里用 GitHub 的 ARM64 Windows runner(`windows-11-arm`,公共仓库免费)
|
||||
# **原生构建**,而不是在 x64 runner 上交叉编译 —— 交叉编译需要 VS 的 ARM64 工具链,
|
||||
# 且 `prepare-console.mjs` 的自检要真执行 node.exe(x64 机器跑不了 arm64 二进制),
|
||||
# 会逼着引入「跳过自检」这类削弱验证的开关。原生构建则自检照跑、无特殊分支。
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'apps/console/**'
|
||||
- 'apps/agent/**'
|
||||
- 'packages/**'
|
||||
- 'scripts/prepare-console.mjs'
|
||||
- 'scripts/lib/bundle-agent.mjs'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- '.github/workflows/console-windows.yml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'apps/console/**'
|
||||
- '.github/workflows/console-windows.yml'
|
||||
workflow_dispatch:
|
||||
# 供 `console-release.yml` 在 tag 推送时调用。
|
||||
#
|
||||
# 为什么不直接把 `tags` 加进上面的 push:`paths` 与 `tags` 同时存在时,
|
||||
# tag 推送会因为「路径过滤」判定不通过而**静默不触发**(这是 GitHub Actions
|
||||
# 的经典坑)。拆成「主工作流只跑分支 + 发布工作流只认 tag」最不容易出错。
|
||||
workflow_call:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: 控制台安装包 · ${{ matrix.arch }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 60
|
||||
|
||||
strategy:
|
||||
# 一条架构挂了不要拖停另一条 —— 否则「arm64 失败」会掩盖「x64 到底成没成」
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: x64
|
||||
runner: windows-latest
|
||||
# x64 给两种安装形态:msi(企业批量部署)+ nsis(普通用户双击)
|
||||
bundles: msi,nsis
|
||||
pe: '0x8664'
|
||||
- arch: arm64
|
||||
runner: windows-11-arm
|
||||
# arm64 只出 nsis:WiX 的 arm64 MSI 支持面较窄,不值得为它引入不确定性
|
||||
bundles: nsis
|
||||
pe: '0xaa64'
|
||||
|
||||
steps:
|
||||
- name: 检出代码
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: 安装 pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 12
|
||||
|
||||
- name: 安装 Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: 安装 Rust 工具链
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: 缓存 Rust 构建产物
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
# 只缓存 src-tauri(控制台壳),不牵动仓库根的 Rust 工作区
|
||||
workspaces: apps/console/src-tauri
|
||||
|
||||
- name: 安装依赖
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# ⚠️ 这条断言是必需项,不是「顺手检查」。
|
||||
# ARM runner 的镜像里确实存在 x64 模拟的组件(社区实测 python 即为 AMD64),
|
||||
# 一旦 Node 也是模拟版,process.arch 会是 x64 → prepare-console 会把 x64 的 node.exe
|
||||
# 打进 arm64 安装包 → **装到真机上能启动、TLS 握手不完成**,且本机 CI 全绿。
|
||||
# 故先断言「我到底在什么架构上编」,再往下走。
|
||||
- name: 断言构建机架构与目标一致
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$arch = node -p "process.arch"
|
||||
Write-Host "node process.arch = $arch"
|
||||
Write-Host "runner = ${{ matrix.runner }}"
|
||||
if ($arch -ne '${{ matrix.arch }}') {
|
||||
throw "架构不符:期望 ${{ matrix.arch }},实际 $arch —— 打进包的 node.exe 会是错的架构"
|
||||
}
|
||||
|
||||
- name: 构建 workspace 包(含被控端)
|
||||
# agent 的 dist 是 prepare-console 打包被控端的前置产物,顺序不能调
|
||||
run: pnpm --filter "./packages/*" --filter "@nodeagent/agent" build
|
||||
|
||||
# prepare-console 的前置检查要求 sidecar.mjs 已存在;它自己也会调 esbuild 打 agent.mjs
|
||||
- name: 构建 sidecar 并准备随包资源(node + agent.mjs)
|
||||
working-directory: apps/console
|
||||
run: |
|
||||
pnpm sidecar:build
|
||||
pnpm prepare:node
|
||||
|
||||
# ⚠️ 交付物级断言(与 pack.mjs 的双架构校验同源思路):
|
||||
# 不只看「脚本跑完了」,而是直接读最终要打进包的那个 node.exe 的 PE 头。
|
||||
# 中间逻辑再自洽,也可能因为上游给了错架构的二进制而产出错包。
|
||||
- name: 断言随包 node.exe 的 PE 架构
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$node = 'apps/console/src-tauri/runtime/node.exe'
|
||||
if (-not (Test-Path $node)) { throw "缺少随包 Node 运行时:$node" }
|
||||
$bytes = [IO.File]::ReadAllBytes((Resolve-Path $node))
|
||||
$peOff = [BitConverter]::ToUInt32($bytes, 0x3C)
|
||||
$machine = [BitConverter]::ToUInt16($bytes, $peOff + 4)
|
||||
$actual = '0x{0:x4}' -f $machine
|
||||
Write-Host "node.exe PE Machine = $actual(体积 $([math]::Round($bytes.Length/1MB,1)) MB)"
|
||||
if ($actual -ne '${{ matrix.pe }}') {
|
||||
throw "随包 node.exe 架构错误:期望 ${{ matrix.pe }},实际 $actual —— 严禁发布该包"
|
||||
}
|
||||
|
||||
- name: 打包安装包(${{ matrix.bundles }})
|
||||
working-directory: apps/console
|
||||
# beforeBuildCommand(vite build)由 tauri 自己触发;资源靠 --config 注入,
|
||||
# 默认 conf 里绝不出现 runtime 路径(bundle.resources 是编译期契约,声明了不存在的文件 build.rs 会直接失败)
|
||||
run: pnpm tauri build --config src-tauri/tauri.bundle.gen.json --bundles ${{ matrix.bundles }}
|
||||
|
||||
- name: 验证产物自举
|
||||
# 真 spawn 产物内的 node 去跑产物内的 sidecar,走一次 JSON-RPC
|
||||
# (这在 macOS 上抓出过 strip 破坏签名导致 SIGKILL 的问题,Windows 侧同样值得钉住)
|
||||
run: node apps/console/scripts/verify-bundle.mjs --runtime
|
||||
|
||||
# ⚠️ 断言的是**最终交付物**,不是中间产物。
|
||||
#
|
||||
# 这一版是本步的第二次实现。首版写成「安装包体积 > 50MB」,理由是
|
||||
# 「含 Node 运行时(约 80MB),故不可能是个小文件」—— 这个推理把
|
||||
# **未压缩的目录形态**(.app ≈ 92MB)当成了**压缩安装包的体积**。
|
||||
# 2026-10-11 首个 run 两个 job 双双死在这一行:实测 msi 34.2MB、
|
||||
# nsis 19.5~23.2MB(LZMA 下 node.exe 自身就能压到 1/3~1/4),阈值必然误杀。
|
||||
#
|
||||
# 更根本的问题是:**体积阈值属于「中间逻辑自洽」式断言** —— 包大
|
||||
# 不等于 node.exe 真在里面,包小也不等于没在里面(压缩率会随版本漂)。
|
||||
# 故改为**解开包、查内容**,一次钉死三件事:
|
||||
# ① 三个随包资源(node.exe / sidecar.mjs / agent.mjs)必须都在 ——
|
||||
# 缺任一,装到真机上就是「应用能开、但托不起被控端」;
|
||||
# ② node.exe 的 PE Machine 必须等于目标架构 —— 最终产物的架构断言,
|
||||
# 直接对治「arm64 包里装的是 x64 node.exe」那类只在真机暴露的事故;
|
||||
# ③ node.exe 解压后体积必须 > 40MB —— 挡住「塞了个占位文件」。
|
||||
# 附带收益:真装一遍本身就是安装器冒烟(比只读目录更强)。
|
||||
- name: 断言安装包内容(随包 node.exe 齐全且架构正确)
|
||||
shell: pwsh
|
||||
# 静默安装理论上会自行结束;给个上限,免得万一卡住把 job 拖满 60 分钟
|
||||
timeout-minutes: 8
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$expect = '${{ matrix.pe }}'
|
||||
$bundle = 'apps/console/src-tauri/target/release/bundle'
|
||||
|
||||
function Get-PeMachine([string]$path) {
|
||||
$fs = [System.IO.File]::OpenRead($path)
|
||||
try {
|
||||
$br = [System.IO.BinaryReader]::new($fs)
|
||||
$fs.Position = 0x3C
|
||||
$peOff = $br.ReadInt32()
|
||||
$fs.Position = $peOff
|
||||
if ($br.ReadUInt32() -ne 0x00004550) { throw "不是 PE 文件:$path" }
|
||||
return '0x{0:x4}' -f $br.ReadUInt16()
|
||||
} finally { $fs.Dispose() }
|
||||
}
|
||||
|
||||
# 在候选目录里找出真正装了 node.exe 的那个(安装位置随安装形态而异)
|
||||
function Find-PayloadRoot([string[]]$candidates) {
|
||||
foreach ($c in $candidates) {
|
||||
if (-not (Test-Path $c)) { continue }
|
||||
$hit = Get-ChildItem -Path $c -Recurse -Filter 'node.exe' -ErrorAction SilentlyContinue |
|
||||
Select-Object -First 1
|
||||
if ($hit) { return $c }
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
# 安装器可能「自己先退出、再由子进程继续装」,故给它一段收敛时间
|
||||
function Wait-PayloadRoot([string[]]$candidates, [int]$seconds = 30) {
|
||||
$deadline = (Get-Date).AddSeconds($seconds)
|
||||
do {
|
||||
$r = Find-PayloadRoot $candidates
|
||||
if ($r) { return $r }
|
||||
Start-Sleep -Seconds 1
|
||||
} while ((Get-Date) -lt $deadline)
|
||||
return $null
|
||||
}
|
||||
|
||||
# 解开后的目录里必须三样齐全,且 node.exe 架构要对
|
||||
function Assert-Payload([string]$root, [string]$label) {
|
||||
$node = Get-ChildItem -Path $root -Recurse -Filter 'node.exe' -ErrorAction SilentlyContinue |
|
||||
Select-Object -First 1
|
||||
$side = Get-ChildItem -Path $root -Recurse -Filter 'sidecar.mjs' -ErrorAction SilentlyContinue |
|
||||
Select-Object -First 1
|
||||
$agent = Get-ChildItem -Path $root -Recurse -Filter 'agent.mjs' -ErrorAction SilentlyContinue |
|
||||
Select-Object -First 1
|
||||
$listing = (Get-ChildItem -Path $root -Recurse -File -ErrorAction SilentlyContinue |
|
||||
Select-Object -First 40 -ExpandProperty FullName) -join "`n"
|
||||
if (-not $side) { throw "$label 内未找到 sidecar.mjs`n实际内容:`n$listing" }
|
||||
if (-not $agent) {
|
||||
throw "$label 内未找到 agent.mjs —— 被控端未随包,装上后启动不了本机被控端`n实际内容:`n$listing"
|
||||
}
|
||||
if (-not $node) {
|
||||
throw "$label 内未找到 node.exe —— 随包 Node 运行时未打入,装上后一启动就找不到 node`n实际内容:`n$listing"
|
||||
}
|
||||
$mb = [math]::Round($node.Length / 1MB, 1)
|
||||
$machine = Get-PeMachine $node.FullName
|
||||
Write-Host " node.exe $mb MB PE Machine=$machine"
|
||||
Write-Host " sidecar.mjs $([math]::Round($side.Length / 1KB)) KB"
|
||||
Write-Host " agent.mjs $([math]::Round($agent.Length / 1KB)) KB"
|
||||
if ($mb -lt 40) { throw "$label 内 node.exe 仅 $mb MB —— 体积异常,疑似占位文件" }
|
||||
if ($machine -ne $expect) {
|
||||
throw "$label 内 node.exe 架构错误:期望 $expect,实际 $machine —— 严禁发布该包"
|
||||
}
|
||||
}
|
||||
|
||||
$msis = @(Get-ChildItem -Path "$bundle/msi/*.msi" -ErrorAction SilentlyContinue)
|
||||
$nsis = @(Get-ChildItem -Path "$bundle/nsis/*.exe" -ErrorAction SilentlyContinue)
|
||||
if (($msis.Count + $nsis.Count) -eq 0) { throw "未产出任何安装包($bundle 下为空)" }
|
||||
|
||||
$checked = 0
|
||||
|
||||
foreach ($m in $msis) {
|
||||
Write-Host "── MSI:$($m.Name) $([math]::Round($m.Length / 1MB, 1)) MB"
|
||||
# 管理安装(/a)= 只解包不注册,正合此处用途
|
||||
$dest = Join-Path $PWD 'pkg-extract/msi'
|
||||
Remove-Item -Recurse -Force $dest -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Force -Path $dest | Out-Null
|
||||
$log = Join-Path $PWD 'msi-extract.log'
|
||||
# ⚠️ 必须用 Start-Process -Wait -PassThru 取退出码,**不能**用 `& msiexec.exe`:
|
||||
# msiexec 是 GUI 子系统程序,脚本里 `&` 调它**既不等待、也不设 `$LASTEXITCODE`**
|
||||
# (实测:整个步骤 0.8 秒就跑到下一行,`$LASTEXITCODE` 为空 → `$null -ne 0` 判真 → 当场抛错)。
|
||||
# 参数拼成**单个字符串**传递,避免 -ArgumentList 数组被 PowerShell 自动加引号而破坏 /D= 语义。
|
||||
$argLine = "/a `"$($m.FullName)`" /qn TARGETDIR=`"$dest`" /l*v `"$log`""
|
||||
$p = Start-Process -FilePath 'msiexec.exe' -ArgumentList $argLine -Wait -PassThru
|
||||
Write-Host " msiexec 退出码 = $($p.ExitCode)"
|
||||
# 3010 = 成功但需重启,不是失败
|
||||
if ($p.ExitCode -ne 0 -and $p.ExitCode -ne 3010) {
|
||||
$tail = (Get-Content $log -Tail 30 -ErrorAction SilentlyContinue) -join "`n"
|
||||
throw "msiexec /a 失败(退出码 $($p.ExitCode)):$($m.Name)`n$tail"
|
||||
}
|
||||
$root = Wait-PayloadRoot @($dest)
|
||||
if (-not $root) { throw "MSI 管理安装后未在 $dest 下找到 node.exe" }
|
||||
Assert-Payload $root $m.Name
|
||||
$checked++
|
||||
}
|
||||
|
||||
foreach ($n in $nsis) {
|
||||
Write-Host "── NSIS:$($n.Name) $([math]::Round($n.Length / 1MB, 1)) MB"
|
||||
# /S 静默、/D 指定目录:两者都由 NSIS **运行时自身**在 .onInit 之前解析
|
||||
# (核心参数,不依赖 Tauri 模板),故对任何 NSIS 安装包都成立。
|
||||
# /D= 必须是最后一个参数且**不加引号**,故这里用仓库目录下的相对路径
|
||||
# (不含空格),而不是通常带空格的用户目录。
|
||||
$dest = Join-Path $PWD 'pkg-extract/nsis'
|
||||
Remove-Item -Recurse -Force $dest -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Force -Path $dest | Out-Null
|
||||
# 同 MSI:安装器也是 GUI 子系统程序,必须 Start-Process -Wait 才拿得到退出码。
|
||||
# 单个字符串传参 → `/D=` 保持原样不加引号(NSIS 的硬要求)。
|
||||
$p = Start-Process -FilePath $n.FullName -ArgumentList "/S /D=$dest" -Wait -PassThru
|
||||
Write-Host " 安装器退出码 = $($p.ExitCode)"
|
||||
if ($p.ExitCode -ne 0 -and $p.ExitCode -ne 3010) {
|
||||
throw "NSIS 静默安装失败(退出码 $($p.ExitCode)):$($n.Name)"
|
||||
}
|
||||
# /D= 理论上一律生效,但若模板在 .onInit 里改写了 $INSTDIR 就未必;
|
||||
# 故把默认安装位置一并列为候选,避免「装成功了却因为找不到目录而误报」。
|
||||
$root = Wait-PayloadRoot @(
|
||||
$dest,
|
||||
(Join-Path $env:LOCALAPPDATA 'nodeagent-console'),
|
||||
(Join-Path $env:LOCALAPPDATA 'Programs/nodeagent-console'),
|
||||
(Join-Path $env:ProgramFiles 'nodeagent-console')
|
||||
)
|
||||
if (-not $root) {
|
||||
throw "NSIS 静默安装后未找到随包 node.exe —— 已查:$dest 与默认安装位置"
|
||||
}
|
||||
Write-Host " 安装位置:$root"
|
||||
Assert-Payload $root $n.Name
|
||||
$checked++
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "✓ 已核对 $checked 个安装包:node.exe / sidecar.mjs / agent.mjs 齐全,node.exe 架构 = $expect"
|
||||
|
||||
- name: 上传安装包
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: nodeagent-console-windows-${{ matrix.arch }}
|
||||
path: |
|
||||
apps/console/src-tauri/target/release/bundle/msi/*.msi
|
||||
apps/console/src-tauri/target/release/bundle/nsis/*.exe
|
||||
# 一条架构没产出就报错,避免「jobs 全绿但产物不全」
|
||||
if-no-files-found: error
|
||||
# 交付流水线 §4:CI artifact 公开可下载,留存越久暴露面越大,30 → 7
|
||||
retention-days: 7
|
||||
|
||||
# 发布:tag 推送时把两个架构的安装包补挂到 release.mjs 建好的 Release 上。
|
||||
#
|
||||
# 为什么必须有这一步(2026-10-17 真机教训)
|
||||
# --------------------------------------
|
||||
# `scripts/release.mjs` 只产出 agent / cli / mcp,控制台的 NSIS/MSI
|
||||
# **从未进入过 Release** —— 后果是:用户从 Release 页面下载到的永远是旧控制台,
|
||||
# 而当天修的 6 个真机缺陷只存在于 CI artifact(retention 7 天,到期自动删除)。
|
||||
# 一句话:**修复发出去了,但没人拿得到。**
|
||||
publish:
|
||||
name: 补挂安装包到 Release
|
||||
# 只有 tag 才发布;分支构建(日常验证)一律跳过
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: 下载两个架构的安装包
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
pattern: nodeagent-console-windows-*
|
||||
|
||||
- name: 列出待发布文件(先看清再上传)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "--- dist 实际内容 ---"
|
||||
find dist -type f | sort | head -40
|
||||
echo "--- 安装包 ---"
|
||||
find dist -type f \( -name '*.exe' -o -name '*.msi' \) -printf '%10s %p\n' | sort -k2
|
||||
|
||||
# 期望值必须与上面的 build 矩阵一致:
|
||||
# x64 → msi + nsis(2 个)
|
||||
# arm64 → 仅 nsis(1 个,WiX 的 arm64 MSI 支持面窄,刻意不出)
|
||||
# 合计 3 个。
|
||||
#
|
||||
# ⚠️ 这里曾写死「两架构各一套(exe+msi)= 4 个」,与矩阵漂移后**每次必红** ——
|
||||
# 红门禁等于没有门禁(本项目反复踩的同一类坑:断言与实现各改各的)。
|
||||
# 故改为**按架构**断言,并在缺失时报出具体是哪个架构,便于一眼定位。
|
||||
fail=0
|
||||
for spec in "x64:2" "arm64:1"; do
|
||||
arch=${spec%%:*}; want=${spec##*:}
|
||||
dir="dist/nodeagent-console-windows-$arch"
|
||||
# ⚠️ 变量引用一律用 ${var} 形式:`$dir)` 这种「$var + 全角括号」在
|
||||
# set -u 下会被 bash 当成变量名的一部分 → "unbound variable",
|
||||
# 而写法在本地肉眼完全正常(本地实测复现过)。
|
||||
if [ ! -d "${dir}" ]; then
|
||||
echo "::error::缺少 ${arch} 的产物目录(${dir})—— 该架构构建失败或 artifact 名称变更"
|
||||
fail=1
|
||||
continue
|
||||
fi
|
||||
got=$(find "${dir}" -type f \( -name '*.exe' -o -name '*.msi' \) | wc -l)
|
||||
echo " ${arch}: ${got} / ${want}"
|
||||
if [ "${got}" -lt "${want}" ]; then
|
||||
echo "::error::${arch} 安装包不足(${got} < ${want})—— 产物缺失,或矩阵已变更但本断言未同步"
|
||||
fail=1
|
||||
fi
|
||||
done
|
||||
[ "${fail}" -eq 0 ] || exit 1
|
||||
echo "✓ 两架构安装包齐全"
|
||||
|
||||
- name: 挂到对应 Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# release.mjs 在本地跑、与本工作流并行,故先等 Release 出现(最多 10 分钟)
|
||||
for i in $(seq 1 60); do
|
||||
if gh release view "$TAG" >/dev/null 2>&1; then echo "✓ Release $TAG 已存在"; break; fi
|
||||
echo "等待 Release $TAG 创建… ($i/60)"; sleep 10
|
||||
done
|
||||
if ! gh release view "$TAG" >/dev/null 2>&1; then
|
||||
echo "::warning::Release $TAG 尚未创建,安装包仅留在 CI artifact。请先跑 scripts/release.mjs"
|
||||
exit 0
|
||||
fi
|
||||
find dist -type f \( -name '*.exe' -o -name '*.msi' \) -print0 \
|
||||
| xargs -0 gh release upload "$TAG" --clobber
|
||||
echo "✓ 已挂上的资产:"
|
||||
gh release view "$TAG" --json assets -q '.assets[].name'
|
||||
@@ -0,0 +1,129 @@
|
||||
# 交付流水线左移门禁(见 docs/DELIVERY-PIPELINE.md §1 §2 §8)
|
||||
#
|
||||
# 本文件只做「快且能阻断」的检查:密钥、依赖漏洞、类型、覆盖率、提交规范。
|
||||
# 真机链路(install.cmd / control.cmd 双击、Windows 能力)留在 windows-e2e.yml
|
||||
# —— 那一环跑得慢但价值最高,不可被这条更快的流水线替代。
|
||||
#
|
||||
# ⚠️ 阈值说明(别照搬通用 80%):详见 docs/DELIVERY-PIPELINE.md §2。
|
||||
# 全量覆盖率当前基线约 66.9%,故全量只观测;阻断只针对**变更文件**(≥70%)。
|
||||
|
||||
name: Quality Gate
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# ① 代码审计:密钥 + 依赖漏洞
|
||||
audit:
|
||||
name: 代码审计(密钥 + 依赖)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
# gitleaks 扫历史提交,需要完整历史
|
||||
fetch-depth: 0
|
||||
|
||||
# 密钥扫描:nodeagent 的密钥面比一般项目大 ——
|
||||
# PSK、Ed25519 私钥、TLS 自签证书、PSK.txt、agent.json
|
||||
- name: 密钥与敏感信息扫描
|
||||
uses: gitleaks/gitleaks-action@v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
# 与本仓库其他工作流保持一致(console-windows.yml / rust.yml / windows-e2e.yml 均为 12)
|
||||
version: 12
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
# 依赖漏洞:High 及以上阻断
|
||||
- name: 依赖漏洞扫描(SCA)
|
||||
# ⚠️ 必须显式指定公共 registry(2026-10-17)
|
||||
# 仓库 `.npmrc` 指向 registry.npmmirror.com(镜像加速),但**镜像不实现
|
||||
# audit 接口**,`pnpm audit` 会直接报 ERR_PNPM_AUDIT_ENDPOINT_NOT_EXISTS。
|
||||
# 结果就是这道门禁从加上那天起**一直是红的**,等于没有门禁。
|
||||
run: pnpm audit --audit-level=high --registry=https://registry.npmjs.org
|
||||
|
||||
# ② 质量门禁:类型 + 覆盖率
|
||||
gate:
|
||||
name: 质量门禁(类型 + 覆盖率)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
# 覆盖率门禁要算「变更文件」,需与基线做 diff
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
# 与本仓库其他工作流保持一致(console-windows.yml / rust.yml / windows-e2e.yml 均为 12)
|
||||
version: 12
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
# ⚠️ 必须先全量构建,再查类型(2026-10-17)
|
||||
#
|
||||
# 各包 import 的是**同仓其它包的编译产物**(dist/*.d.ts),依赖链是
|
||||
# `protocol → client → cli / mcp / agent`。CI 全新检出没有 dist,
|
||||
# 直接 typecheck 会逐层报 `TS2307: Cannot find module '@nodeagent/xxx'`;
|
||||
# 而本地因为 dist 还在,完全看不出来 —— 典型「本地绿 / CI 红」。
|
||||
#
|
||||
# 一开始只想「只构建 protocol 就够」,实测不够:cli 又报找不到 `@nodeagent/client`。
|
||||
# 与其逐个补依赖(下次加包还会漏),不如直接用 `-r build` 把整条链建出来。
|
||||
# 顺带:单测 import 的也是 dist/,这一步同时保证了覆盖率不为空(原步骤在 typecheck 之后,
|
||||
# 顺序本就矛盾 —— 既然后面也要 build,不如提前一次到位)。
|
||||
- name: 构建 workspace(typecheck 与单测都依赖 dist)
|
||||
run: pnpm -r build
|
||||
|
||||
- run: pnpm typecheck
|
||||
|
||||
- name: 采集覆盖率
|
||||
run: |
|
||||
mkdir -p coverage
|
||||
node --test --experimental-test-coverage \
|
||||
--test-reporter=lcov \
|
||||
--test-reporter-destination=coverage/lcov.info \
|
||||
"tests/unit/**/*.test.mjs"
|
||||
|
||||
- name: 覆盖率门禁
|
||||
run: node scripts/ci/check-coverage.mjs --lcov coverage/lcov.info --base origin/main --threshold 70
|
||||
|
||||
# coverage/ 已在 .gitignore 中,此处留档便于人工查看
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: coverage
|
||||
path: coverage/
|
||||
retention-days: 7
|
||||
|
||||
# ③ 提交信息规范
|
||||
commits:
|
||||
name: 提交信息规范
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: 校验 Conventional Commits
|
||||
run: node scripts/ci/check-commit-msg.mjs --base ${{ github.event.pull_request.base.sha }}
|
||||
@@ -0,0 +1,120 @@
|
||||
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'
|
||||
- 'scripts/lib/bundle-agent.mjs'
|
||||
- 'apps/agent/**'
|
||||
- '.github/workflows/rust.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'apps/console/src-tauri/**'
|
||||
- 'apps/console/sidecar/**'
|
||||
- 'scripts/prepare-console.mjs'
|
||||
- 'scripts/lib/bundle-agent.mjs'
|
||||
- 'apps/agent/**'
|
||||
- '.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
|
||||
|
||||
# ⚠️ 顺序有依赖:prepare:node 现在**还要 esbuild 打包被控端**(控制台随包分发
|
||||
# agent.mjs,用于托起本机被控端)。故 @nodeagent/agent 的 dist 必须先构建出来,
|
||||
# 否则 prepare 阶段会以「未找到被控端构建产物」失败。
|
||||
# (本项目已因「新步骤漏排前置产物」在 Windows CI 上红过一次,此处显式写全。)
|
||||
- name: 构建 workspace 包(sidecar 与 agent 的 dist 都是前置产物)
|
||||
run: pnpm --filter "./packages/*" --filter "@nodeagent/agent" build
|
||||
|
||||
# ⚠️ tauri-build 在**编译期**校验 tauri.conf.json 里 resources 的文件存在性,
|
||||
# 故必须先产出 sidecar.mjs,否则 clippy/test 会在 build.rs 阶段就失败。
|
||||
- name: 构建 sidecar(resources 引用的产物)
|
||||
run: pnpm --filter nodeagent-console sidecar:build
|
||||
|
||||
# 随包分发的 Node 运行时 + 被控端 agent.mjs 由该脚本准备;它同时产出
|
||||
# 「构建期注入片段」。在此真跑一遍:既验证脚本本身可用(含两份产物自检:
|
||||
# node 必须真能执行、agent.mjs 必须含被控端标识),也验证注入片段声明就绪。
|
||||
# 注意 Linux 上不会走 strip 分支(那是 macOS 专属,见脚本内注释),
|
||||
# 故「strip 后必须 adhoc 重签」这条由 macOS 本机实测覆盖,CI 不重复验证。
|
||||
- name: 准备 Node 运行时 + 被控端(验证 prepare-console.mjs)
|
||||
run: pnpm --filter nodeagent-console prepare:node
|
||||
|
||||
- name: 注入片段应声明三份资源(node + sidecar + agent)
|
||||
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 !== 3) { console.error("资源项应为 3,实际", keys); process.exit(1); }
|
||||
const vals = Object.values(r);
|
||||
for (const need of ["sidecar.mjs", "agent.mjs"]) {
|
||||
if (!vals.includes(need)) { console.error("缺少资源", need, vals); process.exit(1); }
|
||||
}
|
||||
if (!vals.includes("node") && !vals.includes("node.exe")) {
|
||||
console.error("缺少 node 运行时", vals); process.exit(1);
|
||||
}
|
||||
console.log("✓", JSON.stringify(r));
|
||||
'
|
||||
|
||||
# 更强的一道:不是「文件在不在」,而是「产物能不能自举」——
|
||||
# 真 spawn 打包内的 node 跑 sidecar 并走一次 JSON-RPC 握手,
|
||||
# 并用打包内的 node 解析一次 agent.mjs(截断/损坏会在此暴露)。
|
||||
# 这类问题只在产物中暴露(源码与单测都正常)。
|
||||
- name: 验证运行时 + sidecar + agent 可自举
|
||||
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,129 @@ jobs:
|
||||
- name: 类型检查
|
||||
run: pnpm typecheck
|
||||
|
||||
# 控制台「随包分发的 Node 运行时 + 被控端 agent.mjs」在 Windows 上的分支
|
||||
# 此前零覆盖(准备脚本本机只在 macOS 上跑过)。此处真跑:复制 node.exe(无 strip
|
||||
# 分支)→ 自检可执行 → 再验证它与 sidecar 能否走通一次 JSON-RPC 自举,
|
||||
# 并用同一个 node 解析 agent.mjs。
|
||||
# ⚠️ 必须先 sidecar:build —— prepare-console.mjs 的前置检查要求 sidecar.mjs 存在
|
||||
# (首次推送时漏了这步,CI 在 Windows 上当场报「未找到 sidecar/dist/sidecar.mjs」)。
|
||||
# agent.mjs 的前置是 `pnpm -r build`(上面的「构建」步骤已覆盖)。
|
||||
- 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"
|
||||
# 真机教训(虚拟化平台 + 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 +206,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,21 @@ 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/
|
||||
|
||||
# 被控端 / 控制端本机运行配置(含真实密钥与节点地址,绝不入库)
|
||||
config.json
|
||||
agent.json
|
||||
@@ -0,0 +1,25 @@
|
||||
# gitleaks 误报台账(按指纹精确忽略,不影响任何规则)
|
||||
#
|
||||
# ## 为什么用 .gitleaksignore 而不是 .gitleaks.toml 的 allowlist
|
||||
# - `.gitleaks.toml` 的顶层 `[allowlist].paths = ['tests/**']` 会对**所有规则**放行,
|
||||
# 于是 `private-key`(私钥块)、`aws-access-token` 等在测试目录下也失效 ——
|
||||
# 而「为了造样本把真实私钥贴进 fixture」恰恰是最常见的泄露方式。
|
||||
# - `[[rules]]` 重定义 `generic-api-key` 会**替换**内置规则(gitleaks 要求重写 regex),
|
||||
# 等于自己维护一份正则,反而更容易漏。
|
||||
# - 指纹是 `<commit>:<file>:<rule>:<line>`,**只忽略这一条**,其余一概照旧。
|
||||
#
|
||||
# ## 逐条核对记录(2026-10-17)
|
||||
# 4 条命中全部位于测试文件,值均为明显的递增占位(含 `0123456789abcdef` 模式):
|
||||
# sec-e2e-key-0123456789abcdef0123456789
|
||||
# test-key-0123456789abcdef
|
||||
# fs-key-0123456789abcdef0123456789abcdef
|
||||
# e2e-test-key-0123456789abcdef0123456789abcdef
|
||||
# 已逐一核对:**均不等于被控端真实 PSK,均不含 64 位十六进制串** → 判定为误报。
|
||||
#
|
||||
# ## 若日后有人在此文件里追加指纹
|
||||
# 必须先做同样的核对:确认该值不是真实凭据。**这个文件不是「让 CI 变绿」的开关。**
|
||||
#
|
||||
0d59d6032b96c42f9eda380c4b6c2e95d253f856:tests/e2e/run.mjs:generic-api-key:906
|
||||
f4c605cc6d26e85c81b50e10da3a466f755d8221:tests/unit/hmac.test.mjs:generic-api-key:6
|
||||
4ff1b4fd9021755d7ed060da5baadc5618dcbc3d:tests/e2e/run.mjs:generic-api-key:30
|
||||
77c024703abf5ac07e63f73c551f623539ff0856:tests/e2e/run.mjs:generic-api-key:21
|
||||
@@ -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}`,不能只记哈希 ——
|
||||
日志轮转会丢弃最旧段使条目数下降,只比哈希会把**轮转误判成篡改**。
|
||||
锚点文件**只追加**(可覆盖的话,攻击者重写链后再刷新锚点即可抹痕)。
|
||||
|
||||
> 真正发挥价值的前提:把锚点文件指向**另一台机器 / 网盘同步目录**。
|
||||
> 本机文件被整盘重写时,锚点也随之消失 —— 那就等于没锚。
|
||||
|
||||
**52 项能力** · **42 个 MCP 工具** · **483 项单元测试** · **50 项端到端用例**(34 通用 + 16 Windows 专属) · **23 项 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)**
|
||||
@@ -51,7 +195,7 @@ pnpm build
|
||||
1. 在控制端构建安装包(**需在项目目录执行**):
|
||||
|
||||
```bash
|
||||
cd <项目目录> # 例如 ~/WorkBuddy/NodeAgent/nodeagent
|
||||
cd <项目目录> # 例如 ~/projects/nodeagent
|
||||
pnpm pack:win
|
||||
|
||||
# 或从任意目录调用:
|
||||
@@ -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,64 @@ 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 依赖),**不引入第二套协议实现**。
|
||||
|
||||
**打开即用、全程不碰命令行**:应用自带 Node 运行时与被控端脚本,
|
||||
默认页面就是「本机服务」——点按钮即可启动 / 停止 / 重启本机被控端,
|
||||
并支持检查更新、查看日志、后台常驻(托盘)、开机自启。
|
||||
|
||||
**界面**:导航为**纯文字**(不使用图标);配色为两套中性灰阶主题 ——
|
||||
「简约白」与「高级灰」,侧栏底部一键切换。
|
||||
|
||||
| 页面 | 能力 |
|
||||
|---|---|
|
||||
| **本机服务**(默认) | 本机被控端启停/重启、连接本机、节点 ID 与密钥、开机自启、后台运行、异常自动重启、检查并应用更新 |
|
||||
| **服务日志** | 被控端 stdout/stderr 实时推送、关键字过滤、暂停接收、导出、清空 |
|
||||
| 设备管理 | 连接/断开、保存多台设备、能力清单与握手元信息、实时日志 |
|
||||
| 状态总览 | 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 窗口)
|
||||
pnpm --filter nodeagent-console bundle # 打包(含 sidecar + Node 运行时 + 被控端脚本)
|
||||
# macOS 实测:.app 92.2 MiB / .dmg 33.78 MiB(分发件,其中 Node 运行时 86MB)
|
||||
```
|
||||
|
||||
Windows 版由 CI 产出(`.github/workflows/console-windows.yml`),**双架构各一套**:
|
||||
|
||||
| 架构 | runner | 安装包 | 实测体积 |
|
||||
|---|---|---|---|
|
||||
| x64 | `windows-latest` | `.msi`(企业批量部署)+ `-setup.exe`(NSIS,双击安装) | 34.2 MiB / 23.2 MiB |
|
||||
| arm64 | `windows-11-arm` | `-setup.exe`(NSIS) | 19.5 MiB |
|
||||
|
||||
> 为什么必须两套:控制台内含 Node 运行时,且要用它**托起本机被控端**(即自己就是 TLS 服务端)。
|
||||
> 若在 ARM Windows 上装到 x64 的运行时,会**能启动、能监听,但 TLS 握手不完成** ——
|
||||
> 客户端只见超时,且开发机上 100% 复现不出。CI 里为此加了三道断言:
|
||||
> 「构建机架构 ⇄ 目标架构」、「随包 `node.exe` 的 PE Machine」、
|
||||
> 以及**解开安装包查内容**(三样随包资源齐全 + 包内 `node.exe` 的 PE Machine 对得上 ——
|
||||
> 前两条查的是打包**前**的中间产物,这一条查的才是**最终交付物**;
|
||||
> 实测解出 x64 `node.exe` 82.9 MB / `0x8664`、arm64 73.5 MB / `0xaa64`)。
|
||||
|
||||
⚠️ 平台差异:`app.*` 与 `system.service.list` **仅在被控端为 Windows 时可用**,
|
||||
非 Windows 被控端会返回 `E_UNSUPPORTED_PLATFORM`。控制台连接后会探测
|
||||
`system.info.os` 并**事前禁用**这些入口,而不是发出注定失败的请求。
|
||||
|
||||
⚠️ 控制台**已自带** Node 运行时与被控端脚本,目标机器无需预装 Node.js,也无需先手工安装被控端;
|
||||
但**开机自启与被控端的图形能力**要求运行在**登录用户会话**中(Windows 计划任务用
|
||||
`Interactive` + 登录用户;SYSTEM 会话 0 没有交互桌面,截图/窗口类能力会失败)。
|
||||
|
||||
## 安全模型
|
||||
|
||||
三层防护,逐层收窄权限:
|
||||
@@ -244,6 +471,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 配置片段(私钥入系统钥匙串)
|
||||
```
|
||||
|
||||
@@ -252,10 +480,28 @@ nodeagent keygen [--id mac_01] # 生成 Ed25519 密钥 + AC
|
||||
```bash
|
||||
nodeagent restart [--delay 2000] # 受控重启被控端(配置变更后让它生效)
|
||||
nodeagent deploy <agent.mjs> [--path <远端路径>] # 一键升级(备份 → 上传 → 重启 → 复验)
|
||||
nodeagent rollback [--dry-run] # 从 LKG 回滚(指纹比对 → 覆盖 → 重启 → 复核)
|
||||
nodeagent daemon [start|stop|status] # 常驻连接池(批量操作省去每次握手)
|
||||
nodeagent fanout <能力名> [--nodes a,b] [--args JSON] # 多设备并发下发并汇总
|
||||
```
|
||||
|
||||
> **回滚的能力圈边界**:`rollback` 是 RPC 命令,**需被控端可达**——救的是「新版本能启动但需回退」。
|
||||
> 新版本「完全起不来」(无监听可连)时:被控端看门狗会在启动连续失败 N 次后自动从 LKG 恢复
|
||||
> (`scripts/startup-guard.mjs`);仍不行再走带外恢复(`docs/DELIVERY-PIPELINE.md` 附录 C)。
|
||||
> 自动回滚刻意不做,理由见该文 §6。
|
||||
|
||||
**看门狗(被控端自启链上的一层)**:被控端由外层 supervisor 看守,启动连续失败 N 次(默认 3)自动从 LKG 恢复入口文件:
|
||||
|
||||
| 平台 | supervisor | 接入方式 |
|
||||
|---|---|---|
|
||||
| Windows | 计划任务 | `scripts/install.ps1` 把启动动作包一层 `scripts/startup-guard.mjs`(`RestartCount 10`) |
|
||||
| Linux | systemd | `scripts/install-linux.sh` 的 unit `ExecStart` 包一层 |
|
||||
| macOS | launchd | 无安装器,手工接法见 `docs/DEVELOPMENT.md` §7.4 |
|
||||
|
||||
安全边界:**无 LKG / 无指纹匹配的备份时只记日志放行,绝不擦除安装目录**;恢复目标恒为 LKG
|
||||
(单一、已验证,不做「择优回滚」)。看门狗自身「裸环境可跑」——零项目依赖,只依赖
|
||||
Node ≥22 内置模块 + 同目录纯函数核心,随安装包/便携包分发。
|
||||
|
||||
### 长任务与剪贴板
|
||||
|
||||
```bash
|
||||
@@ -265,7 +511,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":"记事本"}'
|
||||
@@ -287,7 +553,10 @@ nodeagent mouse drag 300 200 700 500 # 拖拽(拖文件/框选
|
||||
| `na_clip` | 读写剪贴板文本或图片 |
|
||||
| `na_fs_list` · `na_fs_read` · `na_fs_write` · `na_fs_stat` | 读目录 / 读文件 / 写文件 / 元信息 |
|
||||
| `na_restart` | 受控重启被控端 |
|
||||
| `na_audit` · `na_discover` · `na_nodes` · `na_use` | 查审计、发现设备、多设备切换 |
|
||||
| `na_audit` · `na_audit_l2` · `na_discover` · `na_nodes` · `na_use` | 查审计(`na_audit_l2` = 系统级 L2,仅 Windows 被控端)、发现设备、多设备切换 |
|
||||
| `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 +579,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 +610,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 +623,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 +649,60 @@ 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.2**(2026-10-11):51 项能力 · 274 单测 · Windows/Rust CI 全绿 —— 本版为**资产修复**(v2.0.1 的 `arm64` 包内实为 x64 node,详见 CHANGELOG) | ✅ 已发布 |
|
||||
|
||||
## 文档
|
||||
|
||||
- [产品需求文档(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/DEPLOY-LINUX.md`](docs/DEPLOY-LINUX.md) —— **Linux 被控端部署**(Debian/Ubuntu/NAS 系统;含实测能力清单与排查)
|
||||
- [`docs/VM-VERIFICATION.md`](docs/VM-VERIFICATION.md) —— **无 Windows 真机时,用宿主虚拟化平台的 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 +712,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.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
@@ -20,7 +20,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@nodeagent/protocol": "workspace:*",
|
||||
"selfsigned": "^2.4.1",
|
||||
"selfsigned": "^5.5.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -2,6 +2,13 @@ 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,
|
||||
AUDIT_TYPES,
|
||||
type AnchorComparison,
|
||||
type AnchorRecord,
|
||||
} from '@nodeagent/protocol';
|
||||
|
||||
export type AuditType =
|
||||
| 'agent.start'
|
||||
@@ -11,7 +18,51 @@ 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.1.0:**L2 系统级审计事件**(Windows Security 日志归一化副本)。
|
||||
* 真源注释见 `packages/protocol/src/audit-types.ts`——不复用 `agent.start`:
|
||||
* L2 策略操作与进程生命周期无关,混入会污染两边的统计与筛选。
|
||||
*/
|
||||
| 'agent.l2'
|
||||
/** v2.0.0:连接空闲被主动断开 */
|
||||
| 'session.idle_close'
|
||||
/**
|
||||
* v2.0.3:**被拒的请求**统一留痕。
|
||||
*
|
||||
* 为什么单独一个类型:原来「畸形报文 / 未知方法 / 未认证调用 / 参数非法 / 能力不存在」
|
||||
* 这类拒绝**完全不留痕**(U-01)—— 被扫描、被爆破、被误用时事后无从复盘。
|
||||
* 归到 `invoke` 会污染正常调用统计,归到 `acl.denied` 又不准确(很多拒绝与 ACL 无关)。
|
||||
*/
|
||||
| 'request.rejected'
|
||||
/**
|
||||
* v2.0.3:**进程级未捕获错误 / 未处理 Promise 拒绝**。
|
||||
*
|
||||
* 为什么单独一个类型:这类错误是「最后一次兜底拦截」(`index.ts` 的
|
||||
* `unhandledRejection` / `uncaughtException`)。既然选择了**不退出进程**,
|
||||
* 就必须留下证据 —— 归到 `request.rejected` 会让「被扫描」与「我们自己有 bug」
|
||||
* 混在同一类里,事后无法区分。
|
||||
*/
|
||||
| 'agent.error';
|
||||
|
||||
/**
|
||||
* 编译期守卫(v2.0.3,修 C-01):本文件这份联合类型必须与 `@nodeagent/protocol` 的
|
||||
* `AUDIT_TYPES` **双向一致**。
|
||||
*
|
||||
* 少了 → 「写进日志但协议不认」(控制台筛不到,用户以为没发生);
|
||||
* 多了 → 「协议认但从不写」(下拉框里是个永远为空的选项)。
|
||||
* 两种漂移都**不报错**,所以必须让 `tsc` 来说话。真正的真源在
|
||||
* `packages/protocol/src/audit-types.ts`,此处保留逐条注释只为记录「哪个版本引入的」。
|
||||
*/
|
||||
type AssertExact<A, B> = [A] extends [B] ? ([B] extends [A] ? true : never) : never;
|
||||
const _auditTypeInSyncWithProtocol: AssertExact<AuditType, (typeof AUDIT_TYPES)[number]> = true;
|
||||
void _auditTypeInSyncWithProtocol;
|
||||
|
||||
/** 一条审计记录(JSONL 单行)。 */
|
||||
export interface AuditEntry {
|
||||
@@ -27,6 +78,13 @@ export interface AuditEntry {
|
||||
error?: string | null;
|
||||
/** 参数摘要(sha256 前缀),用于比对而不泄露内容 */
|
||||
args_digest?: string;
|
||||
/**
|
||||
* v2.1.0(A-P0-4):参数序列化后的**总字节数**。
|
||||
* 与 `args_digest_truncated` 同时出现即表示「摘要只覆盖前 64KB」。
|
||||
*/
|
||||
args_bytes?: number;
|
||||
/** v2.1.0(A-P0-4):摘要是否为截断哈希(序列化超过 64KB 时只哈希前 64KB)。 */
|
||||
args_truncated?: boolean;
|
||||
/** 参数预览(默认不记录;开启后自动脱敏) */
|
||||
args_preview?: string;
|
||||
reason?: string;
|
||||
@@ -123,12 +181,34 @@ function sanitize(value: unknown, depth = 0): unknown {
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 参数摘要阈值(字节):超过即只哈希前 64KB。
|
||||
*
|
||||
* ⚠️ A-P0-4(2026-10-25):原实现每次 invoke 都全量 `JSON.stringify` + sha256。
|
||||
* `fs.write` 的 data 可达数 MB(协议单帧,ws 默认 maxPayload 隐式放行)——
|
||||
* 同步序列化 + 哈希一个 8MB 字符串 ≈ 数十毫秒级 CPU,且**阻塞事件循环**:
|
||||
* 期间 GUI 注入(`input.*`)、事件推送、保活 ping 全部延迟。
|
||||
* 截断后:摘要口径从「全量哈希」变为「前 64KB 哈希」,**只影响审计摘要,
|
||||
* 不影响任何安全属性**(摘要用途是比对与去泄密,从来不是完整性证明)。
|
||||
* 总长记入 `args_bytes`,截断记入 `args_truncated`,语义不断代。
|
||||
*/
|
||||
const ARGS_DIGEST_MAX_BYTES = 64 * 1024;
|
||||
|
||||
/** 生成参数摘要与(可选)脱敏预览 —— 字段名与 AuditEntry 对齐,便于展开。 */
|
||||
export function describeArgs(args: unknown): { args_digest: string; args_preview?: string } {
|
||||
export function describeArgs(args: unknown): {
|
||||
args_digest: string;
|
||||
args_bytes?: number;
|
||||
args_truncated?: boolean;
|
||||
args_preview?: string;
|
||||
} {
|
||||
const json = JSON.stringify(args ?? {});
|
||||
const digest = `sha256:${createHash('sha256').update(json).digest('hex').slice(0, 16)}`;
|
||||
if (!opts.logArgs) return { args_digest: digest };
|
||||
return { args_digest: digest, args_preview: JSON.stringify(sanitize(args)) };
|
||||
const bytes = Buffer.byteLength(json, 'utf8');
|
||||
// 截断按**字节**切(多字节字符下按字符切会得到非确定性的 64KB 边界)
|
||||
const payload = bytes > ARGS_DIGEST_MAX_BYTES ? Buffer.from(json, 'utf8').subarray(0, ARGS_DIGEST_MAX_BYTES) : json;
|
||||
const digest = `sha256:${createHash('sha256').update(payload).digest('hex').slice(0, 16)}`;
|
||||
const base = bytes > ARGS_DIGEST_MAX_BYTES ? { args_bytes: bytes, args_truncated: true } : {};
|
||||
if (!opts.logArgs) return { args_digest: digest, ...base };
|
||||
return { args_digest: digest, ...base, args_preview: JSON.stringify(sanitize(args)) };
|
||||
}
|
||||
|
||||
/** 写入一条审计记录。任何异常都被吞掉 —— 审计绝不阻断主流程。 */
|
||||
@@ -271,6 +351,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 +406,120 @@ export function readAudit(query: AuditQuery = {}): { entries: AuditEntry[]; tota
|
||||
collected.sort((a, b) => a.ts - b.ts);
|
||||
return { entries: collected.slice(-limit), total, file: p };
|
||||
}
|
||||
|
||||
|
||||
// ---------------- v2.0.0 审计链外部锚定 ----------------
|
||||
//
|
||||
// ## 为什么需要
|
||||
// 审计链的哈希使**链内**篡改可被发现(改一条就要重算其后全部)。但「有 root 的攻击者」
|
||||
// 可以**整链重写**:从零重建一份自洽的假日志,链校验照样通过。
|
||||
// 外部锚定就是打破这一点:定期把**链头哈希 + 条目数**记到链外的独立位置
|
||||
// (另一台机器 / 网盘 / U 盘)。事后比对时,若「当前链在某锚点之后的部分对不上」,
|
||||
// 说明这段被重写过 —— 攻击者无法回到过去改掉已经落到别处的锚点。
|
||||
//
|
||||
// ## 记什么(关键:不能只记哈希)
|
||||
// 轮转会丢弃最旧的段,所以条目数会**下降**。只记 head_hash 会导致轮转后被误判为篡改。
|
||||
// 故锚点记 {entries, head_hash, head_ts, rotated_segments} 四元组,
|
||||
// 比对时先按"轮转了几段"对齐,再判断尾部是否一致。
|
||||
|
||||
export interface AuditHead {
|
||||
/** 当前链可见条目数(含轮转段) */
|
||||
entries: number;
|
||||
/** 链头哈希(末条的 hash)—— 这是要外部留存的核心值 */
|
||||
head_hash: string | null;
|
||||
/** 末条时间戳 */
|
||||
head_ts: number | null;
|
||||
/** 存在的轮转段数(.log.1 … .log.20) */
|
||||
rotated_segments: number;
|
||||
file: string;
|
||||
file_bytes: number;
|
||||
computed_at: number;
|
||||
}
|
||||
|
||||
/** 计算当前审计链的头部信息(只读)。 */
|
||||
export function computeAuditHead(): AuditHead {
|
||||
const p = auditFilePath();
|
||||
let rotated = 0;
|
||||
for (let i = 1; i <= 20; i += 1) if (existsSync(`${p}.${i}`)) rotated += 1;
|
||||
|
||||
const entries = readAuditAll();
|
||||
const last = entries.length > 0 ? entries[entries.length - 1]! : null;
|
||||
let fileBytes = 0;
|
||||
try {
|
||||
fileBytes = statSync(p).size;
|
||||
} catch {
|
||||
/* 文件可能尚不存在(还没写过审计) */
|
||||
}
|
||||
return {
|
||||
entries: entries.length,
|
||||
head_hash: last?.hash ?? null,
|
||||
head_ts: last?.ts ?? null,
|
||||
rotated_segments: rotated,
|
||||
file: p,
|
||||
file_bytes: fileBytes,
|
||||
computed_at: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
// 锚点记录与比对结果的定义**移到了 protocol**(唯一实现,控制端脚本也用它)——
|
||||
// 见 packages/protocol/src/audit-anchor.ts 顶部的说明。此处 re-export 以保持既有导入路径可用。
|
||||
export type { AnchorRecord, AnchorComparison } from '@nodeagent/protocol';
|
||||
|
||||
/** 锚点文件路径(默认与审计同目录)。 */
|
||||
export function anchorFilePath(custom?: string): string {
|
||||
return custom && custom.trim().length > 0 ? custom.trim() : join(agentDir(), 'audit-anchors.jsonl');
|
||||
}
|
||||
|
||||
/** 把当前链头**追加**到锚点文件(追加式:历史锚点不可被后续覆盖)。 */
|
||||
export function anchorAudit(opts: { path?: string; note?: string } = {}): {
|
||||
file: string;
|
||||
record: AnchorRecord;
|
||||
total_lines: number;
|
||||
} {
|
||||
const head = computeAuditHead();
|
||||
const file = anchorFilePath(opts.path);
|
||||
const rec: AnchorRecord = {
|
||||
ts: Date.now(),
|
||||
entries: head.entries,
|
||||
head_hash: head.head_hash,
|
||||
head_ts: head.head_ts,
|
||||
rotated_segments: head.rotated_segments,
|
||||
...(opts.note ? { note: opts.note } : {}),
|
||||
};
|
||||
appendFileSync(file, `${JSON.stringify(rec)}\n`, 'utf8');
|
||||
let total = 0;
|
||||
try {
|
||||
total = readFileSync(file, 'utf8').split('\n').filter((l) => l.trim().length > 0).length;
|
||||
} catch {
|
||||
total = 1;
|
||||
}
|
||||
return { file, record: rec, total_lines: total };
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 与最近一条锚点比对,判断当前链是否与锚定时刻自洽。
|
||||
*
|
||||
* 判定逻辑(三条):
|
||||
* - 条目数应 **≥** 锚点记录(只增不减;减少只能由轮转解释,轮转会让 rotated_segments 变大)
|
||||
* - 若条目数与轮转段数都没变 → 链头哈希必须**完全一致**
|
||||
* - 若已轮转(段数变大)→ 只校验"当前 entries ≥ 锚点 entries",并提示需人工核对
|
||||
*/
|
||||
export function compareWithAnchors(customPath?: string): AnchorComparison {
|
||||
const file = anchorFilePath(customPath);
|
||||
if (!existsSync(file)) {
|
||||
return { ok: true, anchors_checked: 0, verdict: '尚无锚点:请先调用 system.audit.anchor 建立外部锚点' };
|
||||
}
|
||||
let anchors: AnchorRecord[] | null = null;
|
||||
try {
|
||||
anchors = parseAnchorsFromJsonl(readFileSync(file, 'utf8'));
|
||||
} catch {
|
||||
return { ok: false, anchors_checked: 0, verdict: '锚点文件损坏,无法比对' };
|
||||
}
|
||||
if (!anchors) {
|
||||
return { ok: true, anchors_checked: 0, verdict: '锚点文件为空' };
|
||||
}
|
||||
// 判定逻辑在 protocol(judgeAnchors)—— 与「控制端对云盘锚点」的比对共用同一份实现,
|
||||
// 避免两处规则漂移(那会导致同一份日志两端结论不同)。
|
||||
return judgeAnchors(computeAuditHead(), anchors);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
import { IS_WINDOWS, execCommand } from '../util/exec.js';
|
||||
import { psLit } from '../util/quote.js';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
@@ -108,7 +109,9 @@ export async function appInstall(args: Args): Promise<unknown> {
|
||||
);
|
||||
}
|
||||
|
||||
const target = id ? `--id ${quote(id)} --exact` : `${quote(pkg)}`;
|
||||
// ⚠️ package / id 来自控制端,且命令经 `powershell.exe -Command` 执行
|
||||
// → 必须用 PS 单引号字面量(旧的 `"…"` + `\"` 写法可被 `;` / `$(…)` 逃逸,同类 P0)。
|
||||
const target = id ? `--id ${psLit(id)} --exact` : `${psLit(pkg)}`;
|
||||
const flags = [
|
||||
silent ? '--silent' : '',
|
||||
'--accept-package-agreements',
|
||||
@@ -134,10 +137,6 @@ export async function appInstall(args: Args): Promise<unknown> {
|
||||
};
|
||||
}
|
||||
|
||||
function quote(s: string): string {
|
||||
return `"${s.replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
|
||||
function extractVersion(output: string): string | null {
|
||||
const m = /(\d+\.\d+(?:\.\d+)*(?:-\w+)?)/.exec(output);
|
||||
return m ? m[1]! : null;
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
import { IS_WINDOWS, execCommand } from '../util/exec.js';
|
||||
import { runPowerShellSmart } from '../util/ps-helper.js';
|
||||
import { shLit } from '../util/quote.js';
|
||||
import { assertGuiPlatform } from '../util/platform.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> {
|
||||
// ⚠️ AppleScript 源码里含用户可控文本 → 必须走 shell 单引号字面量。
|
||||
// 曾用 JSON.stringify(双引号)→ bash 会在双引号内展开 `$(...)` 与反引号(注入面)。
|
||||
const r = await execCommand({ command: `osascript -e ${shLit(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> {
|
||||
// 该能力仅在 Windows/macOS 有实现(Linux 端未实现)
|
||||
assertGuiPlatform('audio.get');
|
||||
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> {
|
||||
// 该能力仅在 Windows/macOS 有实现(Linux 端未实现)
|
||||
assertGuiPlatform('audio.set');
|
||||
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,364 @@
|
||||
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { join } from 'node:path';
|
||||
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
import { execCommand } from '../util/exec.js';
|
||||
import { assertWindows } from '../util/platform.js';
|
||||
import { agentDir } from '../config.js';
|
||||
import { audit } from '../audit.js';
|
||||
|
||||
/**
|
||||
* L2 系统级审计兜底(2026-10-17)。
|
||||
*
|
||||
* ## 它补的是 L1 结构上记不到的那块
|
||||
* L1 是「agent 自己记的」:只看得见经过自己 RPC 的调用。
|
||||
* 攻击者绕过 agent 直接跑系统命令、或干脆把 agent 停掉 —— L1 全都看不到。
|
||||
* L2 采集**操作系统**的审计事件,补的正是这块。
|
||||
*
|
||||
* ## ⚠️ 信任边界(不要夸大它的作用)
|
||||
* 采集器与被审计对象**同进程同权限** —— 攻击者能停 agent 就能停采集器。
|
||||
* 故本地 `audit-l2.log` 只是一份**归一化副本**,防篡改价值有限。
|
||||
* 真正的独立性来自**把记录转发出机**(与 SECURITY.md §4.8 的锚定同一思路)。
|
||||
* 本模块不实现自动转发,只提供导出接口。
|
||||
*
|
||||
* ## 为什么 Windows 侧选内置 auditpol 而不是 Sysmon
|
||||
* - Sysmon 是第三方驱动:需单独分发、需重启生效、版本行为有差异;
|
||||
* - 内置高级审计策略是 Windows 自带的,零依赖即可立起来;
|
||||
* - 策略:检测到装了 Sysmon 就并用,没装也不尝试下载(不在本仓库职责内)。
|
||||
*
|
||||
* ## 平台现状
|
||||
* - Windows:**已落地**(status / enable / collect / verify)
|
||||
* - Linux:仅设计(auditd,需预装)—— 见 docs/AUDIT-L2.md
|
||||
* - macOS:仅设计,**ESF 需 Apple entitlement,个人自签无法启用** —— 不写假装能用的代码
|
||||
*/
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
/** 归一化后的事件类别(跨平台枚举,详见 docs/AUDIT-L2.md §4)。 */
|
||||
export type L2Kind =
|
||||
| 'process.create'
|
||||
| 'process.exit'
|
||||
| 'auth.success'
|
||||
| 'auth.failure'
|
||||
| 'file.access';
|
||||
|
||||
export interface L2Entry {
|
||||
/** 事件发生时间(来自 OS,不是采集时刻) */
|
||||
ts: number;
|
||||
src: 'windows-security' | 'sysmon' | 'linux-auditd' | 'macos-esf';
|
||||
/** OS 原始事件 ID —— 保留原始值以便人工核对 */
|
||||
event_id: number | string;
|
||||
kind: L2Kind;
|
||||
subject?: string;
|
||||
object?: string;
|
||||
detail?: Record<string, unknown>;
|
||||
/** 采集时刻。与 ts 相差过大说明期间离线或 agent 未运行 */
|
||||
collected_at: number;
|
||||
prev?: string;
|
||||
hash?: string;
|
||||
}
|
||||
|
||||
const GENESIS = 'genesis';
|
||||
|
||||
/** L2 独立落盘,不与 L1 混 —— 混了就无法区分「agent 记的」与「OS 记的」。 */
|
||||
export function l2FilePath(): string {
|
||||
return join(agentDir(), 'audit-l2.log');
|
||||
}
|
||||
|
||||
function hashEntry(record: Record<string, unknown>, prev: string): string {
|
||||
const keys = Object.keys(record).sort();
|
||||
const body = JSON.stringify(record, keys);
|
||||
return createHash('sha256').update(`${prev}|${body}`).digest('hex').slice(0, 32);
|
||||
}
|
||||
|
||||
function lastHashOf(): string {
|
||||
try {
|
||||
const p = l2FilePath();
|
||||
if (!existsSync(p)) return GENESIS;
|
||||
const lines = readFileSync(p, 'utf8').split('\n').filter((l) => l.trim());
|
||||
const last = lines[lines.length - 1];
|
||||
if (!last) return GENESIS;
|
||||
return (JSON.parse(last) as L2Entry).hash ?? GENESIS;
|
||||
} catch {
|
||||
return GENESIS;
|
||||
}
|
||||
}
|
||||
|
||||
/** 追加一批 L2 记录(带独立哈希链)。 */
|
||||
function appendL2(entries: Omit<L2Entry, 'prev' | 'hash' | 'collected_at'>[]): number {
|
||||
const p = l2FilePath();
|
||||
mkdirSync(agentDir(), { recursive: true });
|
||||
let prev = lastHashOf();
|
||||
const now = Date.now();
|
||||
let n = 0;
|
||||
for (const e of entries) {
|
||||
const full = { ...e, collected_at: now, prev } as unknown as Record<string, unknown>;
|
||||
const hash = hashEntry(full, prev);
|
||||
(full as Record<string, unknown>)['hash'] = hash;
|
||||
appendFileSync(p, `${JSON.stringify(full)}\n`, 'utf8');
|
||||
prev = hash;
|
||||
n += 1;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// ---------------- Windows:Security 事件 → 归一化 ----------------
|
||||
|
||||
/** Windows 事件 ID → 归一化类别。 */
|
||||
const WIN_EVENT_KIND: Record<number, L2Kind> = {
|
||||
4688: 'process.create',
|
||||
4689: 'process.exit',
|
||||
4624: 'auth.success',
|
||||
4625: 'auth.failure',
|
||||
4663: 'file.access',
|
||||
};
|
||||
|
||||
const WIN_WATCHED_IDS = Object.keys(WIN_EVENT_KIND).map(Number);
|
||||
|
||||
/**
|
||||
* 采集 Windows Security 日志。
|
||||
*
|
||||
* 只用 PowerShell 内置 cmdlet(Get-WinEvent),不引外部依赖。
|
||||
* @param sinceMs 起始时间(Unix ms);默认 24 小时内
|
||||
* @param limit 上限,防止一次拉爆(Security 日志可能极多)
|
||||
*/
|
||||
async function collectWindows(sinceMs: number, limit: number): Promise<Omit<L2Entry, 'prev' | 'hash' | 'collected_at'>[]> {
|
||||
// 用 ISO8601 传时间,避免区域设置导致的日期解析差异
|
||||
const sinceIso = new Date(sinceMs).toISOString();
|
||||
const idList = WIN_WATCHED_IDS.join(',');
|
||||
const ps = [
|
||||
`$ErrorActionPreference = 'Stop'`,
|
||||
`$ids = @(${idList})`,
|
||||
`$since = (Get-Date '${sinceIso}').ToUniversalTime()`,
|
||||
`$evs = Get-WinEvent -FilterHashtable @{LogName='Security'; Id=$ids; StartTime=$since} -MaxEvents ${limit} -ErrorAction SilentlyContinue`,
|
||||
`if ($null -eq $evs) { Write-Output '[]'; exit 0 }`,
|
||||
`$out = foreach ($e in $evs) {`,
|
||||
` $x = @{ id = $e.Id; t = $e.TimeCreated.ToUniversalTime().ToString('o'); msg = $e.Message }`,
|
||||
` try { $x.sub = $e.Properties[0].Value.ToString() } catch { $x.sub = '' }`,
|
||||
` $x | ConvertTo-Json -Compress -Depth 3`,
|
||||
`}`,
|
||||
`Write-Output ($out -join "\`n")`,
|
||||
].join('\n');
|
||||
|
||||
const r = await execCommand({ command: ps, timeoutMs: 60_000 });
|
||||
if (r.exit_code !== 0) {
|
||||
// 常见:权限不足(非管理员读不了 Security 日志)
|
||||
throw new CapabilityError(
|
||||
ErrorCodes.EXECUTION_FAILED,
|
||||
'读取 Windows Security 日志失败(常见原因:未以管理员运行,或审计策略未启用)',
|
||||
{ detail: (r.stderr || r.stdout).slice(0, 300) },
|
||||
);
|
||||
}
|
||||
const text = r.stdout.trim();
|
||||
if (!text || text === '[]') return [];
|
||||
|
||||
const out: Omit<L2Entry, 'prev' | 'hash' | 'collected_at'>[] = [];
|
||||
for (const line of text.split('\n')) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const o = JSON.parse(line) as { id?: number; t?: string; msg?: string; sub?: string };
|
||||
const id = Number(o.id);
|
||||
const kind = WIN_EVENT_KIND[id];
|
||||
if (!kind) continue;
|
||||
const ts = o.t ? Date.parse(o.t) : Date.now();
|
||||
// 从 Message 里抠进程名(4688 的「新进程名称」),拿不到就留空 —— 不猜
|
||||
// 括号不可省:TS 不允许 ?? 与 || 无括号混用
|
||||
const obj =
|
||||
(/新进程名称:\s*(.+)/.exec(o.msg ?? '')?.[1]?.trim() ??
|
||||
/进程名称:\s*(.+)/.exec(o.msg ?? '')?.[1]?.trim()) ||
|
||||
undefined;
|
||||
out.push({
|
||||
ts: Number.isFinite(ts) ? ts : Date.now(),
|
||||
src: 'windows-security',
|
||||
event_id: id,
|
||||
kind,
|
||||
subject: o.sub || undefined,
|
||||
object: obj,
|
||||
detail: { raw_message_head: (o.msg ?? '').slice(0, 300) },
|
||||
});
|
||||
} catch {
|
||||
/* 跳过解析失败的单条,不影响整体 */
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------- 对外能力 ----------------
|
||||
|
||||
export async function auditL2(args: Args): Promise<unknown> {
|
||||
const action = (args['action'] as string | undefined) ?? 'status';
|
||||
|
||||
// 平台门控走统一 assertWindows(与 app.* / system.service.list 同一形状):
|
||||
// ① 错误信息格式统一(能力名 + 未实现平台 + 受支持平台);
|
||||
// ② 让 console-contract 的平台门控守卫扫得到本能力(裸 throw 会漏检 → 控制台
|
||||
// WINDOWS_ONLY_CAPS 少列一项 → 非 Windows 被控端上按钮照常显示,点了才报错)。
|
||||
// 平台现状与 Linux/macOS 未实现的原因见文件头注释与 docs/AUDIT-L2.md。
|
||||
assertWindows('system.audit.l2');
|
||||
|
||||
if (action === 'status') return l2Status();
|
||||
if (action === 'enable') return await l2Enable();
|
||||
if (action === 'collect') {
|
||||
const since = typeof args['since'] === 'number' ? args['since'] : Date.now() - 24 * 3600 * 1000;
|
||||
const limit = Math.max(1, Math.min(5000, (args['limit'] as number | undefined) ?? 1000));
|
||||
const entries = await collectWindows(since, limit);
|
||||
const written = appendL2(entries);
|
||||
audit({ type: 'agent.l2', status: 'ok', reason: `l2.collect written=${written} since=${new Date(since).toISOString()}` });
|
||||
return {
|
||||
collected: entries.length,
|
||||
written,
|
||||
since,
|
||||
limit,
|
||||
file: l2FilePath(),
|
||||
note:
|
||||
'本地 audit-l2.log 只是归一化副本;真正防篡改依赖把记录转发出机(见 docs/AUDIT-L2.md §2)',
|
||||
};
|
||||
}
|
||||
if (action === 'verify') return verifyL2();
|
||||
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `action 只支持 status|enable|collect|verify,收到 ${action}`);
|
||||
}
|
||||
|
||||
/** 检查 Windows 审计策略是否已启用(并提示命令行记录开关)。 */
|
||||
async function l2Status(): Promise<unknown> {
|
||||
const ps = [
|
||||
`$ErrorActionPreference='Stop'`,
|
||||
`$o = auditpol /get /category:* /r`,
|
||||
// /r 输出 CSV:Machine Name,Policy Target,Subcategory,Subcategory GUID,Inclusion Setting,Exclusion Setting
|
||||
`$rows = $o | ConvertFrom-Csv`,
|
||||
`$want = @('Process Creation','Logon','Process Termination')`,
|
||||
`$got = @()`,
|
||||
`foreach ($w in $want) {`,
|
||||
` $m = $rows | Where-Object { $_.Subcategory -eq $w } | Select-Object -First 1`,
|
||||
` if ($m) { $got += ($w + '=' + $m.'Inclusion Setting') } else { $got += ($w + '=not found') }`,
|
||||
`}`,
|
||||
`Write-Output ($got -join '; ')`,
|
||||
`$legacy = (Get-ItemProperty 'HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Lsa' -Name SCENoApplyLegacyAuditPolicy -ErrorAction SilentlyContinue).SCENoApplyLegacyAuditPolicy`,
|
||||
`Write-Output ("SCENoApplyLegacyAuditPolicy=" + $(if ($null -eq $legacy) { 'not set' } else { $legacy }))`,
|
||||
`$cmdline = (Get-ItemProperty 'HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Policies\\System\\Audit' -Name ProcessCreationIncludeCmdLine_Enabled -ErrorAction SilentlyContinue).ProcessCreationIncludeCmdLine_Enabled`,
|
||||
`Write-Output ("ProcessCreationIncludeCmdLine_Enabled=" + $(if ($null -eq $cmdline) { 'not set' } else { $cmdline }))`,
|
||||
].join('\n');
|
||||
|
||||
const r = await execCommand({ command: ps, timeoutMs: 60_000 });
|
||||
const out = r.stdout || '';
|
||||
const sysmon = await detectSysmon();
|
||||
|
||||
return {
|
||||
platform: 'windows',
|
||||
supported: true,
|
||||
auditpol: out.trim(),
|
||||
// 不设它,组策略的旧式类别策略会盖掉 subcategory 设置 ——
|
||||
// 表现为「auditpol 显示已启用,但事件日志里没有 4688」
|
||||
legacy_policy_disabled: /SCENoApplyLegacyAuditPolicy=1/.test(out),
|
||||
cmdline_logging: /ProcessCreationIncludeCmdLine_Enabled=1/.test(out),
|
||||
sysmon_installed: sysmon,
|
||||
file: l2FilePath(),
|
||||
file_exists: existsSync(l2FilePath()),
|
||||
note: r.exit_code !== 0 ? 'auditpol 查询未成功返回(可能需要管理员权限)' : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** 启用 Windows 高级审计策略(需管理员)。 */
|
||||
async function l2Enable(): Promise<unknown> {
|
||||
const ps = [
|
||||
`$ErrorActionPreference='Stop'`,
|
||||
// ① 让「高级审计策略」生效,覆盖旧式类别策略
|
||||
`New-ItemProperty -Path 'HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Lsa' -Name SCENoApplyLegacyAuditPolicy -Value 1 -PropertyType DWORD -Force | Out-Null`,
|
||||
// ② 命令行记录:不开的话 4688 只有进程名,看不到参数
|
||||
`New-Item -Path 'HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Policies\\System\\Audit' -Force | Out-Null`,
|
||||
`New-ItemProperty -Path 'HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Policies\\System\\Audit' -Name ProcessCreationIncludeCmdLine_Enabled -Value 1 -PropertyType DWORD -Force | Out-Null`,
|
||||
// ③ 开启关注的 subcategory
|
||||
`auditpol /set /subcategory:"进程创建" /success:enable /failure:enable`,
|
||||
`auditpol /set /subcategory:"登录" /success:enable /failure:enable`,
|
||||
`auditpol /set /subcategory:"进程终止" /success:enable`,
|
||||
`Write-Output 'enabled'`,
|
||||
].join('\n');
|
||||
|
||||
const r = await execCommand({ command: ps, timeoutMs: 90_000 });
|
||||
if (r.exit_code !== 0) {
|
||||
throw new CapabilityError(
|
||||
ErrorCodes.EXECUTION_FAILED,
|
||||
'启用审计策略失败(需要管理员权限)。未提权时请先用管理员身份运行。',
|
||||
{ detail: (r.stderr || r.stdout).slice(0, 300) },
|
||||
);
|
||||
}
|
||||
audit({ type: 'agent.l2', status: 'ok', reason: 'l2.enable windows audit policy' });
|
||||
return {
|
||||
enabled: true,
|
||||
platform: 'windows',
|
||||
policies: ['进程创建 4688', '登录 4624/4625', '进程终止 4689'],
|
||||
cmdline_logging: true,
|
||||
hint: '策略生效后需要一段时间才会产生事件;用 action=collect 拉取',
|
||||
};
|
||||
}
|
||||
|
||||
/** 检测 Sysmon 是否安装(装了就并用其日志,没装不尝试下载)。 */
|
||||
async function detectSysmon(): Promise<boolean> {
|
||||
const r = await execCommand({
|
||||
command: `$ErrorActionPreference='SilentlyContinue'; (Get-Service Sysmon -ErrorAction SilentlyContinue).Name`,
|
||||
timeoutMs: 20_000,
|
||||
});
|
||||
return r.stdout.trim() === 'Sysmon';
|
||||
}
|
||||
|
||||
// ---------------- L2 链校验 ----------------
|
||||
|
||||
export interface L2VerifyResult {
|
||||
ok: boolean;
|
||||
checked: number;
|
||||
broken_at?: { line: number; reason: string };
|
||||
}
|
||||
|
||||
/** 校验 L2 链完整性(与 L1 同构算法,独立文件独立链)。 */
|
||||
export function verifyL2(): L2VerifyResult {
|
||||
const p = l2FilePath();
|
||||
if (!existsSync(p)) return { ok: true, checked: 0 };
|
||||
let expected: string | null = null;
|
||||
let checked = 0;
|
||||
let line = 0;
|
||||
try {
|
||||
for (const raw of readFileSync(p, 'utf8').split('\n')) {
|
||||
if (!raw.trim()) continue;
|
||||
line += 1;
|
||||
let rec: L2Entry;
|
||||
try {
|
||||
rec = JSON.parse(raw) as L2Entry;
|
||||
} catch {
|
||||
return { ok: false, checked, broken_at: { line, reason: 'JSON 解析失败(条目被破坏)' } };
|
||||
}
|
||||
const { hash, ...body } = rec;
|
||||
if (!hash) continue;
|
||||
const recalc = hashEntry(body as unknown as Record<string, unknown>, rec.prev ?? GENESIS);
|
||||
if (recalc !== hash) {
|
||||
return { ok: false, checked, broken_at: { line, reason: '内容与哈希不符(条目被篡改)' } };
|
||||
}
|
||||
if (expected !== null && rec.prev !== expected) {
|
||||
return { ok: false, checked, broken_at: { line, reason: '链断裂(prev 与上一条哈希不匹配)' } };
|
||||
}
|
||||
expected = hash;
|
||||
checked += 1;
|
||||
}
|
||||
} catch {
|
||||
return { ok: false, checked, broken_at: { line, reason: '读取 L2 文件失败' } };
|
||||
}
|
||||
return { ok: true, checked };
|
||||
}
|
||||
|
||||
/** 读取 L2 记录(供导出转发 —— 真正的独立性来自这里,而非本地副本)。 */
|
||||
export function readL2(limit = 200): L2Entry[] {
|
||||
const p = l2FilePath();
|
||||
if (!existsSync(p)) return [];
|
||||
const out: L2Entry[] = [];
|
||||
try {
|
||||
const lines = readFileSync(p, 'utf8').split('\n').filter((l) => l.trim());
|
||||
for (const l of lines.slice(-Math.max(1, limit))) {
|
||||
try {
|
||||
out.push(JSON.parse(l) as L2Entry);
|
||||
} catch {
|
||||
/* 跳过损坏行 */
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* 读失败返回已有部分 */
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,13 @@ import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
import { IS_WINDOWS, execCommand } from '../util/exec.js';
|
||||
import { assertGuiPlatform } from '../util/platform.js';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
/** Set-Clipboard 遇剪贴板被瞬时占用时的重试次数(实测约 20% 概率偶发)。 */
|
||||
const CLIP_SET_RETRIES = 3;
|
||||
|
||||
/**
|
||||
* 图片类剪贴板操作必须走**临时文件**:
|
||||
* 命令行参数在 Windows 上有 ~32KB 上限,而截图 base64 常达数 MB;
|
||||
@@ -27,9 +31,57 @@ 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> {
|
||||
// 该能力仅在 Windows/macOS 有实现(Linux 端未实现)
|
||||
assertGuiPlatform('clipboard.get');
|
||||
const format = (args['format'] as string | undefined) ?? 'auto';
|
||||
|
||||
if (IS_WINDOWS) {
|
||||
@@ -83,6 +135,8 @@ async function getImageWindows(): Promise<Record<string, unknown> | null> {
|
||||
// ---------------- clip.set ----------------
|
||||
|
||||
export async function clipSet(args: Args): Promise<unknown> {
|
||||
// 该能力仅在 Windows/macOS 有实现(Linux 端未实现)
|
||||
assertGuiPlatform('clipboard.set');
|
||||
const image = args['image_base64'] as string | undefined;
|
||||
const text = args['text'] as string | undefined;
|
||||
|
||||
@@ -100,15 +154,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,438 @@
|
||||
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 { shLit } from '../util/quote.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} ${shLit(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;
|
||||
|
||||
// ⚠️ `text` 来自控制端,`bin`/`shot` 是内部路径 —— 一律 shLit 包成 shell 单引号字面量。
|
||||
// 曾用 JSON.stringify(双引号)→ `$(touch /tmp/p)` 会被 bash 执行(P1 D-01,实测复现)。
|
||||
const r = await execCommand({
|
||||
command: `${shLit(bin)} ${shLit(shot)} ${shLit(text)}`,
|
||||
timeoutMs: 60_000,
|
||||
maxOutputBytes: 4 * 1024 * 1024,
|
||||
});
|
||||
if (r.exit_code !== 0) {
|
||||
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, 'OCR 失败', {
|
||||
detail: (r.stderr || r.stdout).slice(0, 300),
|
||||
});
|
||||
}
|
||||
const start = r.stdout.indexOf('[');
|
||||
const end = r.stdout.lastIndexOf(']');
|
||||
const raw =
|
||||
start >= 0 && end > start
|
||||
? (JSON.parse(r.stdout.slice(start, end + 1)) as Array<{
|
||||
text: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
confidence: number;
|
||||
}>)
|
||||
: [];
|
||||
matches = raw.slice(0, limit).map((m) => {
|
||||
// 先由像素换算回逻辑点,再叠加区域偏移 -> 得到可直接用于点击的屏幕坐标
|
||||
const px = m.x / scale;
|
||||
const py = m.y / scale;
|
||||
const pw = m.w / scale;
|
||||
const ph = m.h / scale;
|
||||
return {
|
||||
name: m.text,
|
||||
control_type: 'Text(ocr)',
|
||||
x: Math.round(px + pw / 2 + captureRegion.x),
|
||||
y: Math.round(py + ph / 2 + captureRegion.y),
|
||||
left: Math.round(px + captureRegion.x),
|
||||
top: Math.round(py + captureRegion.y),
|
||||
width: Math.round(pw),
|
||||
height: Math.round(ph),
|
||||
};
|
||||
});
|
||||
if (matches.length > 0 || Date.now() - startedAt >= waitMs) break;
|
||||
await new Promise((res) => setTimeout(res, intervalMs));
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* 忽略清理失败 */
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
matches,
|
||||
engine: 'ocr',
|
||||
waited_ms: Date.now() - startedAt,
|
||||
note: 'macOS 无 UIA 等价物,统一走 Vision OCR(只识别可见内容);坐标已由 Retina 像素换算为逻辑点',
|
||||
};
|
||||
}
|
||||
|
||||
/** 取整块虚拟屏的尺寸(多屏取并集)。 */
|
||||
async function fullVirtualScreen(): Promise<{ x: number; y: number; width: number; height: number }> {
|
||||
const r = await runOsa(
|
||||
`tell application "Finder" to get bounds of window of desktop`,
|
||||
10_000,
|
||||
);
|
||||
if (r.ok) {
|
||||
const m = /(-?\d+),\s*(-?\d+),\s*(-?\d+),\s*(-?\d+)/.exec(r.stdout);
|
||||
if (m) {
|
||||
const [x1, y1, x2, y2] = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])];
|
||||
return { x: x1, y: y1, width: x2 - x1, height: y2 - y1 };
|
||||
}
|
||||
}
|
||||
return { x: 0, y: 0, width: 1920, height: 1080 };
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import {
|
||||
appendFileSync,
|
||||
closeSync,
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
openSync,
|
||||
readSync,
|
||||
readdirSync,
|
||||
realpathSync,
|
||||
renameSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
@@ -26,24 +27,70 @@ const HASH_LIMIT_BYTES = 8 * 1024 * 1024;
|
||||
let allowedRoots: string[] = [];
|
||||
|
||||
export function setFsRoots(roots: string[] | undefined): void {
|
||||
allowedRoots = (roots ?? []).map((r) => resolve(r));
|
||||
// 白名单根也做一次 realpath 归一:否则「配置里写的是软链」会与后面 realpath 后的路径对不上
|
||||
allowedRoots = (roots ?? []).map((r) => realPathBestEffort(resolve(r)));
|
||||
}
|
||||
|
||||
function guardPath(input: string): string {
|
||||
/**
|
||||
* 尽力取真实路径(解析符号链接)。
|
||||
*
|
||||
* - 路径存在 → 直接 `realpathSync`
|
||||
* - 不存在(写入场景很常见)→ 向上找到**最近的已存在祖先**做 realpath,再把剩余段拼回去
|
||||
*
|
||||
* 这样即便目标文件本身还不存在,也能正确判断「它最终会落在哪里」,
|
||||
* 从而拦住「用软链接把访问点引到白名单之外」这一类绕过。
|
||||
*/
|
||||
function realPathBestEffort(abs: string): string {
|
||||
try {
|
||||
return realpathSync.native(abs);
|
||||
} catch {
|
||||
/* 继续向上找 */
|
||||
}
|
||||
const parts: string[] = [];
|
||||
let cur = abs;
|
||||
for (;;) {
|
||||
const parent = dirname(cur);
|
||||
if (parent === cur) return abs; // 到根了仍不存在 → 原样返回
|
||||
parts.unshift(cur.slice(parent.length + (parent.endsWith(sep) ? 0 : 1)));
|
||||
try {
|
||||
const base = realpathSync.native(parent);
|
||||
return parts.length > 0 ? join(base, ...parts) : base;
|
||||
} catch {
|
||||
cur = parent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 路径白名单守卫(v5)。
|
||||
*
|
||||
* ⚠️ 两道防线,缺一不可:
|
||||
* 1. **解析符号链接后的真实路径**再做包含判断 —— 只比较字面路径时,
|
||||
* `白名单/a_软链 → /etc` 这样的路径能直接穿出去(绕过 `fs_roots`)。
|
||||
* 2. 前缀比较必须带上分隔符(`/root` 不得匹配 `/rootX`),且大小写按平台归一
|
||||
* (Windows 文件系统不区分大小写)。
|
||||
*/
|
||||
export function guardPath(input: string): string {
|
||||
if (typeof input !== 'string' || input.length === 0) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'path 不能为空');
|
||||
}
|
||||
const abs = resolve(input);
|
||||
if (allowedRoots.length === 0) return abs;
|
||||
|
||||
const ok = allowedRoots.some((root) => {
|
||||
if (abs === root) return true;
|
||||
const real = realPathBestEffort(abs);
|
||||
const norm = (p: string): string => (process.platform === 'win32' ? p.toLowerCase() : p);
|
||||
const target = norm(real);
|
||||
|
||||
const ok = allowedRoots.some((rootRaw) => {
|
||||
const root = norm(rootRaw);
|
||||
if (target === root) return true;
|
||||
const prefix = root.endsWith(sep) ? root : root + sep;
|
||||
return abs.startsWith(prefix);
|
||||
return target.startsWith(prefix);
|
||||
});
|
||||
if (!ok) {
|
||||
throw new CapabilityError(ErrorCodes.ACL_DENIED, `路径不在允许范围内: ${abs}`, {
|
||||
allowed_roots: allowedRoots,
|
||||
resolved: real,
|
||||
});
|
||||
}
|
||||
return abs;
|
||||
@@ -207,8 +254,10 @@ export async function fsWrite(args: Args): Promise<unknown> {
|
||||
if (append) {
|
||||
appendFileSync(p, buf);
|
||||
} else {
|
||||
// 原子写:先写临时文件再重命名,避免中途失败留下半截文件
|
||||
const tmp = `${p}.nodeagent-tmp-${process.pid}`;
|
||||
// 原子写:先写临时文件再重命名,避免中途失败留下半截文件。
|
||||
// ⚠️ 临时名必须**唯一**:原来只用 PID,同一进程内并发写同一路径(或两个会话同时写)
|
||||
// 会互相踩对方的 tmp(先完成的 rename 走后,后一个 renameSync 会 ENOENT 失败)。
|
||||
const tmp = `${p}.nodeagent-tmp-${process.pid}-${randomBytes(4).toString('hex')}`;
|
||||
writeFileSync(tmp, buf);
|
||||
renameSync(tmp, p);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,42 @@
|
||||
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 { auditL2 } from './audit-l2.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;
|
||||
/**
|
||||
* 调用方身份(`client_id`)。用于**跨控制端隔离**:
|
||||
* 事件订阅的 list / unwatch / poll 只暴露给「创建它的那个 client_id」。
|
||||
*
|
||||
* 为什么按 client_id 而不是连接:同一次工作的多个连接(如 MCP 每次调用新建连接、
|
||||
* CLI 的 watch 与 poll 分属两个进程)必须能互相看到自己的订阅,否则语义就断了。
|
||||
* psk 模式下 client_id 由客户端自报(配置级约束);ed25519 + 每客户端独立 PSK 下身份才被真正绑定。
|
||||
*/
|
||||
clientId?: string;
|
||||
/** 向该连接推送事件 */
|
||||
emit: (evt: unknown) => void;
|
||||
}
|
||||
|
||||
export type CapabilityHandler = (
|
||||
args: Record<string, unknown>,
|
||||
ctx?: HandlerContext,
|
||||
) => Promise<unknown>;
|
||||
|
||||
/**
|
||||
* 装配能力注册表。
|
||||
@@ -38,10 +64,26 @@ 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,
|
||||
// L2 系统级审计兜底(见 docs/AUDIT-L2.md)
|
||||
[CapabilityNames.AuditL2]: auditL2,
|
||||
// v13 成功指标
|
||||
[CapabilityNames.Metrics]: metricsReport,
|
||||
// v5 文件传输
|
||||
[CapabilityNames.FsList]: fsList,
|
||||
[CapabilityNames.FsStat]: fsStat,
|
||||
@@ -60,5 +102,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,5 +1,13 @@
|
||||
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';
|
||||
import { PS_DPI_AWARE } from '../util/dpi.js';
|
||||
import { assertGuiPlatform } from '../util/platform.js';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
@@ -79,13 +87,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,22 +136,116 @@ 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;
|
||||
}
|
||||
}`;
|
||||
|
||||
/** 组装:加类型定义 + 具体操作。 */
|
||||
function psScript(body: string[]): string {
|
||||
return [`$ErrorActionPreference = 'Stop'`, `$code = @'`, CS_CODE, `'@`, `Add-Type -TypeDefinition $code`, ...body].join(
|
||||
'\n',
|
||||
);
|
||||
/**
|
||||
* 组装:加 DPI 感知 + 类型定义 + 具体操作。
|
||||
*
|
||||
* 导出**仅供单测断言**(`tests/unit/dpi-awareness.test.mjs` 直接检查这里下发的
|
||||
* PowerShell 是否带上了 DPI 感知、且位于坐标调用之前)——真机 2026-10-12 的
|
||||
* 「点不准」缺陷就是这里漏了 DPI 感知,而源码扫描式守卫抓不到。
|
||||
*/
|
||||
export function buildInputPsScript(body: string[]): string {
|
||||
return [
|
||||
`$ErrorActionPreference = 'Stop'`,
|
||||
// ⚠️ 必须最先执行,且**不可删**:不设 DPI 感知时 `SetCursorPos` / `mouse_event` 的坐标
|
||||
// 会被系统按缩放比虚拟化(200% 下 ×2),而 `screen.find`(UIA)返回的是**物理**坐标 ——
|
||||
// 「找得到、点不准」,且不报任何错。真机 2026-10-12 实证:
|
||||
// 请求 (100,100) 实际落到 (200,200),请求 (552,745) 实际落到 (1104,1439)。
|
||||
PS_DPI_AWARE,
|
||||
`$code = @'`,
|
||||
CS_CODE,
|
||||
`'@`,
|
||||
`Add-Type -TypeDefinition $code`,
|
||||
...body,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function runPs(body: string[], timeoutMs = 30_000): Promise<void> {
|
||||
const r = await execCommand({ command: psScript(body), timeoutMs });
|
||||
async function runPs(body: string[], timeoutMs = 30_000): Promise<string> {
|
||||
const r = await execCommand({ command: buildInputPsScript(body), timeoutMs });
|
||||
if (r.exit_code !== 0) {
|
||||
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '输入注入失败', {
|
||||
detail: (r.stderr || r.stdout).slice(0, 400),
|
||||
});
|
||||
}
|
||||
return r.stdout ?? '';
|
||||
}
|
||||
|
||||
// ---------------- 鼠标 ----------------
|
||||
@@ -136,6 +257,7 @@ const MOUSE_FLAGS: Record<string, { down: number; up: number }> = {
|
||||
};
|
||||
|
||||
export async function mouseMove(args: Args): Promise<unknown> {
|
||||
assertGuiPlatform('input.mouseMove');
|
||||
assertAllowed('input.mouse.move');
|
||||
requireWindows('input.mouse.move');
|
||||
const x = args['x'] as number;
|
||||
@@ -163,6 +285,7 @@ export async function mouseMove(args: Args): Promise<unknown> {
|
||||
}
|
||||
|
||||
export async function mouseClick(args: Args): Promise<unknown> {
|
||||
assertGuiPlatform('input.mouseClick');
|
||||
assertAllowed('input.mouse.click');
|
||||
requireWindows('input.mouse.click');
|
||||
const button = (args['button'] as string) ?? 'left';
|
||||
@@ -182,9 +305,11 @@ export async function mouseClick(args: Args): Promise<unknown> {
|
||||
}
|
||||
await runPs(body);
|
||||
|
||||
// 返回实际点击位置
|
||||
// 返回实际点击位置。
|
||||
// ⚠️ 读回光标位置同样要 DPI 感知,否则这里读到的是**逻辑**值(200% 下是真实值的一半),
|
||||
// 于是「主叫方传物理坐标 → 返回被腰斩的坐标」,看起来像点错位置。真机 2026-10-12 排查。
|
||||
const pos = await execCommand({
|
||||
command: `Add-Type -AssemblyName System.Windows.Forms; $p = [System.Windows.Forms.Cursor]::Position; "{0},{1}" -f $p.X, $p.Y`,
|
||||
command: `${PS_DPI_AWARE}; Add-Type -AssemblyName System.Windows.Forms; $p = [System.Windows.Forms.Cursor]::Position; "{0},{1}" -f $p.X, $p.Y`,
|
||||
timeoutMs: 20_000,
|
||||
});
|
||||
const [cx, cy] = pos.stdout.trim().split(',').map(Number);
|
||||
@@ -192,6 +317,7 @@ export async function mouseClick(args: Args): Promise<unknown> {
|
||||
}
|
||||
|
||||
export async function mouseScroll(args: Args): Promise<unknown> {
|
||||
assertGuiPlatform('input.mouseScroll');
|
||||
assertAllowed('input.mouse.scroll');
|
||||
requireWindows('input.mouse.scroll');
|
||||
const delta = args['delta'] as number;
|
||||
@@ -211,6 +337,7 @@ export async function mouseScroll(args: Args): Promise<unknown> {
|
||||
* 用于拖文件、框选、拖动滑块/窗口。整段动作在一次进程内完成(避免多次 spawn 抖动)。
|
||||
*/
|
||||
export async function mouseDrag(args: Args): Promise<unknown> {
|
||||
assertGuiPlatform('input.mouseDrag');
|
||||
assertAllowed('input.mouse.drag');
|
||||
requireWindows('input.mouse.drag');
|
||||
const x1 = args['from_x'] as number;
|
||||
@@ -237,29 +364,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 +372,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,411 @@
|
||||
/**
|
||||
* 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, execFile } from 'node:child_process';
|
||||
import { join, resolve as resolvePath, sep } 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验监控 id:**唯一入口**,四个命令(start/report/stop/delete)都必须先过它。
|
||||
*
|
||||
* ⚠️ M-01/M-02(真机实测):`monitor.report` / `monitor.delete` 曾经**不校验 id**
|
||||
* 就直接 `join(monitorDir(), id + '.jsonl')` → `id:"../escape-target"` 可读/删
|
||||
* 数据目录外的任意 `.jsonl` 文件(路径穿越)。
|
||||
* 这里除了字符白名单,还做一次**解析后前缀比对**(`..` 与符号链接都拦得住)。
|
||||
*/
|
||||
function safeMonitorId(raw: unknown): string {
|
||||
const id = typeof raw === 'string' ? raw.trim() : '';
|
||||
if (!id) throw new CapabilityError(ErrorCodes.PARAM_INVALID, '需要 id');
|
||||
if (!/^[\w.-]{1,64}$/.test(id) || id.includes('..')) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'id 只允许字母数字与 . _ -(≤64 字符,不含 ..)', {
|
||||
got: id,
|
||||
});
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/** 监控样本文件路径 —— id 已在 safeMonitorId 校验过,这里再兜一次「必须在 monitors 目录内」。 */
|
||||
function monitorFile(id: string): string {
|
||||
const dir = resolvePath(monitorDir());
|
||||
const file = resolvePath(dir, `${id}.jsonl`);
|
||||
if (!file.startsWith(dir + sep)) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'id 解析后越出 monitors 目录', { id });
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
// ---------------- 采样器 ----------------
|
||||
|
||||
/** 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) => {
|
||||
// ⚠️ D-02(真机实测):原来把 `name` 拼进 `tasklist /FI "IMAGENAME eq ${name}"` 与
|
||||
// `ps … | grep -i "${name}"`,payload `zzz"; touch /tmp/m…; #` 会被 shell 执行。
|
||||
// 正解:**不经过 shell** —— Windows 用 execFile 传参数数组,POSIX 直接把 ps 输出
|
||||
// 交给 JS 过滤(不再 grep)。
|
||||
const collect = (stdout: string, err: Error | null): void => {
|
||||
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) } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
// 无 shell:name 作为单个参数传入,`"` `;` `&` 等都不再具特殊含义
|
||||
execFile('tasklist', ['/FI', `IMAGENAME eq ${name}`, '/NH'], { timeout: 8000 }, (err, stdout) =>
|
||||
collect(stdout ?? '', err),
|
||||
);
|
||||
return;
|
||||
}
|
||||
execFile('ps', ['-axo', 'pid=,comm='], { timeout: 8000 }, (err, stdout) => {
|
||||
const needle = name.toLowerCase();
|
||||
const filtered = (stdout ?? '')
|
||||
.split('\n')
|
||||
.filter((l) => l.toLowerCase().includes(needle))
|
||||
.join('\n');
|
||||
collect(filtered, err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** 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)}`;
|
||||
safeMonitorId(id); // 与 report/delete 同一套校验(防路径穿越 + 统一口径)
|
||||
if (monitors.has(id)) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `监控 id 已存在: ${id}`, { hint: '换个 id,或先 monitor.stop' });
|
||||
}
|
||||
|
||||
const file = monitorFile(id);
|
||||
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 = safeMonitorId(args['id']);
|
||||
const rec = monitors.get(id);
|
||||
const file = monitorFile(id);
|
||||
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 = safeMonitorId(args['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 = safeMonitorId(args['id']);
|
||||
if (monitors.has(id)) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, '该监控仍在运行,请先 stop', { hint: `monitor.stop {"id":"${id}"}` });
|
||||
}
|
||||
const file = monitorFile(id);
|
||||
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,748 @@
|
||||
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 { psLit as psLitShared, shLit } from '../util/quote.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 ${psLit(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 且不留日志(真机踩过)。
|
||||
* 需要字面量时一律走本函数;需要插值时用双引号但避免反斜杠。
|
||||
*
|
||||
* 注:实现已收敛到 `util/quote.ts`(单一真源),此处保留同名导出以兼容既有引用与测试。
|
||||
*/
|
||||
export const psLit = psLitShared;
|
||||
|
||||
/**
|
||||
* 生成「注册一次性计划任务」的 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 = ${psLit(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 = psLit(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 ${psLit(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) => psLit(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 ${psLit(snap.gateway[0])} -ErrorAction SilentlyContinue | Out-Null`,
|
||||
);
|
||||
}
|
||||
if (snap.dns.length > 0) {
|
||||
lines.push(
|
||||
` Set-DnsClientServerAddress -InterfaceAlias ${a} -ServerAddresses ${snap.dns.map((d) => psLit(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 ${psLit(iface)} -ErrorAction SilentlyContinue
|
||||
if (-not $c) { Write-Output '{}'; return }
|
||||
$o = [ordered]@{ interface = ${psLit(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 ${psLit(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 = psLit(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 = psLit(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 ${psLit(gateway)} -ErrorAction SilentlyContinue | Out-Null`,
|
||||
);
|
||||
}
|
||||
if (dns.length > 0) {
|
||||
lines.push(
|
||||
`Set-DnsClientServerAddress -InterfaceAlias ${a} -ServerAddresses ${dns.map((d) => psLit(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 = psLit(iface);
|
||||
const rb = psLit(rollbackScriptPath);
|
||||
const cond = expectIp
|
||||
? `$ok = @(Get-NetIPAddress -InterfaceAlias ${a} -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -eq ${psLit(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 ${shLit(rollbackPath)} && setsid nohup sh ${shLit(rollbackPath)} > ${shLit(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;
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
import { IS_WINDOWS, execCommand } from '../util/exec.js';
|
||||
import { PS_DPI_AWARE } from '../util/dpi.js';
|
||||
import { assertGuiPlatform } from '../util/platform.js';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
@@ -87,6 +89,8 @@ async function tryMuxMp4(dir: string, fps: number): Promise<string | null> {
|
||||
* —— 避免把大体积视频塞进单条 RPC 响应。
|
||||
*/
|
||||
export async function screenRecord(args: Args): Promise<unknown> {
|
||||
// 该能力仅在 Windows/macOS 有实现(Linux 端需 X11/Wayland 后端,尚未实现)
|
||||
assertGuiPlatform('screen.record');
|
||||
if (!IS_WINDOWS) {
|
||||
throw new CapabilityError(ErrorCodes.UNSUPPORTED_PLATFORM, 'screen.record 当前仅支持 Windows 被控端', {
|
||||
platform: process.platform,
|
||||
@@ -109,9 +113,10 @@ export async function screenRecord(args: Args): Promise<unknown> {
|
||||
let height = region?.height ?? 0;
|
||||
|
||||
if (!region) {
|
||||
// ⚠️ 这里必须先设 DPI 感知,`VirtualScreen` 才是**物理**尺寸(见 util/dpi.ts)。
|
||||
// 统一走 PS_DPI_AWARE,避免各能力各写一份 P/Invoke 壳而漂移。
|
||||
const probe = await execCommand({
|
||||
command:
|
||||
'Add-Type -AssemblyName System.Windows.Forms; Add-Type -TypeDefinition \'using System.Runtime.InteropServices; public class NADPIR { [DllImport("user32.dll")] public static extern bool SetProcessDPIAware(); }\'; [void][NADPIR]::SetProcessDPIAware(); $vs = [System.Windows.Forms.SystemInformation]::VirtualScreen; Write-Output ("{0},{1},{2},{3}" -f $vs.X, $vs.Y, $vs.Width, $vs.Height)',
|
||||
command: `${PS_DPI_AWARE}; Add-Type -AssemblyName System.Windows.Forms; $vs = [System.Windows.Forms.SystemInformation]::VirtualScreen; Write-Output ("{0},{1},{2},{3}" -f $vs.X, $vs.Y, $vs.Width, $vs.Height)`,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
const nums = probe.stdout.trim().split(',').map(Number);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
import { IS_WINDOWS, execCommand } from '../util/exec.js';
|
||||
import { shLit } from '../util/quote.js';
|
||||
import { PS_DPI_AWARE } from '../util/dpi.js';
|
||||
import { assertGuiPlatform } from '../util/platform.js';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
@@ -25,22 +28,37 @@ const MAX_CAPTURE_BYTES = 40 * 1024 * 1024;
|
||||
|
||||
// ---------------- screen.info ----------------
|
||||
|
||||
/**
|
||||
* listDisplays 下发给 PowerShell 的脚本。
|
||||
*
|
||||
* 导出**仅供单测断言**(检查 DPI 感知确实注入且位于坐标 API 之前)——
|
||||
* 真机 2026-10-12 的「缺省整屏只截到左上 1/4」就是这里漏了 DPI 感知,
|
||||
* 源码扫描式守卫抓不到(文件里别处也引用了 PS_DPI_AWARE)。
|
||||
*/
|
||||
export function buildListDisplaysScript(): string {
|
||||
return [
|
||||
// ⚠️ 必须最先执行:不设 DPI 感知时,`Screen.Bounds` 返回的是**逻辑**尺寸,
|
||||
// 而 `CopyFromScreen` 又按**物理**像素裁剪 → 缺省整屏截图只截到左上 1/4
|
||||
//(200% 缩放下 2560×1440 的桌面报成 1280×720)。真机 2026-10-12 实证。
|
||||
PS_DPI_AWARE,
|
||||
'Add-Type -AssemblyName System.Windows.Forms',
|
||||
'$s = [System.Windows.Forms.Screen]::AllScreens',
|
||||
// 读取 DPI 缩放(HKCU AppliedDPI;读不到则按 100% 处理)。
|
||||
// 注意:设了 DPI 感知后 Bounds 已是**物理**像素,scale 仅作参考信息保留。
|
||||
'$dpi = (Get-ItemProperty -Path "HKCU:\\Control Panel\\Desktop\\WindowMetrics" -Name AppliedDPI -ErrorAction SilentlyContinue).AppliedDPI',
|
||||
'$scale = if ($dpi) { [math]::Round($dpi / 96.0, 2) } else { 1.0 }',
|
||||
'$out = @()',
|
||||
'for ($i = 0; $i -lt $s.Count; $i++) {',
|
||||
' $d = $s[$i]',
|
||||
' $out += [pscustomobject]@{ id = $i; name = $d.DeviceName; width = $d.Bounds.Width; height = $d.Bounds.Height; is_primary = $d.Primary; scale = $scale; x = $d.Bounds.X; y = $d.Bounds.Y }',
|
||||
'}',
|
||||
'$out | ConvertTo-Json -Compress',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function listDisplays(): Promise<Display[]> {
|
||||
if (IS_WINDOWS) {
|
||||
const script = [
|
||||
'Add-Type -AssemblyName System.Windows.Forms',
|
||||
'$s = [System.Windows.Forms.Screen]::AllScreens',
|
||||
// 读取 DPI 缩放(HKCU AppliedDPI;读不到则按 100% 处理)
|
||||
'$dpi = (Get-ItemProperty -Path "HKCU:\\Control Panel\\Desktop\\WindowMetrics" -Name AppliedDPI -ErrorAction SilentlyContinue).AppliedDPI',
|
||||
'$scale = if ($dpi) { [math]::Round($dpi / 96.0, 2) } else { 1.0 }',
|
||||
'$out = @()',
|
||||
'for ($i = 0; $i -lt $s.Count; $i++) {',
|
||||
' $d = $s[$i]',
|
||||
' $out += [pscustomobject]@{ id = $i; name = $d.DeviceName; width = $d.Bounds.Width; height = $d.Bounds.Height; is_primary = $d.Primary; scale = $scale; x = $d.Bounds.X; y = $d.Bounds.Y }',
|
||||
'}',
|
||||
'$out | ConvertTo-Json -Compress',
|
||||
].join('\n');
|
||||
const r = await execCommand({ command: script, timeoutMs: 30_000 });
|
||||
const r = await execCommand({ command: buildListDisplaysScript(), timeoutMs: 30_000 });
|
||||
if (r.exit_code !== 0) {
|
||||
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '读取显示器信息失败', { detail: r.stderr.slice(0, 500) });
|
||||
}
|
||||
@@ -65,6 +83,8 @@ async function listDisplays(): Promise<Display[]> {
|
||||
}
|
||||
|
||||
export async function screenInfo(_args: Args): Promise<unknown> {
|
||||
// 该能力仅在 Windows/macOS 有实现(Linux 端需 X11/Wayland 后端,尚未实现)
|
||||
assertGuiPlatform('screen.info');
|
||||
const displays = await listDisplays();
|
||||
return {
|
||||
displays: displays.map(({ id, name, width, height, is_primary, scale }) => ({
|
||||
@@ -98,6 +118,8 @@ interface CaptureResult {
|
||||
}
|
||||
|
||||
export async function screenCapture(args: Args): Promise<unknown> {
|
||||
// 该能力仅在 Windows/macOS 有实现(Linux 端需 X11/Wayland 后端,尚未实现)
|
||||
assertGuiPlatform('screen.capture');
|
||||
const opts: CaptureOpts = {
|
||||
display_id: (args['display_id'] as number) ?? 0,
|
||||
format: (args['format'] as 'png' | 'jpeg') ?? 'jpeg',
|
||||
@@ -106,27 +128,61 @@ export async function screenCapture(args: Args): Promise<unknown> {
|
||||
region: args['region'] as CaptureOpts['region'],
|
||||
};
|
||||
|
||||
/**
|
||||
* ⚠️ S-01(v2.0.3):region 的合法性检查必须放在**枚举显示器之前**。
|
||||
*
|
||||
* 两道闸的由来:被控端会照 region 直接 `New-Object Bitmap $w,$h`(32bpp ≈ 4×w×h 字节),
|
||||
* 一个 100000×100000 的请求足以吃光内存(≈40GB)→ 进程被杀。
|
||||
* 收敛为「单边 ≤ 16384(常见位图/纹理上限)+ 总像素 ≤ 32M(≈128MB)」。
|
||||
*
|
||||
* 放在枚举之前有两个理由:① 校验不依赖显示器信息,没理由先付出枚举成本;
|
||||
* ② 真要挡住的就是这种请求 —— 让它在任何副作用之前就失败(fail fast)。
|
||||
*/
|
||||
if (opts.region) assertRegionSane(opts.region);
|
||||
|
||||
const displays = await listDisplays();
|
||||
const display = displays[opts.display_id] ?? displays[0];
|
||||
if (!display) {
|
||||
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, `显示器 ${opts.display_id} 不存在`);
|
||||
}
|
||||
|
||||
// region 缺省 = 整个目标屏;带上多屏偏移
|
||||
// region 缺省 = 整个目标屏;带上多屏偏移。
|
||||
// 缺省值也要过同一道闸:显示器自报异常尺寸时同样不能放行。
|
||||
const region = opts.region ?? { x: display.x, y: display.y, width: display.width, height: display.height };
|
||||
if (region.width < 1 || region.height < 1) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'region 宽高必须 ≥ 1', { region });
|
||||
}
|
||||
assertRegionSane(region);
|
||||
|
||||
if (IS_WINDOWS) return captureWindows(opts, region);
|
||||
return capturePosix(opts, region);
|
||||
}
|
||||
|
||||
async function captureWindows(
|
||||
opts: CaptureOpts,
|
||||
/** region 上界校验(S-01)。缺省值与显式传入走**同一份**判据,避免两处漂移。 */
|
||||
function assertRegionSane(region: NonNullable<CaptureOpts['region']>): void {
|
||||
if (region.width < 1 || region.height < 1) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'region 宽高必须 ≥ 1', { region });
|
||||
}
|
||||
const MAX_EDGE = 16384;
|
||||
const MAX_PIXELS = 33_554_432;
|
||||
if (region.width > MAX_EDGE || region.height > MAX_EDGE || region.width * region.height > MAX_PIXELS) {
|
||||
throw new CapabilityError(
|
||||
ErrorCodes.PARAM_INVALID,
|
||||
`region 过大:单边须 ≤ ${MAX_EDGE} 且总像素 ≤ ${MAX_PIXELS}(当前 ${region.width}×${region.height})`,
|
||||
{ region, max_edge: MAX_EDGE, max_pixels: MAX_PIXELS },
|
||||
);
|
||||
}
|
||||
if (![region.x, region.y, region.width, region.height].every((n) => Number.isFinite(n))) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'region 必须都是有限数字', { region });
|
||||
}
|
||||
}
|
||||
|
||||
/** captureWindows 下发给 PowerShell 的脚本(导出**仅供单测断言**,同 buildListDisplaysScript)。 */
|
||||
export function buildCaptureScript(
|
||||
region: { x: number; y: number; width: number; height: number },
|
||||
): Promise<CaptureResult> {
|
||||
const script = [
|
||||
opts: { scale: number; format: string; quality: number },
|
||||
): string {
|
||||
return [
|
||||
// ⚠️ 与 listDisplays 同理:region 是物理像素,进程也必须是 DPI 感知,
|
||||
// 否则「region 语义(物理)」与「进程坐标观(逻辑)」两套口径并存。
|
||||
PS_DPI_AWARE,
|
||||
"$ErrorActionPreference = 'Stop'",
|
||||
'Add-Type -AssemblyName System.Drawing',
|
||||
`$x = ${region.x}; $y = ${region.y}; $w = ${region.width}; $h = ${region.height}`,
|
||||
@@ -157,8 +213,17 @@ async function captureWindows(
|
||||
'$ms.Dispose(); $bmp.Dispose()',
|
||||
'Write-Output ("{0}|{1}|{2}" -f [Convert]::ToBase64String($bytes), $bmpW, $bmpH)',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const r = await execCommand({ command: script, timeoutMs: 60_000, maxOutputBytes: MAX_CAPTURE_BYTES });
|
||||
async function captureWindows(
|
||||
opts: CaptureOpts,
|
||||
region: { x: number; y: number; width: number; height: number },
|
||||
): Promise<CaptureResult> {
|
||||
const r = await execCommand({
|
||||
command: buildCaptureScript(region, opts),
|
||||
timeoutMs: 60_000,
|
||||
maxOutputBytes: MAX_CAPTURE_BYTES,
|
||||
});
|
||||
if (r.exit_code !== 0) {
|
||||
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '截屏失败', { detail: r.stderr.slice(0, 500) });
|
||||
}
|
||||
@@ -197,7 +262,7 @@ async function capturePosix(
|
||||
|
||||
// macOS 用 screencapture;区域用 -R x,y,w,h
|
||||
const cmd =
|
||||
`screencapture -x -t ${ext} -R ${region.x},${region.y},${region.width},${region.height} ${JSON.stringify(file)}`;
|
||||
`screencapture -x -t ${ext} -R ${region.x},${region.y},${region.width},${region.height} ${shLit(file)}`;
|
||||
const r = await execCommand({ command: cmd, timeoutMs: 30_000 });
|
||||
if (r.exit_code !== 0) {
|
||||
throw new CapabilityError(ErrorCodes.UNSUPPORTED_PLATFORM, '当前平台截屏不可用(需要 macOS 屏幕录制权限)', {
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
copyFileSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
renameSync,
|
||||
statSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { join, dirname, basename } 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';
|
||||
import { httpGet, resolveProxy } from '../net/index.js';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* 拉取式自更新(v19):被控端**自己**从 URL 下载新版本并替换自身。
|
||||
*
|
||||
* ## 与 `nodeagent deploy`(推送式)的分工
|
||||
* - 推送式:控制端与被控端可达 → 逐块上传(简单、无需被控端能上网)
|
||||
* - 拉取式(本能力):两者不可达,但被控端能上网 → 从 GitHub Release / 自建镜像拉取
|
||||
* 典型场景:被控端在老家、控制端在出差地;或跨网段/穿 NAT 失败。
|
||||
*
|
||||
* ## 安全(必须严格遵守,否则等于开放远程代码执行)
|
||||
* 1. **哈希必须钉死**:`sha256` 必填,且**逐字节校验下载内容**后才替换。
|
||||
* 没有它,GitHub 账号被盗 / DNS 被污染 / 中间人 = 直接拿下被控端。
|
||||
* 2. 只接受 http/https;拒绝 file:// 等其它 scheme。
|
||||
* 3. 大小上限(默认 64MB,content-length 预检 + 流式中断双闸)+ 下载超时。
|
||||
* 4. **先备份再替换**(同目录备份;Windows 上就地覆写,不用 rename —— 真机踩过),
|
||||
* 失败可回滚;写后校验不一致自动回滚。
|
||||
* 5. `dry_run` 只下载校验、不动现有文件 —— 用于「先确认能不能更新」。
|
||||
* 6. 动作全程进审计(能力调用本身已记录 `invoke`,这里再加一条 `agent.update`)。
|
||||
*
|
||||
* ## 出网:经 `net/proxy`(v2.1.0,已批准的方案 A)
|
||||
* 历史立场(v19~v2.0.3)是「改用镜像地址,而不是在 agent 里塞代理隧道」——
|
||||
* 真机教训证明该立场不成立:境内无透明代理的机器上,**即使配好了代理**,
|
||||
* Node 内置 fetch 也不读代理环境变量、更读不到 Windows/macOS 系统代理,
|
||||
* 「自更新」必然失败(与控制台 sidecar 2026-10-13 的同一缺陷)。
|
||||
* 故 v2.1.0 起复用 sidecar 已验证的出网方案(`apps/console/sidecar/proxy.mjs`
|
||||
* 的 TS 移植,见 `net/proxy.ts`):
|
||||
* `NA_PROXY` → 目标协议环境变量 → 系统代理(reg query / scutil);
|
||||
* PAC / socks **明确报错不静默降级**;整个重定向链共享一个超时预算。
|
||||
*/
|
||||
|
||||
const MAX_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
/** 出网诊断信息(返回结构与 CapabilityError.data 共用,保证成功/失败口径一致)。 */
|
||||
interface OutboundInfo {
|
||||
via: string;
|
||||
proxy: string | null;
|
||||
proxy_source: string | null;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
/** 组一条可操作的失败线索:走了哪条路、代理从哪来、为什么不走代理。 */
|
||||
function outboundHint(o: OutboundInfo, timeoutMs: number): Record<string, unknown> {
|
||||
return {
|
||||
via: o.via,
|
||||
proxy: o.proxy,
|
||||
proxy_source: o.proxy_source,
|
||||
timeout_ms: timeoutMs,
|
||||
notes: o.notes,
|
||||
hint: o.proxy
|
||||
? '代理已生效但仍失败:请确认代理本身能访问更新源(浏览器里试同一个地址)'
|
||||
: '未使用代理。若本机需要代理才能访问更新源,请在系统里开启「系统代理」,或设置环境变量 NA_PROXY=http://127.0.0.1:<端口> 后重启被控端',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为「主动中止」类错误 —— **只认真正的 abort**。
|
||||
*
|
||||
* ⚠️ 别把 `ECONNRESET` 之类也塞进来(sidecar 第一版就这么写的,真机上立刻现形):
|
||||
* 那次耗时 82.9s、预算 120s,连接是被对端**中途重置**的,却被报成
|
||||
* 「请求超时(120000ms 内未完成)」—— 一句话把用户引到完全错误的方向上。
|
||||
* 此处按 sidecar `main.mjs:146-151` **同款移植**,不许重新发明。
|
||||
*/
|
||||
export function isAbortLike(err: unknown): boolean {
|
||||
const name = (err as { name?: string } | null)?.name ?? '';
|
||||
const code = (err as { code?: string } | null)?.code ?? '';
|
||||
const msg = String((err as { message?: unknown } | null)?.message ?? '');
|
||||
return name === 'AbortError' || code === 'ABORT_ERR' || /\babort(ed)?\b/i.test(msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把底层网络错误翻译成「用户能据以行动」的一句话。
|
||||
*
|
||||
* 按 sidecar `main.mjs:154-165` **同款移植**(含真机教训:ECONNRESET 不是超时)。
|
||||
*/
|
||||
export function describeNetError(err: unknown, timeoutMs: number): string {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const code = (err as { code?: string } | null)?.code ?? '';
|
||||
if (isAbortLike(err)) return `请求超时(${timeoutMs}ms 内未完成)`;
|
||||
if (code === 'ECONNRESET' || code === 'EPIPE') {
|
||||
return `${msg}(连接被对端中途断开;若走了代理,多半是代理或线路的问题)`;
|
||||
}
|
||||
if (code === 'ENOTFOUND' || code === 'EAI_AGAIN' || code === 'ECONNREFUSED') {
|
||||
return `${msg}(域名解析失败或目标拒绝连接;若配了代理,请核对代理地址与端口)`;
|
||||
}
|
||||
// 2026-10-27 真机补:境内直连 GitHub 的**最常见**失败形态就是 TCP 层 ETIMEDOUT
|
||||
// (不是我们自己的 timeoutMs,也不是 ECONNRESET)。原先它落到下面兜底分支,
|
||||
// 用户只看到一串 IP:443 超时,不知道该干什么 —— 而这时唯一有效的动作就是配代理。
|
||||
if (code === 'ETIMEDOUT' || code === 'EHOSTUNREACH' || code === 'ENETUNREACH') {
|
||||
return `${msg}(连接超时/网络不可达;若目标在境外或需要代理,请设置 NA_PROXY=<代理地址> 或配置系统代理后重试)`;
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
/** 决议代理;任何异常都退化成「直连」—— 探测本身绝不该成为新的失败点。 */
|
||||
async function resolveProxySafe(targetUrl: string): Promise<{ proxy: URL | null; info: OutboundInfo }> {
|
||||
try {
|
||||
const r = await resolveProxy({ targetUrl });
|
||||
return {
|
||||
proxy: r.proxy,
|
||||
info: {
|
||||
via: r.proxy ? `经代理 ${r.proxy.href}` : '直连',
|
||||
proxy: r.proxy ? r.proxy.href : null,
|
||||
proxy_source: r.source,
|
||||
notes: r.notes,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
proxy: null,
|
||||
info: {
|
||||
via: '直连',
|
||||
proxy: null,
|
||||
proxy_source: null,
|
||||
notes: [`代理探测异常(${err instanceof Error ? err.message : String(err)}),按直连处理`],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载到内存(带大小上限与超时;经代理出网)。
|
||||
*
|
||||
* 超时语义:`timeoutMs` 是**整条重定向链的总预算**(与 sidecar 一致:链共享一个
|
||||
* AbortController,防 5 跳 × 30s 穿过 RPC 超时)。
|
||||
*
|
||||
* @returns 下载内容 + 出网诊断(成功/失败都带,保证「代理没配 / 哈希不符 / 超时」三类可分)
|
||||
*/
|
||||
async function download(
|
||||
url: string,
|
||||
timeoutMs: number,
|
||||
): Promise<{ buf: Buffer; outbound: OutboundInfo }> {
|
||||
const { proxy, info } = await resolveProxySafe(url);
|
||||
let res;
|
||||
try {
|
||||
res = await httpGet(url, { proxy, timeoutMs, maxBytes: MAX_BYTES });
|
||||
} catch (err) {
|
||||
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, `下载失败: ${describeNetError(err, timeoutMs)}`, {
|
||||
url,
|
||||
...outboundHint(info, timeoutMs),
|
||||
});
|
||||
}
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
throw new CapabilityError(
|
||||
ErrorCodes.EXECUTION_FAILED,
|
||||
`下载失败: HTTP ${res.status} ${res.statusText}`,
|
||||
{ url, ...outboundHint(info, timeoutMs) },
|
||||
);
|
||||
}
|
||||
const declared = Number(res.headers['content-length'] ?? '0');
|
||||
if (declared > MAX_BYTES) {
|
||||
throw new CapabilityError(
|
||||
ErrorCodes.PARAM_INVALID,
|
||||
`文件过大(${declared} 字节 > 上限 ${MAX_BYTES})`,
|
||||
{ url, ...outboundHint(info, timeoutMs) },
|
||||
);
|
||||
}
|
||||
if (res.body.length > MAX_BYTES) {
|
||||
throw new CapabilityError(
|
||||
ErrorCodes.PARAM_INVALID,
|
||||
`下载超过上限 ${MAX_BYTES} 字节,已中断`,
|
||||
{ url, ...outboundHint(info, timeoutMs) },
|
||||
);
|
||||
}
|
||||
return { buf: res.body, outbound: info };
|
||||
}
|
||||
|
||||
/**
|
||||
* 备份清理计划(纯函数,供单测):
|
||||
* 按 mtime **新→旧**排序,保留最近 `keep` 个,返回**应删除**的名单。
|
||||
*
|
||||
* 为什么需要:selfupdate 的备份是 `<script>.bak-<ts>`、永不清理 ——
|
||||
* 推送式 deploy 踩过的坑(真机堆了 15+ 个 bak)在拉取式路径复发。
|
||||
* 保留最近 3 个:再往前的版本可由更早的备份或重新发布获得,磁盘不无限堆积。
|
||||
*/
|
||||
export function planBackupCleanup(files: { name: string; mtime_ms: number }[], keep = 3): string[] {
|
||||
const sorted = [...files].sort((a, b) => b.mtime_ms - a.mtime_ms);
|
||||
// 反过来给「最旧先删」:万一删到一半失败,已删掉的也是价值最低的那些
|
||||
return sorted.slice(Math.max(0, keep)).reverse().map((f) => f.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理入口同目录的旧 selfupdate 备份(保留最近 3 个)。
|
||||
*
|
||||
* 删除失败一律忽略(被杀软占位的 .bak 删不掉不影响正确性)。
|
||||
* @returns 实际删除的文件名(供审计/返回值观察)
|
||||
*/
|
||||
function cleanupOldBackups(scriptPath: string, keep = 3): string[] {
|
||||
try {
|
||||
const dir = dirname(scriptPath);
|
||||
const prefix = `${basename(scriptPath)}.bak-`;
|
||||
const files: { name: string; mtime_ms: number }[] = [];
|
||||
for (const name of readdirSync(dir)) {
|
||||
if (!name.startsWith(prefix)) continue;
|
||||
try {
|
||||
files.push({ name, mtime_ms: statSync(join(dir, name)).mtimeMs });
|
||||
} catch {
|
||||
/* 文件可能已消失,跳过 */
|
||||
}
|
||||
}
|
||||
const removed: string[] = [];
|
||||
for (const name of planBackupCleanup(files, keep)) {
|
||||
try {
|
||||
unlinkSync(join(dir, name));
|
||||
removed.push(name);
|
||||
} catch {
|
||||
/* 删不掉不影响正确性 */
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
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, outbound } = 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,
|
||||
...outboundHint(outbound, timeoutMs),
|
||||
});
|
||||
}
|
||||
|
||||
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)} via=${outbound.via}` });
|
||||
return {
|
||||
dry_run: true,
|
||||
verified: true,
|
||||
bytes: buf.length,
|
||||
incoming_hash: got.slice(0, 12),
|
||||
current_hash: prevHash,
|
||||
current_bytes: prevBytes,
|
||||
note: '仅下载并校验,未改动任何文件',
|
||||
via: outbound.via,
|
||||
proxy: outbound.proxy,
|
||||
proxy_source: outbound.proxy_source,
|
||||
outbound_notes: outbound.notes,
|
||||
};
|
||||
}
|
||||
|
||||
// ③ 备份 + ④ 替换(同目录备份;就地覆写不用 rename —— Windows 不允许替换
|
||||
// 正被运行进程打开的文件,真机踩过: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 {
|
||||
/* 清理失败不影响正确性 */
|
||||
}
|
||||
|
||||
// ⑤ 备份清理:保留最近 3 个,其余删(A-P0-3:selfupdate 路径此前永不清理)
|
||||
const removedBackups = cleanupOldBackups(scriptPath, 3);
|
||||
|
||||
audit({
|
||||
type: 'agent.update',
|
||||
status: 'ok',
|
||||
reason:
|
||||
`url=${url} ${prevHash}->${got.slice(0, 12)} bytes=${buf.length} backup=${backup} via=${outbound.via}` +
|
||||
(removedBackups.length > 0 ? ` cleaned=${removedBackups.length}` : ''),
|
||||
});
|
||||
|
||||
// ⑥ 受控重启(复用 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',
|
||||
// v2.1.0 新增(只增字段):出网诊断
|
||||
via: outbound.via,
|
||||
proxy: outbound.proxy,
|
||||
proxy_source: outbound.proxy_source,
|
||||
outbound_notes: outbound.notes,
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,18 @@
|
||||
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 { lkgStatus } from '../lkg.js';
|
||||
import { startTask } from './task.js';
|
||||
import { psLit } from '../util/quote.js';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
@@ -42,6 +51,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 +144,18 @@ 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(),
|
||||
},
|
||||
// 交付流水线 §6:最后已知可用版本(回滚锚点)。
|
||||
// matches=false 表示「当前版本还没被证明能启动」—— 刚更新完、尚未完成一次成功
|
||||
// 启动时会是这种情况,控制端应据此暂缓向其余设备放量。
|
||||
lkg: lkgStatus(),
|
||||
};
|
||||
|
||||
if (fields && fields.length > 0) {
|
||||
@@ -436,15 +533,38 @@ 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`,
|
||||
`$a = New-ScheduledTaskAction -Execute 'cmd.exe' -Argument '${cmdLine.replace(/'/g, "''")}'`,
|
||||
// 清理历史 selfrestart 任务。
|
||||
//
|
||||
// ⚠️ 为什么不能简单清所有非 Running 的:重启任务的 cmd 会**一直持有新 agent 进程**,
|
||||
// 所以任务长期处于 Running;若把 Running 的全清掉,会连当前 agent 的宿主一起杀
|
||||
// (= 自杀,表现为"restart 成功但 agent 消失")。原实现只清非 Running,
|
||||
// 代价是真机累积了 3 个 Running 僵尸(2026-10-09 实测)。
|
||||
//
|
||||
// 现策略:非 Running **或** LastRunTime **早于"本 agent 启动时刻"** → 清。
|
||||
//
|
||||
// ⚠️ 2026-10-11 真机(虚拟化平台上的 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`,
|
||||
// ⚠️ 不要手写 `replace(/'/g,"''")` —— 那是「又发明一份 psLit」,两份实现必然漂移。
|
||||
// PS 里 `\` 不是转义符、双引号串还会插值,故 cmdLine 唯一安全的落法就是单引号字面量。
|
||||
`$a = New-ScheduledTaskAction -Execute 'cmd.exe' -Argument ${psLit(cmdLine)}`,
|
||||
// 不限时(默认 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 +629,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,39 @@
|
||||
/**
|
||||
* OCR 引擎预加载段:截图 / DPI / WinRT 类型与 Await 辅助函数
|
||||
*
|
||||
* 2026-10 从 window.ts 抽出(结构拆分,**内容逐字节不变**)。
|
||||
* ⚠️ WIN_HELPER_PRELUDE 是本段与另外几段的拼接 —— 改动会直接影响常驻 PS 助手。
|
||||
* 拆分以 tests/unit/win-prelude.test.mjs + prelude sha256 比对双重把关。
|
||||
*/
|
||||
|
||||
/**
|
||||
* OCR 引擎预加载段(v14):截图 / WinRT 类型与 Await 辅助函数。
|
||||
* 注意:WinRT 类型加载用 try/catch 包住 —— 个别环境缺组件时只应让 OCR 退化,
|
||||
* 不该让整个助手(连带 window.list/focus/UIA)起不来。
|
||||
*
|
||||
* ⚠️ DPI 感知(`SetProcessDPIAware`)**已从本段移出**,改由 WIN_HELPER_PRELUDE 最前面的
|
||||
* `PS_DPI_AWARE` 无条件执行:放在这个 try 里会被 catch 顺带吞掉,一旦吞掉
|
||||
* 助手就退回 DPI 不感知,`GetWindowRect` 与 UIA / 截图坐标口径不一致(真机 2026-10-12 排查)。
|
||||
*/
|
||||
export const OCR_PRELUDE = `
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
try {
|
||||
Add-Type -AssemblyName System.Runtime.WindowsRuntime
|
||||
$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,18 @@
|
||||
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
import { IS_WINDOWS, execCommand } from '../util/exec.js';
|
||||
import { runPowerShellSmart } from '../util/ps-helper.js';
|
||||
import { psLit } from '../util/quote.js';
|
||||
import { agentDir } from '../config.js';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { macScreenFind, macWindowFocus, macWindowList } from './darwin.js';
|
||||
|
||||
import { PS_DPI_AWARE } from '../util/dpi.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 +29,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 +88,53 @@ 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
|
||||
`;
|
||||
|
||||
/**
|
||||
* 常驻助手的预加载段。
|
||||
*
|
||||
* ⚠️ `PS_DPI_AWARE` **必须排在最前**,且不能放进 `OCR_PRELUDE` 的 try/catch 里 ——
|
||||
* 那段 catch 是为了「缺 WinRT 组件时只让 OCR 退化」,但它同时也会把
|
||||
* `SetProcessDPIAware` 一起吞掉;一旦吞掉,助手退回 DPI 不感知,
|
||||
* `GetWindowRect`(window.list)就与 UIA / 截图的口径不一致了。
|
||||
* DPI 感知是**坐标口径前提**,不是可退化项。
|
||||
*/
|
||||
export const WIN_HELPER_PRELUDE = `${PS_DPI_AWARE}
|
||||
${WIN32_PRELUDE}
|
||||
${UIA_ASSEMBLY_PRELUDE}
|
||||
${OCR_PRELUDE}
|
||||
${IMAGE_MATCH_PRELUDE}`;
|
||||
|
||||
interface WinInfo {
|
||||
hwnd: string;
|
||||
@@ -119,13 +153,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 +172,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,13 +193,43 @@ 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。
|
||||
*
|
||||
* ⚠️⚠️ `title` / `hwnd` 来自控制端,**必须**用 `psLit` 包成 PS 单引号字面量。
|
||||
* 曾用 `JSON.stringify` → 双引号串 + `\"` 被 PS 解析成「字面反斜杠 + 结束引号」
|
||||
* → 任意命令执行(P0 W-01,真机实测「返回正常业务错误但命令已执行」)。
|
||||
*/
|
||||
function focusBody(title: string | undefined, hwnd: string | undefined): string {
|
||||
return `
|
||||
$h = [IntPtr]::Zero
|
||||
$want = ${JSON.stringify(hwnd ?? '')}
|
||||
$want = ${psLit(hwnd ?? '')}
|
||||
if ($want -ne '') { $h = [IntPtr][Convert]::ToInt64($want, 16) }
|
||||
else {
|
||||
$re = ${JSON.stringify(title ?? '')}
|
||||
$re = ${psLit(title ?? '')}
|
||||
$fg = [NAWin32]::GetForegroundWindow()
|
||||
$found = [IntPtr]::Zero
|
||||
$cb = [NAWin32+EnumProc]{
|
||||
@@ -176,10 +245,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 +275,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 +293,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 +306,198 @@ 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}
|
||||
$text = ${JSON.stringify(text)}
|
||||
// ⚠️ 全部来自控制端,一律 psLit(详见文件顶部 windowFocus 处的 P0 说明)
|
||||
const body = `
|
||||
$text = ${psLit(text)}
|
||||
$limit = ${Number(limit)}
|
||||
$scope = [System.Windows.Automation.AutomationElement]::RootElement
|
||||
$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 }
|
||||
$ct = ${psLit(controlType ?? '')}
|
||||
$winTitle = ${psLit(windowTitle ?? '')}
|
||||
# v2.0.0 属性过滤:where 只含 enabled/selected/value/toggle 四类键(白名单式)。
|
||||
#
|
||||
# ⚠️⚠️ 这里踩过**两次**同一个坑,真机 2026-10-09 连修两轮才通:
|
||||
# ✗ 第一版:$where = {"enabled":true} → PS 不认 JSON 字面量,ParseError
|
||||
# ✗ 第二版:$whereJson = "{\"enabled\":true}" → PS 5.1 **不认反斜杠转义**
|
||||
# (PS 用反引号),依然 ParseError,整份助手脚本失败
|
||||
# (症状:带 where 一律"窗口操作失败",不带 where 完全正常)
|
||||
# ✓ 第三版:**PS 单引号字符串字面量**,JSON 内的单引号翻倍成 ''(本行)
|
||||
# 并在真机现场验证过:$j = '{"enabled":true}' → ConvertFrom-Json 摊平成哈希表 → OK。
|
||||
# 摊平是因为 Test-UiaWhere 依赖 .Keys 遍历,而 PSCustomObject 没有 Keys。
|
||||
$whereJson = ${psLit(whereArg ? JSON.stringify(whereArg) : '')}
|
||||
$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 双引号会做变量插值且把 \ 当字面两个反斜杠。
|
||||
// (统一走 util/quote.ts 的 psLit,避免各文件各写一份实现而漏改)
|
||||
const q = psLit;
|
||||
|
||||
// 截图区域:显式 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 = ${psLit(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 +528,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,14 +548,14 @@ $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)}
|
||||
$text = ${psLit(text)}
|
||||
$limit = ${Number(limit)}
|
||||
# Windows OCR 会在中文字符间插入空格(如「我的加速」识别为「我 的 加 速」),
|
||||
# 因此匹配一律在「去除空白后」的文本上进行(两边同样处理)。
|
||||
$textPlain = ${JSON.stringify(text)}.Replace(' ','')
|
||||
$textPlain = (${psLit(text)}).Replace(' ','')
|
||||
if ($textPlain -eq '') { $textPlain = ($text -replace '\\s','') }
|
||||
$out = New-Object System.Collections.ArrayList
|
||||
foreach ($line in $result.Lines) {
|
||||
@@ -453,21 +622,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 {
|
||||
@@ -12,7 +13,7 @@ export interface TlsMaterial {
|
||||
* 确保自签证书存在(首次生成并缓存到 ~/.nodeagent/certs/)。
|
||||
* v1 用自签证书做传输加密;控制端可校验证书指纹或临时跳过校验。
|
||||
*/
|
||||
export function ensureCert(): TlsMaterial {
|
||||
export async function ensureCert(): Promise<TlsMaterial> {
|
||||
const dir = join(agentDir(), 'certs');
|
||||
const certPath = join(dir, 'cert.pem');
|
||||
const keyPath = join(dir, 'key.pem');
|
||||
@@ -22,8 +23,16 @@ export function ensureCert(): TlsMaterial {
|
||||
}
|
||||
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const pems = selfsigned.generate([{ name: 'commonName', value: 'nodeagent' }], {
|
||||
days: 3650,
|
||||
// selfsigned 5.x:`generate` 改为**异步**(旧版同步返回)。
|
||||
// 升级动因不是追新,而是安全:2.x 依赖 node-forge 1.4.0,
|
||||
// 该版本有 high 级 advisory 且**上游无修复版本**(patched_versions: None);
|
||||
// 5.x 已整体换成 @peculiar/x509 + pkijs,node-forge 被彻底移除。
|
||||
// v5 把 `days` 换成了 `notAfterDate`(绝对时间),语义相同但必须换算 ——
|
||||
// 这是升级时的**静默不兼容点**:老写法编译期报 TS2353,能拦住;
|
||||
// 但若只改类型不改值,就会签出一个有效期不对的证书,运行期才暴露。
|
||||
const notAfterDate = new Date(Date.now() + 3650 * 24 * 60 * 60 * 1000);
|
||||
const pems = await selfsigned.generate([{ name: 'commonName', value: 'nodeagent' }], {
|
||||
notAfterDate,
|
||||
keySize: 2048,
|
||||
algorithm: 'sha256',
|
||||
});
|
||||
@@ -31,3 +40,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,326 @@
|
||||
import { watch, type FSWatcher } from 'node:fs';
|
||||
import { exec } from 'node:child_process';
|
||||
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
// ⚠️ E-03:file watcher 必须与 fs.* 用**同一套**路径白名单守卫(否则 fs_roots 形同虚设)
|
||||
import { guardPath } from './capabilities/fs.js';
|
||||
|
||||
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; // 连接标识,断开时清理
|
||||
/** 创建者的 client_id —— 跨控制端隔离的依据(E-01/E-02) */
|
||||
clientId: 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;
|
||||
/** 调用方 client_id(跨控制端隔离;缺省视为匿名,彼此可见) */
|
||||
clientId?: string;
|
||||
/** 该连接的推送出口 */
|
||||
emit: (evt: WatchEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 该 watcher 是否属于 caller。
|
||||
*
|
||||
* 兼容性:`clientId` 为空(匿名 / 老客户端)时**不做隔离** —— 但只要有 client_id,
|
||||
* 隔离就必须生效:否则 B 控制端能看到甚至 `event.unwatch` 掉 A 的订阅(E-01/E-02 实测)。
|
||||
*/
|
||||
function ownedBy(rec: WatchRecord, callerId: string): boolean {
|
||||
if (!callerId) return true;
|
||||
return !rec.clientId || rec.clientId === callerId;
|
||||
}
|
||||
|
||||
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,
|
||||
clientId: ctx.clientId ?? '',
|
||||
description: '',
|
||||
createdAt: Date.now(),
|
||||
count: 0,
|
||||
emit: ctx.emit,
|
||||
dispose: () => undefined,
|
||||
};
|
||||
|
||||
if (kind === 'file') {
|
||||
const raw = args['path'] as string | undefined;
|
||||
if (!raw) throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'file 类型需要 path');
|
||||
// ⚠️ E-03(实测复现):file watcher **必须**过 `guardPath`。
|
||||
// 原来 `fs.read` 会被 `fs_roots` 正确拦下,而 `event.watch` 用同一路径却畅通无阻
|
||||
// —— 白名单形同虚设(可通过事件流观察白名单外的目录变动)。
|
||||
const path = guardPath(raw);
|
||||
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 };
|
||||
}
|
||||
|
||||
/** 取消订阅。**只有创建者(同 client_id)能取消** —— 否则任意控制端可掐掉别人的监控。 */
|
||||
export async function eventUnwatch(args: Args, ctx?: WatchContext): Promise<unknown> {
|
||||
const id = args['watch_id'] as string;
|
||||
const rec = watches.get(id);
|
||||
if (!rec) return { removed: false };
|
||||
const callerId = ctx?.clientId ?? '';
|
||||
if (!ownedBy(rec, callerId)) {
|
||||
// 明确回报,而不是假装成功(也便于审计追查越权尝试)
|
||||
return { removed: false, reason: `订阅 ${id} 属于其他控制端,无权取消` };
|
||||
}
|
||||
rec.dispose();
|
||||
watches.delete(id);
|
||||
return { removed: true };
|
||||
}
|
||||
|
||||
/** 列出订阅:**只列自己的**(含匿名创建的)。 */
|
||||
export async function eventList(_args: Args, ctx?: WatchContext): Promise<unknown> {
|
||||
const callerId = ctx?.clientId ?? '';
|
||||
const mine = [...watches.values()].filter((w) => ownedBy(w, callerId));
|
||||
return {
|
||||
watches: mine.map((w) => ({
|
||||
watch_id: w.id,
|
||||
kind: w.kind,
|
||||
description: w.description,
|
||||
events: w.count,
|
||||
created_at: w.createdAt,
|
||||
})),
|
||||
buffered: buffer.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** 拉取事件:只回「属于本 client_id 的订阅」产生的事件。 */
|
||||
export async function eventPoll(args: Args, ctx?: WatchContext): 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;
|
||||
const callerId = ctx?.clientId ?? '';
|
||||
// watch_id 显式指定时先验归属,避免「猜 id 就能读别人事件」
|
||||
if (watchId) {
|
||||
const rec = watches.get(watchId);
|
||||
if (rec && !ownedBy(rec, callerId)) {
|
||||
throw new CapabilityError(ErrorCodes.ACL_DENIED, `订阅 ${watchId} 属于其他控制端`, { watch_id: watchId });
|
||||
}
|
||||
}
|
||||
let list = buffer.filter((e) => e.seq > since);
|
||||
if (watchId) {
|
||||
list = list.filter((e) => e.watch_id === watchId);
|
||||
} else if (callerId) {
|
||||
const allowed = new Set(
|
||||
[...watches.values()].filter((w) => ownedBy(w, callerId)).map((w) => w.id),
|
||||
);
|
||||
list = list.filter((e) => allowed.has(e.watch_id));
|
||||
}
|
||||
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,11 +1,15 @@
|
||||
import os from 'node:os';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { warmupWindowHelper } from './capabilities/window.js';
|
||||
import { CAPABILITY_MANIFEST } from '@nodeagent/protocol';
|
||||
import { loadAgentConfig, agentConfigPath } from './config.js';
|
||||
import { agentDir, loadAgentConfig, agentConfigPath } from './config.js';
|
||||
import { ensureCert } from './certs.js';
|
||||
import { createAgentServer } from './server.js';
|
||||
import { initAudit, audit, auditFilePath } from './audit.js';
|
||||
import { startBeacon, DEFAULT_DISCOVERY_PORT, type Beacon } from './discovery.js';
|
||||
import { startHubClient, type HubClient } from './hub-mode.js';
|
||||
import { recordLKG } from './lkg.js';
|
||||
|
||||
function listLocalIps(): string[] {
|
||||
const out: string[] = [];
|
||||
@@ -17,6 +21,143 @@ function listLocalIps(): string[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 看门狗失败计数归零(v2.1.0,决策 R-1;scripts/startup-guard.mjs)。
|
||||
*
|
||||
* 看门狗用 `<数据目录>/state/startup-state.json` 记录「连续启动失败次数」,
|
||||
* 达到阈值即从 LKG 恢复入口。计数必须在**启动成功后**归零 —— 与 LKG 同一
|
||||
* 铁律:不把「启动中」当成功,否则坏版本每崩一次都会被上一次的成功洗白,
|
||||
* 阈值形同虚设。写入失败一律吞掉:重置计数不能影响启动(与 recordLKG 同哲学)。
|
||||
*/
|
||||
function resetStartupGuardState(): void {
|
||||
try {
|
||||
const dir = join(agentDir(), 'state');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(dir, 'startup-state.json'),
|
||||
`${JSON.stringify({ failures: 0, updated_at: new Date().toISOString() })}\n`,
|
||||
'utf8',
|
||||
);
|
||||
} catch {
|
||||
/* 重置失败不影响启动 */
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// v2.0.3:进程级最后一道防线(崩溃链兜底)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// ## 为什么需要
|
||||
// 真机缺陷链的共同根因:`ws.on('message')` 里用 `void handleMessage(...)` 启动异步处理,
|
||||
// 未挂 `.catch`;而全仓**没有任何** `unhandledRejection` 处理器 → Node ≥22 的默认策略是
|
||||
// **直接终止进程**。于是一条畸形报文就能把被控端打死(外部只见 `code=1` / 连接断开),
|
||||
// 且因为 `verifyHmac`(psk 默认路径)没有 try/catch,连**未认证**的报文都能触发。
|
||||
//
|
||||
// 根治在源头(`server.ts` 已改 `.catch(...)` + 逐处报文类型校验),这里补的是
|
||||
// 「将来某处再漏一个 await」时的兜底 —— 有证据地活着,胜过静默地死。
|
||||
//
|
||||
// ## 为什么**不**退出进程
|
||||
// 被控端是常驻服务,「可用性」优先于「出错即死」。若这里改成 `process.exit(1)`,
|
||||
// 就退化回「一条畸形报文打死服务」—— 正是本次要修的东西。
|
||||
// 代价(可能带着不一致的状态继续跑)由两点对冲:
|
||||
// ① 源头已有报文校验与 `.catch`;② 这里每次都写审计 + 日志,问题不会被掩盖。
|
||||
//
|
||||
// ## 为什么要限流审计
|
||||
// 畸形报文可以高频重复(扫描/爆破)→ 无限写 `agent.error` 会把审计日志冲爆、把
|
||||
// 正常记录挤掉。故日志每次都打(stdout 由控制台的环形缓冲兜底),但**审计按时间窗限流**。
|
||||
|
||||
let procErrCount = 0;
|
||||
let lastProcErrAuditAt = 0;
|
||||
/** 两条 `agent.error` 审计之间的最小间隔 */
|
||||
const PROC_ERR_AUDIT_INTERVAL_MS = 10_000;
|
||||
|
||||
function reportProcessError(kind: string, detail: string): void {
|
||||
procErrCount += 1;
|
||||
console.error(
|
||||
`[${new Date().toISOString()}] [ERROR] ${kind}(已拦截,进程继续运行;累计 ${procErrCount} 次):${detail}`,
|
||||
);
|
||||
const now = Date.now();
|
||||
if (now - lastProcErrAuditAt < PROC_ERR_AUDIT_INTERVAL_MS) return;
|
||||
lastProcErrAuditAt = now;
|
||||
audit({
|
||||
type: 'agent.error',
|
||||
status: 'failed',
|
||||
reason: `${kind}: ${detail}(累计 ${procErrCount} 次,审计按 ${PROC_ERR_AUDIT_INTERVAL_MS / 1000}s 限流)`,
|
||||
});
|
||||
}
|
||||
|
||||
process.on('unhandledRejection', (reason: unknown) => {
|
||||
reportProcessError(
|
||||
'未处理的 Promise 拒绝',
|
||||
reason instanceof Error ? `${reason.name}: ${reason.message}` : String(reason),
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// stdout / stderr 断管容错(2026-10-17 真机两次踩到)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// ## 现象
|
||||
// 端口仍然 `OPEN`,但**所有** RPC 都超时(握手都不回)。持续数分钟不自愈,
|
||||
// 只能靠重开控制台恢复。
|
||||
//
|
||||
// ## 根因
|
||||
// 被控端由控制台 spawn 时,stdout/stderr 是**管道**而非终端。用户退出控制台
|
||||
// (尤其选「保留被控端」)后,管道**读端消失**,而 agent 仍在往这个死管道写日志:
|
||||
// - Node 对未监听 'error' 的 stream 会把 `EPIPE` **抛成未捕获异常** → 进程退出;
|
||||
// - 更糟的是写端在无读者时可能**阻塞**,把事件循环一起卡住 ——
|
||||
// 于是端口保住了(OS 层 socket 还在),但 JS 再也不执行了。
|
||||
// 这解释了为什么「退出控制台后被控端还在,却谁都连不上」:它活着,但哑了。
|
||||
//
|
||||
// ## 做法
|
||||
// ① 监听 'error' 吞掉 EPIPE,不让它升级成未捕获异常;
|
||||
// ② 一旦判定通道已断,就**短路后续写**,不再往死管道里堆字节。
|
||||
//
|
||||
// 日志不会因此丢失:审计走文件(`audit.log`),控制台在时照常收到管道输出。
|
||||
// 宁可少一行 stdout,也不能让常驻服务因为「没人看日志」而假死。
|
||||
let stdoutDead = false;
|
||||
let stderrDead = false;
|
||||
|
||||
function markDead(e: NodeJS.ErrnoException, isErr: boolean): void {
|
||||
if (e.code === 'EPIPE' || e.code === 'ERR_STREAM_DESTROYED' || e.code === 'ERR_STREAM_WRITE_AFTER_END') {
|
||||
if (isErr) stderrDead = true;
|
||||
else stdoutDead = true;
|
||||
return;
|
||||
}
|
||||
// 其他写错误也一律吞掉:日志通道的故障不该打死被控端
|
||||
if (isErr) stderrDead = true;
|
||||
else stdoutDead = true;
|
||||
}
|
||||
|
||||
process.stdout.on('error', (e: NodeJS.ErrnoException) => markDead(e, false));
|
||||
process.stderr.on('error', (e: NodeJS.ErrnoException) => markDead(e, true));
|
||||
|
||||
const rawLog = console.log.bind(console);
|
||||
const rawErr = console.error.bind(console);
|
||||
console.log = (...a: unknown[]): void => {
|
||||
if (stdoutDead) return;
|
||||
try {
|
||||
rawLog(...a);
|
||||
} catch {
|
||||
stdoutDead = true;
|
||||
}
|
||||
};
|
||||
console.error = (...a: unknown[]): void => {
|
||||
if (stderrDead) return;
|
||||
try {
|
||||
rawErr(...a);
|
||||
} catch {
|
||||
stderrDead = true;
|
||||
}
|
||||
};
|
||||
|
||||
process.on('uncaughtException', (err: unknown) => {
|
||||
reportProcessError(
|
||||
'未捕获异常',
|
||||
err instanceof Error ? `${err.name}: ${err.message}` : String(err),
|
||||
);
|
||||
});
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const { config, isNew } = loadAgentConfig();
|
||||
initAudit({
|
||||
@@ -27,14 +168,20 @@ async function main(): Promise<void> {
|
||||
});
|
||||
// v6:Hub 模式下不监听本地端口,改为主动外连 Hub(穿 NAT)
|
||||
const useHub = config.hub?.enabled === true;
|
||||
const tls = !useHub && config.tls ? ensureCert() : null;
|
||||
const tls = !useHub && config.tls ? await ensureCert() : null;
|
||||
const server = useHub ? null : await createAgentServer(config, tls);
|
||||
|
||||
let hubClient: HubClient | null = null;
|
||||
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 +201,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',
|
||||
@@ -66,6 +216,14 @@ async function main(): Promise<void> {
|
||||
},
|
||||
(msg) => console.log(`[WARN] ${msg}`),
|
||||
);
|
||||
// 交付流水线 §6:此刻脚本已加载、配置已读、证书已签、端口已绑、心跳已起 ——
|
||||
// 走到这里说明「这个版本至少能启动」,故记为最后已知可用版本(LKG)。
|
||||
// 注意**不在更新写完时就记**:那会把尚未验证的版本当成已知可用,LKG 就失去意义。
|
||||
recordLKG();
|
||||
// 同理(v2.1.0 看门狗):启动成功才把「连续启动失败」计数归零。
|
||||
// 顺序在 recordLKG 之后、启动横幅之前 —— 两者都是「起来了」的注脚。
|
||||
resetStartupGuardState();
|
||||
|
||||
console.log('');
|
||||
console.log(' nodeagent 被控端已启动');
|
||||
console.log(' ─────────────────────────────────────────────');
|
||||
@@ -81,6 +239,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 +266,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,179 @@
|
||||
import { readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { join, basename } from 'node:path';
|
||||
import { agentDir } from './config.js';
|
||||
import { buildInfo } from './capabilities/system.js';
|
||||
|
||||
/**
|
||||
* 最后已知可用版本(Last-Known-Good, LKG)—— 交付流水线 §6 的回滚锚点。
|
||||
*
|
||||
* ## 为什么需要(本项目特有风险,通用发布规范里没有)
|
||||
* 对 Web 服务,发布失败可以回滚、可以摘流量。对 nodeagent:
|
||||
* **回滚通道本身就是被更新的那个程序**。一次失败的更新会让一台物理不可达的
|
||||
* 远程设备永久脱管 —— 这是本项目最严重的上线失败形态。
|
||||
*
|
||||
* ## 记什么、什么时候记
|
||||
* 关键设计:**只在「本次启动确实起来了」之后才记**。
|
||||
*
|
||||
* 若在「更新刚写完」时就记 LKG,等于把还没验证的版本当成已知可用 ——
|
||||
* 那 LKG 就失去了意义。故由 `index.ts` 在服务器 listen 成功、打印完启动信息后
|
||||
* 调用 `recordLKG()`:此刻能走到这一行,说明脚本能被 Node 加载、配置能读、
|
||||
* 证书能签、端口能绑。**能启动 ≠ 功能全对,但能启动 = 至少不是「打死」状态。**
|
||||
*
|
||||
* ## 明确不做的事:自动回滚
|
||||
* 本文刻意**不实现**「启动失败自动回滚」。理由:
|
||||
* - 远程设备上无法区分「更新本身坏了」与「网络抖动 / 端口被占 / 依赖服务没起」;
|
||||
* - 自动回滚可能触发**回滚循环**(回滚→启动→又判定失败→再回滚),
|
||||
* 在无人值守设备上比「停在坏版本」更难恢复;
|
||||
* - 被控端一旦自作主张改自己的程序,控制端将失去对「当前跑的是什么」的掌控。
|
||||
*
|
||||
* 因此 LKG 的定位是**给回滚提供确定的目标**,而不是替人做回滚决策:
|
||||
* 控制端 `nodeagent rollback` 读 LKG → 覆盖回入口文件 → 重启。
|
||||
*/
|
||||
|
||||
export interface LkgRecord {
|
||||
/** 入口脚本(agent.mjs)的 sha256 前 12 位 —— 与 system.info 的 build.hash 同口径 */
|
||||
hash: string;
|
||||
/**
|
||||
* v2.1.0 新增(只增字段):入口脚本的 sha256 **全量 64 位**。
|
||||
*
|
||||
* 为什么加:回滚时控制端要用「枚举备份 + 指纹匹配」定位内容,12 位前缀存在
|
||||
* 理论碰撞风险(1/16¹²,可接受但非零);全量比对消除该风险。
|
||||
* 老记录(无此字段)回落 12 位前缀比对,见 `lkgHashMatches`。
|
||||
*/
|
||||
hash_full?: string;
|
||||
bytes: number;
|
||||
script_path: string;
|
||||
/**
|
||||
* v2.1.0 新增(只增字段):回滚内容候选的文件名模式(`<basename>.bak*`)。
|
||||
* 覆盖推送式 deploy 的固定名 `.bak` 与拉取式 selfupdate 的时间戳名 `.bak-<ts>`
|
||||
* 两套命名,供控制端枚举候选(`fs.list` + 过滤)。
|
||||
*/
|
||||
backup_glob?: string;
|
||||
/** 记录时刻(Unix ms) */
|
||||
recorded_at: number;
|
||||
/** 当时的 node 版本,便于判断「是不是 Node 运行时升级导致的」 */
|
||||
node_version: string;
|
||||
platform: string;
|
||||
}
|
||||
|
||||
export function lkgFilePath(): string {
|
||||
return join(agentDir(), 'agent-lkg.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算当前入口脚本指纹。
|
||||
* 与 `capabilities/system.ts` 的 buildInfo() 同口径(都是脚本内容的 sha256 前 12 位),
|
||||
* 故可与 `nodeagent info` 里的 `build.hash` 直接比对。
|
||||
*/
|
||||
function currentScriptHash(): { hash: string; hash_full: string; bytes: number; path: string } | null {
|
||||
const p = process.argv[1] ?? '';
|
||||
if (!p) return null;
|
||||
try {
|
||||
const buf = readFileSync(p);
|
||||
const full = createHash('sha256').update(buf).digest('hex');
|
||||
return {
|
||||
hash: full.slice(0, 12),
|
||||
hash_full: full,
|
||||
bytes: buf.length,
|
||||
path: p,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* LKG 指纹比对(v2.1.0):有 `hash_full` 按**全量**比对,老记录回落 12 位前缀。
|
||||
*
|
||||
* 纯函数、不猜:两边都拿不出可比的值时返回 false。
|
||||
* 供 rollback 的「备份指纹匹配」与任何需要判断「这份内容是不是 LKG」的场景共用,
|
||||
* 避免各写一份比对规则(两份规则必然漂移)。
|
||||
*/
|
||||
export function lkgHashMatches(rec: LkgRecord | null, candidateFullHash: string | undefined | null): boolean {
|
||||
if (!rec || !candidateFullHash) return false;
|
||||
if (rec.hash_full) return rec.hash_full === candidateFullHash;
|
||||
return rec.hash === candidateFullHash.slice(0, 12);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录当前版本为 LKG。
|
||||
*
|
||||
* 任何失败都被吞掉 —— 写入失败不能影响启动。
|
||||
* @returns 写入的记录,失败返回 null
|
||||
*/
|
||||
export function recordLKG(): LkgRecord | null {
|
||||
const cur = currentScriptHash();
|
||||
if (!cur) return null;
|
||||
const rec: LkgRecord = {
|
||||
hash: cur.hash,
|
||||
hash_full: cur.hash_full,
|
||||
bytes: cur.bytes,
|
||||
script_path: cur.path,
|
||||
backup_glob: `${basename(cur.path)}.bak*`,
|
||||
recorded_at: Date.now(),
|
||||
node_version: process.version,
|
||||
platform: process.platform,
|
||||
};
|
||||
try {
|
||||
mkdirSync(agentDir(), { recursive: true });
|
||||
writeFileSync(lkgFilePath(), `${JSON.stringify(rec, null, 2)}\n`, 'utf8');
|
||||
return rec;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 LKG 记录;不存在或损坏返回 null(不作任何猜测)。
|
||||
*
|
||||
* 向后兼容:v2.1.0 之前写的记录没有 `hash_full` / `backup_glob` —— 原样读回,
|
||||
* 由消费方按 `lkgHashMatches` 的 12 位前缀回落处理,不在这里猜测补齐。
|
||||
*/
|
||||
export function readLKG(): LkgRecord | null {
|
||||
try {
|
||||
const raw = readFileSync(lkgFilePath(), 'utf8');
|
||||
const rec = JSON.parse(raw) as LkgRecord;
|
||||
if (!rec || typeof rec.hash !== 'string') return null;
|
||||
return rec;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前版本与 LKG 是否一致。
|
||||
*
|
||||
* 用途:更新后复核 —— 若「当前 hash ≠ LKG」且刚做过更新,
|
||||
* 说明新版本**尚未被证明能启动**,控制端应暂缓放量到其余设备(交付流水线 §6 分批策略)。
|
||||
*/
|
||||
export function lkgStatus(): {
|
||||
lkg: LkgRecord | null;
|
||||
current_hash: string | null;
|
||||
matches: boolean;
|
||||
/** LKG 与当前版本的字节差,便于快速判断是「升级」还是「回退」 */
|
||||
byte_delta: number | null;
|
||||
} {
|
||||
const lkg = readLKG();
|
||||
const cur = currentScriptHash();
|
||||
const current_hash = cur?.hash ?? null;
|
||||
return {
|
||||
lkg,
|
||||
current_hash,
|
||||
matches: !!lkg && lkg.hash === current_hash,
|
||||
byte_delta: lkg && cur ? cur.bytes - lkg.bytes : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 供审计/排障:LKG 文件是否存在及其大小(不读内容,避免大文件 IO)。 */
|
||||
export function lkgFileInfo(): { path: string; exists: boolean; bytes: number } {
|
||||
const p = lkgFilePath();
|
||||
try {
|
||||
return { path: p, exists: true, bytes: statSync(p).size };
|
||||
} catch {
|
||||
return { path: p, exists: false, bytes: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/** 让 system.info 能顺带暴露构建指纹(与 buildInfo 同源,避免两处口径漂移)。 */
|
||||
export { buildInfo };
|
||||
@@ -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');
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 被控端出网模块(桶文件)—— 导出面收敛。
|
||||
*
|
||||
* 目前只有代理出网(`proxy.ts`)。将来若新增其它出网能力(如镜像探测),
|
||||
* 也从这里统一导出,调用方只依赖本文件,不直接深入子模块。
|
||||
*
|
||||
* 依赖纪律:本目录下的模块**只依赖 node 内置模块**,不 import 项目内任何模块 ——
|
||||
* 保证可被单测独立加载、依赖方向严格单向(capabilities/* → net/)。
|
||||
*/
|
||||
|
||||
export {
|
||||
normalizeProxyUrl,
|
||||
isBypassed,
|
||||
isLoopbackTarget,
|
||||
pickEnvProxy,
|
||||
parseScutilProxy,
|
||||
parseRegQueryProxy,
|
||||
detectSystemProxy,
|
||||
systemProxy,
|
||||
__resetSystemProxyCache,
|
||||
resolveProxy,
|
||||
tunnelConnection,
|
||||
httpGet,
|
||||
type SystemProxyInfo,
|
||||
type ProxyResolution,
|
||||
type HttpResponse,
|
||||
type CommandRunner,
|
||||
} from './proxy.js';
|
||||
@@ -0,0 +1,618 @@
|
||||
/**
|
||||
* 出站代理支持 —— 被控端「拉取式自更新」的出网模块。
|
||||
*
|
||||
* ## 为什么必须自己实现(这是缺陷,不是优化)
|
||||
*
|
||||
* Node 内置 `fetch`(undici)**默认不读任何代理环境变量** —— 实测:设了 `HTTPS_PROXY`
|
||||
* 仍然 `fetch failed`。能开的只有 `NODE_USE_ENV_PROXY=1`(Node 22.x 实验性、24 正式),
|
||||
* 而它**只能读环境变量**,读不到 Windows 系统代理(WinINET)或 macOS 系统代理
|
||||
* —— 而中文用户用 Clash / v2rayN 时配置的恰恰是「系统代理」。
|
||||
* 结果:境内用户即使代理一切正常,`system.agent.update` 也**必然**报 `fetch failed`。
|
||||
* (真机取证见 apps/console/sidecar/proxy.mjs 顶部:VM 上 DNS 与 TCP 443 都通、
|
||||
* 却 `fetch failed`;同机经代理取 `latest.json` 返回 302。)
|
||||
*
|
||||
* 另外 `fetch(url, { dispatcher })` 也不通:`dispatcher` 必须是 undici 的 Dispatcher 实例,
|
||||
* 而 Node 没有把 undici 暴露成可导入的模块(只能整包 vendored 进来,会把 agent.mjs
|
||||
* 撑胖一大截,且 undici 依赖 `.wasm` 解析器、esbuild 单文件打包有风险)。
|
||||
*
|
||||
* ## 做法
|
||||
*
|
||||
* 不用 `fetch`,改用 `node:http` / `node:https` 自己发请求:
|
||||
* - **https 目标经代理**:先向代理发 `CONNECT host:443` 建隧道,再在隧道 socket 上做 TLS
|
||||
* (通过 `createConnection` 交给 `http.request` —— 注意这里刻意用 `http` 而非 `https`,
|
||||
* 因为我们交出去的**已经是 TLS socket**,用 `https.request` 会二次握手);
|
||||
* - **http 目标经代理**:按 HTTP 代理规范发**绝对形态**请求行(`GET http://… HTTP/1.1`);
|
||||
* - **无代理**:与改动前一致,直连(走 `https.request`)。
|
||||
*
|
||||
* ## 代理来源优先级
|
||||
*
|
||||
* ① `NA_PROXY`(显式覆盖,便于排查与将来做配置项)
|
||||
* ② 目标协议对应的环境变量(`HTTPS_PROXY`/`https_proxy` → `ALL_PROXY`/`all_proxy` → …)
|
||||
* ③ **系统代理**(Windows 读注册表 WinINET;macOS 读 `scutil --proxy`)
|
||||
*
|
||||
* `NO_PROXY` / `no_proxy` 命中的主机一律直连。只支持 **HTTP 代理**(`http(s)://`)——
|
||||
* HTTPS 目标经 HTTP 代理走 CONNECT,是通用做法;`socks5://` 会**明确**记一条 note
|
||||
* 而不是静默直连(静默正是本次缺陷最恶劣的地方)。
|
||||
*
|
||||
* ## 依赖纪律
|
||||
*
|
||||
* 本模块**只依赖 node 内置模块**(http/https/tls/child_process),不 import 项目内
|
||||
* 任何模块 —— 保证可被单测独立加载、可被将来其它出网能力复用;依赖方向严格单向
|
||||
* (selfupdate → net/proxy),杜绝环。
|
||||
*
|
||||
* ## 安全
|
||||
*
|
||||
* 只做「出站取数据」,不引入任何新依赖;TLS 证书默认校验,不提供跳过开关。
|
||||
*/
|
||||
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import tls from 'node:tls';
|
||||
import { execFile } from 'node:child_process';
|
||||
|
||||
/** 代理来源优先级(按目标协议分列)。`NA_PROXY` 恒为第一优先。 */
|
||||
const ENV_ORDER: Record<string, string[]> = {
|
||||
'https:': ['NA_PROXY', 'HTTPS_PROXY', 'https_proxy', 'ALL_PROXY', 'all_proxy', 'HTTP_PROXY', 'http_proxy'],
|
||||
'http:': ['NA_PROXY', 'HTTP_PROXY', 'http_proxy', 'ALL_PROXY', 'all_proxy'],
|
||||
};
|
||||
const NO_PROXY_KEYS = ['NO_PROXY', 'no_proxy'];
|
||||
const REDIRECT_STATUS = new Set([301, 302, 303, 307, 308]);
|
||||
const UA = 'nodeagent-agent';
|
||||
|
||||
/** 系统代理探测结果(`url` 为 `host:port` 或完整 URL;`bypass` 为逗号分隔绕过列表)。 */
|
||||
export interface SystemProxyInfo {
|
||||
url: string | null;
|
||||
bypass: string;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
/** 代理决议结果。 */
|
||||
export interface ProxyResolution {
|
||||
proxy: URL | null;
|
||||
source: string | null;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
/** `httpGet` 的单次响应(跟随重定向后的最终响应)。 */
|
||||
export interface HttpResponse {
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: Record<string, unknown>;
|
||||
body: Buffer;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/** 可注入的只读命令 runner(单测用假 runner,不必真去读注册表)。 */
|
||||
export type CommandRunner = (file: string, args: string[]) => Promise<string | null>;
|
||||
|
||||
/**
|
||||
* 把环境里/配置里写的各种代理形态归一成可用的 URL。
|
||||
*
|
||||
* 接受 `http://127.0.0.1:7890`、`127.0.0.1:7890`(补 `http://`)、带账密的 URL;
|
||||
* 其余(`socks5://`、空串、无法解析)返回 `null` —— 由调用方把「看到了但用不了」
|
||||
* 明确报出来,**不许静默直连**。
|
||||
*/
|
||||
export function normalizeProxyUrl(raw: unknown): URL | null {
|
||||
if (typeof raw !== 'string') return null;
|
||||
const s = raw.trim();
|
||||
if (!s) return null;
|
||||
let u: URL;
|
||||
try {
|
||||
u = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(s) ? s : `http://${s}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
|
||||
if (!u.hostname) return null;
|
||||
return u;
|
||||
}
|
||||
|
||||
/** 代理 URL 的主机端口(缺省按代理自身协议补 80/443)。 */
|
||||
function proxyEndpoint(u: URL): { host: string; port: number } {
|
||||
return { host: u.hostname, port: u.port ? Number(u.port) : u.protocol === 'https:' ? 443 : 80 };
|
||||
}
|
||||
|
||||
/** 代理 URL 里的账密 → `Proxy-Authorization` 头(没有则 null)。 */
|
||||
function proxyAuthHeader(u: URL): string | null {
|
||||
if (!u.username) return null;
|
||||
const raw = `${decodeURIComponent(u.username)}:${decodeURIComponent(u.password)}`;
|
||||
return 'Basic ' + Buffer.from(raw).toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* `NO_PROXY` 命中判定。支持:精确主机、后缀(`example.com` 或 `.example.com` 都匹配
|
||||
* `a.example.com`)、`host:port`、`*`。大小写不敏感。
|
||||
*
|
||||
* ⚠️ 刻意**不支持 CIDR**(如 `169.254/16`):本模块的目标只有公网域名,
|
||||
* 而 CIDR 主机名判定极易写错、错了就是「该走代理的没走」。遇到疑似 CIDR 条目
|
||||
* 由调用方记 note(见 `pickEnvProxy` 的 notes 语义)。
|
||||
*/
|
||||
export function isBypassed(targetUrl: string, noProxy: string): boolean {
|
||||
if (!noProxy) return false;
|
||||
let host: string;
|
||||
let port: string;
|
||||
try {
|
||||
const u = new URL(targetUrl);
|
||||
host = u.hostname.toLowerCase();
|
||||
port = u.port || (u.protocol === 'https:' ? '443' : '80');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
for (const rawEntry of String(noProxy).split(',')) {
|
||||
const entry = rawEntry.trim().toLowerCase();
|
||||
if (!entry) continue;
|
||||
if (entry === '*') return true;
|
||||
let eHost = entry;
|
||||
let ePort: string | null = null;
|
||||
const i = entry.lastIndexOf(':');
|
||||
// 排除 IPv6 字面量(`[::1]:8080`)里的冒号误判
|
||||
if (i > 0 && !entry.slice(0, i).includes(':')) {
|
||||
eHost = entry.slice(0, i);
|
||||
ePort = entry.slice(i + 1);
|
||||
}
|
||||
if (ePort && ePort !== port) continue;
|
||||
const bare = eHost.replace(/^\./, '');
|
||||
if (!bare) continue;
|
||||
if (host === bare || host.endsWith(`.${bare}`)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 从环境变量里挑代理(纯函数,便于单测)。 */
|
||||
export function pickEnvProxy(
|
||||
env: Record<string, string | undefined>,
|
||||
targetUrl: string,
|
||||
): { url: URL | null; source: string | null; notes: string[] } {
|
||||
let proto = 'https:';
|
||||
try {
|
||||
proto = new URL(targetUrl).protocol;
|
||||
} catch {
|
||||
/* 保持默认 */
|
||||
}
|
||||
const keys = ENV_ORDER[proto] ?? ENV_ORDER['https:']!;
|
||||
const notes: string[] = [];
|
||||
for (const k of keys) {
|
||||
const v = env?.[k];
|
||||
if (!v) continue;
|
||||
const u = normalizeProxyUrl(v);
|
||||
if (u) return { url: u, source: k, notes };
|
||||
notes.push(`${k}=${String(v).trim()} 不是受支持的代理协议(仅支持 http/https 代理),已忽略`);
|
||||
}
|
||||
return { url: null, source: null, notes };
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 macOS `scutil --proxy` 的输出。
|
||||
*
|
||||
* 形如:
|
||||
* <dictionary> {
|
||||
* HTTPSEnable : 1
|
||||
* HTTPSPort : 7890
|
||||
* HTTPSProxy : 127.0.0.1
|
||||
* ExceptionsList : <array> { 0 : *.local 1 : 169.254/16 }
|
||||
* }
|
||||
*
|
||||
* 注意 macOS 上 HTTPS 与 HTTP 是**分别开关**的;这里 https 优先,取不到再退 HTTP。
|
||||
* `ProxyAutoConfigEnable : 1`(PAC 自动配置脚本)**无法解析** —— 返回 notes 说明,
|
||||
* 让上层给出「请显式设 HTTPS_PROXY」这类可操作提示,而不是含糊的 fetch failed。
|
||||
*/
|
||||
export function parseScutilProxy(text: string): SystemProxyInfo | null {
|
||||
if (typeof text !== 'string' || !text.includes('{')) return null;
|
||||
const scalar = (key: string): string | null => {
|
||||
const m = new RegExp(`^\\s*${key}\\s*:\\s*(.+?)\\s*$`, 'm').exec(text);
|
||||
return m ? m[1]!.trim() : null;
|
||||
};
|
||||
const isOn = (v: string | null): boolean => v === '1' || v === 'true';
|
||||
const notes: string[] = [];
|
||||
|
||||
// ExceptionsList 是 <array> 块里的 “序号 : 值” 行 —— 收集成逗号分隔的绕过列表
|
||||
const bypass: string[] = [];
|
||||
const arrIdx = text.indexOf('ExceptionsList');
|
||||
if (arrIdx >= 0) {
|
||||
const body = text.slice(arrIdx, text.indexOf('}', arrIdx) + 1);
|
||||
const re = /^\s*\d+\s*:\s*(.+?)\s*$/gm;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(body)) !== null) bypass.push(m[1]!.trim());
|
||||
}
|
||||
|
||||
for (const [flag, hostKey, portKey, dflt] of [
|
||||
['HTTPSEnable', 'HTTPSProxy', 'HTTPSPort', '443'],
|
||||
['HTTPEnable', 'HTTPProxy', 'HTTPPort', '80'],
|
||||
] as const) {
|
||||
if (!isOn(scalar(flag))) continue;
|
||||
const host = scalar(hostKey);
|
||||
if (!host) continue;
|
||||
const port = scalar(portKey) ?? dflt;
|
||||
return { url: `${host}:${port}`, bypass: bypass.join(','), notes };
|
||||
}
|
||||
|
||||
if (isOn(scalar('ProxyAutoConfigEnable'))) {
|
||||
notes.push('系统代理使用 PAC 自动配置脚本(ProxyAutoConfigEnable=1),本程序无法解析脚本内容,已按「无代理」处理');
|
||||
}
|
||||
return { url: null, bypass: bypass.join(','), notes };
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 Windows `reg query …\Internet Settings` 的输出。
|
||||
*
|
||||
* 形如(`REG_SZ` / `REG_DWORD` 是**类型名,不随系统语言变化**,可安全按 token 解析):
|
||||
* ProxyEnable REG_DWORD 0x1
|
||||
* ProxyOverride REG_SZ *.local;<local>
|
||||
* ProxyServer REG_SZ 127.0.0.1:7890 (也可能是 http=…;https=… 分协议形态)
|
||||
*
|
||||
* `ProxyEnable=0x0` 视为**显式关闭** → 无代理(哪怕 ProxyServer 有值)。
|
||||
* `<local>` 是「本机地址直连」的占位符,不是域名,从绕过列表里剔除。
|
||||
*/
|
||||
export function parseRegQueryProxy(text: string): SystemProxyInfo | null {
|
||||
if (typeof text !== 'string' || !text.includes('REG_')) return null;
|
||||
const value = (name: string): string | null => {
|
||||
const m = new RegExp(`^\\s*${name}\\s+REG_(?:SZ|EXPAND_SZ|DWORD|BINARY)\\s+(.*?)\\s*$`, 'm').exec(text);
|
||||
return m ? m[1]!.trim() : null;
|
||||
};
|
||||
const notes: string[] = [];
|
||||
|
||||
const enable = value('ProxyEnable');
|
||||
if (enable !== null && /^0x0+$/i.test(enable)) return { url: null, bypass: '', notes };
|
||||
|
||||
const server = value('ProxyServer');
|
||||
if (!server) return { url: null, bypass: '', notes };
|
||||
|
||||
const override = value('ProxyOverride');
|
||||
const bypass = override
|
||||
? override
|
||||
.split(';')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s && !/^<local>$/i.test(s))
|
||||
.join(',')
|
||||
: '';
|
||||
|
||||
// 分协议形态:http=127.0.0.1:7890;https=127.0.0.1:7891 —— https 优先
|
||||
let url: string | null = server;
|
||||
if (server.includes('=')) {
|
||||
const map = new Map<string, string>();
|
||||
for (const part of server.split(';')) {
|
||||
const i = part.indexOf('=');
|
||||
if (i > 0) map.set(part.slice(0, i).trim().toLowerCase(), part.slice(i + 1).trim());
|
||||
}
|
||||
url = map.get('https') ?? map.get('http') ?? null;
|
||||
if (!url) notes.push(`系统代理仅配置了非 http(s) 协议(ProxyServer=${server})`);
|
||||
}
|
||||
return { url, bypass, notes };
|
||||
}
|
||||
|
||||
/**
|
||||
* 跑一个只读命令,失败/超时一律返回 null(探测系统代理**绝不允许**把自更新搞挂)。
|
||||
*
|
||||
* `windowsHide: true` —— 被控端由计划任务/服务拉起时是「无控制台」进程,
|
||||
* 若再 spawn 控制台程序(reg.exe),Windows 会**新开一个控制台** → 黑窗闪一下。
|
||||
*/
|
||||
function runQuiet(file: string, args: string[]): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
execFile(
|
||||
file,
|
||||
args,
|
||||
{ windowsHide: true, timeout: 5000, encoding: 'utf8', maxBuffer: 1024 * 1024 },
|
||||
(err, stdout) => resolve(err ? null : String(stdout)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 探测系统代理。`run` 可注入(单测用假 runner,不必真去读注册表)。
|
||||
*
|
||||
* 任何异常都吞掉返回 null —— 探测失败绝不能成为新的失败点。
|
||||
*/
|
||||
export async function detectSystemProxy(
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
run: CommandRunner = runQuiet,
|
||||
): Promise<SystemProxyInfo | null> {
|
||||
try {
|
||||
if (platform === 'win32') {
|
||||
const out = await run('reg', [
|
||||
'query',
|
||||
'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings',
|
||||
]);
|
||||
return out ? parseRegQueryProxy(out) : null;
|
||||
}
|
||||
if (platform === 'darwin') {
|
||||
const out = await run('scutil', ['--proxy']);
|
||||
return out ? parseScutilProxy(out) : null;
|
||||
}
|
||||
// Linux:桌面环境各异(gsettings / 环境变量 / PAC),不做猜测 —— 只认环境变量
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 系统代理只探测一次(同一进程内结果不变;用户改代理需重启被控端)。 */
|
||||
let systemProxyPromise: Promise<SystemProxyInfo | null> | undefined;
|
||||
export function systemProxy(): Promise<SystemProxyInfo | null> {
|
||||
if (!systemProxyPromise) systemProxyPromise = detectSystemProxy().catch(() => null);
|
||||
return systemProxyPromise;
|
||||
}
|
||||
|
||||
/** 单测用:清掉系统代理缓存。 */
|
||||
export function __resetSystemProxyCache(): void {
|
||||
systemProxyPromise = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 目标是否为「本机回环 / 链路本地」地址。
|
||||
*
|
||||
* 为什么强制直连(对 sidecar 原版的有意加强):代理另一侧的 `localhost` **不是**
|
||||
* 本机的 localhost —— 把发往 `127.0.0.1:<自建镜像/测试源站>` 的请求交给代理,
|
||||
* 必然失败且报错形态费解(代理返回它自己那侧的 404 / 拒连)。
|
||||
* CI 与开发机普遍设置 `HTTP_PROXY`(本仓库 e2e 就这样踩到过:测试源站在回环,
|
||||
* agent 继承沙箱代理后拿到 404)。公开域名的行为**不变**(本函数只命中回环)。
|
||||
*/
|
||||
export function isLoopbackTarget(targetUrl: string): boolean {
|
||||
let host: string;
|
||||
try {
|
||||
host = new URL(targetUrl).hostname.toLowerCase();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (host === 'localhost') return true;
|
||||
// IPv6 回环:URL 会把 [::1] 规范化成 ::1
|
||||
if (host === '::1' || host === '[::1]') return true;
|
||||
// IPv4 回环段 127.0.0.0/8(只认 0-255 的合法字节,不把 127.999 也算进去)
|
||||
const m = /^127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
||||
if (m) {
|
||||
const ok = m.slice(1).every((octet) => Number(octet) <= 255);
|
||||
if (ok) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 决议本次请求该用哪个代理。
|
||||
*
|
||||
* @param opts `system` 显式传入时不做探测(单测用);传 `null` 表示「确认没有系统代理」。
|
||||
*/
|
||||
export async function resolveProxy(
|
||||
opts: {
|
||||
targetUrl: string;
|
||||
env?: Record<string, string | undefined>;
|
||||
system?: SystemProxyInfo | null;
|
||||
} = { targetUrl: '' },
|
||||
): Promise<ProxyResolution> {
|
||||
const { targetUrl } = opts;
|
||||
const env = opts.env ?? process.env;
|
||||
const notes: string[] = [];
|
||||
const noProxy = NO_PROXY_KEYS.map((k) => env?.[k]).find(Boolean) ?? '';
|
||||
if (noProxy && isBypassed(targetUrl, noProxy)) {
|
||||
return { proxy: null, source: null, notes: [`目标命中 NO_PROXY=${noProxy},按直连处理`] };
|
||||
}
|
||||
// 回环/链路本地目标恒直连(理由见 isLoopbackTarget:代理的另一侧没有这台机器的 localhost)
|
||||
if (isLoopbackTarget(targetUrl)) {
|
||||
return { proxy: null, source: null, notes: ['目标为本机回环地址,按直连处理(代理另一侧的 localhost 不是本机)'] };
|
||||
}
|
||||
|
||||
const fromEnv = pickEnvProxy(env ?? {}, targetUrl);
|
||||
notes.push(...fromEnv.notes);
|
||||
if (fromEnv.url) return { proxy: fromEnv.url, source: fromEnv.source, notes };
|
||||
|
||||
const sys = opts.system === undefined ? await systemProxy() : opts.system;
|
||||
if (sys?.url) {
|
||||
notes.push(...(sys.notes ?? []));
|
||||
if (sys.bypass && isBypassed(targetUrl, sys.bypass)) {
|
||||
notes.push(`目标命中系统代理的绕过列表(${sys.bypass}),按直连处理`);
|
||||
return { proxy: null, source: null, notes };
|
||||
}
|
||||
const u = normalizeProxyUrl(String(sys.url));
|
||||
if (u) return { proxy: u, source: 'system', notes };
|
||||
notes.push(`系统代理 ${sys.url} 不受支持(仅支持 http/https 代理),已按直连处理`);
|
||||
return { proxy: null, source: null, notes };
|
||||
}
|
||||
notes.push(...(sys?.notes ?? []));
|
||||
return { proxy: null, source: null, notes };
|
||||
}
|
||||
|
||||
/**
|
||||
* 造一个 `createConnection`:向代理发 CONNECT 建隧道,https 目标再在隧道上叠 TLS。
|
||||
*
|
||||
* 返回的 socket 交给 `http.request` 使用(那已经是 TLS socket,故用 `http` 模块)。
|
||||
*
|
||||
* ## ⚠️ 为什么必须自己管 `signal`(真机踩过)
|
||||
* `createConnection` 的回调**返回之前**,外层 `http.request` 还没建立 —— 此时
|
||||
* 外层请求上的 `signal` / `timeout` 都**无人受理**。真机形态:代理 TCP 能连上、却对
|
||||
* CONNECT 或随后的 TLS 握手**永不响应**,于是「75s 超时」形同虚设,实际挂了 109s。
|
||||
* 所以:CONNECT 请求要带 signal,**拿到 socket 与 tlsSock 之后还要自己挂 abort**,
|
||||
* 失败/成功时都要摘掉监听,避免泄漏与误伤。
|
||||
*/
|
||||
export function tunnelConnection(
|
||||
proxy: URL,
|
||||
target: { protocol: string; hostname: string; port: string },
|
||||
signal: AbortSignal | undefined,
|
||||
): (opts: http.RequestOptions, cb: (err: Error | null, socket?: tls.TLSSocket) => void) => void {
|
||||
const pe = proxyEndpoint(proxy);
|
||||
return (_opts, cb) => {
|
||||
let settled = false;
|
||||
let tunnelSock: import('node:net').Socket | null = null;
|
||||
let tlsSock: tls.TLSSocket | null = null;
|
||||
|
||||
const detach = (): void => {
|
||||
if (signal) signal.removeEventListener('abort', onAbort);
|
||||
if (tunnelSock) tunnelSock.removeListener('error', onSockError);
|
||||
};
|
||||
const fail = (err: Error): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
detach();
|
||||
// 半开的隧道必须销毁,否则这个悬挂连接会一直占着(点几次就攒一堆)
|
||||
try {
|
||||
tlsSock?.destroy();
|
||||
tunnelSock?.destroy();
|
||||
} catch {
|
||||
/* 已经没了 */
|
||||
}
|
||||
cb(err);
|
||||
};
|
||||
const onSockError = (e: Error): void => fail(e);
|
||||
const onAbort = (): void => fail(signal?.reason instanceof Error ? signal.reason : new Error('请求已中止'));
|
||||
|
||||
if (signal?.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
|
||||
const port = target.port || (target.protocol === 'https:' ? '443' : '80');
|
||||
const headers: Record<string, string> = { host: `${target.hostname}:${port}` };
|
||||
const auth = proxyAuthHeader(proxy);
|
||||
if (auth) headers['proxy-authorization'] = auth;
|
||||
|
||||
const req = http.request({
|
||||
host: pe.host,
|
||||
port: pe.port,
|
||||
method: 'CONNECT',
|
||||
path: `${target.hostname}:${port}`,
|
||||
headers,
|
||||
agent: false,
|
||||
signal,
|
||||
});
|
||||
req.once('connect', (res, socket, head) => {
|
||||
tunnelSock = socket;
|
||||
if (res.statusCode !== 200) {
|
||||
fail(new Error(`代理拒绝 CONNECT(HTTP ${res.statusCode})`));
|
||||
return;
|
||||
}
|
||||
if (head && head.length) socket.unshift(head);
|
||||
if (target.protocol !== 'https:') {
|
||||
settled = true;
|
||||
detach();
|
||||
cb(null, socket as unknown as tls.TLSSocket);
|
||||
return;
|
||||
}
|
||||
socket.once('error', onSockError);
|
||||
tlsSock = tls.connect({ socket, servername: target.hostname });
|
||||
tlsSock.once('secureConnect', () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
detach();
|
||||
cb(null, tlsSock!);
|
||||
});
|
||||
tlsSock.once('error', onSockError);
|
||||
});
|
||||
req.once('error', onSockError);
|
||||
req.end();
|
||||
};
|
||||
}
|
||||
|
||||
/** 单跳响应(`url` 由 httpGet 在跟随重定向后补上)。 */
|
||||
type SingleResponse = Omit<HttpResponse, 'url'>;
|
||||
|
||||
/** 单跳请求(不跟随重定向)。 */
|
||||
function once(
|
||||
urlStr: string,
|
||||
{ proxy, timeoutMs, signal, maxBytes }: { proxy: URL | null; timeoutMs: number; signal: AbortSignal; maxBytes?: number },
|
||||
): Promise<SingleResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const u = new URL(urlStr);
|
||||
const isHttps = u.protocol === 'https:';
|
||||
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
||||
reject(new Error(`不支持的协议:${u.protocol}`));
|
||||
return;
|
||||
}
|
||||
// ⚠️ 手搭 options,**不要**把 URL 对象当第一参再覆盖字段 —— 传 URL 时 Node 会用
|
||||
// URL 里的 host/port/path 覆盖后面的同名字段(原型实测踩过:代理形同无效、退化成直连)。
|
||||
const opts: http.RequestOptions = {
|
||||
method: 'GET',
|
||||
signal,
|
||||
timeout: timeoutMs,
|
||||
host: u.hostname,
|
||||
port: u.port || (isHttps ? 443 : 80),
|
||||
path: `${u.pathname}${u.search}`,
|
||||
headers: { accept: '*/*', 'user-agent': UA, host: u.host },
|
||||
};
|
||||
|
||||
let req: http.ClientRequest;
|
||||
if (proxy && !isHttps) {
|
||||
// http 目标经代理 → 绝对形态请求行,且请求行交给代理
|
||||
const pe = proxyEndpoint(proxy);
|
||||
opts.host = pe.host;
|
||||
opts.port = pe.port;
|
||||
opts.path = u.href;
|
||||
const auth = proxyAuthHeader(proxy);
|
||||
if (auth) (opts.headers as Record<string, string>)['proxy-authorization'] = auth;
|
||||
req = http.request(opts);
|
||||
} else if (proxy) {
|
||||
opts.createConnection = tunnelConnection(proxy, u, signal) as never;
|
||||
req = http.request(opts);
|
||||
} else {
|
||||
req = (isHttps ? https : http).request(opts);
|
||||
}
|
||||
|
||||
req.once('timeout', () => req.destroy(new Error(`请求超时(${timeoutMs}ms 无响应)`)));
|
||||
req.once('response', (res) => {
|
||||
// 上限第一闸:content-length 声明超限 → 根本不开始读 body
|
||||
const declared = Number(res.headers['content-length'] ?? '0');
|
||||
if (maxBytes !== undefined && declared > maxBytes) {
|
||||
res.destroy(new Error(`响应体超过上限 ${maxBytes} 字节(content-length=${declared}),已中断`));
|
||||
return;
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
res.on('data', (c: Buffer) => {
|
||||
// 上限第二闸(流式):服务端谎报/不报 content-length 时,收到即累计,
|
||||
// 超限立刻断,不让无界数据先把被控端内存吃爆
|
||||
total += c.length;
|
||||
if (maxBytes !== undefined && total > maxBytes) {
|
||||
res.destroy(new Error(`响应体超过上限 ${maxBytes} 字节,已中断`));
|
||||
return;
|
||||
}
|
||||
chunks.push(c);
|
||||
});
|
||||
res.once('error', reject);
|
||||
res.once('end', () =>
|
||||
resolve({
|
||||
status: res.statusCode ?? 0,
|
||||
statusText: res.statusMessage ?? '',
|
||||
headers: res.headers as Record<string, unknown>,
|
||||
body: Buffer.concat(chunks),
|
||||
}),
|
||||
);
|
||||
});
|
||||
req.once('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GET 一个 URL(跟随重定向)。
|
||||
*
|
||||
* 与 `fetch(url, {redirect:'follow'})` 的差异:不自动解压(不发 `accept-encoding`,
|
||||
* 服务端会回 identity)、不解析 cookies —— 这两条对本用途都无影响。
|
||||
*
|
||||
* 整个重定向链共享**同一个**超时预算与 abort 信号(而不是每跳各自计时),
|
||||
* 否则 5 跳 × 30s = 150s 会穿过上层的 RPC 超时。
|
||||
*
|
||||
* @param maxBytes 响应体流式上限(累计字节);超限立即中断。缺省不设限。
|
||||
*/
|
||||
export async function httpGet(
|
||||
url: string,
|
||||
{
|
||||
proxy = null,
|
||||
timeoutMs = 30_000,
|
||||
maxRedirects = 5,
|
||||
maxBytes,
|
||||
}: { proxy?: URL | null; timeoutMs?: number; maxRedirects?: number; maxBytes?: number } = {},
|
||||
): Promise<HttpResponse> {
|
||||
const ac = new AbortController();
|
||||
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
||||
try {
|
||||
let current = String(url);
|
||||
for (let hop = 0; ; hop += 1) {
|
||||
const res = await once(current, { proxy, timeoutMs, signal: ac.signal, maxBytes });
|
||||
const loc = res.headers?.['location'];
|
||||
if (REDIRECT_STATUS.has(res.status) && typeof loc === 'string' && hop < maxRedirects) {
|
||||
current = new URL(loc, current).href;
|
||||
continue;
|
||||
}
|
||||
return { ...res, url: current };
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { WebSocketServer, WebSocket, type RawData } from 'ws';
|
||||
import {
|
||||
Methods,
|
||||
PROTOCOL_VERSION,
|
||||
ipInAllowlist,
|
||||
generateNonce,
|
||||
verifyHmac,
|
||||
verifyNonce,
|
||||
@@ -26,8 +27,12 @@ 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 { createRateLimiter } from './util/rate-limit.js';
|
||||
import type { AgentConfig } from './config.js';
|
||||
import type { TlsMaterial } from './certs.js';
|
||||
|
||||
@@ -42,6 +47,10 @@ interface ConnState {
|
||||
authorized: string[] | null;
|
||||
/** 来源地址(审计用) */
|
||||
remote: string;
|
||||
/** v12:连接标识,用于清理该连接创建的事件订阅 */
|
||||
connId: string;
|
||||
/** v2.0.0:末次活跃时间(任何入站消息),空闲断开的依据 */
|
||||
lastActiveAt: number;
|
||||
}
|
||||
|
||||
export interface AgentServer {
|
||||
@@ -56,31 +65,47 @@ 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 {
|
||||
const capabilityRegistry = createCapabilityRegistry(cfg);
|
||||
/** v3:本次实例采用的认证模式(psk 向后兼容 / ed25519 零信任) */
|
||||
const authMode: 'psk' | 'ed25519' = cfg.auth_mode === 'ed25519' ? 'ed25519' : 'psk';
|
||||
const aclPolicy = cfg.acl ?? { default_effect: 'deny' as const, clients: [] };
|
||||
/** 只要配置了 ACL 就启用能力级授权(无论 psk 还是 ed25519)。 */
|
||||
const hasAcl = Boolean(cfg.acl?.clients?.length);
|
||||
/**
|
||||
* ACL 策略归一化。
|
||||
*
|
||||
* ⚠️ A-01(真机实测,P0):`clients` 必须在**这里**补齐成数组。
|
||||
* 原实现只写 `cfg.acl ?? {…, clients: []}` —— 当用户配置 `{"acl":{"default_effect":"deny"}}`
|
||||
* (很自然的写法,省略空的 clients)时,`cfg.acl` 非空 → `??` 不兜底 →
|
||||
* `aclPolicy.clients` 为 `undefined` → 后面无条件执行 `.find()` → **每次 invoke 都崩掉被控端进程**。
|
||||
*/
|
||||
const aclPolicy = {
|
||||
default_effect: cfg.acl?.default_effect === 'allow' ? ('allow' as const) : ('deny' as const),
|
||||
clients: Array.isArray(cfg.acl?.clients) ? cfg.acl.clients : [],
|
||||
};
|
||||
/**
|
||||
* 只要**配置了 ACL 对象**就启用能力级授权(无论 psk 还是 ed25519)。
|
||||
*
|
||||
* ⚠️ A-02(真机实测,P1):判据必须是「对象是否存在」,**不能**用 `clients.length`。
|
||||
* 原实现 `Boolean(cfg.acl?.clients?.length)` 在 `{"acl":{"default_effect":"deny","clients":[]}}`
|
||||
* 时得到 `false` → 整段 ACL 校验被跳过 → **fail-open 全放行**(配置写着默认拒绝却全通)。
|
||||
* 空 clients 的正确语义是「没有任何调用方被授权」,即拒绝一切(由 default_effect 决定)。
|
||||
*/
|
||||
const hasAcl = Boolean(cfg.acl);
|
||||
|
||||
/** 按调用方的滑动窗口限速(每分钟)。 */
|
||||
const rateBuckets = new Map<string, number[]>();
|
||||
function allowByRate(clientId: string, limitPerMin?: number, bucket?: string): boolean {
|
||||
if (!limitPerMin || limitPerMin <= 0) return true;
|
||||
const key = bucket ? `${clientId}|${bucket}` : clientId;
|
||||
const now = Date.now();
|
||||
const recent = (rateBuckets.get(key) ?? []).filter((t) => now - t < 60_000);
|
||||
if (recent.length >= limitPerMin) {
|
||||
rateBuckets.set(key, recent);
|
||||
return false;
|
||||
}
|
||||
recent.push(now);
|
||||
rateBuckets.set(key, recent);
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* 按调用方的滑动窗口限速(每分钟)。
|
||||
*
|
||||
* ⚠️ A-P0-2(2026-10-25):原实现 `allowByRate(clientId, limit, String(limit))`
|
||||
* 把**限速值**当桶名 —— 同限速值的不同能力共用一个桶(互相挤占/漏过),
|
||||
* 且 `rateBuckets` 只增不删(psk 模式 client_id 自报 → 可枚举造键 → 内存无界增长)。
|
||||
* 现抽到 `util/rate-limit.ts`:桶名 = 能力名;过期键顺手删 + 容量上限淘汰最旧。
|
||||
*/
|
||||
const rateLimiter = createRateLimiter();
|
||||
const levelOrder: Record<LogLevel, number> = { debug: 0, info: 1, warn: 2 };
|
||||
const log = (level: LogLevel, msg: string): void => {
|
||||
if (levelOrder[level] >= levelOrder[cfg.log_level]) {
|
||||
@@ -88,7 +113,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));
|
||||
@@ -100,14 +127,37 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
|
||||
function handleHello(ws: WebSocket, state: ConnState, req: RpcRequest): void {
|
||||
const params = req.params as HelloParams | undefined;
|
||||
const proto = params?.protocol ?? '';
|
||||
// ⚠️ B-01(真机实测,P1):`protocol` 必须先做**类型**校验再 `.split()`。
|
||||
// 原实现 `params?.protocol ?? ''` 对 `protocol: 123` 得到数字 → `proto.split` 抛
|
||||
// TypeError → 未捕获的 rejection → Node ≥22 直接终止被控端进程(**免认证即可打崩**)。
|
||||
const rawProto = params?.protocol;
|
||||
if (typeof rawProto !== 'string' || rawProto === '') {
|
||||
log('warn', `握手失败:protocol 非法(${typeof rawProto})`);
|
||||
audit({
|
||||
type: 'request.rejected',
|
||||
remote: state.remote,
|
||||
status: 'denied',
|
||||
reason: `hello.protocol 类型非法: ${typeof rawProto}`,
|
||||
});
|
||||
sendError(ws, req.id, ErrorCodes.PARAM_INVALID, 'hello 的 protocol 必须是形如 "1.0" 的字符串', {
|
||||
got: typeof rawProto,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const proto = rawProto;
|
||||
if (proto.split('.')[0] !== PROTOCOL_VERSION.split('.')[0]) {
|
||||
audit({
|
||||
type: 'request.rejected',
|
||||
remote: state.remote,
|
||||
status: 'denied',
|
||||
reason: `协议版本不兼容: 客户端 ${proto}`,
|
||||
});
|
||||
sendError(ws, req.id, ErrorCodes.PROTOCOL_MISMATCH, `协议版本不兼容: 客户端 ${proto},服务端 ${PROTOCOL_VERSION}`);
|
||||
return;
|
||||
}
|
||||
state.nonce = generateNonce();
|
||||
state.nonceExpiresAt = Date.now() + NONCE_TTL_MS;
|
||||
state.clientId = params?.client_id ?? null;
|
||||
state.clientId = typeof params?.client_id === 'string' ? params.client_id : null;
|
||||
// 新一轮握手开始:清除上一轮的认证态与授权
|
||||
// (Hub 模式下同一连接会被不同控制端先后复用,必须重置,否则存在越权风险)
|
||||
state.authenticated = false;
|
||||
@@ -117,33 +167,45 @@ 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;
|
||||
}
|
||||
// ⚠️ B-02(真机实测,P1):nonce / hmac / signature 都要先做类型校验。
|
||||
// `params?.nonce !== state.nonce` 对数字、对象等都只会走「不匹配」分支 —— 这步安全;
|
||||
// 真正致命的是下一段:把 `hmac:1` 交给 `verifyHmac` → `Buffer.from(1, 'hex')` 抛
|
||||
// `ERR_INVALID_ARG_TYPE` → 未捕获 rejection → 进程终止(免认证即可打崩)。
|
||||
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 ?? '?';
|
||||
const clientId = typeof params.client_id === 'string' ? 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 ?? '')) {
|
||||
if (!verifyNonce(client.pubkey, state.nonce, typeof params.signature === 'string' ? 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 +213,45 @@ 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 模式下才有意义。
|
||||
//
|
||||
// ⚠️ B-03(真机实测,P1):必须用 **own-property** 判断,不能直接 `cfg.keys?.[clientId]`。
|
||||
// `client_id:"constructor"` 会沿原型链取到 `Object.prototype.constructor`(一个函数)
|
||||
// 并被当作 HMAC 密钥 → `Buffer.from(function…)` 抛错 → 进程终止。
|
||||
const hasScopedKey = Boolean(cfg.keys && Object.prototype.hasOwnProperty.call(cfg.keys, clientId));
|
||||
const scopedKey = hasScopedKey ? cfg.keys![clientId] : undefined;
|
||||
const keyForClient = scopedKey ?? cfg.key;
|
||||
const keyKind = scopedKey ? 'scoped' : cfg.keys ? 'shared-fallback' : 'shared';
|
||||
// ⚠️ B-02:hmac 同样要做类型校验 + verifyHmac 自身不得把异常泄漏到顶层
|
||||
const hmac = typeof params.hmac === 'string' ? params.hmac : '';
|
||||
let hmacOk = false;
|
||||
try {
|
||||
hmacOk = Boolean(keyForClient) && hmac !== '' && verifyHmac(keyForClient as string, state.nonce, hmac);
|
||||
} catch (err) {
|
||||
log('warn', `鉴权失败(HMAC 计算异常):${err instanceof Error ? err.message : String(err)}`);
|
||||
hmacOk = false;
|
||||
}
|
||||
if (!hmacOk) {
|
||||
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,16 +260,31 @@ 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> {
|
||||
if (!state.authenticated) {
|
||||
audit({
|
||||
type: 'request.rejected',
|
||||
remote: state.remote,
|
||||
status: 'denied',
|
||||
reason: '未完成认证即调用能力',
|
||||
});
|
||||
sendError(ws, req.id, ErrorCodes.AUTH_REQUIRED, '未完成认证,请先握手');
|
||||
return;
|
||||
}
|
||||
const params = req.params as InvokeParams | undefined;
|
||||
const name = params?.capability ?? '';
|
||||
const name = typeof params?.capability === 'string' ? params.capability : '';
|
||||
|
||||
// 能力级 ACL:只要被控端配置了 ACL 即执行(deny 优先 → allow → 默认拒绝)。
|
||||
// 注:psk 模式下 client_id 由客户端自报,此为「配置级约束」;对抗恶意调用方仍需 ed25519 绑定身份。
|
||||
@@ -213,6 +312,7 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
}
|
||||
|
||||
// v3+:按调用方滑动窗口限速(ACL 的 rate_limits 按能力覆盖,未命中回退 max_calls_per_min)
|
||||
// 注:aclPolicy.clients 已在 createAgentCore 里归一成数组(A-01),这里取不到就是 undefined。
|
||||
const clientRule = aclPolicy.clients.find((c) => c.client_id === state.clientId);
|
||||
|
||||
// v11:来源 IP 白/黑名单 + 生效时段(仅在已配置该 client 规则时生效)
|
||||
@@ -255,8 +355,11 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
}
|
||||
}
|
||||
|
||||
// 限速键 = `${认证身份 ?? 远端地址}:${能力名}`:
|
||||
// ① 不同能力各占一桶(同限速值不再互挤/漏过);
|
||||
// ② 未认证身份(psk 自报缺失)时退化按远端地址,不用空串把所有人混为一桶。
|
||||
const rateLimit = clientRule ? rateLimitFor(clientRule, name) : undefined;
|
||||
if (!allowByRate(state.clientId ?? '', rateLimit, String(rateLimit ?? ''))) {
|
||||
if (!rateLimiter.allow(state.clientId ?? state.remote, rateLimit, name)) {
|
||||
log('warn', `限速拒绝 client_id=${state.clientId} capability=${name}`);
|
||||
audit({
|
||||
type: 'rate.limited',
|
||||
@@ -276,6 +379,14 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
const cap = findCapability(name);
|
||||
const handler = capabilityRegistry[name];
|
||||
if (!cap || !handler) {
|
||||
audit({
|
||||
type: 'request.rejected',
|
||||
client_id: state.clientId ?? undefined,
|
||||
remote: state.remote,
|
||||
capability: name,
|
||||
status: 'denied',
|
||||
reason: `不支持的能力: ${name}`,
|
||||
});
|
||||
sendError(ws, req.id, ErrorCodes.CAPABILITY_NOT_FOUND, `不支持的能力: ${name}`, { capability: name });
|
||||
return;
|
||||
}
|
||||
@@ -283,6 +394,15 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
const args = (params?.args ?? {}) as Record<string, unknown>;
|
||||
const errors = validate(args, cap.params_schema);
|
||||
if (errors.length > 0) {
|
||||
// ⚠️ U-01:拒绝必须留痕(探测行为若不留审计,事后无法复盘被谁、用什么参数试过)
|
||||
audit({
|
||||
type: 'request.rejected',
|
||||
client_id: state.clientId ?? undefined,
|
||||
remote: state.remote,
|
||||
capability: name,
|
||||
status: 'denied',
|
||||
reason: `参数校验失败: ${errors.slice(0, 3).join('; ')}`,
|
||||
});
|
||||
sendError(ws, req.id, ErrorCodes.PARAM_INVALID, '参数校验失败', { errors });
|
||||
return;
|
||||
}
|
||||
@@ -291,7 +411,12 @@ 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,
|
||||
clientId: state.clientId ?? '',
|
||||
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({
|
||||
@@ -336,10 +461,18 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
try {
|
||||
req = JSON.parse(text) as RpcRequest;
|
||||
} catch {
|
||||
audit({ type: 'request.rejected', remote: states.get(ws)?.remote, status: 'denied', reason: '无法解析 JSON' });
|
||||
sendError(ws, null, ErrorCodes.PARSE_ERROR, '无法解析 JSON');
|
||||
return;
|
||||
}
|
||||
if (req?.jsonrpc !== '2.0' || typeof req.id !== 'string' || typeof req.method !== 'string') {
|
||||
// 信封非法:可能是扫描器/协议错误,也留一条痕(原实现完全静默)
|
||||
audit({
|
||||
type: 'request.rejected',
|
||||
remote: states.get(ws)?.remote,
|
||||
status: 'denied',
|
||||
reason: `请求信封无效(jsonrpc=${typeof req?.jsonrpc} id=${typeof req?.id} method=${typeof req?.method})`,
|
||||
});
|
||||
sendError(ws, typeof req?.id === 'string' ? req.id : null, ErrorCodes.INVALID_REQUEST, '请求信封无效');
|
||||
return;
|
||||
}
|
||||
@@ -358,6 +491,12 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
return;
|
||||
case Methods.Capabilities:
|
||||
if (!state.authenticated) {
|
||||
audit({
|
||||
type: 'request.rejected',
|
||||
remote: state.remote,
|
||||
status: 'denied',
|
||||
reason: '未认证即拉取能力清单',
|
||||
});
|
||||
sendError(ws, req.id, ErrorCodes.AUTH_REQUIRED, '未完成认证');
|
||||
return;
|
||||
}
|
||||
@@ -367,12 +506,63 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
sendResult(ws, req.id, { pong: true, ts: Date.now() });
|
||||
return;
|
||||
default:
|
||||
audit({
|
||||
type: 'request.rejected',
|
||||
client_id: state.clientId ?? undefined,
|
||||
remote: state.remote,
|
||||
status: 'denied',
|
||||
reason: `未知方法: ${req.method}`,
|
||||
});
|
||||
sendError(ws, req.id, ErrorCodes.METHOD_NOT_FOUND, `未知方法: ${req.method}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 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 +570,51 @@ 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) => {
|
||||
void handleMessage(ws, raw.toString());
|
||||
// v2.0.0:任何入站消息都算活跃(空闲断开以"完全静默"为准,不是"没有调用能力")
|
||||
const st = states.get(ws);
|
||||
if (st) st.lastActiveAt = Date.now();
|
||||
// ⚠️ 崩溃链根因(P0/P1 多条共用):原来这里是 `void handleMessage(...)` ——
|
||||
// Promise 一旦 reject 就成为 unhandled rejection,Node ≥22 默认**终止进程**。
|
||||
// 「畸形 hello」「畸形 auth」之所以能把被控端打崩,靠的就是这条路径。
|
||||
// 这里显式兜底:任何处理异常只记日志 + 回一个错误响应,绝不冒泡到进程级。
|
||||
void handleMessage(ws, raw.toString()).catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
log('warn', `处理消息时发生未预期异常(已拦截,不影响进程):${msg}`);
|
||||
try {
|
||||
sendError(ws, null, ErrorCodes.EXECUTION_FAILED, '服务端处理消息时发生未预期异常', { detail: msg });
|
||||
} catch {
|
||||
/* 连接可能已关闭 */
|
||||
}
|
||||
});
|
||||
});
|
||||
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('close', () => log('info', `连接关闭: ${remote}`));
|
||||
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 +634,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,42 @@
|
||||
/**
|
||||
* Windows **DPI 感知** PowerShell 前置段(物理像素坐标的唯一真源)。
|
||||
*
|
||||
* ## 为什么必须有
|
||||
*
|
||||
* PowerShell 默认是「**DPI 不感知**」进程。在高 DPI(如 200% 缩放)下,这种进程里
|
||||
* **同时存在两套坐标系**,而且互不提醒:
|
||||
*
|
||||
* | 调用 | 该进程看到的 |
|
||||
* |---|---|
|
||||
* | `[System.Windows.Forms.Screen]::Bounds` | **逻辑**尺寸(物理 2560×1440 → 报 1280×720) |
|
||||
* | `[System.Windows.Forms.SystemInformation]::VirtualScreen` | **逻辑**(同上) |
|
||||
* | `CopyFromScreen(0,0,1280,720)` | **物理**裁剪 → 只截到屏幕左上 1/4 |
|
||||
* | `SetCursorPos(552,745)` | 落点实际在物理 (1104, 1490)(被系统 ×2) |
|
||||
* | UIA(`screen.find`) | **物理**(与进程 DPI 感知无关) |
|
||||
*
|
||||
* 于是最要命的一条链路会静默断掉:`screen.find` 返回物理坐标 → 直接喂给
|
||||
* `input.mouse.click` → **点到两倍远的地方**(还可能被屏幕边界截断)。
|
||||
* 「找得到、点不准」,而且不报任何错。
|
||||
*
|
||||
* ## 用法
|
||||
*
|
||||
* 在 **任何** `System.Windows.Forms` / 截屏 / 光标调用**之前**执行本段;
|
||||
* 同一进程内只需一次(`SetProcessDPIAware` 幂等)。新起的 PowerShell 每次都要带上。
|
||||
*
|
||||
* ## 真机实证(2026-10-12)
|
||||
*
|
||||
* 某虚拟化平台上的 Win11 ARM 虚拟机,宿主缩放 200%,物理 2560×1440:
|
||||
* - 缺省 `screen.capture` 只拿到 1280×720(= 屏幕左上 1/4),且 `screen.info` 报 1280×720
|
||||
* - `input.mouse.move 100,100` → DPI 感知读回 **(200,200)**
|
||||
* - `input.mouse.move 552,745` → 实际落点 **(1104, 1439)**(截断到屏幕底部)
|
||||
* - 补上本段后:`screen.capture` 拿到完整 2560×1440;`screen.find` 的物理坐标
|
||||
* 可直接用于 `input.mouse.click`(同一坐标一次点中)
|
||||
*
|
||||
* ⚠️ 不要删。这不是「锦上添花」的兼容代码,而是坐标口径统一的**前提**。
|
||||
* 守卫见 `tests/unit/dpi-awareness.test.mjs`。
|
||||
*/
|
||||
export const PS_DPI_AWARE = [
|
||||
// Add-Type 现编译一个小 P/Invoke 壳;类名固定,每进程只应出现一次。
|
||||
`Add-Type -TypeDefinition 'using System.Runtime.InteropServices; public class NADpiAware { [DllImport("user32.dll")] public static extern bool SetProcessDPIAware(); }'`,
|
||||
`[void][NADpiAware]::SetProcessDPIAware()`,
|
||||
].join('\n');
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 平台能力守卫(跨平台最小实现)。
|
||||
*
|
||||
* ## 为什么要统一
|
||||
* 各能力原本各自判断平台,风格不一:
|
||||
* - `darwin.ts` 有 `assertDarwin()`
|
||||
* - `window.ts` 写 `if (platform === 'darwin') return macXxx()`,其余平台**静默走到 Windows 实现**,
|
||||
* 于是在 Linux 上表现为 `spawn /bin/bash ENOENT` 这类**误导性错误**(像是环境缺依赖,
|
||||
* 实际是"本平台根本没实现")。
|
||||
*
|
||||
* 统一后的语义:**没实现就明确说"不支持当前平台"**(`E_UNSUPPORTED_PLATFORM`),
|
||||
* 并带上「支持哪些平台」,让调用方一眼看懂,也让能力清单的"声明可用"不至于撒谎。
|
||||
*
|
||||
* ## 用法
|
||||
* ```ts
|
||||
* assertPlatform('screen.capture', 'win32', 'darwin');
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
|
||||
/** 人类可读的平台名(错误信息里用)。 */
|
||||
const PLATFORM_LABEL: Record<string, string> = {
|
||||
win32: 'Windows',
|
||||
darwin: 'macOS',
|
||||
linux: 'Linux',
|
||||
};
|
||||
|
||||
export function platformLabel(p: NodeJS.Platform): string {
|
||||
return PLATFORM_LABEL[p] ?? p;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前平台是否在允许集合内。
|
||||
*
|
||||
* 与 `assertPlatform` 的分工:这个用于**条件注册/条件分支**,那个用于**进入能力时兜底拒绝**。
|
||||
*/
|
||||
export function isPlatform(...allowed: NodeJS.Platform[]): boolean {
|
||||
return allowed.includes(process.platform);
|
||||
}
|
||||
|
||||
/**
|
||||
* 断言当前平台受支持;否则抛 `E_UNSUPPORTED_PLATFORM`。
|
||||
*
|
||||
* @param capability 能力名(写进错误信息,便于定位)
|
||||
* @param allowed 支持该能力的平台(如 `'win32', 'darwin'`)
|
||||
*/
|
||||
export function assertPlatform(capability: string, ...allowed: NodeJS.Platform[]): void {
|
||||
if (isPlatform(...allowed)) return;
|
||||
const supported = allowed.map(platformLabel).join(' / ');
|
||||
throw new CapabilityError(
|
||||
ErrorCodes.UNSUPPORTED_PLATFORM,
|
||||
`${capability} 未在 ${platformLabel(process.platform)} 上实现(仅支持 ${supported})`,
|
||||
{
|
||||
platform: process.platform,
|
||||
supported,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅 Windows 的能力(PowerShell / Win32 实现)。
|
||||
*
|
||||
* 覆盖:截屏与录屏、键鼠注入、剪贴板、音量、软件装卸(winget)、窗口与 UIA 语义。
|
||||
* 注意 **不含** `system.info` / `system.status` / `system.process.list` / `shell.exec` / `fs.*` ——
|
||||
* 这些有分平台实现,在 Linux 上可用。
|
||||
*/
|
||||
export function assertWindows(capability: string): void {
|
||||
assertPlatform(capability, 'win32');
|
||||
}
|
||||
|
||||
/**
|
||||
* 图形界面类能力(Windows 或 macOS 有实现,Linux 目前没有)。
|
||||
*
|
||||
* 用于 `screen.*` / `window.*` / `input.*` 这类:在 Windows 走 Win32/UIA、
|
||||
* 在 macOS 走 AppleScript/Vision,**Linux 端尚未实现**(需 X11/Wayland 后端)。
|
||||
*/
|
||||
export function assertGuiPlatform(capability: string): void {
|
||||
assertPlatform(capability, 'win32', 'darwin');
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
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';
|
||||
import { isSafeBareWindowsPathArg } from './quote.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');
|
||||
// ⚠️ 这里**只能裸拼**路径:`-File` 的引号由 powershell.exe 自己的命令行解析器处理,
|
||||
// 塞 PS 单引号字面量进去不保证被剥掉。既然裸拼,就必须**显式**确认路径里没有
|
||||
// 会改变解析的字符(引号/$/&/|/^/…)—— 正常它由 mkdtempSync 生成,但 TMP 被设成
|
||||
// 怪路径时前提就破了,而且破了也是静默的(与 W-01 同类)。破前提就当场报错。
|
||||
if (!isSafeBareWindowsPathArg(scriptPath)) {
|
||||
throw new Error(
|
||||
`PowerShell 临时脚本路径含可能改变命令行解析的字符,拒绝拼接:${JSON.stringify(scriptPath)}` +
|
||||
'(请检查 TMP/TEMP 或用户目录名)',
|
||||
);
|
||||
}
|
||||
const r = await execCommand({
|
||||
command: `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ${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' };
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* 跨平台「安全字面量」工具 —— 把不可信字符串安全地嵌进 shell / PowerShell 脚本。
|
||||
*
|
||||
* ## 为什么必须有这个文件
|
||||
*
|
||||
* 本项目真机踩到过 **P0 级静默 RCE**:把 `JSON.stringify(title)` 直接插进 PowerShell
|
||||
* 双引号串,payload `x"; New-Item …; #` 就执行了任意命令 —— 而接口还返回「正常的业务错误」,
|
||||
* 调用方完全看不出异常(见 `window.ts` 的历史注释与 bug 报告 W-01)。
|
||||
*
|
||||
* 两条铁律(违反任一条都会重新引入漏洞):
|
||||
*
|
||||
* 1. **PowerShell 里 `\` 不是转义符**(那是 C/JS 的规则)。JSON 产出的 `\"` 会被 PS 解析成
|
||||
* 「字面反斜杠 + 字符串结束引号」,后面的内容就成了新语句。
|
||||
* 2. **PS 双引号串会做变量插值与子表达式展开**(`$x`、`$(...)`、反引号),
|
||||
* 而**单引号串什么都不展开** —— 这是唯一正确的选择。
|
||||
*
|
||||
* 因此:**任何**嵌入 PowerShell 的不可信值都必须走 `psLit()`;
|
||||
* 嵌入 bash/sh 的走 `shLit()`;嵌进命令行参数数组的(`spawn(file, args)`)根本不需要引用,
|
||||
* 那是首选方案,不要拼字符串。
|
||||
*/
|
||||
|
||||
/** PowerShell 单引号字面量:内部单引号翻倍,其余字符(含 `$`、反引号、反斜杠)全部原样。 */
|
||||
export function psLit(v: string): string {
|
||||
return `'${v.replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
/** PowerShell 数组字面量:`@('a','b')`。空数组产出 `@()`。 */
|
||||
export function psLitArray(values: readonly string[]): string {
|
||||
return `@(${values.map(psLit).join(',')})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* POSIX shell 单引号字面量:把 `'` 拆成 `'\''`(闭合 → 转义单引号 → 重开)。
|
||||
* 单引号内不会有变量展开、命令替换或反斜杠转义。
|
||||
*/
|
||||
export function shLit(v: string): string {
|
||||
return `'${v.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验单引号字面量的形状(供调用侧自检):首尾是 `'`,且内部**没有落单的单引号**。
|
||||
*
|
||||
* 原理:正确转义后内部每个 `'` 都成对(`''`),把 `''` 全部消去后不应再残留任何 `'`。
|
||||
* 只做形状检查,不替代真机验证。
|
||||
*/
|
||||
export function assertSingleQuotedLiteral(lit: string): boolean {
|
||||
if (!lit.startsWith("'") || !lit.endsWith("'")) return false;
|
||||
return !lit.slice(1, -1).replace(/''/g, '').includes("'");
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows 原生命令行参数的「裸传」前提校验。
|
||||
*
|
||||
* 有些位置**不能用** `psLit()`:例如 `powershell.exe -File <path>` —— 这一段的引号由
|
||||
* `powershell.exe` 自己的命令行解析器处理,塞 PS 单引号字面量进去不保证被剥掉。
|
||||
* 这种位置只能把路径**裸着**拼进命令行,于是必须保证它**本身含不了任何会改变语义的字符**。
|
||||
*
|
||||
* 真例(v2.0.3 审查发现,与 W-01 同类):`util/ps-helper.ts` 把 `mkdtempSync` 产出的临时脚本
|
||||
* 路径直接拼进 `-File`。那条路径由本进程生成、正常含不了引号,但**这是隐含前提**——
|
||||
* 一旦 `TMP`/`TEMP` 被设成怪路径(或用户目录名含特殊字符),前提就破了,而且是静默的。
|
||||
* 故把前提**显式化**:不满足就当场报错,而不是把可疑字符串拼进命令行。
|
||||
*/
|
||||
export function isSafeBareWindowsPathArg(v: string): boolean {
|
||||
if (v === '' || v.includes('\0')) return false;
|
||||
// 引号/反引号/$/&/|/</>/(/)/%/^/!/换行:任何一个都能改变 cmd.exe 或 PS 的解析
|
||||
if (/["'`$&|<>()%^!\r\n\t]/.test(v)) return false;
|
||||
// 结尾是单个反斜杠会在双引号包裹时吃掉闭引号(cmd 的经典怪癖)
|
||||
return !/\\$/.test(v);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 按调用方的滑动窗口限速器(每分钟)。
|
||||
*
|
||||
* ## 为什么从 server.ts 抽出来(A-P0-2,2026-10-25)
|
||||
* 原实现有两处缺陷,且逻辑埋在 `createAgentCore` 闭包里无法单测:
|
||||
*
|
||||
* 1. **桶 key 用错值**:原 `allowByRate(clientId, limit, String(limit))` 第三参
|
||||
* (bucket)传的是**限速值本身**。于是「screen.capture ≤ 5/min」与
|
||||
* 「app.install ≤ 5/min」两条同值规则共用 `clientId|5` 一个桶:
|
||||
* 截图 5 次后装软件也被限速(误伤),且两者总量仍按 5/min(漏过)。
|
||||
* 正确口径:key = `${身份}:${能力名}`。
|
||||
* 2. **桶只增不删**:`rateBuckets` Map 无任何清扫,key 永久留存。而 psk 模式下
|
||||
* `client_id` 由客户端**自报**(配置级约束)→ 外部可枚举任意 client_id 造键
|
||||
* → 长期运行内存无界增长。现在:每次允许判定顺手删过期键 + 容量上限淘汰最旧。
|
||||
*
|
||||
* ## 语义
|
||||
* - 窗口 60s 滑动:只数「最近 60 秒内」的调用;
|
||||
* - `limitPerMin` 为空/≤0 表示不限速(直接放行,**不建桶** —— 与旧行为一致);
|
||||
* - 身份用「认证身份 ?? 远端地址」:psk 自报 client_id 不可信,但限速键仍以它为纲
|
||||
* (配了限速就说明该 client_id 已在 ACL 里登记),无身份的调用方退化按远端地址。
|
||||
*/
|
||||
|
||||
/** 桶容量上限:超出淘汰最旧(Map 保持插入顺序,首键即最旧)。 */
|
||||
const DEFAULT_MAX_KEYS = 10_000;
|
||||
|
||||
const WINDOW_MS = 60_000;
|
||||
|
||||
export interface RateLimiter {
|
||||
/**
|
||||
* 请求一次调用配额。
|
||||
*
|
||||
* @param identity 调用方身份(认证身份 ?? 远端地址)
|
||||
* @param limitPerMin 每分钟上限;空/≤0 = 不限速
|
||||
* @param bucket 桶名(能力名)—— 不同能力各占一桶,互不挤占
|
||||
*/
|
||||
allow(identity: string, limitPerMin: number | undefined, bucket: string): boolean;
|
||||
/** 当前桶数(供测试与观测)。 */
|
||||
size(): number;
|
||||
}
|
||||
|
||||
export interface RateLimiterOptions {
|
||||
/** 桶容量上限(超出淘汰最旧)。默认 10_000。 */
|
||||
maxKeys?: number;
|
||||
/** 时钟注入(单测用假时钟推进 60s 窗口,不必真等)。 */
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export function createRateLimiter(opts: RateLimiterOptions = {}): RateLimiter {
|
||||
const maxKeys = opts.maxKeys ?? DEFAULT_MAX_KEYS;
|
||||
const now = opts.now ?? Date.now;
|
||||
const buckets = new Map<string, number[]>();
|
||||
|
||||
return {
|
||||
allow(identity: string, limitPerMin: number | undefined, bucket: string): boolean {
|
||||
if (!limitPerMin || limitPerMin <= 0) return true;
|
||||
const key = `${identity}|${bucket}`;
|
||||
const t = now();
|
||||
|
||||
// 清扫①:过期键直接删(只增不删 = 内存无界增长;自报 client_id 可枚举造键)
|
||||
for (const [k, times] of buckets) {
|
||||
if (times.length === 0 || t - times[times.length - 1]! >= WINDOW_MS) buckets.delete(k);
|
||||
}
|
||||
// 清扫②:容量上限,超出淘汰最旧(插入序 = 时间序,首键最旧)
|
||||
while (buckets.size >= maxKeys) {
|
||||
const oldest = buckets.keys().next();
|
||||
if (oldest.done) break;
|
||||
buckets.delete(oldest.value);
|
||||
}
|
||||
|
||||
const recent = (buckets.get(key) ?? []).filter((ts) => t - ts < WINDOW_MS);
|
||||
if (recent.length >= limitPerMin) {
|
||||
buckets.set(key, recent);
|
||||
return false;
|
||||
}
|
||||
recent.push(t);
|
||||
buckets.set(key, recent);
|
||||
return true;
|
||||
},
|
||||
size(): number {
|
||||
return buckets.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/cli",
|
||||
"version": "0.1.0",
|
||||
"version": "2.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
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,
|
||||
// 原来这里直接扫 `process.argv.includes('--no-tls')` —— 绕过了解析器,
|
||||
// 于是「帮助里承诺的选项」与「解析器认识的选项」是两套(L-01 同类)。
|
||||
tls: !opts.noTls,
|
||||
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,576 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { CapabilityNames, type InvokeResult } from '@nodeagent/protocol';
|
||||
import { printJson, probeOnce, withClient } from '../core.js';
|
||||
|
||||
/**
|
||||
* `nodeagent rollback` —— 从 LKG(最后已知可用版本)恢复被控端(ARCH T03 / PRD P0-2)。
|
||||
*
|
||||
* ## 能力圈边界(必须向用户讲清楚,见 DELIVERY-PIPELINE §6 与 A-P0-6)
|
||||
* 本命令本质是 **RPC 命令,要求被控端可达**。它覆盖的场景是
|
||||
* 「新版本能启动但需要回退(行为异常 / 主动避险)」;
|
||||
* 「新版本进程完全起不来」时没有监听可连,CLI 无能为力 —— 那种场景走:
|
||||
* ① 看门狗自动恢复(scripts/startup-guard.mjs:启动连续失败 N 次自动从 LKG 恢复);
|
||||
* ② 物理/带外恢复(DELIVERY-PIPELINE 附录 C:备份文件手工还原)。
|
||||
* 本命令**不提供自动回滚**:无法归因、回滚循环、失控三问见 DELIVERY-PIPELINE §6。
|
||||
*
|
||||
* ## 执行流程(对齐 deploy 的成熟姿势,见 cmdDeploy)
|
||||
* ① system.info → agent_script / build.hash / lkg
|
||||
* ② 无 LKG → 报错退出(exit 2),零改动
|
||||
* ③ current == LKG.hash → 「当前即 LKG」提示退出(提示 --backup 逃生门)
|
||||
* ④ fs.list 枚举备份候选(`<basename>.bak` / `<basename>.bak-<ts>`),mtime 新→早
|
||||
* ⑤ 流式读 + sha256 指纹匹配(全量 hash_full 优先,老记录回落 12 位前缀)
|
||||
* —— 无匹配 → 列候选指纹报错退出(exit 2,不动任何文件)
|
||||
* ⑥ 覆盖写入(同 deploy:首块 append:false + 续块 append:true)→ 回读校验
|
||||
* ├─ 一致 → ⑦
|
||||
* └─ 不一致 → 用回滚源再覆盖一次(还原到回滚源内容=原状)→ 报错退出(exit 1)
|
||||
* ⑦ system.agent.restart(独立连接,吞连接中断)
|
||||
* ⑧ probeOnce 轮询直到 build.hash == 期望指纹(90s 墙钟)
|
||||
*
|
||||
* ## --dry-run
|
||||
* 只做 ①~⑤(含匹配结论),**零写入**:无 fs.write / 无 restart。
|
||||
*/
|
||||
|
||||
// ---------- 公开类型 ----------
|
||||
|
||||
/** LKG 记录(system.info.lkg.lkg)。hash_full/backup_glob 为 v2.1.0 只增字段,老记录没有。 */
|
||||
export interface LkgInfo {
|
||||
/** 入口脚本 sha256 前 12 位(与 system.info 的 build.hash 同口径) */
|
||||
hash: string;
|
||||
/** sha256 全量 64 位(v2.1.0 起记录;老记录无此字段,回落 12 位前缀比对) */
|
||||
hash_full?: string;
|
||||
bytes: number;
|
||||
script_path: string;
|
||||
/** 回滚内容候选的文件名模式(`<basename>.bak*`,v2.1.0 起记录) */
|
||||
backup_glob?: string;
|
||||
recorded_at: number;
|
||||
node_version: string;
|
||||
platform: string;
|
||||
}
|
||||
|
||||
/** system.info 里 rollback 关心的字段(其余字段忽略)。 */
|
||||
export interface SystemInfoLike {
|
||||
agent_script?: string;
|
||||
build?: { hash?: string };
|
||||
lkg?: {
|
||||
lkg: LkgInfo | null;
|
||||
current_hash: string | null;
|
||||
matches: boolean;
|
||||
byte_delta: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
/** rollback 可注入的最小客户端面(NodeAgentClient 结构上满足它)。 */
|
||||
export interface RollbackClient {
|
||||
invoke<T = unknown>(
|
||||
capability: string,
|
||||
args: Record<string, unknown>,
|
||||
timeoutMs?: number,
|
||||
): Promise<InvokeResult<T>>;
|
||||
}
|
||||
|
||||
/** rollback 命令的选项面(Options 的子集,便于单测构造)。 */
|
||||
export interface RollbackOptions {
|
||||
/** 高级逃生门:直接把该远端路径当回滚源(跳过 LKG 与指纹匹配) */
|
||||
backup?: string;
|
||||
/** 只读比对,零写入 */
|
||||
dryRun?: boolean;
|
||||
/** 原始 JSON 输出 */
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
/** planRollback 的判定结果。 */
|
||||
export type RollbackAction = 'no-lkg' | 'already-lkg' | 'explicit' | 'match';
|
||||
|
||||
export interface RollbackPlan {
|
||||
action: RollbackAction;
|
||||
/** 期望的回滚后指纹:LKG 指纹(match)/ 备份指纹(explicit,调用方算好后回填)/ 空(no-lkg) */
|
||||
targetHash: string;
|
||||
/** 被控端入口脚本路径(回滚写入目标) */
|
||||
scriptPath: string;
|
||||
}
|
||||
|
||||
/** 备份候选(fs.list 的一条)。 */
|
||||
export interface BackupCandidate {
|
||||
path: string;
|
||||
name: string;
|
||||
type: string;
|
||||
size: number;
|
||||
mtime: number;
|
||||
}
|
||||
|
||||
/** 指纹比对条目(候选 + 其远端内容指纹)。 */
|
||||
export interface HashedCandidate {
|
||||
path: string;
|
||||
hash: string;
|
||||
mtime: number;
|
||||
}
|
||||
|
||||
/** runRollback 的结构化结果(不直接碰进程退出码,便于单测断言)。 */
|
||||
export interface RollbackResult {
|
||||
/** 0 成功/已是 LKG;1 执行失败;2 前提不满足 */
|
||||
code: 0 | 1 | 2;
|
||||
plan: RollbackPlan;
|
||||
/** 命中/指定的回滚源(未找到为 null) */
|
||||
source: { path: string; hash: string } | null;
|
||||
/** 候选指纹清单(无匹配时供排查) */
|
||||
candidates: HashedCandidate[];
|
||||
/** 写后校验失败后是否已执行还原写 */
|
||||
restored: boolean;
|
||||
/** 是否需要进入「重启 + 复核」阶段(dry-run 恒为 false) */
|
||||
needsRestart: boolean;
|
||||
/** 失败原因(成功为 undefined) */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ---------- 纯函数 ----------
|
||||
|
||||
/**
|
||||
* 由入口文件名生成备份候选的本地过滤正则。
|
||||
*
|
||||
* 覆盖两套命名(ARCH A-P0-3):
|
||||
* - deploy 推送式:固定名 `<basename>.bak`(覆盖写)
|
||||
* - selfupdate 拉取式:时间戳名 `<basename>.bak-<ts>`
|
||||
*
|
||||
* 平台相关字符串(Windows 反斜杠路径、文件名)全部收敛进本函数:
|
||||
* basename 的切分兼容 `\` 与 `/`,正则元字符一律转义。
|
||||
*/
|
||||
export function backupNameRegex(basename: string): RegExp {
|
||||
const base = remoteBasename(basename);
|
||||
const esc = base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return new RegExp(`^${esc}\\.bak(-.*)?$`);
|
||||
}
|
||||
|
||||
/** 取远端路径的 basename(兼容 Windows 反斜杠)。 */
|
||||
export function remoteBasename(p: string): string {
|
||||
return p.split(/[\\/]/).pop() ?? '';
|
||||
}
|
||||
|
||||
/** 取远端路径的目录部分(兼容 Windows 反斜杠);无目录时返回空串。 */
|
||||
export function remoteDirname(p: string): string {
|
||||
const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'));
|
||||
return i > 0 ? p.slice(0, i) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 指纹比对:全量相等,或老记录(12 位)按前缀比。
|
||||
*
|
||||
* 为什么允许前缀:LkgRecord 老数据只有 12 位 hash(A-P0-3/R-2),
|
||||
* 控制端不得要求被控端先升级才能回滚 —— 向后兼容,不猜。
|
||||
*/
|
||||
export function hashMatches(got: string, want: string): boolean {
|
||||
if (!got || !want) return false;
|
||||
if (got === want) return true;
|
||||
return want.length < got.length && got.startsWith(want);
|
||||
}
|
||||
|
||||
/**
|
||||
* 双向前缀匹配:短的一方是长的一方的前缀即算命中。
|
||||
*
|
||||
* 为什么需要它(v2.1.0 QA 缺陷单):指纹有两个口径同时在场 ——
|
||||
* · `build.hash` / 老 LKG 的 `hash` 是 **12 位**前缀;
|
||||
* · `hash_full` / 流式 sha256 结果是 **64 位**全量。
|
||||
* 写后校验与重启复核是「拿 64 位实测值去比期望值」,而期望值可能是 12 位(老
|
||||
* 记录)或 64 位(新记录)。用严格 === 会让其中一种口径必然失败:
|
||||
* · 12 位期望 ↔ 64 位实测 → 老记录回滚永远走「写后校验失败」;
|
||||
* · 64 位期望 ↔ 12 位实测 → 新记录回滚成功后被误报复核失败(退出码 1)。
|
||||
* 因此校验方向必须容忍两种口径,而「挑回滚源」方向仍用单向 hashMatches
|
||||
* (hash_full 在手时不许前缀糊弄,见 tests/unit/qa-v210-supplement.test.mjs C⑥)。
|
||||
*/
|
||||
export function hashMatchesEither(a: string, b: string): boolean {
|
||||
if (!a || !b) return false;
|
||||
if (a === b) return true;
|
||||
return a.length < b.length ? b.startsWith(a) : a.startsWith(b);
|
||||
}
|
||||
|
||||
/**
|
||||
* 回滚计划(纯函数):决定 action 与期望指纹。
|
||||
*
|
||||
* - `no-lkg`:无 LKG 记录且未指定 --backup → 前提不满足
|
||||
* - `already-lkg`:当前指纹已是 LKG → 零写入退出(提示 --backup 逃生门)
|
||||
* - `explicit`:给了 --backup → 以备份自身为源与期望指纹(targetHash 由调用方回填)
|
||||
* - `match`:枚举备份 + 指纹匹配定位 LKG 内容
|
||||
*/
|
||||
export function planRollback(info: SystemInfoLike, backupPath?: string): RollbackPlan {
|
||||
const lkg = info.lkg?.lkg ?? null;
|
||||
const scriptPath = info.agent_script ?? lkg?.script_path ?? '';
|
||||
if (backupPath) {
|
||||
return { action: 'explicit', targetHash: '', scriptPath };
|
||||
}
|
||||
if (!lkg) {
|
||||
return { action: 'no-lkg', targetHash: '', scriptPath };
|
||||
}
|
||||
const current = info.build?.hash ?? '';
|
||||
if (current && lkg.hash && current === lkg.hash) {
|
||||
return { action: 'already-lkg', targetHash: lkg.hash, scriptPath };
|
||||
}
|
||||
return { action: 'match', targetHash: lkg.hash_full ?? lkg.hash, scriptPath };
|
||||
}
|
||||
|
||||
/**
|
||||
* 从「已算好指纹」的候选里挑回滚源(纯函数;mtime 新→早的排序在外层做)。
|
||||
*
|
||||
* 为什么排序在外层:本函数只负责「第一个指纹匹配者」,与时间序解耦,可独立测。
|
||||
*/
|
||||
export function pickBackupByHash(
|
||||
cands: HashedCandidate[],
|
||||
wantHash: string,
|
||||
): { path: string; hash: string } | null {
|
||||
if (!wantHash) return null;
|
||||
for (const c of cands) {
|
||||
if (hashMatches(c.hash, wantHash)) return { path: c.path, hash: c.hash };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------- 远端 IO(client 注入 → 可假测) ----------
|
||||
|
||||
/** 分块大小:与 cmdDeploy 一致(512KB),单条 RPC 的安全分块 */
|
||||
const CHUNK = 512 * 1024;
|
||||
|
||||
/**
|
||||
* 流式计算远端文件指纹(fs.read 分块 + sha256)。
|
||||
*
|
||||
* ⚠️ offset 前进量必须用**本次返回字节数**,不能用返回记录里的起始 offset
|
||||
* (拿它前进会原地打转 —— cmdDeploy 的真机教训:备份 33B→268KB 且卡死 200s)。
|
||||
*/
|
||||
export async function hashRemoteFile(
|
||||
client: RollbackClient,
|
||||
path: string,
|
||||
): Promise<{ hash: string; bytes: number }> {
|
||||
const hash = createHash('sha256');
|
||||
let offset = 0;
|
||||
for (;;) {
|
||||
const r = await client.invoke<{ data?: string; bytes?: number; eof?: boolean }>(
|
||||
CapabilityNames.FsRead,
|
||||
{ path, offset, max_bytes: CHUNK, encoding: 'base64' },
|
||||
);
|
||||
if (r.status !== 'ok') {
|
||||
throw new Error(`读取失败: ${r.error?.name ?? 'E_EXECUTION_FAILED'}: ${r.error?.message ?? '未知错误'}`);
|
||||
}
|
||||
const d = r.data ?? {};
|
||||
const chunk = d.data ?? '';
|
||||
if (chunk) hash.update(Buffer.from(chunk, 'base64'));
|
||||
const n = d.bytes ?? 0;
|
||||
offset += n;
|
||||
if (d.eof || n === 0) break;
|
||||
}
|
||||
return { hash: hash.digest('hex'), bytes: offset };
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式复制远端文件(src → dst),返回复制内容的指纹与字节数。
|
||||
*
|
||||
* 写姿势与 cmdDeploy 的上传段完全一致:首块 `append:false`(截断写),
|
||||
* 续块 `append:true`。base64 必须显式声明 —— 默认 utf8 会把二进制当文本读写坏
|
||||
* (真机踩过:备份 33 字节的垃圾)。
|
||||
*/
|
||||
async function copyRemoteFile(
|
||||
client: RollbackClient,
|
||||
srcPath: string,
|
||||
dstPath: string,
|
||||
): Promise<{ hash: string; bytes: number }> {
|
||||
const hash = createHash('sha256');
|
||||
let offset = 0;
|
||||
let first = true;
|
||||
for (;;) {
|
||||
const r = await client.invoke<{ data?: string; bytes?: number; eof?: boolean }>(
|
||||
CapabilityNames.FsRead,
|
||||
{ path: srcPath, offset, max_bytes: CHUNK, encoding: 'base64' },
|
||||
);
|
||||
if (r.status !== 'ok') {
|
||||
throw new Error(`读取失败: ${r.error?.name ?? 'E_EXECUTION_FAILED'}: ${r.error?.message ?? '未知错误'}`);
|
||||
}
|
||||
const d = r.data ?? {};
|
||||
const chunk = d.data ?? '';
|
||||
const n = d.bytes ?? 0;
|
||||
if (chunk) {
|
||||
hash.update(Buffer.from(chunk, 'base64'));
|
||||
const w = await client.invoke(CapabilityNames.FsWrite, {
|
||||
path: dstPath,
|
||||
data: chunk,
|
||||
encoding: 'base64',
|
||||
append: !first,
|
||||
});
|
||||
if (w.status !== 'ok') {
|
||||
throw new Error(`写入失败: ${w.error?.name ?? 'E_EXECUTION_FAILED'}: ${w.error?.message ?? '未知错误'}`);
|
||||
}
|
||||
first = false;
|
||||
}
|
||||
offset += n;
|
||||
if (d.eof || n === 0) break;
|
||||
}
|
||||
// 空文件也要落到目标(否则目标保留旧内容,与「复制」语义相反)
|
||||
if (first) {
|
||||
const w = await client.invoke(CapabilityNames.FsWrite, { path: dstPath, data: '' });
|
||||
if (w.status !== 'ok') {
|
||||
throw new Error(`写入失败: ${w.error?.name ?? 'E_EXECUTION_FAILED'}: ${w.error?.message ?? '未知错误'}`);
|
||||
}
|
||||
}
|
||||
return { hash: hash.digest('hex'), bytes: offset };
|
||||
}
|
||||
|
||||
// ---------- 主编排(①~⑥,client 注入 → 可假测) ----------
|
||||
|
||||
/**
|
||||
* 执行 rollback 的 ①~⑥ 阶段(不含重启与复核)。
|
||||
*
|
||||
* 为什么与 cmdRollback 分开:重启/复核要真实网络(probeOnce 反复重连),
|
||||
* 单测只覆盖「决策 + 写入 + 校验」这段编排 —— 故 client 注入、
|
||||
* 进程退出码用返回值表达(code),由 cmdRollback 翻译成 process.exitCode。
|
||||
*/
|
||||
export async function runRollback(
|
||||
client: RollbackClient,
|
||||
opts: RollbackOptions,
|
||||
): Promise<RollbackResult> {
|
||||
const say = (line: string): void => {
|
||||
if (!opts.json) console.log(line);
|
||||
};
|
||||
|
||||
// ① system.info
|
||||
const infoRes = await client.invoke<SystemInfoLike>(CapabilityNames.SystemInfo, {});
|
||||
if (infoRes.status !== 'ok') {
|
||||
return {
|
||||
code: 2,
|
||||
plan: { action: 'no-lkg', targetHash: '', scriptPath: '' },
|
||||
source: null,
|
||||
candidates: [],
|
||||
restored: false,
|
||||
needsRestart: false,
|
||||
error: `无法读取被控端信息: ${infoRes.error?.message ?? '未知错误'}`,
|
||||
};
|
||||
}
|
||||
const info = infoRes.data ?? {};
|
||||
const plan = planRollback(info, opts.backup);
|
||||
|
||||
const lkg = info.lkg?.lkg ?? null;
|
||||
if (lkg) {
|
||||
say(`LKG 记录 : ${lkg.hash}${lkg.hash_full ? `(全量 ${lkg.hash_full.slice(0, 16)}…)` : ''} ${lkg.bytes} 字节`);
|
||||
say(` 记录于 ${new Date(lkg.recorded_at).toLocaleString('zh-CN')} · node ${lkg.node_version} · ${lkg.platform}`);
|
||||
}
|
||||
const current = info.build?.hash ?? 'unknown';
|
||||
say(`当前指纹 : ${current}${info.agent_script ? `\n入口路径 : ${info.agent_script}` : ''}`);
|
||||
|
||||
// ② 无 LKG(且未给 --backup)
|
||||
if (plan.action === 'no-lkg') {
|
||||
return {
|
||||
code: 2,
|
||||
plan,
|
||||
source: null,
|
||||
candidates: [],
|
||||
restored: false,
|
||||
needsRestart: false,
|
||||
error: '被控端没有 LKG 记录(尚未成功启动过或记录已丢失)—— 无回滚目标,零改动',
|
||||
};
|
||||
}
|
||||
|
||||
// ③ 当前即 LKG
|
||||
if (plan.action === 'already-lkg') {
|
||||
say('✓ 当前版本就是 LKG,无需回滚');
|
||||
say(' 如需回退到更早的备份,用逃生门: nodeagent rollback --backup <远端备份路径>');
|
||||
return { code: 0, plan, source: null, candidates: [], restored: false, needsRestart: false };
|
||||
}
|
||||
|
||||
if (!plan.scriptPath) {
|
||||
return {
|
||||
code: 2,
|
||||
plan,
|
||||
source: null,
|
||||
candidates: [],
|
||||
restored: false,
|
||||
needsRestart: false,
|
||||
error: '无法确定被控端入口路径(system.info 未上报 agent_script,LKG 也无 script_path)',
|
||||
};
|
||||
}
|
||||
|
||||
// ④ 枚举备份候选(fs.list + 本地正则过滤;正则才是权威判据)
|
||||
const dir = remoteDirname(plan.scriptPath);
|
||||
const base = remoteBasename(plan.scriptPath);
|
||||
const glob = lkg?.backup_glob ?? `${base}.bak*`;
|
||||
const listRes = await client.invoke<{ entries?: BackupCandidate[] }>(
|
||||
CapabilityNames.FsList,
|
||||
{ path: dir, pattern: glob, max_entries: 500 },
|
||||
);
|
||||
if (listRes.status !== 'ok') {
|
||||
return {
|
||||
code: 2,
|
||||
plan,
|
||||
source: null,
|
||||
candidates: [],
|
||||
restored: false,
|
||||
needsRestart: false,
|
||||
error: `枚举备份失败: ${listRes.error?.message ?? '未知错误'}`,
|
||||
};
|
||||
}
|
||||
const re = backupNameRegex(base);
|
||||
const cands: BackupCandidate[] = (listRes.data?.entries ?? [])
|
||||
.filter((e) => e.type === 'file' && re.test(e.name ?? ''))
|
||||
.sort((a, b) => (b.mtime ?? 0) - (a.mtime ?? 0)); // mtime 新→早
|
||||
if (cands.length === 0) {
|
||||
return {
|
||||
code: 2,
|
||||
plan,
|
||||
source: null,
|
||||
candidates: [],
|
||||
restored: false,
|
||||
needsRestart: false,
|
||||
error: `未找到备份候选(${dir} 下无 ${glob})—— 回滚内容不存在,零改动`,
|
||||
};
|
||||
}
|
||||
|
||||
// ⑤ 指纹匹配(流式读 + sha256)
|
||||
const hashed: HashedCandidate[] = [];
|
||||
for (const c of cands) {
|
||||
try {
|
||||
const h = await hashRemoteFile(client, c.path);
|
||||
hashed.push({ path: c.path, hash: h.hash, mtime: c.mtime });
|
||||
} catch (err) {
|
||||
say(` ⚠️ 跳过无法读取的候选 ${c.path}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
say(`备份候选 ${hashed.length} 个(mtime 新→早),指纹比对中…`);
|
||||
|
||||
let source: { path: string; hash: string } | null;
|
||||
let expectHash: string;
|
||||
if (plan.action === 'explicit') {
|
||||
// 逃生门:--backup 指定的路径即回滚源;期望指纹 = 它自身的内容指纹
|
||||
const backupPath = opts.backup as string;
|
||||
try {
|
||||
const h = await hashRemoteFile(client, backupPath);
|
||||
source = { path: backupPath, hash: h.hash };
|
||||
expectHash = h.hash;
|
||||
say(`逃生门 : 指定备份 ${backupPath}(指纹 ${h.hash.slice(0, 12)})`);
|
||||
} catch (err) {
|
||||
return {
|
||||
code: 2,
|
||||
plan,
|
||||
source: null,
|
||||
candidates: hashed,
|
||||
restored: false,
|
||||
needsRestart: false,
|
||||
error: `无法读取指定备份 ${backupPath}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
source = pickBackupByHash(hashed, plan.targetHash);
|
||||
if (!source) {
|
||||
// 无匹配:列候选指纹供排查,零改动退出
|
||||
for (const h of hashed) say(` · ${h.path} ${h.hash.slice(0, 12)}`);
|
||||
return {
|
||||
code: 2,
|
||||
plan,
|
||||
source: null,
|
||||
candidates: hashed,
|
||||
restored: false,
|
||||
needsRestart: false,
|
||||
error: '没有备份的指纹与 LKG 匹配 —— 已列出各候选指纹,未改动任何文件',
|
||||
};
|
||||
}
|
||||
expectHash = plan.targetHash;
|
||||
say(`✓ 命中回滚源: ${source.path}(指纹 ${source.hash.slice(0, 12)})`);
|
||||
}
|
||||
|
||||
if (opts.dryRun) {
|
||||
say('--dry-run:只读比对完成,未写入、未重启');
|
||||
return { code: 0, plan, source, candidates: hashed, restored: false, needsRestart: false };
|
||||
}
|
||||
|
||||
// ⑥ 覆盖写入 + 写后校验(不一致 → 用回滚源还原 → 报错)
|
||||
try {
|
||||
const written = await copyRemoteFile(client, source.path, plan.scriptPath);
|
||||
const back = await hashRemoteFile(client, plan.scriptPath);
|
||||
if (back.hash !== written.hash || !hashMatchesEither(back.hash, expectHash)) {
|
||||
say('✗ 写后校验失败,正在用回滚源还原…');
|
||||
await copyRemoteFile(client, source.path, plan.scriptPath);
|
||||
return {
|
||||
code: 1,
|
||||
plan,
|
||||
source,
|
||||
candidates: hashed,
|
||||
restored: true,
|
||||
needsRestart: false,
|
||||
error:
|
||||
`写后校验失败(期望 ${expectHash.slice(0, 12)},实得 ${back.hash.slice(0, 12)})—— ` +
|
||||
`已用回滚源还原 ${plan.scriptPath};请检查磁盘/杀软/路径权限后重试`,
|
||||
};
|
||||
}
|
||||
say(`✓ 已写入 ${plan.scriptPath} 并通过写后校验(指纹 ${back.hash.slice(0, 12)})`);
|
||||
return { code: 0, plan, source, candidates: hashed, restored: false, needsRestart: true };
|
||||
} catch (err) {
|
||||
return {
|
||||
code: 1,
|
||||
plan,
|
||||
source,
|
||||
candidates: hashed,
|
||||
restored: false,
|
||||
needsRestart: false,
|
||||
error: `回滚写入失败: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 重启与复核(⑦⑧,真实网络) ----------
|
||||
|
||||
/** 触发受控重启 + 轮询复核指纹上线(90s 墙钟)。返回是否复核通过。 */
|
||||
export async function restartAndVerify(expectHash: string): Promise<boolean> {
|
||||
// ⑦ 独立连接触发重启,吞掉连接中断(重启会切断本连接,属预期行为;
|
||||
// cmdDeploy 真机教训:当成失败抛出会把成功的部署误判为失败)
|
||||
try {
|
||||
const r = await withClient((c) =>
|
||||
c.invoke(CapabilityNames.AgentRestart, { delay_ms: 1500, reason: 'rollback' }),
|
||||
);
|
||||
if (r.status === 'failed') {
|
||||
console.error(`✗ 重启失败: ${r.error?.message ?? '未知错误'}`);
|
||||
return false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(`(重启切断了连接,属预期:${err instanceof Error ? err.message : String(err)})`);
|
||||
}
|
||||
console.log('✓ 已触发重启,复核回滚版本上线…');
|
||||
|
||||
// ⑧ probeOnce 轮询(永不抛出的探测;withClient 连不上会 fail 退出,不能用它)
|
||||
let lastSeen = '连不上';
|
||||
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';
|
||||
if (hashMatchesEither(p.buildHash ?? '', expectHash)) {
|
||||
console.log(`✓ 回滚完成并复核通过:指纹 ${p.buildHash},PID ${p.pid ?? '?'},能力 ${p.caps} 项`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
console.error(`✗ 90s 内未复核到回滚版本(期望 ${expectHash.slice(0, 12)},最后见到 ${lastSeen})`);
|
||||
console.error(' 排查:被控端可能没起来 —— 看 <数据目录>/logs/startup-guard.log;');
|
||||
console.error(' 仍不行则按 docs/DELIVERY-PIPELINE.md 附录 C 走带外恢复');
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------- CLI 入口 ----------
|
||||
|
||||
/** CLI 主编排:连接被控端 → 执行回滚 →(非 dry-run)重启复核。 */
|
||||
export async function cmdRollback(opts: RollbackOptions): Promise<void> {
|
||||
const result = await withClient((c) => runRollback(c, opts));
|
||||
|
||||
if (opts.json) {
|
||||
printJson({
|
||||
code: result.code,
|
||||
action: result.plan.action,
|
||||
target_hash: result.plan.targetHash,
|
||||
script_path: result.plan.scriptPath,
|
||||
source: result.source,
|
||||
candidates: result.candidates.map((c) => ({ path: c.path, hash: c.hash })),
|
||||
restored: result.restored,
|
||||
error: result.error,
|
||||
});
|
||||
} else if (result.error) {
|
||||
console.error(`✗ ${result.error}`);
|
||||
}
|
||||
|
||||
if (result.code === 0 && result.needsRestart && result.source) {
|
||||
const ok = await restartAndVerify(result.plan.targetHash || result.source.hash);
|
||||
if (!ok) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
process.exitCode = result.code;
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
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} 条锚点)`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* L2 系统级审计兜底(见 docs/AUDIT-L2.md)。
|
||||
* nodeagent audit l2 [status|enable|collect|verify] [--since <ms>] [--limit N]
|
||||
*/
|
||||
export async function cmdAuditL2(action: string | undefined, opts: Options): Promise<void> {
|
||||
const args: Record<string, unknown> = { action: action ?? 'status' };
|
||||
if (opts.since !== undefined) args['since'] = Number(opts.since);
|
||||
if (opts.limit !== undefined) args['limit'] = Number(opts.limit);
|
||||
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.AuditL2, args, opts.json, (data) => {
|
||||
const d = data as Record<string, unknown>;
|
||||
if (d['collected'] !== undefined) {
|
||||
console.log(`✓ 已采集 ${d['collected']} 条,写入 ${d['written']} 条`);
|
||||
console.log(` 文件:${d['file']}`);
|
||||
if (typeof d['note'] === 'string') console.log(` ⚠️ ${d['note']}`);
|
||||
return;
|
||||
}
|
||||
if (d['ok'] !== undefined) {
|
||||
const ok = d['ok'] === true;
|
||||
const broken = d['broken_at'] as { line: number; reason: string } | undefined;
|
||||
console.log(
|
||||
ok
|
||||
? `✓ L2 链完整(校验 ${d['checked']} 条)`
|
||||
: `✗ L2 链异常:第 ${broken?.line} 行 —— ${broken?.reason}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (d['enabled'] === true) {
|
||||
console.log(`✓ 已启用 Windows 审计策略:${(d['policies'] as string[]).join(' / ')}`);
|
||||
if (typeof d['hint'] === 'string') console.log(` ${d['hint']}`);
|
||||
return;
|
||||
}
|
||||
// status
|
||||
console.log(`平台 : ${d['platform']}`);
|
||||
console.log(`审计策略 : ${d['auditpol']}`);
|
||||
console.log(`已禁用旧式策略覆盖 : ${d['legacy_policy_disabled'] ? '是' : '否(⚠️ 会导致 4688 不产生)'}`);
|
||||
console.log(`记录命令行 : ${d['cmdline_logging'] ? '是' : '否(只能看到进程名)'}`);
|
||||
console.log(`Sysmon : ${d['sysmon_installed'] ? '已安装(可并用)' : '未安装(不自动下载)'}`);
|
||||
console.log(`L2 文件 : ${d['file']}${d['file_exists'] ? '' : '(尚未生成)'}`);
|
||||
if (typeof d['note'] === 'string') console.log(`⚠️ ${d['note']}`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
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,104 @@
|
||||
// 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 audit l2 [status|enable|collect|verify] [--since <ms>] [--limit N]
|
||||
L2 系统级审计兜底:采集 OS 自身审计事件,
|
||||
补 L1 记不到的那块(agent 被停/被绕过时)(见 docs/AUDIT-L2.md)
|
||||
仅 Windows 已落地;Linux 需预装 auditd;
|
||||
macOS ESF 受 Apple entitlement 限制不可用
|
||||
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 rollback [--dry-run] [--backup <远端备份路径>]
|
||||
从 LKG(最后已知可用版本)回滚被控端 (v2.1.0):
|
||||
指纹比对定位备份 → 覆盖回入口 → 受控重启 → 复核上线
|
||||
⚠️ 需被控端可达(RPC 命令):新版本「完全起不来」时
|
||||
没有监听可连,请依赖看门狗自动恢复(启动连续失败 N 次,
|
||||
见 docs/DELIVERY-PIPELINE.md §6)或带外恢复(附录 C)
|
||||
自动回滚刻意不做(无法归因/回滚循环/失控)
|
||||
--dry-run 只读比对零写入;--backup 为高级逃生门
|
||||
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,227 @@
|
||||
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: {
|
||||
// ⚠️ 这两个是 L-01 同类问题的「漏网之鱼」,由 cli-options 守卫扫出(v2.0.3):
|
||||
// `--help` 原来靠 positionals[0]===undefined 偶然命中;`--no-tls` 则绕过解析器
|
||||
// 直接在 cmd/conn.ts 里扫 `process.argv`。两者都是「能跑,但没接进解析器」。
|
||||
help: { type: 'boolean', default: false },
|
||||
'no-tls': { type: 'boolean', default: false },
|
||||
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' },
|
||||
// ⚠️ L-01(v2.0.3 修):`--timeout` 曾经**没在这里声明**。因为 `strict: false`,
|
||||
// 未声明选项会被当成 boolean 塞进 `values.timeout`(`true`),而它的值
|
||||
// `30000` 则掉进 `positionals` —— 于是 `--timeout` 静默失效,
|
||||
// 并且**污染位置参数**(可能把子命令/主机名解析错)。
|
||||
// 帮助文本与 `cmd/gui.ts` 的 await 命令用的都是 `--timeout`,故必须显式声明。
|
||||
timeout: { 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 },
|
||||
// v2.1.0 rollback 逃生门(--backup <远端备份路径>)
|
||||
backup: { type: 'string' },
|
||||
mute: { type: 'string' },
|
||||
volume: { type: 'string' },
|
||||
seconds: { type: 'string' },
|
||||
'interval-ms': { type: 'string' },
|
||||
},
|
||||
allowPositionals: true,
|
||||
strict: false,
|
||||
});
|
||||
return {
|
||||
opts: {
|
||||
help: Boolean(values['help']),
|
||||
noTls: Boolean(values['no-tls']),
|
||||
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,
|
||||
// 两个写法都接受:`--timeout` 是帮助文本/`await` 命令里的正式写法,
|
||||
// `--timeout-ms` 是早期在 `bg` 命令里引入的别名。二者同时给时以 `--timeout` 为准。
|
||||
timeoutMs: (values['timeout'] ?? 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']),
|
||||
backup: values['backup'] as string | undefined,
|
||||
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,120 @@
|
||||
// CLI 共享类型与常量(从 index.ts 拆出)
|
||||
/** 已解析的常用选项。 */
|
||||
export interface Options {
|
||||
/** 打印帮助后退出(`--help` / `-h`)。选项而非位置参数 */
|
||||
help: boolean;
|
||||
/** 关闭 TLS(`--no-tls`)。原来是直接扫 process.argv,现归位到解析器 */
|
||||
noTls: boolean;
|
||||
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;
|
||||
/** v2.1.0 rollback 逃生门:直接指定远端备份路径为回滚源 */
|
||||
backup?: string;
|
||||
/** 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,28 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>nodeagent 控制台</title>
|
||||
<script>
|
||||
// 首帧防闪:Vue 接管之前浏览器已经会画一次背景,若不先刷上——
|
||||
// 「高级灰」用户每次启动都会看到一帧刺眼的白。
|
||||
//
|
||||
// 此处只重复**两个**底色值(真值在 src/theme/index.ts),因为在此之前
|
||||
// 没有任何模块可依赖;其余所有颜色仍由 App.vue 运行期注入的 --na-* 负责。
|
||||
// 改调色板底色时记得同步这两行。
|
||||
try {
|
||||
var isGray = localStorage.getItem("nodeagent.console.theme") === "gray";
|
||||
document.documentElement.style.background = isGray ? "#1d1f22" : "#f4f4f5";
|
||||
document.documentElement.style.colorScheme = isGray ? "dark" : "light";
|
||||
} catch (e) {
|
||||
/* localStorage 不可用(隐私模式)时走默认底色即可 */
|
||||
}
|
||||
</script>
|
||||
</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.1.0",
|
||||
"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 --filter @nodeagent/agent 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.3.5",
|
||||
"vue-tsc": "^3.3.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
#!/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, spawnSync } 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 agentScript;
|
||||
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 保持一致;
|
||||
// `"runtime/agent.mjs": "agent.mjs"` 则落在 Contents/Resources/agent.mjs,
|
||||
// 与 src-tauri/src/agent.rs 的 resolve_bundled_agent 一致。
|
||||
const res = join(app, 'Contents/Resources');
|
||||
nodeBin = join(res, NODE_NAME);
|
||||
sidecar = join(res, 'sidecar.mjs');
|
||||
agentScript = join(res, 'agent.mjs');
|
||||
source = `打包产物 ${app}`;
|
||||
} else {
|
||||
nodeBin = join(consoleDir, `src-tauri/runtime/${NODE_NAME}`);
|
||||
sidecar = join(consoleDir, 'sidecar/dist/sidecar.mjs');
|
||||
agentScript = join(consoleDir, 'src-tauri/runtime/agent.mjs');
|
||||
source = '准备产物(runtime + sidecar/dist)';
|
||||
}
|
||||
|
||||
console.log(`=== 验证控制台产物自举 ===`);
|
||||
console.log(` 来源:${source}`);
|
||||
|
||||
for (const [label, p] of [
|
||||
['Node 运行时', nodeBin],
|
||||
['sidecar', sidecar],
|
||||
['被控端 agent.mjs', agentScript],
|
||||
]) {
|
||||
if (!existsSync(p)) {
|
||||
console.error(`✗ 缺少 ${label}:${p}`);
|
||||
console.error(' 提示:先跑 `pnpm bundle`(含 prepare:node),或 `pnpm prepare:node`。');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(` ✓ ${label}:${p}`);
|
||||
}
|
||||
|
||||
// ---------- ③ 打包内的 node 必须能真正**解析**打包内的 agent.mjs ----------
|
||||
// 只查「文件存在」是不够的:截断、半写、依赖缺失都会让文件在、却加载不了,
|
||||
// 而那要到用户点「启动」时才炸。`node --check` 只做语法/模块解析、不执行,
|
||||
// 无副作用、不占端口,适合流水线。
|
||||
{
|
||||
const r = spawnSync(nodeBin, ['--check', agentScript], { encoding: 'utf8' });
|
||||
if (r.status !== 0) {
|
||||
console.error('\n✗ 打包内的 node 无法解析 agent.mjs —— 被控端资源已损坏');
|
||||
console.error(' ' + (r.stderr || r.stdout || '').trim().split('\n').slice(0, 4).join('\n '));
|
||||
console.error(' 提示:重跑 `pnpm prepare:node`(会重新 esbuild 打包被控端)。');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(' ✓ 打包内 node 可解析 agent.mjs(语法/模块解析通过)');
|
||||
}
|
||||
|
||||
// ---------- ①②③ 合一的实测:真 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,425 @@
|
||||
#!/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"}
|
||||
* {"jsonrpc":"2.0","id":5,"method":"check_update","params":{console_version,url?,timeout_ms?}}
|
||||
* {"jsonrpc":"2.0","id":6,"method":"download","params":{url,sha256,dest,timeout_ms?}}
|
||||
* ⚠️ `timeout_ms` 由 Rust 侧下发,**必须小于**它在 `sidecar.rs::request` 上的 RPC 超时,
|
||||
* 否则永远看到的是「RPC 超时」这种假失败(拿不到真正的原因)。两侧口径在
|
||||
* apps/console/src-tauri/src/lib.rs 的 `UPDATE_CHECK_TIMEOUT_MS` / `UPDATE_RPC_TIMEOUT_SECS` 里成对定义。
|
||||
* 响应(本进程 → 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 { createHash } from 'node:crypto';
|
||||
import { mkdir, readFile, writeFile, unlink } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
import { NodeAgentClient } from '@nodeagent/client';
|
||||
import { httpGet, resolveProxy } from './proxy.mjs';
|
||||
|
||||
/** @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',
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------- 方法实现 ----------------
|
||||
|
||||
/**
|
||||
* 比较两个 `x.y.z` 版本号:a > b 返回正、相等返回 0、a < b 返回负。
|
||||
*
|
||||
* 按**数值**逐段比较,不做字符串比较 —— 否则 `"2.0.10" < "2.0.9"`(字符串序),
|
||||
* 到两位数的补丁号时会误判「无更新」。
|
||||
*/
|
||||
function cmpVersion(a, b) {
|
||||
const pa = String(a).split('.').map((x) => parseInt(x, 10) || 0);
|
||||
const pb = String(b).split('.').map((x) => parseInt(x, 10) || 0);
|
||||
for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
|
||||
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
|
||||
if (d !== 0) return d > 0 ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 出网超时(毫秒)的裁剪。参数由 Rust 侧下发,**默认值只作兜底** —— 真实口径以
|
||||
* `lib.rs` 的 `UPDATE_CHECK_TIMEOUT_MS` / `UPDATE_DOWNLOAD_TIMEOUT_MS` 为准。
|
||||
*/
|
||||
function clampTimeoutMs(raw, dflt, min = 5_000, max = 1_800_000) {
|
||||
const n = typeof raw === 'number' && Number.isFinite(raw) ? Math.floor(raw) : dflt;
|
||||
return Math.max(min, Math.min(max, n));
|
||||
}
|
||||
|
||||
/**
|
||||
* 决议代理是否可用;**任何异常都退化成「直连」**。
|
||||
*
|
||||
* 代理只是为了「本来连不上时能连上」,它本身绝不该成为新的失败点:
|
||||
* 探测系统代理要跑 `reg`/`scutil`,被安全软件拦、被超时都是可能的,
|
||||
* 那种情况下退回直连(与改动前行为一致)比让「检查更新」直接报错要好。
|
||||
*/
|
||||
async function resolveProxyOrDirect(targetUrl) {
|
||||
try {
|
||||
return await resolveProxy({ targetUrl });
|
||||
} catch (err) {
|
||||
return { proxy: null, source: null, notes: [`代理探测异常(${err instanceof Error ? err.message : String(err)}),按直连处理`] };
|
||||
}
|
||||
}
|
||||
|
||||
/** 组一条可操作的失败线索:走了哪条路、代理从哪来、为什么不走代理。 */
|
||||
function outboundHint({ proxy, source, notes, timeoutMs }) {
|
||||
return {
|
||||
via: proxy ? `经代理 ${proxy.href}` : '直连',
|
||||
proxy: proxy ? proxy.href : null,
|
||||
proxy_source: source,
|
||||
timeout_ms: timeoutMs,
|
||||
notes,
|
||||
hint: proxy
|
||||
? '代理已生效但仍失败:请确认代理本身能访问 GitHub(浏览器里试同一个地址)'
|
||||
: '未使用代理。若本机需要代理才能访问 GitHub,请在系统里开启「系统代理」,或设置环境变量 HTTPS_PROXY=http://127.0.0.1:<端口> 后重启控制台',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为「主动中止」类错误 —— **只认真正的 abort**。
|
||||
*
|
||||
* ⚠️ 别把 `ECONNRESET` 之类也塞进来(第一版就这么写的,真机上立刻现形):
|
||||
* 那次耗时 82.9s、预算 120s,连接是被对端**中途重置**的,却被报成
|
||||
* 「请求超时(120000ms 内未完成)」—— 一句话把用户引到完全错误的方向上。
|
||||
*/
|
||||
function isAbortLike(err) {
|
||||
const name = err?.name ?? '';
|
||||
const code = err?.code ?? '';
|
||||
const msg = String(err?.message ?? '');
|
||||
return name === 'AbortError' || code === 'ABORT_ERR' || /\babort(ed)?\b/i.test(msg);
|
||||
}
|
||||
|
||||
/** 把底层网络错误翻译成「用户能据以行动」的一句话。 */
|
||||
function describeNetError(err, timeoutMs) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const code = err?.code ?? '';
|
||||
if (isAbortLike(err)) return `请求超时(${timeoutMs}ms 内未完成)`;
|
||||
if (code === 'ECONNRESET' || code === 'EPIPE') {
|
||||
return `${msg}(连接被对端中途断开;若走了代理,多半是代理或线路的问题)`;
|
||||
}
|
||||
if (code === 'ENOTFOUND' || code === 'EAI_AGAIN' || code === 'ECONNREFUSED') {
|
||||
return `${msg}(域名解析失败或目标拒绝连接;若配了代理,请核对代理地址与端口)`;
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
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 } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 检查更新:拉项目最新 Release 的 `latest.json`,与本应用版本比对。
|
||||
*
|
||||
* ## 为什么放在 sidecar 而不是 Rust 壳
|
||||
* 本壳的既定分工是「**进程管理 + 消息转发**,协议与 IO 逻辑集中在 Node 一侧」
|
||||
* (见文件头注释)。更新检查要做 HTTPS + JSON + 后续大概率还要下载文件校验哈希,
|
||||
* 在 Rust 侧等于再引一套 HTTP 栈;而 Node 有内置 `fetch`,一行到位。**单一实现**优先。
|
||||
*
|
||||
* `latest.json` 由发版脚本产出(含 version / commit / agent.url / agent.sha256),
|
||||
* 走 `releases/latest/download/` 这个**稳定别名**,无需调 GitHub API、不占速率配额。
|
||||
*/
|
||||
async check_update(params) {
|
||||
const consoleVersion = String(params?.console_version ?? '0.0.0');
|
||||
const url = String(
|
||||
params?.url ?? 'https://github.com/jkooe/nodeagent/releases/latest/download/latest.json',
|
||||
);
|
||||
const timeoutMs = clampTimeoutMs(params?.timeout_ms, 120_000);
|
||||
|
||||
const out = await resolveProxyOrDirect(url);
|
||||
log('info', `检查更新:${out.proxy ? `经代理 ${out.proxy.href}(来源 ${out.source})` : '直连'}`);
|
||||
for (const n of out.notes) log('warn', `检查更新:${n}`);
|
||||
|
||||
const t0 = Date.now();
|
||||
let meta;
|
||||
try {
|
||||
const res = await httpGet(url, { proxy: out.proxy, timeoutMs });
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
throw new Error(`拉取 latest.json 失败:HTTP ${res.status} ${res.statusText}`);
|
||||
}
|
||||
meta = JSON.parse(res.body.toString('utf8'));
|
||||
} catch (err) {
|
||||
const msg = describeNetError(err, timeoutMs);
|
||||
const where = out.proxy ? `经代理 ${out.proxy.href}` : '直连';
|
||||
throw Object.assign(
|
||||
new Error(`检查更新失败:${msg}(${where},耗时 ${Date.now() - t0}ms)`),
|
||||
{ data: { url, ...outboundHint({ ...out, timeoutMs }) } },
|
||||
);
|
||||
}
|
||||
|
||||
const latest = String(meta?.version ?? '');
|
||||
if (!latest) {
|
||||
throw new Error('latest.json 缺少 version 字段(发版产物异常)');
|
||||
}
|
||||
const agent = meta?.agent ?? {};
|
||||
return {
|
||||
console_version: consoleVersion,
|
||||
latest_version: latest,
|
||||
has_update: cmpVersion(latest, consoleVersion) > 0,
|
||||
published_at: meta?.built_at ?? null,
|
||||
commit: meta?.commit ?? null,
|
||||
capabilities: meta?.capabilities ?? null,
|
||||
agent: {
|
||||
url: agent.url ?? null,
|
||||
sha256: agent.sha256 ?? null,
|
||||
bytes: agent.bytes ?? null,
|
||||
},
|
||||
release_url: `https://github.com/jkooe/nodeagent/releases/tag/v${latest}`,
|
||||
source: url,
|
||||
// 让界面/日志能一眼看出这次是直连还是走了代理(真机复验与用户自查都需要)
|
||||
via_proxy: out.source,
|
||||
via: out.proxy ? `经代理 ${out.proxy.href}` : '直连',
|
||||
checked_at: Date.now(),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 下载文件并做 sha256 校验(校验不通过**不落盘**,避免半成品污染数据目录)。
|
||||
* 供「更新本机被控端」使用。
|
||||
*/
|
||||
async download(params) {
|
||||
const url = String(params?.url ?? '');
|
||||
const expect = String(params?.sha256 ?? '').toLowerCase();
|
||||
const dest = String(params?.dest ?? '');
|
||||
if (!url || !dest) {
|
||||
throw Object.assign(new Error('download 需要 url 与 dest'), { code: -32602 });
|
||||
}
|
||||
if (!/^[0-9a-f]{12,64}$/.test(expect)) {
|
||||
throw Object.assign(
|
||||
new Error('download 需要 sha256(12~64 位十六进制)—— 不校验哈希等于接受任意内容'),
|
||||
{ code: -32602 },
|
||||
);
|
||||
}
|
||||
|
||||
const timeoutMs = clampTimeoutMs(params?.timeout_ms, 300_000);
|
||||
|
||||
const out = await resolveProxyOrDirect(url);
|
||||
log('info', `下载更新包:${out.proxy ? `经代理 ${out.proxy.href}(来源 ${out.source})` : '直连'}`);
|
||||
|
||||
const t0 = Date.now();
|
||||
let buf;
|
||||
try {
|
||||
const res = await httpGet(url, { proxy: out.proxy, timeoutMs });
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
||||
}
|
||||
buf = res.body;
|
||||
} catch (err) {
|
||||
const msg = describeNetError(err, timeoutMs);
|
||||
const where = out.proxy ? `经代理 ${out.proxy.href}` : '直连';
|
||||
throw Object.assign(new Error(`下载失败:${msg}(${where},耗时 ${Date.now() - t0}ms)`), {
|
||||
data: { url, ...outboundHint({ ...out, timeoutMs }) },
|
||||
});
|
||||
}
|
||||
|
||||
const got = createHash('sha256').update(buf).digest('hex');
|
||||
if (got !== expect && !got.startsWith(expect)) {
|
||||
throw Object.assign(new Error('哈希校验失败,已丢弃(未写入任何文件)'), {
|
||||
data: { expected: expect.slice(0, 12), actual: got.slice(0, 12), bytes: buf.length },
|
||||
});
|
||||
}
|
||||
|
||||
await mkdir(dirname(dest), { recursive: true });
|
||||
await writeFile(dest, buf);
|
||||
// 写后复核:防止截断/半写(磁盘满等)
|
||||
const written = createHash('sha256')
|
||||
.update(await readFile(dest))
|
||||
.digest('hex');
|
||||
if (written !== got) {
|
||||
await unlink(dest).catch(() => {});
|
||||
throw new Error('写入后校验不一致,已删除(请检查磁盘空间)');
|
||||
}
|
||||
return { path: dest, sha256: got, bytes: buf.length };
|
||||
},
|
||||
|
||||
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,523 @@
|
||||
/**
|
||||
* 出站代理支持 —— 只为「检查更新 / 下载更新包」这两条 HTTPS 出网路径服务。
|
||||
*
|
||||
* ## 为什么必须自己实现(这是缺陷,不是优化)
|
||||
*
|
||||
* Node 内置 `fetch`(undici)**默认不读任何代理环境变量** —— 实测:设了 `HTTPS_PROXY`
|
||||
* 仍然 `fetch failed`。能开的只有 `NODE_USE_ENV_PROXY=1`(Node 22.x 实验性、24 正式),
|
||||
* 而它**只能读环境变量**,读不到 Windows 系统代理(WinINET)或 macOS 系统代理
|
||||
* —— 而中文用户用 Clash / v2rayN 时配置的恰恰是「系统代理」,不是环境变量。
|
||||
* 结果:境内用户即使代理一切正常,「检查更新」也**必然**报 `fetch failed`。
|
||||
* (真机取证 2026-10-13:VM 上 DNS 与 TCP 443 都通、但 `fetch failed`;同一台机器
|
||||
* 直连 GitHub 被阻断、经代理取 `latest.json` 返回 302。)
|
||||
*
|
||||
* 另外 `fetch(url, { dispatcher })` 也不通:`dispatcher` 必须是 undici 的 Dispatcher 实例,
|
||||
* 而 Node 没有把 undici 暴露成可导入的模块(只能整包 vendored 进来,那会把 sidecar
|
||||
* 从 227KB 撑胖一大截,且 undici 依赖 `.wasm` 解析器、esbuild 单文件打包有风险)。
|
||||
*
|
||||
* ## 做法
|
||||
*
|
||||
* 不用 `fetch`,改用 `node:http` / `node:https` 自己发请求:
|
||||
* - **https 目标经代理**:先向代理发 `CONNECT host:443` 建隧道,再在隧道 socket 上做 TLS
|
||||
* (通过 `createConnection` 交给 `http.request` —— 注意这里刻意用 `http` 而非 `https`,
|
||||
* 因为我们交出去的**已经是 TLS socket**,用 `https.request` 会二次握手);
|
||||
* - **http 目标经代理**:按 HTTP 代理规范发**绝对形态**请求行(`GET http://… HTTP/1.1`);
|
||||
* - **无代理**:与改动前一致,直连(走 `https.request`)。
|
||||
*
|
||||
* ## 代理来源优先级
|
||||
*
|
||||
* ① `NA_PROXY`(显式覆盖,便于排查与将来做配置项)
|
||||
* ② 目标协议对应的环境变量(`HTTPS_PROXY`/`https_proxy` → `ALL_PROXY`/`all_proxy` → …)
|
||||
* ③ **系统代理**(Windows 读注册表 WinINET;macOS 读 `scutil --proxy`)
|
||||
*
|
||||
* `NO_PROXY` / `no_proxy` 命中的主机一律直连。只支持 **HTTP 代理**(`http(s)://`)——
|
||||
* HTTPS 目标经 HTTP 代理走 CONNECT,是通用做法;`socks5://` 会**明确**记一条 note
|
||||
* 而不是静默直连(静默正是本次缺陷最恶劣的地方)。
|
||||
*
|
||||
* ## 安全
|
||||
*
|
||||
* 只做「出站取数据」,不引入任何新依赖;TLS 证书默认校验,不提供跳过开关。
|
||||
*/
|
||||
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import tls from 'node:tls';
|
||||
import { execFile } from 'node:child_process';
|
||||
|
||||
/** 代理来源优先级(按目标协议分列)。`NA_PROXY` 恒为第一优先。 */
|
||||
const ENV_ORDER = {
|
||||
'https:': ['NA_PROXY', 'HTTPS_PROXY', 'https_proxy', 'ALL_PROXY', 'all_proxy', 'HTTP_PROXY', 'http_proxy'],
|
||||
'http:': ['NA_PROXY', 'HTTP_PROXY', 'http_proxy', 'ALL_PROXY', 'all_proxy'],
|
||||
};
|
||||
const NO_PROXY_KEYS = ['NO_PROXY', 'no_proxy'];
|
||||
const REDIRECT_STATUS = new Set([301, 302, 303, 307, 308]);
|
||||
const UA = 'nodeagent-console-sidecar';
|
||||
|
||||
/**
|
||||
* 把环境里/配置里写的各种代理形态归一成可用的 URL。
|
||||
*
|
||||
* 接受 `http://127.0.0.1:7890`、`127.0.0.1:7890`(补 `http://`)、带账密的 URL;
|
||||
* 其余(`socks5://`、空串、无法解析)返回 `null` —— 由调用方把「看到了但用不了」
|
||||
* 明确报出来,**不许静默直连**。
|
||||
*
|
||||
* @param {unknown} raw
|
||||
* @returns {URL | null}
|
||||
*/
|
||||
export function normalizeProxyUrl(raw) {
|
||||
if (typeof raw !== 'string') return null;
|
||||
const s = raw.trim();
|
||||
if (!s) return null;
|
||||
let u;
|
||||
try {
|
||||
u = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(s) ? s : `http://${s}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
|
||||
if (!u.hostname) return null;
|
||||
return u;
|
||||
}
|
||||
|
||||
/** 代理 URL 的主机端口(缺省按代理自身协议补 80/443)。 */
|
||||
function proxyEndpoint(u) {
|
||||
return { host: u.hostname, port: u.port ? Number(u.port) : u.protocol === 'https:' ? 443 : 80 };
|
||||
}
|
||||
|
||||
/** 代理 URL 里的账密 → `Proxy-Authorization` 头(没有则 null)。 */
|
||||
function proxyAuthHeader(u) {
|
||||
if (!u.username) return null;
|
||||
const raw = `${decodeURIComponent(u.username)}:${decodeURIComponent(u.password)}`;
|
||||
return 'Basic ' + Buffer.from(raw).toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* `NO_PROXY` 命中判定。支持:精确主机、后缀(`example.com` 或 `.example.com` 都匹配
|
||||
* `a.example.com`)、`host:port`、`*`。大小写不敏感。
|
||||
*
|
||||
* ⚠️ 刻意**不支持 CIDR**(如 `169.254/16`):本模块的目标只有公网域名,
|
||||
* 而 CIDR 主机名判定极易写错、错了就是「该走代理的没走」。遇到疑似 CIDR 条目
|
||||
* 由调用方记 note(见 `pickEnvProxy` 的 notes 语义)。
|
||||
*
|
||||
* @param {string} targetUrl
|
||||
* @param {string} noProxy
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isBypassed(targetUrl, noProxy) {
|
||||
if (!noProxy) return false;
|
||||
let host;
|
||||
let port;
|
||||
try {
|
||||
const u = new URL(targetUrl);
|
||||
host = u.hostname.toLowerCase();
|
||||
port = u.port || (u.protocol === 'https:' ? '443' : '80');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
for (const rawEntry of String(noProxy).split(',')) {
|
||||
const entry = rawEntry.trim().toLowerCase();
|
||||
if (!entry) continue;
|
||||
if (entry === '*') return true;
|
||||
let eHost = entry;
|
||||
let ePort = null;
|
||||
const i = entry.lastIndexOf(':');
|
||||
// 排除 IPv6 字面量(`[::1]:8080`)里的冒号误判
|
||||
if (i > 0 && !entry.slice(0, i).includes(':')) {
|
||||
eHost = entry.slice(0, i);
|
||||
ePort = entry.slice(i + 1);
|
||||
}
|
||||
if (ePort && ePort !== port) continue;
|
||||
const bare = eHost.replace(/^\./, '');
|
||||
if (!bare) continue;
|
||||
if (host === bare || host.endsWith(`.${bare}`)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从环境变量里挑代理(纯函数,便于单测)。
|
||||
*
|
||||
* @param {Record<string, string | undefined>} env
|
||||
* @param {string} targetUrl
|
||||
* @returns {{ url: URL | null, source: string | null, notes: string[] }}
|
||||
*/
|
||||
export function pickEnvProxy(env, targetUrl) {
|
||||
let proto = 'https:';
|
||||
try {
|
||||
proto = new URL(targetUrl).protocol;
|
||||
} catch {
|
||||
/* 保持默认 */
|
||||
}
|
||||
const keys = ENV_ORDER[proto] ?? ENV_ORDER['https:'];
|
||||
const notes = [];
|
||||
for (const k of keys) {
|
||||
const v = env?.[k];
|
||||
if (!v) continue;
|
||||
const u = normalizeProxyUrl(v);
|
||||
if (u) return { url: u, source: k, notes };
|
||||
notes.push(`${k}=${String(v).trim()} 不是受支持的代理协议(仅支持 http/https 代理),已忽略`);
|
||||
}
|
||||
return { url: null, source: null, notes };
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 macOS `scutil --proxy` 的输出。
|
||||
*
|
||||
* 形如:
|
||||
* <dictionary> {
|
||||
* HTTPSEnable : 1
|
||||
* HTTPSPort : 7890
|
||||
* HTTPSProxy : 127.0.0.1
|
||||
* ExceptionsList : <array> { 0 : *.local 1 : 169.254/16 }
|
||||
* }
|
||||
*
|
||||
* 注意 macOS 上 HTTPS 与 HTTP 是**分别开关**的;这里 https 优先,取不到再退 HTTP。
|
||||
* `ProxyAutoConfigEnable : 1`(PAC 自动配置脚本)**无法解析** —— 返回 notes 说明,
|
||||
* 让上层给出「请显式设 HTTPS_PROXY」这类可操作提示,而不是含糊的 fetch failed。
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {{ url: string | null, bypass: string, notes: string[] } | null}
|
||||
*/
|
||||
export function parseScutilProxy(text) {
|
||||
if (typeof text !== 'string' || !text.includes('{')) return null;
|
||||
const scalar = (key) => {
|
||||
const m = new RegExp(`^\\s*${key}\\s*:\\s*(.+?)\\s*$`, 'm').exec(text);
|
||||
return m ? m[1].trim() : null;
|
||||
};
|
||||
const isOn = (v) => v === '1' || v === 'true';
|
||||
const notes = [];
|
||||
|
||||
// ExceptionsList 是 <array> 块里的 “序号 : 值” 行 —— 收集成逗号分隔的绕过列表
|
||||
const bypass = [];
|
||||
const arrIdx = text.indexOf('ExceptionsList');
|
||||
if (arrIdx >= 0) {
|
||||
const body = text.slice(arrIdx, text.indexOf('}', arrIdx) + 1);
|
||||
const re = /^\s*\d+\s*:\s*(.+?)\s*$/gm;
|
||||
let m;
|
||||
while ((m = re.exec(body)) !== null) bypass.push(m[1].trim());
|
||||
}
|
||||
|
||||
for (const [flag, hostKey, portKey, dflt] of [
|
||||
['HTTPSEnable', 'HTTPSProxy', 'HTTPSPort', '443'],
|
||||
['HTTPEnable', 'HTTPProxy', 'HTTPPort', '80'],
|
||||
]) {
|
||||
if (!isOn(scalar(flag))) continue;
|
||||
const host = scalar(hostKey);
|
||||
if (!host) continue;
|
||||
const port = scalar(portKey) ?? dflt;
|
||||
return { url: `${host}:${port}`, bypass: bypass.join(','), notes };
|
||||
}
|
||||
|
||||
if (isOn(scalar('ProxyAutoConfigEnable'))) {
|
||||
notes.push('系统代理使用 PAC 自动配置脚本(ProxyAutoConfigEnable=1),本程序无法解析脚本内容,已按「无代理」处理');
|
||||
}
|
||||
return { url: null, bypass: bypass.join(','), notes };
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 Windows `reg query …\Internet Settings` 的输出。
|
||||
*
|
||||
* 形如(`REG_SZ` / `REG_DWORD` 是**类型名,不随系统语言变化**,可安全按 token 解析):
|
||||
* ProxyEnable REG_DWORD 0x1
|
||||
* ProxyOverride REG_SZ *.local;<local>
|
||||
* ProxyServer REG_SZ 127.0.0.1:7890 (也可能是 http=…;https=… 分协议形态)
|
||||
*
|
||||
* `ProxyEnable=0x0` 视为**显式关闭** → 无代理(哪怕 ProxyServer 有值)。
|
||||
* `<local>` 是「本机地址直连」的占位符,不是域名,从绕过列表里剔除。
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {{ url: string | null, bypass: string, notes: string[] } | null}
|
||||
*/
|
||||
export function parseRegQueryProxy(text) {
|
||||
if (typeof text !== 'string' || !text.includes('REG_')) return null;
|
||||
const value = (name) => {
|
||||
const m = new RegExp(`^\\s*${name}\\s+REG_(?:SZ|EXPAND_SZ|DWORD|BINARY)\\s+(.*?)\\s*$`, 'm').exec(text);
|
||||
return m ? m[1].trim() : null;
|
||||
};
|
||||
const notes = [];
|
||||
|
||||
const enable = value('ProxyEnable');
|
||||
if (enable !== null && /^0x0+$/i.test(enable)) return { url: null, bypass: '', notes };
|
||||
|
||||
const server = value('ProxyServer');
|
||||
if (!server) return { url: null, bypass: '', notes };
|
||||
|
||||
const override = value('ProxyOverride');
|
||||
const bypass = override
|
||||
? override
|
||||
.split(';')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s && !/^<local>$/i.test(s))
|
||||
.join(',')
|
||||
: '';
|
||||
|
||||
// 分协议形态:http=127.0.0.1:7890;https=127.0.0.1:7891 —— https 优先
|
||||
let url = server;
|
||||
if (server.includes('=')) {
|
||||
const map = new Map();
|
||||
for (const part of server.split(';')) {
|
||||
const i = part.indexOf('=');
|
||||
if (i > 0) map.set(part.slice(0, i).trim().toLowerCase(), part.slice(i + 1).trim());
|
||||
}
|
||||
url = map.get('https') ?? map.get('http') ?? null;
|
||||
if (!url) notes.push(`系统代理仅配置了非 http(s) 协议(ProxyServer=${server})`);
|
||||
}
|
||||
return { url, bypass, notes };
|
||||
}
|
||||
|
||||
/** 跑一个只读命令,失败/超时一律返回 null(探测系统代理**绝不允许**把检查更新搞挂)。 */
|
||||
function runQuiet(file, args) {
|
||||
return new Promise((resolve) => {
|
||||
execFile(
|
||||
file,
|
||||
args,
|
||||
// windowsHide:本进程是被 CREATE_NO_WINDOW 拉起的「无控制台」进程,
|
||||
// 若再 spawn 控制台程序(reg.exe),Windows 会**新开一个控制台** → 黑窗闪一下。
|
||||
{ windowsHide: true, timeout: 5000, encoding: 'utf8', maxBuffer: 1024 * 1024 },
|
||||
(err, stdout) => resolve(err ? null : String(stdout)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 探测系统代理。`run` 可注入(单测用假 runner,不必真去读注册表)。
|
||||
*
|
||||
* @param {NodeJS.Platform} [platform]
|
||||
* @param {(file: string, args: string[]) => Promise<string | null>} [run]
|
||||
* @returns {Promise<{ url: string | null, bypass: string, notes: string[] } | null>}
|
||||
*/
|
||||
export async function detectSystemProxy(platform = process.platform, run = runQuiet) {
|
||||
try {
|
||||
if (platform === 'win32') {
|
||||
const out = await run('reg', [
|
||||
'query',
|
||||
'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings',
|
||||
]);
|
||||
return out ? parseRegQueryProxy(out) : null;
|
||||
}
|
||||
if (platform === 'darwin') {
|
||||
const out = await run('scutil', ['--proxy']);
|
||||
return out ? parseScutilProxy(out) : null;
|
||||
}
|
||||
// Linux:桌面环境各异(gsettings / 环境变量 / PAC),不做猜测 —— 只认环境变量
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 系统代理只探测一次(同一进程内结果不变;用户改代理需重启控制台)。 */
|
||||
let systemProxyPromise;
|
||||
export function systemProxy() {
|
||||
if (!systemProxyPromise) systemProxyPromise = detectSystemProxy().catch(() => null);
|
||||
return systemProxyPromise;
|
||||
}
|
||||
|
||||
/** 单测用:清掉系统代理缓存。 */
|
||||
export function __resetSystemProxyCache() {
|
||||
systemProxyPromise = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 决议本次请求该用哪个代理。
|
||||
*
|
||||
* @param {{ targetUrl: string, env?: Record<string, string | undefined>, system?: unknown }} opts
|
||||
* `system` 显式传入时不做探测(单测用);传 `null` 表示「确认没有系统代理」。
|
||||
* @returns {Promise<{ proxy: URL | null, source: string | null, notes: string[] }>}
|
||||
*/
|
||||
export async function resolveProxy({ targetUrl, env = process.env, system } = {}) {
|
||||
const notes = [];
|
||||
const noProxy = NO_PROXY_KEYS.map((k) => env?.[k]).find(Boolean) ?? '';
|
||||
if (noProxy && isBypassed(targetUrl, noProxy)) {
|
||||
return { proxy: null, source: null, notes: [`目标命中 NO_PROXY=${noProxy},按直连处理`] };
|
||||
}
|
||||
|
||||
const fromEnv = pickEnvProxy(env ?? {}, targetUrl);
|
||||
notes.push(...fromEnv.notes);
|
||||
if (fromEnv.url) return { proxy: fromEnv.url, source: fromEnv.source, notes };
|
||||
|
||||
const sys = system === undefined ? await systemProxy() : system;
|
||||
if (sys?.url) {
|
||||
notes.push(...(sys.notes ?? []));
|
||||
if (sys.bypass && isBypassed(targetUrl, sys.bypass)) {
|
||||
notes.push(`目标命中系统代理的绕过列表(${sys.bypass}),按直连处理`);
|
||||
return { proxy: null, source: null, notes };
|
||||
}
|
||||
const u = normalizeProxyUrl(String(sys.url));
|
||||
if (u) return { proxy: u, source: 'system', notes };
|
||||
notes.push(`系统代理 ${sys.url} 不受支持(仅支持 http/https 代理),已按直连处理`);
|
||||
return { proxy: null, source: null, notes };
|
||||
}
|
||||
notes.push(...(sys?.notes ?? []));
|
||||
return { proxy: null, source: null, notes };
|
||||
}
|
||||
|
||||
/**
|
||||
* 造一个 `createConnection`:向代理发 CONNECT 建隧道,https 目标再在隧道上叠 TLS。
|
||||
*
|
||||
* 返回的 socket 交给 `http.request` 使用(那已经是 TLS socket,故用 `http` 模块)。
|
||||
*
|
||||
* ## ⚠️ 为什么必须自己管 `signal`(真机踩过)
|
||||
* `createConnection` 的回调**返回之前**,外层 `http.request` 还没建立 —— 此时
|
||||
* 外层请求上的 `signal` / `timeout` 都**无人受理**。真机形态:代理 TCP 能连上、却对
|
||||
* CONNECT 或随后的 TLS 握手**永不响应**,于是「75s 超时」形同虚设,实际挂了 109s。
|
||||
* 所以:CONNECT 请求要带 signal,**拿到 socket 与 tlsSock 之后还要自己挂 abort**,
|
||||
* 失败/成功时都要摘掉监听,避免泄漏与误伤。
|
||||
*/
|
||||
export function tunnelConnection(proxy, target, signal) {
|
||||
const pe = proxyEndpoint(proxy);
|
||||
return (_opts, cb) => {
|
||||
let settled = false;
|
||||
let tunnelSock = null;
|
||||
let tlsSock = null;
|
||||
|
||||
const detach = () => {
|
||||
if (signal) signal.removeEventListener('abort', onAbort);
|
||||
if (tunnelSock) tunnelSock.removeListener('error', onSockError);
|
||||
};
|
||||
const fail = (err) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
detach();
|
||||
// 半开的隧道必须销毁,否则这个悬挂连接会一直占着(用户点几次就攒一堆)
|
||||
try {
|
||||
tlsSock?.destroy();
|
||||
tunnelSock?.destroy();
|
||||
} catch {
|
||||
/* 已经没了 */
|
||||
}
|
||||
cb(err);
|
||||
};
|
||||
const onSockError = (e) => fail(e);
|
||||
const onAbort = () => fail(signal?.reason instanceof Error ? signal.reason : new Error('请求已中止'));
|
||||
|
||||
if (signal?.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
|
||||
const port = target.port || (target.protocol === 'https:' ? 443 : 80);
|
||||
const headers = { host: `${target.hostname}:${port}` };
|
||||
const auth = proxyAuthHeader(proxy);
|
||||
if (auth) headers['proxy-authorization'] = auth;
|
||||
|
||||
const req = http.request({
|
||||
host: pe.host,
|
||||
port: pe.port,
|
||||
method: 'CONNECT',
|
||||
path: `${target.hostname}:${port}`,
|
||||
headers,
|
||||
agent: false,
|
||||
signal,
|
||||
});
|
||||
req.once('connect', (res, socket, head) => {
|
||||
tunnelSock = socket;
|
||||
if (res.statusCode !== 200) {
|
||||
fail(new Error(`代理拒绝 CONNECT(HTTP ${res.statusCode})`));
|
||||
return;
|
||||
}
|
||||
if (head && head.length) socket.unshift(head);
|
||||
if (target.protocol !== 'https:') {
|
||||
settled = true;
|
||||
detach();
|
||||
cb(null, socket);
|
||||
return;
|
||||
}
|
||||
socket.once('error', onSockError);
|
||||
tlsSock = tls.connect({ socket, servername: target.hostname });
|
||||
tlsSock.once('secureConnect', () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
detach();
|
||||
cb(null, tlsSock);
|
||||
});
|
||||
tlsSock.once('error', onSockError);
|
||||
});
|
||||
req.once('error', onSockError);
|
||||
req.end();
|
||||
};
|
||||
}
|
||||
|
||||
/** 单跳请求(不跟随重定向)。 */
|
||||
function once(urlStr, { proxy, timeoutMs, signal }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const u = new URL(urlStr);
|
||||
const isHttps = u.protocol === 'https:';
|
||||
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
||||
reject(new Error(`不支持的协议:${u.protocol}`));
|
||||
return;
|
||||
}
|
||||
// ⚠️ 手搭 options,**不要**把 URL 对象当第一参再覆盖字段 —— 传 URL 时 Node 会用
|
||||
// URL 里的 host/port/path 覆盖后面的同名字段(原型实测踩过:代理形同无效、退化成直连)。
|
||||
const opts = {
|
||||
method: 'GET',
|
||||
signal,
|
||||
timeout: timeoutMs,
|
||||
host: u.hostname,
|
||||
port: u.port || (isHttps ? 443 : 80),
|
||||
path: `${u.pathname}${u.search}`,
|
||||
headers: { accept: '*/*', 'user-agent': UA, host: u.host },
|
||||
};
|
||||
|
||||
let req;
|
||||
if (proxy && !isHttps) {
|
||||
// http 目标经代理 → 绝对形态请求行,且请求行交给代理
|
||||
const pe = proxyEndpoint(proxy);
|
||||
opts.host = pe.host;
|
||||
opts.port = pe.port;
|
||||
opts.path = u.href;
|
||||
const auth = proxyAuthHeader(proxy);
|
||||
if (auth) opts.headers['proxy-authorization'] = auth;
|
||||
req = http.request(opts);
|
||||
} else if (proxy) {
|
||||
opts.createConnection = tunnelConnection(proxy, u, signal);
|
||||
req = http.request(opts);
|
||||
} else {
|
||||
req = (isHttps ? https : http).request(opts);
|
||||
}
|
||||
|
||||
req.once('timeout', () => req.destroy(new Error(`请求超时(${timeoutMs}ms 无响应)`)));
|
||||
req.once('response', (res) => {
|
||||
const chunks = [];
|
||||
res.on('data', (c) => chunks.push(c));
|
||||
res.once('error', reject);
|
||||
res.once('end', () =>
|
||||
resolve({ status: res.statusCode, statusText: res.statusMessage, headers: res.headers, body: Buffer.concat(chunks) }),
|
||||
);
|
||||
});
|
||||
req.once('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GET 一个 URL(跟随重定向)。
|
||||
*
|
||||
* 与 `fetch(url, {redirect:'follow'})` 的差异:不自动解压(不发 `accept-encoding`,
|
||||
* 服务端会回 identity)、不解析 cookies —— 这两条对本用途都无影响。
|
||||
*
|
||||
* 整个重定向链共享**同一个**超时预算与 abort 信号(而不是每跳各自计时),
|
||||
* 否则 5 跳 × 30s = 150s 会穿过上层的 RPC 超时。
|
||||
*
|
||||
* @param {string} url
|
||||
* @param {{ proxy?: URL | null, timeoutMs?: number, maxRedirects?: number }} [opts]
|
||||
* @returns {Promise<{ status: number, statusText: string, headers: Record<string, unknown>, body: Buffer, url: string }>}
|
||||
*/
|
||||
export async function httpGet(url, { proxy = null, timeoutMs = 30_000, maxRedirects = 5 } = {}) {
|
||||
const ac = new AbortController();
|
||||
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
||||
try {
|
||||
let current = String(url);
|
||||
for (let hop = 0; ; hop += 1) {
|
||||
const res = await once(current, { proxy, timeoutMs, signal: ac.signal });
|
||||
const loc = res.headers?.location;
|
||||
if (REDIRECT_STATUS.has(res.status) && typeof loc === 'string' && hop < maxRedirects) {
|
||||
current = new URL(loc, current).href;
|
||||
continue;
|
||||
}
|
||||
return { ...res, url: current };
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
@@ -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,41 @@
|
||||
[package]
|
||||
name = "nodeagent-console"
|
||||
version = "2.1.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]
|
||||
# `tray-icon`:托盘常驻(后台运行 / 开机自启的载体)必需。
|
||||
# 不给这个 feature,`TrayIconBuilder` 根本不存在(编译期就找不到符号)。
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
tauri-plugin-opener = "2"
|
||||
# ⚠️ 单实例保护(v2.0.3 修):缺了它,「开机自启的隐藏实例 + 用户手开的实例」会并存,
|
||||
# 各自托管一个被控端 → 同机两个被控端抢同一个 8765。详见 src/lib.rs 顶部「单实例」一节。
|
||||
tauri-plugin-single-instance = "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,299 @@
|
||||
//! 开机自启管理 —— 「随系统启动」开关的落地实现。
|
||||
//!
|
||||
//! ## 设计:自启的是**控制台的仅服务模式**,不是裸 node
|
||||
//!
|
||||
//! 两个候选方案,各有硬伤:
|
||||
//! - **直接自启 `node agent.mjs`**:Windows 上会弹一个黑色控制台窗口(schtasks 无法指定
|
||||
//! `CREATE_NO_WINDOW`),且进程无人托管 —— 崩了不会拉起、也无法从界面停止。
|
||||
//! - **自启本控制台(`--agent-only`)**:本应用是 GUI 子系统程序,**不会有控制台窗口**;
|
||||
//! 且它本来就负责「托管被控端」(启动/停止/自动重启/日志),语义完全对齐。
|
||||
//!
|
||||
//! 故选后者。`--agent-only` 模式下应用不显示主窗口,直接进托盘并拉起被控端。
|
||||
//!
|
||||
//! ## 平台差异
|
||||
//! | 平台 | 机制 | 位置 |
|
||||
//! |---|---|---|
|
||||
//! | macOS | LaunchAgent(用户级,无需提权) | `~/Library/LaunchAgents/<label>.plist` |
|
||||
//! | Windows | 计划任务(登录时触发,Interactive 登录类型) | 任务名 `<label>` |
|
||||
//!
|
||||
//! Windows 用**登录用户 + Interactive** 而非 SYSTEM:被控端的图形能力(截图 / 键鼠 / 窗口)
|
||||
//! 必须跑在交互式会话里,SYSTEM(会话 0)没有桌面 —— 这一点在真机验证时已实证。
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
/// 自启项标识(macOS 的 launchd label / Windows 的计划任务名)。
|
||||
const LABEL: &str = "cn.jkooe.nodeagent-console";
|
||||
|
||||
/// 仅服务模式命令行参数(无窗口、直接进托盘并拉起被控端)。
|
||||
pub const AGENT_ONLY_FLAG: &str = "--agent-only";
|
||||
|
||||
/// 当前可执行文件路径。
|
||||
///
|
||||
/// ⚠️ 出口经 `unverbatim`:Windows 上 `current_exe()` 可能带 `\\?\` 前缀,
|
||||
/// 而这条路径会**被写进计划任务的执行路径**(`New-ScheduledTaskAction -Execute`)。
|
||||
/// 带前缀的路径不是「一定不能用」,但它会以原样进入 PowerShell 字符串与任务定义 ——
|
||||
/// 与 2026-10-13「verbatim 路径交给外部程序」那类真机缺陷同源,统一在出口净化。
|
||||
fn app_exe() -> Result<PathBuf, String> {
|
||||
std::env::current_exe()
|
||||
.map(crate::sidecar::unverbatim)
|
||||
.map_err(|e| format!("取当前程序路径失败:{e}"))
|
||||
}
|
||||
|
||||
/// macOS 上 LaunchAgent plist 的位置。
|
||||
fn macos_plist_path() -> Result<PathBuf, String> {
|
||||
let home = std::env::var_os("HOME")
|
||||
.map(PathBuf::from)
|
||||
.ok_or("拿不到 HOME")?;
|
||||
Ok(home.join("Library").join("LaunchAgents").join(format!("{LABEL}.plist")))
|
||||
}
|
||||
|
||||
/// 生成 macOS LaunchAgent plist。
|
||||
///
|
||||
/// `RunAtLoad` 保证登录即拉起;**不用** `KeepAlive` —— 让「退出」按钮能真正退出,
|
||||
/// 用户想恢复就重新登录或再开应用。进程级别的崩溃自愈由控制台的 `AgentManager` 负责,
|
||||
/// 不该在这里再叠一层(两层自愈会互相打架)。
|
||||
fn macos_plist(exe: &Path) -> String {
|
||||
let exe = xml_escape(&exe.to_string_lossy());
|
||||
format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<!-- 由 nodeagent 控制台生成 —— 请勿手工编辑(界面上的「开机自启」开关会覆写本文件) -->
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>{LABEL}</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>{exe}</string>
|
||||
<string>{AGENT_ONLY_FLAG}</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>ProcessType</key>
|
||||
<string>Interactive</string>
|
||||
</dict>
|
||||
</plist>
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
fn xml_escape(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
|
||||
/// 当前用户的 uid(macOS 的 `launchctl bootstrap gui/<uid>` 需要它)。
|
||||
///
|
||||
/// 用 `id -u` 现取,**不硬编码 501** —— 501 只是首个本地用户的常见值,
|
||||
/// 多用户机器上并不成立。
|
||||
fn current_uid() -> Option<String> {
|
||||
let out = Command::new("id").arg("-u").output().ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
if s.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(s)
|
||||
}
|
||||
}
|
||||
|
||||
/// macOS:加载 / 卸载 LaunchAgent。
|
||||
///
|
||||
/// 新版 macOS 推荐 `launchctl bootstrap gui/<uid>`,旧版用 `load -w`;两条都试,
|
||||
/// 任一路径成功即算成功(不同 macOS 版本上可用的那条不一样)。
|
||||
fn macos_launchctl(plist: &Path, enable: bool) -> Result<String, String> {
|
||||
let mut notes: Vec<String> = Vec::new();
|
||||
let uid = current_uid();
|
||||
let domain = uid.map(|u| format!("gui/{u}"));
|
||||
|
||||
let attempts: Vec<Vec<String>> = if enable {
|
||||
let mut v = vec![vec!["load".into(), "-w".into(), plist.to_string_lossy().to_string()]];
|
||||
if let Some(d) = &domain {
|
||||
v.push(vec!["bootstrap".into(), d.clone(), plist.to_string_lossy().to_string()]);
|
||||
}
|
||||
v
|
||||
} else {
|
||||
let mut v = vec![vec!["unload".into(), "-w".into(), plist.to_string_lossy().to_string()]];
|
||||
if let Some(d) = &domain {
|
||||
v.push(vec!["bootout".into(), format!("{d}/{LABEL}")]);
|
||||
}
|
||||
v
|
||||
};
|
||||
|
||||
for args in attempts {
|
||||
match Command::new("launchctl").args(&args).output() {
|
||||
Ok(o) if o.status.success() => return Ok(format!("launchctl {} 成功", args[0])),
|
||||
Ok(o) => notes.push(format!(
|
||||
"launchctl {} 失败:{}",
|
||||
args[0],
|
||||
String::from_utf8_lossy(&o.stderr).trim()
|
||||
)),
|
||||
Err(e) => notes.push(format!("launchctl {} 无法执行:{e}", args[0])),
|
||||
}
|
||||
}
|
||||
if enable {
|
||||
Err(notes.join(";"))
|
||||
} else {
|
||||
// 卸载失败不致命:plist 已删,下次登录不会再起
|
||||
Ok(format!("(卸载命令未全部成功,但配置已移除){}", notes.join(";")))
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows:注册 / 注销计划任务。
|
||||
///
|
||||
/// 用 PowerShell 的 `New-ScheduledTask*` + `Register-ScheduledTask` 而非 `schtasks /TR` ——
|
||||
/// 后者要拼一层引号转义(路径含空格时尤其容易出错),前者用参数对象,无转义问题。
|
||||
fn windows_schtasks(exe: &Path, enable: bool) -> Result<String, String> {
|
||||
if enable {
|
||||
let script = format!(
|
||||
r#"$ErrorActionPreference='Stop'
|
||||
$exe = '{exe}'
|
||||
$action = New-ScheduledTaskAction -Execute $exe -Argument '{AGENT_ONLY_FLAG}'
|
||||
$trigger = New-ScheduledTaskTrigger -AtLogOn
|
||||
$user = "$env:USERDOMAIN\$env:USERNAME"
|
||||
$principal = New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Highest
|
||||
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit ([TimeSpan]::Zero)
|
||||
Register-ScheduledTask -TaskName '{LABEL}' -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null
|
||||
Write-Output ('OK ' + $user)"#,
|
||||
exe = exe.display().to_string().replace('\'', "''"),
|
||||
);
|
||||
run_powershell(&script)
|
||||
} else {
|
||||
let script = format!(
|
||||
r#"$ErrorActionPreference='Stop'
|
||||
Unregister-ScheduledTask -TaskName '{LABEL}' -Confirm:$false
|
||||
Write-Output 'OK removed'"#
|
||||
);
|
||||
run_powershell(&script)
|
||||
}
|
||||
}
|
||||
|
||||
fn run_powershell(script: &str) -> Result<String, String> {
|
||||
let out = Command::new("powershell")
|
||||
.args(["-NoProfile", "-NonInteractive", "-Command", script])
|
||||
.output()
|
||||
.map_err(|e| format!("无法执行 PowerShell:{e}"))?;
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||
if out.status.success() {
|
||||
Ok(stdout)
|
||||
} else {
|
||||
Err(if stderr.is_empty() { stdout } else { stderr })
|
||||
}
|
||||
}
|
||||
|
||||
/// 自启是否已启用。
|
||||
///
|
||||
/// ## 为什么要缓存(2026-10-17 真机)
|
||||
/// Windows 上 `schtasks /Query` 实测**约 2 秒**(`Get-ScheduledTask` 同样 2s),
|
||||
/// 而这个函数每次 `agent_status` 都会被调用 —— 于是每刷新一次界面就白等 2 秒,
|
||||
/// 叠加多次刷新后表现为「按钮转圈不结束、PID 不再更新」。
|
||||
///
|
||||
/// 自启开关是低频状态(用户手动切换才会变),故缓存 5 秒即可:
|
||||
/// 切换自启后紧接着的那次 `status()` 可能读到旧值,但 `set_autostart` 本身
|
||||
/// 会把结果写回缓存(见 `invalidate_cache`),界面不会看到错的状态。
|
||||
fn is_enabled_uncached() -> bool {
|
||||
if cfg!(target_os = "macos") {
|
||||
macos_plist_path().map(|p| p.exists()).unwrap_or(false)
|
||||
} else if cfg!(windows) {
|
||||
Command::new("schtasks")
|
||||
.args(["/Query", "/TN", LABEL])
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// 自启状态缓存:`(取值时刻 ms, 值)`。0 表示从未查过。
|
||||
static AUTOSTART_CACHE: std::sync::Mutex<(u64, bool)> = std::sync::Mutex::new((0, false));
|
||||
const AUTOSTART_TTL_MS: u64 = 5_000;
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn is_enabled() -> bool {
|
||||
{
|
||||
let c = AUTOSTART_CACHE.lock().unwrap();
|
||||
if c.0 != 0 && now_ms().saturating_sub(c.0) < AUTOSTART_TTL_MS {
|
||||
return c.1;
|
||||
}
|
||||
}
|
||||
let v = is_enabled_uncached();
|
||||
*AUTOSTART_CACHE.lock().unwrap() = (now_ms(), v);
|
||||
v
|
||||
}
|
||||
|
||||
/// 切换自启后立刻让缓存失效 —— 否则用户点完开关,界面会显示 5 秒前的旧值。
|
||||
pub fn invalidate_cache() {
|
||||
*AUTOSTART_CACHE.lock().unwrap() = (0, false);
|
||||
}
|
||||
|
||||
/// 读取自启状态的完整快照(供界面展示)。
|
||||
pub fn status() -> Value {
|
||||
let exe = app_exe();
|
||||
let supported = cfg!(any(target_os = "macos", windows));
|
||||
let where_ = if cfg!(target_os = "macos") {
|
||||
macos_plist_path().map(|p| p.to_string_lossy().to_string()).unwrap_or_default()
|
||||
} else if cfg!(windows) {
|
||||
format!("计划任务 {LABEL}")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
json!({
|
||||
"enabled": is_enabled(),
|
||||
"supported": supported,
|
||||
"location": where_,
|
||||
"app_exe": exe.map(|p| p.to_string_lossy().to_string()).unwrap_or_default(),
|
||||
"mode": AGENT_ONLY_FLAG,
|
||||
"note": if supported {
|
||||
"开机自启将以「仅服务模式」拉起本控制台:不显示窗口、直接进托盘并托管本机被控端。"
|
||||
} else {
|
||||
"当前平台暂不支持——请在系统设置里自行添加登录项。"
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// 开启 / 关闭开机自启。
|
||||
pub fn set(enabled: bool) -> Result<Value, String> {
|
||||
if !cfg!(any(target_os = "macos", windows)) {
|
||||
return Err("当前平台暂不支持自动配置开机自启".to_string());
|
||||
}
|
||||
let exe = app_exe()?;
|
||||
|
||||
let detail = if cfg!(target_os = "macos") {
|
||||
let plist = macos_plist_path()?;
|
||||
if enabled {
|
||||
if let Some(dir) = plist.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| format!("创建 LaunchAgents 目录失败:{e}"))?;
|
||||
}
|
||||
std::fs::write(&plist, macos_plist(&exe)).map_err(|e| format!("写入 plist 失败:{e}"))?;
|
||||
macos_launchctl(&plist, true)?
|
||||
} else {
|
||||
if plist.exists() {
|
||||
std::fs::remove_file(&plist).map_err(|e| format!("删除 plist 失败:{e}"))?;
|
||||
}
|
||||
macos_launchctl(&plist, false)?
|
||||
}
|
||||
} else {
|
||||
windows_schtasks(&exe, enabled)?
|
||||
};
|
||||
|
||||
// 刚改过自启状态,缓存必然过期 —— 不清就会把 5 秒前的旧值回给界面
|
||||
invalidate_cache();
|
||||
|
||||
let mut snap = status();
|
||||
snap["detail"] = json!(detail);
|
||||
Ok(snap)
|
||||
}
|
||||
@@ -0,0 +1,831 @@
|
||||
//! nodeagent 桌面控制台(主控 + 被控一体)——Tauri 后端。
|
||||
//!
|
||||
//! ## 结构(2026-10 起:sidecar 方案)
|
||||
//! 本层只做**进程管理 + 消息转发**,真正的协议实现只有 TS 一份(`@nodeagent/client`),
|
||||
//! 由 `sidecar.rs` 拉起的 Node 进程承载。原先「Rust 版 client 与 TS 版并存」的漂移风险就此消除。
|
||||
//!
|
||||
//! `agent.rs` 是同一思路的延伸:本机**被控端**也由本层托管(启动 / 停止 / 重启 / 日志 /
|
||||
//! 崩溃自愈),用户在界面上点按钮即可,全程不必碰命令行。
|
||||
//!
|
||||
//! ## command 面
|
||||
//! 连接类(签名与语义从未变过):
|
||||
//! - `connect` / `disconnect` / `invoke_capability` / `get_capabilities` / `get_state`
|
||||
//!
|
||||
//! 本机服务类(2026-10-11 新增):
|
||||
//! - `agent_status` / `agent_start` / `agent_stop` / `agent_restart`
|
||||
//! - `agent_logs` / `agent_logs_clear`
|
||||
//! - `autostart_status` / `autostart_set`
|
||||
//! - `check_update` / `apply_update`
|
||||
//! - `settings_get` / `settings_set` / `system_info`
|
||||
//!
|
||||
//! ## 事件面
|
||||
//! - 连接:`na-state` / `na-log` / `na-event`
|
||||
//! - 本机服务:`na-agent-state` / `na-agent-log`
|
||||
//!
|
||||
//! ## 启动模式
|
||||
//! - 默认:显示主窗口
|
||||
//! - `--agent-only`:**不显示窗口**,直接进托盘并托管本机被控端(开机自启用它,
|
||||
//! 这样登录自启也不会有任何窗口弹出来)
|
||||
//!
|
||||
//! ## 单实例(v2.0.3)
|
||||
//! 见下方「单实例」一节 —— 真机缺陷:无保护时同机会跑起两个被控端。
|
||||
|
||||
mod agent;
|
||||
mod autostart;
|
||||
mod settings;
|
||||
mod sidecar;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
|
||||
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
|
||||
use tauri::{AppHandle, Emitter, Manager, State};
|
||||
|
||||
use agent::{AgentEvent, AgentManager};
|
||||
use sidecar::{Sidecar, SidecarEvent};
|
||||
|
||||
/// 全局状态。
|
||||
///
|
||||
/// `connect` 成功后缓存「能力清单 / 版本 / 连接态」——这样 `get_capabilities`、
|
||||
/// `get_state` 能保持**同步**(无需每次往返 sidecar),签名与改造前一致。
|
||||
struct AppState {
|
||||
sidecar: tokio::sync::Mutex<Option<Arc<Sidecar>>>,
|
||||
capabilities: Mutex<Value>,
|
||||
agent_version: Mutex<Option<String>>,
|
||||
conn_state: Mutex<String>,
|
||||
/// 本机被控端管理器(在 `setup` 里建,因为需要 AppHandle 来桥接事件)
|
||||
agent: Mutex<Option<Arc<AgentManager>>>,
|
||||
settings: Mutex<settings::Settings>,
|
||||
/// 控制台自身配置目录(设置存这里)
|
||||
config_dir: PathBuf,
|
||||
/// 控制台版本(由 `package_info` 注入,用于更新比对;不在代码里写死)
|
||||
app_version: String,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
fn agent(&self) -> Result<Arc<AgentManager>, String> {
|
||||
self.agent
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.map(Arc::clone)
|
||||
.ok_or_else(|| "被控端管理器尚未初始化".to_string())
|
||||
}
|
||||
|
||||
fn settings(&self) -> settings::Settings {
|
||||
self.settings.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// 连接成功后回传给前端的元信息(字段名与改造前一致)。
|
||||
#[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,
|
||||
}
|
||||
|
||||
/// 本机被控端的三条路径。
|
||||
struct AgentPaths {
|
||||
node: PathBuf,
|
||||
script: PathBuf,
|
||||
data_dir: PathBuf,
|
||||
}
|
||||
|
||||
/// 解析(并在需要时播种)本机被控端所需路径。
|
||||
fn resolve_agent_paths(app: &AppHandle, state: &AppState) -> Result<AgentPaths, String> {
|
||||
let resource_dir = app.path().resource_dir().ok();
|
||||
let node = sidecar::resolve_node(resource_dir.as_deref())?;
|
||||
let bundled = agent::resolve_bundled_agent(resource_dir.as_deref())?;
|
||||
let data_dir = agent::agent_data_dir();
|
||||
let script = agent::seed_agent_script(&bundled, &data_dir, &state.app_version)?;
|
||||
Ok(AgentPaths { node, script, data_dir })
|
||||
}
|
||||
|
||||
/// 懒启动 sidecar(首次调用时拉起,之后复用同一进程)。
|
||||
async fn ensure_sidecar(app: &AppHandle, state: &AppState) -> Result<Arc<Sidecar>, String> {
|
||||
let mut guard = state.sidecar.lock().await;
|
||||
if let Some(sc) = guard.as_ref() {
|
||||
if sc.is_alive() {
|
||||
return Ok(Arc::clone(sc));
|
||||
}
|
||||
// ⚠️ 缓存里那个 sidecar **已经退出**,绝不能继续复用。
|
||||
//
|
||||
// 为什么以前没发现:往已退出进程的管道里写,在 Windows 上未必立刻报错
|
||||
// (管道缓冲区吞下就返回成功),于是请求会一路干等到 `request` 的超时。
|
||||
// 真机实证(2026-10-13,Windows 11 ARM64):控制台从 10-12 起一直开着,
|
||||
// 其 sidecar 早已退出;用户点「检查更新」等满 45 秒,界面报
|
||||
// 「检查更新失败:check_update 超时」—— 而同一台机器上**手工拉起 sidecar
|
||||
// 跑同一个请求只要 7 秒**(两相对照即锁定「复用死实例」)。
|
||||
// 丢掉缓存往下走,重新拉起一个。
|
||||
eprintln!("[sidecar] 检测到已退出的实例,正在重建");
|
||||
*guard = None;
|
||||
}
|
||||
|
||||
// 打包后 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 = 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)?;
|
||||
*state.conn_state.lock().unwrap() = "未连接".to_string();
|
||||
*guard = Some(Arc::clone(&sc));
|
||||
Ok(sc)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 连接类 command(签名与语义保持不变)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 建立到被控端的连接并完成握手(默认 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?;
|
||||
|
||||
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(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(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()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 本机服务(被控端)command
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 本机服务总览:进程状态 + 配置 + 自启 + 设置 + 版本。
|
||||
#[tauri::command]
|
||||
async fn agent_status(app: AppHandle, state: State<'_, AppState>) -> Result<Value, String> {
|
||||
// 管理器可能尚未建(极早的调用);此时给一个纯配置视图,而不是报错
|
||||
let mgr = state.agent.lock().unwrap().clone();
|
||||
let snap = match &mgr {
|
||||
Some(m) => m.snapshot().await,
|
||||
None => json!({ "state": agent::state::STOPPED }),
|
||||
};
|
||||
|
||||
// 数据目录以「实际使用的」为准;管理器还没跑过就用默认值
|
||||
let data_dir = snap
|
||||
.get("data_dir")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(agent::agent_data_dir);
|
||||
let cfg = agent::read_agent_config(&data_dir);
|
||||
|
||||
let resource_dir = app.path().resource_dir().ok();
|
||||
Ok(json!({
|
||||
"manager": snap,
|
||||
"config": cfg,
|
||||
"autostart": autostart::status(),
|
||||
"settings": settings::to_json(&state.settings()),
|
||||
"app_version": state.app_version,
|
||||
"config_dir": state.config_dir.to_string_lossy(),
|
||||
"installed_agent_version": agent::installed_agent_version(&data_dir),
|
||||
"bundled_agent": agent::resolve_bundled_agent(resource_dir.as_deref())
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_default(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// 启动本机被控端。`auto_restart` 缺省时取设置里的值。
|
||||
#[tauri::command]
|
||||
async fn agent_start(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
auto_restart: Option<bool>,
|
||||
) -> Result<Value, String> {
|
||||
let paths = resolve_agent_paths(&app, &state)?;
|
||||
let ar = auto_restart.unwrap_or_else(|| state.settings().auto_restart);
|
||||
state
|
||||
.agent()?
|
||||
.start(&paths.node, &paths.script, &paths.data_dir, ar)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 停止本机被控端(幂等)。
|
||||
#[tauri::command]
|
||||
async fn agent_stop(state: State<'_, AppState>) -> Result<Value, String> {
|
||||
state.agent()?.stop().await
|
||||
}
|
||||
|
||||
/// 退出控制台 —— 由前端在用户确认后调用。
|
||||
///
|
||||
/// `keep_agent`:
|
||||
/// - `true` 保留被控端继续运行(它将成为孤儿进程;下次启动会被「复用」逻辑接管)
|
||||
/// - `false` 先停掉被控端再退出(退出后不留任何后台进程)
|
||||
///
|
||||
/// 为什么不直接在托盘菜单里 exit:退出是有**副作用**的决定(留不留下一个
|
||||
/// 正在监听端口的后台进程),必须让用户明确选择,而不是替用户猜。
|
||||
#[tauri::command]
|
||||
async fn quit_app(app: AppHandle, state: State<'_, AppState>, keep_agent: bool) -> Result<(), String> {
|
||||
if !keep_agent {
|
||||
// 尽力而为:停不掉也要让用户能退出,否则控制台会卡在确认框后面出不来
|
||||
if let Ok(mgr) = state.agent() {
|
||||
let _ = mgr.stop().await;
|
||||
}
|
||||
}
|
||||
app.exit(0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 重启本机被控端。
|
||||
#[tauri::command]
|
||||
async fn agent_restart(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
auto_restart: Option<bool>,
|
||||
) -> Result<Value, String> {
|
||||
let paths = resolve_agent_paths(&app, &state)?;
|
||||
let ar = auto_restart.unwrap_or_else(|| state.settings().auto_restart);
|
||||
state
|
||||
.agent()?
|
||||
.restart(&paths.node, &paths.script, &paths.data_dir, ar)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 取本机服务日志(增量:传上次拿到的最大 `seq`)。
|
||||
#[tauri::command]
|
||||
fn agent_logs(state: State<'_, AppState>, since: Option<u64>) -> Result<Value, String> {
|
||||
Ok(state.agent()?.logs_since(since.unwrap_or(0)))
|
||||
}
|
||||
|
||||
/// 清空日志缓冲。
|
||||
#[tauri::command]
|
||||
fn agent_logs_clear(state: State<'_, AppState>) -> Result<(), String> {
|
||||
state.agent()?.clear_logs();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 开机自启状态。
|
||||
#[tauri::command]
|
||||
fn autostart_status() -> Value {
|
||||
autostart::status()
|
||||
}
|
||||
|
||||
/// 开关开机自启。
|
||||
#[tauri::command]
|
||||
fn autostart_set(enabled: bool) -> Result<Value, String> {
|
||||
autostart::set(enabled)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 更新
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 检查更新:比对最新 Release 与本控制台版本,并附上本机被控端已安装版本。
|
||||
#[tauri::command]
|
||||
async fn check_update(app: AppHandle, state: State<'_, AppState>) -> Result<Value, String> {
|
||||
let sc = ensure_sidecar(&app, &state).await?;
|
||||
// 超时**成对**下发:内层(sidecar 出网)必须小于外层(RPC),
|
||||
// 否则用户只会看到「check_update 超时」这种丢掉真实原因的假失败。
|
||||
// 见 sidecar.rs 的 UPDATE_CHECK_TIMEOUT_MS / UPDATE_CHECK_RPC_TIMEOUT_SECS。
|
||||
let mut snap = sc
|
||||
.request(
|
||||
"check_update",
|
||||
json!({
|
||||
"console_version": state.app_version,
|
||||
"timeout_ms": sidecar::UPDATE_CHECK_TIMEOUT_MS,
|
||||
}),
|
||||
sidecar::UPDATE_CHECK_RPC_TIMEOUT_SECS,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 本机被控端的版本(数据目录里那份),供界面区分「应用要更新」与「被控端要更新」
|
||||
let data_dir = agent::agent_data_dir();
|
||||
let installed_version = agent::installed_agent_version(&data_dir).unwrap_or_default();
|
||||
let latest = snap
|
||||
.get("latest_version")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let agent_has_update =
|
||||
!installed_version.is_empty() && agent::cmp_version(&latest, &installed_version) > 0;
|
||||
snap["installed_agent_version"] = json!(installed_version);
|
||||
snap["agent_has_update"] = json!(agent_has_update);
|
||||
Ok(snap)
|
||||
}
|
||||
|
||||
/// 应用更新:下载 → 校验 → 替换本机被控端脚本 → 重启。
|
||||
#[tauri::command]
|
||||
async fn apply_update(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
url: String,
|
||||
sha256: String,
|
||||
version: String,
|
||||
) -> Result<Value, String> {
|
||||
let sc = ensure_sidecar(&app, &state).await?;
|
||||
let paths = resolve_agent_paths(&app, &state)?;
|
||||
let mgr = state.agent()?;
|
||||
|
||||
// ① 下载到暂存文件(**校验通过才落盘**;不直接覆盖在跑的脚本)
|
||||
let staged = paths
|
||||
.data_dir
|
||||
.join("runtime")
|
||||
.join(format!("agent.mjs.staged-{}", std::process::id()));
|
||||
let dl = sc
|
||||
.request(
|
||||
"download",
|
||||
json!({
|
||||
"url": url,
|
||||
"sha256": sha256,
|
||||
"dest": staged.to_string_lossy(),
|
||||
"timeout_ms": sidecar::UPDATE_DOWNLOAD_TIMEOUT_MS,
|
||||
}),
|
||||
sidecar::UPDATE_DOWNLOAD_RPC_TIMEOUT_SECS,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// ② 停被控端(Windows 上正被 node 打开的文件不能重命名/替换)
|
||||
let was_running = mgr.snapshot().await["state"] == json!(agent::state::RUNNING);
|
||||
if was_running {
|
||||
mgr.stop().await?;
|
||||
}
|
||||
|
||||
// ③ 原子替换:先改名旧文件做备份,再落新文件
|
||||
let backup = paths.script.with_extension(format!("mjs.bak-{}", now_secs()));
|
||||
let replace = (|| -> Result<(), String> {
|
||||
if paths.script.exists() {
|
||||
std::fs::rename(&paths.script, &backup)
|
||||
.map_err(|e| format!("备份现有脚本失败:{e}"))?;
|
||||
}
|
||||
std::fs::rename(&staged, &paths.script).map_err(|e| {
|
||||
// 失败则回滚
|
||||
let _ = std::fs::rename(&backup, &paths.script);
|
||||
format!("替换脚本失败(已回滚):{e}")
|
||||
})
|
||||
})();
|
||||
if let Err(e) = replace {
|
||||
let _ = std::fs::remove_file(&staged);
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
// ④ 记版本:这是「控制台启动时不要把已更新的被控端降级」的依据
|
||||
agent::mark_installed_version(&paths.data_dir, &version, "console")?;
|
||||
mgr.push_log(
|
||||
"system",
|
||||
format!("已更新本机被控端到 v{version}(备份:{})", backup.display()),
|
||||
);
|
||||
|
||||
// ⑤ 按需重新拉起
|
||||
let mut restarted = false;
|
||||
if was_running || state.settings().auto_start_agent {
|
||||
match mgr
|
||||
.start(&paths.node, &paths.script, &paths.data_dir, state.settings().auto_restart)
|
||||
.await
|
||||
{
|
||||
Ok(_) => restarted = true,
|
||||
Err(e) => mgr.push_log("system", format!("更新后启动失败:{e}")),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"updated": true,
|
||||
"version": version,
|
||||
"bytes": dl.get("bytes").cloned().unwrap_or(json!(0)),
|
||||
"sha256": dl.get("sha256").cloned().unwrap_or(json!("")),
|
||||
"backup": backup.to_string_lossy(),
|
||||
"restarted": restarted,
|
||||
}))
|
||||
}
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 设置与系统信息
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 读设置。
|
||||
#[tauri::command]
|
||||
fn settings_get(state: State<'_, AppState>) -> Value {
|
||||
settings::to_json(&state.settings())
|
||||
}
|
||||
|
||||
/// 合并式写设置(只传要改的字段)。
|
||||
#[tauri::command]
|
||||
fn settings_set(state: State<'_, AppState>, patch: Value) -> Result<Value, String> {
|
||||
let next = settings::merge(&state.config_dir, &patch)?;
|
||||
*state.settings.lock().unwrap() = next.clone();
|
||||
Ok(settings::to_json(&next))
|
||||
}
|
||||
|
||||
/// 控制台自身信息(版本、数据目录、平台),供「关于」与故障上报使用。
|
||||
#[tauri::command]
|
||||
fn system_info(app: AppHandle, state: State<'_, AppState>) -> Value {
|
||||
let resource_dir = app.path().resource_dir().ok();
|
||||
// 先取 node 路径,再把 resource_dir 消费成字符串 —— 顺序反了会「借用已移动的值」
|
||||
let node = sidecar::resolve_node(resource_dir.as_deref())
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
json!({
|
||||
"app_version": state.app_version,
|
||||
"config_dir": state.config_dir.to_string_lossy(),
|
||||
"agent_data_dir": agent::agent_data_dir().to_string_lossy(),
|
||||
"resource_dir": resource_dir.map(|p| p.to_string_lossy().to_string()).unwrap_or_default(),
|
||||
"platform": std::env::consts::OS,
|
||||
"arch": std::env::consts::ARCH,
|
||||
"node": node,
|
||||
"agent_only": std::env::args().any(|a| a == autostart::AGENT_ONLY_FLAG),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 托盘与启动模式
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 显示主窗口(托盘菜单与第二次启动都用它)。
|
||||
fn show_main(app: &AppHandle) {
|
||||
if let Some(w) = app.get_webview_window("main") {
|
||||
let _ = w.show();
|
||||
let _ = w.unminimize();
|
||||
let _ = w.set_focus();
|
||||
}
|
||||
}
|
||||
|
||||
/// 在后台拉起被控端(忽略错误 —— 失败详情在日志里)。
|
||||
fn spawn_start_agent(app: &AppHandle, auto_restart: bool) {
|
||||
let app2 = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let state = app2.state::<AppState>();
|
||||
let mgr = match state.agent() {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
eprintln!("[console] 被控端管理器不可用:{e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if mgr.snapshot().await["state"] == json!(agent::state::RUNNING) {
|
||||
return;
|
||||
}
|
||||
match resolve_agent_paths(&app2, &state) {
|
||||
Ok(p) => {
|
||||
if let Err(e) = mgr.start(&p.node, &p.script, &p.data_dir, auto_restart).await {
|
||||
mgr.push_log("system", format!("自动启动失败:{e}"));
|
||||
}
|
||||
}
|
||||
Err(e) => mgr.push_log("system", format!("自动启动失败(路径解析):{e}")),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn build_tray(app: &AppHandle) -> tauri::Result<()> {
|
||||
let show = MenuItem::with_id(app, "show", "显示主窗口", true, None::<&str>)?;
|
||||
let start = MenuItem::with_id(app, "agent_start", "启动本机被控端", true, None::<&str>)?;
|
||||
let stop = MenuItem::with_id(app, "agent_stop", "停止本机被控端", true, None::<&str>)?;
|
||||
let restart = MenuItem::with_id(app, "agent_restart", "重启本机被控端", true, None::<&str>)?;
|
||||
let sep1 = PredefinedMenuItem::separator(app)?;
|
||||
let sep2 = PredefinedMenuItem::separator(app)?;
|
||||
let quit = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?;
|
||||
|
||||
let menu = Menu::with_items(app, &[&show, &sep1, &start, &stop, &restart, &sep2, &quit])?;
|
||||
|
||||
let mut builder = TrayIconBuilder::with_id("na-tray")
|
||||
.menu(&menu)
|
||||
.show_menu_on_left_click(false)
|
||||
.tooltip("nodeagent 控制台")
|
||||
.on_menu_event(|app, event| {
|
||||
let id = event.id().as_ref().to_string();
|
||||
match id.as_str() {
|
||||
"show" => show_main(app),
|
||||
"agent_start" => {
|
||||
let ar = app.state::<AppState>().settings().auto_restart;
|
||||
spawn_start_agent(app, ar);
|
||||
}
|
||||
"agent_stop" => {
|
||||
let app2 = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let state = app2.state::<AppState>();
|
||||
if let Ok(mgr) = state.agent() {
|
||||
let _ = mgr.stop().await;
|
||||
}
|
||||
});
|
||||
}
|
||||
"agent_restart" => {
|
||||
let app2 = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let state = app2.state::<AppState>();
|
||||
if let Ok(mgr) = state.agent() {
|
||||
if let Ok(p) = resolve_agent_paths(&app2, &state) {
|
||||
let ar = state.settings().auto_restart;
|
||||
let _ = mgr.restart(&p.node, &p.script, &p.data_dir, ar).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// 2026-10-17「退出前确认」需求:
|
||||
// 原实现直接 `app.exit(0)`,而 `kill_on_drop(false)` 会让被控端**成为孤儿
|
||||
// 继续监听** —— 用户以为关了控制台就干净了,实际还有个看不见的进程在跑。
|
||||
//
|
||||
// 现在改为**先问一句**:发事件给前端,由它弹三选一
|
||||
// (保留被控端退出 / 停止被控端退出 / 取消),再回调 `quit_app`。
|
||||
// 之所以走前端而不是加 tauri-plugin-dialog:前端已有 n-dialog-provider,
|
||||
// 不加依赖、风格统一,且能复用既有的 message 视觉。
|
||||
"quit" => {
|
||||
let _ = tauri::Emitter::emit(app, "na-quit-requested", ());
|
||||
// 把窗口唤出来,否则用户在最小化状态下看不到确认框
|
||||
if let Some(w) = tauri::Manager::get_webview_window(app, "main") {
|
||||
let _ = w.show();
|
||||
let _ = w.set_focus();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
// 左键单击托盘图标 → 唤出主窗口(符合习惯)
|
||||
if let TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
show_main(tray.app_handle());
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(icon) = app.default_window_icon() {
|
||||
builder = builder.icon(icon.clone());
|
||||
}
|
||||
builder.build(app)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 单实例
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// ## 为什么必须有(真机缺陷,2026-10-13)
|
||||
// 控制台此前**完全没有单实例保护**。开机自启会拉起一个 `--agent-only` 实例(无窗口、驻托盘),
|
||||
// 用户再手动双击图标 → **第二个进程**。而「本机被控端」是**进程内状态**
|
||||
// (`AgentManager` 各持自己的 `child: Option<Child>` / `pid`),两个进程互相看不见 →
|
||||
// 同一台机器上跑起**两个被控端**,抢同一个 8765 端口。
|
||||
//
|
||||
// 后果按先后顺序分两种,正是用户报的两个症状:
|
||||
// - **A(两个被控端)**:两边都抢到了不同时机 → 同机并存两个被控端进程;
|
||||
// - **B(后开的起不来)**:先占住端口的那一侧赢了,后启动的一侧在 `AgentManager::start`
|
||||
// 的端口检查处失败,界面表现为「应用端无法启动」(本机服务起不来)。
|
||||
//
|
||||
// ## 为什么不能简单「已有实例就退出」
|
||||
// 自启实例是**无窗口**的(`--agent-only`)。若第二次启动只是静默退出,用户双击图标会
|
||||
// **什么都不发生** —— 比缺陷本身更难排查。正确语义是:
|
||||
// 第二次启动 → 把「用户想看到界面」这个意图**交给已有实例**(让它显示并聚焦窗口)→ 新进程再退出。
|
||||
//
|
||||
// `tauri-plugin-single-instance` 正好是这个语义:回调在**已有实例**里执行,新进程由插件负责收尾退出。
|
||||
// 它必须注册为**第一个**插件,才能在其他插件与本项目的 `setup`(会拉起被控端)之前完成抢占判定。
|
||||
|
||||
/// 第二次启动时执行(在**已有实例**的进程里)。
|
||||
fn on_second_instance(app: &AppHandle, argv: Vec<String>) {
|
||||
// 第二次启动若自己就带 `--agent-only`(例如自启被重复触发),**不要弹窗** ——
|
||||
// 那正是「仅服务模式」要避免的行为。只提示已有实例,不做别的。
|
||||
if argv.iter().any(|a| a == autostart::AGENT_ONLY_FLAG) {
|
||||
return;
|
||||
}
|
||||
// 已有实例可能是「自启的隐藏实例」或「窗口已关到托盘」→ 两种都要能唤出来。
|
||||
show_main(app);
|
||||
// 顺带通知前端刷一次状态(第二次启动往往发生在「用户发现没反应、想确认一下」的时刻)。
|
||||
// 前端没监听也无害。
|
||||
let _ = app.emit("na-second-instance", json!({ "argv": argv }));
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let agent_only = std::env::args().any(|a| a == autostart::AGENT_ONLY_FLAG);
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| {
|
||||
on_second_instance(app, argv);
|
||||
}))
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.setup(move |app| {
|
||||
let handle = app.handle().clone();
|
||||
let config_dir = handle
|
||||
.path()
|
||||
.app_config_dir()
|
||||
.unwrap_or_else(|_| PathBuf::from("."));
|
||||
let app_version = handle.package_info().version.to_string();
|
||||
let loaded = settings::load(&config_dir);
|
||||
|
||||
// 事件桥接:被控端管理器 → Tauri event
|
||||
let app_events = handle.clone();
|
||||
let emit: agent::AgentEmit = Arc::new(move |evt: AgentEvent| match evt {
|
||||
AgentEvent::Log(l) => {
|
||||
let _ = app_events.emit("na-agent-log", l);
|
||||
}
|
||||
AgentEvent::State(s) => {
|
||||
let _ = app_events.emit("na-agent-state", s);
|
||||
}
|
||||
});
|
||||
|
||||
let manager = AgentManager::new(emit);
|
||||
manager.push_log(
|
||||
"system",
|
||||
format!(
|
||||
"控制台 v{app_version} 就绪({} / {})",
|
||||
std::env::consts::OS,
|
||||
std::env::consts::ARCH
|
||||
),
|
||||
);
|
||||
|
||||
app.manage(AppState {
|
||||
sidecar: tokio::sync::Mutex::new(None),
|
||||
capabilities: Mutex::new(json!([])),
|
||||
agent_version: Mutex::new(None),
|
||||
conn_state: Mutex::new("未连接".to_string()),
|
||||
agent: Mutex::new(Some(Arc::clone(&manager))),
|
||||
settings: Mutex::new(loaded.clone()),
|
||||
config_dir,
|
||||
app_version,
|
||||
});
|
||||
|
||||
build_tray(&handle)?;
|
||||
|
||||
// 仅服务模式:不显示窗口(开机自启时不该弹任何窗口出来)
|
||||
if agent_only {
|
||||
if let Some(w) = handle.get_webview_window("main") {
|
||||
let _ = w.hide();
|
||||
}
|
||||
manager.push_log("system", "以「仅服务模式」启动(无窗口,常驻托盘)");
|
||||
}
|
||||
|
||||
// 自动拉起被控端(默认开)——「运行应用之后就有服务」的期望
|
||||
if loaded.auto_start_agent {
|
||||
spawn_start_agent(&handle, loaded.auto_restart);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|window, event| {
|
||||
// 关窗 → 最小化到托盘(可在设置里关掉,改为直接退出)
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
let app = window.app_handle();
|
||||
let state = app.state::<AppState>();
|
||||
let close_to_tray = state.settings().close_to_tray;
|
||||
if close_to_tray {
|
||||
api.prevent_close();
|
||||
let _ = window.hide();
|
||||
if let Ok(mgr) = state.agent() {
|
||||
mgr.push_log("system", "窗口已隐藏到托盘(被控端继续运行;从托盘菜单可退出)");
|
||||
}
|
||||
} else {
|
||||
// 用户关掉了「最小化到托盘」→ 点 X 等于退出应用。
|
||||
// 这条路同样会留下孤儿被控端,故**也必须问一句**,
|
||||
// 否则「退出确认」只在托盘菜单生效,从 X 走就绕过去了。
|
||||
api.prevent_close();
|
||||
let _ = tauri::Emitter::emit(window.app_handle(), "na-quit-requested", ());
|
||||
}
|
||||
}
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// 连接
|
||||
connect,
|
||||
disconnect,
|
||||
invoke_capability,
|
||||
get_capabilities,
|
||||
get_state,
|
||||
// 本机服务
|
||||
agent_status,
|
||||
agent_start,
|
||||
agent_stop,
|
||||
quit_app,
|
||||
agent_restart,
|
||||
agent_logs,
|
||||
agent_logs_clear,
|
||||
autostart_status,
|
||||
autostart_set,
|
||||
// 更新
|
||||
check_update,
|
||||
apply_update,
|
||||
// 设置与信息
|
||||
settings_get,
|
||||
settings_set,
|
||||
system_info,
|
||||
])
|
||||
.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(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,78 @@
|
||||
//! 控制台自身的偏好设置(持久化到磁盘)。
|
||||
//!
|
||||
//! ## 为什么设置要在 Rust 侧存一份
|
||||
//! 前端有 `localStorage`,本来够用。但**「仅服务模式」(`--agent-only`,开机自启用)下
|
||||
//! 窗口是隐藏的** —— 此时仍需要知道「要不要拉起被控端」「崩了要不要自动重启」。
|
||||
//! 若只存 localStorage,就得靠隐藏的 webview 跑 JS 来驱动,既不直观也不可靠。
|
||||
//! 故由 Rust 侧持有唯一真相,前端只做读写。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Settings {
|
||||
/// 应用启动时自动拉起本机被控端(默认开 —— 这正是「打开就有服务」的期望)
|
||||
pub auto_start_agent: bool,
|
||||
/// 被控端意外退出时自动重启(默认开 —— 「后台运行」的应有之义)
|
||||
pub auto_restart: bool,
|
||||
/// 点窗口关闭按钮时最小化到托盘而非退出(默认开 —— 「后台运行」)
|
||||
pub close_to_tray: bool,
|
||||
/// 连接本机被控端时是否自动接受自签证书(默认开;关掉就得手工钉指纹)
|
||||
pub insecure: bool,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
auto_start_agent: true,
|
||||
auto_restart: true,
|
||||
close_to_tray: true,
|
||||
insecure: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn settings_path(config_dir: &Path) -> PathBuf {
|
||||
config_dir.join("console.json")
|
||||
}
|
||||
|
||||
pub fn load(config_dir: &Path) -> Settings {
|
||||
let p = settings_path(config_dir);
|
||||
match std::fs::read_to_string(&p) {
|
||||
Ok(text) => {
|
||||
// 剥离 BOM(Windows 上由 PowerShell 写出的 JSON 可能带 BOM)
|
||||
serde_json::from_str(text.trim_start_matches('\u{feff}')).unwrap_or_default()
|
||||
}
|
||||
Err(_) => Settings::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(config_dir: &Path, s: &Settings) -> Result<(), String> {
|
||||
std::fs::create_dir_all(config_dir).map_err(|e| format!("创建配置目录失败:{e}"))?;
|
||||
let text = serde_json::to_string_pretty(s).map_err(|e| format!("序列化设置失败:{e}"))?;
|
||||
std::fs::write(settings_path(config_dir), format!("{text}\n"))
|
||||
.map_err(|e| format!("写入设置失败:{e}"))
|
||||
}
|
||||
|
||||
/// 把前端传来的「部分字段」合并进当前设置(未提供的字段保持原值)。
|
||||
pub fn merge(config_dir: &Path, patch: &Value) -> Result<Settings, String> {
|
||||
let mut cur = load(config_dir);
|
||||
let mut raw = serde_json::to_value(&cur).map_err(|e| e.to_string())?;
|
||||
if let (Some(obj), Some(p)) = (raw.as_object_mut(), patch.as_object()) {
|
||||
for (k, v) in p {
|
||||
// 类型不匹配的键直接忽略(前端传 `undefined` 序列化成 null 是常见情形)
|
||||
if obj.get(k).map(|old| std::mem::discriminant(old) == std::mem::discriminant(v)).unwrap_or(false) {
|
||||
obj.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
cur = serde_json::from_value(raw).map_err(|e| format!("设置字段类型不合法:{e}"))?;
|
||||
save(config_dir, &cur)?;
|
||||
Ok(cur)
|
||||
}
|
||||
|
||||
pub fn to_json(s: &Settings) -> Value {
|
||||
serde_json::to_value(s).unwrap_or_else(|_| json!({}))
|
||||
}
|
||||
@@ -0,0 +1,733 @@
|
||||
//! 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::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::process::{Child, ChildStdin, Command};
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 更新链路的超时口径(「检查更新 / 下载更新包」)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// 两层超时必须**成对**看:内层是 sidecar 自己的出网超时(下发给它),
|
||||
// 外层是本壳等 RPC 的超时。**内层必须小于外层**,否则本壳先放弃,
|
||||
// 用户只会看到「check_update 超时」——一条**丢掉真实原因**的假失败
|
||||
// (2026-10-13 真机排查就被这种假失败带偏过一轮)。
|
||||
//
|
||||
// 下面那条单测断言的就是这层关系:改一侧忘了另一侧,测试直接红。
|
||||
|
||||
/// 「检查更新」下发给 sidecar 的**出网超时**(毫秒)。
|
||||
///
|
||||
/// ## 为什么从 20s 抬到 120s(2026-10-13 实测)
|
||||
/// 取 `latest.json` 要**跟随两次重定向**(`releases/latest/download/…` → 版本化 URL →
|
||||
/// 资产 CDN)。整条链的耗时波动极大,真机连续多次实测:
|
||||
/// **2.0s / 10.0s / 39.7s / 68.6s / >109s**(同一台机器、同一个代理)。
|
||||
/// 原来的 20s 是「假定能直连 GitHub」时定的值 —— 对真实链路**必然**超时,
|
||||
/// 于是用户看到的失败原因永远是「超时」,而不是「到底为什么连不上」。
|
||||
///
|
||||
/// 为什么敢给到 2 分钟:这是**用户手动点**的动作,界面上按钮有 loading 态
|
||||
/// (`LocalServiceView.vue` 的 `:loading="busy"`),等待是可见的;
|
||||
/// 而「假失败」会让用户以为功能坏了。宁可慢,不要假失败。
|
||||
pub(crate) const UPDATE_CHECK_TIMEOUT_MS: u64 = 120_000;
|
||||
|
||||
/// `check_update` 的 RPC 超时(秒)。必须 > `UPDATE_CHECK_TIMEOUT_MS`(120s)。
|
||||
pub(crate) const UPDATE_CHECK_RPC_TIMEOUT_SECS: u64 = 150;
|
||||
|
||||
/// 「下载更新包」下发给 sidecar 的出网超时(毫秒)。被控端约 1MB,300s 足够。
|
||||
pub(crate) const UPDATE_DOWNLOAD_TIMEOUT_MS: u64 = 300_000;
|
||||
|
||||
/// `download` 的 RPC 超时(秒)。必须 > `UPDATE_DOWNLOAD_TIMEOUT_MS`(300s)。
|
||||
pub(crate) const UPDATE_DOWNLOAD_RPC_TIMEOUT_SECS: u64 = 330;
|
||||
|
||||
/// **编译期不变式**:内层出网超时必须小于外层 RPC 超时。
|
||||
///
|
||||
/// ## 为什么放在 `const _` 里而不是写成 `#[test]`
|
||||
/// ① 这层关系一旦破坏,症状是「用户看到假失败、且失败原因指向别处」——
|
||||
/// 让**构建**直接失败,比等某条单测被跑到更早、更不可能被绕过
|
||||
/// (CI 的 `cargo clippy` / `cargo build` 都是闸门,而单测可能被 filter 掉)。
|
||||
/// ② 两侧都是常量,写成断言函数会被 `clippy::assertions_on_constants` 拦下
|
||||
/// (`-D warnings` 下即失败)—— clippy 给的正是本写法。
|
||||
///
|
||||
/// 注入验证(2026-10-13):把 `UPDATE_CHECK_RPC_TIMEOUT_SECS` 改成 50、
|
||||
/// 或把 `UPDATE_CHECK_TIMEOUT_MS` 改回 20_000,**构建**即失败并打印下面的文案。
|
||||
const _: () = {
|
||||
assert!(
|
||||
UPDATE_CHECK_TIMEOUT_MS < UPDATE_CHECK_RPC_TIMEOUT_SECS * 1000,
|
||||
"check_update:出网超时必须小于 RPC 超时(否则用户只会看到「check_update 超时」这种丢掉真实原因的假失败)"
|
||||
);
|
||||
assert!(
|
||||
UPDATE_DOWNLOAD_TIMEOUT_MS < UPDATE_DOWNLOAD_RPC_TIMEOUT_SECS * 1000,
|
||||
"download:出网超时必须小于 RPC 超时"
|
||||
);
|
||||
// 60s 下限:真机上取 latest.json 的重定向链实测出现过 68.6s(三次为 2.0 / 10.0 / 68.6s),
|
||||
// 回落到「假定能直连」时代的 20s 必然超时。
|
||||
assert!(
|
||||
UPDATE_CHECK_TIMEOUT_MS >= 60_000,
|
||||
"检查更新的出网超时不得低于 60s"
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 路径净化:**交给外部进程**(node)的路径必须先去掉 Windows verbatim 前缀
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 去掉 Windows 的 verbatim 前缀(`\\?\` 与 `\\?\UNC\`)。
|
||||
///
|
||||
/// ## 为什么非做不可(2026-10-13 真机缺陷的根因)
|
||||
/// Tauri 的 `resource_dir()` 返回的是**规范化之后的 verbatim 路径**
|
||||
/// (`\\?\C:\Users\…\nodeagent-console`)。把它当**参数**交给 node 时,
|
||||
/// node 启动流程会在入口模块的 `realpathSync` 里把它拆成 `C:` 与剩余部分:
|
||||
///
|
||||
/// ```text
|
||||
/// Error: EISDIR: illegal operation on a directory, lstat 'C:'
|
||||
/// at Object.realpathSync (node:fs:27xx)
|
||||
/// ```
|
||||
///
|
||||
/// → **exit 1,且一行 JS 都来不及执行**(连 sidecar 自己的启动日志都写不出来)。
|
||||
///
|
||||
/// ## 真机 A/B —— 二维四格,精确到是「哪一维」出问题
|
||||
/// Windows 11 ARM64;同一个 `node.exe`、同一份最小脚本 `mini.mjs`;用
|
||||
/// `ProcessStartInfo` 下发,**精确对应 Rust `Command::new(prog).arg(script)` 的形态**:
|
||||
///
|
||||
/// | # | `program` | 脚本参数 | 结果 |
|
||||
/// |---|---|---|---|
|
||||
/// | G1 | `C:\…\node.exe` | `C:\…\mini.mjs` | exit 0,正常输出(**= 修复后的形态**) |
|
||||
/// | G2 | `C:\…\node.exe` | `\\?\C:\…\mini.mjs` | **exit 1**,`EISDIR … lstat 'C:'` |
|
||||
/// | G3 | `\\?\C:\…\node.exe` | `C:\…\mini.mjs` | exit 0,正常输出 |
|
||||
/// | G4 | `\\?\C:\…\node.exe` | `\\?\C:\…\mini.mjs` | **exit 1**,`EISDIR … lstat 'C:'` |
|
||||
///
|
||||
/// **结论**:炸的是「**脚本参数**带 verbatim」,**不是**「可执行文件带 verbatim」——
|
||||
/// G3 证明 `program` 位置带前缀 node 照常启动;真机上也有旁证:被控端与 sidecar 进程的
|
||||
/// `program` 都带 `\\?\`(见 `Get-CimInstance Win32_Process` 的 CommandLine)却长期存活。
|
||||
/// 因此 `sidecar_command` 里**净化 `script` 才是关键修复**,净化 `node` 属防御性
|
||||
/// (幂等、无副作用;且 `\\?\` 路径不适合出现在计划任务 `-Execute` 之类场景)。
|
||||
///
|
||||
/// 另有一次更贴近原症状的观测(同一台机器,直接用真 `sidecar.mjs`):
|
||||
/// 普通路径 **2.5 秒**返回 `check_update` 结果并常驻;
|
||||
/// `\\?\` 路径 **0.5 秒**即退出、exit 1、**零 JS 执行**(连自带启动日志都没写出来)。
|
||||
///
|
||||
/// 控制台侧前后两种表现都由这一条派生:
|
||||
/// - 修存活检测**之前**:请求写进已死进程的管道 → 干等到 RPC 超时(当时 45 秒) → 「check_update 超时」;
|
||||
/// - 修存活检测**之后**:stdout 立即 EOF → 「sidecar 已退出(stdout 关闭),请重试」。
|
||||
///
|
||||
/// ## 为什么单测 / CI / 开发模式全都测不出来
|
||||
/// 开发期 `resolve_sidecar` 走的是 `CARGO_MANIFEST_DIR` 拼出来的**普通路径**;
|
||||
/// 只有**打包之后**才会从 `resource_dir()` 拿到 verbatim 路径。
|
||||
/// 于是「本地全绿 + CI 全绿 + 真机必挂」。
|
||||
///
|
||||
/// 纯字符串处理,**任何平台都可安全调用**(非 Windows 上一般原样返回),
|
||||
/// 因此可以用 Windows 形态的字符串在 macOS / Linux 上直接验证 Windows 分支。
|
||||
///
|
||||
/// ## 为什么用 `to_str()` 而不是 `to_string_lossy()`
|
||||
/// 后者会把**非法字节替换成 U+FFFD** 再用替换后的串重建路径 —— 那是**损坏路径**,
|
||||
/// 比不净化更糟:原本只是 node 会报 EISDIR,改完会变成「指向一个不存在的目录」,
|
||||
/// 更难排查。故非 UTF-8 一律**原样返回**(Windows 上是含未配对代理项的路径;
|
||||
/// Unix 上是含非法字节的文件名 —— 两者都不该被我们的「净化」悄悄改写)。
|
||||
pub(crate) fn unverbatim(path: PathBuf) -> PathBuf {
|
||||
let fixed: Option<PathBuf> = path.to_str().and_then(|s| {
|
||||
let rest = s.strip_prefix(r"\\?\")?;
|
||||
Some(match rest.strip_prefix("UNC\\") {
|
||||
// \\?\UNC\server\share\x → \\server\share\x(UNC 必须还原成双反斜杠)
|
||||
Some(unc) => PathBuf::from(format!(r"\\{unc}")),
|
||||
// \\?\C:\x → C:\x
|
||||
None => PathBuf::from(rest),
|
||||
})
|
||||
});
|
||||
fixed.unwrap_or(path)
|
||||
}
|
||||
|
||||
/// 构造「把 sidecar 拉起来」的那条命令 —— **唯一**把路径交给 node 的地方。
|
||||
///
|
||||
/// 「必须去掉 verbatim 前缀」这条纪律只在这里守一次;单测直接断言本函数产出的
|
||||
/// `program` / `args`,因此**在 macOS / Linux 上也守得住 Windows 分支**(守的是
|
||||
/// 「最终下发文本」,不是「源码里出现过某个常量」)。
|
||||
/// `CREATE_NO_WINDOW`(`0x0800_0000`):进程创建时不新开控制台窗口。
|
||||
///
|
||||
/// ## 为什么必须显式加(2026-10-13 真机发现的既有缺陷)
|
||||
/// 控制台自身是 **GUI 子系统**程序(不占控制台)。GUI 父进程 spawn 一个**控制台子系统**
|
||||
/// 程序(`node.exe`)时,Windows 会**新建一个控制台**;Windows 11 上它由
|
||||
/// **WindowsTerminal.exe** 承载 → 用户屏幕上多出一个常驻黑窗。
|
||||
///
|
||||
/// 修复 verbatim 缺陷**之前**,sidecar 被拉起后 0.5 秒即退,黑窗一闪而过(所以一直没被发现);
|
||||
/// 修复**之后** sidecar 常驻,黑窗就留在屏幕上不走了 —— 真机截图实证(窗口标题即
|
||||
/// `C:\Users\…\nodeagent-console\node.exe`,进程名 `WindowsTerminal`)。
|
||||
///
|
||||
/// 这与「消灭命令行窗口」那条既有目标直接冲突(`AgentManager::start` 早已加了此标志),
|
||||
/// 故 `sidecar_command` 必须同样带上,两边**共用本常量**以免值漂移。
|
||||
///
|
||||
/// ⚠️ **本项没有单测守卫**,原因如实记档:`creation_flags` 只有 setter、**没有 getter**
|
||||
/// (`std::process::Command` 与 `tokio::process::Command` 都如此),断言不到「到底带没带」;
|
||||
/// 而任何「扫源码看有没有写过这个常量」的做法都违反铁律 1(守卫要落在最终下发值上)。
|
||||
/// 因此这条纪律靠**真机截图对照**把关(修复前有黑窗 / 修复后无黑窗)。
|
||||
#[cfg(windows)]
|
||||
pub(crate) const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
|
||||
fn sidecar_command(node: &Path, script: &Path) -> Command {
|
||||
let mut cmd = Command::new(unverbatim(node.to_path_buf()));
|
||||
cmd.arg(unverbatim(script.to_path_buf()))
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true); // app 退出时不留孤儿进程
|
||||
|
||||
// ⚠️ 消灭黑窗:不加这一行,用户每点一次「检查更新」都会多出一个常驻黑窗
|
||||
// (详见 `CREATE_NO_WINDOW` 的说明)。
|
||||
#[cfg(windows)]
|
||||
{
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
/// 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,
|
||||
/// 「本实例已不可用」的标记(stdout 关闭 / 写入失败)。
|
||||
///
|
||||
/// ⚠️ 为什么不能只靠 `child.try_wait()`:往**已退出进程的管道**里写在 Windows 上
|
||||
/// 未必立刻报错(管道缓冲区吞下就返回成功),于是请求会一路干等到 `request` 的超时。
|
||||
/// 真机实证(2026-10-13,Windows 11 ARM64):sidecar 早已退出,控制台却仍复用缓存里的
|
||||
/// `Arc<Sidecar>`,点「检查更新」等满 RPC 超时(当时 45 秒) → 界面显示「检查更新失败:check_update 超时」;
|
||||
/// 而同一台机器上**手工拉起 sidecar 跑同一个请求只要 7 秒**。
|
||||
/// 所以判定「它死了」要多路取证:进程状态 ∨ stdout EOF ∨ 写入失败。
|
||||
dead: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
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> {
|
||||
// 进程边界:先净化一次(用于报错信息与实际下发文本一致)。
|
||||
// 真正「不得带 verbatim 前缀」这道纪律由 `sidecar_command` 兜底 ——
|
||||
// 去掉它会复现真机缺陷(node 启动即 EISDIR 退出),单测会红。
|
||||
let node = unverbatim(node.to_path_buf());
|
||||
let script = unverbatim(script.to_path_buf());
|
||||
|
||||
let mut child = sidecar_command(&node, &script)
|
||||
.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()));
|
||||
let dead = Arc::new(AtomicBool::new(false));
|
||||
|
||||
// stdout:逐行读,分派「响应」与「通知」
|
||||
{
|
||||
let pending = Arc::clone(&pending);
|
||||
let dead = Arc::clone(&dead);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// 走到这里 = stdout 关闭(EOF 或读取出错)= sidecar 进程已结束。
|
||||
dead.store(true, Ordering::SeqCst);
|
||||
// 顺手唤醒**所有还在等响应**的请求,让它们立刻失败,
|
||||
// 而不是干等各自的 timeout(见 UPDATE_CHECK_RPC_TIMEOUT_SECS / UPDATE_DOWNLOAD_RPC_TIMEOUT_SECS)。
|
||||
// 没有这一步,sidecar 死掉的那一刻就会有请求白等好几分钟。
|
||||
let mut waiting = pending.lock().unwrap();
|
||||
for (_, tx) in waiting.drain() {
|
||||
let _ = tx.send(Err("sidecar 已退出(stdout 关闭),请重试".to_string()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 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),
|
||||
dead,
|
||||
}))
|
||||
}
|
||||
|
||||
/// 本实例是否仍然可用(子进程还在跑,且未被判定失效)。
|
||||
///
|
||||
/// `try_wait()` 返回 `Ok(None)` = 仍在运行;返回 `Some(_)` = 已退出。
|
||||
/// 任何拿不准的情况一律按「不可用」处理 —— 宁可付一次 sidecar 冷启动的代价,
|
||||
/// 也不要让用户的每个操作都干等到 timeout(那正是真机上报「更新失败」的形态)。
|
||||
pub fn is_alive(&self) -> bool {
|
||||
if self.dead.load(Ordering::SeqCst) {
|
||||
return false;
|
||||
}
|
||||
let mut guard = self.child.lock().unwrap();
|
||||
match guard.as_mut() {
|
||||
Some(child) => matches!(child.try_wait(), Ok(None)),
|
||||
None => false, // shutdown() 已经把 child 取走了
|
||||
}
|
||||
}
|
||||
|
||||
/// 发一个请求并等响应(`timeout_secs` 兜底,避免 sidecar 卡住时本壳一起挂)。
|
||||
pub async fn request(&self, method: &str, params: Value, timeout_secs: u64) -> Result<Value, String> {
|
||||
// 入口先判存活 —— 这是「秒失败」的那一道。
|
||||
// 只靠写入报错是不够的:Windows 上往已关闭的管道写可能被缓冲区吞掉而"成功",
|
||||
// 之后就会一路干等到 timeout(真机形态:check_update 的 RPC 超时,当时 45s)。宁可在入口直接拒绝。
|
||||
if !self.is_alive() {
|
||||
return Err(format!("sidecar 不可用(进程已退出),{method} 已放弃"));
|
||||
}
|
||||
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;
|
||||
// 写入/刷新失败 = 管道对面没人了 → 立刻置 dead,让下一次 ensure_sidecar 重建。
|
||||
// 否则后续每个请求都会重演「写进去、永远没响应、干等到超时」。
|
||||
if let Err(e) = stdin.write_all(format!("{msg}\n").as_bytes()).await {
|
||||
self.dead.store(true, Ordering::SeqCst);
|
||||
return Err(format!(
|
||||
"写入 sidecar 失败:{e}(sidecar 可能已退出,下次操作会自动重建)"
|
||||
));
|
||||
}
|
||||
if let Err(e) = stdin.flush().await {
|
||||
self.dead.store(true, Ordering::SeqCst);
|
||||
return Err(format!(
|
||||
"flush sidecar 失败:{e}(sidecar 可能已退出,下次操作会自动重建)"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
self.dead.store(true, Ordering::SeqCst); // 主动关闭同样视为不可用
|
||||
if let Some(mut child) = child {
|
||||
let _ = child.kill().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 路径解析:开发期用系统 node + 仓库内脚本;打包后从 Tauri resource 目录取
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 解析 node 可执行文件。
|
||||
///
|
||||
/// 优先级:环境变量 `NODEAGENT_CONSOLE_NODE` → 随包 resource 的 `node` → 系统 `node`。
|
||||
///
|
||||
/// ⚠️ 出口一律经 `unverbatim`:`resource_dir()` 在 Windows 上给的是 verbatim 路径。
|
||||
/// 对本函数而言这是**防御性**净化 —— 真机四格实验(见 `unverbatim`)证明 `program`
|
||||
/// 位置带 `\\?\` 本身无害(G3 照常启动)。之所以仍然净化,是为了守住「凡从本模块
|
||||
/// 出去的路径都干净」这一条**单一纪律**:靠「记住哪些位置安全」来省这一步,
|
||||
/// 迟早会有人把它挪到参数位置而不自知(那才是 G2/G4 的必炸形态)。
|
||||
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(unverbatim(PathBuf::from(p)));
|
||||
}
|
||||
}
|
||||
if let Some(dir) = resource_dir {
|
||||
let bundled = unverbatim(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)。
|
||||
///
|
||||
/// ⚠️ 出口一律经 `unverbatim` —— **这一条就是 2026-10-13 真机缺陷的修复点**:
|
||||
/// 打包后 `resource_dir()` 给的是 `\\?\C:\…`,交给 node 当参数会让 node 在启动期
|
||||
/// `realpathSync` 里炸成 `EISDIR ... lstat 'C:'` 并 exit 1(sidecar 秒退)。
|
||||
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(unverbatim(PathBuf::from(p)));
|
||||
}
|
||||
}
|
||||
if let Some(dir) = resource_dir {
|
||||
let bundled = unverbatim(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 单测")
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 「交给 node 的路径不得带 Windows verbatim 前缀」—— 2026-10-13 真机缺陷
|
||||
//
|
||||
// 现象(真机):控制台点「检查更新」,sidecar 被拉起后**存活约 0.5 秒即消失**、
|
||||
// 退出码 1,且一行 JS 都没执行(连自带的启动日志都写不出来);界面报
|
||||
// 「sidecar 已退出(stdout 关闭),请重试」(修存活检测之前报「等满 RPC 超时」,当时 45 秒)。
|
||||
// 根因:`resource_dir()` 给的是 verbatim 路径(`\\?\C:\…`),当参数交给 node 后,
|
||||
// node 启动期的 `realpathSync` 把 `\\?\C:` 拆成 `C:` → `EISDIR` → exit 1。
|
||||
// 为什么本地/CI 全绿:开发模式走的是仓库相对路径(普通路径),只有打包后才暴露。
|
||||
// 因此这里的守卫**必须落在「最终下发给 node 的 program / args」上**,
|
||||
// 而不是「源码里有没有调用过某个函数」—— 这样在 macOS / Linux 上也能守住 Windows 分支。
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn unverbatim_去掉各类_windows_前缀() {
|
||||
// 常见的盘符形态
|
||||
assert_eq!(
|
||||
unverbatim(PathBuf::from(r"\\?\C:\Users\u\AppData\Local\x\node.exe")),
|
||||
PathBuf::from(r"C:\Users\u\AppData\Local\x\node.exe")
|
||||
);
|
||||
// UNC 形态:\\?\UNC\server\share\x → \\server\share\x
|
||||
assert_eq!(
|
||||
unverbatim(PathBuf::from(r"\\?\UNC\srv\share\sidecar.mjs")),
|
||||
PathBuf::from(r"\\srv\share\sidecar.mjs")
|
||||
);
|
||||
// 普通路径原样返回(含 POSIX)
|
||||
assert_eq!(
|
||||
unverbatim(PathBuf::from(r"C:\a\b.mjs")),
|
||||
PathBuf::from(r"C:\a\b.mjs")
|
||||
);
|
||||
assert_eq!(
|
||||
unverbatim(PathBuf::from("/Users/x/y.mjs")),
|
||||
PathBuf::from("/Users/x/y.mjs")
|
||||
);
|
||||
// 幂等:净化两次 == 净化一次(spawn 与 sidecar_command 都会调)
|
||||
let once = unverbatim(PathBuf::from(r"\\?\C:\a\b.mjs"));
|
||||
assert_eq!(unverbatim(once.clone()), once);
|
||||
}
|
||||
|
||||
/// 反例守卫:**非 UTF-8 路径必须原样返回,不得被 lossy 重建损坏**。
|
||||
///
|
||||
/// ⚠️ 构造必须**同时**含 `\\?\` 前缀与非法字节,否则测不出差异 ——
|
||||
/// 不带前缀时新旧实现都会落到「原样返回」那一步(`to_string_lossy` 也安全),
|
||||
/// 只有在「剥前缀 → `PathBuf::from(rest)`」这一步才会把 U+FFFD 写进路径。
|
||||
/// 若把实现退回 `to_string_lossy()`,本用例立刻变红:那是把「node 报 EISDIR」
|
||||
/// 换成「指向一个不存在的路径」,更难排查 —— 宁可原样返回。
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn unverbatim_对非_utf8_的_verbatim_路径必须原样返回不得损坏() {
|
||||
use std::ffi::OsStr;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
// 0xff / 0xfe 永不出现在合法 UTF-8 序列里;前缀形态按 Windows 写法给全
|
||||
let raw = OsStr::from_bytes(b"\\\\?\\C:\\na-\xff\xfe-weird.mjs");
|
||||
let p = PathBuf::from(raw);
|
||||
assert!(p.to_str().is_none(), "构造出来的路径应确实是非 UTF-8");
|
||||
assert_eq!(
|
||||
unverbatim(p.clone()),
|
||||
p,
|
||||
"非 UTF-8 路径被改写了(很可能退回了 to_string_lossy)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unverbatim_对_resource_dir_拼接后的路径同样生效() {
|
||||
// 真实形态就是「resource_dir().join("sidecar.mjs")」——前缀在目录上,拼完仍在最前
|
||||
let joined = unverbatim(Path::new(r"\\?\C:\app").join("sidecar.mjs"));
|
||||
let s = joined.to_string_lossy().into_owned();
|
||||
assert!(!s.contains(r"\\?\"), "拼接后仍带 verbatim 前缀:{s}");
|
||||
assert!(s.starts_with(r"C:\app"), "应剥成普通路径,实际:{s}");
|
||||
assert!(s.ends_with("sidecar.mjs"), "文件名不该被动:{s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn 交给_node_的路径必须去掉_verbatim_前缀() {
|
||||
// 直接断言**最终下发给 node 的文本**(program 与 args),而不是源码长相。
|
||||
// 去掉 `sidecar_command` 里的 unverbatim → 本用例立刻变红。
|
||||
let cmd = sidecar_command(
|
||||
Path::new(r"\\?\C:\Users\u\AppData\Local\nodeagent-console\node.exe"),
|
||||
Path::new(r"\\?\C:\Users\u\AppData\Local\nodeagent-console\sidecar.mjs"),
|
||||
);
|
||||
let std_cmd = cmd.as_std();
|
||||
|
||||
let prog = std_cmd.get_program().to_string_lossy().into_owned();
|
||||
assert!(
|
||||
!prog.contains(r"\\?\"),
|
||||
"program 仍带 verbatim 前缀(示例:{prog})"
|
||||
);
|
||||
assert_eq!(
|
||||
prog, r"C:\Users\u\AppData\Local\nodeagent-console\node.exe",
|
||||
"program 应剥成普通路径"
|
||||
);
|
||||
|
||||
let args: Vec<String> = std_cmd
|
||||
.get_args()
|
||||
.map(|a| a.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
assert!(
|
||||
!args.iter().any(|a| a.contains(r"\\?\")),
|
||||
"脚本参数仍带 verbatim 前缀 → node 启动即 EISDIR 退出(真机表现为「sidecar 秒退」):{args:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![r"C:\Users\u\AppData\Local\nodeagent-console\sidecar.mjs".to_string()],
|
||||
"脚本参数应为唯一一个、且已净化的路径"
|
||||
);
|
||||
}
|
||||
|
||||
#[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;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 「sidecar 退出后不得被复用」—— 2026-10-13 真机缺陷
|
||||
//
|
||||
// 现象:控制台点「检查更新」等满 RPC 超时(当时 45 秒),界面报「check_update 超时」;
|
||||
// 而同一台机器上手工拉起 sidecar 跑同一个请求只要 7 秒。
|
||||
// 根因:控制台缓存里的 Arc<Sidecar> 指向早已退出的子进程,
|
||||
// ensure_sidecar 只判「缓存里有没有」,不判「那进程还活着没」。
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// 把 sidecar 的子进程直接杀掉,模拟真机上它意外退出。
|
||||
fn kill_child(sc: &Sidecar) {
|
||||
let mut guard = sc.child.lock().unwrap();
|
||||
if let Some(child) = guard.as_mut() {
|
||||
let _ = child.start_kill();
|
||||
}
|
||||
}
|
||||
|
||||
/// 等 is_alive 变为 false(stdout 任务观察到 EOF 会置 dead)。
|
||||
async fn wait_dead(sc: &Sidecar) {
|
||||
for _ in 0..150 {
|
||||
if !sc.is_alive() {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn 启动后_is_alive_应为真_shutdown_后应为假() {
|
||||
let sc = Sidecar::spawn(&node_bin(), &dev_sidecar_src(), Arc::new(|_| {}))
|
||||
.expect("sidecar 应能启动");
|
||||
assert!(sc.is_alive(), "刚启动的 sidecar 应判为存活");
|
||||
sc.shutdown().await;
|
||||
assert!(!sc.is_alive(), "shutdown 之后必须判为不存活");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn 子进程被杀后_is_alive_必须变假() {
|
||||
let sc = Sidecar::spawn(&node_bin(), &dev_sidecar_src(), Arc::new(|_| {}))
|
||||
.expect("sidecar 应能启动");
|
||||
assert!(sc.is_alive());
|
||||
|
||||
kill_child(&sc);
|
||||
wait_dead(&sc).await;
|
||||
|
||||
assert!(
|
||||
!sc.is_alive(),
|
||||
"子进程已退出,is_alive 仍为真 → ensure_sidecar 会继续复用死实例(就是真机那个缺陷)"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn 对已退出的_sidecar_发请求必须秒失败而不是干等超时() {
|
||||
let sc = Sidecar::spawn(&node_bin(), &dev_sidecar_src(), Arc::new(|_| {}))
|
||||
.expect("sidecar 应能启动");
|
||||
|
||||
kill_child(&sc);
|
||||
wait_dead(&sc).await;
|
||||
|
||||
// 故意给 30 秒超时:正确实现应当在入口就拒绝(毫秒级)。
|
||||
// 首版缺陷的表现恰恰是「干等到超时」,所以这里**断言耗时**而不只是断言出错。
|
||||
let t0 = std::time::Instant::now();
|
||||
let r = sc.request("state", json!({}), 30).await;
|
||||
let cost = t0.elapsed();
|
||||
|
||||
assert!(r.is_err(), "对已退出的 sidecar 发请求必须失败");
|
||||
assert!(
|
||||
cost < std::time::Duration::from_secs(5),
|
||||
"应快速失败,实际耗时 {cost:?} —— 若接近 30s 说明仍在干等超时"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "nodeagent-console",
|
||||
"version": "2.1.0",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||