Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
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,379 @@
|
||||
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
|
||||
find dist -type f \( -name '*.exe' -o -name '*.msi' \) -printf '%10s %p\n' | sort -k2
|
||||
n=$(find dist -type f \( -name '*.exe' -o -name '*.msi' \) | wc -l)
|
||||
echo "共 $n 个安装包"
|
||||
# 两架构各一套(exe+msi)= 4 个;少一个说明产物不全,宁可红也不要静默少发
|
||||
if [ "$n" -lt 4 ]; then echo "::error::安装包数量不足($n < 4),架构可能缺失"; exit 1; fi
|
||||
|
||||
- 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
|
||||
@@ -40,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
|
||||
@@ -27,11 +27,148 @@ nodeagent 把一台 Windows 机器的能力**标准化成一组可授权、可
|
||||
| **屏幕** | `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 者整链重写」可被发现 |
|
||||
|
||||
**36 项能力** · **32 个 MCP 工具** · **114 项单元测试** · **26 项端到端用例**(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 工具** · **477 项单元测试** · **50 项端到端用例**(34 通用 + 16 Windows 专属) · **23 项 Rust 壳单测**(真跑 Node sidecar)(CI 在真实 Windows / Linux runner 上验证)
|
||||
|
||||
> 关键里程碑:**GUI 语义**(`window.list` + `screen.find`,UIA 找不到自动降级 OCR)
|
||||
> 让 AI 从「看得到画面但读不懂界面」变成「按名字取坐标点下去」。
|
||||
@@ -42,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)**
|
||||
@@ -52,7 +195,7 @@ pnpm build
|
||||
1. 在控制端构建安装包(**需在项目目录执行**):
|
||||
|
||||
```bash
|
||||
cd <项目目录> # 例如 ~/WorkBuddy/NodeAgent/nodeagent
|
||||
cd <项目目录> # 例如 ~/projects/nodeagent
|
||||
pnpm pack:win
|
||||
|
||||
# 或从任意目录调用:
|
||||
@@ -168,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 中转(跨网段/公网)
|
||||
```
|
||||
@@ -185,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 没有交互桌面,截图/窗口类能力会失败)。
|
||||
|
||||
## 安全模型
|
||||
|
||||
三层防护,逐层收窄权限:
|
||||
@@ -273,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
|
||||
@@ -328,7 +553,7 @@ 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 / 拦截率)+ 达标判定 |
|
||||
@@ -427,47 +652,57 @@ nodeagent/
|
||||
├── scripts/ # install.cmd / control.cmd(一键入口)/ install.ps1 / control.ps1 / verify.mjs
|
||||
├── tests/e2e/ # 端到端测试
|
||||
├── docs/ # 开发文档
|
||||
├── PRD.md # 产品需求文档
|
||||
├── docs/ # 项目文档(含 PRD)
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 路线图
|
||||
|
||||
| 阶段 | 目标 | 状态 |
|
||||
> 下表「**批次**」是**开发阶段的内部代号**(一批相关改动的集合),**不是版本号** ——
|
||||
> 项目的唯一版本号是 semver(见 `git tag` / `package.json`)。批次与发布版本的对应关系
|
||||
> 及书写规范见 [`docs/VERSIONING.md`](docs/VERSIONING.md) §0。
|
||||
|
||||
| 阶段(开发批次) | 目标 | 状态 |
|
||||
|---|---|---|
|
||||
| **v1** | 命令级接管 + 装软件 + 查状态 + 基本鉴权 + CLI + MCP | ✅ |
|
||||
| **v2** | 图形接管(`screen.*` 截屏 + `input.*` 键鼠)+ 输入控制开关 | ✅ |
|
||||
| **v3** | 零信任:Ed25519 身份认证 + 能力级 ACL | ✅ |
|
||||
| **v3+** | 审计日志(留痕 + 查询)+ 调用限速 | ✅ |
|
||||
| **v4** | 无感体验:局域网自动发现 + 断线自动重连 | ✅ |
|
||||
| **v5** | 多设备管理 + 文件传输(分块 / 大文件 / 原子写) | ✅ |
|
||||
| **v6** | Hub 中转:跨网段 / 公网接入(被控端主动外连,穿 NAT) | ✅ |
|
||||
| **v7** | **自持**:受控重启(配置变更后让自身生效,不再丢连接) | ✅ |
|
||||
| **v8** | **GUI 语义**:`window.list` / `window.focus` / `screen.find`(UIA 按名取坐标) | ✅ |
|
||||
| **v9** | `screen.find` **OCR 兜底**(自绘 UI 也能定位)+ MCP 工具同步 | ✅ |
|
||||
| **v10** | **长任务异步化**(`system.task.*`)+ 剪贴板 + CLI 常驻连接池 daemon | ✅ |
|
||||
| **v11** | **安全加固**:私钥入系统密钥库(Keychain/DPAPI)+ 审计链防篡改 + ACL 细化(IP/时段/按能力限速) | ✅ |
|
||||
| **v11+** | **操作面补完**:鼠标拖拽 / 录屏 / 剪贴板图片 / 多设备并发 / 一键升级 | ✅ |
|
||||
| **v12** | **事件订阅**(file/process/net 主动推送)+ **GUI 宏**(步骤序列回放)+ **Hub 并发多控制端** + 设备分组 | ✅ |
|
||||
| **v12.1** | **Hub 侧按控制端授权**(多令牌 + 按设备白/黑名单 + 注册准入) | ✅ |
|
||||
| **v12.2** | GUI **等待/重试语义**(`wait_ms`)—— 界面加载/动画不再假失败 | ✅ |
|
||||
| **v12.3** | **macOS 端 GUI 对齐**:AppleScript 窗口枚举/聚焦 + Vision OCR 元素定位(含 Retina 坐标换算) | ✅ |
|
||||
| **v12.4** | **事件直达 MCP 宿主**(实时推送 + 拉取双通路) | ✅ |
|
||||
| **v13** | **成功指标聚合**(PRD 2.2 四项)+ 依真实数据修正口径(时延分层) | ✅ |
|
||||
| **v14** | **PowerShell 常驻助手** —— GUI 能力提速 4~25 倍(预加载 C#/UIA,免每次现编译) | ✅ |
|
||||
| **v15** | `screen.find` **图像模板匹配**(纯图标/无文字控件盲区)+ 一堆真机根因修复 | ✅ |
|
||||
| **待办** | CI 单元测试步骤已就绪(分支 `ci/workflow-unit-tests`,**需 `gh auth refresh -s workflow` 授权后推送**);GUI 图像锚点匹配(自绘 UI 更稳) | 🚧 |
|
||||
| **批次 1** | 命令级接管 + 装软件 + 查状态 + 基本鉴权 + CLI + MCP | ✅ |
|
||||
| **批次 2** | 图形接管(`screen.*` 截屏 + `input.*` 键鼠)+ 输入控制开关 | ✅ |
|
||||
| **批次 3** | 零信任:Ed25519 身份认证 + 能力级 ACL | ✅ |
|
||||
| **批次 3+** | 审计日志(留痕 + 查询)+ 调用限速 | ✅ |
|
||||
| **批次 4** | 无感体验:局域网自动发现 + 断线自动重连 | ✅ |
|
||||
| **批次 5** | 多设备管理 + 文件传输(分块 / 大文件 / 原子写) | ✅ |
|
||||
| **批次 6** | Hub 中转:跨网段 / 公网接入(被控端主动外连,穿 NAT) | ✅ |
|
||||
| **批次 7** | **自持**:受控重启(配置变更后让自身生效,不再丢连接) | ✅ |
|
||||
| **批次 8** | **GUI 语义**:`window.list` / `window.focus` / `screen.find`(UIA 按名取坐标) | ✅ |
|
||||
| **批次 9** | `screen.find` **OCR 兜底**(自绘 UI 也能定位)+ MCP 工具同步 | ✅ |
|
||||
| **批次 10** | **长任务异步化**(`system.task.*`)+ 剪贴板 + CLI 常驻连接池 daemon | ✅ |
|
||||
| **批次 11** | **安全加固**:私钥入系统密钥库(Keychain/DPAPI)+ 审计链防篡改 + ACL 细化(IP/时段/按能力限速) | ✅ |
|
||||
| **批次 11+** | **操作面补完**:鼠标拖拽 / 录屏 / 剪贴板图片 / 多设备并发 / 一键升级 | ✅ |
|
||||
| **批次 12** | **事件订阅**(file/process/net 主动推送)+ **GUI 宏**(步骤序列回放)+ **Hub 并发多控制端** + 设备分组 | ✅ |
|
||||
| **批次 12.1** | **Hub 侧按控制端授权**(多令牌 + 按设备白/黑名单 + 注册准入) | ✅ |
|
||||
| **批次 12.2** | GUI **等待/重试语义**(`wait_ms`)—— 界面加载/动画不再假失败 | ✅ |
|
||||
| **批次 12.3** | **macOS 端 GUI 对齐**:AppleScript 窗口枚举/聚焦 + Vision OCR 元素定位(含 Retina 坐标换算) | ✅ |
|
||||
| **批次 12.4** | **事件直达 MCP 宿主**(实时推送 + 拉取双通路) | ✅ |
|
||||
| **批次 13** | **成功指标聚合**(PRD 2.2 四项)+ 依真实数据修正口径(时延分层) | ✅ |
|
||||
| **批次 14** | **PowerShell 常驻助手** —— GUI 能力提速 4~25 倍(预加载 C#/UIA,免每次现编译) | ✅ |
|
||||
| **批次 15** | `screen.find` **图像模板匹配**(纯图标/无文字控件盲区)+ 一堆真机根因修复 | ✅ |
|
||||
| **批次 16 ~ 26** | 网络两阶段提交 · 音频控制 · 构建指纹 · 自更新 · 证书钉住 · 每客户端 PSK · 连接层三防护 · 零信任安装 · 审计锚定 | ✅ |
|
||||
| **批次 27 ~ 29** | **语义可断言**(`gui.await` + UIA 属性 + `where` 谓词 + `any_of`)· **状态可回看**(`log.query` + `monitor.*`) | ✅ |
|
||||
| **发布** | **v2.0.2**(2026-10-11):51 项能力 · 274 单测 · Windows/Rust CI 全绿 —— 本版为**资产修复**(v2.0.1 的 `arm64` 包内实为 x64 node,详见 CHANGELOG) | ✅ 已发布 |
|
||||
|
||||
## 文档
|
||||
|
||||
- [`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)](./PRD.md)
|
||||
- [产品需求文档(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 对照表**
|
||||
|
||||
## 关键决策
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/agent",
|
||||
"version": "1.3.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'
|
||||
@@ -17,7 +24,45 @@ export type AuditType =
|
||||
/** v21:来源不在 allow_from 被拒 */
|
||||
| 'net.deny'
|
||||
/** v19:agent 自更新(拉取式) */
|
||||
| 'agent.update';
|
||||
| '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 {
|
||||
@@ -33,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;
|
||||
@@ -129,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)) };
|
||||
}
|
||||
|
||||
/** 写入一条审计记录。任何异常都被吞掉 —— 审计绝不阻断主流程。 */
|
||||
@@ -332,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;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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>;
|
||||
|
||||
@@ -132,7 +134,9 @@ async function runWindows(body: string, timeoutMs = 30_000): Promise<string> {
|
||||
|
||||
/** macOS:osascript 原生音量控制(静音用 `output muted`,音量用 `output volume`)。 */
|
||||
async function runMac(script: string): Promise<string> {
|
||||
const r = await execCommand({ command: `osascript -e ${JSON.stringify(script)}`, timeoutMs: 15_000 });
|
||||
// ⚠️ 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),
|
||||
@@ -160,6 +164,8 @@ async function macState(): Promise<{ muted: boolean; volume: number }> {
|
||||
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 };
|
||||
@@ -175,6 +181,8 @@ export async function audioGet(_args: Args): Promise<unknown> {
|
||||
// ---------------- 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';
|
||||
|
||||
@@ -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,6 +3,7 @@ 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>;
|
||||
|
||||
@@ -79,6 +80,8 @@ async function writeClipboardWindows(b64: string, expect: string): Promise<void>
|
||||
// ---------------- 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) {
|
||||
@@ -132,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;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ 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>;
|
||||
@@ -317,7 +318,15 @@ async function ensureOcrBin(): Promise<string> {
|
||||
export async function macScreenFind(args: Args): Promise<unknown> {
|
||||
assertDarwin('screen.find');
|
||||
const text = args['text'] as string;
|
||||
if (!text) throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'text 不能为空');
|
||||
if (!text) {
|
||||
// macOS 走 Vision OCR,**没有控件属性可读** —— where-only 无法满足,
|
||||
// 明确说清原因,别让调用方以为是参数写错。
|
||||
throw new CapabilityError(
|
||||
ErrorCodes.UNSUPPORTED_PLATFORM,
|
||||
'macOS 无 UIA 等价物:where 属性过滤不可用,请提供 text(OCR 按文字查找)',
|
||||
{ platform: 'darwin', engine: 'ocr' },
|
||||
);
|
||||
}
|
||||
const limit = (args['limit'] as number | undefined) ?? 20;
|
||||
const waitMs = Math.max(0, Math.min(30_000, (args['wait_ms'] as number | undefined) ?? 0));
|
||||
const intervalMs = Math.max(100, Math.min(2000, (args['interval_ms'] as number | undefined) ?? 400));
|
||||
@@ -336,7 +345,7 @@ export async function macScreenFind(args: Args): Promise<unknown> {
|
||||
try {
|
||||
for (;;) {
|
||||
const cap = await execCommand({
|
||||
command: `screencapture -x -t png -R ${captureRegion.x},${captureRegion.y},${captureRegion.width},${captureRegion.height} ${JSON.stringify(shot)}`,
|
||||
command: `screencapture -x -t png -R ${captureRegion.x},${captureRegion.y},${captureRegion.width},${captureRegion.height} ${shLit(shot)}`,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
if (cap.exit_code !== 0) {
|
||||
@@ -351,8 +360,10 @@ export async function macScreenFind(args: Args): Promise<unknown> {
|
||||
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: `${JSON.stringify(bin)} ${JSON.stringify(shot)} ${JSON.stringify(text)}`,
|
||||
command: `${shLit(bin)} ${shLit(shot)} ${shLit(text)}`,
|
||||
timeoutMs: 60_000,
|
||||
maxOutputBytes: 4 * 1024 * 1024,
|
||||
});
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
import { CapabilityNames } from '@nodeagent/protocol';
|
||||
import type { AgentConfig } from '../config.js';
|
||||
import { systemInfo, systemStatus, processList, serviceList, shellExec, auditList, auditVerify, metricsReport, agentRestart } from './system.js';
|
||||
import { systemInfo, systemStatus, processList, serviceList, shellExec, auditList, auditVerify, auditHead, auditAnchor, metricsReport, agentRestart } from './system.js';
|
||||
import { auditL2 } from './audit-l2.js';
|
||||
import { appList, appInstall } from './app.js';
|
||||
import { screenInfo, screenCapture } from './screen.js';
|
||||
import { windowList, windowFocus, screenFind } from './window.js';
|
||||
@@ -13,10 +14,21 @@ 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 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;
|
||||
}
|
||||
@@ -52,10 +64,24 @@ export function createCapabilityRegistry(cfg: AgentConfig): Record<string, Capab
|
||||
[CapabilityNames.MouseDrag]: mouseDrag,
|
||||
[CapabilityNames.KeyType]: keyType,
|
||||
[CapabilityNames.KeyPress]: keyPress,
|
||||
// v1.6.0 等待条件(把盲试变为可断言)
|
||||
[CapabilityNames.GuiAwait]: guiAwait,
|
||||
// v2.0.0 状态采样(方向二)
|
||||
[CapabilityNames.LogQuery]: logQuery,
|
||||
[CapabilityNames.MonitorStart]: monitorStart,
|
||||
[CapabilityNames.MonitorReport]: monitorReport,
|
||||
[CapabilityNames.MonitorStop]: monitorStop,
|
||||
[CapabilityNames.MonitorList]: monitorList,
|
||||
[CapabilityNames.MonitorDelete]: monitorDelete,
|
||||
// v3+ 审计
|
||||
[CapabilityNames.AuditList]: auditList,
|
||||
// v11 审计防篡改
|
||||
[CapabilityNames.AuditVerify]: auditVerify,
|
||||
// v2.0.0 审计链外部锚定(防整链重写)
|
||||
[CapabilityNames.AuditHead]: auditHead,
|
||||
[CapabilityNames.AuditAnchor]: auditAnchor,
|
||||
// L2 系统级审计兜底(见 docs/AUDIT-L2.md)
|
||||
[CapabilityNames.AuditL2]: auditL2,
|
||||
// v13 成功指标
|
||||
[CapabilityNames.Metrics]: metricsReport,
|
||||
// v5 文件传输
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -128,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 ?? '';
|
||||
}
|
||||
|
||||
// ---------------- 鼠标 ----------------
|
||||
@@ -155,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;
|
||||
@@ -182,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';
|
||||
@@ -201,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);
|
||||
@@ -211,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;
|
||||
@@ -230,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;
|
||||
@@ -256,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');
|
||||
@@ -303,35 +388,96 @@ 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);
|
||||
|
||||
// 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 {
|
||||
@@ -339,5 +485,9 @@ export async function keyPress(args: Args): Promise<unknown> {
|
||||
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 };
|
||||
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFile
|
||||
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';
|
||||
@@ -59,7 +60,7 @@ async function runPsFile(script: string, timeoutMs: number, tag = 'ps'): Promise
|
||||
const path = join(dir, `${tag}-${Date.now()}.ps1`);
|
||||
writeFileSync(path, withBom(script), 'utf8');
|
||||
return execCommand({
|
||||
command: `powershell -NoProfile -ExecutionPolicy Bypass -File ${JSON.stringify(path)}`,
|
||||
command: `powershell -NoProfile -ExecutionPolicy Bypass -File ${psLit(path)}`,
|
||||
timeoutMs,
|
||||
});
|
||||
}
|
||||
@@ -68,7 +69,7 @@ async function runPsFile(script: string, timeoutMs: number, tag = 'ps'): Promise
|
||||
* 生成的 .ps1 **必须带 UTF-8 BOM**。
|
||||
*
|
||||
* ⚠️ 真机踩过:PowerShell 5.1 读**无 BOM** 的 .ps1 会按系统 ANSI(GBK) 解析,
|
||||
* 脚本里的中文(如网卡名「Ethernet」)会吃掉后面的闭合引号 →
|
||||
* 脚本里的非 ASCII 字符(如中文网卡名)会吃掉后面的闭合引号 →
|
||||
* 报「The string is missing the terminator」→ 计划任务退出码 1 且不留日志。
|
||||
* 所有落盘的 PowerShell 脚本一律经此函数。
|
||||
*/
|
||||
@@ -83,17 +84,17 @@ export function withBom(content: string): string {
|
||||
* 曾经用 JSON.stringify(双引号)或手动把 \ 翻倍当转义,结果生成 `C:\\Users\\...`
|
||||
* 这种非法路径 → Out-File 失败 → 计划任务退出码 1 且不留日志(真机踩过)。
|
||||
* 需要字面量时一律走本函数;需要插值时用双引号但避免反斜杠。
|
||||
*
|
||||
* 注:实现已收敛到 `util/quote.ts`(单一真源),此处保留同名导出以兼容既有引用与测试。
|
||||
*/
|
||||
export function psLit(v: string): string {
|
||||
return `'${v.replace(/'/g, "''")}'`;
|
||||
}
|
||||
export const psLit = psLitShared;
|
||||
|
||||
/**
|
||||
* 生成「注册一次性计划任务」的 PowerShell 脚本(唯一实现,两处复用)。
|
||||
*
|
||||
* ⚠️ 两个真机教训都固化在这里:
|
||||
* 1) **账号名必须用 WindowsIdentity 取**,不要用 "$env:USERDOMAIN\$env:USERNAME" 拼 ——
|
||||
* 在 TS 模板串里 `\$` 会被当转义吃掉反斜杠,拼出「机器名<user>」这种不可解析的账号,
|
||||
* 在 TS 模板串里 `\$` 会被当转义吃掉反斜杠,拼出「机器名+纯数字」这种不可解析的账号,
|
||||
* Register-ScheduledTask 报 HRESULT 0x80070534「No mapping between account names...」。
|
||||
* 2) 复用同一实现:此前回滚/延时两处各写一份,改了一处漏了另一处(真机踩过)。
|
||||
*/
|
||||
@@ -173,7 +174,7 @@ export interface IfaceSnapshot {
|
||||
async function snapshotIface(iface: string): Promise<IfaceSnapshot> {
|
||||
const script = `
|
||||
$ErrorActionPreference='Continue'
|
||||
$a = ${JSON.stringify(iface)}
|
||||
$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') }
|
||||
@@ -210,7 +211,7 @@ $o | ConvertTo-Json -Compress -Depth 4
|
||||
|
||||
/** 由结构化快照生成**原生 cmdlet** 回滚脚本(不用 netsh set —— 它在 Win11 上会挂起)。 */
|
||||
export function buildRollbackScript(snap: IfaceSnapshot, backupTxt: string, logPath: string): string {
|
||||
const a = JSON.stringify(snap.alias);
|
||||
const a = psLit(snap.alias);
|
||||
const L = psLit(logPath);
|
||||
const lines = [
|
||||
`# nodeagent 自动回滚(生成于 ${new Date().toISOString()})`,
|
||||
@@ -229,23 +230,23 @@ export function buildRollbackScript(snap: IfaceSnapshot, backupTxt: string, logP
|
||||
// 同样「先加后清 + 先清默认路由」——否则回滚本身也会踩 already exists(真机事故根因)
|
||||
for (const addr of snap.ipv4) {
|
||||
lines.push(
|
||||
` New-NetIPAddress -InterfaceAlias ${a} -IPAddress ${JSON.stringify(addr.ip)} -PrefixLength ${addr.prefix} -ErrorAction SilentlyContinue | Out-Null`,
|
||||
` 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) => JSON.stringify(x.ip)).join(',');
|
||||
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 ${JSON.stringify(snap.gateway[0])} -ErrorAction SilentlyContinue | Out-Null`,
|
||||
` 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) => JSON.stringify(d)).join(',')} -ErrorAction SilentlyContinue`,
|
||||
` Set-DnsClientServerAddress -InterfaceAlias ${a} -ServerAddresses ${snap.dns.map((d) => psLit(d)).join(',')} -ErrorAction SilentlyContinue`,
|
||||
);
|
||||
}
|
||||
lines.push(` W 'restored: static'`);
|
||||
@@ -260,14 +261,14 @@ export function buildRollbackScript(snap: IfaceSnapshot, backupTxt: string, logP
|
||||
async function readIfaceWindows(iface: string): Promise<Record<string, unknown>> {
|
||||
const script = `
|
||||
$ErrorActionPreference='Continue'
|
||||
$c = Get-NetIPConfiguration -InterfaceAlias ${JSON.stringify(iface)} -ErrorAction SilentlyContinue
|
||||
$c = Get-NetIPConfiguration -InterfaceAlias ${psLit(iface)} -ErrorAction SilentlyContinue
|
||||
if (-not $c) { Write-Output '{}'; return }
|
||||
$o = [ordered]@{ interface = ${JSON.stringify(iface)} }
|
||||
$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 ${JSON.stringify(iface)} -AddressFamily IPv4 -ErrorAction SilentlyContinue
|
||||
$dhcp = Get-NetIPInterface -InterfaceAlias ${psLit(iface)} -AddressFamily IPv4 -ErrorAction SilentlyContinue
|
||||
$o.dhcp = ($dhcp.Dhcp -eq 'Enabled')
|
||||
$o | ConvertTo-Json -Compress
|
||||
`;
|
||||
@@ -436,7 +437,7 @@ export async function netApply(args: Args): Promise<unknown> {
|
||||
* netsh 仅保留用于「读取」与人工恢复用的 dump 备份(对操作者友好)。
|
||||
*/
|
||||
export function buildApplyCommand(mode: string, iface: string, args: Args, snap: IfaceSnapshot): string {
|
||||
const a = JSON.stringify(iface);
|
||||
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 参数');
|
||||
@@ -459,7 +460,7 @@ export function buildApplyCommand(mode: string, iface: string, args: Args, snap:
|
||||
}
|
||||
const prefix = maskToPrefix(mask);
|
||||
const dns = (args['dns'] as string[] | undefined) ?? [];
|
||||
const ipLit = JSON.stringify(ip);
|
||||
const ipLit = psLit(ip);
|
||||
const lines = [
|
||||
`$ErrorActionPreference='Continue'`,
|
||||
// 关掉 DHCP(否则设静态地址会与 DHCP 租约打架)
|
||||
@@ -477,12 +478,12 @@ export function buildApplyCommand(mode: string, iface: string, args: Args, snap:
|
||||
];
|
||||
if (gateway) {
|
||||
lines.push(
|
||||
`New-NetRoute -InterfaceAlias ${a} -DestinationPrefix '0.0.0.0/0' -NextHop ${JSON.stringify(gateway)} -ErrorAction SilentlyContinue | Out-Null`,
|
||||
`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) => JSON.stringify(d)).join(',')} -ErrorAction SilentlyContinue`,
|
||||
`Set-DnsClientServerAddress -InterfaceAlias ${a} -ServerAddresses ${dns.map((d) => psLit(d)).join(',')} -ErrorAction SilentlyContinue`,
|
||||
);
|
||||
}
|
||||
lines.push(`Write-Output 'static applied'`);
|
||||
@@ -495,10 +496,10 @@ export function buildApplyCommand(mode: string, iface: string, args: Args, snap:
|
||||
* 而调用方还以为变更成功了(真机事故:机器掉到 APIPA 彻底失联)。
|
||||
*/
|
||||
export function buildSelfCheck(iface: string, expectIp: string | null, rollbackScriptPath: string): string {
|
||||
const a = JSON.stringify(iface);
|
||||
const rb = JSON.stringify(rollbackScriptPath);
|
||||
const a = psLit(iface);
|
||||
const rb = psLit(rollbackScriptPath);
|
||||
const cond = expectIp
|
||||
? `$ok = @(Get-NetIPAddress -InterfaceAlias ${a} -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -eq ${JSON.stringify(expectIp)} }).Count -gt 0`
|
||||
? `$ok = @(Get-NetIPAddress -InterfaceAlias ${a} -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.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 [
|
||||
``,
|
||||
@@ -551,7 +552,7 @@ async function posixCommandApply(
|
||||
'utf8',
|
||||
);
|
||||
const launched = await execCommand({
|
||||
command: `chmod +x ${JSON.stringify(rollbackPath)} && setsid nohup sh ${JSON.stringify(rollbackPath)} > ${JSON.stringify(join(dir, `rollback-${ts}.log`))} 2>&1 & echo started`,
|
||||
command: `chmod +x ${shLit(rollbackPath)} && setsid nohup sh ${shLit(rollbackPath)} > ${shLit(join(dir, `rollback-${ts}.log`))} 2>&1 & echo started`,
|
||||
timeoutMs: 15_000,
|
||||
});
|
||||
|
||||
|
||||
@@ -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 屏幕录制权限)', {
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { copyFileSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
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>;
|
||||
|
||||
@@ -21,59 +31,198 @@ type Args = Record<string, unknown>;
|
||||
* 1. **哈希必须钉死**:`sha256` 必填,且**逐字节校验下载内容**后才替换。
|
||||
* 没有它,GitHub 账号被盗 / DNS 被污染 / 中间人 = 直接拿下被控端。
|
||||
* 2. 只接受 http/https;拒绝 file:// 等其它 scheme。
|
||||
* 3. 大小上限(默认 64MB)+ 下载超时,避免被喂垃圾撑爆磁盘。
|
||||
* 4. **先备份再原子替换**(同目录 rename),失败可回滚。
|
||||
* 3. 大小上限(默认 64MB,content-length 预检 + 流式中断双闸)+ 下载超时。
|
||||
* 4. **先备份再替换**(同目录备份;Windows 上就地覆写,不用 rename —— 真机踩过),
|
||||
* 失败可回滚;写后校验不一致自动回滚。
|
||||
* 5. `dry_run` 只下载校验、不动现有文件 —— 用于「先确认能不能更新」。
|
||||
* 6. 动作全程进审计(能力调用本身已记录 `invoke`,这里再加一条 `agent.update`)。
|
||||
*
|
||||
* ## 为什么不用 `fetch` 的代理能力
|
||||
* Node 内置 `fetch` 不支持 HTTP 代理(需要 undici 的 ProxyAgent,而 undici 未对外暴露)。
|
||||
* 国内访问 GitHub 不稳时,正确做法是**把 url 指向自建镜像 / 加速服务**(本能力接受任意
|
||||
* http(s) 地址,天然支持),而不是在 agent 里再塞一套代理隧道。
|
||||
* ## 出网:经 `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;
|
||||
|
||||
/** 下载到内存(带大小上限与超时)。 */
|
||||
async function download(url: string, timeoutMs: number): Promise<Buffer> {
|
||||
const ac = new AbortController();
|
||||
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
||||
/** 出网诊断信息(返回结构与 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}(域名解析失败或目标拒绝连接;若配了代理,请核对代理地址与端口)`;
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
/** 决议代理;任何异常都退化成「直连」—— 探测本身绝不该成为新的失败点。 */
|
||||
async function resolveProxySafe(targetUrl: string): Promise<{ proxy: URL | null; info: OutboundInfo }> {
|
||||
try {
|
||||
const res = await fetch(url, { signal: ac.signal, redirect: 'follow' });
|
||||
if (!res.ok) {
|
||||
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, `下载失败: HTTP ${res.status} ${res.statusText}`, {
|
||||
url,
|
||||
});
|
||||
}
|
||||
const declared = Number(res.headers.get('content-length') ?? '0');
|
||||
if (declared > MAX_BYTES) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `文件过大(${declared} 字节 > 上限 ${MAX_BYTES})`, { url });
|
||||
}
|
||||
const reader = res.body?.getReader();
|
||||
if (!reader) {
|
||||
return Buffer.from(await res.arrayBuffer());
|
||||
}
|
||||
const parts: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value) {
|
||||
total += value.length;
|
||||
if (total > MAX_BYTES) {
|
||||
await reader.cancel();
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `下载超过上限 ${MAX_BYTES} 字节,已中断`, { url });
|
||||
}
|
||||
parts.push(value);
|
||||
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 {
|
||||
/* 文件可能已消失,跳过 */
|
||||
}
|
||||
}
|
||||
return Buffer.concat(parts);
|
||||
} catch (err) {
|
||||
if (err instanceof CapabilityError) throw err;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, `下载失败: ${msg}`, { url });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
const removed: string[] = [];
|
||||
for (const name of planBackupCleanup(files, keep)) {
|
||||
try {
|
||||
unlinkSync(join(dir, name));
|
||||
removed.push(name);
|
||||
} catch {
|
||||
/* 删不掉不影响正确性 */
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,9 +258,9 @@ export async function agentUpdate(args: Args): Promise<unknown> {
|
||||
const dir = join(agentDir(), 'update');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
|
||||
// ① 下载 + ② 校验哈希(先校验再落盘,避免半成品污染)
|
||||
// ① 下载(经代理出网)+ ② 校验哈希(先校验再落盘,避免半成品污染)
|
||||
const t0 = Date.now();
|
||||
const buf = await download(url, timeoutMs);
|
||||
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)}` });
|
||||
@@ -119,6 +268,7 @@ export async function agentUpdate(args: Args): Promise<unknown> {
|
||||
expected: expect,
|
||||
actual: got.slice(0, 12),
|
||||
bytes: buf.length,
|
||||
...outboundHint(outbound, timeoutMs),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -146,7 +296,7 @@ export async function agentUpdate(args: Args): Promise<unknown> {
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
}
|
||||
audit({ type: 'agent.update', status: 'ok', reason: `dry_run url=${url} hash=${got.slice(0, 12)}` });
|
||||
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,
|
||||
@@ -155,10 +305,15 @@ export async function agentUpdate(args: Args): Promise<unknown> {
|
||||
current_hash: prevHash,
|
||||
current_bytes: prevBytes,
|
||||
note: '仅下载并校验,未改动任何文件',
|
||||
via: outbound.via,
|
||||
proxy: outbound.proxy,
|
||||
proxy_source: outbound.proxy_source,
|
||||
outbound_notes: outbound.notes,
|
||||
};
|
||||
}
|
||||
|
||||
// ③ 备份 + ④ 原子替换(同目录 rename,失败时旧文件不受影响)
|
||||
// ③ 备份 + ④ 替换(同目录备份;就地覆写不用 rename —— Windows 不允许替换
|
||||
// 正被运行进程打开的文件,真机踩过:rename 必失败)
|
||||
const backup = `${scriptPath}.bak-${Date.now()}`;
|
||||
try {
|
||||
copyFileSync(scriptPath, backup);
|
||||
@@ -167,23 +322,51 @@ export async function agentUpdate(args: Args): Promise<unknown> {
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
// ⚠️ 必须**就地覆写**,不能用 rename 覆盖 —— Windows 不允许重命名/替换「正被运行进程
|
||||
// 打开的文件」(EPERM/EBUSY),而 node.exe 正持有 agent.mjs。真机踩过:rename 必失败。
|
||||
// 覆写打开中的文件是允许的(只有删除/重命名被禁),这也正是推送式 deploy 能工作的原因。
|
||||
try {
|
||||
renameSync(staged, scriptPath);
|
||||
writeFileSync(scriptPath, buf);
|
||||
} catch (err) {
|
||||
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '替换脚本失败', {
|
||||
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}`,
|
||||
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,均不依赖本进程存活)
|
||||
// ⑥ 受控重启(复用 v7;Windows 走计划任务、POSIX 走 detached,均不依赖本进程存活)
|
||||
let restarted = false;
|
||||
if (doRestart) {
|
||||
await agentRestart({ delay_ms: 1500, reason: 'self-update' });
|
||||
@@ -204,5 +387,10 @@ export async function agentUpdate(args: Args): Promise<unknown> {
|
||||
? '已替换并重启;请用 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,4 +1,5 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { guardPath } from './fs.js';
|
||||
import { readFileSync, statSync } from 'node:fs';
|
||||
import { spawn } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
@@ -6,10 +7,12 @@ import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
import { IS_WINDOWS, execCommand } from '../util/exec.js';
|
||||
import { currentCertFingerprint } from '../certs.js';
|
||||
import { loadAgentConfig } from '../config.js';
|
||||
import { readAudit, readAuditAll, verifyAudit } from '../audit.js';
|
||||
import { readAudit, readAuditAll, verifyAudit, computeAuditHead, anchorAudit, compareWithAnchors } from '../audit.js';
|
||||
import { computeMetrics } from '../metrics.js';
|
||||
import { allPsShellStats } from '../util/ps-helper.js';
|
||||
import { lkgStatus } from '../lkg.js';
|
||||
import { startTask } from './task.js';
|
||||
import { psLit } from '../util/quote.js';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
@@ -55,7 +58,6 @@ async function isAdmin(): Promise<boolean> {
|
||||
// 故一律用 typeof 探测,绝不能直接引用(否则 dev 启动就 ReferenceError)。
|
||||
declare const __AGENT_VERSION__: string | undefined;
|
||||
declare const __BUILD_COMMIT__: string | undefined;
|
||||
declare const __BUILD_TIME__: string | undefined;
|
||||
|
||||
/** v21:读取当前生效的来源白名单(用于 system.info 上报)。 */
|
||||
function configAllowFrom(): string[] | null {
|
||||
@@ -67,10 +69,9 @@ function configAllowFrom(): string[] | null {
|
||||
}
|
||||
}
|
||||
|
||||
function injected(name: 'version' | 'commit' | 'time'): string {
|
||||
function injected(name: 'version' | 'commit'): string {
|
||||
if (name === 'version') return typeof __AGENT_VERSION__ === 'undefined' ? 'dev' : __AGENT_VERSION__;
|
||||
if (name === 'commit') return typeof __BUILD_COMMIT__ === 'undefined' ? 'dev' : __BUILD_COMMIT__;
|
||||
return typeof __BUILD_TIME__ === 'undefined' ? 'dev' : __BUILD_TIME__;
|
||||
return typeof __BUILD_COMMIT__ === 'undefined' ? 'dev' : __BUILD_COMMIT__;
|
||||
}
|
||||
|
||||
const STARTED_AT = Date.now();
|
||||
@@ -118,7 +119,9 @@ export function buildInfo(): {
|
||||
// v20:语义化版本 + 提交 + 构建时间(来自构建期注入)
|
||||
version: injected('version'),
|
||||
commit: injected('commit'),
|
||||
built_at: injected('time'),
|
||||
// 构建时间取入口文件的 mtime(而非构建期注入)—— 注入会让每次打包字节不同,
|
||||
// 破坏「指纹 = 构建身份」的确定性(v22 真机踩过)
|
||||
built_at: buildCache.mtime_ms ? new Date(buildCache.mtime_ms).toISOString() : 'unknown',
|
||||
// v21:TLS 证书指纹(控制端据此钉住被控端身份)
|
||||
cert_sha256: currentCertFingerprint(),
|
||||
};
|
||||
@@ -149,6 +152,10 @@ export async function systemInfo(args: Args): Promise<unknown> {
|
||||
network: {
|
||||
allow_from: configAllowFrom(),
|
||||
},
|
||||
// 交付流水线 §6:最后已知可用版本(回滚锚点)。
|
||||
// matches=false 表示「当前版本还没被证明能启动」—— 刚更新完、尚未完成一次成功
|
||||
// 启动时会是这种情况,控制端应据此暂缓向其余设备放量。
|
||||
lkg: lkgStatus(),
|
||||
};
|
||||
|
||||
if (fields && fields.length > 0) {
|
||||
@@ -526,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}'`,
|
||||
@@ -600,6 +630,31 @@ export async function auditVerify(_args: Args): Promise<unknown> {
|
||||
return verifyAudit();
|
||||
}
|
||||
|
||||
/**
|
||||
* v2.0.0:返回审计链的链头(只读)。
|
||||
*
|
||||
* 用途:控制端定期拉取并存到**链外**(Mac 本地 / 另一台机器)—— 这是"外部锚定"的一半。
|
||||
* compare=true 时附带与最近一条锚点的比对结论(另一半)。
|
||||
*/
|
||||
export async function auditHead(args: Args): Promise<unknown> {
|
||||
const head = computeAuditHead();
|
||||
if (args['compare'] !== true) return head;
|
||||
return { ...head, comparison: compareWithAnchors(args['anchor_path'] as string | undefined) };
|
||||
}
|
||||
|
||||
/**
|
||||
* v2.0.0:把当前链头**追加**写到锚点文件(默认 <数据目录>/audit-anchors.jsonl)。
|
||||
*
|
||||
* 为什么是追加而不是覆盖:历史锚点一旦写成就不可被后续覆盖,否则攻击者重写链后
|
||||
* 再"刷新"锚点即可抹掉痕迹。追加式让每次锚定都留痕。
|
||||
* path 若指定,仍受 fs_roots 白名单约束(不新开写入面)。
|
||||
*/
|
||||
export async function auditAnchor(args: Args): Promise<unknown> {
|
||||
const raw = args['path'] as string | undefined;
|
||||
const path = raw ? guardPath(raw) : undefined;
|
||||
return anchorAudit({ ...(path ? { path } : {}), ...(args['note'] ? { note: String(args['note']) } : {}) });
|
||||
}
|
||||
|
||||
/** v13:成功指标聚合(对齐 PRD 2.2)—— 数据源为审计日志,无需额外埋点。 */
|
||||
export async function metricsReport(args: Args): Promise<unknown> {
|
||||
const since = args['since'] as number | undefined;
|
||||
|
||||
@@ -132,6 +132,13 @@ function getTask(id: string): TaskRecord {
|
||||
|
||||
// ---------------- system.task.list ----------------
|
||||
|
||||
/** v2.0.0:正在跑的任务数(连接层"有空闲断开但不动长任务"的判据)。 */
|
||||
export function runningTaskCount(): number {
|
||||
let n = 0;
|
||||
for (const t of tasks.values()) if (t.state === 'running') n += 1;
|
||||
return n;
|
||||
}
|
||||
|
||||
export async function taskList(_args: Args): Promise<unknown> {
|
||||
return {
|
||||
tasks: [...tasks.values()].map((t) => ({
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 枚举可见顶层窗口 → JSON(**不含 prelude**,供常驻助手与一次性路径复用)
|
||||
*
|
||||
* 2026-10 从 window.ts 抽出(结构拆分,**内容逐字节不变**)。
|
||||
* ⚠️ WIN_HELPER_PRELUDE 是本段与另外几段的拼接 —— 改动会直接影响常驻 PS 助手。
|
||||
* 拆分以 tests/unit/win-prelude.test.mjs + prelude sha256 比对双重把关。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 枚举可见顶层窗口 → JSON(**不含 prelude**,供常驻助手与一次性路径复用)。
|
||||
* 注意:本段会被送进常驻进程执行,**严禁出现 exit**(会杀掉助手进程)。
|
||||
*/
|
||||
export const LIST_BODY = `
|
||||
$fg = [NAWin32]::GetForegroundWindow()
|
||||
$list = New-Object System.Collections.ArrayList
|
||||
$cb = [NAWin32+EnumProc]{
|
||||
param($h, $l)
|
||||
if (-not [NAWin32]::IsWindowVisible($h)) { return $true }
|
||||
$sb = New-Object System.Text.StringBuilder 512
|
||||
[void][NAWin32]::GetWindowTextW($h, $sb, 512)
|
||||
$title = $sb.ToString()
|
||||
if ([string]::IsNullOrWhiteSpace($title)) { return $true }
|
||||
$r = New-Object NAWin32+RECT
|
||||
if (-not [NAWin32]::GetWindowRect($h, [ref]$r)) { return $true }
|
||||
$w = $r.Right - $r.Left; $ht = $r.Bottom - $r.Top
|
||||
if ($w -le 0 -or $ht -le 0) { return $true }
|
||||
# ⚠️ 变量名不能叫 $pid —— 它是 PowerShell 的自动变量(当前进程 ID),赋值会被忽略,
|
||||
# 导致所有窗口都报成 powershell 进程(真机踩过)。
|
||||
$procId = 0
|
||||
[void][NAWin32]::GetWindowThreadProcessId($h, [ref]$procId)
|
||||
$pn = ''
|
||||
try { $pn = (Get-Process -Id $procId -ErrorAction Stop).ProcessName } catch {}
|
||||
[void]$list.Add([pscustomobject]@{
|
||||
hwnd = ('0x{0:X}' -f $h.ToInt64()); title = $title; process = $pn; pid = $procId
|
||||
x = $r.Left; y = $r.Top; width = $w; height = $ht
|
||||
is_foreground = ($h -eq $fg); is_minimized = [NAWin32]::IsIconic($h)
|
||||
})
|
||||
return $true
|
||||
}
|
||||
[void][NAWin32]::EnumWindows($cb, [IntPtr]::Zero)
|
||||
$list | ConvertTo-Json -Compress
|
||||
`;
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* 图像模板匹配预加载段(ZNCC 粗到精)
|
||||
*
|
||||
* 2026-10 从 window.ts 抽出(结构拆分,**内容逐字节不变**)。
|
||||
* ⚠️ WIN_HELPER_PRELUDE 是本段与另外几段的拼接 —— 改动会直接影响常驻 PS 助手。
|
||||
* 拆分以 tests/unit/win-prelude.test.mjs + prelude sha256 比对双重把关。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 图像模板匹配预加载段(v15)。
|
||||
*
|
||||
* 动机:UIA 靠控件树、OCR 靠文字 —— **纯图标/无文字的控件**两者都抓不到(审查报告 §5.2 指出的盲区)。
|
||||
* 做法:把模板图与屏幕区域都转灰度,做**零均值归一化互相关(ZNCC)**;
|
||||
* 全分辨率逐像素太慢(3440×1440 屏 × 64×64 模板 ≈ 190 亿次),故**粗到精**:
|
||||
* ① 1/4 分辨率全图扫描(约 8000 万次)取候选
|
||||
* ② 对候选在全分辨率 ±10px 邻域精修
|
||||
* 编译一次常驻(随助手预加载),运行时纯 native 循环。
|
||||
* 亮度和对比度无关(ZNCC 归一化),故对主题/亮度变化不敏感。
|
||||
*/
|
||||
export const IMAGE_MATCH_PRELUDE = `
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
# ⚠️ Add-Type 的老坑:-AssemblyName 只把程序集加载进 PowerShell 会话,
|
||||
# 编译 C# 时还必须显式 -ReferencedAssemblies,否则报「命名空间 System.Drawing
|
||||
# 中不存在 Imaging」。真机踩过:编译失败会让整个助手起不来,并伴随误导性的
|
||||
# 「找不到类型 System.Windows.Forms.SystemInformation」二次错误。
|
||||
Add-Type @"
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
public class NAImageMatch {
|
||||
public class Hit { public int x; public int y; public int w; public int h; public double score; }
|
||||
|
||||
private static byte[] Gray(Bitmap src, out int w, out int h) {
|
||||
w = src.Width; h = src.Height;
|
||||
var dst = new byte[w * h];
|
||||
var rect = new Rectangle(0, 0, w, h);
|
||||
Bitmap bmp = src.PixelFormat == PixelFormat.Format24bppRgb ? src : src.Clone(rect, PixelFormat.Format24bppRgb);
|
||||
var data = bmp.LockBits(rect, ImageLockMode.ReadOnly, PixelFormat.Format24bppRgb);
|
||||
try {
|
||||
int stride = data.Stride;
|
||||
var buf = new byte[stride * h];
|
||||
Marshal.Copy(data.Scan0, buf, 0, buf.Length);
|
||||
for (int y = 0; y < h; y++) {
|
||||
int row = y * stride;
|
||||
for (int x = 0; x < w; x++) {
|
||||
int i = row + x * 3;
|
||||
// BGR -> luma (integer approximation of ITU-R BT.601)
|
||||
dst[y * w + x] = (byte)((buf[i + 2] * 77 + buf[i + 1] * 150 + buf[i] * 29) >> 8);
|
||||
}
|
||||
}
|
||||
} finally { bmp.UnlockBits(data); if (bmp != src) bmp.Dispose(); }
|
||||
return dst;
|
||||
}
|
||||
|
||||
// Area-average downscale (coarse search layer)
|
||||
private static byte[] Downscale(byte[] src, int w, int h, int f, out int ow, out int oh) {
|
||||
ow = w / f; oh = h / f;
|
||||
var dst = new byte[ow * oh];
|
||||
for (int y = 0; y < oh; y++) {
|
||||
for (int x = 0; x < ow; x++) {
|
||||
int sum = 0;
|
||||
for (int dy = 0; dy < f; dy++) {
|
||||
int row = (y * f + dy) * w;
|
||||
for (int dx = 0; dx < f; dx++) sum += src[row + x * f + dx];
|
||||
}
|
||||
dst[y * ow + x] = (byte)(sum / (f * f));
|
||||
}
|
||||
}
|
||||
return dst;
|
||||
}
|
||||
|
||||
// Zero-mean normalized cross correlation; 1.0 = identical
|
||||
private static double Zncc(byte[] S, int sw, int sx, int sy, byte[] T, int tw, int th) {
|
||||
int n = tw * th;
|
||||
double sumT = 0;
|
||||
for (int i = 0; i < n; i++) sumT += T[i];
|
||||
double meanT = sumT / n;
|
||||
|
||||
double sumS = 0;
|
||||
for (int y = 0; y < th; y++) {
|
||||
int row = (sy + y) * sw + sx;
|
||||
for (int x = 0; x < tw; x++) sumS += S[row + x];
|
||||
}
|
||||
double meanS = sumS / n;
|
||||
|
||||
double num = 0, dS = 0, dT = 0;
|
||||
for (int y = 0; y < th; y++) {
|
||||
int row = (sy + y) * sw + sx;
|
||||
int trow = y * tw;
|
||||
for (int x = 0; x < tw; x++) {
|
||||
double a = S[row + x] - meanS;
|
||||
double b = T[trow + x] - meanT;
|
||||
num += a * b; dS += a * a; dT += b * b;
|
||||
}
|
||||
}
|
||||
double den = Math.Sqrt(dS * dT);
|
||||
if (den < 1e-9) return 0;
|
||||
return num / den;
|
||||
}
|
||||
|
||||
/**
|
||||
// (comment removed: ASCII only)
|
||||
// (comment removed: ASCII only)
|
||||
*/
|
||||
public static string Match(string screenPath, string templatePath, int maxResults, double threshold, int searchX, int searchY, int searchW, int searchH) {
|
||||
var hits = new List<Hit>();
|
||||
using (var screenBmp = new Bitmap(screenPath))
|
||||
using (var tmplBmp = new Bitmap(templatePath)) {
|
||||
int sw, sh, tw, th;
|
||||
var S0 = Gray(screenBmp, out sw, out sh);
|
||||
var T0 = Gray(tmplBmp, out tw, out th);
|
||||
if (tw >= sw || th >= sh) return "[]";
|
||||
|
||||
// Guard: a flat template carries no gradient information, so correlation is
|
||||
// undefined (ZNCC denominator ~ 0). Report it explicitly instead of silently
|
||||
// returning no hits - a uniform crop usually means the wrong region was picked.
|
||||
//
|
||||
// NOTE: build all JSON/string literals with the char constant Q.
|
||||
// Writing a backslash-quote inside a TS template literal collapses it to a bare
|
||||
// quote and breaks the C# compile - this trap bit this file twice.
|
||||
const char Q = '"';
|
||||
var ci0 = System.Globalization.CultureInfo.InvariantCulture;
|
||||
double tSum = 0, tSum2 = 0;
|
||||
for (int i = 0; i < T0.Length; i++) { tSum += T0[i]; tSum2 += (double)T0[i] * T0[i]; }
|
||||
double tMean = tSum / T0.Length;
|
||||
double tVar = (tSum2 / T0.Length) - (tMean * tMean);
|
||||
if (tVar < 4.0) {
|
||||
return "[" + "{" + Q + "__error" + Q + ":" + Q + "template_has_no_contrast" + Q
|
||||
+ "," + Q + "variance" + Q + ":" + tVar.ToString("F2", ci0) + "}" + "]";
|
||||
}
|
||||
|
||||
// Optional search window: crop a sub-image, then add the offset back to hit coords
|
||||
int ox = 0, oy = 0;
|
||||
if (searchW > 0 && searchH > 0) {
|
||||
ox = Math.Max(0, Math.Min(searchX, sw - 1));
|
||||
oy = Math.Max(0, Math.Min(searchY, sh - 1));
|
||||
int w = Math.Min(searchW, sw - ox), h = Math.Min(searchH, sh - oy);
|
||||
var sub = new byte[w * h];
|
||||
for (int y = 0; y < h; y++) Array.Copy(S0, (oy + y) * sw + ox, sub, y * w, w);
|
||||
S0 = sub; sw = w; sh = h;
|
||||
}
|
||||
|
||||
const int F = 4;
|
||||
int sw4, sh4, tw4, th4;
|
||||
var S4 = Downscale(S0, sw, sh, F, out sw4, out sh4);
|
||||
var T4 = Downscale(T0, tw, th, F, out tw4, out th4);
|
||||
|
||||
// (1) coarse scan: collect candidates.
|
||||
// NOTE: the pre-filter must be MUCH looser than the final threshold.
|
||||
// Downsampling lowers the correlation of the true position (typically 0.7-0.85
|
||||
// for a 0.9+ full-res match), so a tight pre-filter discards the right answer
|
||||
// before refinement ever runs (this exact bug made every match return empty).
|
||||
var cands = new List<Hit>();
|
||||
if (tw4 > 0 && th4 > 0 && tw4 <= sw4 && th4 <= sh4) {
|
||||
double preFilter = Math.Max(0.30, threshold - 0.30);
|
||||
double bestCoarse = -1; int bx4 = 0, by4 = 0;
|
||||
for (int y = 0; y <= sh4 - th4; y++) {
|
||||
for (int x = 0; x <= sw4 - tw4; x++) {
|
||||
double sc = Zncc(S4, sw4, x, y, T4, tw4, th4);
|
||||
if (sc > bestCoarse) { bestCoarse = sc; bx4 = x; by4 = y; }
|
||||
if (sc >= preFilter) cands.Add(new Hit { x = x * F, y = y * F, w = tw, h = th, score = sc });
|
||||
}
|
||||
}
|
||||
// Belt and braces: always keep the coarse best, even if it missed the pre-filter
|
||||
if (bestCoarse > 0) {
|
||||
bool seen = false;
|
||||
foreach (var c in cands) { if (Math.Abs(c.x - bx4 * F) < F && Math.Abs(c.y - by4 * F) < F) { seen = true; break; } }
|
||||
if (!seen) cands.Add(new Hit { x = bx4 * F, y = by4 * F, w = tw, h = th, score = bestCoarse });
|
||||
}
|
||||
}
|
||||
cands.Sort((a, b) => b.score.CompareTo(a.score));
|
||||
if (cands.Count > 40) cands.RemoveRange(40, cands.Count - 40);
|
||||
|
||||
// (2) refine at full resolution within a +/-F*2 neighborhood
|
||||
foreach (var c in cands) {
|
||||
double best = -1; int bx = c.x, by = c.y;
|
||||
for (int dy = -F * 2; dy <= F * 2; dy++) {
|
||||
for (int dx = -F * 2; dx <= F * 2; dx++) {
|
||||
int x = c.x + dx, y = c.y + dy;
|
||||
if (x < 0 || y < 0 || x + tw > sw || y + th > sh) continue;
|
||||
double sc = Zncc(S0, sw, x, y, T0, tw, th);
|
||||
if (sc > best) { best = sc; bx = x; by = y; }
|
||||
}
|
||||
}
|
||||
if (best >= threshold) hits.Add(new Hit { x = bx + ox, y = by + oy, w = tw, h = th, score = best });
|
||||
}
|
||||
|
||||
// (3) de-dup: non-maximum suppression (keep best within 8px)
|
||||
hits.Sort((a, b) => b.score.CompareTo(a.score));
|
||||
var final = new List<Hit>();
|
||||
foreach (var h in hits) {
|
||||
bool dup = false;
|
||||
foreach (var f in final) {
|
||||
if (Math.Abs(f.x - h.x) < 8 && Math.Abs(f.y - h.y) < 8) { dup = true; break; }
|
||||
}
|
||||
if (!dup) final.Add(h);
|
||||
if (final.Count >= maxResults) break;
|
||||
}
|
||||
|
||||
var parts = new List<string>();
|
||||
foreach (var h in final) {
|
||||
// Build JSON by concatenation; reuse the shared Q / ci0 declared above.
|
||||
var ci = ci0;
|
||||
parts.Add("{" + Q + "x" + Q + ":" + h.x.ToString(ci) + "," + Q + "y" + Q + ":" + h.y.ToString(ci)
|
||||
+ "," + Q + "width" + Q + ":" + h.w.ToString(ci) + "," + Q + "height" + Q + ":" + h.h.ToString(ci)
|
||||
+ "," + Q + "score" + Q + ":" + h.score.ToString("F4", ci) + "}");
|
||||
}
|
||||
return "[" + String.Join(",", parts.ToArray()) + "]";
|
||||
}
|
||||
}
|
||||
|
||||
// Capture a screen region to a file (avoids extra process round-trips)
|
||||
public static string Capture(string outPath, int x, int y, int w, int h) {
|
||||
using (var bmp = new Bitmap(w, h))
|
||||
using (var g = Graphics.FromImage(bmp)) {
|
||||
g.CopyFromScreen(x, y, 0, 0, new Size(w, h));
|
||||
bmp.Save(outPath, ImageFormat.Png);
|
||||
}
|
||||
return outPath;
|
||||
}
|
||||
}
|
||||
"@ -ReferencedAssemblies System.Drawing
|
||||
`;
|
||||
@@ -0,0 +1,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,11 +1,19 @@
|
||||
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>;
|
||||
|
||||
function toArray<T>(v: T | T[] | null | undefined): T[] {
|
||||
@@ -80,381 +88,11 @@ function extractJson<T>(raw: string): T | null {
|
||||
}
|
||||
|
||||
/** Win32 窗口枚举与矩形读取(供 window.list / window.focus 复用)。 */
|
||||
const WIN32_PRELUDE = `
|
||||
Add-Type @"
|
||||
using System;
|
||||
using System.Text;
|
||||
using System.Runtime.InteropServices;
|
||||
public class NAWin32 {
|
||||
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left, Top, Right, Bottom; }
|
||||
public delegate bool EnumProc(IntPtr hWnd, IntPtr lParam);
|
||||
[DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr p);
|
||||
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);
|
||||
[DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);
|
||||
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetWindowTextW(IntPtr h, StringBuilder s, int n);
|
||||
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);
|
||||
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
|
||||
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
|
||||
[DllImport("user32.dll")] public static extern void mouse_event(uint f, int dx, int dy, uint d, UIntPtr e);
|
||||
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
|
||||
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd);
|
||||
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
|
||||
}
|
||||
"@
|
||||
`;
|
||||
|
||||
/**
|
||||
* 枚举可见顶层窗口 → JSON(**不含 prelude**,供常驻助手与一次性路径复用)。
|
||||
* 注意:本段会被送进常驻进程执行,**严禁出现 exit**(会杀掉助手进程)。
|
||||
*/
|
||||
const LIST_BODY = `
|
||||
$fg = [NAWin32]::GetForegroundWindow()
|
||||
$list = New-Object System.Collections.ArrayList
|
||||
$cb = [NAWin32+EnumProc]{
|
||||
param($h, $l)
|
||||
if (-not [NAWin32]::IsWindowVisible($h)) { return $true }
|
||||
$sb = New-Object System.Text.StringBuilder 512
|
||||
[void][NAWin32]::GetWindowTextW($h, $sb, 512)
|
||||
$title = $sb.ToString()
|
||||
if ([string]::IsNullOrWhiteSpace($title)) { return $true }
|
||||
$r = New-Object NAWin32+RECT
|
||||
if (-not [NAWin32]::GetWindowRect($h, [ref]$r)) { return $true }
|
||||
$w = $r.Right - $r.Left; $ht = $r.Bottom - $r.Top
|
||||
if ($w -le 0 -or $ht -le 0) { return $true }
|
||||
# ⚠️ 变量名不能叫 $pid —— 它是 PowerShell 的自动变量(当前进程 ID),赋值会被忽略,
|
||||
# 导致所有窗口都报成 powershell 进程(真机踩过)。
|
||||
$procId = 0
|
||||
[void][NAWin32]::GetWindowThreadProcessId($h, [ref]$procId)
|
||||
$pn = ''
|
||||
try { $pn = (Get-Process -Id $procId -ErrorAction Stop).ProcessName } catch {}
|
||||
[void]$list.Add([pscustomobject]@{
|
||||
hwnd = ('0x{0:X}' -f $h.ToInt64()); title = $title; process = $pn; pid = $procId
|
||||
x = $r.Left; y = $r.Top; width = $w; height = $ht
|
||||
is_foreground = ($h -eq $fg); is_minimized = [NAWin32]::IsIconic($h)
|
||||
})
|
||||
return $true
|
||||
}
|
||||
[void][NAWin32]::EnumWindows($cb, [IntPtr]::Zero)
|
||||
$list | ConvertTo-Json -Compress
|
||||
`;
|
||||
|
||||
/**
|
||||
* v14:常驻助手预加载段 —— 这些内容**只在助手启动时执行一次**。
|
||||
* 过去每次调用都重新编译 C# / 加载程序集,真机实测占单次耗时的绝大部分
|
||||
* (window.list 1267ms / window.focus 1631ms,其中「起进程+编译」是固定成本)。
|
||||
*/
|
||||
const UIA_ASSEMBLY_PRELUDE = `
|
||||
Add-Type -AssemblyName UIAutomationClient
|
||||
Add-Type -AssemblyName UIAutomationTypes
|
||||
Add-Type -AssemblyName WindowsBase
|
||||
function Get-WinByTitle([string]$re) {
|
||||
$root = [System.Windows.Automation.AutomationElement]::RootElement
|
||||
$cond = New-Object System.Windows.Automation.PropertyCondition(
|
||||
[System.Windows.Automation.AutomationElement]::ControlTypeProperty,
|
||||
[System.Windows.Automation.ControlType]::Window)
|
||||
$wins = $root.FindAll([System.Windows.Automation.TreeScope]::Children, $cond)
|
||||
foreach ($w in $wins) {
|
||||
try {
|
||||
$t = $w.Current.Name
|
||||
if ($t -and $t -match $re) { return $w }
|
||||
} catch {}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
# v14:按子串在给定 scope 内遍历查找(FindAll + 子串过滤)。
|
||||
# UIA 的 PropertyCondition 只能精确匹配,故必须遍历后过滤;遍历规模设上限防超大 UI 树卡死。
|
||||
function Find-UiaByText($scope, [string]$text, [int]$limit, [string]$ct) {
|
||||
$cond = [System.Windows.Automation.Condition]::TrueCondition
|
||||
if ($ct -ne '') {
|
||||
$ctObj = $null
|
||||
try { $ctObj = [System.Windows.Automation.ControlType]::$ct } catch {}
|
||||
if ($ctObj -ne $null) {
|
||||
$cond = New-Object System.Windows.Automation.PropertyCondition(
|
||||
[System.Windows.Automation.AutomationElement]::ControlTypeProperty, $ctObj)
|
||||
}
|
||||
}
|
||||
$found = $scope.FindAll([System.Windows.Automation.TreeScope]::Descendants, $cond)
|
||||
$out = New-Object System.Collections.ArrayList
|
||||
$scanned = 0
|
||||
foreach ($e in $found) {
|
||||
if ($out.Count -ge $limit) { break }
|
||||
$scanned++
|
||||
if ($scanned -gt 20000) { break }
|
||||
try {
|
||||
$nm = $e.Current.Name
|
||||
if ([string]::IsNullOrEmpty($nm)) { continue }
|
||||
if ($nm.IndexOf($text, [System.StringComparison]::OrdinalIgnoreCase) -lt 0) { continue }
|
||||
$r = $e.Current.BoundingRectangle
|
||||
if ($r.Width -le 0 -or $r.Height -le 0) { continue }
|
||||
[void]$out.Add([pscustomobject]@{
|
||||
name = $nm
|
||||
control_type = ($e.Current.ControlType.ProgrammaticName -replace 'ControlType\\.','')
|
||||
automation_id = $e.Current.AutomationId
|
||||
x = [int]($r.Left + $r.Width / 2)
|
||||
y = [int]($r.Top + $r.Height / 2)
|
||||
left = [int]$r.Left; top = [int]$r.Top
|
||||
width = [int]$r.Width; height = [int]$r.Height
|
||||
})
|
||||
} catch {}
|
||||
}
|
||||
return ,$out
|
||||
}
|
||||
`;
|
||||
|
||||
/** 助手统一预加载段(window.list / window.focus / screen.find 共用同一个进程)。 */
|
||||
|
||||
|
||||
/**
|
||||
* OCR 引擎预加载段(v14):截图 / DPI / WinRT 类型与 Await 辅助函数。
|
||||
* 注意:WinRT 类型加载用 try/catch 包住 —— 个别环境缺组件时只应让 OCR 退化,
|
||||
* 不该让整个助手(连带 window.list/focus/UIA)起不来。
|
||||
*/
|
||||
const OCR_PRELUDE = `
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
try {
|
||||
Add-Type -AssemblyName System.Runtime.WindowsRuntime
|
||||
Add-Type -TypeDefinition 'using System.Runtime.InteropServices; public class NADPI { [DllImport("user32.dll")] public static extern bool SetProcessDPIAware(); }'
|
||||
[void][NADPI]::SetProcessDPIAware()
|
||||
$null = [Windows.Media.Ocr.OcrEngine, Windows.Foundation, ContentType=WindowsRuntime]
|
||||
$null = [Windows.Graphics.Imaging.BitmapDecoder, Windows.Foundation, ContentType=WindowsRuntime]
|
||||
$null = [Windows.Storage.Streams.InMemoryRandomAccessStream, Windows.Foundation, ContentType=WindowsRuntime]
|
||||
$null = [Windows.Storage.Streams.DataWriter, Windows.Foundation, ContentType=WindowsRuntime]
|
||||
$null = [Windows.Graphics.Imaging.SoftwareBitmap, Windows.Foundation, ContentType=WindowsRuntime]
|
||||
$script:asTaskGeneric = ([System.WindowsRuntimeSystemExtensions].GetMethods() | Where-Object { $_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation\`1' })[0]
|
||||
function global:Await($WinRtTask, $ResultType) {
|
||||
$asTask = $script:asTaskGeneric.MakeGenericMethod($ResultType)
|
||||
$netTask = $asTask.Invoke($null, @($WinRtTask))
|
||||
$netTask.Wait(-1) | Out-Null
|
||||
$netTask.Result
|
||||
}
|
||||
$script:ocrReady = $true
|
||||
} catch {
|
||||
$script:ocrReady = $false
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
/**
|
||||
* 图像模板匹配预加载段(v15)。
|
||||
*
|
||||
* 动机:UIA 靠控件树、OCR 靠文字 —— **纯图标/无文字的控件**两者都抓不到(审查报告 §5.2 指出的盲区)。
|
||||
* 做法:把模板图与屏幕区域都转灰度,做**零均值归一化互相关(ZNCC)**;
|
||||
* 全分辨率逐像素太慢(3440×1440 屏 × 64×64 模板 ≈ 190 亿次),故**粗到精**:
|
||||
* ① 1/4 分辨率全图扫描(约 8000 万次)取候选
|
||||
* ② 对候选在全分辨率 ±10px 邻域精修
|
||||
* 编译一次常驻(随助手预加载),运行时纯 native 循环。
|
||||
* 亮度和对比度无关(ZNCC 归一化),故对主题/亮度变化不敏感。
|
||||
*/
|
||||
const IMAGE_MATCH_PRELUDE = `
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
# ⚠️ Add-Type 的老坑:-AssemblyName 只把程序集加载进 PowerShell 会话,
|
||||
# 编译 C# 时还必须显式 -ReferencedAssemblies,否则报「命名空间 System.Drawing
|
||||
# 中不存在 Imaging」。真机踩过:编译失败会让整个助手起不来,并伴随误导性的
|
||||
# 「找不到类型 System.Windows.Forms.SystemInformation」二次错误。
|
||||
Add-Type @"
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
public class NAImageMatch {
|
||||
public class Hit { public int x; public int y; public int w; public int h; public double score; }
|
||||
|
||||
private static byte[] Gray(Bitmap src, out int w, out int h) {
|
||||
w = src.Width; h = src.Height;
|
||||
var dst = new byte[w * h];
|
||||
var rect = new Rectangle(0, 0, w, h);
|
||||
Bitmap bmp = src.PixelFormat == PixelFormat.Format24bppRgb ? src : src.Clone(rect, PixelFormat.Format24bppRgb);
|
||||
var data = bmp.LockBits(rect, ImageLockMode.ReadOnly, PixelFormat.Format24bppRgb);
|
||||
try {
|
||||
int stride = data.Stride;
|
||||
var buf = new byte[stride * h];
|
||||
Marshal.Copy(data.Scan0, buf, 0, buf.Length);
|
||||
for (int y = 0; y < h; y++) {
|
||||
int row = y * stride;
|
||||
for (int x = 0; x < w; x++) {
|
||||
int i = row + x * 3;
|
||||
// BGR -> luma (integer approximation of ITU-R BT.601)
|
||||
dst[y * w + x] = (byte)((buf[i + 2] * 77 + buf[i + 1] * 150 + buf[i] * 29) >> 8);
|
||||
}
|
||||
}
|
||||
} finally { bmp.UnlockBits(data); if (bmp != src) bmp.Dispose(); }
|
||||
return dst;
|
||||
}
|
||||
|
||||
// Area-average downscale (coarse search layer)
|
||||
private static byte[] Downscale(byte[] src, int w, int h, int f, out int ow, out int oh) {
|
||||
ow = w / f; oh = h / f;
|
||||
var dst = new byte[ow * oh];
|
||||
for (int y = 0; y < oh; y++) {
|
||||
for (int x = 0; x < ow; x++) {
|
||||
int sum = 0;
|
||||
for (int dy = 0; dy < f; dy++) {
|
||||
int row = (y * f + dy) * w;
|
||||
for (int dx = 0; dx < f; dx++) sum += src[row + x * f + dx];
|
||||
}
|
||||
dst[y * ow + x] = (byte)(sum / (f * f));
|
||||
}
|
||||
}
|
||||
return dst;
|
||||
}
|
||||
|
||||
// Zero-mean normalized cross correlation; 1.0 = identical
|
||||
private static double Zncc(byte[] S, int sw, int sx, int sy, byte[] T, int tw, int th) {
|
||||
int n = tw * th;
|
||||
double sumT = 0;
|
||||
for (int i = 0; i < n; i++) sumT += T[i];
|
||||
double meanT = sumT / n;
|
||||
|
||||
double sumS = 0;
|
||||
for (int y = 0; y < th; y++) {
|
||||
int row = (sy + y) * sw + sx;
|
||||
for (int x = 0; x < tw; x++) sumS += S[row + x];
|
||||
}
|
||||
double meanS = sumS / n;
|
||||
|
||||
double num = 0, dS = 0, dT = 0;
|
||||
for (int y = 0; y < th; y++) {
|
||||
int row = (sy + y) * sw + sx;
|
||||
int trow = y * tw;
|
||||
for (int x = 0; x < tw; x++) {
|
||||
double a = S[row + x] - meanS;
|
||||
double b = T[trow + x] - meanT;
|
||||
num += a * b; dS += a * a; dT += b * b;
|
||||
}
|
||||
}
|
||||
double den = Math.Sqrt(dS * dT);
|
||||
if (den < 1e-9) return 0;
|
||||
return num / den;
|
||||
}
|
||||
|
||||
/**
|
||||
// (comment removed: ASCII only)
|
||||
// (comment removed: ASCII only)
|
||||
*/
|
||||
public static string Match(string screenPath, string templatePath, int maxResults, double threshold, int searchX, int searchY, int searchW, int searchH) {
|
||||
var hits = new List<Hit>();
|
||||
using (var screenBmp = new Bitmap(screenPath))
|
||||
using (var tmplBmp = new Bitmap(templatePath)) {
|
||||
int sw, sh, tw, th;
|
||||
var S0 = Gray(screenBmp, out sw, out sh);
|
||||
var T0 = Gray(tmplBmp, out tw, out th);
|
||||
if (tw >= sw || th >= sh) return "[]";
|
||||
|
||||
// Guard: a flat template carries no gradient information, so correlation is
|
||||
// undefined (ZNCC denominator ~ 0). Report it explicitly instead of silently
|
||||
// returning no hits - a uniform crop usually means the wrong region was picked.
|
||||
//
|
||||
// NOTE: build all JSON/string literals with the char constant Q.
|
||||
// Writing a backslash-quote inside a TS template literal collapses it to a bare
|
||||
// quote and breaks the C# compile - this trap bit this file twice.
|
||||
const char Q = '"';
|
||||
var ci0 = System.Globalization.CultureInfo.InvariantCulture;
|
||||
double tSum = 0, tSum2 = 0;
|
||||
for (int i = 0; i < T0.Length; i++) { tSum += T0[i]; tSum2 += (double)T0[i] * T0[i]; }
|
||||
double tMean = tSum / T0.Length;
|
||||
double tVar = (tSum2 / T0.Length) - (tMean * tMean);
|
||||
if (tVar < 4.0) {
|
||||
return "[" + "{" + Q + "__error" + Q + ":" + Q + "template_has_no_contrast" + Q
|
||||
+ "," + Q + "variance" + Q + ":" + tVar.ToString("F2", ci0) + "}" + "]";
|
||||
}
|
||||
|
||||
// Optional search window: crop a sub-image, then add the offset back to hit coords
|
||||
int ox = 0, oy = 0;
|
||||
if (searchW > 0 && searchH > 0) {
|
||||
ox = Math.Max(0, Math.Min(searchX, sw - 1));
|
||||
oy = Math.Max(0, Math.Min(searchY, sh - 1));
|
||||
int w = Math.Min(searchW, sw - ox), h = Math.Min(searchH, sh - oy);
|
||||
var sub = new byte[w * h];
|
||||
for (int y = 0; y < h; y++) Array.Copy(S0, (oy + y) * sw + ox, sub, y * w, w);
|
||||
S0 = sub; sw = w; sh = h;
|
||||
}
|
||||
|
||||
const int F = 4;
|
||||
int sw4, sh4, tw4, th4;
|
||||
var S4 = Downscale(S0, sw, sh, F, out sw4, out sh4);
|
||||
var T4 = Downscale(T0, tw, th, F, out tw4, out th4);
|
||||
|
||||
// (1) coarse scan: collect candidates.
|
||||
// NOTE: the pre-filter must be MUCH looser than the final threshold.
|
||||
// Downsampling lowers the correlation of the true position (typically 0.7-0.85
|
||||
// for a 0.9+ full-res match), so a tight pre-filter discards the right answer
|
||||
// before refinement ever runs (this exact bug made every match return empty).
|
||||
var cands = new List<Hit>();
|
||||
if (tw4 > 0 && th4 > 0 && tw4 <= sw4 && th4 <= sh4) {
|
||||
double preFilter = Math.Max(0.30, threshold - 0.30);
|
||||
double bestCoarse = -1; int bx4 = 0, by4 = 0;
|
||||
for (int y = 0; y <= sh4 - th4; y++) {
|
||||
for (int x = 0; x <= sw4 - tw4; x++) {
|
||||
double sc = Zncc(S4, sw4, x, y, T4, tw4, th4);
|
||||
if (sc > bestCoarse) { bestCoarse = sc; bx4 = x; by4 = y; }
|
||||
if (sc >= preFilter) cands.Add(new Hit { x = x * F, y = y * F, w = tw, h = th, score = sc });
|
||||
}
|
||||
}
|
||||
// Belt and braces: always keep the coarse best, even if it missed the pre-filter
|
||||
if (bestCoarse > 0) {
|
||||
bool seen = false;
|
||||
foreach (var c in cands) { if (Math.Abs(c.x - bx4 * F) < F && Math.Abs(c.y - by4 * F) < F) { seen = true; break; } }
|
||||
if (!seen) cands.Add(new Hit { x = bx4 * F, y = by4 * F, w = tw, h = th, score = bestCoarse });
|
||||
}
|
||||
}
|
||||
cands.Sort((a, b) => b.score.CompareTo(a.score));
|
||||
if (cands.Count > 40) cands.RemoveRange(40, cands.Count - 40);
|
||||
|
||||
// (2) refine at full resolution within a +/-F*2 neighborhood
|
||||
foreach (var c in cands) {
|
||||
double best = -1; int bx = c.x, by = c.y;
|
||||
for (int dy = -F * 2; dy <= F * 2; dy++) {
|
||||
for (int dx = -F * 2; dx <= F * 2; dx++) {
|
||||
int x = c.x + dx, y = c.y + dy;
|
||||
if (x < 0 || y < 0 || x + tw > sw || y + th > sh) continue;
|
||||
double sc = Zncc(S0, sw, x, y, T0, tw, th);
|
||||
if (sc > best) { best = sc; bx = x; by = y; }
|
||||
}
|
||||
}
|
||||
if (best >= threshold) hits.Add(new Hit { x = bx + ox, y = by + oy, w = tw, h = th, score = best });
|
||||
}
|
||||
|
||||
// (3) de-dup: non-maximum suppression (keep best within 8px)
|
||||
hits.Sort((a, b) => b.score.CompareTo(a.score));
|
||||
var final = new List<Hit>();
|
||||
foreach (var h in hits) {
|
||||
bool dup = false;
|
||||
foreach (var f in final) {
|
||||
if (Math.Abs(f.x - h.x) < 8 && Math.Abs(f.y - h.y) < 8) { dup = true; break; }
|
||||
}
|
||||
if (!dup) final.Add(h);
|
||||
if (final.Count >= maxResults) break;
|
||||
}
|
||||
|
||||
var parts = new List<string>();
|
||||
foreach (var h in final) {
|
||||
// Build JSON by concatenation; reuse the shared Q / ci0 declared above.
|
||||
var ci = ci0;
|
||||
parts.Add("{" + Q + "x" + Q + ":" + h.x.ToString(ci) + "," + Q + "y" + Q + ":" + h.y.ToString(ci)
|
||||
+ "," + Q + "width" + Q + ":" + h.w.ToString(ci) + "," + Q + "height" + Q + ":" + h.h.ToString(ci)
|
||||
+ "," + Q + "score" + Q + ":" + h.score.ToString("F4", ci) + "}");
|
||||
}
|
||||
return "[" + String.Join(",", parts.ToArray()) + "]";
|
||||
}
|
||||
}
|
||||
|
||||
// Capture a screen region to a file (avoids extra process round-trips)
|
||||
public static string Capture(string outPath, int x, int y, int w, int h) {
|
||||
using (var bmp = new Bitmap(w, h))
|
||||
using (var g = Graphics.FromImage(bmp)) {
|
||||
g.CopyFromScreen(x, y, 0, 0, new Size(w, h));
|
||||
bmp.Save(outPath, ImageFormat.Png);
|
||||
}
|
||||
return outPath;
|
||||
}
|
||||
}
|
||||
"@ -ReferencedAssemblies System.Drawing
|
||||
`;
|
||||
|
||||
/**
|
||||
* 助手统一预加载段(window.list / window.focus / screen.find 的 UIA/OCR/图像三引擎共用)。
|
||||
* ⚠️ 必须定义在四段之后(块级作用域),且**四段缺一不可** ——
|
||||
@@ -483,7 +121,17 @@ export function warmupWindowHelper(): void {
|
||||
}, 1500).unref?.();
|
||||
}
|
||||
|
||||
export const WIN_HELPER_PRELUDE = `${WIN32_PRELUDE}
|
||||
/**
|
||||
* 常驻助手的预加载段。
|
||||
*
|
||||
* ⚠️ `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}`;
|
||||
@@ -570,14 +218,18 @@ export async function windowFocus(args: Args): Promise<unknown> {
|
||||
/**
|
||||
* 聚焦脚本主体(不含 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]{
|
||||
@@ -664,11 +316,26 @@ export async function screenFind(args: Args): Promise<unknown> {
|
||||
}
|
||||
const text = (args['text'] as string | undefined) ?? '';
|
||||
const wantImage = (args['method'] as string | undefined) === 'image';
|
||||
if (!wantImage && (typeof text !== 'string' || text.length === 0)) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'text 不能为空(method=image 时用 template 代替)');
|
||||
// v2.0.0 修正:`where` 可单独使用(不传 text)——「找一个 enabled 的按钮」比
|
||||
// 「找一个叫某名字的按钮」更贴近真实意图。此前只在 gui.await 侧放开了,
|
||||
// screen.find 自身的入口校验没同步,导致直连调用(以及 await 的 where-only)
|
||||
// 被这里的"text 不能为空"拦下(真机 2026-10-09 验证时暴露)。
|
||||
const hasWhere = (() => {
|
||||
const w = args['where'];
|
||||
return typeof w === 'object' && w !== null && !Array.isArray(w) && Object.keys(w as object).length > 0;
|
||||
})();
|
||||
if (!wantImage && text.length === 0 && !hasWhere) {
|
||||
throw new CapabilityError(
|
||||
ErrorCodes.PARAM_INVALID,
|
||||
'text 与 where 至少给一个(method=image 时用 template)',
|
||||
{ hint: 'where 仅 UIA 引擎生效,如 {"enabled":true}' },
|
||||
);
|
||||
}
|
||||
const windowTitle = args['window'] as string | undefined;
|
||||
const controlType = args['control_type'] as string | undefined;
|
||||
// v2.0.0:属性谓词。仅 UIA 引擎生效;OCR / 图像模板忽略(无属性可读),
|
||||
// 此时在返回里带 note 说明,避免调用方误以为"按属性过滤过了"。
|
||||
const whereArg = args['where'] as Record<string, unknown> | undefined;
|
||||
const limit = (args['limit'] as number | undefined) ?? 20;
|
||||
const method = (args['method'] as string | undefined) ?? 'auto'; // auto | uia | ocr
|
||||
// v12.2:等待语义 —— 界面常有动画/加载延迟,一次性查找极易假失败。
|
||||
@@ -686,17 +353,39 @@ export async function screenFind(args: Args): Promise<unknown> {
|
||||
// ---------- UIA 引擎 ----------
|
||||
const uiaFind = async (): Promise<Record<string, unknown>[]> => {
|
||||
const ct = controlType ?? '';
|
||||
// ⚠️ 全部来自控制端,一律 psLit(详见文件顶部 windowFocus 处的 P0 说明)
|
||||
const body = `
|
||||
$text = ${JSON.stringify(text)}
|
||||
$text = ${psLit(text)}
|
||||
$limit = ${Number(limit)}
|
||||
$ct = ${JSON.stringify(controlType ?? '')}
|
||||
$winTitle = ${JSON.stringify(windowTitle ?? '')}
|
||||
$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 {
|
||||
($whereJson | ConvertFrom-Json).PSObject.Properties | ForEach-Object { $where[$_.Name] = $_.Value }
|
||||
} catch {
|
||||
$where = $null # JSON 不合法就当没传(业务层已在 Node 侧校验过键名)
|
||||
}
|
||||
if ($where -ne $null -and $where.Count -eq 0) { $where = $null }
|
||||
}
|
||||
$out = New-Object System.Collections.ArrayList
|
||||
if ($winTitle -ne '') {
|
||||
# 指定窗口:直接在该窗口子树内找
|
||||
$w = Get-WinByTitle $winTitle
|
||||
if ($w -eq $null) { Write-Output '[]'; return } # 严禁 exit:会杀掉常驻助手
|
||||
$out = Find-UiaByText $w $text $limit $ct
|
||||
$out = Find-UiaByText $w $text $limit $ct $where
|
||||
} else {
|
||||
# v14 优化:先在前台窗口子树内找(典型只几百个元素,快一个数量级),
|
||||
# 找不到再退回全桌面遍历(原来每次都在全桌面上跑,真机实测 3.1~3.7s)。
|
||||
@@ -704,9 +393,9 @@ if ($winTitle -ne '') {
|
||||
try {
|
||||
$fgEl = [System.Windows.Automation.AutomationElement]::FromHandle([NAWin32]::GetForegroundWindow())
|
||||
} catch {}
|
||||
if ($fgEl -ne $null) { $out = Find-UiaByText $fgEl $text $limit $ct }
|
||||
if ($fgEl -ne $null) { $out = Find-UiaByText $fgEl $text $limit $ct $where }
|
||||
if ($out.Count -eq 0) {
|
||||
$out = Find-UiaByText ([System.Windows.Automation.AutomationElement]::RootElement) $text $limit $ct
|
||||
$out = Find-UiaByText ([System.Windows.Automation.AutomationElement]::RootElement) $text $limit $ct $where
|
||||
}
|
||||
}
|
||||
# ── 输出 ──(遍历与子串过滤已收敛到预加载的 Find-UiaByText,避免逻辑重复)
|
||||
@@ -730,7 +419,8 @@ if ($out.Count -eq 0) { Write-Output '[]' } else { $out | ConvertTo-Json -Compre
|
||||
const shot = join(tmpDir, `match-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.png`);
|
||||
// PowerShell 单引号字面量:不插值(避免路径里的 $ 被展开)、不处理反斜杠转义。
|
||||
// 不能用 JSON.stringify —— 它会产出双引号串,而 PS 双引号会做变量插值且把 \ 当字面两个反斜杠。
|
||||
const q = (v: string): string => `'${v.replace(/'/g, "''")}'`;
|
||||
// (统一走 util/quote.ts 的 psLit,避免各文件各写一份实现而漏改)
|
||||
const q = psLit;
|
||||
|
||||
// 截图区域:显式 region 优先,否则整块虚拟屏
|
||||
const capX = regionArg ? regionArg.x : 'VSX';
|
||||
@@ -801,7 +491,7 @@ $xv = ${regionArg ? regionArg.x : -1}
|
||||
$yv = ${regionArg ? regionArg.y : -1}
|
||||
$wv = ${regionArg ? regionArg.width : -1}
|
||||
$hv = ${regionArg ? regionArg.height : -1}
|
||||
$winRe = ${JSON.stringify(regionArg ? '' : windowTitle ?? '')}
|
||||
$winRe = ${psLit(regionArg ? '' : windowTitle ?? '')}
|
||||
$x = 0; $y = 0
|
||||
$vs = [System.Windows.Forms.SystemInformation]::VirtualScreen
|
||||
$w = $vs.Width; $h = $vs.Height
|
||||
@@ -861,11 +551,11 @@ $engine = [Windows.Media.Ocr.OcrEngine]::TryCreateFromUserProfileLanguages()
|
||||
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) {
|
||||
@@ -954,10 +644,22 @@ $out | ConvertTo-Json -Compress
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
last = await attempt();
|
||||
}
|
||||
// v2.0.0:where 只在 UIA 引擎下有属性可读 —— 若最终走的是 OCR/image,
|
||||
// 必须**明确告知**,否则调用方会以为"按属性过滤过了"而误判结果。
|
||||
const whereIgnored = hasWhere && last.engine !== 'uia';
|
||||
return {
|
||||
matches: last.matches,
|
||||
engine: last.engine,
|
||||
waited_ms: Date.now() - startedAt,
|
||||
...(waitMs > 0 ? { wait_ms: waitMs } : {}),
|
||||
...(whereIgnored
|
||||
? {
|
||||
note:
|
||||
`where 已忽略:实际引擎为 ${last.engine}(无控件属性可读)。` +
|
||||
'属性过滤仅在 UIA 生效;text 过滤仍然有效。',
|
||||
where_applied: false,
|
||||
}
|
||||
: {}),
|
||||
...(hasWhere && last.engine === 'uia' ? { where_applied: true } : {}),
|
||||
};
|
||||
}
|
||||
@@ -13,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');
|
||||
@@ -23,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',
|
||||
});
|
||||
|
||||
@@ -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:是否允许输入控制(鼠标 / 键盘)。
|
||||
@@ -32,6 +41,19 @@ export interface AgentConfig {
|
||||
* "allow_from": ["192.168.0.0/16", "10.0.0.0/8"]
|
||||
*/
|
||||
allow_from?: string[];
|
||||
|
||||
/**
|
||||
* v2.0.0 连接层防护(三项,默认值见 server.ts)。
|
||||
* 单项缺省即启用默认策略;显式设 0 可关闭对应项。
|
||||
*/
|
||||
security?: {
|
||||
/** 握手失败封禁:同来源连续失败 max_attempts 次起,ban = ban_ms × 2^(N-max),封顶 24h */
|
||||
auth_ban?: { max_attempts?: number; ban_ms?: number; whitelist?: string[] };
|
||||
/** 最大并发连接数(默认 8) */
|
||||
max_connections?: number;
|
||||
/** 空闲断开(默认 30 分钟;0 = 关闭) */
|
||||
idle_timeout_ms?: number;
|
||||
};
|
||||
/** v3+:审计日志配置 */
|
||||
audit?: {
|
||||
/** 是否启用(默认 true) */
|
||||
@@ -53,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:文件访问白名单(根目录列表)。
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 };
|
||||
@@ -10,20 +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;
|
||||
input_enabled?: boolean;
|
||||
/**
|
||||
* v21:TLS 证书指纹(sha256 hex)。
|
||||
* ⚠️ 这是**未认证**广播,可被伪造 —— 只用于「看起来像不像」,不能作为身份凭据;
|
||||
* 真正的身份确认发生在握手后(auth_ok 里带指纹,且控制端会严格比对)。
|
||||
*/
|
||||
cert_sha256?: string | null;
|
||||
platform: string;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -34,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 {
|
||||
@@ -63,16 +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,
|
||||
cert_sha256: currentCertFingerprint(),
|
||||
...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}`);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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>;
|
||||
|
||||
@@ -30,6 +32,8 @@ interface WatchRecord {
|
||||
id: string;
|
||||
kind: 'file' | 'process' | 'net';
|
||||
owner: string; // 连接标识,断开时清理
|
||||
/** 创建者的 client_id —— 跨控制端隔离的依据(E-01/E-02) */
|
||||
clientId: string;
|
||||
description: string;
|
||||
createdAt: number;
|
||||
count: number;
|
||||
@@ -186,10 +190,23 @@ function startNetWatch(rec: WatchRecord, intervalMs: number): () => void {
|
||||
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)) {
|
||||
@@ -202,6 +219,7 @@ export async function eventWatch(args: Args, ctx: WatchContext): Promise<unknown
|
||||
id,
|
||||
kind,
|
||||
owner: ctx.owner,
|
||||
clientId: ctx.clientId ?? '',
|
||||
description: '',
|
||||
createdAt: Date.now(),
|
||||
count: 0,
|
||||
@@ -210,8 +228,12 @@ export async function eventWatch(args: Args, ctx: WatchContext): Promise<unknown
|
||||
};
|
||||
|
||||
if (kind === 'file') {
|
||||
const path = args['path'] as string | undefined;
|
||||
if (!path) throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'file 类型需要 path');
|
||||
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}` : ''}`;
|
||||
@@ -227,18 +249,27 @@ export async function eventWatch(args: Args, ctx: WatchContext): Promise<unknown
|
||||
return { watch_id: id, kind, description: rec.description };
|
||||
}
|
||||
|
||||
export async function eventUnwatch(args: Args): Promise<unknown> {
|
||||
/** 取消订阅。**只有创建者(同 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): Promise<unknown> {
|
||||
/** 列出订阅:**只列自己的**(含匿名创建的)。 */
|
||||
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: [...watches.values()].map((w) => ({
|
||||
watches: mine.map((w) => ({
|
||||
watch_id: w.id,
|
||||
kind: w.kind,
|
||||
description: w.description,
|
||||
@@ -249,18 +280,39 @@ export async function eventList(_args: Args): Promise<unknown> {
|
||||
};
|
||||
}
|
||||
|
||||
export async function eventPoll(args: Args): Promise<unknown> {
|
||||
/** 拉取事件:只回「属于本 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);
|
||||
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) {
|
||||
|
||||
@@ -1,12 +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[] = [];
|
||||
@@ -18,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({
|
||||
@@ -28,7 +168,7 @@ 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;
|
||||
@@ -61,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',
|
||||
@@ -73,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(' ─────────────────────────────────────────────');
|
||||
@@ -88,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} 心跳广播)` : '已关闭'}`,
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,9 @@ import {
|
||||
import { buildInfo } from './capabilities/system.js';
|
||||
import { createCapabilityRegistry } from './capabilities/index.js';
|
||||
import { audit, describeArgs } from './audit.js';
|
||||
import { disposeWatchesByOwner } from './events.js';
|
||||
import { disposeWatchesByOwner, watchCount } from './events.js';
|
||||
import { runningTaskCount } from './capabilities/task.js';
|
||||
import { createRateLimiter } from './util/rate-limit.js';
|
||||
import type { AgentConfig } from './config.js';
|
||||
import type { TlsMaterial } from './certs.js';
|
||||
|
||||
@@ -47,6 +49,8 @@ interface ConnState {
|
||||
remote: string;
|
||||
/** v12:连接标识,用于清理该连接创建的事件订阅 */
|
||||
connId: string;
|
||||
/** v2.0.0:末次活跃时间(任何入站消息),空闲断开的依据 */
|
||||
lastActiveAt: number;
|
||||
}
|
||||
|
||||
export interface AgentServer {
|
||||
@@ -61,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]) {
|
||||
@@ -93,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));
|
||||
@@ -105,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;
|
||||
@@ -122,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(
|
||||
@@ -156,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({
|
||||
@@ -195,11 +274,17 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
|
||||
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 绑定身份。
|
||||
@@ -227,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 规则时生效)
|
||||
@@ -269,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',
|
||||
@@ -290,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;
|
||||
}
|
||||
@@ -297,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;
|
||||
}
|
||||
@@ -308,6 +414,7 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
// 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;
|
||||
@@ -354,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;
|
||||
}
|
||||
@@ -376,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;
|
||||
}
|
||||
@@ -385,10 +506,60 @@ 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)}`;
|
||||
@@ -400,20 +571,50 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
authorized: null,
|
||||
remote,
|
||||
connId,
|
||||
lastActiveAt: Date.now(),
|
||||
});
|
||||
// v2.0.0 修正:**客户端对 ping 的 pong 回应也算活跃**。
|
||||
// 之前只更新 message 的活跃时间 → 僵尸连接(TCP 未断但客户端已死)不会被判空闲,
|
||||
// 会被永久算进 states.size → 并发上限虚高 → **可能把正常控制端挡在外面**。
|
||||
ws.on('pong', () => {
|
||||
const st = states.get(ws);
|
||||
if (st) st.lastActiveAt = Date.now();
|
||||
});
|
||||
ws.on('message', (raw: RawData) => {
|
||||
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('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 服务。 */
|
||||
@@ -433,6 +634,11 @@ export function createAgentServer(cfg: AgentConfig, tls: TlsMaterial | null): Pr
|
||||
: createHttpServer();
|
||||
const wss = new WebSocketServer({ server: httpServer });
|
||||
|
||||
// v23②:连接数上限(判定放在连接入口,见下方 wss.on('connection'))
|
||||
const maxConnections = Math.max(1, cfg.security?.max_connections ?? 8);
|
||||
// v23③:空闲断开阈值(0 = 关闭)
|
||||
const idleTimeoutMs = Math.max(0, cfg.security?.idle_timeout_ms ?? 1_800_000);
|
||||
|
||||
// v21 第一批加固:来源网段白名单 —— **在业务握手之前**就断开。
|
||||
// 放在这里而不是等认证失败,是因为拒绝得更早、开销更低(不必做 TLS/挑战应答)。
|
||||
wss.on('connection', (ws, req) => {
|
||||
@@ -443,13 +649,53 @@ export function createAgentServer(cfg: AgentConfig, tls: TlsMaterial | null): Pr
|
||||
ws.close(1008, 'source not allowed');
|
||||
return;
|
||||
}
|
||||
// v23①:封禁中的来源直接拒(在 TLS 之后、业务握手之前)
|
||||
const bannedLeft = core.banRemainingMs(remote);
|
||||
if (bannedLeft > 0) {
|
||||
log('warn', `连接被拒绝:来源处于封禁期(remote=${remote},剩余 ${Math.ceil(bannedLeft / 1000)}s)`);
|
||||
audit({ type: 'net.deny', remote, reason: `auth banned, ${bannedLeft}ms left` });
|
||||
ws.close(1008, `too many failed attempts, retry in ${Math.ceil(bannedLeft / 1000)}s`);
|
||||
return;
|
||||
}
|
||||
// v23②:连接数上限
|
||||
if (core.states.size >= maxConnections) {
|
||||
log('warn', `连接被拒绝:已达并发上限 ${maxConnections}(current=${wss.clients.size})`);
|
||||
audit({ type: 'net.deny', remote, reason: `max connections reached: ${maxConnections}` });
|
||||
ws.close(1013, 'too many connections');
|
||||
return;
|
||||
}
|
||||
core.attach(ws, remote);
|
||||
});
|
||||
|
||||
// WebSocket 层保活:30s ping,探测死连接
|
||||
// WebSocket 层保活:30s ping,探测死连接;v23③ 同时做空闲连接清扫
|
||||
const heartbeat = setInterval(() => {
|
||||
for (const ws of wss.clients) {
|
||||
if (ws.readyState === WebSocket.OPEN) ws.ping();
|
||||
if (ws.readyState !== WebSocket.OPEN) continue;
|
||||
// v2.0.0 修正(真机踩到):**pong 缺失必须主动 terminate**。
|
||||
// ws 库默认不会因「ping 无 pong」而断开 —— 客户端进程已死(TCP 未发 FIN)时,
|
||||
// 连接会一直挂在 states 里,占用 max_connections 名额。
|
||||
// 判据用与空闲清扫同一个 lastActiveAt(pong 已在上方更新它)。
|
||||
const st = core.states.get(ws);
|
||||
if (st && idleTimeoutMs > 0 && Date.now() - st.lastActiveAt > idleTimeoutMs * 2) {
|
||||
log('warn', `连接无响应(ping 未回 pong 且静默超 ${Math.round((Date.now() - st.lastActiveAt) / 1000)}s)→ 强制断开 ${st.connId}`);
|
||||
audit({ type: 'session.idle_close', client_id: st.clientId ?? undefined, remote: st.remote, reason: 'unresponsive (no pong)' });
|
||||
ws.terminate();
|
||||
continue;
|
||||
}
|
||||
ws.ping();
|
||||
}
|
||||
if (idleTimeoutMs <= 0) return;
|
||||
// 保守策略:只要还有任务在跑 / 还有事件订阅,就不断任何连接 ——
|
||||
// 宁可少断,也不能把"挂着长任务等结果"的控制端踢下线。
|
||||
if (runningTaskCount() > 0 || watchCount() > 0) return;
|
||||
const now = Date.now();
|
||||
for (const [ws, st] of core.states) {
|
||||
if (ws.readyState !== WebSocket.OPEN) continue;
|
||||
const idleFor = now - st.lastActiveAt;
|
||||
if (idleFor < idleTimeoutMs) continue;
|
||||
log('info', `空闲断开:连接 ${st.connId}(${st.remote})已静默 ${Math.round(idleFor / 1000)}s`);
|
||||
audit({ type: 'session.idle_close', client_id: st.clientId ?? undefined, remote: st.remote, reason: `idle ${idleFor}ms` });
|
||||
ws.close(1000, 'idle timeout');
|
||||
}
|
||||
}, 30_000);
|
||||
heartbeat.unref();
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -4,6 +4,7 @@ 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)。
|
||||
@@ -438,8 +439,18 @@ export async function runPowerShellSmart(opts: RunPsOptions): Promise<{ stdout:
|
||||
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 ${JSON.stringify(scriptPath)}`,
|
||||
command: `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ${scriptPath}`,
|
||||
timeoutMs,
|
||||
});
|
||||
if (r.exit_code !== 0) {
|
||||
|
||||
@@ -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": "1.3.0",
|
||||
"version": "2.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -25,8 +25,9 @@ import {
|
||||
} from '@nodeagent/client';
|
||||
import {
|
||||
CapabilityNames,
|
||||
matchPattern,
|
||||
DEFAULT_DISCOVERY_PORT,
|
||||
generateSharedKey,
|
||||
matchPattern,
|
||||
type CapabilityDescriptor,
|
||||
type InvokeResult,
|
||||
} from '@nodeagent/protocol';
|
||||
@@ -56,7 +57,9 @@ export async function cmdConnect(host: string, opts: Options): Promise<void> {
|
||||
const profile: NodeProfile = {
|
||||
host,
|
||||
port,
|
||||
tls: !process.argv.includes('--no-tls'),
|
||||
// 原来这里直接扫 `process.argv.includes('--no-tls')` —— 绕过了解析器,
|
||||
// 于是「帮助里承诺的选项」与「解析器认识的选项」是两套(L-01 同类)。
|
||||
tls: !opts.noTls,
|
||||
insecure: opts.insecure,
|
||||
key: opts.key ?? '',
|
||||
auth_mode: authMode,
|
||||
@@ -176,6 +179,31 @@ export async function cmdRemove(name: string | undefined, opts: Options): Promis
|
||||
|
||||
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}`);
|
||||
@@ -206,7 +234,7 @@ export async function cmdDiscover(opts: Options): Promise<void> {
|
||||
|
||||
let nodes: Awaited<ReturnType<typeof discoverOnce>>;
|
||||
try {
|
||||
nodes = await discoverOnce(waitMs, { port });
|
||||
nodes = await discoverOnce(waitMs, { port, secret: opts.discoverSecret });
|
||||
} catch (err) {
|
||||
fail(`监听失败:${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
@@ -222,11 +250,21 @@ export async function cmdDiscover(opts: Options): Promise<void> {
|
||||
if (opts.json) return printJson(nodes);
|
||||
|
||||
console.log(`发现 ${nodes.length} 台被控端:\n`);
|
||||
console.log(` ${'节点 ID'.padEnd(16)} ${'地址'.padEnd(20)} ${'协议'.padEnd(6)} ${'认证'.padEnd(8)} 平台`);
|
||||
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)} ${n.platform}${ctl}`,
|
||||
` ${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]!;
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from '@nodeagent/client';
|
||||
import {
|
||||
CapabilityNames,
|
||||
HOTKEY_PRESETS,
|
||||
matchPattern,
|
||||
DEFAULT_DISCOVERY_PORT,
|
||||
type CapabilityDescriptor,
|
||||
@@ -61,7 +62,15 @@ export async function cmdScreen(opts: Options): Promise<void> {
|
||||
export async function cmdScreenshot(opts: Options): Promise<void> {
|
||||
const args: Record<string, unknown> = {};
|
||||
if (opts.format) args['format'] = opts.format;
|
||||
if (opts.scale) args['scale'] = Number(opts.scale);
|
||||
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');
|
||||
@@ -163,19 +172,57 @@ export async function cmdKey(action: string | undefined, positionals: string[],
|
||||
return;
|
||||
}
|
||||
case 'press': {
|
||||
if (positionals.length === 0) fail('用法: nodeagent key press <键1> [键2] ...(如 ctrl c)');
|
||||
// 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, { keys: positionals }, opts.json, (d) => {
|
||||
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 };
|
||||
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('+')
|
||||
: (r.chords ?? []).map((ch) => ch.join('+')).join(' → ') || positionals.join('+');
|
||||
: shown || positionalsEcho.join('+');
|
||||
const times = r.times && r.times > 1 ? `(重复 ${r.times} 次)` : '';
|
||||
console.log(`✓ 已按下 ${label}${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;
|
||||
@@ -185,6 +232,64 @@ export async function cmdKey(action: string | undefined, positionals: string[],
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- v1.6.0 等待条件(gui.await) ----------
|
||||
|
||||
/**
|
||||
* 等待一个条件成立再返回(只读低危)。
|
||||
*
|
||||
* nodeagent await --condition control --text "完成" --timeout 30000
|
||||
* nodeagent await --condition window --title "安装程序"
|
||||
* nodeagent await --condition process --process setup.exe
|
||||
* nodeagent await --condition file --path "C:\\log.txt" --state absent
|
||||
*
|
||||
* 四类条件各自复用 window.list / screen.find / process.list / fs.stat,
|
||||
* 平台行为与那些能力一致。超时是**正常返回**(satisfied:false + note),不抛异常。
|
||||
*/
|
||||
export async function cmdAwait(opts: Options): Promise<void> {
|
||||
const condition = opts.condition as string | undefined;
|
||||
if (!condition) {
|
||||
fail('用法: nodeagent await --condition <window|control|process|file> --timeout <ms> [--text/--title/--process/--path] [--state absent]');
|
||||
}
|
||||
const args: Record<string, unknown> = { condition };
|
||||
// v2.0.0:where / any_of 是 JSON 字符串(属性谓词与组合条件不便用命令行开关表达)
|
||||
for (const k of ['where', 'any_of']) {
|
||||
const raw = (opts as unknown as Record<string, string | undefined>)[k];
|
||||
if (raw !== undefined) {
|
||||
try {
|
||||
args[k] = JSON.parse(raw);
|
||||
} catch {
|
||||
fail(`--${k} 不是合法 JSON: ${raw.slice(0, 60)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (opts.text !== undefined) args['text'] = opts.text;
|
||||
if (opts.title !== undefined) args['title'] = opts.title;
|
||||
if (opts.process !== undefined) args['process'] = opts.process;
|
||||
if (opts.path !== undefined) args['path'] = opts.path;
|
||||
if (opts.state !== undefined) args['state'] = opts.state;
|
||||
if (opts.timeoutMs !== undefined) args['timeout_ms'] = Number(opts.timeoutMs);
|
||||
if (opts.interval !== undefined) args['interval_ms'] = Number(opts.interval);
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.GuiAwait, args, opts.json, (d) => {
|
||||
const r = d as {
|
||||
satisfied: boolean; condition: string; state: string;
|
||||
elapsed_ms: number; attempts: number; last_seen: string; last_error?: string; note?: string;
|
||||
};
|
||||
const mark = r.satisfied ? '✓' : '✗';
|
||||
const label = r.condition === 'control' ? String(args['text'] ?? '')
|
||||
: r.condition === 'window' ? String(args['title'] ?? '')
|
||||
: r.condition === 'process' ? String(args['process'] ?? '')
|
||||
: String(args['path'] ?? '');
|
||||
console.log(
|
||||
`${mark} ${r.state === 'absent' ? '已消失' : '已出现'}: ${r.condition} ${label}` +
|
||||
`(${r.elapsed_ms}ms,${r.attempts} 次轮询,末次=${r.last_seen})`,
|
||||
);
|
||||
if (r.note) console.log(` ℹ️ ${r.note}`);
|
||||
if (r.last_error) console.log(` ⚠️ 期间末次异常:${r.last_error.slice(0, 120)}`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- v3+ 审计 ----------
|
||||
|
||||
/** v11:审计链完整性校验。 */
|
||||
@@ -273,7 +273,10 @@ export async function cmdDeploy(localFile: string, opts: Options): Promise<void>
|
||||
const uploadInfo = await withClient(async (c) => {
|
||||
// 备份走协议自带的 fs 能力(而非 shell 的 Copy-Item)——
|
||||
// Copy-Item 是 Windows 专用,在 macOS 被控端上会静默失败(真机踩过:以为备份了,其实没有)。
|
||||
const backup = `${target}.bak-${Date.now()}`;
|
||||
// 备份用**固定名**(覆盖式):此前用 `.bak-<时间戳>`,每部署一次就多一个文件 ——
|
||||
// 真机 2026-10-09 发现 D:\Nodeagent 下堆了 15+ 个 bak(单文件 1MB,纯浪费)。
|
||||
// 部署回滚只需要「上一个可用版本」,一份就够,故改为覆盖写。
|
||||
const backup = `${target}.bak`;
|
||||
let backupPath = '';
|
||||
try {
|
||||
const st = await c.invoke<{ size?: number; exists?: boolean }>(CapabilityNames.FsStat, { path: target });
|
||||
@@ -329,6 +332,23 @@ export async function cmdDeploy(localFile: string, opts: Options): Promise<void>
|
||||
offset += part.length;
|
||||
}
|
||||
console.log(`✓ 已上传 ${(buf.length / 1024).toFixed(0)} KB`);
|
||||
|
||||
// best-effort 清理历史的时间戳备份(老格式 `.bak-<ts>`)。
|
||||
// 平台分流:Windows 用 PS,类 Unix 用 rm。清不掉不影响部署,只提示。
|
||||
try {
|
||||
const isWin = String((info as { os?: string }).os ?? '').toLowerCase().startsWith('win');
|
||||
const cleanup = isWin
|
||||
? `Get-ChildItem '${target}.bak-*' -EA SilentlyContinue | Remove-Item -Force -EA SilentlyContinue; (Get-ChildItem '${target}.bak-*' -EA SilentlyContinue | Measure-Object).Count`
|
||||
: `rm -f '${target}.bak-'* 2>/dev/null; ls '${target}.bak-'* 2>/dev/null | wc -l`;
|
||||
const r = await c.invoke<{ stdout?: string }>(CapabilityNames.ShellExec, { command: cleanup, timeout_ms: 15_000 });
|
||||
const left = Number((r.data?.stdout ?? '').trim());
|
||||
if (r.status === 'ok' && Number.isFinite(left)) {
|
||||
if (left === 0) console.log('✓ 已清理历史时间戳备份(老格式)');
|
||||
else console.log(`⚠️ 仍有 ${left} 个历史备份未清理(可手动删除)`);
|
||||
}
|
||||
} catch {
|
||||
/* 清理是附加项,失败不影响部署结果 */
|
||||
}
|
||||
return { backupPath };
|
||||
});
|
||||
|
||||
@@ -375,7 +395,7 @@ export async function cmdDeploy(localFile: string, opts: Options): Promise<void>
|
||||
console.error(` 回滚方式:把备份复制回目标即可 ——`);
|
||||
console.error(` nodeagent exec "Copy-Item '${uploadInfo.backupPath}' '${target}' -Force"`);
|
||||
}
|
||||
console.error(' 排查:被控端杀软(主流杀软/杀软)可能处置了被覆盖的 agent 文件');
|
||||
console.error(' 排查:被控端杀软可能处置了被覆盖的 agent 文件');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -620,3 +640,115 @@ export async function cmdClip(opts: Options): Promise<void> {
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- v2.0.0 状态采样(monitor / log) ----------
|
||||
|
||||
/**
|
||||
* 状态采样与回看。
|
||||
*
|
||||
* nodeagent monitor start --source port --target 127.0.0.1:8765 --interval 2000
|
||||
* nodeagent monitor report <id>
|
||||
* nodeagent monitor stop <id>
|
||||
* nodeagent monitor list
|
||||
* nodeagent monitor delete <id>
|
||||
*/
|
||||
export async function cmdMonitor(action: string | undefined, positional: string[], opts: Options): Promise<void> {
|
||||
switch (action) {
|
||||
case 'start': {
|
||||
const source = opts.source;
|
||||
if (!source) fail('用法: nodeagent monitor start --source <port|process|command|metric> [--target X] [--interval ms] [--id name]');
|
||||
const args: Record<string, unknown> = { source };
|
||||
if (opts.target !== undefined) args['target'] = opts.target;
|
||||
if (opts.interval !== undefined) args['interval_ms'] = Number(opts.interval);
|
||||
if (positional[0]) args['id'] = positional[0];
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.MonitorStart, args, opts.json, (d) => {
|
||||
const r = d as { id: string; source: string; target: string; interval_ms: number; file: string };
|
||||
console.log(`✓ 已启动监控 ${r.id}(${r.source}${r.target ? ' → ' + r.target : ''},每 ${r.interval_ms}ms 一针)`);
|
||||
console.log(` 样本文件:${r.file}`);
|
||||
console.log(` 回看:nodeagent monitor report ${r.id}`);
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
case 'report': {
|
||||
const id = positional[0];
|
||||
if (!id) fail('用法: nodeagent monitor report <id> [--limit n]');
|
||||
const args: Record<string, unknown> = { id };
|
||||
if (opts.limit !== undefined) args['limit'] = Number(opts.limit);
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.MonitorReport, args, opts.json, (d) => {
|
||||
const r = d as {
|
||||
running: boolean; source: string; target: string; samples: number;
|
||||
summary: Record<string, number>;
|
||||
};
|
||||
console.log(`${r.running ? '● 运行中' : '○ 已停止'} ${r.source}${r.target ? ' → ' + r.target : ''},共 ${r.samples} 个样本`);
|
||||
// 摘要直接说人话(这是本能力的主要价值)
|
||||
const sm = r.summary;
|
||||
if (sm.down_count !== undefined) {
|
||||
console.log(` 连通:${sm.up_count} 通 / ${sm.down_count} 断;最长连续中断 ${sm.longest_outage_samples} 针` +
|
||||
(sm.ms_avg !== undefined ? `;延迟 min/avg/max = ${sm.ms_min}/${sm.ms_avg}/${sm.ms_max}ms` : ''));
|
||||
}
|
||||
if (sm.dead_count !== undefined) console.log(` 存活:${sm.alive_count} 在 / ${sm.dead_count} 不在;最长连续缺失 ${sm.longest_dead_samples} 针`);
|
||||
if (sm.fail_count !== undefined) console.log(` 命令:${sm.ok_count} 成功 / ${sm.fail_count} 失败;退出码 ${JSON.stringify(sm.exit_codes_seen)}`);
|
||||
if (sm.cpu_avg !== undefined) console.log(` CPU min/avg/max = ${sm.cpu_min}/${sm.cpu_avg}/${sm.cpu_max}%;内存 avg ${sm.mem_avg}%`);
|
||||
if (sm.window_ms !== undefined) console.log(` 时间窗:${(sm.window_ms / 1000).toFixed(1)}s`);
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
case 'stop': {
|
||||
const id = positional[0];
|
||||
if (!id) fail('用法: nodeagent monitor stop <id>');
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.MonitorStop, { id }, opts.json, (d) => {
|
||||
const r = d as { id: string; samples: number; file: string };
|
||||
console.log(`✓ 已停止 ${r.id}(共 ${r.samples} 个样本);样本留在 ${r.file}`);
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
case 'list': {
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.MonitorList, {}, opts.json, (d) => {
|
||||
const r = d as { running: Array<{ id: string; source: string; target: string; samples: number }>; running_count: number; history_files: string[] };
|
||||
console.log(`运行中 ${r.running_count} 个:`);
|
||||
for (const m of r.running) console.log(` ● ${m.id}(${m.source}${m.target ? ' → ' + m.target : ''},${m.samples} 样本)`);
|
||||
if (r.history_files.length) console.log(`历史文件:${r.history_files.join(', ')}`);
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
case 'delete': {
|
||||
const id = positional[0];
|
||||
if (!id) fail('用法: nodeagent monitor delete <id>(运行中需先 stop)');
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.MonitorDelete, { id }, opts.json, () => console.log(`✓ 已删除 ${id} 的历史样本`)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
fail('用法: nodeagent monitor <start|report|stop|list|delete> ...');
|
||||
}
|
||||
}
|
||||
|
||||
/** 在被控端侧过滤日志(v2.0.0):日志常几万行,过滤必须发生在被控端。 */
|
||||
export async function cmdLog(opts: Options): Promise<void> {
|
||||
const logPath = opts.path;
|
||||
if (!logPath) fail('用法: nodeagent log --path <文件> [--pattern 正则] [--level ERROR] [--limit n] [--tail]');
|
||||
const args: Record<string, unknown> = { path: logPath };
|
||||
if (opts.pattern !== undefined) args['pattern'] = opts.pattern;
|
||||
if (opts.level !== undefined) args['level'] = opts.level;
|
||||
if (opts.since !== undefined) args['since'] = Number(opts.since);
|
||||
if (opts.limit !== undefined) args['limit'] = Number(opts.limit);
|
||||
if (opts.offset !== undefined) args['offset'] = Number(opts.offset);
|
||||
if (opts.tail === true) args['tail'] = true;
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.LogQuery, args, opts.json, (d) => {
|
||||
const r = d as { matched: Array<{ n: number; text: string }>; scanned: number; truncated: boolean; note?: string };
|
||||
console.log(`✓ 扫描 ${r.scanned} 行,命中 ${r.matched.length} 条${r.truncated ? '(结果被截断)' : ''}`);
|
||||
for (const m of r.matched) console.log(` ${String(m.n).padStart(6)} ${m.text.slice(0, 160)}`);
|
||||
if (r.note) console.log(` ℹ️ ${r.note}`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -183,6 +183,99 @@ export async function cmdRestart(opts: Options): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- v2.0.0 审计链外部锚定 ----------
|
||||
|
||||
/**
|
||||
* 打印审计链头(可选与最近锚点比对)。
|
||||
*
|
||||
* nodeagent audit head [--compare]
|
||||
*/
|
||||
export async function cmdAuditHead(opts: Options): Promise<void> {
|
||||
const args: Record<string, unknown> = {};
|
||||
if (opts.compare === true) args['compare'] = true;
|
||||
if (opts.anchorPath !== undefined) args['anchor_path'] = opts.anchorPath;
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.AuditHead, args, opts.json, (data) => {
|
||||
const d = data as {
|
||||
entries: number; head_hash: string | null; head_ts: number | null;
|
||||
rotated_segments: number; file_bytes: number;
|
||||
comparison?: { ok: boolean; anchors_checked: number; verdict: string };
|
||||
};
|
||||
console.log(`审计链头:${d.entries} 条,轮转 ${d.rotated_segments} 段,${(d.file_bytes / 1024).toFixed(0)} KB`);
|
||||
console.log(` head_hash : ${d.head_hash ?? '(空链)'}`);
|
||||
console.log(` head_ts : ${d.head_ts ? new Date(d.head_ts).toLocaleString() : '-'}`);
|
||||
console.log(' 💡 把 head_hash 连同 entries 存到链外(另一台机器/网盘)—— 这是外部锚定');
|
||||
if (d.comparison) {
|
||||
const k = d.comparison.ok ? '✓' : '✗';
|
||||
console.log(` ${k} 锚点比对(${d.comparison.anchors_checked} 条锚点):${d.comparison.verdict}`);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 追加一个锚点到锚点文件。
|
||||
*
|
||||
* nodeagent audit anchor [--anchor-path <文件>] [--note "每日例行"]
|
||||
*/
|
||||
export async function cmdAuditAnchor(opts: Options): Promise<void> {
|
||||
const args: Record<string, unknown> = {};
|
||||
if (opts.anchorPath !== undefined) args['path'] = opts.anchorPath;
|
||||
if (opts.note !== undefined) args['note'] = opts.note;
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.AuditAnchor, args, opts.json, (data) => {
|
||||
const d = data as { file: string; total_lines: number; record: { entries: number; head_hash: string | null } };
|
||||
console.log(`✓ 已锚定:${d.record.entries} 条,链头 ${String(d.record.head_hash).slice(0, 16)}…`);
|
||||
console.log(` 锚点文件:${d.file}(累计 ${d.total_lines} 条锚点)`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) => {
|
||||
|
||||
@@ -14,6 +14,11 @@ export const HELP = `nodeagent —— 跨机 AI 接管框架(控制端 CLI)
|
||||
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] 设置静音 / 音量
|
||||
@@ -48,6 +53,13 @@ export const HELP = `nodeagent —— 跨机 AI 接管框架(控制端 CLI)
|
||||
nodeagent macro validate <file.json> | macro init [文件]
|
||||
nodeagent events [--kind file|process|net] [--path <路径>] [--pattern G] [--seconds 15]
|
||||
订阅事件并实时接收推送(无 --kind 则列出订阅)(v12)
|
||||
nodeagent monitor start --source <port|process|command|metric> [--target X] [--interval ms] [--id 名]
|
||||
启动定时采样并落盘 (v2.0.0):port 验连通性/process 验存活/
|
||||
command 取退出码/metric 取 CPU·内存
|
||||
nodeagent monitor report <id> 回看样本 + 摘要(断几次、最长断多久、值域)(v2.0.0)
|
||||
nodeagent monitor stop|list|delete <id>
|
||||
nodeagent log --path <文件> [--pattern 正则] [--level ERROR] [--limit n] [--tail]
|
||||
在被控端侧过滤日志,只回匹配行(日志几万行也不拖网)(v2.0.0)
|
||||
nodeagent record [--duration 5000] [--fps 2] [--scale 0.5] [--region x,y,w,h]
|
||||
录屏为帧序列(有 ffmpeg 则封装 mp4)(v11)
|
||||
nodeagent update --url <地址> --sha256 <哈希> [--dry-run]
|
||||
@@ -55,6 +67,14 @@ export const HELP = `nodeagent —— 跨机 AI 接管框架(控制端 CLI)
|
||||
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)
|
||||
@@ -72,6 +92,9 @@ export const HELP = `nodeagent —— 跨机 AI 接管框架(控制端 CLI)
|
||||
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)
|
||||
|
||||
@@ -34,13 +34,14 @@ import { HELP } from './help.js';
|
||||
import { parseOptions } from './options.js';
|
||||
import { cmdConnect, cmdDiscover, cmdKeygen, cmdNodes, cmdRemove, cmdUse } from './cmd/conn.js';
|
||||
import {
|
||||
cmdApps, cmdAudio, cmdAudit, cmdAuditVerify, cmdExec, cmdInfo, cmdInstall, cmdInvoke, cmdList,
|
||||
cmdApps, cmdAudio, cmdAudit, cmdAuditAnchor, cmdAuditHead, cmdAuditL2, cmdAuditVerify, cmdExec, cmdInfo, cmdInstall, cmdInvoke, cmdList,
|
||||
cmdMetrics, cmdPs, cmdRestart, cmdServices, cmdStatus, cmdUpdate,
|
||||
} from './cmd/system.js';
|
||||
import {
|
||||
cmdBg, cmdClip, cmdDeploy, cmdEvents, cmdFanout, cmdMacro, cmdRecord, cmdTask, cmdTasks,
|
||||
cmdBg, cmdClip, cmdDeploy, cmdEvents, cmdFanout, cmdLog, cmdMacro, cmdMonitor, cmdRecord, cmdTask, cmdTasks,
|
||||
} from './cmd/ops.js';
|
||||
import { cmdKey, cmdMouse, cmdScreen, cmdScreenshot } from './cmd/gui.js';
|
||||
import { cmdRollback } from './cmd/rollback.js';
|
||||
import { cmdAwait, cmdKey, cmdMouse, cmdScreen, cmdScreenshot } from './cmd/gui.js';
|
||||
import { cmdCat, cmdLs, cmdPull, cmdPush, cmdStat } from './cmd/fs.js';
|
||||
import { cmdNet } from './cmd/net.js';
|
||||
import { DAEMON_SOCK, fail, getClientConfig, setNodeOverride } from './core.js';
|
||||
@@ -60,6 +61,16 @@ async function main(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// ⚠️ `--help` 是**选项**、不是位置参数。
|
||||
// parseArgs 开了 `strict: false`,`--help` / `-h` 会被收进 `values`(作 boolean),
|
||||
// `positionals` 里压根没有它 —— 原来靠「`positionals[0] === undefined` 时打印帮助」
|
||||
// **偶然**蒙对,一旦前面多一个位置参数就会掉进「未知命令」。
|
||||
// L-01 总闸守卫(tests/unit/cli-options.test.mjs)扫出这类「承诺了却没接上」的选项。
|
||||
if (opts.help) {
|
||||
console.log(HELP);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (command) {
|
||||
case 'connect': {
|
||||
const host = positionals[0];
|
||||
@@ -187,6 +198,14 @@ async function main(): Promise<void> {
|
||||
case 'events':
|
||||
await cmdEvents(opts);
|
||||
return;
|
||||
case 'monitor': {
|
||||
const [action, ...rest2] = positionals;
|
||||
await cmdMonitor(action, rest2, opts);
|
||||
return;
|
||||
}
|
||||
case 'log':
|
||||
await cmdLog(opts);
|
||||
return;
|
||||
case 'macro': {
|
||||
const sub = positionals[0];
|
||||
await cmdMacro(sub, positionals[1], opts);
|
||||
@@ -242,6 +261,9 @@ async function main(): Promise<void> {
|
||||
await cmdDeploy(f, opts);
|
||||
return;
|
||||
}
|
||||
case 'rollback':
|
||||
await cmdRollback(opts);
|
||||
return;
|
||||
case 'install': {
|
||||
const pkg = positionals[0];
|
||||
if (!pkg) fail('用法: nodeagent install <包名|ID>');
|
||||
@@ -268,6 +290,9 @@ async function main(): Promise<void> {
|
||||
return;
|
||||
case 'audit':
|
||||
if (positionals[0] === 'verify') await cmdAuditVerify(opts);
|
||||
else if (positionals[0] === 'head') await cmdAuditHead(opts);
|
||||
else if (positionals[0] === 'anchor') await cmdAuditAnchor(opts);
|
||||
else if (positionals[0] === 'l2') await cmdAuditL2(positionals[1], opts);
|
||||
else await cmdAudit(opts);
|
||||
return;
|
||||
case 'discover':
|
||||
@@ -304,6 +329,10 @@ async function main(): Promise<void> {
|
||||
await cmdKey(action, rest2, opts);
|
||||
return;
|
||||
}
|
||||
case 'await': {
|
||||
await cmdAwait(opts);
|
||||
return;
|
||||
}
|
||||
case undefined:
|
||||
case 'help':
|
||||
case '--help':
|
||||
|
||||
@@ -38,6 +38,11 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
|
||||
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' },
|
||||
@@ -54,6 +59,24 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
|
||||
button: { type: 'string' },
|
||||
duration: { type: 'string' },
|
||||
interval: { type: 'string' },
|
||||
tail: { type: 'boolean' },
|
||||
compare: { type: 'boolean' },
|
||||
'anchor-path': { type: 'string' },
|
||||
note: { type: 'string' },
|
||||
level: { type: 'string' },
|
||||
target: { type: 'string' },
|
||||
source: { type: 'string' },
|
||||
where: { type: 'string' },
|
||||
any_of: { type: 'string' },
|
||||
title: { type: 'string' },
|
||||
text: { type: 'string' },
|
||||
process: { type: 'string' },
|
||||
state: { type: 'string' },
|
||||
condition: { type: 'string' },
|
||||
// 媒体键定向投递的目标进程 PID(v1.2)
|
||||
pid: { type: 'string' },
|
||||
hold: { type: 'string' },
|
||||
route: { type: 'string' },
|
||||
'auth-mode': { type: 'string' },
|
||||
since: { type: 'string' },
|
||||
type: { type: 'string' },
|
||||
@@ -62,7 +85,6 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
|
||||
'discovery-port': { type: 'string' },
|
||||
node: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
note: { type: 'string' },
|
||||
recursive: { type: 'boolean', default: false },
|
||||
pattern: { type: 'string' },
|
||||
'create-dirs': { type: 'boolean', default: false },
|
||||
@@ -72,6 +94,12 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
|
||||
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' },
|
||||
@@ -94,9 +122,13 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
|
||||
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' },
|
||||
@@ -107,6 +139,8 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
|
||||
});
|
||||
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,
|
||||
@@ -121,6 +155,23 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
|
||||
button: values['button'] as string | undefined,
|
||||
duration: values['duration'] as string | undefined,
|
||||
interval: values['interval'] as string | undefined,
|
||||
tail: values['tail'] as boolean | undefined,
|
||||
compare: values['compare'] as boolean | undefined,
|
||||
anchorPath: values['anchor-path'] as string | undefined,
|
||||
note: values['note'] as string | undefined,
|
||||
level: values['level'] as string | undefined,
|
||||
target: values['target'] as string | undefined,
|
||||
source: values['source'] as string | undefined,
|
||||
where: values['where'] as string | undefined,
|
||||
any_of: values['any_of'] as string | undefined,
|
||||
condition: values['condition'] as string | undefined,
|
||||
state: values['state'] as string | undefined,
|
||||
process: values['process'] as string | undefined,
|
||||
text: values['text'] as string | undefined,
|
||||
title: values['title'] as string | undefined,
|
||||
pid: values['pid'] as string | undefined,
|
||||
hold: values['hold'] as string | undefined,
|
||||
route: values['route'] as string | undefined,
|
||||
authMode: values['auth-mode'] as string | undefined,
|
||||
since: values['since'] as string | undefined,
|
||||
type: values['type'] as string | undefined,
|
||||
@@ -129,7 +180,9 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
|
||||
discoveryPort: values['discovery-port'] as string | undefined,
|
||||
node: values['node'] as string | undefined,
|
||||
delay: values['delay'] as string | undefined,
|
||||
timeoutMs: values['timeout-ms'] as string | undefined,
|
||||
// 两个写法都接受:`--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,
|
||||
@@ -152,15 +205,17 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
|
||||
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,
|
||||
note: values['note'] as string | undefined,
|
||||
recursive: Boolean(values['recursive']),
|
||||
pattern: values['pattern'] as string | undefined,
|
||||
createDirs: Boolean(values['create-dirs']),
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
// CLI 共享类型与常量(从 index.ts 拆出)
|
||||
/** 已解析的常用选项。 */
|
||||
export interface Options {
|
||||
/** 打印帮助后退出(`--help` / `-h`)。选项而非位置参数 */
|
||||
help: boolean;
|
||||
/** 关闭 TLS(`--no-tls`)。原来是直接扫 process.argv,现归位到解析器 */
|
||||
noTls: boolean;
|
||||
json: boolean;
|
||||
insecure: boolean;
|
||||
port?: string;
|
||||
@@ -16,10 +20,38 @@ export interface Options {
|
||||
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+ 审计查询 */
|
||||
since?: string;
|
||||
type?: string;
|
||||
clientId?: string;
|
||||
/** v4 发现 */
|
||||
@@ -28,10 +60,8 @@ export interface Options {
|
||||
/** v5 多设备 */
|
||||
node?: string;
|
||||
name?: string;
|
||||
note?: string;
|
||||
/** v5 文件 */
|
||||
recursive?: boolean;
|
||||
pattern?: string;
|
||||
createDirs?: boolean;
|
||||
/** v6 Hub */
|
||||
hubToken?: string;
|
||||
@@ -41,7 +71,6 @@ export interface Options {
|
||||
/** v10 后台任务 / 剪贴板 */
|
||||
timeoutMs?: string;
|
||||
kill?: boolean;
|
||||
offset?: string;
|
||||
set?: string;
|
||||
/** v11 剪贴板图片 */
|
||||
imageFile?: string;
|
||||
@@ -50,11 +79,14 @@ export interface Options {
|
||||
/** v11 多设备并发 */
|
||||
nodes?: string;
|
||||
/** v11 部署目标路径 */
|
||||
path?: string;
|
||||
/** v12 事件订阅 */
|
||||
kind?: string;
|
||||
/** v12 宏变量 */
|
||||
vars?: string[];
|
||||
/** v22 发现广播认证密钥 */
|
||||
discoverSecret?: string;
|
||||
/** v22 每客户端 PSK */
|
||||
psk?: boolean;
|
||||
/** v21 证书指纹 */
|
||||
forgetCert?: boolean;
|
||||
/** v19 拉取式自更新 */
|
||||
@@ -64,6 +96,8 @@ export interface Options {
|
||||
/** v18 部署校验 */
|
||||
check?: boolean;
|
||||
force?: boolean;
|
||||
/** v2.1.0 rollback 逃生门:直接指定远端备份路径为回滚源 */
|
||||
backup?: string;
|
||||
/** v17 音频 */
|
||||
mute?: string;
|
||||
volume?: string;
|
||||
|
||||
@@ -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.0.16",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { watchEffect } from "vue";
|
||||
import { NConfigProvider, NMessageProvider, NDialogProvider, zhCN, dateZhCN } from "naive-ui";
|
||||
import AppShell from "./components/AppShell.vue";
|
||||
import QuitGuard from "./components/QuitGuard.vue";
|
||||
import { cssVars } from "./theme";
|
||||
import { useThemeStore } from "./stores/theme";
|
||||
|
||||
const theme = useThemeStore();
|
||||
|
||||
/**
|
||||
* 把调色板写成 `--na-*` CSS 变量挂到根元素。
|
||||
*
|
||||
* 色值只在 `theme/index.ts` 定义一份,此处在运行期注入给自定义样式用 ——
|
||||
* 免得「TS 调色板」与「CSS 硬编码」各写一份后静默漂移(换主题时一半新一半旧,
|
||||
* 不报错、类型也抓不到)。
|
||||
*
|
||||
* 同时设 `color-scheme`:不设的话滚动条、`<input>` 等原生控件会一直是浅色,
|
||||
* 在「高级灰」下白得刺眼。
|
||||
*/
|
||||
watchEffect(() => {
|
||||
const { palette, name } = theme.preset;
|
||||
const root = document.documentElement;
|
||||
for (const [k, v] of Object.entries(cssVars(palette))) {
|
||||
root.style.setProperty(k, v);
|
||||
}
|
||||
root.style.colorScheme = palette.scheme;
|
||||
root.dataset.theme = name;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-config-provider
|
||||
:theme="theme.preset.base"
|
||||
:theme-overrides="theme.preset.overrides"
|
||||
:locale="zhCN"
|
||||
:date-locale="dateZhCN"
|
||||
>
|
||||
<n-message-provider>
|
||||
<n-dialog-provider>
|
||||
<!-- 必须在 dialog-provider 内部:它要用 useDialog() 弹退出确认 -->
|
||||
<quit-guard />
|
||||
<app-shell />
|
||||
</n-dialog-provider>
|
||||
</n-message-provider>
|
||||
</n-config-provider>
|
||||
</template>
|
||||
@@ -0,0 +1,179 @@
|
||||
/** Tauri command 调用封装(前端 → Rust 壳 → Node sidecar → 被控端)。 */
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type {
|
||||
AgentActionResult,
|
||||
AgentLogPage,
|
||||
AgentStatus,
|
||||
ApplyUpdateResult,
|
||||
AutostartStatus,
|
||||
CapabilityDescriptor,
|
||||
ConnectInfo,
|
||||
ConsoleSettings,
|
||||
ConsoleSystemInfo,
|
||||
InvokeResult,
|
||||
UpdateInfo,
|
||||
} from "../types";
|
||||
|
||||
/** 建立连接并握手。 */
|
||||
export function connect(url: string, key: string, clientId = "console", insecure = true) {
|
||||
return invoke<ConnectInfo>("connect", { url, key, clientId, insecure });
|
||||
}
|
||||
|
||||
/** 断开连接(停自动重连)。 */
|
||||
export function disconnect() {
|
||||
return invoke<void>("disconnect");
|
||||
}
|
||||
|
||||
/** 泛型能力调用(`timeoutMs` 用于长任务,如装软件 / 长命令)。 */
|
||||
export function call<T = unknown>(
|
||||
capability: string,
|
||||
args: Record<string, unknown> = {},
|
||||
timeoutMs?: number,
|
||||
) {
|
||||
return invoke<InvokeResult<T>>("invoke_capability", { capability, args, timeoutMs });
|
||||
}
|
||||
|
||||
/** 当前连接的能力清单。 */
|
||||
export function getCapabilities() {
|
||||
return invoke<CapabilityDescriptor[]>("get_capabilities");
|
||||
}
|
||||
|
||||
/** 当前连接状态字符串。 */
|
||||
export function getState() {
|
||||
return invoke<string>("get_state");
|
||||
}
|
||||
|
||||
/**
|
||||
* 能力调用失败(业务层失败,非传输层失败)。
|
||||
*
|
||||
* 协议真源 `InvokeResult.error` 只有 `{ name, message, data? }` —— **不含 code**,
|
||||
* 所以分支判断一律走 `name`(如 `E_UNSUPPORTED_PLATFORM`)。
|
||||
*/
|
||||
export class CapabilityFailure extends Error {
|
||||
readonly capability: string;
|
||||
readonly errorName: string;
|
||||
readonly data?: unknown;
|
||||
|
||||
constructor(capability: string, errorName: string, message: string, data?: unknown) {
|
||||
super(message);
|
||||
this.name = "CapabilityFailure";
|
||||
this.capability = capability;
|
||||
this.errorName = errorName;
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
|
||||
/** 被控端平台不支持该能力(如 macOS 被控端上的 app.* / system.service.list)。 */
|
||||
export function isUnsupportedPlatform(e: unknown): boolean {
|
||||
return e instanceof CapabilityFailure && e.errorName === "E_UNSUPPORTED_PLATFORM";
|
||||
}
|
||||
|
||||
/** 能力被 ACL 拒绝。 */
|
||||
export function isAclDenied(e: unknown): boolean {
|
||||
return e instanceof CapabilityFailure && e.errorName === "E_ACL_DENIED";
|
||||
}
|
||||
|
||||
/** 能力被调用限速拦截。 */
|
||||
export function isRateLimited(e: unknown): boolean {
|
||||
return e instanceof CapabilityFailure && e.errorName === "E_RATE_LIMITED";
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用并要求成功:失败时抛出 `CapabilityFailure`(携带被控端 error.name)。
|
||||
* 便于页面里 `await ok("fs.list", { path })` 直接拿数据。
|
||||
*/
|
||||
export async function ok<T = unknown>(
|
||||
capability: string,
|
||||
args: Record<string, unknown> = {},
|
||||
timeoutMs?: number,
|
||||
): Promise<T> {
|
||||
const res = await call<T>(capability, args, timeoutMs);
|
||||
if (res.status !== "ok") {
|
||||
throw new CapabilityFailure(
|
||||
capability,
|
||||
res.error?.name ?? "E_UNKNOWN",
|
||||
res.error?.message ?? `${capability} 执行失败`,
|
||||
res.error?.data,
|
||||
);
|
||||
}
|
||||
return res.data as T;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 本机服务(本机被控端)—— 全部走 Tauri command,不经被控端协议
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 本机服务总览:进程状态 + 配置 + 自启 + 设置 + 版本。 */
|
||||
export function agentStatus() {
|
||||
return invoke<AgentStatus>("agent_status");
|
||||
}
|
||||
|
||||
/** 启动本机被控端。`autoRestart` 省略时取设置里的值。 */
|
||||
export function agentStart(autoRestart?: boolean) {
|
||||
return invoke<AgentActionResult>("agent_start", { autoRestart });
|
||||
}
|
||||
|
||||
/** 停止本机被控端(幂等)。 */
|
||||
export function agentStop() {
|
||||
return invoke<AgentActionResult>("agent_stop");
|
||||
}
|
||||
|
||||
/** 重启本机被控端。 */
|
||||
export function agentRestart(autoRestart?: boolean) {
|
||||
return invoke<AgentActionResult>("agent_restart", { autoRestart });
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出控制台(由前端的确认框调用)。
|
||||
*
|
||||
* `keepAgent = true` 保留被控端继续运行 —— 它将成为孤儿进程,
|
||||
* 下次启动控制台时会被「复用」逻辑接管;`false` 则先停掉再退出。
|
||||
*/
|
||||
export function quitApp(keepAgent: boolean) {
|
||||
return invoke<void>("quit_app", { keepAgent });
|
||||
}
|
||||
|
||||
/** 增量取日志(传上次拿到的最大 seq)。 */
|
||||
export function agentLogs(since = 0) {
|
||||
return invoke<AgentLogPage>("agent_logs", { since });
|
||||
}
|
||||
|
||||
/** 清空日志缓冲。 */
|
||||
export function agentLogsClear() {
|
||||
return invoke<void>("agent_logs_clear");
|
||||
}
|
||||
|
||||
/** 开机自启状态。 */
|
||||
export function autostartStatus() {
|
||||
return invoke<AutostartStatus>("autostart_status");
|
||||
}
|
||||
|
||||
/** 开关开机自启。 */
|
||||
export function autostartSet(enabled: boolean) {
|
||||
return invoke<AutostartStatus>("autostart_set", { enabled });
|
||||
}
|
||||
|
||||
/** 检查更新(比对最新 Release)。 */
|
||||
export function checkUpdate() {
|
||||
return invoke<UpdateInfo>("check_update");
|
||||
}
|
||||
|
||||
/** 应用更新:下载 → 校验 → 替换本机被控端脚本 → 重启。 */
|
||||
export function applyUpdate(url: string, sha256: string, version: string) {
|
||||
return invoke<ApplyUpdateResult>("apply_update", { url, sha256, version });
|
||||
}
|
||||
|
||||
/** 读设置。 */
|
||||
export function settingsGet() {
|
||||
return invoke<ConsoleSettings>("settings_get");
|
||||
}
|
||||
|
||||
/** 合并式写设置(只传要改的字段)。 */
|
||||
export function settingsSet(patch: Partial<ConsoleSettings>) {
|
||||
return invoke<ConsoleSettings>("settings_set", { patch });
|
||||
}
|
||||
|
||||
/** 控制台自身信息。 */
|
||||
export function systemInfo() {
|
||||
return invoke<ConsoleSystemInfo>("system_info");
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from "vue";
|
||||
import { RouterView, useRoute, useRouter } from "vue-router";
|
||||
import {
|
||||
NLayout,
|
||||
NLayoutSider,
|
||||
NLayoutHeader,
|
||||
NLayoutContent,
|
||||
NMenu,
|
||||
NTag,
|
||||
NButton,
|
||||
NSpace,
|
||||
NText,
|
||||
NTooltip,
|
||||
NRadioGroup,
|
||||
NRadioButton,
|
||||
type MenuOption,
|
||||
} from "naive-ui";
|
||||
import { navRoutes, type NavMeta } from "../router";
|
||||
import { useConnStore } from "../stores/conn";
|
||||
import { useAgentStore } from "../stores/agent";
|
||||
import { useThemeStore } from "../stores/theme";
|
||||
import { THEME_LIST } from "../theme";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const conn = useConnStore();
|
||||
const agent = useAgentStore();
|
||||
const theme = useThemeStore();
|
||||
const themeName = computed({
|
||||
get: () => theme.name,
|
||||
set: (v) => theme.set(v),
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
conn.bind();
|
||||
agent.bind();
|
||||
});
|
||||
|
||||
// 纯文字导航:应用内不使用图标(emoji 图标在部分渲染器里会退化成豆腐块)。
|
||||
const menuOptions: MenuOption[] = navRoutes.map((r) => ({
|
||||
key: r.name as string,
|
||||
label: (r.meta as NavMeta).title,
|
||||
}));
|
||||
|
||||
const activeKey = computed(() => (route.name as string) ?? "local");
|
||||
|
||||
function onMenu(key: string) {
|
||||
router.push({ name: key });
|
||||
}
|
||||
|
||||
const stateType = computed(() => {
|
||||
if (conn.state === "connected") return "success" as const;
|
||||
if (conn.state === "reconnecting") return "warning" as const;
|
||||
return "error" as const;
|
||||
});
|
||||
|
||||
const stateLabel = computed(() => {
|
||||
if (conn.state === "connected") return "已连接";
|
||||
if (conn.state === "reconnecting") return "重连中";
|
||||
return "未连接";
|
||||
});
|
||||
|
||||
/** 从侧栏快速启停本机服务(不必切页面)。 */
|
||||
async function quickToggle() {
|
||||
if (agent.running) await agent.stop();
|
||||
else await agent.start();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-layout has-sider style="height: 100vh">
|
||||
<n-layout-sider bordered :width="200" :native-scrollbar="false">
|
||||
<div class="brand">
|
||||
<div class="brand-title">nodeagent</div>
|
||||
<div class="brand-sub">桌面控制台</div>
|
||||
</div>
|
||||
<n-menu :options="menuOptions" :value="activeKey" @update:value="onMenu" />
|
||||
<div class="sider-foot">
|
||||
<div class="foot-label">主题</div>
|
||||
<n-radio-group v-model:value="themeName" size="small" class="theme-switch">
|
||||
<n-radio-button v-for="t in THEME_LIST" :key="t.name" :value="t.name">
|
||||
{{ t.label }}
|
||||
</n-radio-button>
|
||||
</n-radio-group>
|
||||
|
||||
<div class="foot-sep"></div>
|
||||
|
||||
<div class="foot-row">
|
||||
<n-text depth="3" style="font-size: 12px">本机被控端</n-text>
|
||||
<n-tag :type="agent.meta.type" size="tiny" round>{{ agent.meta.label }}</n-tag>
|
||||
</div>
|
||||
<n-button
|
||||
size="tiny"
|
||||
block
|
||||
:loading="agent.busy"
|
||||
:disabled="agent.busy || agent.externalInstance"
|
||||
@click="quickToggle"
|
||||
>
|
||||
{{ agent.running ? "停止服务" : "启动服务" }}
|
||||
</n-button>
|
||||
</div>
|
||||
</n-layout-sider>
|
||||
|
||||
<n-layout>
|
||||
<n-layout-header bordered class="topbar">
|
||||
<n-space align="center">
|
||||
<n-tag :type="stateType" size="small" round>{{ stateLabel }}</n-tag>
|
||||
<n-text depth="3" style="font-size: 12px">{{ conn.url }}</n-text>
|
||||
<n-text v-if="conn.info" depth="3" style="font-size: 12px">
|
||||
· agent {{ conn.info.agent_version ?? "?" }} · {{ conn.info.capabilities.length }} 项能力
|
||||
</n-text>
|
||||
</n-space>
|
||||
<n-space align="center">
|
||||
<n-tooltip v-if="agent.sysInfo?.agent_only" trigger="hover">
|
||||
<template #trigger>
|
||||
<n-tag size="small" type="info" round>仅服务模式</n-tag>
|
||||
</template>
|
||||
本控制台以「仅服务模式」启动(无窗口,常驻托盘)—— 通常来自开机自启
|
||||
</n-tooltip>
|
||||
<n-button size="small" :disabled="!conn.info" @click="conn.disconnect()">断开</n-button>
|
||||
</n-space>
|
||||
</n-layout-header>
|
||||
|
||||
<n-layout-content :native-scrollbar="false" content-style="padding: 20px;">
|
||||
<router-view />
|
||||
</n-layout-content>
|
||||
</n-layout>
|
||||
</n-layout>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 配色只走 `--na-*` 变量(值在 src/theme/index.ts 单点定义,App.vue 运行期注入),
|
||||
此处不留任何写死的色值 —— 否则换主题时会一半新一半旧,且不报错。
|
||||
`color-scheme` 也由 App.vue 按主题设,这里不重复声明。 */
|
||||
html,
|
||||
body {
|
||||
background: var(--na-bg);
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
.brand {
|
||||
padding: 18px 20px 10px;
|
||||
}
|
||||
.brand-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--na-brand);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.brand-sub {
|
||||
font-size: 12px;
|
||||
color: var(--na-text-3);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.topbar {
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
}
|
||||
/* 侧栏做成纵向弹性布局,才能把底部快捷块压到底 */
|
||||
:deep(.n-layout-sider-scroll-container) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.sider-foot {
|
||||
margin-top: auto;
|
||||
padding: 12px 14px 16px;
|
||||
border-top: 1px solid var(--na-border);
|
||||
}
|
||||
.foot-label {
|
||||
font-size: 11px;
|
||||
color: var(--na-text-3);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.foot-sep {
|
||||
height: 1px;
|
||||
background: var(--na-border);
|
||||
margin: 12px 0;
|
||||
}
|
||||
/* 两个主题按钮各占一半宽(侧栏只有 200px,默认内边距会挤出去) */
|
||||
.theme-switch {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
.theme-switch :deep(.n-radio-button) {
|
||||
flex: 1;
|
||||
}
|
||||
.theme-switch :deep(.n-radio-button__label) {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.foot-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 退出确认(2026-10-17 需求方提出)。
|
||||
*
|
||||
* ## 它解决的误解
|
||||
* 旧行为:点托盘「退出」→ 控制台立刻消失,但被控端因 `kill_on_drop(false)`
|
||||
* **成为孤儿继续监听端口**。用户以为「关了窗口就干净了」,实际还有个看不见的
|
||||
* 进程在跑 —— 下次启动还会撞上「端口已被占用」。
|
||||
*
|
||||
* ## 为什么做成三选一
|
||||
* 「留不留一个正在监听端口的后台进程」是有副作用的决定,必须由用户明确选择:
|
||||
* - 保留被控端 → 远程仍可接管(下次启动控制台会自动复用它)
|
||||
* - 停止被控端 → 彻底清净,但本机不再可被远程接管
|
||||
* - 关闭弹窗 → 取消退出
|
||||
*
|
||||
* ## 为什么不在 Rust 里弹系统对话框
|
||||
* 前端已有 `n-dialog-provider`,复用它就不必新增 `tauri-plugin-dialog` 依赖,
|
||||
* 视觉也与全应用一致。Rust 侧只负责「发一个事件 + 提供 quit_app 命令」。
|
||||
*/
|
||||
import { onMounted, onUnmounted } from "vue";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import { useDialog } from "naive-ui";
|
||||
import { h } from "vue";
|
||||
import { quitApp } from "../api";
|
||||
|
||||
const dialog = useDialog();
|
||||
let unlisten: UnlistenFn | null = null;
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
unlisten = await listen("na-quit-requested", () => {
|
||||
const d = dialog.warning({
|
||||
title: "退出控制台",
|
||||
closable: true,
|
||||
maskClosable: true,
|
||||
content: () =>
|
||||
h("div", { style: "line-height:1.7" }, [
|
||||
h("div", "退出后本机被控端要怎么处理?"),
|
||||
h(
|
||||
"div",
|
||||
{ style: "margin-top:6px;font-size:12px;opacity:.72" },
|
||||
"保留:仍然可以被远程接管(下次打开控制台会自动复用它);停止:彻底结束,退出后不再有后台进程。",
|
||||
),
|
||||
]),
|
||||
positiveText: "保留被控端,退出",
|
||||
negativeText: "停止被控端,退出",
|
||||
onPositiveClick: () => {
|
||||
d.loading = true;
|
||||
void quitApp(true);
|
||||
},
|
||||
onNegativeClick: () => {
|
||||
d.loading = true;
|
||||
void quitApp(false);
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
/* 事件注册失败不应阻断应用启动 */
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
unlisten?.();
|
||||
unlisten = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 纯逻辑组件,不渲染任何可见内容 -->
|
||||
<span style="display: none" />
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import router from "./router";
|
||||
import App from "./App.vue";
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(createPinia());
|
||||
app.use(router);
|
||||
app.mount("#app");
|
||||