Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
No files matched your search
@@ -0,0 +1,319 @@
|
||||
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:
|
||||
|
||||
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
|
||||
retention-days: 30
|
||||
+88
-22
@@ -1,21 +1,28 @@
|
||||
name: Rust Client
|
||||
name: Rust Shell
|
||||
|
||||
# crates/nodeagent-client 是桌面控制台的协议客户端(Tauri 壳经 Cargo path 依赖它)。
|
||||
# 它是纯 Rust、无平台相关代码,故单开一个 Linux 快车道:
|
||||
# 比 Windows 那条 E2E 长流程反馈快得多,失败归属也一目了然。
|
||||
# 2026-10:原 crates/nodeagent-client(Rust 版协议客户端)已退役 ——
|
||||
# 协议实现改由 Node sidecar 承担(apps/console/sidecar/),避免两套实现静默漂移。
|
||||
# 本工作流随之改为验证 **console 的 Rust 壳**(sidecar 管理 + command 转发)。
|
||||
#
|
||||
# ⚠️ 本工作流与仓库其余部分的铁律不同:**不涉及** .ps1/.cmd 编码,无需 BOM 守卫。
|
||||
# 它的单测是**真跑** Node sidecar 子进程的(见 apps/console/src-tauri/src/sidecar.rs),
|
||||
# 不依赖 Tauri GUI,因此能在 Linux 快车道上跑。
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'crates/**'
|
||||
- 'packages/protocol/**'
|
||||
- 'apps/console/src-tauri/**'
|
||||
- 'apps/console/sidecar/**'
|
||||
- 'scripts/prepare-console.mjs'
|
||||
- 'scripts/lib/bundle-agent.mjs'
|
||||
- 'apps/agent/**'
|
||||
- '.github/workflows/rust.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'crates/**'
|
||||
- 'packages/protocol/**'
|
||||
- 'apps/console/src-tauri/**'
|
||||
- 'apps/console/sidecar/**'
|
||||
- 'scripts/prepare-console.mjs'
|
||||
- 'scripts/lib/bundle-agent.mjs'
|
||||
- 'apps/agent/**'
|
||||
- '.github/workflows/rust.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -24,31 +31,90 @@ env:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Rust 客户端单测 + E2E
|
||||
name: Rust 壳单测(真跑 sidecar)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 检出代码
|
||||
uses: actions/checkout@v5
|
||||
|
||||
# 版本**锁死**而不跟 stable 漂 —— 否则「本地全绿」与「CI 绿」会因为 lint 集合随
|
||||
# 工具链变化而分叉(本仓库已因同类原因吃过 8 轮 Windows CI 的苦头)。
|
||||
# 当前值与 crates/ 开发环境一致:rustc/cargo 1.98.1、clippy 0.1.98。
|
||||
# 升级时两边同改,升完先本地复跑 `cargo clippy ... -- -D warnings`。
|
||||
- name: 安装 Rust 工具链(1.98.1,含 clippy)
|
||||
# 版本**锁死**而不跟 stable 漂 —— 否则「本地全绿」与「CI 绿」会因 lint 集合随工具链
|
||||
# 变化而分叉(本仓库已因同类原因吃过 8 轮 Windows CI 的苦头)。
|
||||
- name: 安装 Rust 工具链(含 clippy)
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: 1.98.1
|
||||
toolchain: stable
|
||||
components: clippy
|
||||
|
||||
# Tauri 壳在 Linux 上编译需要这些系统库(webkit2gtk 等)。sidecar 单测本身
|
||||
# 不需要 GUI,但整个 crate 要能编过。
|
||||
- name: 安装 Tauri 系统依赖
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev \
|
||||
librsvg2-dev patchelf libgtk-3-dev
|
||||
|
||||
- name: 安装 Node(sidecar 单测要真跑它)
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: 安装 pnpm 与依赖(sidecar 需 @nodeagent/client)
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
# ⚠️ 顺序有依赖: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: crates
|
||||
workspaces: apps/console/src-tauri
|
||||
|
||||
# clippy 单列为一步:它拦的是「能编译但不该这么写」,与测试失败是两类问题。
|
||||
# -D warnings 让告警即失败 —— 该 crate 的既定标准是**零告警**。
|
||||
- name: Clippy(告警即失败)
|
||||
run: cargo clippy --manifest-path crates/nodeagent-client/Cargo.toml --all-targets -- -D warnings
|
||||
run: cargo clippy --manifest-path apps/console/src-tauri/Cargo.toml --all-targets -- -D warnings
|
||||
|
||||
- name: 单测 + E2E
|
||||
run: cargo test --manifest-path crates/nodeagent-client/Cargo.toml
|
||||
- name: 单测(真跑 Node sidecar 进程)
|
||||
run: cargo test --manifest-path apps/console/src-tauri/Cargo.toml
|
||||
@@ -48,6 +48,20 @@ 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 会回退上万行,故只取其提交内容手工并入。
|
||||
@@ -141,6 +155,23 @@ jobs:
|
||||
}
|
||||
Write-Host "OK - package complete; .ps1 has BOM / .cmd has none; no key shipped"
|
||||
} finally { $z.Dispose() }
|
||||
- name: 冒烟 — 双架构包(node.exe PE 架构校验)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
# 真机教训(Parallels 27 + Win11 ARM):曾出现 arm64 包里装的其实是 x64 的 node.exe
|
||||
# —— 两份包的 node.exe 字节完全相同。x64 node 在 ARM Windows 上**能 listen,
|
||||
# 但 TLS 握手不完成**(客户端只见超时)→ 被控端实际不可用,且 macOS 侧 100% 测不出。
|
||||
# 根因是 pack.mjs 的运行时缓存文件名不含架构。这里直接验**最终产物**的 PE 架构,
|
||||
# 比只验脚本内部逻辑更靠近交付物(缓存串包 / 手工塞错文件都会被这一步拦下)。
|
||||
# 校验实现与发版脚本共用(scripts/lib/zip-pe-arch.mjs)—— 避免两套实现各自漂移。
|
||||
foreach ($arch in @('x64','arm64')) {
|
||||
node scripts/pack.mjs --arch $arch
|
||||
}
|
||||
foreach ($arch in @('x64','arm64')) {
|
||||
node scripts/lib/zip-pe-arch.mjs "release/nodeagent-win-$arch.zip" $arch
|
||||
if ($LASTEXITCODE -ne 0) { throw "node.exe 架构校验失败($arch)—— 严禁发布该包" }
|
||||
}
|
||||
- name: 冒烟 — 被控端监听
|
||||
shell: pwsh
|
||||
run: |
|
||||
|
||||
+447
-5
@@ -5,6 +5,442 @@
|
||||
|
||||
---
|
||||
|
||||
## [未发布]
|
||||
|
||||
> 主题:**控制台「去命令行化」** —— 双击应用即用,启停/重启/更新/日志/托盘常驻/开机自启全部在界面里完成。
|
||||
> 目标原文:「不再打开命令行窗口,运行应用之后,有启动、停止、重启、检查更新等功能,
|
||||
> 有日志功能,可以后台运行和开机自启等等功能」。
|
||||
>
|
||||
> 末段另含两轮纯界面调整:**一律不使用图标**(emoji 在部分渲染器里退化成豆腐块)、
|
||||
> **配色收敛为「简约白 / 高级灰」两套中性灰阶主题**。
|
||||
|
||||
### 新增 —— 本机被控端托管(`apps/console/src-tauri/src/agent.rs`,约 560 行)
|
||||
|
||||
- **新增「本机服务」页(`/local`,默认首页)与「服务日志」页(`/logs`)**。
|
||||
默认路由由 `/nodes` 改为 `/local`:打开应用先看到「本机被控端」的启停面板,
|
||||
而不是先要求用户填一个连接地址 —— 这是「运行应用之后就有启动/停止/重启」的落点。
|
||||
- **进程生命周期管理**:`AgentManager`(`STOPPED / STARTING / RUNNING / STOPPING / CRASHED` 五态)。
|
||||
启动、停止、重启均走界面按钮;`stop()` 在 Windows 走 `taskkill /PID <pid> /T /F`(连子进程树一起收),
|
||||
POSIX 走 `child.kill()`,随后**轮询确认端口已释放**才算停干净。
|
||||
**重复启动被拒绝**(不会起出两个进程),**异常退出自动重启**(上限 5 次,超限停在 `CRASHED` 并保留原因)。
|
||||
- **日志功能**:同时捕获被控端 `stdout`/`stderr` 两路,环形缓冲 4000 行。
|
||||
每行带一个**单调递增的 `seq`** —— 这是刻意设计:环形缓冲淘汰旧行后,若用「位置下标」做游标,
|
||||
前端的增量拉取会静默错位;独立 `AtomicU64` 发号可跨淘汰保持连续。
|
||||
界面侧:实时事件推送、关键字过滤、三流(stdout/stderr/system)复选框、自动滚动、
|
||||
暂停接收、重新拉取、滚到底部、导出文本、清空。
|
||||
- **界面化自更新**:`check_update` / `download` / `apply_update` 三个能力。
|
||||
- `check_update` 读 GitHub Release 的**稳定别名** `latest.json`(而非 API),**不占用 API 配额**、不受限流影响。
|
||||
- `download` 的 `--sha256` 是**必填**(不校验哈希等于开放远程代码执行),校验不通过**不落盘**,写后再复核一次。
|
||||
- `apply_update` 的替换顺序:下载到 `runtime/agent.mjs.staged-<pid>` → 复用已有被控端停服 →
|
||||
备份现有脚本 → **原子替换** → 写 `agent.seed.json` 版本 → 按需回启。
|
||||
任一步失败都停在替换之前,不会留下半截产物。
|
||||
- **开机自启**(`autostart.rs`):macOS 写 LaunchAgent `~/Library/LaunchAgents/cn.jkooe.nodeagent-console.plist`
|
||||
(`RunAtLoad`;**刻意不用 `KeepAlive`**,否则会与控制台的崩溃自愈形成两层守护互相打架);
|
||||
Windows 用计划任务(**登录用户 + `Interactive` + `Highest`**)。
|
||||
一个易错点:macOS 的 `bootstrap` 目标 `gui/<uid>` 里的 uid **不能硬编码 501**(那只是首个本地用户的常见值),
|
||||
改为运行时 `id -u` 现取。
|
||||
- **后台常驻(托盘)**:托盘图标 + 右键菜单(显示主窗口 / 启动 / 停止 / 重启 / 退出);
|
||||
左键单击图标唤出窗口。窗口 `CloseRequested` → `prevent_close()` + `hide()`,
|
||||
**关窗不退出**,与「后台运行」的要求一致;真正退出走托盘菜单。
|
||||
- **`--agent-only` 仅服务模式**:开机自启拉起的是**控制台自身**(GUI 子系统程序,不带控制台窗口),
|
||||
进入该模式后不显示窗口、只把被控端托起来;而不是去直接拉起裸 `node`。
|
||||
|
||||
### 新增 —— 消灭命令行窗口(三条措施,缺一不可)
|
||||
|
||||
| # | 措施 | 位置 | 不加会怎样 |
|
||||
|---|---|---|---|
|
||||
| 1 | 控制台是 GUI 子系统程序,自身不弹控制台 | Tauri 默认 | 打开应用先弹黑窗 |
|
||||
| 2 | spawn `node.exe` 时加 `CREATE_NO_WINDOW`(`0x0800_0000`) | `agent.rs` 的 `creation_flags` | **每次启动被控端都闪一个黑窗** —— 正是皇上要求消灭的现象 |
|
||||
| 3 | 开机自启拉起控制台而非裸 `node` | `autostart.rs` + `--agent-only` | 开机时黑窗一闪而过 |
|
||||
|
||||
### 新增 —— 契约守卫(`tests/unit/console-commands.test.mjs`,8 用例)
|
||||
|
||||
把「改了名字两边不同步、但不报错」的三类**静默失效**钉死,避免「界面点了没反应、控制台无异常」:
|
||||
|
||||
| # | 守卫 | 抓什么 |
|
||||
|---|---|---|
|
||||
| 1 | 前端每个 `invoke("x")` 都已在 Rust `generate_handler!` 注册 | 前端调了个不存在的命令 → 运行时才 `not found` |
|
||||
| 2 | Rust 注册的命令都被前端使用 | 死命令堆积(改名后残留的旧命令) |
|
||||
| 3 | `emit("na-*")` ⇄ `listen("na-*")` 双向一致 | 事件名不匹配 → **界面永远不刷新,且无任何报错** |
|
||||
| 4 | `tauri.bundle.gen.json` 资源目标名 ⇄ Rust `resolve_*` 字面量 | 映射改名 → 生产包内找不到 sidecar/node/agent |
|
||||
| 5 | sidecar 方法名 ⇄ Rust `.request("x")` | 跨语言调用错名 |
|
||||
| 6 | 新页面已注册路由 + 默认路由指向 `/local` + 懒加载的 view 文件真实存在 | 「写了页面但进不去」/循环依赖导致的路由表推导失败 |
|
||||
| 7 | 自定义样式用到的 `var(--na-*)` 都由 `cssVars()` 定义 | 拼错变量名**不报错**,颜色只是静默落回继承值 —— 类型检查、构建、运行全都不吭声 |
|
||||
| 8 | `index.html` 的首帧兜底色 ⇄ 调色板的 `bg` | 首帧底色是全应用**唯一允许的色值重复处**;允许重复就必须有守卫 |
|
||||
|
||||
> **已注入故障实测会红**:① 把 `agent_start` 改成 `agent_startX`、`na-agent-state` 改成 `na-agent-statee`
|
||||
> → 当场 2 条失败;② 把某处 `var(--na-sunken)` 拼成 `var(--na-sunkenTypo)`(含大写)与 `var(--na-sunkenx)`
|
||||
> → 第 7 条均失败;③ 把 `index.html` 的 `#f4f4f5` 改回 `#ffffff` → 第 8 条失败。
|
||||
> 全部复原后 `#pass = 8 / #fail = 0`,文件逐字节还原。未验证过会红的守卫等于没有守卫。
|
||||
>
|
||||
> 第 7 条的正则刻意放宽到 `[^)\s,;]+` 而非 `[a-z0-9-]+` —— 后者遇到含大写的拼错会在中途停下导致
|
||||
> **整个模式不匹配**,于是「写错的变量名」反而逃过检查。这是注入故障时才发现的守卫自身漏报。
|
||||
|
||||
### 变更 —— 打包与单一实现
|
||||
|
||||
- **被控端 esbuild 配置抽为 `scripts/lib/bundle-agent.mjs`**(`repoRoot` / `repoVersion` / `gitShortCommit` / `bundleAgent`),
|
||||
`scripts/pack.mjs`(被控端发布包)与 `scripts/prepare-console.mjs`(控制台资源)**共用同一份**。
|
||||
此前 `pack.mjs` 里内联了一份配置,控制台若再内联一份就是**两套真相**(改一处漏一处,只在产物里暴露)。
|
||||
该模块刻意**不注入构建时间** —— 否则每次打包字节都不同,产物指纹比对会失真。
|
||||
- **控制台随包分发被控端脚本**:资源由 2 份(`sidecar.mjs` / `node`)增至 **3 份**(+ `agent.mjs`)。
|
||||
带来的效果:用户装完控制台,**本机被控端已经就绪**,不需要另外手工安装一次才能点「启动」。
|
||||
- **播种防降级**:`<数据目录>/runtime/agent.seed.json` 记录版本,
|
||||
只有「**随包版本 > 已安装版本**」才覆盖数据目录里的脚本。
|
||||
若写成「同版本才跳过」,则用户被控端自更新到更新版本后,下次开控制台会被**静默降回旧版**。
|
||||
版本比较按**数值逐段**(`2.0.10 > 2.0.9`),字符串比较会误判。
|
||||
- `verify-bundle.mjs` 增加 `agent.mjs` 存在性检查 + 用打包内的 node 跑 `--check agent.mjs`。
|
||||
|
||||
### 变更 —— CI
|
||||
|
||||
- `.github/workflows/rust.yml`:资源数断言 2 → **3**;`paths` 增加 `scripts/lib/bundle-agent.mjs` 与 `apps/agent/**`;
|
||||
构建步骤补 `@nodeagent/agent`(`prepare:node` 现在依赖它的 dist,
|
||||
**新步骤必须逐条核对前置产物由哪一步生成、顺序是否在其后** —— 本地验证时产物早就在,会把这层依赖完全掩盖)。
|
||||
- `.github/workflows/windows-e2e.yml`:步骤名与注释同步为含 `agent.mjs` 的说明。
|
||||
|
||||
### 新增 —— Windows 端控制台安装包(`.github/workflows/console-windows.yml`)
|
||||
|
||||
控制台此前**只在 macOS 上出过包**(`.app` / `.dmg`),而它的主战场恰恰是
|
||||
「在 Windows 上管控其他机器」。本轮补上 Windows 构建:**双架构矩阵** ——
|
||||
`windows-latest` 出 x64 `.msi` + NSIS `-setup.exe`,`windows-11-arm` 出 arm64 NSIS `-setup.exe`。
|
||||
|
||||
- **为什么必须双架构**:与 v2.0.2 那次事故同源 —— 包里装着错架构的 `node.exe` 时,
|
||||
ARM Windows 上「能启动、能 listen,但 TLS 握手不完成」,而 macOS 侧 100% 复现不出。
|
||||
控制台更甚:它内含 node 运行时,还要用这个 node **托起本机被控端**,即自己就是 TLS 服务端。
|
||||
- **为什么用原生 ARM runner 而非交叉编译**:`prepare-console.mjs` 的自检要**真执行 `node.exe`**,
|
||||
x64 机器跑不了 arm64 二进制;交叉编译会逼着引入「跳过自检」这类**削弱验证**的开关 ——
|
||||
而产出错架构运行时的时候,自检正是唯一能拦住它的那道闸。
|
||||
- **`cfg!(windows)` 分支第一次被编译器看到**:macOS 上的 `cargo check` **不会编译 `cfg(windows)` 代码**,
|
||||
故 `taskkill` 收进程树、PowerShell 计划任务、`node.exe` 路径等分支此前从未产出过 Windows 产物。
|
||||
- **实测产物**:x64 `.msi` **34.2 MiB** / NSIS **23.2 MiB**;arm64 NSIS **19.5 MiB**(均低于 40MB 目标)。
|
||||
`if-no-files-found: error` + `fail-fast: false`:一条架构没产出就报错,同时一条挂了不掩盖另一条的结果。
|
||||
- **尚未接入 `scripts/release.mjs`**:安装包目前只作为 CI artifact 产出,未随 Release 发布。
|
||||
|
||||
> **首个 run 两个 job 全红,但错的不是构建,是断言。**(run `38151366534`)
|
||||
> 两个 job 的失败点完全一致,而日志显示构建链路**一路全绿**:构建机架构(arm64 runner 上
|
||||
> `process.arch = arm64`,原生属实)、随包 `node.exe` 的 PE Machine(`0x8664` / `0xaa64`)、
|
||||
> `tauri build`、产物自举(打包内 node v22.23.3 真跑起打包内 sidecar)四步全 success。
|
||||
> 死掉的是本步自己写的守卫 —— 原断言「安装包体积 > 50MB」,理由是「含 Node 运行时约 80MB」,
|
||||
> 这**把未压缩的目录形态(`.app` ≈ 92 MiB)当成了压缩安装包的体积**:
|
||||
> LZMA 下 `node.exe` 自身能压到 1/3~1/4,实测 `.msi` 34.2 MiB、NSIS 19.5~23.2 MiB,阈值必然误杀。
|
||||
>
|
||||
> 更根本的问题是:**体积阈值属于「中间逻辑自洽」式断言**。包大不等于 `node.exe` 真在里面
|
||||
> (可能打进的是别的大文件),包小也不等于没在里面(压缩率会随 Node 版本漂移)。
|
||||
> 判据必须落在**产物内容**上,不能落在**间接指标**上。
|
||||
>
|
||||
> 改为**解开安装包查内容**(`.msi` 走 `msiexec /a` 管理安装、NSIS 走 `/S /D=` 静默安装,
|
||||
> 两者都是各自格式的核心能力,不引任何外部依赖),一次钉死三件事:
|
||||
> ① `node.exe` / `sidecar.mjs` / `agent.mjs` **三样随包资源必须都在**(缺任一,真机上就是
|
||||
> 「应用能开、但托不起被控端」);② `node.exe` 的 **PE Machine = 目标架构**(最终交付物级架构断言);
|
||||
> ③ `node.exe` 解压后 **> 40MB**(体积在这是**正确用法**:内容已验明后的自明性检查,
|
||||
> 而不是「运行时在不在」的**替代判据**)。附带收益:**真装一遍本身就是安装器冒烟**。
|
||||
> 另给该步加 `timeout-minutes: 8`,避免静默安装万一卡住把 job 拖满 60 分钟。
|
||||
>
|
||||
> **第二个 run(`38152850819`)两 job 仍同点红,但已是另一处原因,且同样是静默失效**:
|
||||
> 日志里 `msiexec /a 失败(退出码 )` —— `$LASTEXITCODE` 是**空的**,而整步从开始到抛错
|
||||
> 只过了 **0.8 秒**。根因是 `msiexec.exe` 与 NSIS 安装包都是 **GUI 子系统程序**,
|
||||
> PowerShell 用 `&` 调用时**既不等待其结束、也不设置 `$LASTEXITCODE`**
|
||||
> (`$null -ne 0` 判真 → 安装器还没跑完就抛错,报错读起来像「安装失败」,实为「压根没等」);
|
||||
> arm64 那条没有 MSI、直奔 NSIS,失败形态一模一样,正是同一根因的第二个样本。
|
||||
> 修法:改 `Start-Process -Wait -PassThru` 读 `.ExitCode`;参数拼成**单个字符串**传,
|
||||
> 避免数组被自动加引号而破坏 NSIS `/D=` 的「原样、不加引号、且居末」硬要求;
|
||||
> 加 `Wait-PayloadRoot`(30 秒收敛)防安装器「自己先退出、子进程继续装」的竞态误报。
|
||||
>
|
||||
> **第三个 run(`38154282653`)双绿**,断言给出产物实证 —— x64 `.msi`/`-setup.exe` 内均解出
|
||||
> **`node.exe` 82.9 MB / PE `0x8664`**、arm64 `-setup.exe` 内解出 **`node.exe` 73.5 MB / PE `0xaa64`**,
|
||||
> 三样随包资源齐全(sidecar 232 KB、agent 1060 KB)。
|
||||
> 这一步证明的恰是体积断言永远无法区分的那件事:**19.5MB 的压缩包里真的装着 73.5MB 的 arm64 运行时**。
|
||||
|
||||
### 变更 —— 界面一律不使用图标
|
||||
|
||||
- **去掉全部 emoji 图标**:路由表 `meta.icon` 字段整体移除,侧栏导航由「图标 + 标题」改为**纯文字**。
|
||||
触发原因是一次真实的渲染事故:emoji 图标在非系统字体栈的渲染器里退化成**豆腐块**(截图对比可见),
|
||||
而「哪套字体栈一定带 emoji」跨平台并不成立 —— 与其赌字形,不如不用。
|
||||
- **`n-alert` / `n-empty` / `n-popconfirm` 的内置图形一并关闭**:这三个组件的 `showIcon` 默认均为 `true`,
|
||||
故 16 处 `n-empty` 的空态插画、7 处 `n-alert` 的类型图标、3 处 `n-popconfirm` 的询问图标全部消失,
|
||||
只剩文字与配色(严重程度仍由 `type` 的底色/边框区分,信息不丢)。
|
||||
`n-empty` 是**空态里的插画**(比图标更「图形」),在 9 个页面都有,是最容易漏掉的一类。
|
||||
- **夹在文案里的符号也去掉**:`TerminalView` 的「⚠ 高权限操作,请谨慎」保留纯文字。
|
||||
- **清理随包的模板残留资产**:删掉 `public/vite.svg`、`public/tauri.svg`、`src/assets/vue.svg`
|
||||
与 `index.html` 的 favicon 声明。`public/` 下的文件会被整体复制进产物,
|
||||
此前等于把 Vite / Tauri 的 logo 一起发了出去;`index.html` 的标题也还是模板默认值
|
||||
(原生窗口标题取自 `tauri.conf.json`,不受影响),本次一并改齐。
|
||||
|
||||
> **边界(以下不算「界面图标」,未动)**:① 托盘图标 —— 菜单栏没有图标就点不到,
|
||||
> 关窗后应用将无法寻回,属**功能必需**;② 被控端 CLI 自身打印的文本标记(`⚠️`/`ℹ️`/`✅`,
|
||||
> 见 `apps/agent/src/index.ts`、`packages/client/src/keys.ts`、`apps/agent/src/metrics.ts`)——
|
||||
> 它们是**程序输出内容**,会原样出现在「服务日志」页里,但不是界面元素;
|
||||
> ③ 控件的结构性部件(下拉箭头、折叠箭头、输入框清除钮、表格排序箭头)。
|
||||
|
||||
### 变更 —— 配色收敛为两套中性灰阶主题
|
||||
|
||||
- **两套可切换主题:`简约白`(浅)与 `高级灰`(深)**,侧栏底部一行切换,选择存 `localStorage`。
|
||||
两套都**只用中性灰阶**:去掉原先的青色品牌字(`#63e2b7`)与 naive-ui 默认的绿色主色,
|
||||
主按钮 / 开关激活态 / 进度条 / 勾选框统一改吃「主色位 = 中性灰」这一个值。
|
||||
- 白主题的**页底刻意不是 `#ffffff`**(`#f4f4f5`):卡片本身是白的,两者同色就只能靠 1px 分线撑层次,
|
||||
整页发「糊」;压一档浅灰后层次为「页底 < 侧栏 < 卡片」,且仍是白为主。
|
||||
- **语义色不是去掉,而是降饱和**(灰绿 / 灰琥珀 / 灰红 / 灰蓝)。
|
||||
这是运维工具,`运行中 / 出错` 必须一眼可辨,全部灰化会导致误读;故它们只出现在徽章与告警这类小面积上,
|
||||
底色观感仍是「白 + 灰」。严重程度在关闭图标后本就更依赖 `type` 的底色/边框。
|
||||
- **色值只有一份定义**(`src/theme/index.ts` 的 `Palette`):既生成 naive-ui 的 `themeOverrides`,
|
||||
又由 `App.vue` 在运行期注入成 `--na-*` CSS 变量供自定义样式使用。
|
||||
原因是若「TS 调色板」与「CSS 写死的色值」各存一份,换主题时会**一半新一半旧且不报错、类型也抓不到**
|
||||
—— 这正是本轮要消灭的失效模式。
|
||||
- **清掉 17 处写死的色值**(散在 8 个文件:日志三流着色、代码块/预览底色、节点选中边框、历史悬停等),
|
||||
全部改为 `var(--na-*)`。复扫后自定义样式里的硬编码色为 **0**。
|
||||
- **主题偏好只存 `localStorage`,不落 Rust 侧 settings**:那是给「仅服务模式」(无窗口、常驻托盘)
|
||||
也要生效的偏好用的,而主题是纯图形偏好 —— 没有窗口时毫无意义。为一件纯前端的事去动 Rust 命令面与契约守卫不划算。
|
||||
- **首帧防闪**:`index.html` 内联一段极小脚本,在 Vue 接管前先把上次主题的底色刷上,
|
||||
否则「高级灰」用户每次启动都会看到一帧刺眼的白。此处**刻意重复了 2 个底色值**(真值在 `theme/index.ts`),
|
||||
因为在此之前没有任何模块可依赖;已在两处都注明「改底色时同步」。
|
||||
|
||||
### 说明 —— 本轮**刻意不升版本号**
|
||||
|
||||
本轮新增的全部是**控制台**功能,**被控端能力集合未变**(未增删任何 `system.*` / `fs.*` / `app.*` 能力)。
|
||||
而 `scripts/lib/bundle-agent.mjs` 会把**根 `package.json` 的 version 注入 `agent.mjs`**(`__AGENT_VERSION__`):
|
||||
一旦为控制台功能去升版本号,被控端就会**谎报**一个它并未包含的版本 —— 属版本纪律所禁止。
|
||||
故版本号维持 **2.0.2**,本批改动记入本节,待真正含被控端变更时由 `pnpm release minor` 一并升号。
|
||||
|
||||
### 修复 —— 图形能力在 200% 缩放下坐标错位(真机实证,**属被控端变更**)
|
||||
|
||||
> ⚠️ 与上一节结论**不同**:本节改的是**被控端**(`apps/agent/src/**`)。
|
||||
> 按上一节自己立的规矩,发版时**必须**升版本号(PATCH,→ 2.0.3),
|
||||
> 否则 `agent.mjs` 会谎报一个并不包含本修复的版本。
|
||||
|
||||
真机(Parallels 27 + Win11 ARM64,**200% 缩放**)暴露的缺陷:
|
||||
|
||||
被控端的 PowerShell 助手进程**默认 DPI 不感知**,于是**同一个进程里并存两套坐标系**:
|
||||
|
||||
| 调用 | 不感知时的结果 | 实际危害 |
|
||||
|---|---|---|
|
||||
| `Screen.Bounds` / `SystemInformation.VirtualScreen` | **逻辑**尺寸 1800×1010 | `screen.info` 报错尺寸;`screen.capture` 缺省 region 一并错 |
|
||||
| `CopyFromScreen` | 按**物理**像素裁剪 | `screen.capture` 缺省**只截到左上 1/4** |
|
||||
| `SetCursorPos` | 被系统 **×2** | `input.mouse.*` 喂 100,100 → 光标落到 200,200 |
|
||||
| UIA(`screen.find`) | **物理**坐标(与进程是否感知无关) | 与上面三项**口径不一致** |
|
||||
|
||||
净效果:`screen.find` 给出的物理矩形**不能**直接喂给 `input.mouse.*`,必须人工除以 `scale`
|
||||
(上一轮就是靠这个临时绕法才点中的主题按钮 —— **能绕,但属用户侧的隐式约定,必须消灭**)。
|
||||
|
||||
- **新增 `apps/agent/src/util/dpi.ts`**:`PS_DPI_AWARE` 常量,把 `SetProcessDPIAware()` 提到脚本首行,
|
||||
并附完整真机实证表与「不要删」警告。
|
||||
- **注入到全部坐标相关脚本**:`capabilities/screen.ts`(`listDisplays` / `captureWindows`)、
|
||||
`capabilities/input.ts`(`buildInputPsScript` + 点击后读回光标那一次)、
|
||||
`capabilities/window.ts`(`WIN_HELPER_PRELUDE` —— `window.list` / `window.focus` 都走它)、
|
||||
`capabilities/record.ts`(全屏探测)。
|
||||
- **从 OCR 前奏的 `try{}` 里移除** `SetProcessDPIAware`(`win-ps/prelude-ocr.ts`):
|
||||
放在 `try` 里一旦抛错就被 catch 吞掉 → 助手静默退回不感知。
|
||||
- **新增 8 条守卫(`tests/unit/dpi-awareness.test.mjs`)**,关键设计是**断言「实际下发的脚本文本」
|
||||
而非「源码里出现过常量」** —— 首版用源码扫描,注入故障实测**不会变红**(同文件别处也引用了该常量);
|
||||
改为断言 `buildInputPsScript()` / `buildListDisplaysScript()` / `buildCaptureScript()` / `WIN_HELPER_PRELUDE`
|
||||
的脚本串后,二次注入故障**精准变红**。另含一条「`::SetProcessDPIAware()` 恰好出现 1 次」防重复注入
|
||||
(正则用 `::` 区分**调用**与 P/Invoke **声明**,否则把声明也算一次 → 误报 2 次)。
|
||||
- **修复后真机复验(三项全过,零换算)**:
|
||||
|
||||
| 断言 | 结果 |
|
||||
|---|---|
|
||||
| `screen.info` | 报 **3600×2020**,与裸 `GetSystemMetrics`(感知后)一致 ✅ |
|
||||
| `input.mouse.move(x,y)` → 读回光标(物理) | 四次抽样**精确等于** (x,y) ✅ |
|
||||
| `screen.find` 物理矩形 → `input.mouse.click` **直接点击** | 移动后光标 = 物理中心;主题单选框 `selected` 翻转且**可逆** ✅ |
|
||||
|
||||
### 修复 —— 安全加固批次:18 条实机复现缺陷(**属被控端变更**)
|
||||
|
||||
> 来源:一轮「静态审查 + 真机实测」的完整缺陷普查(报告 `bug-report-2026-10-13.html`)。
|
||||
> 基线 v2.0.2 / `ad90634` 上 `test:unit` **291/291** + `test:e2e` **30/30** **全绿** ——
|
||||
> 也就是说 **18 条全部落在既有覆盖之外**。分级 **2×P0 / 5×P1 / 10×P2 / 1×P3**。
|
||||
|
||||
#### P0-1 命令注入(`window.focus` 的 `title`)—— 而且它是**静默**的
|
||||
|
||||
原实现把 `JSON.stringify(title)` 直接插进 PowerShell 的**双引号**串。PS 里 `\` 不是转义符,
|
||||
双引号串还会做变量插值与 `$(...)` 展开 —— 于是 `title` 里的 `"; <命令>; "` 被当代码执行。
|
||||
|
||||
真机实测里最险的一点:**接口返回的是「正常业务错误」**(`E_EXECUTION_FAILED: 未找到匹配的窗口`),
|
||||
而 payload 已经把文件创建出来了 —— 调用方从返回值里完全看不出异常。
|
||||
|
||||
- 新增 `apps/agent/src/util/quote.ts`:`psLit` / `shLit` 作**单一真源**
|
||||
(PS 用 `''` 转义、sh 用 `'\''`),另加 `assertSingleQuotedLiteral()` 与裸参前提校验
|
||||
`isSafeBareWindowsPathArg()`(用于 `-File <path>` 这种不能用引号的位置)。
|
||||
- 全仓注入点收敛到 `psLit`/`shLit`:`window.focus` / `uiaFind` / `ocrFind` /
|
||||
`system.ts` 的计划任务参数 / `ps-helper.ts` 的 `-File` 裸拼 等 **14 处**;
|
||||
原有的「双引号」断言同步**翻转**为单引号 —— 旧断言的方向本就是错的(CI 断言会过期且方向可能反)。
|
||||
- 新增 `tests/unit/quote.test.mjs`:既有行为断言,也有**源码形态守卫**
|
||||
(禁止 `${JSON.stringify(` 拼进 PS 模板、禁止再手写 `replace(/'/g,"''")`)。
|
||||
形态守卫带 `stripComments()` —— 否则注释里**讲解漏洞**的文字会被当成犯案。
|
||||
|
||||
#### P0-2 ACL 省略 `clients` → 每条 invoke 崩掉被控端
|
||||
|
||||
`{"acl":{"default_effect":"deny"}}`(没写 `clients`)会让 `aclPolicy.clients.find(...)` 抛错,
|
||||
而这段**落在 `if (hasAcl)` 块之外**、无条件执行 —— 配置少写一个字段,整个被控端即不可用。
|
||||
|
||||
⚠️ 值得记一笔:**静态审查当时判它是「fail-open 放行」,实测是崩溃**(后果更严重)。
|
||||
静态审查必须配实测 —— 这是本轮最直接的一条教训。
|
||||
|
||||
#### 崩溃链的共同根因:没有 `unhandledRejection` 兜底
|
||||
|
||||
`ws.on('message')` 用 `void handleMessage()` 起 Promise 且**没有 `.catch`**,
|
||||
而全仓没有任何 `process.on('unhandledRejection')` —— Node ≥22 遇到未处理的 rejection **直接终止进程**。
|
||||
`verifyNonce` 有 try/catch,而 psk 默认路径走的 `verifyHmac` 没有。于是两条畸形报文就够了:
|
||||
`hello {protocol:123}` 与 `auth {hmac:1}` 都能在**认证之前**把被控端打死。
|
||||
|
||||
- `apps/agent/src/index.ts` 新增进程级兜底:日志每次都打、**审计按 10s 限流**
|
||||
(否则畸形报文可高频刷爆 `audit.log`)。
|
||||
- 明确**不**退出进程:源头已有校验与 `.catch`,可用性优先(取舍写在注释里)。
|
||||
|
||||
#### 其余 15 条
|
||||
|
||||
| 级别 | 缺陷 | 修法 |
|
||||
|---|---|---|
|
||||
| P1 | `clients: []` → **fail-open 全放行** | 判据改为「对象是否存在」而非「数组长度」 |
|
||||
| P1 | 畸形 hello / 畸形 auth → 崩且**免认证** | 控制面报文类型校验 + 上述进程级兜底 |
|
||||
| P1 | macOS `screen.find` 的 text 拼 bash → `$(...)` 实测执行 | 走 `shLit` |
|
||||
| P1 | 控制台审计筛选项与协议 enum 漂移(除 `invoke` 外全 `E_PARAM_INVALID`) | 新增 `packages/protocol/src/audit-types.ts` 作**唯一真源**,manifest enum 由它派生,并加编译期双向守卫 |
|
||||
| P2 | `monitor.start` 的 target 注入;monitor id 路径穿越(读/删) | 走 `psLit` + 统一过 `guardPath` |
|
||||
| P2 | `keys{}` + `client_id:"constructor"` 崩 | 改 `Object.prototype.hasOwnProperty.call`(原型链取值) |
|
||||
| P2 | 事件订阅跨控制端可见/可删;`event.watch` 绕开 `fs_roots` | 有状态资源落 owner + 统一路径白名单 |
|
||||
| P2 | `screen.capture` 的 region 无上界 | `assertRegionSane()`,并**提前到枚举显示器之前**(fail fast) |
|
||||
| P2 | `ipMatchesCidr('8.8.8.8','10.0.0.0/')` → `true`(空掩码被 `Number('') === 0` 当成 `/0`) | 掩码解析加严,补 `/0` 与 `/32` 对照组用例 |
|
||||
| P3 | CLI `--timeout` 静默失效 | `parseArgs` 补声明(`strict:false` 下未声明选项被当 boolean,值掉进 `positionals`,双重危害) |
|
||||
| P3 | 拒绝类事件不写审计 | 补 `request.rejected` / `agent.error` 两类事件 |
|
||||
|
||||
#### 回归
|
||||
|
||||
`test:unit` **312/312**(+21)、`test:e2e` **33/33**(+3 组硬化用例)、`test:rust` **11/11**。
|
||||
e2e 新增的 ACL / 畸形报文用例有个必须记住的姿势:**每个场景独占端口 + 独立临时 `NODEAGENT_HOME`** ——
|
||||
首版复用了同一端口,结果连上了上一块的残留实例,把对照组误判成「实现有缺陷」。
|
||||
|
||||
### 新增 —— 控制台单实例保护(症状:Windows 端起了两个被控端)
|
||||
|
||||
真机观察到两桩现象:① Windows 上同时跑着**两个被控端**;② **旧的服务端还在运行时,控制台起不来**。
|
||||
根因是同一个:控制台**没有单实例锁**,再启动一次就是第二个进程 → 第二个 `AgentManager`
|
||||
→ 抢同一个 8765 端口 → 后起的那个起不来。另有一条陈旧的 `na-gui` 计划任务指向
|
||||
测试部署目录,也在抢同一个端口。
|
||||
|
||||
- 引入 `tauri-plugin-single-instance`(**必须注册为第一个插件**)。
|
||||
- 第二次启动**不是**静默退出,而是「聚焦既有窗口 + 发 `na-second-instance` 事件」让前端刷新状态。
|
||||
这一点是刻意为之:自启动的实例是**无窗口**的,若第二次启动静默退出,用户双击图标只会看到
|
||||
「什么都不发生」—— 那比缺陷本身更难排查。带自启参数(`AGENT_ONLY_FLAG`)时跳过显示。
|
||||
- 端口占用的报错补上「怎么找出占用者」:`netstat -ano | findstr :<port>` /
|
||||
`lsof -nP -iTCP:<port> -sTCP:LISTEN`,并新增一条单测。
|
||||
|
||||
### 修复 —— 发版脚本漏改 Cargo 清单(版本号会分叉)
|
||||
|
||||
`release.mjs patch --dry-run` 实测:只改 **8 个 `package.json` + 1 个 `tauri.conf.json`**,
|
||||
而 `apps/console/src-tauri/{Cargo.toml,Cargo.lock}` 里**同样存着 2.0.2** —— 发版后必然滞留,
|
||||
与 `tauri.conf.json` 悄悄分叉,且**不报错**(Tauri 打包以 `tauri.conf.json` 为准)。
|
||||
|
||||
- 版本写入抽成 `scripts/lib/version-write.mjs`(纯函数、可测),`release.mjs` 调用它。
|
||||
- `Cargo.toml` **只在 `[package]` 段**改(非 package 段里也可能有独占一行的 `version = "…"`);
|
||||
`Cargo.lock` **按 `[[package]]` 块 + crate 名定位**(全文盲替换会改坏 `thiserror 2.0.21` 这类依赖版本)。
|
||||
- `release.mjs` 的「脏文件白名单」改为取自**实际改写结果**(`changed`),不再手写文件名列表 ——
|
||||
手写的那份一旦与写入逻辑漂移,要么拦下本该放行的(发版卡死),要么放过不该放行的(静默推走改动)。
|
||||
- 新增 `tests/unit/version-write.test.mjs`(9 条):在临时目录铺**真实文件**并 `git init` / `git add`
|
||||
(`versionTargets` 走 `git ls-files`,不真进索引就会假绿),跑完**读回文件**断言;
|
||||
另含一条**仓库版本号一致性守卫**,发版漏项会直接变红。
|
||||
- 注入 4 处故障实测:编排漏 Cargo / `Cargo.toml` 全文盲替换 / `Cargo.lock` 按位置盲改 /
|
||||
仓库版本分叉 —— **全部变红**,还原后全绿。
|
||||
|
||||
> ⚠️ 本批**含被控端变更**(`apps/agent/src/**` 十余个文件),按版本纪律,发版时
|
||||
> `pnpm release patch`(→ **v2.0.3**)**必须**执行,否则 `agent.mjs` 会谎报一个并不包含这些修复的版本。
|
||||
|
||||
---
|
||||
|
||||
## [v2.0.2] —— 2026-10-11
|
||||
|
||||
> 一轮真机验证(Parallels 27 + Win11 ARM)抓出的**发布事故**修复批次。
|
||||
> 已公开发布的 `nodeagent-win-arm64.zip` 内装的其实是 **x64 的 `node.exe`** ——
|
||||
> 该包在 ARM Windows 上能启动、能 listen,却 **TLS 握手不完成**(控制端只见 `E_TIMEOUT`),
|
||||
> 实际不可用。本版重出资产,并把「交付物架构」直接钉进发版脚本与 CI。
|
||||
|
||||
### 新增
|
||||
|
||||
- **控制台自带 Node 运行时,目标机器无需预装 Node**(`pnpm --filter nodeagent-console bundle`)
|
||||
—— 控制台的 sidecar 是 `.mjs`,此前只能在装了 Node 的机器上运行(`resolve_node` 回退系统 node)。
|
||||
现由 `scripts/prepare-console.mjs` 把 Node 运行时准备为 Tauri resource 随包分发。
|
||||
实测:`.app` **90.8 MiB**(含运行时 86MB)/`.dmg` **33.78 MiB**(分发件,仍低于 40MB 目标)。
|
||||
作为对照,本机实测 Electron 运行时本体(`Electron Framework.framework`)为 207MB ——
|
||||
即 Tauri + Node 的运行时开销不到 Electron 的一半。
|
||||
- **`apps/console/scripts/verify-bundle.mjs`** —— 验证「打包产物能否自举」:真 spawn 打包内的 node
|
||||
去跑打包内的 sidecar,并走一次真实 JSON-RPC 握手。源码与单测都正常、只在产物中暴露的问题靠它拦下。
|
||||
|
||||
### 修复 / 规避(两处实测踩出来的约束)
|
||||
|
||||
- **macOS 上 `strip` 会使 Mach-O 代码签名失效 → 内核直接 `SIGKILL`(exit 137)**,
|
||||
且没有任何有用报错(不是警告、不是异常,就是静默无法执行)。
|
||||
实测对照:原样复制 ✓ / strip 不重签 ✗(137) / strip + `codesign --force --sign -` ✓。
|
||||
故准备脚本在 strip 之后**必须** adhoc 重签(省 20% 体积值得,但这步不能少)。
|
||||
- **Node 运行时不得声明进默认 `tauri.conf.json`** —— `bundle.resources` 是**编译期契约**
|
||||
(`tauri-build` 校验声明的文件必须存在),声明「运行期才就绪」的资源会让 `cargo check/test`
|
||||
在 build.rs 阶段直接失败。一律走 `tauri build --config` 注入。
|
||||
|
||||
### 修复 —— 被控端打包(ARM64)
|
||||
|
||||
- **`nodeagent-win-arm64.zip` 里装的其实是 x64 的 `node.exe`**。
|
||||
`pack.mjs` 下载 Node 运行时的缓存文件名是固定的 `node-runtime.zip`(**不含架构**):
|
||||
`release.mjs` 先打 x64 时把它缓存下来,随后打 arm64 时误判「已缓存」直接复用
|
||||
→ **两份公开包内的 `node.exe` 字节完全相同**(sha256 同为 `fdddbf45…`,PE Machine = `0x8664`)。
|
||||
后果极隐蔽:该包在 ARM Windows 上**能启动、能 listen**,但 **TLS 握手不完成**
|
||||
(控制端只见 `E_TIMEOUT`)—— 被控端实际不可用,而 macOS 侧 100% 复现不出来。
|
||||
修法:① 缓存文件名带上架构(`node-runtime-<arch>.zip`);
|
||||
② 解包后**断言 `node.exe` 的 PE Machine 与 `--arch` 一致**(缓存串包 / 手工塞错文件当场红)。
|
||||
- **CI 补「双架构包 PE 架构校验」**(`.github/workflows/windows-e2e.yml`)——
|
||||
此前 CI 只跑 `pnpm pack:win`(默认 x64)且只校验 x64 包,**arm64 打包零覆盖**,
|
||||
故该 bug 必然逃过 CI。现两条架构都打,并直接读最终 zip 内 `node.exe` 的 PE 头比对。
|
||||
- **发版脚本补「交付物架构断言」**(`scripts/lib/zip-pe-arch.mjs`)——
|
||||
`release.mjs` 生成资产前,直接解出**最终 zip 内**的 `node.exe`、读 PE 头比对架构,
|
||||
不符即**中止发版**。与 `pack.mjs` 的断言刻意分两层:后者是打包**流程内部**的自洽,
|
||||
这里钉的是**交付物本身**的性质。纯内置 `zlib` 实现,不依赖 `unzip` / `Expand-Archive`
|
||||
(Windows runner 上没有 `unzip`),另留 CLI 自检入口:
|
||||
`node scripts/lib/zip-pe-arch.mjs <zip路径> <x64|arm64>`(已注入故障实测会红)。
|
||||
|
||||
### 真机实测(ARM64 Windows 虚拟机)
|
||||
|
||||
- **arm64 包**:TLS 握手成功 → PSK 认证 → 声明 51 项能力(两端一致)→
|
||||
`system.info` / `status` / `process.list` / `service.list` / `shell.exec` /
|
||||
`audit verify` 全部正常,`arch` 报告为 `arm64`(原生,非模拟层)。
|
||||
- **x64 包(对照,同机同参)**:`connect` 报 `E_TIMEOUT` —— **复现**了「能 listen 但握手不完成」。
|
||||
→ 证实「按架构出包」不是理论洁癖,而是 ARM Windows 上的硬需求。
|
||||
- 已知边界:被控端若以 **SYSTEM(会话 0)** 手动启动,`screen.capture` 等图形能力会失败
|
||||
(会话 0 无交互桌面)。正式安装路径不受影响 —— `install.ps1` 的计划任务 principal
|
||||
用的是登录用户 + `LogonType Interactive`,注释已明写「图形能力可用」。
|
||||
|
||||
---
|
||||
|
||||
## [v2.0.1] —— 2026-10-11
|
||||
|
||||
> 一轮**真机验证**(Parallels 27 + Win11 ARM)带来的修复批次:在补齐 6 项待验证项的过程中,
|
||||
> **抓到并修掉 5 个真问题** —— 全部只有真机能发现(macOS 上 100% 测不出)。
|
||||
|
||||
### 修复
|
||||
|
||||
- **`control.ps1` 的 `$scheme:` 被当 drive 引用** —— PowerShell **解析期错误 → 整个脚本不可用**
|
||||
(stop / start / status 全废,且只报一行语法错、极易误判为别的问题)。改用 `${scheme}`;
|
||||
并新增守卫扫全仓 `.ps1` 的 `$ident:` 形式(含注入故障验证)。
|
||||
- **连接保活缺 pong 检测** —— 心跳只 `ping()` 而不检查 pong,且 ws 库默认不会因「ping 无 pong」断开;
|
||||
客户端进程已死(TCP 未发 FIN)时,连接会**永久占用 `max_connections` 名额**,
|
||||
叠加「有任务/订阅时清扫保守跳过」的策略 → **会把正常控制端挡在外面**(真机实测把自己锁住了)。
|
||||
现对静默超过 `idle_timeout_ms * 2` 的连接 `terminate()`,并让 pong 也算「活跃」。
|
||||
- **v26 自重启任务清理阈值过粗** —— 原用「早于 1 小时」,频繁 restart(间隔 < 1h)时旧任务照样累积;
|
||||
改为「**早于本 agent 启动时刻**」——语义精确:凡在**我启动之前**运行的任务,必然不是我的宿主。
|
||||
|
||||
### 新增
|
||||
|
||||
- **`scripts/pack.mjs --arch <x64|arm64>`** —— Windows 包分架构。**x64 包在 ARM Windows 上协议层不可用**
|
||||
(能启动、能 listen 8765,但 **TLS 握手不完成** —— Prism 模拟下异常,现象极具误导性);
|
||||
ARM 机器须用 arm64 包。`release.mjs` 随之**同时产出两个架构**,`latest.json` 新增 `zip_arm64` 字段
|
||||
(**只增不减**,旧控制端忽略即可)。
|
||||
- **`scripts/verify-b2.mjs`** —— v23 连接层三防护的真机验证脚本(`idle` / `maxconn` / `ban` 三模式),
|
||||
全部走真实连接而非 mock。
|
||||
- **`docs/VM-VERIFICATION.md`** —— 无 Windows 真机时用 Parallels Win11 ARM 补验证的完整清单
|
||||
(含实测结果、能补/不能补矩阵、8 条排查项)。
|
||||
|
||||
### 真机验证状态(本版实测)
|
||||
|
||||
| 项 | 结果 |
|
||||
|---|---|
|
||||
| A 组(`verify.mjs` 17 项) | 15 通过 / 0 失败 / 1 跳过 |
|
||||
| v1.5 四项(字符串热键/预设别名/长按/后台投递) | 全过(`route=post` 实测命中 138 窗口) |
|
||||
| v23 三防护 | 三场景全过(封禁 / 上限 / 空闲断开) |
|
||||
| v26 自重启清理 | 验证并改进(阈值改为 agent 启动时刻) |
|
||||
| 锚点端到端 | 全过(含云盘链外留存) |
|
||||
|
||||
## [v2.0.0] —— 2026-10-10
|
||||
|
||||
> 自 `v1.6.0` 起累计 37 个提交。**能力数 43 → 51**,单测 195 → 273,端到端 30。
|
||||
@@ -14,10 +450,16 @@
|
||||
> **兼容性**:完全向后兼容 —— 未改任何既有能力的参数名或语义,未新增必填参数,
|
||||
> 返回结构只增不减(遵循 `docs/VERSIONING.md` 的四条铁律)。
|
||||
>
|
||||
> **已知限制**:本版发布时被控端整机离线,`v1.5` 的预设别名/长按/后台投递/热键序列
|
||||
> 与 `v26` 的自重启任务清理策略**尚未经真机验证**(后者已在本地单测锁定判定条件)。
|
||||
> **验证状态**:本版发布时被控端整机离线,`v1.5.0` 的预设别名/长按/后台投递/热键序列
|
||||
> 与 `v2.0.0` 的自重启任务清理策略**当时未经真机验证**(后者已在本地单测锁定判定条件)。
|
||||
>
|
||||
> ✅ **2026-10-11 已补齐**:在 Parallels 27 + Win11 ARM 虚拟机上完成真机验证 ——
|
||||
> A 组 15 通过/0 失败、v1.5 四项全过、v23 三防护三场景全过、v26 清理策略**验证并改进**
|
||||
> (阈值由「1 小时」改为「本 agent 启动时刻」)、锚点端到端跑通(含云盘链外留存)。
|
||||
> 并**顺带发现并修复 5 个真问题**(含 `control.ps1` 解析期错误、连接保活缺 pong 检测等)。
|
||||
> 详见 `docs/VM-VERIFICATION.md`。
|
||||
|
||||
### 新增:语义可断言(v1.6 / v1.7)
|
||||
### 新增:语义可断言(v1.6.0 / v2.0.0)
|
||||
|
||||
- **`gui.await`** —— 等条件成立再返回。四类条件(`window` / `control` / `process` / `file`)
|
||||
× 两种状态(`present` / `absent`),复用既有能力,零新增平台探测代码。
|
||||
@@ -31,7 +473,7 @@
|
||||
例:`[{window:"安装完成"},{control:"错误"}]` = 「装成功或报错,先出现的算」。
|
||||
- CLI:`nodeagent await`;MCP:`na_await`。
|
||||
|
||||
### 新增:状态可回看(v1.8)
|
||||
### 新增:状态可回看(v2.0.0)
|
||||
|
||||
- **`log.query`** —— 日志**在被控端侧过滤**后只回匹配行(`pattern` / `level` / `since` /
|
||||
`offset` / `limit` / `tail`)。流式逐行读,几万行日志也不整份拉回。
|
||||
@@ -40,7 +482,7 @@
|
||||
`metric`(CPU·内存)。**摘要直接给结论**:断了几次、最长断多久、值域。
|
||||
- CLI:`nodeagent monitor` / `log`;MCP:`na_monitor` / `na_log`。
|
||||
|
||||
### 新增:安全加固(v23 / v24 / v25)
|
||||
### 新增:安全加固(v2.0.0)
|
||||
|
||||
- **连接层三防护**:
|
||||
- 握手失败封禁 —— 连续失败达阈值 → 指数退避(封顶 24h),成功即清零,支持白名单豁免
|
||||
|
||||
@@ -34,7 +34,7 @@ nodeagent 把一台 Windows 机器的能力**标准化成一组可授权、可
|
||||
| **状态采样** | `log.query` · `monitor.start` · `monitor.report` · `monitor.stop` · `monitor.list` · `monitor.delete` | 日志**在被控端侧过滤**(不上传整份)/ 定时采样落盘 + **摘要回看**(断几次、最长断多久、CPU/内存值域) |
|
||||
| **审计锚定** | `system.audit.head` · `system.audit.anchor` | 链头可读 + 锚点**追加**落盘,让「有 root 者整链重写」可被发现 |
|
||||
|
||||
## 快捷键(hotkey)控制 —— v1.5
|
||||
## 快捷键(hotkey)控制 —— v1.5.0
|
||||
|
||||
`input.key.press` 支持 **4 种输入形式**(一次只用一种):
|
||||
|
||||
@@ -84,7 +84,7 @@ OEM 符号键(`oem_plus`/`oem_comma`/`oem_period`… → 支持 `win+d`、`ctr
|
||||
|
||||
> MCP 侧同名工具为 `na_key`(`action: "press"`),支持同一套参数。
|
||||
|
||||
## 等待条件 gui.await —— v1.6
|
||||
## 等待条件 gui.await —— v1.6.0
|
||||
|
||||
GUI 操作从「按一下→睡几秒→截图碰运气」变成**可断言**流程:
|
||||
|
||||
@@ -105,7 +105,7 @@ nodeagent --node win invoke gui.await --args '{"condition":"file","path":"C:\\lo
|
||||
|
||||
**典型闭环**:`await control(text:"立即安装")` → `mouse.click(x, y)` → `await control(text:"完成", timeout_ms:60000)` → `click` → `await absent(text:"安装中")`。
|
||||
|
||||
### 属性谓词与组合(v1.7)
|
||||
### 属性谓词与组合(v2.0.0)
|
||||
|
||||
`screen.find` 现在返回 UIA 属性,`gui.await` 可据此等**状态**而不只是「出现」:
|
||||
|
||||
@@ -130,7 +130,7 @@ nodeagent --node win await --any_of '[{"condition":"window","title":"安装完
|
||||
|
||||
---
|
||||
|
||||
## 状态采样:日志过滤与定时采样 —— v1.8
|
||||
## 状态采样:日志过滤与定时采样 —— v2.0.0
|
||||
|
||||
治**间歇性**问题(代理不通、杀软拦截、端口时开时闭)—— `system.status` 只能看当下快照,
|
||||
而 `event.watch` 只解决「有事件时通知」,没有「**持续记录并回看**」。
|
||||
@@ -151,7 +151,7 @@ nodeagent --node win monitor stop probe
|
||||
|
||||
---
|
||||
|
||||
## 审计链外部锚定 —— v25
|
||||
## 审计链外部锚定 —— v2.0.0
|
||||
|
||||
链内哈希只能发现「改一条」。**有 root 的攻击者可以整链重写** —— 从零重建一份自洽的假日志,
|
||||
`audit.verify` 照样通过。外部锚定是唯一能戳破它的手段:把链头记到**链外**。
|
||||
@@ -168,7 +168,7 @@ nodeagent --node win audit anchor --note "每日例行" # 追加一个锚点
|
||||
> 真正发挥价值的前提:把锚点文件指向**另一台机器 / 网盘同步目录**。
|
||||
> 本机文件被整盘重写时,锚点也随之消失 —— 那就等于没锚。
|
||||
|
||||
**51 项能力** · **41 个 MCP 工具** · **269 项单元测试** · **30 项端到端用例** · **9 项 Rust 单测**(CI 在真实 Windows / Linux runner 上验证)
|
||||
**51 项能力** · **41 个 MCP 工具** · **269 项单元测试** · **30 项端到端用例** · **3 项 Rust 壳单测**(真跑 Node sidecar)(CI 在真实 Windows / Linux runner 上验证)
|
||||
|
||||
> 关键里程碑:**GUI 语义**(`window.list` + `screen.find`,UIA 找不到自动降级 OCR)
|
||||
> 让 AI 从「看得到画面但读不懂界面」变成「按名字取坐标点下去」。
|
||||
@@ -340,11 +340,20 @@ nodeagent pull "C:\big.iso" --out ./big.iso # 下载(大文件自动
|
||||
由控制台经 loopback 直连(复用同一个 Agent,不做第二套被控端实现)。
|
||||
|
||||
技术栈 Tauri 2(Rust 壳 + 系统 WebView)+ Vue 3 + TypeScript + Pinia + Naive UI。
|
||||
协议客户端是 `crates/nodeagent-client`(Rust 复刻 `@nodeagent/client`,
|
||||
协议客户端由 **Node sidecar**(`apps/console/sidecar/`)承担(复用 `@nodeagent/client`,
|
||||
Tauri 侧经 Cargo path 依赖),**不引入第二套协议实现**。
|
||||
|
||||
**打开即用、全程不碰命令行**:应用自带 Node 运行时与被控端脚本,
|
||||
默认页面就是「本机服务」——点按钮即可启动 / 停止 / 重启本机被控端,
|
||||
并支持检查更新、查看日志、后台常驻(托盘)、开机自启。
|
||||
|
||||
**界面**:导航为**纯文字**(不使用图标);配色为两套中性灰阶主题 ——
|
||||
「简约白」与「高级灰」,侧栏底部一键切换。
|
||||
|
||||
| 页面 | 能力 |
|
||||
|---|---|
|
||||
| **本机服务**(默认) | 本机被控端启停/重启、连接本机、节点 ID 与密钥、开机自启、后台运行、异常自动重启、检查并应用更新 |
|
||||
| **服务日志** | 被控端 stdout/stderr 实时推送、关键字过滤、暂停接收、导出、清空 |
|
||||
| 设备管理 | 连接/断开、保存多台设备、能力清单与握手元信息、实时日志 |
|
||||
| 状态总览 | CPU / 内存 / 磁盘 / 网卡,3s 轮询 |
|
||||
| 终端 | `system.shell.exec`(可设超时)、历史回放 |
|
||||
@@ -354,15 +363,37 @@ Tauri 侧经 Cargo path 依赖),**不引入第二套协议实现**。
|
||||
| 审计 | `system.audit.list` 筛选 + `system.audit.verify` 链完整性校验 + 导出 JSONL |
|
||||
|
||||
```bash
|
||||
pnpm -r build # 含 console 前端
|
||||
cd apps/console && pnpm tauri dev # 开发态(起 Vite + Tauri 窗口)
|
||||
cd apps/console && pnpm tauri build # 打包(macOS 实测 .app ≈ 6.8 MiB)
|
||||
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`)。
|
||||
|
||||
> 控制台安装包目前**只作为 CI artifact 产出**,尚未接入 `scripts/release.mjs` 随 Release 发布。
|
||||
|
||||
⚠️ 平台差异:`app.*` 与 `system.service.list` **仅在被控端为 Windows 时可用**,
|
||||
非 Windows 被控端会返回 `E_UNSUPPORTED_PLATFORM`。控制台连接后会探测
|
||||
`system.info.os` 并**事前禁用**这些入口,而不是发出注定失败的请求。
|
||||
|
||||
⚠️ 控制台**已自带** Node 运行时与被控端脚本,目标机器无需预装 Node.js,也无需先手工安装被控端;
|
||||
但**开机自启与被控端的图形能力**要求运行在**登录用户会话**中(Windows 计划任务用
|
||||
`Interactive` + 登录用户;SYSTEM 会话 0 没有交互桌面,截图/窗口类能力会失败)。
|
||||
|
||||
## 安全模型
|
||||
|
||||
三层防护,逐层收窄权限:
|
||||
@@ -605,46 +636,53 @@ nodeagent/
|
||||
├── scripts/ # install.cmd / control.cmd(一键入口)/ install.ps1 / control.ps1 / verify.mjs
|
||||
├── tests/e2e/ # 端到端测试
|
||||
├── docs/ # 开发文档
|
||||
├── PRD.md # 产品需求文档
|
||||
├── docs/ # 项目文档(含 PRD)
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 路线图
|
||||
|
||||
| 阶段 | 目标 | 状态 |
|
||||
> 下表「**批次**」是**开发阶段的内部代号**(一批相关改动的集合),**不是版本号** ——
|
||||
> 项目的唯一版本号是 semver(见 `git tag` / `package.json`)。批次与发布版本的对应关系
|
||||
> 及书写规范见 [`docs/VERSIONING.md`](docs/VERSIONING.md) §0。
|
||||
|
||||
| 阶段(开发批次) | 目标 | 状态 |
|
||||
|---|---|---|
|
||||
| **v1** | 命令级接管 + 装软件 + 查状态 + 基本鉴权 + CLI + MCP | ✅ |
|
||||
| **v2** | 图形接管(`screen.*` 截屏 + `input.*` 键鼠)+ 输入控制开关 | ✅ |
|
||||
| **v3** | 零信任:Ed25519 身份认证 + 能力级 ACL | ✅ |
|
||||
| **v3+** | 审计日志(留痕 + 查询)+ 调用限速 | ✅ |
|
||||
| **v4** | 无感体验:局域网自动发现 + 断线自动重连 | ✅ |
|
||||
| **v5** | 多设备管理 + 文件传输(分块 / 大文件 / 原子写) | ✅ |
|
||||
| **v6** | Hub 中转:跨网段 / 公网接入(被控端主动外连,穿 NAT) | ✅ |
|
||||
| **v7** | **自持**:受控重启(配置变更后让自身生效,不再丢连接) | ✅ |
|
||||
| **v8** | **GUI 语义**:`window.list` / `window.focus` / `screen.find`(UIA 按名取坐标) | ✅ |
|
||||
| **v9** | `screen.find` **OCR 兜底**(自绘 UI 也能定位)+ MCP 工具同步 | ✅ |
|
||||
| **v10** | **长任务异步化**(`system.task.*`)+ 剪贴板 + CLI 常驻连接池 daemon | ✅ |
|
||||
| **v11** | **安全加固**:私钥入系统密钥库(Keychain/DPAPI)+ 审计链防篡改 + ACL 细化(IP/时段/按能力限速) | ✅ |
|
||||
| **v11+** | **操作面补完**:鼠标拖拽 / 录屏 / 剪贴板图片 / 多设备并发 / 一键升级 | ✅ |
|
||||
| **v12** | **事件订阅**(file/process/net 主动推送)+ **GUI 宏**(步骤序列回放)+ **Hub 并发多控制端** + 设备分组 | ✅ |
|
||||
| **v12.1** | **Hub 侧按控制端授权**(多令牌 + 按设备白/黑名单 + 注册准入) | ✅ |
|
||||
| **v12.2** | GUI **等待/重试语义**(`wait_ms`)—— 界面加载/动画不再假失败 | ✅ |
|
||||
| **v12.3** | **macOS 端 GUI 对齐**:AppleScript 窗口枚举/聚焦 + Vision OCR 元素定位(含 Retina 坐标换算) | ✅ |
|
||||
| **v12.4** | **事件直达 MCP 宿主**(实时推送 + 拉取双通路) | ✅ |
|
||||
| **v13** | **成功指标聚合**(PRD 2.2 四项)+ 依真实数据修正口径(时延分层) | ✅ |
|
||||
| **v14** | **PowerShell 常驻助手** —— GUI 能力提速 4~25 倍(预加载 C#/UIA,免每次现编译) | ✅ |
|
||||
| **v15** | `screen.find` **图像模板匹配**(纯图标/无文字控件盲区)+ 一堆真机根因修复 | ✅ |
|
||||
| **待办** | CI 单元测试步骤已就绪(分支 `ci/workflow-unit-tests`,**需 `gh auth refresh -s workflow` 授权后推送**);GUI 图像锚点匹配(自绘 UI 更稳) | 🚧 |
|
||||
| **批次 1** | 命令级接管 + 装软件 + 查状态 + 基本鉴权 + CLI + MCP | ✅ |
|
||||
| **批次 2** | 图形接管(`screen.*` 截屏 + `input.*` 键鼠)+ 输入控制开关 | ✅ |
|
||||
| **批次 3** | 零信任:Ed25519 身份认证 + 能力级 ACL | ✅ |
|
||||
| **批次 3+** | 审计日志(留痕 + 查询)+ 调用限速 | ✅ |
|
||||
| **批次 4** | 无感体验:局域网自动发现 + 断线自动重连 | ✅ |
|
||||
| **批次 5** | 多设备管理 + 文件传输(分块 / 大文件 / 原子写) | ✅ |
|
||||
| **批次 6** | Hub 中转:跨网段 / 公网接入(被控端主动外连,穿 NAT) | ✅ |
|
||||
| **批次 7** | **自持**:受控重启(配置变更后让自身生效,不再丢连接) | ✅ |
|
||||
| **批次 8** | **GUI 语义**:`window.list` / `window.focus` / `screen.find`(UIA 按名取坐标) | ✅ |
|
||||
| **批次 9** | `screen.find` **OCR 兜底**(自绘 UI 也能定位)+ MCP 工具同步 | ✅ |
|
||||
| **批次 10** | **长任务异步化**(`system.task.*`)+ 剪贴板 + CLI 常驻连接池 daemon | ✅ |
|
||||
| **批次 11** | **安全加固**:私钥入系统密钥库(Keychain/DPAPI)+ 审计链防篡改 + ACL 细化(IP/时段/按能力限速) | ✅ |
|
||||
| **批次 11+** | **操作面补完**:鼠标拖拽 / 录屏 / 剪贴板图片 / 多设备并发 / 一键升级 | ✅ |
|
||||
| **批次 12** | **事件订阅**(file/process/net 主动推送)+ **GUI 宏**(步骤序列回放)+ **Hub 并发多控制端** + 设备分组 | ✅ |
|
||||
| **批次 12.1** | **Hub 侧按控制端授权**(多令牌 + 按设备白/黑名单 + 注册准入) | ✅ |
|
||||
| **批次 12.2** | GUI **等待/重试语义**(`wait_ms`)—— 界面加载/动画不再假失败 | ✅ |
|
||||
| **批次 12.3** | **macOS 端 GUI 对齐**:AppleScript 窗口枚举/聚焦 + Vision OCR 元素定位(含 Retina 坐标换算) | ✅ |
|
||||
| **批次 12.4** | **事件直达 MCP 宿主**(实时推送 + 拉取双通路) | ✅ |
|
||||
| **批次 13** | **成功指标聚合**(PRD 2.2 四项)+ 依真实数据修正口径(时延分层) | ✅ |
|
||||
| **批次 14** | **PowerShell 常驻助手** —— GUI 能力提速 4~25 倍(预加载 C#/UIA,免每次现编译) | ✅ |
|
||||
| **批次 15** | `screen.find` **图像模板匹配**(纯图标/无文字控件盲区)+ 一堆真机根因修复 | ✅ |
|
||||
| **批次 16 ~ 26** | 网络两阶段提交 · 音频控制 · 构建指纹 · 自更新 · 证书钉住 · 每客户端 PSK · 连接层三防护 · 零信任安装 · 审计锚定 | ✅ |
|
||||
| **批次 27 ~ 29** | **语义可断言**(`gui.await` + UIA 属性 + `where` 谓词 + `any_of`)· **状态可回看**(`log.query` + `monitor.*`) | ✅ |
|
||||
| **发布** | **v2.0.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/VM-VERIFICATION.md`](docs/VM-VERIFICATION.md) —— **无 Windows 真机时,用 Parallels 的 Win11 ARM 虚拟机补验证**(含逐条命令与结论标注规则)
|
||||
- [`CHANGELOG.md`](CHANGELOG.md) —— **变更日志**(未发布版本的内容汇总 / 历史版本)
|
||||
- [`docs/ROADMAP.md`](docs/ROADMAP.md) —— **当前进度盘点与下一步路线**(2026-10-07 基线:待办收尾 / 语义可断言 / 状态采样 / 安全残余)
|
||||
|
||||
- [产品需求文档(PRD)](./PRD.md)
|
||||
- [产品需求文档(PRD)](./docs/PRD.md)
|
||||
- [开发文档(DEVELOPMENT)](./docs/DEVELOPMENT.md) —— 协议细节、能力 schema、安全模型、各端实现指南
|
||||
- [**跨机接管的边界与局限**](./docs/REMOTE-LIMITS.md) —— 哪些问题能远程修、哪些必须人工,附真实故障诊断案例
|
||||
- [**实战复盘:短板与改进优先级**](./docs/RETROSPECTIVE.md) —— 基于真机使用的短板分析(GUI 语义 / 编码 / 自愈 / 可观测性)。**写于 2026-09-28(19 项能力时),多数 P0/P1 已解决 —— 最新状态见 [ROADMAP](./docs/ROADMAP.md) §3 对照表**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/agent",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
+49
-60
@@ -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'
|
||||
@@ -18,8 +25,38 @@ export type AuditType =
|
||||
| 'net.deny'
|
||||
/** v19:agent 自更新(拉取式) */
|
||||
| 'agent.update'
|
||||
/** v23:连接空闲被主动断开 */
|
||||
| 'session.idle_close';
|
||||
/** 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 {
|
||||
@@ -336,7 +373,7 @@ export function readAudit(query: AuditQuery = {}): { entries: AuditEntry[]; tota
|
||||
}
|
||||
|
||||
|
||||
// ---------------- v25 审计链外部锚定 ----------------
|
||||
// ---------------- v2.0.0 审计链外部锚定 ----------------
|
||||
//
|
||||
// ## 为什么需要
|
||||
// 审计链的哈希使**链内**篡改可被发现(改一条就要重算其后全部)。但「有 root 的攻击者」
|
||||
@@ -389,14 +426,9 @@ export function computeAuditHead(): AuditHead {
|
||||
};
|
||||
}
|
||||
|
||||
export interface AnchorRecord {
|
||||
ts: number;
|
||||
entries: number;
|
||||
head_hash: string | null;
|
||||
head_ts: number | null;
|
||||
rotated_segments: number;
|
||||
note?: string;
|
||||
}
|
||||
// 锚点记录与比对结果的定义**移到了 protocol**(唯一实现,控制端脚本也用它)——
|
||||
// 见 packages/protocol/src/audit-anchor.ts 顶部的说明。此处 re-export 以保持既有导入路径可用。
|
||||
export type { AnchorRecord, AnchorComparison } from '@nodeagent/protocol';
|
||||
|
||||
/** 锚点文件路径(默认与审计同目录)。 */
|
||||
export function anchorFilePath(custom?: string): string {
|
||||
@@ -429,13 +461,6 @@ export function anchorAudit(opts: { path?: string; note?: string } = {}): {
|
||||
return { file, record: rec, total_lines: total };
|
||||
}
|
||||
|
||||
export interface AnchorComparison {
|
||||
ok: boolean;
|
||||
anchors_checked: number;
|
||||
latest?: AnchorRecord;
|
||||
verdict: string;
|
||||
detail?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 与最近一条锚点比对,判断当前链是否与锚定时刻自洽。
|
||||
@@ -450,52 +475,16 @@ export function compareWithAnchors(customPath?: string): AnchorComparison {
|
||||
if (!existsSync(file)) {
|
||||
return { ok: true, anchors_checked: 0, verdict: '尚无锚点:请先调用 system.audit.anchor 建立外部锚点' };
|
||||
}
|
||||
let anchors: AnchorRecord[] = [];
|
||||
let anchors: AnchorRecord[] | null = null;
|
||||
try {
|
||||
anchors = readFileSync(file, 'utf8')
|
||||
.split('\n')
|
||||
.filter((l) => l.trim().length > 0)
|
||||
.map((l) => JSON.parse(l) as AnchorRecord);
|
||||
anchors = parseAnchorsFromJsonl(readFileSync(file, 'utf8'));
|
||||
} catch {
|
||||
return { ok: false, anchors_checked: 0, verdict: '锚点文件损坏,无法比对' };
|
||||
}
|
||||
if (anchors.length === 0) {
|
||||
if (!anchors) {
|
||||
return { ok: true, anchors_checked: 0, verdict: '锚点文件为空' };
|
||||
}
|
||||
const latest = anchors[anchors.length - 1]!;
|
||||
const head = computeAuditHead();
|
||||
|
||||
if (head.entries < latest.entries && head.rotated_segments <= latest.rotated_segments) {
|
||||
return {
|
||||
ok: false,
|
||||
anchors_checked: anchors.length,
|
||||
latest,
|
||||
verdict: '**疑似整链重写/回滚**:当前条目数少于锚点,且没有发生轮转来解释',
|
||||
detail: { anchored_entries: latest.entries, current_entries: head.entries, anchored_hash: latest.head_hash, current_hash: head.head_hash },
|
||||
};
|
||||
}
|
||||
if (head.rotated_segments === latest.rotated_segments && head.entries === latest.entries) {
|
||||
if (head.head_hash !== latest.head_hash) {
|
||||
return {
|
||||
ok: false,
|
||||
anchors_checked: anchors.length,
|
||||
latest,
|
||||
verdict: '**链头哈希与锚点不一致**(条目数相同却哈希不同 → 该段被重写)',
|
||||
detail: { anchored_hash: latest.head_hash, current_hash: head.head_hash, entries: head.entries },
|
||||
};
|
||||
}
|
||||
return { ok: true, anchors_checked: anchors.length, latest, verdict: '与锚点完全一致(条目数、轮转段数、链头哈希三者相符)' };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
anchors_checked: anchors.length,
|
||||
latest,
|
||||
// ⚠️ 注意:锚定动作**自身**也会被记入审计(server 层统一记录所有 invoke),
|
||||
// 所以「刚锚定就比对」几乎必然看到"已增长"——这是正常现象,不是篡改。
|
||||
// 想看到"完全一致",需在同一时刻不再产生新审计(实际很难,属预期)。
|
||||
verdict:
|
||||
'链已增长(或发生轮转)—— 锚点仍在链上,无需人工核对到该锚点为止的部分。' +
|
||||
'(刚锚定就比对通常显示"已增长",因为锚定调用自身也会写一条审计。)',
|
||||
detail: { anchored_entries: latest.entries, current_entries: head.entries, rotated_delta: head.rotated_segments - latest.rotated_segments, anchored_hash: latest.head_hash, current_hash: head.head_hash },
|
||||
};
|
||||
// 判定逻辑在 protocol(judgeAnchors)—— 与「控制端对云盘锚点」的比对共用同一份实现,
|
||||
// 避免两处规则漂移(那会导致同一份日志两端结论不同)。
|
||||
return judgeAnchors(computeAuditHead(), anchors);
|
||||
}
|
||||
@@ -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,7 @@
|
||||
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';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
@@ -132,7 +133,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),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* gui.await —— 等待条件成立(v1.6)。
|
||||
* gui.await —— 等待条件成立(v1.6.0)。
|
||||
*
|
||||
* ## 为什么需要它(复盘三大短板之「语义」)
|
||||
* 原来的 GUI 操作是**盲试**:按一下、截个图、看看有没有变化,不行再试。
|
||||
@@ -63,7 +63,7 @@ function buildInnerArgs(condition: Condition, args: Args): Args {
|
||||
for (const k of ['text', 'window', 'control_type', 'method', 'template', 'limit', 'timeout_ms']) {
|
||||
if (args[k] !== undefined) inner[k] = args[k];
|
||||
}
|
||||
// v1.7:属性谓词(仅 UIA 引擎生效)。有 where 时允许不传 text ——
|
||||
// v2.0.0:属性谓词(仅 UIA 引擎生效)。有 where 时允许不传 text ——
|
||||
// 「等一个 enabled 的按钮」比「等一个叫某名字的按钮」更贴近真实意图。
|
||||
const where = args['where'];
|
||||
if (where !== undefined) {
|
||||
@@ -117,7 +117,7 @@ function innerProbe(condition: Condition): (args: Args) => Promise<unknown> {
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.7 组合条件:`any_of: [ {...}, {...} ]`,任一命中原句即算命中。
|
||||
* v2.0.0 组合条件:`any_of: [ {...}, {...} ]`,任一命中原句即算命中。
|
||||
*
|
||||
* 为什么只做 any_of 不做 and/or/not generalize:真实场景里"弹窗出现 **且**
|
||||
* 其中某按钮 enabled"用 any_of(弹窗) + where(按钮 enabled) 两步就能表达,
|
||||
@@ -152,7 +152,7 @@ function buildAnyOf(args: Args): Array<{ condition: Condition; inner: Args; labe
|
||||
}
|
||||
|
||||
export async function guiAwait(args: Args): Promise<unknown> {
|
||||
// v1.7:有 any_of 时**不要求**顶层 condition(语义就是"这组条件任一命中"),
|
||||
// v2.0.0:有 any_of 时**不要求**顶层 condition(语义就是"这组条件任一命中"),
|
||||
// 校验顺序也因此调整为 any_of 先行 —— 否则会先撞上"condition 缺失"的报错。
|
||||
const condition = args['condition'] as Condition | undefined;
|
||||
if (!args['any_of'] && (!condition || !CONDITIONS.includes(condition))) {
|
||||
@@ -175,7 +175,7 @@ export async function guiAwait(args: Args): Promise<unknown> {
|
||||
let lastHit = false;
|
||||
let lastError: string | null = null;
|
||||
|
||||
// v1.7:组合条件走单独路径(任一命中即算),失败语义与单条完全一致
|
||||
// v2.0.0:组合条件走单独路径(任一命中即算),失败语义与单条完全一致
|
||||
const anyOf = args['any_of'] !== undefined ? buildAnyOf(args) : null;
|
||||
if (anyOf) {
|
||||
for (;;) {
|
||||
|
||||
@@ -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>;
|
||||
@@ -344,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) {
|
||||
@@ -359,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,9 +27,49 @@ 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)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 尽力取真实路径(解析符号链接)。
|
||||
*
|
||||
* - 路径存在 → 直接 `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 不能为空');
|
||||
@@ -36,14 +77,20 @@ export function guardPath(input: string): string {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,15 @@ import { logQuery, monitorStart, monitorReport, monitorStop, monitorList, monito
|
||||
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;
|
||||
}
|
||||
@@ -54,9 +63,9 @@ export function createCapabilityRegistry(cfg: AgentConfig): Record<string, Capab
|
||||
[CapabilityNames.MouseDrag]: mouseDrag,
|
||||
[CapabilityNames.KeyType]: keyType,
|
||||
[CapabilityNames.KeyPress]: keyPress,
|
||||
// v1.6 等待条件(把盲试变为可断言)
|
||||
// v1.6.0 等待条件(把盲试变为可断言)
|
||||
[CapabilityNames.GuiAwait]: guiAwait,
|
||||
// v1.8 状态采样(方向二)
|
||||
// v2.0.0 状态采样(方向二)
|
||||
[CapabilityNames.LogQuery]: logQuery,
|
||||
[CapabilityNames.MonitorStart]: monitorStart,
|
||||
[CapabilityNames.MonitorReport]: monitorReport,
|
||||
@@ -67,7 +76,7 @@ export function createCapabilityRegistry(cfg: AgentConfig): Record<string, Capab
|
||||
[CapabilityNames.AuditList]: auditList,
|
||||
// v11 审计防篡改
|
||||
[CapabilityNames.AuditVerify]: auditVerify,
|
||||
// v25 审计链外部锚定(防整链重写)
|
||||
// v2.0.0 审计链外部锚定(防整链重写)
|
||||
[CapabilityNames.AuditHead]: auditHead,
|
||||
[CapabilityNames.AuditAnchor]: auditAnchor,
|
||||
// v13 成功指标
|
||||
|
||||
@@ -45,13 +45,13 @@ export const VK_MAP: Record<string, number> = {
|
||||
launch_mail: 0xb4, launch_media: 0xb5, launch_app1: 0xb6, launch_app2: 0xb7,
|
||||
browser_favorites: 0xab, browser_home: 0xac,
|
||||
|
||||
// ---- 左右侧修饰键(v1.5)----
|
||||
// ---- 左右侧修饰键(v1.5.0)----
|
||||
// 游戏/部分应用区分左右(如游戏中把 LAlt 设为跑步、RAlt 设为瞄准),故不能只给一个 alt。
|
||||
lctrl: 0xa2, rctrl: 0xa3, lshift: 0xa0, rshift: 0xa1,
|
||||
lalt: 0xa4, ralt: 0xa5, lwin: 0x5b, rwin: 0x5c,
|
||||
super: 0x5b, cmd: 0x5b, left_cmd: 0x5b, right_cmd: 0x5c,
|
||||
|
||||
// ---- 小键盘(v1.5)----
|
||||
// ---- 小键盘(v1.5.0)----
|
||||
// numpadN 与数字 N 分开:Excel 等程序靠 NumLock 状态区分二者行为,合成一个会坏掉快捷键。
|
||||
numpad0: 0x60, numpad1: 0x61, numpad2: 0x62, numpad3: 0x63, numpad4: 0x64,
|
||||
numpad5: 0x65, numpad6: 0x66, numpad7: 0x67, numpad8: 0x68, numpad9: 0x69,
|
||||
@@ -59,7 +59,7 @@ export const VK_MAP: Record<string, number> = {
|
||||
numlock: 0x90, scrolllock: 0x91,
|
||||
numpad_enter: 0x0d,
|
||||
|
||||
// ---- OEM 符号键(v1.5,美式布局 VK 码)----
|
||||
// ---- OEM 符号键(v1.5.0,美式布局 VK 码)----
|
||||
// 必备理由:大量系统/应用快捷键就是"修饰键 + 符号键"(win+d 显示桌面、win+, 速览、
|
||||
// ctrl++ 放大、ctrl+- 缩小、win+. 表情面板),此前只能靠字母数字,覆盖不到。
|
||||
oem_1: 0xba, // : ;
|
||||
@@ -75,7 +75,7 @@ export const VK_MAP: Record<string, number> = {
|
||||
oem_comma: 0xbc, // , <
|
||||
oem_period: 0xbe,// . >
|
||||
|
||||
// ---- 系统/IME 杂项(v1.5)----
|
||||
// ---- 系统/IME 杂项(v1.5.0)----
|
||||
sleep: 0x5f, help: 0x2f, select: 0x29, execute: 0x2b, clear: 0x0c,
|
||||
separator: 0x6c, oem_clear: 0xfe,
|
||||
kana: 0x15, hangul: 0x15, kanji: 0x19, hanja: 0x19,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
buildPostBody,
|
||||
} from './input-script.js';
|
||||
import { IS_WINDOWS, execCommand } from '../util/exec.js';
|
||||
import { PS_DPI_AWARE } from '../util/dpi.js';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
@@ -183,7 +184,7 @@ public static class NAInput {
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// v1.5:向目标进程的**全部顶层窗口** PostMessage 键盘消息(后端定向快捷键投放)。
|
||||
/// v1.5.0:向目标进程的**全部顶层窗口** PostMessage 键盘消息(后端定向快捷键投放)。
|
||||
///
|
||||
/// 场景:目标窗口不在前台(被遮挡 / 最小化 / 聊天窗口在后台),又要给它发快捷键。
|
||||
/// WM_KEYDOWN=0x0100,WM_KEYUP=0x0101;修饰键按顺序投递,调用方负责释放顺序。
|
||||
@@ -213,15 +214,31 @@ public static class NAInput {
|
||||
}
|
||||
}`;
|
||||
|
||||
/** 组装:加类型定义 + 具体操作。 */
|
||||
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,1489) 实际落到 (1104,2019)。
|
||||
PS_DPI_AWARE,
|
||||
`$code = @'`,
|
||||
CS_CODE,
|
||||
`'@`,
|
||||
`Add-Type -TypeDefinition $code`,
|
||||
...body,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function runPs(body: string[], timeoutMs = 30_000): Promise<string> {
|
||||
const r = await execCommand({ command: psScript(body), timeoutMs });
|
||||
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),
|
||||
@@ -285,9 +302,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);
|
||||
@@ -364,7 +383,7 @@ export async function keyPress(args: Args): Promise<unknown> {
|
||||
assertAllowed('input.key.press');
|
||||
requireWindows('input.key.press');
|
||||
|
||||
// v1.5:入参统一经 protocol 的 expandChords 展开 —— 字符串热键 / 预设名 / 老数组形式
|
||||
// v1.5.0:入参统一经 protocol 的 expandChords 展开 —— 字符串热键 / 预设名 / 老数组形式
|
||||
// 全部收敛成「归一化和弦序列」。混用多种形式会直接报错,避免"以谁为准"的歧义。
|
||||
let expanded;
|
||||
try {
|
||||
@@ -384,9 +403,9 @@ export async function keyPress(args: Args): Promise<unknown> {
|
||||
const presets = expanded.presets;
|
||||
|
||||
const gap = Math.max(0, Math.min(2000, (args['interval_ms'] as number) ?? 40));
|
||||
// v1.5:长按(按下到释放的保持时长),用于"长按音量/持续按住方向键"等场景
|
||||
// v1.5.0:长按(按下到释放的保持时长),用于"长按音量/持续按住方向键"等场景
|
||||
const holdMs = Math.max(0, Math.min(5000, Math.round((args['hold_ms'] as number | undefined) ?? 0)));
|
||||
// v1.5:投放路由。foreground = SendInput 注入当前焦点(默认,旧行为);
|
||||
// v1.5.0:投放路由。foreground = SendInput 注入当前焦点(默认,旧行为);
|
||||
// post = 向目标进程全部顶层窗口 PostMessage(窗口在后台时可用,但兼容性差)
|
||||
const route = (args['route'] as string | undefined) ?? 'foreground';
|
||||
if (route !== 'foreground' && route !== 'post') {
|
||||
@@ -423,7 +442,7 @@ export async function keyPress(args: Args): Promise<unknown> {
|
||||
};
|
||||
}
|
||||
|
||||
// v1.5:后端定向投递(route=post)—— 目标窗口不在前台时使用
|
||||
// v1.5.0:后端定向投递(route=post)—— 目标窗口不在前台时使用
|
||||
if (route === 'post') {
|
||||
let sent = 0;
|
||||
const out = await runPs(buildPostBody(chords, { gapMs: gap, holdMs, targetPid: pid, route }));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* log.query(v1.8,方向二「状态」第一块)。
|
||||
* log.query(v2.0.0,方向二「状态」第一块)。
|
||||
*
|
||||
* ## 为什么必须有
|
||||
* 治间歇性问题(代理不通、杀软拦截、端口时开时闭)要看**历史**,而被控端日志常在
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* monitor.*(v1.8,方向二「状态」第二块)—— 定时采样落盘与回看。
|
||||
* monitor.*(v2.0.0,方向二「状态」第二块)—— 定时采样落盘与回看。
|
||||
*
|
||||
* ## 为什么需要(复盘 §四)
|
||||
* 代理不通、杀软拦截、端口时开时闭都是**间歇性**的,而 `system.status` 只能看当下快照。
|
||||
@@ -17,8 +17,8 @@
|
||||
|
||||
import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { connect } from 'node:net';
|
||||
import { exec } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import { 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';
|
||||
@@ -60,6 +60,35 @@ function monitorDir(): string {
|
||||
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 连通性 + 握手耗时(跨平台,无额外依赖)。 */
|
||||
@@ -81,11 +110,11 @@ function samplePort(host: string, port: number, timeoutMs: number): Promise<Samp
|
||||
/** process:按名判断存活(Windows tasklist / POSIX ps,与 events.ts 同策略)。 */
|
||||
function sampleProcess(name: string): Promise<Sample> {
|
||||
return new Promise((resolve) => {
|
||||
const cmd =
|
||||
process.platform === 'win32'
|
||||
? `tasklist /FI "IMAGENAME eq ${name}" /NH`
|
||||
: `ps -axo pid=,comm= | grep -i "${name}" | grep -v grep`;
|
||||
exec(cmd, { timeout: 8000 }, (err, stdout) => {
|
||||
// ⚠️ 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())
|
||||
@@ -96,7 +125,28 @@ function sampleProcess(name: string): Promise<Sample> {
|
||||
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) } : {}) });
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -171,15 +221,15 @@ export async function monitorStart(args: Args): Promise<unknown> {
|
||||
});
|
||||
}
|
||||
|
||||
const id = (args['id'] as string | undefined)?.trim() || `m_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
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' });
|
||||
}
|
||||
if (!/^[\w.-]{1,64}$/.test(id)) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'id 只允许字母数字与 . _ -(≤64 字符)', { got: id });
|
||||
}
|
||||
|
||||
const file = join(monitorDir(), `${id}.jsonl`);
|
||||
const file = monitorFile(id);
|
||||
const rec: MonitorRecord = {
|
||||
id, source, target, intervalMs, createdAt: Date.now(), samples: 0, file,
|
||||
timer: setInterval(() => { void tick(rec); }, intervalMs),
|
||||
@@ -214,10 +264,9 @@ async function tick(rec: MonitorRecord): Promise<void> {
|
||||
}
|
||||
|
||||
export async function monitorReport(args: Args): Promise<unknown> {
|
||||
const id = args['id'] as string;
|
||||
if (!id) throw new CapabilityError(ErrorCodes.PARAM_INVALID, '需要 id');
|
||||
const id = safeMonitorId(args['id']);
|
||||
const rec = monitors.get(id);
|
||||
const file = join(monitorDir(), `${id}.jsonl`);
|
||||
const file = monitorFile(id);
|
||||
if (!rec && !existsSync(file)) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `没有这个监控: ${id}`, { running: [...monitors.keys()] });
|
||||
}
|
||||
@@ -313,8 +362,7 @@ function summarize(samples: Sample[], source: MonitorSource): Record<string, unk
|
||||
}
|
||||
|
||||
export async function monitorStop(args: Args): Promise<unknown> {
|
||||
const id = args['id'] as string;
|
||||
if (!id) throw new CapabilityError(ErrorCodes.PARAM_INVALID, '需要 id');
|
||||
const id = safeMonitorId(args['id']);
|
||||
const rec = monitors.get(id);
|
||||
if (!rec) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `没有正在运行的监控: ${id}`, { running: [...monitors.keys()] });
|
||||
@@ -346,12 +394,11 @@ export async function monitorList(_args: Args): Promise<unknown> {
|
||||
|
||||
/** 供 monitor.delete:删除历史文件(不影响运行中的实例)。 */
|
||||
export async function monitorDelete(args: Args): Promise<unknown> {
|
||||
const id = args['id'] as string;
|
||||
if (!id) throw new CapabilityError(ErrorCodes.PARAM_INVALID, '需要 id');
|
||||
const id = safeMonitorId(args['id']);
|
||||
if (monitors.has(id)) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, '该监控仍在运行,请先 stop', { hint: `monitor.stop {"id":"${id}"}` });
|
||||
}
|
||||
const file = join(monitorDir(), `${id}.jsonl`);
|
||||
const file = monitorFile(id);
|
||||
if (!existsSync(file)) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `没有历史文件: ${id}`);
|
||||
}
|
||||
|
||||
@@ -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,10 +84,10 @@ 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 脚本(唯一实现,两处复用)。
|
||||
@@ -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,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 { PS_DPI_AWARE } from '../util/dpi.js';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
@@ -109,9 +110,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,7 @@
|
||||
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';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
@@ -25,22 +27,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% 缩放下 3600×2020 的桌面报成 1800×1010)。真机 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) });
|
||||
}
|
||||
@@ -106,27 +123,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 +208,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 +257,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 屏幕录制权限)', {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { readAudit, readAuditAll, verifyAudit, computeAuditHead, anchorAudit, co
|
||||
import { computeMetrics } from '../metrics.js';
|
||||
import { allPsShellStats } from '../util/ps-helper.js';
|
||||
import { startTask } from './task.js';
|
||||
import { psLit } from '../util/quote.js';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
@@ -534,11 +535,20 @@ async function scheduleWindowsRestart(p: RestartParams): Promise<unknown> {
|
||||
// (= 自杀,表现为"restart 成功但 agent 消失")。原实现只清非 Running,
|
||||
// 代价是真机累积了 3 个 Running 僵尸(2026-10-09 实测)。
|
||||
//
|
||||
// 现策略:非 Running **或** LastRunTime 早于 1 小时 → 清。
|
||||
// 1 小时余量远大于一次重启的耗时,因此"当前宿主"绝不会被误判(它刚刚才运行)。
|
||||
`$cut = (Get-Date).AddHours(-1)`,
|
||||
// 现策略:非 Running **或** LastRunTime **早于"本 agent 启动时刻"** → 清。
|
||||
//
|
||||
// ⚠️ 2026-10-11 真机(Parallels Win11 ARM)修正:原先用「早于 1 小时」作阈值,
|
||||
// 实测发现**频繁 restart(间隔 < 1h)时旧任务照样累积**(v26 想治的僵尸只是从
|
||||
// "永不清理"变成"1 小时后才清")。根因是阈值选得太粗:
|
||||
// 阈值应表达「**这个任务是不是我(当前 agent)的宿主**」——
|
||||
// 凡在**我启动之前**就运行的任务,必然不是我的宿主(我的宿主是刚刚才创建的),
|
||||
// 因此可以安全删除。用 uptime 换算出的启动时刻正是这个边界。
|
||||
`$agentStart = (Get-Date).AddMilliseconds(-${Math.max(0, Math.round(process.uptime() * 1000))})`,
|
||||
`$cut = $agentStart`,
|
||||
`Get-ScheduledTask -TaskName 'nodeagent-selfrestart*' -ErrorAction SilentlyContinue | Where-Object { if ($_.State -ne 'Running') { return $true }; $ti = ($_ | Get-ScheduledTaskInfo -ErrorAction SilentlyContinue); if ($ti -and $ti.LastRunTime -and $ti.LastRunTime -lt $cut) { return $true }; return $false } | Unregister-ScheduledTask -Confirm:$false -ErrorAction SilentlyContinue`,
|
||||
`$a = New-ScheduledTaskAction -Execute 'cmd.exe' -Argument '${cmdLine.replace(/'/g, "''")}'`,
|
||||
// ⚠️ 不要手写 `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 杀不掉它,
|
||||
@@ -616,7 +626,7 @@ export async function auditVerify(_args: Args): Promise<unknown> {
|
||||
}
|
||||
|
||||
/**
|
||||
* v25:返回审计链的链头(只读)。
|
||||
* v2.0.0:返回审计链的链头(只读)。
|
||||
*
|
||||
* 用途:控制端定期拉取并存到**链外**(Mac 本地 / 另一台机器)—— 这是"外部锚定"的一半。
|
||||
* compare=true 时附带与最近一条锚点的比对结论(另一半)。
|
||||
@@ -628,7 +638,7 @@ export async function auditHead(args: Args): Promise<unknown> {
|
||||
}
|
||||
|
||||
/**
|
||||
* v25:把当前链头**追加**写到锚点文件(默认 <数据目录>/audit-anchors.jsonl)。
|
||||
* v2.0.0:把当前链头**追加**写到锚点文件(默认 <数据目录>/audit-anchors.jsonl)。
|
||||
*
|
||||
* 为什么是追加而不是覆盖:历史锚点一旦写成就不可被后续覆盖,否则攻击者重写链后
|
||||
* 再"刷新"锚点即可抹掉痕迹。追加式让每次锚定都留痕。
|
||||
|
||||
@@ -132,7 +132,7 @@ function getTask(id: string): TaskRecord {
|
||||
|
||||
// ---------------- system.task.list ----------------
|
||||
|
||||
/** v23:正在跑的任务数(连接层"有空闲断开但不动长任务"的判据)。 */
|
||||
/** v2.0.0:正在跑的任务数(连接层"有空闲断开但不动长任务"的判据)。 */
|
||||
export function runningTaskCount(): number {
|
||||
let n = 0;
|
||||
for (const t of tasks.values()) if (t.state === 'running') n += 1;
|
||||
|
||||
@@ -7,17 +7,19 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* OCR 引擎预加载段(v14):截图 / DPI / WinRT 类型与 Await 辅助函数。
|
||||
* 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
|
||||
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]
|
||||
|
||||
@@ -32,7 +32,7 @@ function Get-WinByTitle([string]$re) {
|
||||
|
||||
# v14:按子串在给定 scope 内遍历查找(FindAll + 子串过滤)。
|
||||
# UIA 的 PropertyCondition 只能精确匹配,故必须遍历后过滤;遍历规模设上限防超大 UI 树卡死。
|
||||
# v23:读取元素的 UIA 属性(v1.7 语义属性化)。
|
||||
# v2.0.0:读取元素的 UIA 属性(v2.0.0 语义属性化)。
|
||||
# 为什么逐个 try:ValuePattern / SelectionItemPattern / TogglePattern 并非所有控件
|
||||
# 都支持(不支持时 GetSupportedPattern 返回 false 或抛异常),而且**部分应用
|
||||
# (Electron / 游戏 UI)根本给不出值** —— 拿不到就留空,由上层判"未知"而非"false"。
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
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';
|
||||
@@ -119,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}`;
|
||||
@@ -206,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]{
|
||||
@@ -300,7 +316,7 @@ export async function screenFind(args: Args): Promise<unknown> {
|
||||
}
|
||||
const text = (args['text'] as string | undefined) ?? '';
|
||||
const wantImage = (args['method'] as string | undefined) === 'image';
|
||||
// v1.8 修正:`where` 可单独使用(不传 text)——「找一个 enabled 的按钮」比
|
||||
// v2.0.0 修正:`where` 可单独使用(不传 text)——「找一个 enabled 的按钮」比
|
||||
// 「找一个叫某名字的按钮」更贴近真实意图。此前只在 gui.await 侧放开了,
|
||||
// screen.find 自身的入口校验没同步,导致直连调用(以及 await 的 where-only)
|
||||
// 被这里的"text 不能为空"拦下(真机 2026-10-09 验证时暴露)。
|
||||
@@ -317,7 +333,7 @@ export async function screenFind(args: Args): Promise<unknown> {
|
||||
}
|
||||
const windowTitle = args['window'] as string | undefined;
|
||||
const controlType = args['control_type'] as string | undefined;
|
||||
// v1.7:属性谓词。仅 UIA 引擎生效;OCR / 图像模板忽略(无属性可读),
|
||||
// v2.0.0:属性谓词。仅 UIA 引擎生效;OCR / 图像模板忽略(无属性可读),
|
||||
// 此时在返回里带 note 说明,避免调用方误以为"按属性过滤过了"。
|
||||
const whereArg = args['where'] as Record<string, unknown> | undefined;
|
||||
const limit = (args['limit'] as number | undefined) ?? 20;
|
||||
@@ -337,12 +353,13 @@ 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 ?? '')}
|
||||
# v1.7 属性过滤:where 只含 enabled/selected/value/toggle 四类键(白名单式)。
|
||||
$ct = ${psLit(controlType ?? '')}
|
||||
$winTitle = ${psLit(windowTitle ?? '')}
|
||||
# v2.0.0 属性过滤:where 只含 enabled/selected/value/toggle 四类键(白名单式)。
|
||||
#
|
||||
# ⚠️⚠️ 这里踩过**两次**同一个坑,真机 2026-10-09 连修两轮才通:
|
||||
# ✗ 第一版:$where = {"enabled":true} → PS 不认 JSON 字面量,ParseError
|
||||
@@ -352,7 +369,7 @@ $winTitle = ${JSON.stringify(windowTitle ?? '')}
|
||||
# ✓ 第三版:**PS 单引号字符串字面量**,JSON 内的单引号翻倍成 ''(本行)
|
||||
# 并在真机现场验证过:$j = '{"enabled":true}' → ConvertFrom-Json 摊平成哈希表 → OK。
|
||||
# 摊平是因为 Test-UiaWhere 依赖 .Keys 遍历,而 PSCustomObject 没有 Keys。
|
||||
$whereJson = '${(whereArg ? JSON.stringify(whereArg) : '').replace(/'/g, "''")}'
|
||||
$whereJson = ${psLit(whereArg ? JSON.stringify(whereArg) : '')}
|
||||
$where = $null
|
||||
if ($whereJson -ne '') {
|
||||
$where = @{}
|
||||
@@ -402,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';
|
||||
@@ -473,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
|
||||
@@ -533,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) {
|
||||
@@ -626,7 +644,7 @@ $out | ConvertTo-Json -Compress
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
last = await attempt();
|
||||
}
|
||||
// v1.8:where 只在 UIA 引擎下有属性可读 —— 若最终走的是 OCR/image,
|
||||
// v2.0.0:where 只在 UIA 引擎下有属性可读 —— 若最终走的是 OCR/image,
|
||||
// 必须**明确告知**,否则调用方会以为"按属性过滤过了"而误判结果。
|
||||
const whereIgnored = hasWhere && last.engine !== 'uia';
|
||||
return {
|
||||
|
||||
@@ -43,7 +43,7 @@ export interface AgentConfig {
|
||||
allow_from?: string[];
|
||||
|
||||
/**
|
||||
* v23 连接层防护(三项,默认值见 server.ts)。
|
||||
* v2.0.0 连接层防护(三项,默认值见 server.ts)。
|
||||
* 单项缺省即启用默认策略;显式设 0 可关闭对应项。
|
||||
*/
|
||||
security?: {
|
||||
|
||||
@@ -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,19 +280,35 @@ 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 泄漏)。 */
|
||||
/** v23:当前事件订阅数(空闲断开的豁免判据之一)。 */
|
||||
/** v2.0.0:当前事件订阅数(空闲断开的豁免判据之一)。 */
|
||||
export function watchCount(): number {
|
||||
return watches.size;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,63 @@ function listLocalIps(): string[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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),
|
||||
);
|
||||
});
|
||||
|
||||
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({
|
||||
|
||||
+159
-23
@@ -48,7 +48,7 @@ interface ConnState {
|
||||
remote: string;
|
||||
/** v12:连接标识,用于清理该连接创建的事件订阅 */
|
||||
connId: string;
|
||||
/** v23:末次活跃时间(任何入站消息),空闲断开的依据 */
|
||||
/** v2.0.0:末次活跃时间(任何入站消息),空闲断开的依据 */
|
||||
lastActiveAt: number;
|
||||
}
|
||||
|
||||
@@ -64,9 +64,9 @@ type LogLevel = 'debug' | 'info' | 'warn';
|
||||
export interface AgentCore {
|
||||
/** 在给定连接上提供被控端服务(连接已建立)。 */
|
||||
attach(ws: WebSocket, remote: string): void;
|
||||
/** v23:该来源封禁剩余毫秒(0 = 不挡)。连接入口在业务握手前调用。 */
|
||||
/** v2.0.0:该来源封禁剩余毫秒(0 = 不挡)。连接入口在业务握手前调用。 */
|
||||
banRemainingMs(remote: string): number;
|
||||
/** v23:连接表(并发上限 / 空闲清扫判据;key 为 WebSocket)。 */
|
||||
/** v2.0.0:连接表(并发上限 / 空闲清扫判据;key 为 WebSocket)。 */
|
||||
states: Map<WebSocket, ConnState>;
|
||||
}
|
||||
|
||||
@@ -74,9 +74,27 @@ 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[]>();
|
||||
@@ -100,7 +118,7 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
}
|
||||
};
|
||||
|
||||
// v23:用 Map 而非 WeakMap —— 连接层需要 size(并发上限)与遍历(空闲清扫)。
|
||||
// v2.0.0:用 Map 而非 WeakMap —— 连接层需要 size(并发上限)与遍历(空闲清扫)。
|
||||
// 代价是必须手动清理:close 时 delete,否则断开的连接会一直留在表里。
|
||||
const states = new Map<WebSocket, ConnState>();
|
||||
|
||||
@@ -114,14 +132,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;
|
||||
@@ -147,12 +188,16 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
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) {
|
||||
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';
|
||||
|
||||
@@ -163,7 +208,7 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
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}`);
|
||||
failOut('Ed25519 签名校验失败', '确认用的是该 client_id 对应的私钥');
|
||||
return;
|
||||
@@ -176,10 +221,24 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
} else {
|
||||
// v22:优先用「该 client_id 专属的钥匙」,未登记才回落到共享 key。
|
||||
// 这样「知道 key A」就只能以 A 的身份进来 —— ACL 的身份维度在 psk 模式下才有意义。
|
||||
const scopedKey = cfg.keys?.[clientId];
|
||||
//
|
||||
// ⚠️ 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';
|
||||
if (!keyForClient || !verifyHmac(keyForClient, state.nonce, params.hmac ?? '')) {
|
||||
// ⚠️ 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'
|
||||
@@ -191,7 +250,7 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
|
||||
state.nonce = null; // 一次性,用后即废
|
||||
state.nonceExpiresAt = 0;
|
||||
recordAuthSuccess(state.remote); // v23:握手成功即清零失败计数(避免误伤手滑的控制端)
|
||||
recordAuthSuccess(state.remote); // v2.0.0:握手成功即清零失败计数(避免误伤手滑的控制端)
|
||||
state.authenticated = true;
|
||||
state.clientId = clientId;
|
||||
state.authorized = authorized;
|
||||
@@ -220,11 +279,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 绑定身份。
|
||||
@@ -252,6 +317,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 规则时生效)
|
||||
@@ -315,6 +381,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;
|
||||
}
|
||||
@@ -322,6 +396,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;
|
||||
}
|
||||
@@ -333,6 +416,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;
|
||||
@@ -379,10 +463,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;
|
||||
}
|
||||
@@ -401,6 +493,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;
|
||||
}
|
||||
@@ -410,11 +508,18 @@ 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}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- v23 连接层防护:握手失败封禁 ----------
|
||||
// ---------- v2.0.0 连接层防护:握手失败封禁 ----------
|
||||
// 状态放在 core:handleAuth 在此函数内,且 server 侧连接入口也要查它。
|
||||
interface BanState { fails: number; bannedUntil: number }
|
||||
const authBan = new Map<string, BanState>();
|
||||
@@ -470,27 +575,46 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
connId,
|
||||
lastActiveAt: Date.now(),
|
||||
});
|
||||
ws.on('message', (raw: RawData) => {
|
||||
// v23:任何入站消息都算活跃(空闲断开以"完全静默"为准,不是"没有调用能力")
|
||||
// v2.0.0 修正:**客户端对 ping 的 pong 回应也算活跃**。
|
||||
// 之前只更新 message 的活跃时间 → 僵尸连接(TCP 未断但客户端已死)不会被判空闲,
|
||||
// 会被永久算进 states.size → 并发上限虚高 → **可能把正常控制端挡在外面**。
|
||||
ws.on('pong', () => {
|
||||
const st = states.get(ws);
|
||||
if (st) st.lastActiveAt = Date.now();
|
||||
void handleMessage(ws, raw.toString());
|
||||
});
|
||||
ws.on('message', (raw: RawData) => {
|
||||
// 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); // v23:Map 需手动清理(WeakMap 时代靠 GC)
|
||||
states.delete(ws); // v2.0.0:Map 需手动清理(WeakMap 时代靠 GC)
|
||||
});
|
||||
ws.on('error', (err: Error) => log('warn', `连接错误: ${err.message}`));
|
||||
}
|
||||
|
||||
return {
|
||||
attach: attachConnection,
|
||||
/** v23:连接入口用 —— 该来源是否在封禁期(返回剩余毫秒,0=不挡)。 */
|
||||
/** v2.0.0:连接入口用 —— 该来源是否在封禁期(返回剩余毫秒,0=不挡)。 */
|
||||
banRemainingMs,
|
||||
/** v23:连接表(空闲清扫与并发上限判据)。 */
|
||||
/** v2.0.0:连接表(空闲清扫与并发上限判据)。 */
|
||||
states,
|
||||
};
|
||||
}
|
||||
@@ -548,7 +672,19 @@ export function createAgentServer(cfg: AgentConfig, tls: TlsMaterial | null): Pr
|
||||
// WebSocket 层保活:30s ping,探测死连接;v23③ 同时做空闲连接清扫
|
||||
const heartbeat = setInterval(() => {
|
||||
for (const ws of wss.clients) {
|
||||
if (ws.readyState === WebSocket.OPEN) ws.ping();
|
||||
if (ws.readyState !== WebSocket.OPEN) continue;
|
||||
// v2.0.0 修正(真机踩到):**pong 缺失必须主动 terminate**。
|
||||
// ws 库默认不会因「ping 无 pong」而断开 —— 客户端进程已死(TCP 未发 FIN)时,
|
||||
// 连接会一直挂在 states 里,占用 max_connections 名额。
|
||||
// 判据用与空闲清扫同一个 lastActiveAt(pong 已在上方更新它)。
|
||||
const st = core.states.get(ws);
|
||||
if (st && idleTimeoutMs > 0 && Date.now() - st.lastActiveAt > idleTimeoutMs * 2) {
|
||||
log('warn', `连接无响应(ping 未回 pong 且静默超 ${Math.round((Date.now() - st.lastActiveAt) / 1000)}s)→ 强制断开 ${st.connId}`);
|
||||
audit({ type: 'session.idle_close', client_id: st.clientId ?? undefined, remote: st.remote, reason: 'unresponsive (no pong)' });
|
||||
ws.terminate();
|
||||
continue;
|
||||
}
|
||||
ws.ping();
|
||||
}
|
||||
if (idleTimeoutMs <= 0) return;
|
||||
// 保守策略:只要还有任务在跑 / 还有事件订阅,就不断任何连接 ——
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Windows **DPI 感知** PowerShell 前置段(物理像素坐标的唯一真源)。
|
||||
*
|
||||
* ## 为什么必须有
|
||||
*
|
||||
* PowerShell 默认是「**DPI 不感知**」进程。在高 DPI(如 200% 缩放)下,这种进程里
|
||||
* **同时存在两套坐标系**,而且互不提醒:
|
||||
*
|
||||
* | 调用 | 该进程看到的 |
|
||||
* |---|---|
|
||||
* | `[System.Windows.Forms.Screen]::Bounds` | **逻辑**尺寸(物理 3600×2020 → 报 1800×1010) |
|
||||
* | `[System.Windows.Forms.SystemInformation]::VirtualScreen` | **逻辑**(同上) |
|
||||
* | `CopyFromScreen(0,0,1800,1010)` | **物理**裁剪 → 只截到屏幕左上 1/4 |
|
||||
* | `SetCursorPos(552,1489)` | 落点实际在物理 (1104, 2978)(被系统 ×2) |
|
||||
* | UIA(`screen.find`) | **物理**(与进程 DPI 感知无关) |
|
||||
*
|
||||
* 于是最要命的一条链路会静默断掉:`screen.find` 返回物理坐标 → 直接喂给
|
||||
* `input.mouse.click` → **点到两倍远的地方**(还可能被屏幕边界截断)。
|
||||
* 「找得到、点不准」,而且不报任何错。
|
||||
*
|
||||
* ## 用法
|
||||
*
|
||||
* 在 **任何** `System.Windows.Forms` / 截屏 / 光标调用**之前**执行本段;
|
||||
* 同一进程内只需一次(`SetProcessDPIAware` 幂等)。新起的 PowerShell 每次都要带上。
|
||||
*
|
||||
* ## 真机实证(2026-10-12)
|
||||
*
|
||||
* Parallels Win11 ARM 虚拟机,主机缩放 200%,物理 3600×2020:
|
||||
* - 缺省 `screen.capture` 只拿到 1800×1010(= 屏幕左上 1/4),且 `screen.info` 报 1800×1010
|
||||
* - `input.mouse.move 100,100` → DPI 感知读回 **(200,200)**
|
||||
* - `input.mouse.move 552,1489` → 实际落点 **(1104, 2019)**(截断到屏幕底部)
|
||||
* - 补上本段后:`screen.capture` 拿到完整 3600×2020;`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');
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/cli",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -57,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,
|
||||
|
||||
@@ -172,7 +172,7 @@ export async function cmdKey(action: string | undefined, positionals: string[],
|
||||
return;
|
||||
}
|
||||
case 'press': {
|
||||
// v1.5:三种用法(一次一种)
|
||||
// v1.5.0:三种用法(一次一种)
|
||||
// nodeagent key press "ctrl+shift+esc" 字符串热键
|
||||
// nodeagent key press copy 预设名
|
||||
// nodeagent key press ctrl c 老形式(空格分隔的各键)
|
||||
@@ -193,7 +193,7 @@ export async function cmdKey(action: string | undefined, positionals: string[],
|
||||
}
|
||||
// 老形式说明:位置参数以空格分隔 → 兼容"ctrl c"(无 + 号)
|
||||
if (opts.interval) args['interval_ms'] = Number(opts.interval);
|
||||
// v1.5 新增可选参数
|
||||
// v1.5.0 新增可选参数
|
||||
if (opts.hold) args['hold_ms'] = Number(opts.hold);
|
||||
if (opts.route) args['route'] = opts.route;
|
||||
// 媒体键可定向到指定进程(播放器常建 30+ 辅助窗口,只投主窗口往往无效)
|
||||
@@ -232,7 +232,7 @@ export async function cmdKey(action: string | undefined, positionals: string[],
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- v1.6 等待条件(gui.await) ----------
|
||||
// ---------- v1.6.0 等待条件(gui.await) ----------
|
||||
|
||||
/**
|
||||
* 等待一个条件成立再返回(只读低危)。
|
||||
@@ -251,7 +251,7 @@ export async function cmdAwait(opts: Options): Promise<void> {
|
||||
fail('用法: nodeagent await --condition <window|control|process|file> --timeout <ms> [--text/--title/--process/--path] [--state absent]');
|
||||
}
|
||||
const args: Record<string, unknown> = { condition };
|
||||
// v1.7:where / any_of 是 JSON 字符串(属性谓词与组合条件不便用命令行开关表达)
|
||||
// v2.0.0:where / any_of 是 JSON 字符串(属性谓词与组合条件不便用命令行开关表达)
|
||||
for (const k of ['where', 'any_of']) {
|
||||
const raw = (opts as unknown as Record<string, string | undefined>)[k];
|
||||
if (raw !== undefined) {
|
||||
|
||||
@@ -395,7 +395,7 @@ export async function cmdDeploy(localFile: string, opts: Options): Promise<void>
|
||||
console.error(` 回滚方式:把备份复制回目标即可 ——`);
|
||||
console.error(` nodeagent exec "Copy-Item '${uploadInfo.backupPath}' '${target}' -Force"`);
|
||||
}
|
||||
console.error(' 排查:被控端杀软(主流杀软/杀软)可能处置了被覆盖的 agent 文件');
|
||||
console.error(' 排查:被控端杀软可能处置了被覆盖的 agent 文件');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -641,7 +641,7 @@ export async function cmdClip(opts: Options): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- v1.8 状态采样(monitor / log) ----------
|
||||
// ---------- v2.0.0 状态采样(monitor / log) ----------
|
||||
|
||||
/**
|
||||
* 状态采样与回看。
|
||||
@@ -732,7 +732,7 @@ export async function cmdMonitor(action: string | undefined, positional: string[
|
||||
}
|
||||
}
|
||||
|
||||
/** 在被控端侧过滤日志(v1.8):日志常几万行,过滤必须发生在被控端。 */
|
||||
/** 在被控端侧过滤日志(v2.0.0):日志常几万行,过滤必须发生在被控端。 */
|
||||
export async function cmdLog(opts: Options): Promise<void> {
|
||||
const logPath = opts.path;
|
||||
if (!logPath) fail('用法: nodeagent log --path <文件> [--pattern 正则] [--level ERROR] [--limit n] [--tail]');
|
||||
|
||||
@@ -183,7 +183,7 @@ export async function cmdRestart(opts: Options): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- v25 审计链外部锚定 ----------
|
||||
// ---------- v2.0.0 审计链外部锚定 ----------
|
||||
|
||||
/**
|
||||
* 打印审计链头(可选与最近锚点比对)。
|
||||
|
||||
@@ -49,12 +49,12 @@ export const HELP = `nodeagent —— 跨机 AI 接管框架(控制端 CLI)
|
||||
nodeagent events [--kind file|process|net] [--path <路径>] [--pattern G] [--seconds 15]
|
||||
订阅事件并实时接收推送(无 --kind 则列出订阅)(v12)
|
||||
nodeagent monitor start --source <port|process|command|metric> [--target X] [--interval ms] [--id 名]
|
||||
启动定时采样并落盘 (v1.8):port 验连通性/process 验存活/
|
||||
启动定时采样并落盘 (v2.0.0):port 验连通性/process 验存活/
|
||||
command 取退出码/metric 取 CPU·内存
|
||||
nodeagent monitor report <id> 回看样本 + 摘要(断几次、最长断多久、值域)(v1.8)
|
||||
nodeagent monitor report <id> 回看样本 + 摘要(断几次、最长断多久、值域)(v2.0.0)
|
||||
nodeagent monitor stop|list|delete <id>
|
||||
nodeagent log --path <文件> [--pattern 正则] [--level ERROR] [--limit n] [--tail]
|
||||
在被控端侧过滤日志,只回匹配行(日志几万行也不拖网)(v1.8)
|
||||
在被控端侧过滤日志,只回匹配行(日志几万行也不拖网)(v2.0.0)
|
||||
nodeagent record [--duration 5000] [--fps 2] [--scale 0.5] [--region x,y,w,h]
|
||||
录屏为帧序列(有 ffmpeg 则封装 mp4)(v11)
|
||||
nodeagent update --url <地址> --sha256 <哈希> [--dry-run]
|
||||
|
||||
@@ -60,6 +60,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];
|
||||
|
||||
+16
-1
@@ -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' },
|
||||
@@ -89,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' },
|
||||
@@ -126,6 +137,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,
|
||||
@@ -165,7 +178,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,
|
||||
|
||||
+10
-6
@@ -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;
|
||||
@@ -20,11 +24,11 @@ export interface Options {
|
||||
/** 通用选项:文本 / 标题 */
|
||||
text?: string;
|
||||
title?: string;
|
||||
/** v25 审计锚定 */
|
||||
/** v2.0.0 审计锚定 */
|
||||
compare?: boolean;
|
||||
anchorPath?: string;
|
||||
note?: string;
|
||||
/** v1.8 状态采样 */
|
||||
/** v2.0.0 状态采样 */
|
||||
source?: string;
|
||||
target?: string;
|
||||
pattern?: string;
|
||||
@@ -32,17 +36,17 @@ export interface Options {
|
||||
since?: string;
|
||||
offset?: string;
|
||||
tail?: boolean;
|
||||
/** v1.7 gui.await 扩展 */
|
||||
/** v2.0.0 gui.await 扩展 */
|
||||
where?: string;
|
||||
any_of?: string;
|
||||
/** v1.6 gui.await */
|
||||
/** v1.6.0 gui.await */
|
||||
condition?: string;
|
||||
state?: string;
|
||||
path?: string;
|
||||
process?: string;
|
||||
/** v1.5 快捷键扩展:长按毫秒 */
|
||||
/** v1.5.0 快捷键扩展:长按毫秒 */
|
||||
hold?: string;
|
||||
/** v1.5 快捷键扩展:投递路由 foreground|post */
|
||||
/** v1.5.0 快捷键扩展:投递路由 foreground|post */
|
||||
route?: string;
|
||||
pid?: string;
|
||||
/** v3:认证模式 psk | ed25519 */
|
||||
|
||||
+16
-2
@@ -2,9 +2,23 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Tauri + Vue + Typescript App</title>
|
||||
<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>
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
{
|
||||
"name": "nodeagent-console",
|
||||
"private": true,
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
"tauri": "tauri",
|
||||
"sidecar:dev": "node sidecar/main.mjs",
|
||||
"sidecar:build": "node sidecar/build.mjs",
|
||||
"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",
|
||||
@@ -16,7 +21,9 @@
|
||||
"naive-ui": "^2.45.3",
|
||||
"pinia": "^4.0.3",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^5.3.1"
|
||||
"vue-router": "^5.3.1",
|
||||
"@nodeagent/client": "workspace:*",
|
||||
"@nodeagent/protocol": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
|
||||
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.5 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -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,340 @@
|
||||
#!/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?}}
|
||||
* {"jsonrpc":"2.0","id":6,"method":"download","params":{url,sha256,dest}}
|
||||
* 响应(本进程 → 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';
|
||||
|
||||
/** @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;
|
||||
}
|
||||
|
||||
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 =
|
||||
params?.url ??
|
||||
'https://github.com/jkooe/nodeagent/releases/latest/download/latest.json';
|
||||
|
||||
const ac = new AbortController();
|
||||
const timer = setTimeout(() => ac.abort(), 20_000);
|
||||
let meta;
|
||||
try {
|
||||
const res = await fetch(url, { signal: ac.signal, redirect: 'follow' });
|
||||
if (!res.ok) {
|
||||
throw new Error(`拉取 latest.json 失败:HTTP ${res.status} ${res.statusText}`);
|
||||
}
|
||||
meta = await res.json();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
throw Object.assign(new Error(`检查更新失败:${msg}(地址 ${url})`), {
|
||||
data: { url, hint: '若本机网络无法直连 GitHub,可在被控端配置里改用镜像地址' },
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
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,
|
||||
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 ac = new AbortController();
|
||||
const timer = setTimeout(() => ac.abort(), 300_000);
|
||||
let buf;
|
||||
try {
|
||||
const res = await fetch(url, { signal: ac.signal, redirect: 'follow' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
||||
buf = Buffer.from(await res.arrayBuffer());
|
||||
} catch (err) {
|
||||
throw new Error(`下载失败:${err instanceof Error ? err.message : String(err)}`);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
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})`);
|
||||
@@ -5,3 +5,10 @@
|
||||
# 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
|
||||
Generated
+34
-545
@@ -213,29 +213,6 @@ version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.45.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
"dunce",
|
||||
"fs_extra",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.21.7"
|
||||
@@ -254,12 +231,6 @@ version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
@@ -299,15 +270,6 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block2"
|
||||
version = "0.6.2"
|
||||
@@ -462,8 +424,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
@@ -500,17 +460,6 @@ version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.1",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
@@ -523,21 +472,6 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmake"
|
||||
version = "0.1.58"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmov"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.8"
|
||||
@@ -557,12 +491,6 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.18.2"
|
||||
@@ -622,15 +550,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
@@ -665,15 +584,6 @@ dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cssparser"
|
||||
version = "0.37.0"
|
||||
@@ -703,42 +613,6 @@ version = "1.0.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d"
|
||||
|
||||
[[package]]
|
||||
name = "ctutils"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574"
|
||||
dependencies = [
|
||||
"cmov",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek"
|
||||
version = "5.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.1",
|
||||
"curve25519-dalek-derive",
|
||||
"digest 0.11.3",
|
||||
"fiat-crypto",
|
||||
"rustc_version",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek-derive"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.24.1"
|
||||
@@ -773,12 +647,6 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
|
||||
|
||||
[[package]]
|
||||
name = "dbus"
|
||||
version = "0.9.12"
|
||||
@@ -821,16 +689,6 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
@@ -867,20 +725,8 @@ version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer 0.10.4",
|
||||
"crypto-common 0.1.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer 0.12.1",
|
||||
"const-oid",
|
||||
"crypto-common 0.2.2",
|
||||
"ctutils",
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1001,31 +847,6 @@ version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "ed25519"
|
||||
version = "3.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a"
|
||||
dependencies = [
|
||||
"pkcs8",
|
||||
"signature",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519-dalek"
|
||||
version = "3.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de"
|
||||
dependencies = [
|
||||
"curve25519-dalek",
|
||||
"ed25519",
|
||||
"serde",
|
||||
"sha2 0.11.0",
|
||||
"signature",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embed-resource"
|
||||
version = "3.0.12"
|
||||
@@ -1135,12 +956,6 @@ dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fiat-crypto"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24"
|
||||
|
||||
[[package]]
|
||||
name = "field-offset"
|
||||
version = "0.3.6"
|
||||
@@ -1216,12 +1031,6 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs_extra"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.34"
|
||||
@@ -1415,17 +1224,6 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
@@ -1447,7 +1245,6 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1634,15 +1431,6 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
|
||||
dependencies = [
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "html5ever"
|
||||
version = "0.39.0"
|
||||
@@ -1692,15 +1480,6 @@ version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.12.0"
|
||||
@@ -2072,16 +1851,6 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
version = "0.1.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
|
||||
dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.106"
|
||||
@@ -2330,37 +2099,17 @@ version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nodeagent-client"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"ed25519-dalek",
|
||||
"futures-util",
|
||||
"hmac",
|
||||
"rand",
|
||||
"rustls",
|
||||
"rustls-pemfile",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"url",
|
||||
"webpki-roots 1.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nodeagent-console"
|
||||
version = "0.1.0"
|
||||
version = "2.0.3"
|
||||
dependencies = [
|
||||
"nodeagent-client",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-single-instance",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2759,16 +2508,6 @@ dependencies = [
|
||||
"futures-io",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkcs8"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7"
|
||||
dependencies = [
|
||||
"der",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.34"
|
||||
@@ -2956,23 +2695,6 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom 0.4.3",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
@@ -3081,20 +2803,6 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.3"
|
||||
@@ -3123,51 +2831,6 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pemfile"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||
dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
@@ -3428,17 +3091,6 @@ dependencies = [
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.1",
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
@@ -3446,19 +3098,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.1",
|
||||
"digest 0.11.3",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3477,12 +3118,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signature"
|
||||
version = "3.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5"
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.10"
|
||||
@@ -3565,16 +3200,6 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spki"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"der",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
@@ -3611,12 +3236,6 @@ version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "swift-rs"
|
||||
version = "1.0.8"
|
||||
@@ -3839,7 +3458,7 @@ dependencies = [
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"sha2",
|
||||
"syn 2.0.119",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.21",
|
||||
@@ -3901,6 +3520,22 @@ dependencies = [
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-single-instance"
|
||||
version = "2.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c142ed88deee222bd2d979269d35c73b6c1c0f6ebd5b79b4ff80066fcad6af1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.12.1"
|
||||
@@ -4119,6 +3754,7 @@ dependencies = [
|
||||
"libc",
|
||||
"mio",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
@@ -4135,32 +3771,6 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.30.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tungstenite",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.19"
|
||||
@@ -4374,24 +3984,6 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.30.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"sha1",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typeid"
|
||||
version = "1.0.3"
|
||||
@@ -4427,12 +4019,6 @@ version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
@@ -4687,24 +4273,6 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||
dependencies = [
|
||||
"webpki-roots 1.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webview2-com"
|
||||
version = "0.39.1"
|
||||
@@ -4893,16 +4461,7 @@ version = "0.45.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
|
||||
dependencies = [
|
||||
"windows-targets 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4920,29 +4479,13 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.42.2",
|
||||
"windows_aarch64_msvc 0.42.2",
|
||||
"windows_i686_gnu 0.42.2",
|
||||
"windows_i686_msvc 0.42.2",
|
||||
"windows_x86_64_gnu 0.42.2",
|
||||
"windows_x86_64_gnullvm 0.42.2",
|
||||
"windows_x86_64_msvc 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.52.6",
|
||||
"windows_aarch64_msvc 0.52.6",
|
||||
"windows_i686_gnu 0.52.6",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc 0.52.6",
|
||||
"windows_x86_64_gnu 0.52.6",
|
||||
"windows_x86_64_gnullvm 0.52.6",
|
||||
"windows_x86_64_msvc 0.52.6",
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4969,90 +4512,42 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.5.40"
|
||||
@@ -5123,7 +4618,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"percent-encoding",
|
||||
"raw-window-handle",
|
||||
"sha2 0.10.9",
|
||||
"sha2",
|
||||
"soup3",
|
||||
"tao-macros",
|
||||
"thiserror 2.0.21",
|
||||
@@ -5272,12 +4767,6 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.5"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "nodeagent-console"
|
||||
version = "0.1.0"
|
||||
description = "nodeagent 桌面控制台(主控 + 被控一体)"
|
||||
version = "2.0.3"
|
||||
description = "nodeagent 桌面控制台(主控 + 被控一体,含本机服务托管)"
|
||||
authors = ["jkooe"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -18,11 +18,18 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
# `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"
|
||||
nodeagent-client = { path = "../../../crates/nodeagent-client" }
|
||||
# ⚠️ 2026-10:不再依赖 crates/nodeagent-client —— 协议实现改由 Node sidecar 承担
|
||||
# (见 src/sidecar.rs 的说明:避免 Rust/TS 两套协议实现静默漂移)
|
||||
tokio = { version = "1", features = ["process", "io-util", "sync", "time", "macros", "rt-multi-thread"] }
|
||||
|
||||
|
||||
# Read the optimization guideline for more details: https://tauri.app/concept/size/#cargo-configuration
|
||||
|
||||
@@ -0,0 +1,856 @@
|
||||
//! 本机被控端(agent)进程管理器 —— 让控制台能「启动 / 停止 / 重启」本机被控端,
|
||||
//! 用户全程**不必碰命令行**。
|
||||
//!
|
||||
//! ## 关键设计(每条都是踩出来的)
|
||||
//!
|
||||
//! 1. **脚本必须在数据目录里跑,不能在 app bundle 里跑。**
|
||||
//! macOS 的 `.app` 是签名保护的只读资源 —— 自更新写不进去;且开机自启需要**稳定路径**
|
||||
//! (用户挪动 / 重装 / 删除 app 不该带走被控端)。故首次运行时把内置的 `agent.mjs`
|
||||
//! **播种**到 `<数据目录>/runtime/agent.mjs`,此后一律跑这一份。
|
||||
//!
|
||||
//! 2. **绝不覆盖「已自更新」的副本。**
|
||||
//! 用 `<数据目录>/runtime/agent.seed.json` 记「播种时的应用版本」;只有**应用版本变了**
|
||||
//! 才重新播种。否则控制台每次启动都会把被控端(可能已自更新到更新版)**降级**回随包版本 ——
|
||||
//! 这类「悄悄降级」不会报任何错,只是功能莫名回退。
|
||||
//!
|
||||
//! 3. **Windows 必须 `CREATE_NO_WINDOW`。**
|
||||
//! 否则每次 spawn `node.exe` 都会弹一个黑色控制台窗口。本项目的目标之一正是
|
||||
//! 「不再打开命令行窗口」,故这一步不可省。
|
||||
//!
|
||||
//! 4. **退出监控用轮询 `try_wait`,不把 `Child` 移出去。**
|
||||
//! 若把 `Child` 交给 `wait()` 的 task,`stop()` 就再也拿不到它去 kill 了。
|
||||
//! 轮询 800ms 代价可忽略,换来 stop 随时可用。
|
||||
//!
|
||||
//! ## 可测试性
|
||||
//! 本模块**不依赖 Tauri GUI** —— 单测直接拉起真实的 node 子进程,验证
|
||||
//! 「启动 → 捕获日志 → 停止 → 状态收敛」全链路。
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::VecDeque;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::process::{Child, Command};
|
||||
|
||||
/// 日志环形缓冲上限(行)。够回看一次启动/一次故障,又不至于吃内存。
|
||||
const LOG_CAP: usize = 4000;
|
||||
|
||||
/// 被控端状态机取值(前后端共用字面量)。
|
||||
pub mod state {
|
||||
pub const STOPPED: &str = "stopped";
|
||||
pub const STARTING: &str = "starting";
|
||||
pub const RUNNING: &str = "running";
|
||||
pub const STOPPING: &str = "stopping";
|
||||
pub const CRASHED: &str = "crashed";
|
||||
}
|
||||
|
||||
/// 一行日志。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct LogLine {
|
||||
/// 全局单调递增序号(环形缓冲淘汰旧行后仍然递增 —— 前端靠它做增量拉取)
|
||||
pub seq: u64,
|
||||
/// 毫秒时间戳(UTC)
|
||||
pub ts: u64,
|
||||
/// `stdout` / `stderr` / `system`(本管理器自己产生的说明行)
|
||||
pub stream: String,
|
||||
pub line: String,
|
||||
}
|
||||
|
||||
impl LogLine {
|
||||
fn new(seq: u64, stream: &str, line: impl Into<String>) -> Self {
|
||||
Self {
|
||||
seq,
|
||||
ts: now_ms(),
|
||||
stream: stream.to_string(),
|
||||
line: line.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 管理器向外推送的事件。
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AgentEvent {
|
||||
Log(LogLine),
|
||||
/// 状态变化(取值见 `state::*`)
|
||||
State(String),
|
||||
}
|
||||
|
||||
pub type AgentEmit = Arc<dyn Fn(AgentEvent) + Send + Sync>;
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// 数据目录:优先 `NODEAGENT_HOME`(跨平台统一),否则 `~/.nodeagent`。
|
||||
///
|
||||
/// ⚠️ Windows 认 `USERPROFILE`、不认 `HOME` —— 与被控端 `apps/agent/src/config.ts`
|
||||
/// 的 `agentDir()` 必须保持一致,否则「控制台显示的数据目录」与「被控端实际用的」
|
||||
/// 会是两个地方。
|
||||
pub fn agent_data_dir() -> PathBuf {
|
||||
if let Ok(p) = std::env::var("NODEAGENT_HOME") {
|
||||
if !p.trim().is_empty() {
|
||||
return PathBuf::from(p);
|
||||
}
|
||||
}
|
||||
let home = std::env::var_os("USERPROFILE")
|
||||
.or_else(|| std::env::var_os("HOME"))
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
home.join(".nodeagent")
|
||||
}
|
||||
|
||||
/// 被控端入口脚本(数据目录里的那一份)。
|
||||
pub fn agent_script_path(data_dir: &Path) -> PathBuf {
|
||||
data_dir.join("runtime").join("agent.mjs")
|
||||
}
|
||||
|
||||
/// 播种标记文件。
|
||||
fn seed_marker_path(data_dir: &Path) -> PathBuf {
|
||||
data_dir.join("runtime").join("agent.seed.json")
|
||||
}
|
||||
|
||||
/// 比较 `x.y.z` 版本号(数值逐段比,不做字符串比 —— 否则 `2.0.10 < 2.0.9`)。
|
||||
pub fn cmp_version(a: &str, b: &str) -> i32 {
|
||||
let pa: Vec<u32> = a.split('.').map(|x| x.parse().unwrap_or(0)).collect();
|
||||
let pb: Vec<u32> = b.split('.').map(|x| x.parse().unwrap_or(0)).collect();
|
||||
for i in 0..pa.len().max(pb.len()) {
|
||||
let d = pa.get(i).copied().unwrap_or(0) as i64 - pb.get(i).copied().unwrap_or(0) as i64;
|
||||
if d != 0 {
|
||||
return if d > 0 { 1 } else { -1 };
|
||||
}
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
/// 读出「数据目录里当前脚本所对应的版本」(无标记则视为 `0.0.0`)。
|
||||
pub fn installed_agent_version(data_dir: &Path) -> Option<String> {
|
||||
let text = std::fs::read_to_string(seed_marker_path(data_dir)).ok()?;
|
||||
let v: Value = serde_json::from_str(&text).ok()?;
|
||||
v.get("version").and_then(Value::as_str).map(str::to_string)
|
||||
}
|
||||
|
||||
/// 记录「数据目录里当前脚本的版本」。
|
||||
///
|
||||
/// 播种与被控端自更新(**含控制台发起的更新**)都要写它 —— 这是防止
|
||||
/// 「控制台启动时把已更新的被控端降级回随包版本」的唯一依据。
|
||||
pub fn mark_installed_version(data_dir: &Path, version: &str, source: &str) -> Result<(), String> {
|
||||
let marker = seed_marker_path(data_dir);
|
||||
if let Some(dir) = marker.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| format!("创建 runtime 目录失败:{e}"))?;
|
||||
}
|
||||
std::fs::write(
|
||||
&marker,
|
||||
format!(
|
||||
"{{\n \"version\": \"{}\",\n \"source\": \"{}\",\n \"at\": {}\n}}\n",
|
||||
version,
|
||||
source,
|
||||
now_ms()
|
||||
),
|
||||
)
|
||||
.map_err(|e| format!("写入播种标记失败:{e}"))
|
||||
}
|
||||
|
||||
/// 定位随包(或开发期)的内置 `agent.mjs`。
|
||||
///
|
||||
/// 优先级:环境变量 `NODEAGENT_CONSOLE_AGENT` → 随包 resource 的 `agent.mjs` →
|
||||
/// 开发期的 `apps/agent/dist/index.js`。
|
||||
pub fn resolve_bundled_agent(resource_dir: Option<&Path>) -> Result<PathBuf, String> {
|
||||
if let Ok(p) = std::env::var("NODEAGENT_CONSOLE_AGENT") {
|
||||
if !p.is_empty() && Path::new(&p).exists() {
|
||||
return Ok(PathBuf::from(p));
|
||||
}
|
||||
}
|
||||
if let Some(dir) = resource_dir {
|
||||
let bundled = dir.join("agent.mjs");
|
||||
if bundled.exists() {
|
||||
return Ok(bundled);
|
||||
}
|
||||
}
|
||||
// 开发期:src-tauri → apps/agent/dist/index.js
|
||||
let dev = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../agent/dist/index.js");
|
||||
if dev.exists() {
|
||||
return Ok(dev);
|
||||
}
|
||||
Err("找不到内置 agent.mjs(先跑 `pnpm -r build`,或用 NODEAGENT_CONSOLE_AGENT 指定)".to_string())
|
||||
}
|
||||
|
||||
/// 把内置被控端**播种**到数据目录。
|
||||
///
|
||||
/// **只在「数据目录里的脚本版本 < 随包版本」时才覆盖** —— 这条规则防的是两类静默故障:
|
||||
/// - 控制台每次启动都覆盖 → 把**已自更新**的被控端**降级**回随包版本(功能莫名回退,不报错);
|
||||
/// - 应用被降级安装后,反过来把更新的被控端覆盖掉。
|
||||
///
|
||||
/// 返回实际使用的脚本路径。
|
||||
pub fn seed_agent_script(
|
||||
bundled: &Path,
|
||||
data_dir: &Path,
|
||||
app_version: &str,
|
||||
) -> Result<PathBuf, String> {
|
||||
let target = agent_script_path(data_dir);
|
||||
|
||||
if target.exists() {
|
||||
let installed = installed_agent_version(data_dir).unwrap_or_else(|| "0.0.0".to_string());
|
||||
if cmp_version(app_version, &installed) <= 0 {
|
||||
// 数据目录里的版本不低于随包版本(可能是自更新来的)→ 不动它
|
||||
return Ok(target);
|
||||
}
|
||||
}
|
||||
|
||||
std::fs::create_dir_all(target.parent().unwrap_or(data_dir))
|
||||
.map_err(|e| format!("创建 runtime 目录失败:{e}"))?;
|
||||
std::fs::copy(bundled, &target).map_err(|e| {
|
||||
format!(
|
||||
"播种被控端脚本失败({} → {}):{e}",
|
||||
bundled.display(),
|
||||
target.display()
|
||||
)
|
||||
})?;
|
||||
// 保留可执行位(POSIX;Windows 无此概念)
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755));
|
||||
}
|
||||
mark_installed_version(data_dir, app_version, "bundled")?;
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
/// TCP 探测端口是否已被监听(判断「是否已有实例在跑」)。
|
||||
///
|
||||
/// 只做一次 TCP 连接,不跑协议 —— 快、无副作用,足够回答「这个端口有没有人在听」。
|
||||
pub fn port_in_use(port: u16) -> bool {
|
||||
use std::net::{SocketAddr, TcpStream};
|
||||
let addr: SocketAddr = ([127, 0, 0, 1], port).into();
|
||||
TcpStream::connect_timeout(&addr, Duration::from_millis(400)).is_ok()
|
||||
}
|
||||
|
||||
/// 读被控端配置(`<数据目录>/agent.json`)里与控制台有关的部分。
|
||||
///
|
||||
/// 只读、不创建 —— 配置由被控端自己在首次启动时生成(含随机密钥)。
|
||||
pub fn read_agent_config(data_dir: &Path) -> Value {
|
||||
let p = data_dir.join("agent.json");
|
||||
let (node_id, port, key, auth_mode, tls, exists) = match std::fs::read_to_string(&p) {
|
||||
Ok(text) => {
|
||||
// 剥离 BOM:Windows 上由 PowerShell 写出的 JSON 可能带 BOM
|
||||
let parsed: Value = serde_json::from_str(text.trim_start_matches('\u{feff}')).unwrap_or(json!({}));
|
||||
(
|
||||
parsed.get("node_id").and_then(Value::as_str).unwrap_or("").to_string(),
|
||||
parsed.get("port").and_then(Value::as_u64).unwrap_or(8765),
|
||||
parsed.get("key").and_then(Value::as_str).unwrap_or("").to_string(),
|
||||
parsed.get("auth_mode").and_then(Value::as_str).unwrap_or("psk").to_string(),
|
||||
parsed.get("tls").and_then(Value::as_bool).unwrap_or(true),
|
||||
true,
|
||||
)
|
||||
}
|
||||
Err(_) => (String::new(), 8765, String::new(), "psk".to_string(), true, false),
|
||||
};
|
||||
json!({
|
||||
"data_dir": data_dir.to_string_lossy(),
|
||||
"config_path": p.to_string_lossy(),
|
||||
"config_exists": exists,
|
||||
"node_id": node_id,
|
||||
"port": port,
|
||||
"tls": tls,
|
||||
"auth_mode": auth_mode,
|
||||
// 密钥回传给本机 UI 属预期(用户要用它连接自己的机器);
|
||||
// 它本来就在用户本机的 600 文件里,不构成额外暴露面。
|
||||
"key": key,
|
||||
"port_in_use": port_in_use(port as u16),
|
||||
"url": format!("ws://127.0.0.1:{port}"),
|
||||
})
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
state: String,
|
||||
child: Option<Child>,
|
||||
pid: Option<u32>,
|
||||
started_at: Option<u64>,
|
||||
restarts: u32,
|
||||
last_exit: Option<String>,
|
||||
}
|
||||
|
||||
/// 本机被控端管理器。
|
||||
pub struct AgentManager {
|
||||
inner: tokio::sync::Mutex<Inner>,
|
||||
/// 环形日志缓冲。用 `Arc<Mutex<..>>` 是为了让日志泵任务能**同时**入缓冲与推事件,
|
||||
/// 而不必持有整个管理器(否则自引用,Arc 循环)。
|
||||
logs: Arc<Mutex<VecDeque<LogLine>>>,
|
||||
seq: Arc<AtomicU64>,
|
||||
emit: AgentEmit,
|
||||
/// 脚本与 node 路径(start 时注入,便于 status 展示)
|
||||
paths: Mutex<Option<(PathBuf, PathBuf, PathBuf)>>, // (node, script, data_dir)
|
||||
}
|
||||
|
||||
impl AgentManager {
|
||||
pub fn new(emit: AgentEmit) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
inner: tokio::sync::Mutex::new(Inner {
|
||||
state: state::STOPPED.to_string(),
|
||||
child: None,
|
||||
pid: None,
|
||||
started_at: None,
|
||||
restarts: 0,
|
||||
last_exit: None,
|
||||
}),
|
||||
logs: Arc::new(Mutex::new(VecDeque::with_capacity(LOG_CAP))),
|
||||
seq: Arc::new(AtomicU64::new(1)),
|
||||
emit,
|
||||
paths: Mutex::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
/// 追加一行日志:入环形缓冲 + 推事件。
|
||||
pub fn push_log(&self, stream: &str, text: impl Into<String>) {
|
||||
let entry = LogLine::new(self.next_seq(), stream, text);
|
||||
{
|
||||
let mut logs = self.logs.lock().unwrap();
|
||||
if logs.len() >= LOG_CAP {
|
||||
logs.pop_front();
|
||||
}
|
||||
logs.push_back(entry.clone());
|
||||
}
|
||||
(self.emit)(AgentEvent::Log(entry));
|
||||
}
|
||||
|
||||
fn next_seq(&self) -> u64 {
|
||||
self.seq.fetch_add(1, Ordering::SeqCst)
|
||||
}
|
||||
|
||||
async fn set_state(&self, s: &str) {
|
||||
{
|
||||
let mut inner = self.inner.lock().await;
|
||||
if inner.state == s {
|
||||
return;
|
||||
}
|
||||
inner.state = s.to_string();
|
||||
}
|
||||
(self.emit)(AgentEvent::State(s.to_string()));
|
||||
}
|
||||
|
||||
/// 当前状态快照(不探测端口,纯读内存 + 已记录的路径)。
|
||||
pub async fn snapshot(&self) -> Value {
|
||||
let inner = self.inner.lock().await;
|
||||
let paths = self.paths.lock().unwrap().clone();
|
||||
let (node, script, data_dir) = match paths {
|
||||
Some((n, s, d)) => (
|
||||
n.to_string_lossy().to_string(),
|
||||
s.to_string_lossy().to_string(),
|
||||
d.to_string_lossy().to_string(),
|
||||
),
|
||||
None => (String::new(), String::new(), agent_data_dir().to_string_lossy().to_string()),
|
||||
};
|
||||
json!({
|
||||
"state": inner.state,
|
||||
"pid": inner.pid,
|
||||
"started_at": inner.started_at,
|
||||
"uptime_ms": inner.started_at.map(|t| now_ms().saturating_sub(t)),
|
||||
"restarts": inner.restarts,
|
||||
"last_exit": inner.last_exit,
|
||||
"node": node,
|
||||
"script": script,
|
||||
"data_dir": data_dir,
|
||||
})
|
||||
}
|
||||
|
||||
/// 取日志(`since` 为上次拿到的最大 `seq`,0 表示取全部缓冲)。
|
||||
pub fn logs_since(&self, since: u64) -> Value {
|
||||
let logs = self.logs.lock().unwrap();
|
||||
let total = self.seq.load(Ordering::SeqCst).saturating_sub(1);
|
||||
let lines: Vec<Value> = logs
|
||||
.iter()
|
||||
.filter(|l| l.seq > since)
|
||||
.map(|l| json!({ "seq": l.seq, "ts": l.ts, "stream": l.stream, "line": l.line }))
|
||||
.collect();
|
||||
json!({ "lines": lines, "total": total })
|
||||
}
|
||||
|
||||
pub fn clear_logs(&self) {
|
||||
self.logs.lock().unwrap().clear();
|
||||
}
|
||||
|
||||
/// 启动被控端。
|
||||
///
|
||||
/// 接收者写成 `self: &Arc<Self>`(而非 `&self`)是**必需**的:启动后要挂一个
|
||||
/// **自持引用**的监控任务(崩溃自动重启要能再次调用 `start`),而 `&self` 拿不到 `Arc`。
|
||||
///
|
||||
/// `auto_restart`:进程意外退出时是否自动拉起(「后台运行」的语义要求)。
|
||||
pub async fn start(
|
||||
self: &Arc<Self>,
|
||||
node: &Path,
|
||||
script: &Path,
|
||||
data_dir: &Path,
|
||||
auto_restart: bool,
|
||||
) -> Result<Value, String> {
|
||||
{
|
||||
let inner = self.inner.lock().await;
|
||||
if inner.child.is_some() {
|
||||
return Err("被控端已在运行".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// 端口占用检查:既有的(非本控制台托管的)实例在跑时,给出可执行的说明,
|
||||
// 而不是让用户看到一个 `EADDRINUSE` 堆栈。
|
||||
//
|
||||
// ⚠️ 这里是症状 B(「应用端无法启动」)的**现场**:真机上曾有陈旧计划任务拉起的
|
||||
// 被控端占着 8765,控制台自己的被控端起不来。端口检查本身是对的,
|
||||
// 但只给一句「已被占用」用户无从下手 —— 故补上「怎么找出占用者」。
|
||||
let cfg = read_agent_config(data_dir);
|
||||
let port = cfg.get("port").and_then(Value::as_u64).unwrap_or(8765) as u16;
|
||||
if port_in_use(port) {
|
||||
let hint = if cfg!(windows) {
|
||||
format!(
|
||||
"netstat -ano | findstr :{port} 找到 PID,再 tasklist /FI \"PID eq <PID>\" 看是谁"
|
||||
)
|
||||
} else {
|
||||
format!("lsof -nP -iTCP:{port} -sTCP:LISTEN")
|
||||
};
|
||||
return Err(format!(
|
||||
"端口 {port} 已被占用 —— 已有另一个被控端在监听(手工安装的、计划任务拉起的,\
|
||||
或另一个控制台实例)。\n\
|
||||
排查占用者:{hint}\n\
|
||||
处理:先停掉它;或把 {} 里的 port 改成别的端口后重启本机服务。",
|
||||
data_dir.join("agent.json").display()
|
||||
));
|
||||
}
|
||||
|
||||
*self.paths.lock().unwrap() = Some((node.to_path_buf(), script.to_path_buf(), data_dir.to_path_buf()));
|
||||
self.set_state(state::STARTING).await;
|
||||
self.push_log(
|
||||
"system",
|
||||
format!(
|
||||
"启动被控端:{} {}(数据目录 {})",
|
||||
node.display(),
|
||||
script.display(),
|
||||
data_dir.display()
|
||||
),
|
||||
);
|
||||
|
||||
let mut cmd = Command::new(node);
|
||||
cmd.arg(script)
|
||||
.current_dir(data_dir)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(false); // 由本管理器显式管理生命周期,不留孤儿也不误杀
|
||||
|
||||
// 让被控端与控制台用**同一个**数据目录(不设则各按各自规则解析)
|
||||
cmd.env("NODEAGENT_HOME", data_dir);
|
||||
|
||||
// ⚠️ 消灭黑窗:Windows 上不加这个标志,每 spawn 一次 node 就弹一个控制台窗口
|
||||
#[cfg(windows)]
|
||||
{
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("启动被控端失败({}):{e}", node.display()))?;
|
||||
|
||||
let pid = child.id();
|
||||
let stdout = child.stdout.take();
|
||||
let stderr = child.stderr.take();
|
||||
|
||||
{
|
||||
let mut inner = self.inner.lock().await;
|
||||
inner.child = Some(child);
|
||||
inner.pid = pid;
|
||||
inner.started_at = Some(now_ms());
|
||||
}
|
||||
self.set_state(state::RUNNING).await;
|
||||
|
||||
// 日志泵:既入环形缓冲(供前端「拉取历史」),也推事件(供前端「实时滚动」)
|
||||
if let Some(out) = stdout {
|
||||
spawn_line_pump(out, "stdout", Arc::clone(&self.emit), Arc::clone(&self.logs), Arc::clone(&self.seq));
|
||||
}
|
||||
if let Some(err) = stderr {
|
||||
spawn_line_pump(err, "stderr", Arc::clone(&self.emit), Arc::clone(&self.logs), Arc::clone(&self.seq));
|
||||
}
|
||||
|
||||
// 退出监控 + 自动重启
|
||||
AgentManager::spawn_monitor(
|
||||
self,
|
||||
node.to_path_buf(),
|
||||
script.to_path_buf(),
|
||||
data_dir.to_path_buf(),
|
||||
auto_restart,
|
||||
);
|
||||
|
||||
Ok(json!({ "pid": pid, "state": state::RUNNING }))
|
||||
}
|
||||
|
||||
/// 停止被控端(幂等)。
|
||||
pub async fn stop(&self) -> Result<Value, String> {
|
||||
self.set_state(state::STOPPING).await;
|
||||
let child = {
|
||||
let mut inner = self.inner.lock().await;
|
||||
inner.child.take()
|
||||
};
|
||||
let Some(mut child) = child else {
|
||||
self.set_state(state::STOPPED).await;
|
||||
return Ok(json!({ "state": state::STOPPED, "note": "本来就未运行" }));
|
||||
};
|
||||
let pid = child.id();
|
||||
self.push_log("system", format!("停止被控端(pid {})…", pid.map(|p| p.to_string()).unwrap_or_else(|| "?".into())));
|
||||
|
||||
// Windows:用 taskkill /T 连子进程树一起收;POSIX:直接 kill 本进程。
|
||||
let killed = if cfg!(windows) {
|
||||
match pid {
|
||||
Some(p) => {
|
||||
let out = Command::new("taskkill")
|
||||
.args(["/PID", &p.to_string(), "/T", "/F"])
|
||||
.output()
|
||||
.await;
|
||||
match out {
|
||||
Ok(o) => o.status.success(),
|
||||
Err(_) => child.kill().await.is_ok(),
|
||||
}
|
||||
}
|
||||
None => child.kill().await.is_ok(),
|
||||
}
|
||||
} else {
|
||||
child.kill().await.is_ok()
|
||||
};
|
||||
|
||||
let _ = child.wait().await;
|
||||
{
|
||||
let mut inner = self.inner.lock().await;
|
||||
inner.pid = None;
|
||||
inner.started_at = None;
|
||||
inner.last_exit = Some(if killed { "stopped by console".into() } else { "stop failed".into() });
|
||||
}
|
||||
self.set_state(state::STOPPED).await;
|
||||
self.push_log("system", "被控端已停止");
|
||||
|
||||
// 端口释放确认(如被控端刚触发了「受控重启」,可能留下继承者)
|
||||
let data_dir = self
|
||||
.paths
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.map(|(_, _, d)| d.clone())
|
||||
.unwrap_or_else(agent_data_dir);
|
||||
let port = read_agent_config(&data_dir).get("port").and_then(Value::as_u64).unwrap_or(8765) as u16;
|
||||
for _ in 0..12 {
|
||||
if !port_in_use(port) {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(400)).await;
|
||||
}
|
||||
let still = port_in_use(port);
|
||||
if still {
|
||||
self.push_log(
|
||||
"system",
|
||||
format!("⚠️ 端口 {port} 仍未释放 —— 可能存在继承进程(如受控重启产生的后继),请检查"),
|
||||
);
|
||||
}
|
||||
Ok(json!({ "state": state::STOPPED, "port_released": !still }))
|
||||
}
|
||||
|
||||
/// 重启 = 停止 + 启动。
|
||||
pub async fn restart(
|
||||
self: &Arc<Self>,
|
||||
node: &Path,
|
||||
script: &Path,
|
||||
data_dir: &Path,
|
||||
auto_restart: bool,
|
||||
) -> Result<Value, String> {
|
||||
self.push_log("system", "重启被控端…");
|
||||
let _ = self.stop().await;
|
||||
tokio::time::sleep(Duration::from_millis(600)).await;
|
||||
self.start(node, script, data_dir, auto_restart).await
|
||||
}
|
||||
|
||||
/// 退出监控:轮询 `try_wait`(**不把 Child 移出去**,否则 stop() 就杀不到它了)。
|
||||
fn spawn_monitor(
|
||||
me: &Arc<Self>,
|
||||
node: PathBuf,
|
||||
script: PathBuf,
|
||||
data_dir: PathBuf,
|
||||
auto_restart: bool,
|
||||
) {
|
||||
let me = Arc::clone(me);
|
||||
tauri::async_runtime::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_millis(800)).await;
|
||||
let mut inner = me.inner.lock().await;
|
||||
let Some(child) = inner.child.as_mut() else {
|
||||
return; // 已被 stop() 取走 → 结束监控
|
||||
};
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
let code = status.code();
|
||||
inner.child = None;
|
||||
inner.pid = None;
|
||||
inner.started_at = None;
|
||||
inner.last_exit = Some(format!("exit code {:?}", code));
|
||||
drop(inner);
|
||||
|
||||
me.push_log("system", format!("被控端已退出(exit code {:?})", code));
|
||||
me.set_state(state::CRASHED).await;
|
||||
|
||||
if !auto_restart {
|
||||
return;
|
||||
}
|
||||
// 自动重启:退避 2s,超 5 次则放弃并留在 crashed(避免无限重启风暴)
|
||||
let n = {
|
||||
let mut inner = me.inner.lock().await;
|
||||
inner.restarts += 1;
|
||||
inner.restarts
|
||||
};
|
||||
if n > 5 {
|
||||
me.push_log("system", "自动重启已达上限(5 次),停止拉起。请查看上方日志定位原因。");
|
||||
return;
|
||||
}
|
||||
me.push_log("system", format!("将在 2 秒后自动重启(第 {n} 次)…"));
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
let _ = me.stop().await;
|
||||
if let Err(e) = me.start(&node, &script, &data_dir, true).await {
|
||||
me.push_log("system", format!("自动重启失败:{e}"));
|
||||
me.set_state(state::CRASHED).await;
|
||||
return;
|
||||
}
|
||||
// start() 已重新挂上监控,本任务结束
|
||||
return;
|
||||
}
|
||||
Ok(None) => { /* 仍在运行 */ }
|
||||
Err(e) => {
|
||||
me.push_log("system", format!("监控被控端失败:{e}"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 逐行读一个管道:入环形缓冲 + 推日志事件。
|
||||
fn spawn_line_pump<R>(
|
||||
reader: R,
|
||||
stream: &'static str,
|
||||
emit: AgentEmit,
|
||||
logs: Arc<Mutex<VecDeque<LogLine>>>,
|
||||
seq: Arc<AtomicU64>,
|
||||
) where
|
||||
R: tokio::io::AsyncRead + Unpin + Send + 'static,
|
||||
{
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut lines = BufReader::new(reader).lines();
|
||||
while let Ok(Some(text)) = lines.next_line().await {
|
||||
let entry = LogLine::new(seq.fetch_add(1, Ordering::SeqCst), stream, text);
|
||||
{
|
||||
let mut buf = logs.lock().unwrap();
|
||||
if buf.len() >= LOG_CAP {
|
||||
buf.pop_front();
|
||||
}
|
||||
buf.push_back(entry.clone());
|
||||
}
|
||||
emit(AgentEvent::Log(entry));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 单测:真跑 node 子进程
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn node_bin() -> PathBuf {
|
||||
crate::sidecar::resolve_node(None).expect("需要 node 才能跑被控端管理单测")
|
||||
}
|
||||
|
||||
/// 造一个「打印两行、然后常驻」的假被控端。
|
||||
fn fake_agent(dir: &Path) -> PathBuf {
|
||||
let p = dir.join("fake-agent.mjs");
|
||||
std::fs::write(
|
||||
&p,
|
||||
"console.log('READY-LINE');\nconsole.error('ERR-LINE');\nsetInterval(()=>{}, 1000);\n",
|
||||
)
|
||||
.unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
fn tmp_dir(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("na-agent-test-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn 启动应捕获日志且状态为running() {
|
||||
let dir = tmp_dir("start");
|
||||
let script = fake_agent(&dir);
|
||||
let m = AgentManager::new(Arc::new(|_| {}));
|
||||
|
||||
m.start(&node_bin(), &script, &dir, false).await.expect("应能启动");
|
||||
assert_eq!(m.snapshot().await["state"], json!(state::RUNNING));
|
||||
|
||||
// 等日志泵读到两行
|
||||
let mut collected = String::new();
|
||||
for _ in 0..40 {
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
let logs = m.logs_since(0);
|
||||
collected = logs["lines"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|l| l["line"].as_str().unwrap_or("").to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
if collected.contains("READY-LINE") && collected.contains("ERR-LINE") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(collected.contains("READY-LINE"), "应捕获 stdout,实际:{collected}");
|
||||
assert!(collected.contains("ERR-LINE"), "应捕获 stderr,实际:{collected}");
|
||||
|
||||
let r = m.stop().await.expect("应能停止");
|
||||
assert_eq!(r["state"], json!(state::STOPPED));
|
||||
assert_eq!(m.snapshot().await["state"], json!(state::STOPPED));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn 停止后应可再次启动() {
|
||||
let dir = tmp_dir("restart");
|
||||
let script = fake_agent(&dir);
|
||||
let m = AgentManager::new(Arc::new(|_| {}));
|
||||
|
||||
m.start(&node_bin(), &script, &dir, false).await.unwrap();
|
||||
m.stop().await.unwrap();
|
||||
m.start(&node_bin(), &script, &dir, false).await.expect("停止后应能重新启动");
|
||||
assert_eq!(m.snapshot().await["state"], json!(state::RUNNING));
|
||||
m.stop().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn 重复启动应被拒绝而不是起两个进程() {
|
||||
let dir = tmp_dir("dup");
|
||||
let script = fake_agent(&dir);
|
||||
let m = AgentManager::new(Arc::new(|_| {}));
|
||||
|
||||
m.start(&node_bin(), &script, &dir, false).await.unwrap();
|
||||
let err = m.start(&node_bin(), &script, &dir, false).await;
|
||||
assert!(err.is_err(), "已在运行时再次启动应被拒绝");
|
||||
assert!(err.unwrap_err().contains("已在运行"), "错误信息应说明原因");
|
||||
m.stop().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn 端口被外部占用时应给出可执行的错误说明() {
|
||||
let dir = tmp_dir("port-busy");
|
||||
let script = fake_agent(&dir);
|
||||
// 占住一个空闲端口,扮演「手工安装的 / 计划任务拉起的另一个被控端」
|
||||
// (真机症状 B:陈旧计划任务 na-gui 占着 8765,控制台自己的被控端起不来)
|
||||
let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).expect("应能绑一个空闲端口");
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
std::fs::write(
|
||||
dir.join("agent.json"),
|
||||
format!("{{\"node_id\":\"t\",\"port\":{port},\"key\":\"k\",\"tls\":true}}"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let m = AgentManager::new(Arc::new(|_| {}));
|
||||
let err = m
|
||||
.start(&node_bin(), &script, &dir, false)
|
||||
.await
|
||||
.expect_err("端口被占时应拒绝启动");
|
||||
assert!(err.contains("已被占用"), "应说明端口被占用,实际:{err}");
|
||||
assert!(err.contains(&port.to_string()), "错误里应带上具体端口号,实际:{err}");
|
||||
// 关键:必须给出「怎么找出占用者」的可执行线索,否则用户无从下手
|
||||
assert!(
|
||||
err.contains("lsof") || err.contains("netstat"),
|
||||
"应给出排查占用者的命令,实际:{err}"
|
||||
);
|
||||
// 端口检查失败不该改动状态、更不该留下进程
|
||||
assert_eq!(m.snapshot().await["state"], json!(state::STOPPED));
|
||||
drop(listener);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn 进程意外退出应转为crashed() {
|
||||
let dir = tmp_dir("crash");
|
||||
let p = dir.join("crash-agent.mjs");
|
||||
std::fs::write(&p, "console.log('BYE');\nprocess.exit(3);\n").unwrap();
|
||||
let m = AgentManager::new(Arc::new(|_| {}));
|
||||
|
||||
m.start(&node_bin(), &p, &dir, false).await.unwrap();
|
||||
let mut seen = String::new();
|
||||
for _ in 0..40 {
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
seen = m.snapshot().await["state"].as_str().unwrap_or("").to_string();
|
||||
if seen == state::CRASHED {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(seen, state::CRASHED, "进程退出后状态应为 crashed");
|
||||
assert!(
|
||||
m.snapshot().await["last_exit"].as_str().unwrap_or("").contains("3"),
|
||||
"应记录退出码"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn 播种应只在应用版本变化时覆盖() {
|
||||
let dir = tmp_dir("seed");
|
||||
let bundled = dir.join("bundled.mjs");
|
||||
std::fs::write(&bundled, "// v1\n").unwrap();
|
||||
let data = dir.join("data");
|
||||
std::fs::create_dir_all(&data).unwrap();
|
||||
|
||||
let t = seed_agent_script(&bundled, &data, "2.0.0").unwrap();
|
||||
assert!(t.exists());
|
||||
assert_eq!(std::fs::read_to_string(&t).unwrap(), "// v1\n");
|
||||
|
||||
// 模拟「被控端自更新」改了副本
|
||||
std::fs::write(&t, "// self-updated v9\n").unwrap();
|
||||
// 同版本再播种 → 不应覆盖
|
||||
seed_agent_script(&bundled, &data, "2.0.0").unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&t).unwrap(),
|
||||
"// self-updated v9\n",
|
||||
"同版本播种不得覆盖已自更新的副本(否则是静默降级)"
|
||||
);
|
||||
|
||||
// 应用升级 → 应重新播种
|
||||
std::fs::write(&bundled, "// v2\n").unwrap();
|
||||
seed_agent_script(&bundled, &data, "2.1.0").unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&t).unwrap(), "// v2\n", "应用版本变化后应重新播种");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn 播种不得把更新过的被控端降级() {
|
||||
let dir = tmp_dir("seed-down");
|
||||
let bundled = dir.join("bundled.mjs");
|
||||
std::fs::write(&bundled, "// bundled 2.0.3\n").unwrap();
|
||||
let data = dir.join("data");
|
||||
std::fs::create_dir_all(&data).unwrap();
|
||||
|
||||
// 模拟:被控端已通过控制台更新到 2.1.0
|
||||
std::fs::create_dir_all(data.join("runtime")).unwrap();
|
||||
std::fs::write(agent_script_path(&data), "// console-updated 2.1.0\n").unwrap();
|
||||
mark_installed_version(&data, "2.1.0", "console").unwrap();
|
||||
|
||||
seed_agent_script(&bundled, &data, "2.0.3").unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(agent_script_path(&data)).unwrap(),
|
||||
"// console-updated 2.1.0\n",
|
||||
"随包版本更低时**不得**覆盖数据目录里更新的被控端(那是静默降级)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn 版本比较应按数值而非字符串() {
|
||||
assert_eq!(cmp_version("2.0.10", "2.0.9"), 1, "2.0.10 应大于 2.0.9");
|
||||
assert_eq!(cmp_version("2.0.2", "2.0.2"), 0);
|
||||
assert_eq!(cmp_version("2.1.0", "2.0.99"), 1);
|
||||
assert_eq!(cmp_version("2.0", "2.0.1"), -1, "缺段按 0 处理");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
//! 开机自启管理 —— 「随系统启动」开关的落地实现。
|
||||
//!
|
||||
//! ## 设计:自启的是**控制台的仅服务模式**,不是裸 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";
|
||||
|
||||
/// 当前可执行文件路径。
|
||||
fn app_exe() -> Result<PathBuf, String> {
|
||||
std::env::current_exe().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 })
|
||||
}
|
||||
}
|
||||
|
||||
/// 自启是否已启用。
|
||||
pub fn is_enabled() -> 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
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取自启状态的完整快照(供界面展示)。
|
||||
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)?
|
||||
};
|
||||
|
||||
let mut snap = status();
|
||||
snap["detail"] = json!(detail);
|
||||
Ok(snap)
|
||||
}
|
||||
@@ -1,42 +1,151 @@
|
||||
//! nodeagent 桌面控制台(主控 + 被控一体)——Tauri 后端。
|
||||
//!
|
||||
//! command 层把 `nodeagent-client`(Rust 控制端)接到前端:
|
||||
//! ## 结构(2026-10 起:sidecar 方案)
|
||||
//! 本层只做**进程管理 + 消息转发**,真正的协议实现只有 TS 一份(`@nodeagent/client`),
|
||||
//! 由 `sidecar.rs` 拉起的 Node 进程承载。原先「Rust 版 client 与 TS 版并存」的漂移风险就此消除。
|
||||
//!
|
||||
//! `agent.rs` 是同一思路的延伸:本机**被控端**也由本层托管(启动 / 停止 / 重启 / 日志 /
|
||||
//! 崩溃自愈),用户在界面上点按钮即可,全程不必碰命令行。
|
||||
//!
|
||||
//! ## command 面
|
||||
//! 连接类(签名与语义从未变过):
|
||||
//! - `connect` / `disconnect` / `invoke_capability` / `get_capabilities` / `get_state`
|
||||
//! - client 的日志/状态/被控端事件经 Tauri `emit` 推给前端(`na-log`/`na-state`/`na-event`)。
|
||||
//!
|
||||
//! 本机服务类(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 nodeagent_client::{
|
||||
CapabilityDescriptor, Client, ClientEvent, ClientOptions, ConnState, InvokeResult,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
use serde_json::{json, Value};
|
||||
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
|
||||
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
|
||||
use tauri::{AppHandle, Emitter, Manager, State};
|
||||
|
||||
/// 全局状态:持有当前连接(`None` = 未连接)。
|
||||
use agent::{AgentEvent, AgentManager};
|
||||
use sidecar::{Sidecar, SidecarEvent};
|
||||
|
||||
/// 全局状态。
|
||||
///
|
||||
/// `connect` 成功后缓存「能力清单 / 版本 / 连接态」——这样 `get_capabilities`、
|
||||
/// `get_state` 能保持**同步**(无需每次往返 sidecar),签名与改造前一致。
|
||||
struct AppState {
|
||||
client: Mutex<Option<Client>>,
|
||||
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,
|
||||
}
|
||||
|
||||
/// 连接成功后回传给前端的元信息。
|
||||
#[derive(Serialize)]
|
||||
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: Vec<CapabilityDescriptor>,
|
||||
capabilities: Value,
|
||||
agent_version: Option<String>,
|
||||
authorized: Option<Vec<String>>,
|
||||
authorized: Option<Value>,
|
||||
peer_cert_fp: Option<String>,
|
||||
auth_mode: String,
|
||||
client_id: String,
|
||||
}
|
||||
|
||||
fn conn_state_str(s: ConnState) -> &'static str {
|
||||
match s {
|
||||
ConnState::Connected => "connected",
|
||||
ConnState::Reconnecting => "reconnecting",
|
||||
ConnState::Closed => "closed",
|
||||
}
|
||||
/// 本机被控端的三条路径。
|
||||
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() {
|
||||
return Ok(Arc::clone(sc));
|
||||
}
|
||||
|
||||
// 打包后 node 与 sidecar.mjs 随 resource 分发;开发期用系统 node + 仓库内脚本
|
||||
let resource_dir = app.path().resource_dir().ok();
|
||||
let node = sidecar::resolve_node(resource_dir.as_deref())?;
|
||||
let script = sidecar::resolve_sidecar(resource_dir.as_deref())?;
|
||||
|
||||
// 事件桥接:sidecar 通知 → Tauri event(名字与改造前一致)
|
||||
let app_events = app.clone();
|
||||
let on_event = 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(
|
||||
@@ -47,51 +156,51 @@ async fn connect(
|
||||
client_id: Option<String>,
|
||||
insecure: Option<bool>,
|
||||
) -> Result<ConnectInfo, String> {
|
||||
let sc = ensure_sidecar(&app, &state).await?;
|
||||
let client_id = client_id.unwrap_or_else(|| "console".to_string());
|
||||
let client = Client::new(ClientOptions {
|
||||
url,
|
||||
key,
|
||||
client_id: client_id.clone(),
|
||||
insecure: insecure.unwrap_or(true),
|
||||
auto_reconnect: true,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// 把 client 的事件桥接成 Tauri 事件,推给前端。
|
||||
let app_events = app.clone();
|
||||
client.set_handler(Arc::new(move |evt| match evt {
|
||||
ClientEvent::State(s) => {
|
||||
let _ = app_events.emit("na-state", conn_state_str(s));
|
||||
}
|
||||
ClientEvent::Log(msg) => {
|
||||
let _ = app_events.emit("na-log", msg);
|
||||
}
|
||||
ClientEvent::Event(params) => {
|
||||
let _ = app_events.emit("na-event", params);
|
||||
}
|
||||
}));
|
||||
let snap = sc
|
||||
.request(
|
||||
"connect",
|
||||
json!({
|
||||
"url": url,
|
||||
"key": key,
|
||||
"client_id": client_id,
|
||||
"insecure": insecure.unwrap_or(true),
|
||||
}),
|
||||
30,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let caps = client.connect().await.map_err(|e| e.to_string())?;
|
||||
let caps = snap.get("capabilities").cloned().unwrap_or_else(|| json!([]));
|
||||
let ver = snap.get("agent_version").and_then(Value::as_str).map(str::to_string);
|
||||
*state.capabilities.lock().unwrap() = caps.clone();
|
||||
*state.agent_version.lock().unwrap() = ver.clone();
|
||||
*state.conn_state.lock().unwrap() = "connected".to_string();
|
||||
|
||||
let info = ConnectInfo {
|
||||
agent_version: client.agent_version(),
|
||||
authorized: client.list_authorized(),
|
||||
peer_cert_fp: client.peer_cert_fingerprint(),
|
||||
auth_mode: "psk".to_string(),
|
||||
Ok(ConnectInfo {
|
||||
capabilities: caps,
|
||||
agent_version: ver,
|
||||
authorized: snap.get("authorized").cloned(),
|
||||
peer_cert_fp: snap.get("peer_cert_fp").and_then(Value::as_str).map(str::to_string),
|
||||
auth_mode: snap
|
||||
.get("auth_mode")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("psk")
|
||||
.to_string(),
|
||||
client_id,
|
||||
};
|
||||
|
||||
*state.client.lock().unwrap() = Some(client);
|
||||
Ok(info)
|
||||
})
|
||||
}
|
||||
|
||||
/// 断开连接(停止自动重连)。
|
||||
#[tauri::command]
|
||||
fn disconnect(state: State<'_, AppState>) -> Result<(), String> {
|
||||
if let Some(client) = state.client.lock().unwrap().take() {
|
||||
client.close();
|
||||
async fn disconnect(state: State<'_, AppState>) -> Result<(), String> {
|
||||
let sc = { state.sidecar.lock().await.as_ref().map(Arc::clone) };
|
||||
if let Some(sc) = sc {
|
||||
sc.request("disconnect", json!({}), 10).await?;
|
||||
}
|
||||
*state.conn_state.lock().unwrap() = "未连接".to_string();
|
||||
*state.capabilities.lock().unwrap() = json!([]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -102,50 +211,559 @@ async fn invoke_capability(
|
||||
capability: String,
|
||||
args: Option<Value>,
|
||||
timeout_ms: Option<u64>,
|
||||
) -> Result<InvokeResult, String> {
|
||||
let client = {
|
||||
let guard = state.client.lock().unwrap();
|
||||
guard.as_ref().cloned().ok_or_else(|| "未连接".to_string())?
|
||||
) -> Result<Value, String> {
|
||||
let sc = {
|
||||
state
|
||||
.sidecar
|
||||
.lock()
|
||||
.await
|
||||
.as_ref()
|
||||
.map(Arc::clone)
|
||||
.ok_or_else(|| "未连接".to_string())?
|
||||
};
|
||||
let args = args.unwrap_or(Value::Object(Default::default()));
|
||||
match timeout_ms {
|
||||
Some(ms) if ms > 0 => client.invoke_with_timeout(&capability, args, ms).await,
|
||||
_ => client.invoke(&capability, args).await,
|
||||
let mut params = json!({ "capability": capability, "args": args.unwrap_or_else(|| json!({})) });
|
||||
if let Some(ms) = timeout_ms.filter(|m| *m > 0) {
|
||||
params["timeout_ms"] = json!(ms);
|
||||
}
|
||||
.map_err(|e| e.to_string())
|
||||
// sidecar 侧已把「未知能力 / ACL 拒绝 / 超时」归一成 {status:"failed",error:{...}},
|
||||
// 故这里拿到的是稳定的 InvokeResult 形态(前端只需判 status)。
|
||||
sc.request("invoke", params, timeout_ms.map(|m| (m / 1000) + 10).unwrap_or(70)).await
|
||||
}
|
||||
|
||||
/// 当前连接的能力清单。
|
||||
/// 当前连接的能力清单(读缓存,保持同步签名)。
|
||||
#[tauri::command]
|
||||
fn get_capabilities(state: State<'_, AppState>) -> Result<Vec<CapabilityDescriptor>, String> {
|
||||
let guard = state.client.lock().unwrap();
|
||||
let client = guard.as_ref().ok_or_else(|| "未连接".to_string())?;
|
||||
Ok(client.list_capabilities())
|
||||
fn get_capabilities(state: State<'_, AppState>) -> Result<Value, String> {
|
||||
Ok(state.capabilities.lock().unwrap().clone())
|
||||
}
|
||||
|
||||
/// 当前连接状态字符串(connected / reconnecting / closed / 未连接)。
|
||||
#[tauri::command]
|
||||
fn get_state(state: State<'_, AppState>) -> String {
|
||||
match state.client.lock().unwrap().as_ref() {
|
||||
Some(client) => conn_state_str(client.state()).to_string(),
|
||||
None => "未连接".to_string(),
|
||||
state.conn_state.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 本机服务(被控端)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
|
||||
}
|
||||
|
||||
/// 重启本机被控端。
|
||||
#[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?;
|
||||
let mut snap = sc
|
||||
.request(
|
||||
"check_update",
|
||||
json!({ "console_version": state.app_version }),
|
||||
45,
|
||||
)
|
||||
.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(),
|
||||
}),
|
||||
320,
|
||||
)
|
||||
.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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
"quit" => app.exit(0),
|
||||
_ => {}
|
||||
}
|
||||
})
|
||||
.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())
|
||||
.manage(AppState {
|
||||
client: Mutex::new(None),
|
||||
.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", "窗口已隐藏到托盘(被控端继续运行;从托盘菜单可退出)");
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// 连接
|
||||
connect,
|
||||
disconnect,
|
||||
invoke_capability,
|
||||
get_capabilities,
|
||||
get_state
|
||||
get_state,
|
||||
// 本机服务
|
||||
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,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
.run(|app, event| {
|
||||
// app 退出时优雅收尾:先让 sidecar 主动 disconnect,再杀进程
|
||||
//(Sidecar 设了 kill_on_drop,即使这里失败也不会留孤儿进程)
|
||||
if let tauri::RunEvent::Exit = event {
|
||||
let state = app.state::<AppState>();
|
||||
let sc = tauri::async_runtime::block_on(async {
|
||||
state.sidecar.lock().await.as_ref().map(Arc::clone)
|
||||
});
|
||||
if let Some(sc) = sc {
|
||||
tauri::async_runtime::block_on(async {
|
||||
sc.shutdown().await;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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,315 @@
|
||||
//! Node sidecar 管理器 —— 启动 `apps/console/sidecar/main.mjs` 并做 JSON-RPC 转发。
|
||||
//!
|
||||
//! ## 为什么是 sidecar(而不是 Rust 版 client)
|
||||
//! 原设计让本壳自己实现一份 client,与 TS 版(`@nodeagent/client`)**两套协议实现并存** ——
|
||||
//! 改了一处漏另一处时,构建过、单测过,只在真机暴露。sidecar 让本壳只做
|
||||
//! **进程管理 + 消息转发**,协议实现只剩 TS 一份。
|
||||
//!
|
||||
//! ## 协议(行分隔 JSON,见 apps/console/sidecar/main.mjs 顶部注释)
|
||||
//! 出(本壳 → sidecar):`{"jsonrpc":"2.0","id":N,"method":...,"params":{...}}`
|
||||
//! 入(sidecar → 本壳):
|
||||
//! - 响应:`{"jsonrpc":"2.0","id":N,"result"|"error":...}`
|
||||
//! - 通知:`{"jsonrpc":"2.0","method":"event","params":{"kind":...,...}}`
|
||||
//!
|
||||
//! ## 可测试性(本项目纪律:不能只 `cargo check`)
|
||||
//! 本模块的 `spawn` / `request` / 事件回调**都能在 `cargo test` 里真跑** ——
|
||||
//! 单测直接拉起真实的 node 进程,不依赖 Tauri GUI。GUI 那层才需要人工验证。
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::process::{Child, ChildStdin, Command};
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
/// sidecar 主动推上来的事件(与 `main.mjs` 的 `params.kind` 一一对应)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SidecarEvent {
|
||||
/// 连接状态变化:connected / reconnecting / closed
|
||||
State(String),
|
||||
/// 日志一行
|
||||
Log(String),
|
||||
/// 被控端主动推送的事件(v12 `event.*`)
|
||||
Event(Value),
|
||||
/// sidecar 上来的未知通知(保留原始形态,便于排查)
|
||||
Other(Value),
|
||||
}
|
||||
|
||||
type Pending = Arc<Mutex<HashMap<u64, oneshot::Sender<Result<Value, String>>>>>;
|
||||
|
||||
/// 一个已启动的 sidecar 进程。
|
||||
pub struct Sidecar {
|
||||
child: Mutex<Option<Child>>,
|
||||
stdin: tokio::sync::Mutex<ChildStdin>,
|
||||
pending: Pending,
|
||||
next_id: AtomicU64,
|
||||
}
|
||||
|
||||
impl Sidecar {
|
||||
/// 启动 sidecar 并开始读取其 stdout。
|
||||
///
|
||||
/// `on_event`:sidecar 的通知回调(本壳里用于桥接成 Tauri event)。
|
||||
pub fn spawn(
|
||||
node: &Path,
|
||||
script: &Path,
|
||||
on_event: Arc<dyn Fn(SidecarEvent) + Send + Sync>,
|
||||
) -> Result<Arc<Self>, String> {
|
||||
let mut child = Command::new(node)
|
||||
.arg(script)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true) // app 退出时不留孤儿进程
|
||||
.spawn()
|
||||
.map_err(|e| format!("启动 sidecar 失败({} {}):{e}", node.display(), script.display()))?;
|
||||
|
||||
let stdin = child.stdin.take().ok_or("sidecar stdin 不可用")?;
|
||||
let stdout = child.stdout.take().ok_or("sidecar stdout 不可用")?;
|
||||
let stderr = child.stderr.take().ok_or("sidecar stderr 不可用")?;
|
||||
|
||||
let pending: Pending = Arc::new(Mutex::new(HashMap::new()));
|
||||
|
||||
// stdout:逐行读,分派「响应」与「通知」
|
||||
{
|
||||
let pending = Arc::clone(&pending);
|
||||
tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(stdout).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
let text = line.trim();
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Ok(msg) = serde_json::from_str::<Value>(text) else {
|
||||
// 混入非 JSON 行说明协议被破坏 —— 记日志但不崩
|
||||
eprintln!("[sidecar] 非 JSON 输出:{}", &text[..text.len().min(200)]);
|
||||
continue;
|
||||
};
|
||||
|
||||
// 响应:有 id
|
||||
if let Some(id) = msg.get("id").and_then(Value::as_u64) {
|
||||
if let Some(tx) = pending.lock().unwrap().remove(&id) {
|
||||
let out = if let Some(err) = msg.get("error") {
|
||||
Err(err
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("sidecar 返回错误")
|
||||
.to_string())
|
||||
} else {
|
||||
Ok(msg.get("result").cloned().unwrap_or(Value::Null))
|
||||
};
|
||||
let _ = tx.send(out);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 通知:method == "event"
|
||||
if msg.get("method").and_then(Value::as_str) == Some("event") {
|
||||
let p = msg.get("params").cloned().unwrap_or(Value::Null);
|
||||
let kind = p.get("kind").and_then(Value::as_str).unwrap_or("");
|
||||
let evt = match kind {
|
||||
"state" => SidecarEvent::State(
|
||||
p.get("state").and_then(Value::as_str).unwrap_or("closed").to_string(),
|
||||
),
|
||||
"log" => SidecarEvent::Log(
|
||||
p.get("message").and_then(Value::as_str).unwrap_or("").to_string(),
|
||||
),
|
||||
"agent_event" => {
|
||||
SidecarEvent::Event(p.get("event").cloned().unwrap_or(Value::Null))
|
||||
}
|
||||
_ => SidecarEvent::Other(p),
|
||||
};
|
||||
on_event(evt);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// stderr:sidecar 的日志 → 本壳 stderr(保持 stdout 干净)
|
||||
tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(stderr).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
eprintln!("{}", line);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Arc::new(Self {
|
||||
child: Mutex::new(Some(child)),
|
||||
stdin: tokio::sync::Mutex::new(stdin),
|
||||
pending,
|
||||
next_id: AtomicU64::new(1),
|
||||
}))
|
||||
}
|
||||
|
||||
/// 发一个请求并等响应(`timeout_secs` 兜底,避免 sidecar 卡住时本壳一起挂)。
|
||||
pub async fn request(&self, method: &str, params: Value, timeout_secs: u64) -> Result<Value, String> {
|
||||
let id = self.next_id.fetch_add(1, Ordering::SeqCst);
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.pending.lock().unwrap().insert(id, tx);
|
||||
|
||||
let msg = json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params });
|
||||
{
|
||||
let mut stdin = self.stdin.lock().await;
|
||||
stdin
|
||||
.write_all(format!("{msg}\n").as_bytes())
|
||||
.await
|
||||
.map_err(|e| format!("写入 sidecar 失败:{e}"))?;
|
||||
stdin.flush().await.map_err(|e| format!("flush sidecar 失败:{e}"))?;
|
||||
}
|
||||
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), rx).await {
|
||||
Ok(Ok(r)) => r,
|
||||
Ok(Err(_)) => Err("sidecar 通道已关闭".to_string()),
|
||||
Err(_) => {
|
||||
self.pending.lock().unwrap().remove(&id);
|
||||
Err(format!("{method} 超时({timeout_secs}s)"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 关掉 sidecar(app 退出或显式 disconnect 时调用)。
|
||||
pub async fn shutdown(&self) {
|
||||
let _ = self.request("disconnect", json!({}), 5).await;
|
||||
// ⚠️ 必须**先把 child 取出来、让锁离开作用域**,再 await kill()。
|
||||
// 若写成 `if let Some(child) = self.child.lock().unwrap().take() { child.kill().await }`,
|
||||
// MutexGuard 会存活到 if 块结束 → 跨 await 持有 std Mutex(clippy::await_holding_lock,
|
||||
// 且真会阻塞 executor)。
|
||||
let child = self.child.lock().unwrap().take();
|
||||
if let Some(mut child) = child {
|
||||
let _ = child.kill().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 路径解析:开发期用系统 node + 仓库内脚本;打包后从 Tauri resource 目录取
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 解析 node 可执行文件。
|
||||
///
|
||||
/// 优先级:环境变量 `NODEAGENT_CONSOLE_NODE` → 随包 resource 的 `node` → 系统 `node`。
|
||||
pub fn resolve_node(resource_dir: Option<&Path>) -> Result<PathBuf, String> {
|
||||
if let Ok(p) = std::env::var("NODEAGENT_CONSOLE_NODE") {
|
||||
if !p.is_empty() {
|
||||
return Ok(PathBuf::from(p));
|
||||
}
|
||||
}
|
||||
if let Some(dir) = resource_dir {
|
||||
let bundled = dir.join(if cfg!(windows) { "node.exe" } else { "node" });
|
||||
if bundled.exists() {
|
||||
return Ok(bundled);
|
||||
}
|
||||
}
|
||||
// 系统 node(开发期)
|
||||
for cand in ["node", "node.exe"] {
|
||||
if which(cand).is_some() {
|
||||
return Ok(PathBuf::from(cand));
|
||||
}
|
||||
}
|
||||
Err("找不到 node 可执行文件(可用 NODEAGENT_CONSOLE_NODE 指定)".to_string())
|
||||
}
|
||||
|
||||
/// 极简 which(避免为一个函数引依赖)。
|
||||
fn which(cmd: &str) -> Option<PathBuf> {
|
||||
let path = std::env::var_os("PATH")?;
|
||||
for dir in std::env::split_paths(&path) {
|
||||
let full = dir.join(cmd);
|
||||
if full.is_file() {
|
||||
return Some(full);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 解析 sidecar 脚本。
|
||||
///
|
||||
/// 优先级:环境变量 `NODEAGENT_CONSOLE_SIDECAR` → 随包 resource 的 `sidecar.mjs` →
|
||||
/// 开发期的 `apps/console/sidecar/dist/sidecar.mjs`(相对本 crate)。
|
||||
pub fn resolve_sidecar(resource_dir: Option<&Path>) -> Result<PathBuf, String> {
|
||||
if let Ok(p) = std::env::var("NODEAGENT_CONSOLE_SIDECAR") {
|
||||
if !p.is_empty() && Path::new(&p).exists() {
|
||||
return Ok(PathBuf::from(p));
|
||||
}
|
||||
}
|
||||
if let Some(dir) = resource_dir {
|
||||
let bundled = dir.join("sidecar.mjs");
|
||||
if bundled.exists() {
|
||||
return Ok(bundled);
|
||||
}
|
||||
}
|
||||
// 开发期:src-tauri → apps/console/sidecar/dist/sidecar.mjs
|
||||
let dev = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../sidecar/dist/sidecar.mjs");
|
||||
if dev.exists() {
|
||||
return Ok(dev);
|
||||
}
|
||||
Err("找不到 sidecar.mjs(先跑 `pnpm --filter nodeagent-console sidecar:build`,\
|
||||
或用 NODEAGENT_CONSOLE_SIDECAR 指定)"
|
||||
.to_string())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 单测:真跑 sidecar 进程(不依赖 Tauri GUI)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
|
||||
/// 开发期的 sidecar 源码(不依赖打包产物,pnpm 的 node_modules 已能解析其依赖)。
|
||||
fn dev_sidecar_src() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../sidecar/main.mjs")
|
||||
}
|
||||
|
||||
fn node_bin() -> PathBuf {
|
||||
resolve_node(None).expect("需要 node 才能跑 sidecar 单测")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn 启动后_state_请求应返回未连接() {
|
||||
let seen: Arc<AtomicUsize> = Arc::new(AtomicUsize::new(0));
|
||||
let counter = Arc::clone(&seen);
|
||||
let sc = Sidecar::spawn(
|
||||
&node_bin(),
|
||||
&dev_sidecar_src(),
|
||||
Arc::new(move |_e| {
|
||||
counter.fetch_add(1, Ordering::SeqCst);
|
||||
}),
|
||||
)
|
||||
.expect("sidecar 应能启动");
|
||||
|
||||
let r = sc.request("state", json!({}), 15).await.expect("state 应成功");
|
||||
assert_eq!(r["connected"], json!(false), "未连接时 connected 应为 false");
|
||||
sc.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn 未知方法应返回错误而不是崩溃() {
|
||||
let sc = Sidecar::spawn(&node_bin(), &dev_sidecar_src(), Arc::new(|_| {}))
|
||||
.expect("sidecar 应能启动");
|
||||
|
||||
let err = sc.request("no_such_method", json!({}), 15).await;
|
||||
assert!(err.is_err(), "未知方法应返回错误");
|
||||
|
||||
// 关键:进程没崩,后续请求仍可用
|
||||
let ok = sc.request("state", json!({}), 15).await.expect("sidecar 不应因错误退出");
|
||||
assert_eq!(ok["connected"], json!(false));
|
||||
sc.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn 未连接时_invoke_应返回明确错误() {
|
||||
let sc = Sidecar::spawn(&node_bin(), &dev_sidecar_src(), Arc::new(|_| {}))
|
||||
.expect("sidecar 应能启动");
|
||||
|
||||
let err = sc.request("invoke", json!({"capability": "system.info"}), 15).await;
|
||||
match err {
|
||||
Err(msg) => assert!(msg.contains("尚未连接"), "错误信息应提示未连接,实际:{msg}"),
|
||||
Ok(v) => panic!("未连接时 invoke 应失败,实际返回:{v}"),
|
||||
}
|
||||
sc.shutdown().await;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "nodeagent-console",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.3",
|
||||
"identifier": "cn.jkooe.nodeagent-console",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "pnpm build",
|
||||
"beforeBuildCommand": "pnpm build && pnpm sidecar:build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
@@ -30,6 +30,9 @@
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
],
|
||||
"resources": {
|
||||
"../sidecar/dist/sidecar.mjs": "sidecar.mjs"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
NConfigProvider,
|
||||
NMessageProvider,
|
||||
NDialogProvider,
|
||||
darkTheme,
|
||||
zhCN,
|
||||
dateZhCN,
|
||||
} from "naive-ui";
|
||||
import { watchEffect } from "vue";
|
||||
import { NConfigProvider, NMessageProvider, NDialogProvider, zhCN, dateZhCN } from "naive-ui";
|
||||
import AppShell from "./components/AppShell.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="darkTheme" :locale="zhCN" :date-locale="dateZhCN">
|
||||
<n-config-provider
|
||||
:theme="theme.preset.base"
|
||||
:theme-overrides="theme.preset.overrides"
|
||||
:locale="zhCN"
|
||||
:date-locale="dateZhCN"
|
||||
>
|
||||
<n-message-provider>
|
||||
<n-dialog-provider>
|
||||
<app-shell />
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
/** Tauri command 调用封装(前端 → Rust 壳 → nodeagent-client)。 */
|
||||
/** Tauri command 调用封装(前端 → Rust 壳 → Node sidecar → 被控端)。 */
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { CapabilityDescriptor, ConnectInfo, InvokeResult } from "../types";
|
||||
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) {
|
||||
@@ -86,3 +98,72 @@ export async function ok<T = unknown>(
|
||||
}
|
||||
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 });
|
||||
}
|
||||
|
||||
/** 增量取日志(传上次拿到的最大 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");
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 496 B |
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted } from "vue";
|
||||
import { computed, onMounted } from "vue";
|
||||
import { RouterView, useRoute, useRouter } from "vue-router";
|
||||
import {
|
||||
NLayout,
|
||||
@@ -11,25 +11,39 @@ import {
|
||||
NButton,
|
||||
NSpace,
|
||||
NText,
|
||||
NTooltip,
|
||||
NRadioGroup,
|
||||
NRadioButton,
|
||||
type MenuOption,
|
||||
} from "naive-ui";
|
||||
import { navRoutes } from "../router";
|
||||
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: () => h("span", `${(r.meta as { icon: string }).icon} ${(r.meta as { title: string }).title}`),
|
||||
label: (r.meta as NavMeta).title,
|
||||
}));
|
||||
|
||||
const activeKey = computed(() => (route.name as string) ?? "nodes");
|
||||
const activeKey = computed(() => (route.name as string) ?? "local");
|
||||
|
||||
function onMenu(key: string) {
|
||||
router.push({ name: key });
|
||||
@@ -46,6 +60,12 @@ const stateLabel = computed(() => {
|
||||
if (conn.state === "reconnecting") return "重连中";
|
||||
return "未连接";
|
||||
});
|
||||
|
||||
/** 从侧栏快速启停本机服务(不必切页面)。 */
|
||||
async function quickToggle() {
|
||||
if (agent.running) await agent.stop();
|
||||
else await agent.start();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -56,6 +76,30 @@ const stateLabel = computed(() => {
|
||||
<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>
|
||||
@@ -67,7 +111,15 @@ const stateLabel = computed(() => {
|
||||
· agent {{ conn.info.agent_version ?? "?" }} · {{ conn.info.capabilities.length }} 项能力
|
||||
</n-text>
|
||||
</n-space>
|
||||
<n-button size="small" :disabled="!conn.info" @click="conn.disconnect()">断开</n-button>
|
||||
<n-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;">
|
||||
@@ -78,8 +130,12 @@ const stateLabel = computed(() => {
|
||||
</template>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
/* 配色只走 `--na-*` 变量(值在 src/theme/index.ts 单点定义,App.vue 运行期注入),
|
||||
此处不留任何写死的色值 —— 否则换主题时会一半新一半旧,且不报错。
|
||||
`color-scheme` 也由 App.vue 按主题设,这里不重复声明。 */
|
||||
html,
|
||||
body {
|
||||
background: var(--na-bg);
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
@@ -90,12 +146,12 @@ body {
|
||||
.brand-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #63e2b7;
|
||||
color: var(--na-brand);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.brand-sub {
|
||||
font-size: 12px;
|
||||
opacity: 0.6;
|
||||
color: var(--na-text-3);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.topbar {
|
||||
@@ -105,4 +161,44 @@ body {
|
||||
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>
|
||||
@@ -1,54 +1,70 @@
|
||||
/** 路由表(7 页)。hash history 便于 Tauri 打包后稳定工作。 */
|
||||
/** 路由表。hash history 便于 Tauri 打包后稳定工作。 */
|
||||
import { createRouter, createWebHashHistory } from "vue-router";
|
||||
|
||||
// 刻意不设 icon 字段:应用内一律不使用图标,导航以纯文字呈现。
|
||||
// (原因见 CHANGELOG [未发布]:emoji 图标在非系统字体栈的渲染器里会退化成豆腐块,
|
||||
// 而跨平台「哪套字体栈一定带 emoji」并不成立 —— 与其赌字形,不如不用。)
|
||||
export interface NavMeta {
|
||||
title: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
const routes = [
|
||||
{ path: "/", redirect: "/nodes" },
|
||||
// 默认落在「本机服务」—— 这是「打开应用就要能启动/停止/重启」的落点,
|
||||
// 而不是先让用户去填一个连接地址。
|
||||
{ path: "/", redirect: "/local" },
|
||||
{
|
||||
path: "/local",
|
||||
name: "local",
|
||||
component: () => import("../views/LocalServiceView.vue"),
|
||||
meta: { title: "本机服务" } satisfies NavMeta,
|
||||
},
|
||||
{
|
||||
path: "/logs",
|
||||
name: "logs",
|
||||
component: () => import("../views/LogsView.vue"),
|
||||
meta: { title: "服务日志" } satisfies NavMeta,
|
||||
},
|
||||
{
|
||||
path: "/nodes",
|
||||
name: "nodes",
|
||||
component: () => import("../views/NodesView.vue"),
|
||||
meta: { title: "设备管理", icon: "🖥" } satisfies NavMeta,
|
||||
meta: { title: "设备管理" } satisfies NavMeta,
|
||||
},
|
||||
{
|
||||
path: "/overview",
|
||||
name: "overview",
|
||||
component: () => import("../views/OverviewView.vue"),
|
||||
meta: { title: "状态总览", icon: "📊" } satisfies NavMeta,
|
||||
meta: { title: "状态总览" } satisfies NavMeta,
|
||||
},
|
||||
{
|
||||
path: "/terminal",
|
||||
name: "terminal",
|
||||
component: () => import("../views/TerminalView.vue"),
|
||||
meta: { title: "命令执行", icon: "⌨" } satisfies NavMeta,
|
||||
meta: { title: "命令执行" } satisfies NavMeta,
|
||||
},
|
||||
{
|
||||
path: "/processes",
|
||||
name: "processes",
|
||||
component: () => import("../views/ProcessesView.vue"),
|
||||
meta: { title: "进程与服务", icon: "⚙" } satisfies NavMeta,
|
||||
meta: { title: "进程与服务" } satisfies NavMeta,
|
||||
},
|
||||
{
|
||||
path: "/software",
|
||||
name: "software",
|
||||
component: () => import("../views/SoftwareView.vue"),
|
||||
meta: { title: "软件管理", icon: "📦" } satisfies NavMeta,
|
||||
meta: { title: "软件管理" } satisfies NavMeta,
|
||||
},
|
||||
{
|
||||
path: "/files",
|
||||
name: "files",
|
||||
component: () => import("../views/FilesView.vue"),
|
||||
meta: { title: "文件管理", icon: "📁" } satisfies NavMeta,
|
||||
meta: { title: "文件管理" } satisfies NavMeta,
|
||||
},
|
||||
{
|
||||
path: "/audit",
|
||||
name: "audit",
|
||||
component: () => import("../views/AuditView.vue"),
|
||||
meta: { title: "审计日志", icon: "📜" } satisfies NavMeta,
|
||||
meta: { title: "审计日志" } satisfies NavMeta,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* 本机服务 store —— 托管**本机**被控端(启动 / 停止 / 重启 / 自启 / 更新 / 设置)。
|
||||
*
|
||||
* 与 `conn` store 的分工:`conn` 管「连到某台被控端」(远程控制),
|
||||
* 本 store 管「本机这台被控端本身的进程与配置」。二者都连本机时是同一个实例,
|
||||
* 但关注点不同 —— 本 store 不依赖连接是否成功(进程没起也能启动它)。
|
||||
*/
|
||||
import { defineStore } from "pinia";
|
||||
import { computed, ref } from "vue";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import * as api from "../api";
|
||||
import { useConnStore } from "./conn";
|
||||
import type {
|
||||
AgentLogLine,
|
||||
AgentState,
|
||||
AgentStatus,
|
||||
ApplyUpdateResult,
|
||||
ConsoleSettings,
|
||||
ConsoleSystemInfo,
|
||||
UpdateInfo,
|
||||
} from "../types";
|
||||
|
||||
/** 状态 → 展示文案与配色。 */
|
||||
export const STATE_META: Record<AgentState, { label: string; type: "success" | "warning" | "error" | "default" }> = {
|
||||
running: { label: "运行中", type: "success" },
|
||||
starting: { label: "启动中", type: "warning" },
|
||||
stopping: { label: "停止中", type: "warning" },
|
||||
stopped: { label: "已停止", type: "default" },
|
||||
crashed: { label: "已异常退出", type: "error" },
|
||||
};
|
||||
|
||||
export const useAgentStore = defineStore("agent", () => {
|
||||
const status = ref<AgentStatus | null>(null);
|
||||
const sysInfo = ref<ConsoleSystemInfo | null>(null);
|
||||
const update = ref<UpdateInfo | null>(null);
|
||||
const busy = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const bound = ref(false);
|
||||
|
||||
const process = computed(() => status.value?.manager ?? null);
|
||||
const state = computed<AgentState>(() => process.value?.state ?? "stopped");
|
||||
const meta = computed(() => STATE_META[state.value] ?? STATE_META.stopped);
|
||||
const settings = computed<ConsoleSettings>(
|
||||
() => status.value?.settings ?? { auto_start_agent: true, auto_restart: true, close_to_tray: true, insecure: true },
|
||||
);
|
||||
const running = computed(() => state.value === "running");
|
||||
/** 端口被占但本管理器没在跑 → 已有「非托管」的实例 */
|
||||
const externalInstance = computed(
|
||||
() => !running.value && (status.value?.config.port_in_use ?? false),
|
||||
);
|
||||
|
||||
/** 包一层:统一置 busy / 收集错误,避免每个动作都写一遍 try-finally。 */
|
||||
async function run<T>(fn: () => Promise<T>): Promise<T | null> {
|
||||
busy.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const r = await fn();
|
||||
await refresh();
|
||||
return r;
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : String(e);
|
||||
return null;
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 刷新总览(状态 + 配置 + 自启 + 设置)。 */
|
||||
async function refresh() {
|
||||
try {
|
||||
status.value = await api.agentStatus();
|
||||
error.value = null;
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
return status.value;
|
||||
}
|
||||
|
||||
async function loadSystemInfo() {
|
||||
sysInfo.value = await api.systemInfo();
|
||||
return sysInfo.value;
|
||||
}
|
||||
|
||||
/** 绑定后端事件(应用启动时调用一次):进程状态变化由推送驱动,不轮询。 */
|
||||
async function bind() {
|
||||
if (bound.value) return;
|
||||
bound.value = true;
|
||||
await listen<string>("na-agent-state", () => {
|
||||
void refresh();
|
||||
});
|
||||
// v2.0.3 单实例:用户再次双击图标时,由**已存在实例**接管(把窗口唤出来)。
|
||||
// 顺手刷新一次 —— 触发它的场景往往正是「点了没反应,想看看怎么回事」。
|
||||
await listen<{ argv?: string[] }>("na-second-instance", () => {
|
||||
void refresh();
|
||||
});
|
||||
await refresh();
|
||||
void loadSystemInfo();
|
||||
}
|
||||
|
||||
const start = () => run(() => api.agentStart());
|
||||
const stop = () => run(() => api.agentStop());
|
||||
const restart = () => run(() => api.agentRestart());
|
||||
const clearLogs = () => api.agentLogsClear();
|
||||
|
||||
async function setAutostart(enabled: boolean) {
|
||||
return run(() => api.autostartSet(enabled));
|
||||
}
|
||||
|
||||
async function patchSettings(patch: Partial<ConsoleSettings>) {
|
||||
return run(() => api.settingsSet(patch));
|
||||
}
|
||||
|
||||
/** 检查更新。 */
|
||||
async function checkUpdate() {
|
||||
busy.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
update.value = await api.checkUpdate();
|
||||
return update.value;
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : String(e);
|
||||
return null;
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 应用更新(仅更新本机被控端;控制台自身需重新下载安装包)。 */
|
||||
async function applyUpdate(): Promise<ApplyUpdateResult | null> {
|
||||
const u = update.value;
|
||||
const url = u?.agent?.url;
|
||||
const sha = u?.agent?.sha256;
|
||||
if (!u || !url || !sha) {
|
||||
error.value = "最新版本信息不完整(缺少下载地址或哈希),无法更新";
|
||||
return null;
|
||||
}
|
||||
const r = await run(() => api.applyUpdate(url, sha, u.latest_version));
|
||||
if (r) await checkUpdate();
|
||||
return r;
|
||||
}
|
||||
|
||||
/** 一键连接本机被控端(用配置里的地址与密钥)。 */
|
||||
async function connectLocal() {
|
||||
const cfg = status.value?.config;
|
||||
if (!cfg) return null;
|
||||
const conn = useConnStore();
|
||||
return conn.connect(cfg.url, cfg.key);
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
sysInfo,
|
||||
update,
|
||||
busy,
|
||||
error,
|
||||
process,
|
||||
state,
|
||||
meta,
|
||||
settings,
|
||||
running,
|
||||
externalInstance,
|
||||
bind,
|
||||
refresh,
|
||||
loadSystemInfo,
|
||||
start,
|
||||
stop,
|
||||
restart,
|
||||
clearLogs,
|
||||
setAutostart,
|
||||
patchSettings,
|
||||
checkUpdate,
|
||||
applyUpdate,
|
||||
connectLocal,
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* 日志 store —— 本机服务日志的实时流 + 增量拉取。
|
||||
*
|
||||
* 设计要点:**事件推 + 序号去重**。若只靠事件,页面重新挂载后会丢掉历史;
|
||||
* 若只靠轮询,则看不到实时的那一行。故两条路并用,用 `seq` 去重。
|
||||
*/
|
||||
export const useLogsStore = defineStore("logs", () => {
|
||||
const lines = ref<AgentLogLine[]>([]);
|
||||
const maxSeq = ref(0);
|
||||
const paused = ref(false);
|
||||
const bound = ref(false);
|
||||
/** 内存里保留的最大行数(与 Rust 侧环形缓冲上限对齐,避免界面无限膨胀) */
|
||||
const CAP = 4000;
|
||||
|
||||
function append(rows: AgentLogLine[]) {
|
||||
const fresh = rows.filter((r) => r.seq > maxSeq.value);
|
||||
if (fresh.length === 0) return;
|
||||
lines.value.push(...fresh);
|
||||
maxSeq.value = Math.max(maxSeq.value, ...fresh.map((r) => r.seq));
|
||||
if (lines.value.length > CAP) lines.value.splice(0, lines.value.length - CAP);
|
||||
}
|
||||
|
||||
/** 拉取历史(首次进页面 / 清空后重建)。 */
|
||||
async function load() {
|
||||
const page = await api.agentLogs(0);
|
||||
lines.value = [];
|
||||
maxSeq.value = 0;
|
||||
append(page.lines);
|
||||
return page;
|
||||
}
|
||||
|
||||
/** 绑定实时日志事件。 */
|
||||
async function bind() {
|
||||
if (bound.value) return;
|
||||
bound.value = true;
|
||||
await listen<AgentLogLine>("na-agent-log", (e) => {
|
||||
if (paused.value) return;
|
||||
append([e.payload]);
|
||||
});
|
||||
await load();
|
||||
}
|
||||
|
||||
async function clear() {
|
||||
await api.agentLogsClear();
|
||||
lines.value = [];
|
||||
maxSeq.value = 0;
|
||||
}
|
||||
|
||||
/** 导出为文本(在浏览器侧生成,不额外调后端)。 */
|
||||
function toText(filtered?: AgentLogLine[]): string {
|
||||
const src = filtered ?? lines.value;
|
||||
return src
|
||||
.map((l) => `${new Date(l.ts).toISOString()} [${l.stream}] ${l.line}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function downloadText(text: string, filename: string) {
|
||||
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
}
|
||||
|
||||
return { lines, maxSeq, paused, bind, load, clear, append, toText, downloadText };
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import {
|
||||
THEMES,
|
||||
THEME_STORAGE_KEY,
|
||||
normalizeThemeName,
|
||||
type ThemeName,
|
||||
} from "../theme";
|
||||
|
||||
/**
|
||||
* 主题选择。
|
||||
*
|
||||
* 只存 **localStorage**,不落 Rust 侧 settings:那是「仅服务模式」(无窗口、
|
||||
* 常驻托盘)也要生效的偏好,而主题是纯图形偏好 —— 没有窗口时毫无意义。
|
||||
* 放 localStorage 可避免为一件纯前端的事去动 Rust 命令面与契约守卫。
|
||||
*/
|
||||
export const useThemeStore = defineStore("theme", () => {
|
||||
const name = ref<ThemeName>(read());
|
||||
|
||||
const preset = computed(() => THEMES[name.value]);
|
||||
const isWhite = computed(() => name.value === "white");
|
||||
|
||||
function read(): ThemeName {
|
||||
try {
|
||||
return normalizeThemeName(localStorage.getItem(THEME_STORAGE_KEY));
|
||||
} catch {
|
||||
// 隐私模式等场景下 localStorage 可能不可用 —— 不该因此启动失败
|
||||
return "white";
|
||||
}
|
||||
}
|
||||
|
||||
function set(next: ThemeName) {
|
||||
name.value = normalizeThemeName(next);
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
set(name.value === "white" ? "gray" : "white");
|
||||
}
|
||||
|
||||
watch(
|
||||
name,
|
||||
(v) => {
|
||||
try {
|
||||
localStorage.setItem(THEME_STORAGE_KEY, v);
|
||||
} catch {
|
||||
/* 忽略:存不下只影响下次启动的默认值 */
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
return { name, preset, isWhite, set, toggle };
|
||||
});
|
||||
@@ -0,0 +1,478 @@
|
||||
import {
|
||||
darkTheme,
|
||||
lightTheme,
|
||||
type GlobalTheme,
|
||||
type GlobalThemeOverrides,
|
||||
} from "naive-ui";
|
||||
|
||||
/**
|
||||
* 控制台只有两套主题,且都**只用中性灰阶** —— 不含彩色品牌色。
|
||||
*
|
||||
* 「简约白」= 白底 + 浅灰分线,靠留白与层次而不是颜色区分区域。
|
||||
* 「高级灰」= 低饱和冷灰底 + 浅灰字,同样只用灰阶。
|
||||
*
|
||||
* 之所以真的把两套都做出来、而不是只留一套浅色:深色在长时间盯日志/终端时
|
||||
* 更省眼(运维场景常态),浅色在白天与投屏时更清楚。两套共用同一份灰阶逻辑
|
||||
* (只是明暗反转),故维护成本不翻倍。
|
||||
*/
|
||||
export type ThemeName = "white" | "gray";
|
||||
|
||||
export const THEME_STORAGE_KEY = "nodeagent.console.theme";
|
||||
|
||||
/**
|
||||
* 调色板。**这是全应用唯一一份色值定义**:
|
||||
* 既喂给 naive-ui 的 themeOverrides,也由 App.vue 注入成 `--na-*` CSS 变量
|
||||
* 供自定义样式使用。两处各写一份必然漂移(本项目吃过这类亏),故只此一处。
|
||||
*/
|
||||
export interface Palette {
|
||||
/** 页面底色 */
|
||||
bg: string;
|
||||
/** 侧栏底色(与 bg 拉开一档层次,而不是靠边框) */
|
||||
sider: string;
|
||||
/** 卡片 / 浮层底色 */
|
||||
card: string;
|
||||
/** 内嵌区:日志框、代码块、表头 */
|
||||
sunken: string;
|
||||
border: string;
|
||||
borderStrong: string;
|
||||
text1: string;
|
||||
text2: string;
|
||||
text3: string;
|
||||
hover: string;
|
||||
/** 主色位(naive-ui 的 primary)—— 刻意用中性色,不用彩色 */
|
||||
accent: string;
|
||||
accentHover: string;
|
||||
accentPressed: string;
|
||||
/** 主色底之上的文字色 */
|
||||
accentText: string;
|
||||
/** 品牌字(侧栏站点标题),不再用青绿 */
|
||||
brand: string;
|
||||
/** 语义色:统一降饱和,保证「白 + 灰」的底色观感 */
|
||||
ok: string;
|
||||
warn: string;
|
||||
err: string;
|
||||
info: string;
|
||||
/** 语义色的浅底(用于 Tag / 告警块) */
|
||||
okSoft: string;
|
||||
warnSoft: string;
|
||||
errSoft: string;
|
||||
infoSoft: string;
|
||||
tooltipBg: string;
|
||||
tooltipText: string;
|
||||
scrollbar: string;
|
||||
scrollbarHover: string;
|
||||
shadow: string;
|
||||
/** 原生控件(滚动条、表单控件)的明暗 */
|
||||
scheme: "light" | "dark";
|
||||
}
|
||||
|
||||
/** 简约白:白为主,灰为辅。层次靠灰阶(页底 < 侧栏 < 卡片),不靠颜色。 */
|
||||
const WHITE: Palette = {
|
||||
// 页底刻意不是 #ffffff:卡片也是白的,两者同色就只能靠 1px 分线撑层次,
|
||||
// 整页会发"糊"。压一档浅灰后卡片自然浮起来,且仍是「白」为主。
|
||||
bg: "#f4f4f5",
|
||||
sider: "#fafafa",
|
||||
card: "#ffffff",
|
||||
sunken: "#f5f5f6",
|
||||
border: "#e8e8ea",
|
||||
borderStrong: "#d6d6da",
|
||||
text1: "#1a1a1c",
|
||||
text2: "#55555c",
|
||||
text3: "#8e8e97",
|
||||
hover: "#eeeeef",
|
||||
accent: "#1f1f23",
|
||||
accentHover: "#33333a",
|
||||
accentPressed: "#0d0d10",
|
||||
accentText: "#ffffff",
|
||||
brand: "#1a1a1c",
|
||||
ok: "#4f8768",
|
||||
warn: "#9c7c39",
|
||||
err: "#b4635c",
|
||||
info: "#5b789a",
|
||||
okSoft: "#eef4f0",
|
||||
warnSoft: "#f7f3e9",
|
||||
errSoft: "#f8efee",
|
||||
infoSoft: "#eef2f7",
|
||||
tooltipBg: "#2b2b30",
|
||||
tooltipText: "#f2f2f3",
|
||||
scrollbar: "#d6d6da",
|
||||
scrollbarHover: "#bcbcc2",
|
||||
shadow: "0 1px 2px rgba(16, 16, 20, 0.05)",
|
||||
scheme: "light",
|
||||
};
|
||||
|
||||
/** 高级灰:雾霾冷灰调。低饱和、低对比,但保证正文可读。 */
|
||||
const GRAY: Palette = {
|
||||
bg: "#1d1f22",
|
||||
sider: "#181a1d",
|
||||
card: "#24262a",
|
||||
sunken: "#1a1c1f",
|
||||
border: "#32353a",
|
||||
borderStrong: "#41454c",
|
||||
text1: "#dcdee2",
|
||||
text2: "#a2a6ae",
|
||||
text3: "#71757c",
|
||||
hover: "#2b2e33",
|
||||
accent: "#dcdee2",
|
||||
accentHover: "#eef0f2",
|
||||
accentPressed: "#c6c9ce",
|
||||
accentText: "#1d1f22",
|
||||
brand: "#e6e8eb",
|
||||
ok: "#83a891",
|
||||
warn: "#b99a5c",
|
||||
err: "#cc8b82",
|
||||
info: "#8098b5",
|
||||
okSoft: "#262f2a",
|
||||
warnSoft: "#2f2c22",
|
||||
errSoft: "#312625",
|
||||
infoSoft: "#252b33",
|
||||
tooltipBg: "#3a3e44",
|
||||
tooltipText: "#e8eaed",
|
||||
scrollbar: "#3a3e44",
|
||||
scrollbarHover: "#4a4f56",
|
||||
shadow: "0 1px 2px rgba(0, 0, 0, 0.35)",
|
||||
scheme: "dark",
|
||||
};
|
||||
|
||||
/**
|
||||
* 调色板 → `--na-*` CSS 变量。
|
||||
*
|
||||
* 名字写死而不是自动 kebab 化:自动转换一旦改字段名,CSS 侧会**静默**失配
|
||||
* (变量没人定义 → 颜色落回继承值,不报错、类型也抓不到)。写死则改字段时
|
||||
* 至少能在这里一眼看到。
|
||||
*/
|
||||
export function cssVars(p: Palette): Record<string, string> {
|
||||
return {
|
||||
"--na-bg": p.bg,
|
||||
"--na-sider": p.sider,
|
||||
"--na-card": p.card,
|
||||
"--na-sunken": p.sunken,
|
||||
"--na-border": p.border,
|
||||
"--na-border-strong": p.borderStrong,
|
||||
"--na-text-1": p.text1,
|
||||
"--na-text-2": p.text2,
|
||||
"--na-text-3": p.text3,
|
||||
"--na-hover": p.hover,
|
||||
"--na-accent": p.accent,
|
||||
"--na-accent-text": p.accentText,
|
||||
"--na-brand": p.brand,
|
||||
"--na-ok": p.ok,
|
||||
"--na-warn": p.warn,
|
||||
"--na-err": p.err,
|
||||
"--na-info": p.info,
|
||||
"--na-tooltip-bg": p.tooltipBg,
|
||||
"--na-tooltip-text": p.tooltipText,
|
||||
"--na-scrollbar": p.scrollbar,
|
||||
"--na-scrollbar-hover": p.scrollbarHover,
|
||||
"--na-shadow": p.shadow,
|
||||
};
|
||||
}
|
||||
|
||||
function overridesOf(p: Palette): GlobalThemeOverrides {
|
||||
return {
|
||||
common: {
|
||||
// —— 主色:中性灰。naive-ui 默认主色是绿色,留着它整套配色立刻"不简单"。
|
||||
// 主按钮、开关激活态、进度条、勾选框都吃这一个值。
|
||||
primaryColor: p.accent,
|
||||
primaryColorHover: p.accentHover,
|
||||
primaryColorPressed: p.accentPressed,
|
||||
primaryColorSuppl: p.accentHover,
|
||||
|
||||
// —— 语义色降饱和(仍保留:运维工具里"运行中/出错"必须一眼可辨,
|
||||
// 全部灰化会导致误读;故只在徽章与告警这类小面积上出现)
|
||||
successColor: p.ok,
|
||||
successColorHover: p.ok,
|
||||
successColorPressed: p.ok,
|
||||
successColorSuppl: p.ok,
|
||||
warningColor: p.warn,
|
||||
warningColorHover: p.warn,
|
||||
warningColorPressed: p.warn,
|
||||
warningColorSuppl: p.warn,
|
||||
errorColor: p.err,
|
||||
errorColorHover: p.err,
|
||||
errorColorPressed: p.err,
|
||||
errorColorSuppl: p.err,
|
||||
infoColor: p.info,
|
||||
infoColorHover: p.info,
|
||||
infoColorPressed: p.info,
|
||||
infoColorSuppl: p.info,
|
||||
|
||||
textColorBase: p.text1,
|
||||
textColor1: p.text1,
|
||||
textColor2: p.text2,
|
||||
textColor3: p.text3,
|
||||
placeholderColor: p.text3,
|
||||
placeholderColorDisabled: p.text3,
|
||||
iconColor: p.text3,
|
||||
iconColorHover: p.text2,
|
||||
|
||||
dividerColor: p.border,
|
||||
borderColor: p.border,
|
||||
closeIconColor: p.text3,
|
||||
|
||||
bodyColor: p.bg,
|
||||
cardColor: p.card,
|
||||
modalColor: p.card,
|
||||
popoverColor: p.card,
|
||||
tableColor: p.card,
|
||||
inputColor: p.sunken,
|
||||
inputColorDisabled: p.sunken,
|
||||
tagColor: p.sunken,
|
||||
actionColor: p.sunken,
|
||||
avatarColor: p.sunken,
|
||||
codeColor: p.sunken,
|
||||
tabColor: p.sunken,
|
||||
tableHeaderColor: p.sunken,
|
||||
hoverColor: p.hover,
|
||||
tableColorHover: p.hover,
|
||||
tableColorStriped: p.sunken,
|
||||
pressedColor: p.hover,
|
||||
progressRailColor: p.sunken,
|
||||
railColor: p.sunken,
|
||||
scrollbarColor: p.scrollbar,
|
||||
scrollbarColorHover: p.scrollbarHover,
|
||||
|
||||
borderRadius: "6px",
|
||||
borderRadiusSmall: "4px",
|
||||
},
|
||||
|
||||
Layout: {
|
||||
color: p.bg,
|
||||
siderColor: p.sider,
|
||||
headerColor: p.bg,
|
||||
siderBorderColor: p.border,
|
||||
headerBorderColor: p.border,
|
||||
textColor: p.text1,
|
||||
},
|
||||
|
||||
Menu: {
|
||||
color: p.sider,
|
||||
itemTextColor: p.text2,
|
||||
itemTextColorHover: p.text1,
|
||||
itemTextColorActive: p.text1,
|
||||
itemTextColorActiveHover: p.text1,
|
||||
itemTextColorChildActive: p.text1,
|
||||
itemColorHover: p.hover,
|
||||
itemColorActive: p.hover,
|
||||
itemColorActiveHover: p.hover,
|
||||
itemColorActiveCollapsed: p.hover,
|
||||
itemIconColor: p.text3,
|
||||
itemIconColorHover: p.text2,
|
||||
itemIconColorActive: p.text1,
|
||||
groupTextColor: p.text3,
|
||||
arrowColor: p.text3,
|
||||
borderColorHorizontal: p.border,
|
||||
borderRadius: "6px",
|
||||
},
|
||||
|
||||
Card: {
|
||||
color: p.card,
|
||||
colorModal: p.card,
|
||||
colorPopover: p.card,
|
||||
colorEmbedded: p.sunken,
|
||||
textColor: p.text1,
|
||||
titleTextColor: p.text1,
|
||||
borderColor: p.border,
|
||||
actionColor: p.sunken,
|
||||
titleFontWeight: "600",
|
||||
boxShadow: p.shadow,
|
||||
},
|
||||
|
||||
DataTable: {
|
||||
thColor: p.sunken,
|
||||
thColorHover: p.hover,
|
||||
tdColor: p.card,
|
||||
tdColorHover: p.hover,
|
||||
tdColorStriped: p.sunken,
|
||||
borderColor: p.border,
|
||||
thTextColor: p.text2,
|
||||
tdTextColor: p.text2,
|
||||
thFontWeight: "600",
|
||||
loadingColor: p.accent,
|
||||
},
|
||||
|
||||
Input: {
|
||||
color: p.card,
|
||||
colorFocus: p.card,
|
||||
colorDisabled: p.sunken,
|
||||
textColor: p.text1,
|
||||
textColorDisabled: p.text3,
|
||||
placeholderColor: p.text3,
|
||||
border: `1px solid ${p.border}`,
|
||||
borderHover: `1px solid ${p.borderStrong}`,
|
||||
borderFocus: `1px solid ${p.accent}`,
|
||||
borderDisabled: `1px solid ${p.border}`,
|
||||
boxShadowFocus: `0 0 0 2px ${p.hover}`,
|
||||
caretColor: p.accent,
|
||||
iconColor: p.text3,
|
||||
clearColor: p.text3,
|
||||
clearColorHover: p.text2,
|
||||
},
|
||||
|
||||
Alert: {
|
||||
color: p.sunken,
|
||||
border: `1px solid ${p.border}`,
|
||||
titleTextColor: p.text1,
|
||||
contentTextColor: p.text2,
|
||||
iconColor: p.text3,
|
||||
borderInfo: `1px solid ${p.info}`,
|
||||
colorInfo: p.infoSoft,
|
||||
titleTextColorInfo: p.text1,
|
||||
contentTextColorInfo: p.text2,
|
||||
iconColorInfo: p.info,
|
||||
borderSuccess: `1px solid ${p.ok}`,
|
||||
colorSuccess: p.okSoft,
|
||||
titleTextColorSuccess: p.text1,
|
||||
contentTextColorSuccess: p.text2,
|
||||
iconColorSuccess: p.ok,
|
||||
borderWarning: `1px solid ${p.warn}`,
|
||||
colorWarning: p.warnSoft,
|
||||
titleTextColorWarning: p.text1,
|
||||
contentTextColorWarning: p.text2,
|
||||
iconColorWarning: p.warn,
|
||||
borderError: `1px solid ${p.err}`,
|
||||
colorError: p.errSoft,
|
||||
titleTextColorError: p.text1,
|
||||
contentTextColorError: p.text2,
|
||||
iconColorError: p.err,
|
||||
},
|
||||
|
||||
Tag: {
|
||||
color: p.sunken,
|
||||
textColor: p.text2,
|
||||
border: "1px solid transparent",
|
||||
colorBordered: p.sunken,
|
||||
borderRadius: "5px",
|
||||
colorPrimary: p.accent,
|
||||
textColorPrimary: p.accentText,
|
||||
borderPrimary: "1px solid transparent",
|
||||
colorInfo: p.infoSoft,
|
||||
textColorInfo: p.info,
|
||||
borderInfo: `1px solid ${p.info}`,
|
||||
colorSuccess: p.okSoft,
|
||||
textColorSuccess: p.ok,
|
||||
borderSuccess: `1px solid ${p.ok}`,
|
||||
colorWarning: p.warnSoft,
|
||||
textColorWarning: p.warn,
|
||||
borderWarning: `1px solid ${p.warn}`,
|
||||
colorError: p.errSoft,
|
||||
textColorError: p.err,
|
||||
borderError: `1px solid ${p.err}`,
|
||||
},
|
||||
|
||||
Divider: {
|
||||
color: p.border,
|
||||
textColor: p.text3,
|
||||
},
|
||||
|
||||
Progress: {
|
||||
railColor: p.sunken,
|
||||
fillColor: p.accent,
|
||||
fillColorSuccess: p.ok,
|
||||
fillColorWarning: p.warn,
|
||||
fillColorError: p.err,
|
||||
fillColorInfo: p.info,
|
||||
textColorLineInner: p.accentText,
|
||||
textColorLineOuter: p.text2,
|
||||
},
|
||||
|
||||
Switch: {
|
||||
railColor: p.borderStrong,
|
||||
railColorActive: p.accent,
|
||||
buttonColor: p.card,
|
||||
iconColor: p.text3,
|
||||
loadingColor: p.text3,
|
||||
},
|
||||
|
||||
Statistic: {
|
||||
labelTextColor: p.text3,
|
||||
valueTextColor: p.text1,
|
||||
valuePrefixTextColor: p.text2,
|
||||
valueSuffixTextColor: p.text2,
|
||||
},
|
||||
|
||||
Empty: {
|
||||
textColor: p.text3,
|
||||
iconColor: p.text3,
|
||||
extraTextColor: p.text3,
|
||||
},
|
||||
|
||||
Spin: {
|
||||
color: p.accent,
|
||||
textColor: p.text2,
|
||||
},
|
||||
|
||||
Popover: {
|
||||
color: p.card,
|
||||
textColor: p.text1,
|
||||
dividerColor: p.border,
|
||||
boxShadow: "0 4px 16px rgba(0, 0, 0, 0.12)",
|
||||
},
|
||||
|
||||
Tooltip: {
|
||||
color: p.tooltipBg,
|
||||
textColor: p.tooltipText,
|
||||
},
|
||||
|
||||
Modal: {
|
||||
color: p.card,
|
||||
textColor: p.text1,
|
||||
},
|
||||
|
||||
List: {
|
||||
color: p.card,
|
||||
textColor: p.text1,
|
||||
borderColor: p.border,
|
||||
},
|
||||
|
||||
Collapse: {
|
||||
titleTextColor: p.text1,
|
||||
textColor: p.text2,
|
||||
dividerColor: p.border,
|
||||
},
|
||||
|
||||
Checkbox: {
|
||||
labelTextColor: p.text1,
|
||||
color: p.card,
|
||||
border: `1px solid ${p.borderStrong}`,
|
||||
borderChecked: `1px solid ${p.accent}`,
|
||||
colorChecked: p.accent,
|
||||
checkMarkColor: p.accentText,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface ThemePreset {
|
||||
name: ThemeName;
|
||||
label: string;
|
||||
desc: string;
|
||||
base: GlobalTheme;
|
||||
palette: Palette;
|
||||
overrides: GlobalThemeOverrides;
|
||||
}
|
||||
|
||||
export const THEMES: Record<ThemeName, ThemePreset> = {
|
||||
white: {
|
||||
name: "white",
|
||||
label: "简约白",
|
||||
desc: "白底浅灰分线",
|
||||
base: lightTheme,
|
||||
palette: WHITE,
|
||||
overrides: overridesOf(WHITE),
|
||||
},
|
||||
gray: {
|
||||
name: "gray",
|
||||
label: "高级灰",
|
||||
desc: "雾霾冷灰低对比",
|
||||
base: darkTheme,
|
||||
palette: GRAY,
|
||||
overrides: overridesOf(GRAY),
|
||||
},
|
||||
};
|
||||
|
||||
export const THEME_LIST: ThemePreset[] = [THEMES.white, THEMES.gray];
|
||||
|
||||
/** 越界 / 脏数据一律落回默认主题,避免启动即白屏。 */
|
||||
export function normalizeThemeName(v: unknown): ThemeName {
|
||||
return v === "gray" ? "gray" : "white";
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
/** 协议类型镜像(对齐 Rust 侧 `crates/nodeagent-client` 与 Tauri command 的序列化形状)。 */
|
||||
/** 协议类型镜像(对齐 `@nodeagent/protocol`,亦即 sidecar 与 Tauri command 的序列化形状)。 */
|
||||
|
||||
/** 能力描述(被控端声明的能力清单条目)。 */
|
||||
export interface CapabilityDescriptor {
|
||||
@@ -204,7 +204,10 @@ export type AuditType =
|
||||
| "net.change"
|
||||
| "net.deny"
|
||||
| "agent.update"
|
||||
| "session.idle_close";
|
||||
| "session.idle_close"
|
||||
// v2.0.3:被拒请求统一留痕(U-01)+ 进程级兜底错误
|
||||
| "request.rejected"
|
||||
| "agent.error";
|
||||
|
||||
export interface AuditEntry {
|
||||
ts?: number;
|
||||
@@ -247,3 +250,131 @@ export interface AuditVerifyResult {
|
||||
/** 是**对象**而非数字 */
|
||||
broken_at?: AuditBrokenAt | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 本机服务(本机被控端托管)—— 契约来源:src-tauri/src/agent.rs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 被控端进程状态机取值(与 Rust `agent::state` 一一对应)。 */
|
||||
export type AgentState = "stopped" | "starting" | "running" | "stopping" | "crashed";
|
||||
|
||||
/** 本机被控端进程快照。 */
|
||||
export interface AgentProcess {
|
||||
state: AgentState;
|
||||
pid?: number | null;
|
||||
started_at?: number | null;
|
||||
uptime_ms?: number | null;
|
||||
/** 自动重启已尝试次数 */
|
||||
restarts?: number;
|
||||
last_exit?: string | null;
|
||||
/** 实际使用的 node 可执行文件 */
|
||||
node?: string;
|
||||
/** 实际运行的那份 agent.mjs(在数据目录里,不在 app 包内) */
|
||||
script?: string;
|
||||
data_dir?: string;
|
||||
}
|
||||
|
||||
/** 被控端配置(读自 `<数据目录>/agent.json`)。 */
|
||||
export interface AgentConfigInfo {
|
||||
data_dir: string;
|
||||
config_path: string;
|
||||
config_exists: boolean;
|
||||
node_id: string;
|
||||
port: number;
|
||||
tls: boolean;
|
||||
auth_mode: "psk" | "ed25519" | string;
|
||||
/** PSK 明文(本机展示用;它本就在用户自己的 600 权限文件里) */
|
||||
key: string;
|
||||
/** 端口是否已被监听(可能是一个非本控制台托管的实例) */
|
||||
port_in_use: boolean;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface AutostartStatus {
|
||||
enabled: boolean;
|
||||
supported: boolean;
|
||||
/** 落地位置(plist 路径 / 计划任务名) */
|
||||
location: string;
|
||||
app_exe: string;
|
||||
/** 自启时传给应用的模式参数 */
|
||||
mode: string;
|
||||
note: string;
|
||||
/** 本次操作详情(开关后返回) */
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface ConsoleSettings {
|
||||
auto_start_agent: boolean;
|
||||
auto_restart: boolean;
|
||||
close_to_tray: boolean;
|
||||
insecure: boolean;
|
||||
}
|
||||
|
||||
export interface AgentStatus {
|
||||
manager: AgentProcess;
|
||||
config: AgentConfigInfo;
|
||||
autostart: AutostartStatus;
|
||||
settings: ConsoleSettings;
|
||||
app_version: string;
|
||||
config_dir: string;
|
||||
/** 数据目录里那份脚本的版本(被控端自更新或控制台更新后写入) */
|
||||
installed_agent_version?: string | null;
|
||||
/** 随包内置的 agent.mjs 路径 */
|
||||
bundled_agent?: string;
|
||||
}
|
||||
|
||||
export interface AgentActionResult {
|
||||
state?: AgentState;
|
||||
pid?: number | null;
|
||||
note?: string;
|
||||
port_released?: boolean;
|
||||
}
|
||||
|
||||
export interface AgentLogLine {
|
||||
seq: number;
|
||||
ts: number;
|
||||
/** stdout / stderr / system */
|
||||
stream: string;
|
||||
line: string;
|
||||
}
|
||||
|
||||
export interface AgentLogPage {
|
||||
lines: AgentLogLine[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface UpdateInfo {
|
||||
console_version: string;
|
||||
latest_version: string;
|
||||
has_update: boolean;
|
||||
agent_has_update?: boolean;
|
||||
installed_agent_version?: string;
|
||||
published_at?: string | null;
|
||||
commit?: string | null;
|
||||
capabilities?: number | null;
|
||||
agent: { url?: string | null; sha256?: string | null; bytes?: number | null };
|
||||
release_url: string;
|
||||
source: string;
|
||||
checked_at: number;
|
||||
}
|
||||
|
||||
export interface ApplyUpdateResult {
|
||||
updated: boolean;
|
||||
version: string;
|
||||
bytes: number;
|
||||
sha256: string;
|
||||
backup: string;
|
||||
restarted: boolean;
|
||||
}
|
||||
|
||||
export interface ConsoleSystemInfo {
|
||||
app_version: string;
|
||||
config_dir: string;
|
||||
agent_data_dir: string;
|
||||
resource_dir: string;
|
||||
platform: string;
|
||||
arch: string;
|
||||
node: string;
|
||||
/** 是否以「仅服务模式」启动(开机自启时为 true) */
|
||||
agent_only: boolean;
|
||||
}
|
||||
@@ -28,19 +28,29 @@ const typeFilter = ref<string | null>(null);
|
||||
const loading = ref(false);
|
||||
const verify = ref<AuditVerifyResult | null>(null);
|
||||
|
||||
/**
|
||||
* 审计筛选项(取值必须是 `@nodeagent/protocol` 里 `AUDIT_FILTER_TYPES` 的一员)。
|
||||
*
|
||||
* ⚠️ C-01(v2.0.3 修):这里原本列了 11 个**细粒度**取值(`auth.success`、`acl.denied`…),
|
||||
* 而协议 manifest 的 enum 只有 `['invoke','auth','acl','agent']` → 被控端 schema 校验把
|
||||
* 除 `invoke` 外的**全部**取值判成 `E_PARAM_INVALID`:下拉框形同虚设,且**不报类型错**。
|
||||
*
|
||||
* 改用**粗粒度前缀**:被控端按 `entry.type.startsWith(query)` 过滤,所以
|
||||
* `agent` 一次覆盖 `agent.start/stop/update/error`,`request` 覆盖新增的 `request.rejected`。
|
||||
* 覆盖面更全(每个审计类型都能被至少一项筛到),下拉框也不必膨胀成二十几项。
|
||||
* 防漂移由 `tests/unit/audit-filter.test.mjs` 钉死(正反双向都比对真源)。
|
||||
*/
|
||||
const typeOptions = [
|
||||
{ label: "全部", value: "" },
|
||||
{ label: "invoke", value: "invoke" },
|
||||
{ label: "auth.success", value: "auth.success" },
|
||||
{ label: "auth.failure", value: "auth.failure" },
|
||||
{ label: "acl.denied", value: "acl.denied" },
|
||||
{ label: "capability.disabled", value: "capability.disabled" },
|
||||
{ label: "rate.limited", value: "rate.limited" },
|
||||
{ label: "net.deny", value: "net.deny" },
|
||||
{ label: "net.change", value: "net.change" },
|
||||
{ label: "agent.update", value: "agent.update" },
|
||||
{ label: "agent.start", value: "agent.start" },
|
||||
{ label: "agent.stop", value: "agent.stop" },
|
||||
{ label: "调用 invoke", value: "invoke" },
|
||||
{ label: "认证 auth", value: "auth" },
|
||||
{ label: "ACL 拒绝 acl", value: "acl" },
|
||||
{ label: "能力被禁 capability", value: "capability" },
|
||||
{ label: "限速 rate", value: "rate" },
|
||||
{ label: "网络 net", value: "net" },
|
||||
{ label: "agent 生命周期 agent", value: "agent" },
|
||||
{ label: "会话 session", value: "session" },
|
||||
{ label: "被拒请求 request", value: "request" },
|
||||
];
|
||||
|
||||
const cols: DataTableColumns<AuditEntry> = [
|
||||
@@ -124,7 +134,7 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div v-if="!conn.connected">
|
||||
<n-empty description="请先在「设备管理」页连接被控端" style="margin-top: 80px" />
|
||||
<n-empty :show-icon="false" description="请先在「设备管理」页连接被控端" style="margin-top: 80px" />
|
||||
</div>
|
||||
|
||||
<div v-else class="wrap">
|
||||
@@ -141,7 +151,7 @@ onMounted(() => {
|
||||
v-model:value="typeFilter"
|
||||
:options="typeOptions"
|
||||
size="small"
|
||||
style="width: 140px"
|
||||
style="width: 190px"
|
||||
@update:value="load"
|
||||
/>
|
||||
<n-input-number v-model:value="limit" :min="10" :max="2000" :step="50" size="small" style="width: 110px" />
|
||||
@@ -154,7 +164,6 @@ onMounted(() => {
|
||||
<n-alert
|
||||
v-if="verify"
|
||||
:type="verify.ok ? 'success' : 'error'"
|
||||
:show-icon="true"
|
||||
style="margin-bottom: 12px"
|
||||
>
|
||||
完整性校验:{{ verify.ok ? "通过" : "发现问题" }} · 已校验 {{ verify.checked }} 条
|
||||
@@ -165,7 +174,7 @@ onMounted(() => {
|
||||
</template>
|
||||
</n-alert>
|
||||
|
||||
<n-empty v-if="!entries.length" description="暂无审计记录" size="small" />
|
||||
<n-empty :show-icon="false" v-if="!entries.length" description="暂无审计记录" size="small" />
|
||||
<n-data-table v-else :columns="cols" :data="entries" :bordered="false" size="small" :max-height="560" />
|
||||
</n-card>
|
||||
</div>
|
||||
|
||||
@@ -146,7 +146,7 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div v-if="!conn.connected">
|
||||
<n-empty description="请先在「设备管理」页连接被控端" style="margin-top: 80px" />
|
||||
<n-empty :show-icon="false" description="请先在「设备管理」页连接被控端" style="margin-top: 80px" />
|
||||
</div>
|
||||
|
||||
<div v-else class="wrap">
|
||||
@@ -172,7 +172,7 @@ onMounted(() => {
|
||||
</n-card>
|
||||
|
||||
<n-card size="small" style="margin-top: 16px">
|
||||
<n-empty v-if="!entries.length" description="目录为空或未加载" size="small" />
|
||||
<n-empty :show-icon="false" v-if="!entries.length" description="目录为空或未加载" size="small" />
|
||||
<n-data-table
|
||||
v-else
|
||||
:columns="cols"
|
||||
@@ -228,7 +228,7 @@ onMounted(() => {
|
||||
margin: 12px 0 0;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
background: var(--na-sunken);
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 本机服务 —— 控制台托管的**本机被控端**(默认首页)。
|
||||
*
|
||||
* 目标:用户**完全不碰命令行** —— 打开应用即可启动 / 停止 / 重启被控端、
|
||||
* 开关开机自启、查日志、检查更新。
|
||||
*/
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import {
|
||||
NAlert,
|
||||
NButton,
|
||||
NCard,
|
||||
NDivider,
|
||||
NGrid,
|
||||
NGridItem,
|
||||
NPopconfirm,
|
||||
NSpace,
|
||||
NSpin,
|
||||
NSwitch,
|
||||
NTag,
|
||||
NText,
|
||||
NTooltip,
|
||||
useMessage,
|
||||
} from "naive-ui";
|
||||
import { useAgentStore, useLogsStore } from "../stores/agent";
|
||||
import { formatDuration } from "../utils/format";
|
||||
|
||||
const agent = useAgentStore();
|
||||
const logs = useLogsStore();
|
||||
const msg = useMessage();
|
||||
const router = useRouter();
|
||||
|
||||
const revealKey = ref(false);
|
||||
const copied = ref<string | null>(null);
|
||||
|
||||
const cfg = computed(() => agent.status?.config ?? null);
|
||||
const proc = computed(() => agent.process);
|
||||
const busy = computed(() => agent.busy);
|
||||
|
||||
/** 最近 6 行日志(完整日志在「服务日志」页)。 */
|
||||
const recent = computed(() => logs.lines.slice(-6));
|
||||
|
||||
async function copy(text: string, what: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
copied.value = what;
|
||||
msg.success(`已复制${what}`);
|
||||
setTimeout(() => (copied.value = null), 1500);
|
||||
} catch {
|
||||
msg.error("复制失败(系统剪贴板不可用,可手工选中复制)");
|
||||
}
|
||||
}
|
||||
|
||||
async function doAction(fn: () => Promise<unknown>, name: string) {
|
||||
const r = await fn();
|
||||
if (agent.error) msg.error(`${name}失败:${agent.error}`);
|
||||
else if (r) msg.success(`${name}完成`);
|
||||
}
|
||||
|
||||
async function doAutostart(v: boolean) {
|
||||
const r = await agent.setAutostart(v);
|
||||
if (agent.error) msg.error(`设置开机自启失败:${agent.error}`);
|
||||
else msg.success(v ? `已开启开机自启(${agent.status?.autostart.location})` : "已关闭开机自启");
|
||||
return r;
|
||||
}
|
||||
|
||||
async function doSetting(key: "auto_restart" | "close_to_tray" | "auto_start_agent", v: boolean) {
|
||||
await agent.patchSettings({ [key]: v } as never);
|
||||
if (agent.error) msg.error(`保存设置失败:${agent.error}`);
|
||||
}
|
||||
|
||||
async function doCheckUpdate() {
|
||||
const u = await agent.checkUpdate();
|
||||
if (agent.error) msg.error(`检查更新失败:${agent.error}`);
|
||||
else if (u) msg.success(u.has_update ? `有新版本 v${u.latest_version}` : "已是最新版本");
|
||||
}
|
||||
|
||||
async function doApplyUpdate() {
|
||||
const r = await agent.applyUpdate();
|
||||
if (agent.error) msg.error(`更新失败:${agent.error}`);
|
||||
else if (r) msg.success(`已更新到 v${r.version}(${r.restarted ? "并已重启" : "未自动启动"})`);
|
||||
}
|
||||
|
||||
async function doConnectLocal() {
|
||||
try {
|
||||
await agent.connectLocal();
|
||||
msg.success("已连接本机被控端");
|
||||
} catch (e) {
|
||||
msg.error(`连接失败:${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await agent.bind();
|
||||
await logs.bind();
|
||||
});
|
||||
|
||||
function fmtTime(ts?: number | null) {
|
||||
return ts ? new Date(ts).toLocaleString() : "-";
|
||||
}
|
||||
|
||||
/** 打开 Release 页面(模板里不能直接用 `window`,故走 setup 作用域的函数)。 */
|
||||
function openRelease() {
|
||||
const url = agent.update?.release_url;
|
||||
if (url) window.open(url, "_blank");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-spin :show="busy && !agent.status">
|
||||
<div class="wrap">
|
||||
<n-alert v-if="agent.error" type="error" style="margin-bottom: 14px">
|
||||
{{ agent.error }}
|
||||
</n-alert>
|
||||
|
||||
<!-- 非托管实例提示:端口被占但不是本控制台起的 -->
|
||||
<n-alert
|
||||
v-if="agent.externalInstance"
|
||||
type="warning"
|
||||
style="margin-bottom: 14px"
|
||||
title="检测到已有一个被控端在运行(非本控制台托管)"
|
||||
>
|
||||
端口 {{ cfg?.port }} 已被监听。它可能是手工安装的(`install.cmd` / 计划任务)。
|
||||
本控制台无法停止它;如要用本控制台管理,请先停掉它,或直接点「连接本机」复用它。
|
||||
</n-alert>
|
||||
|
||||
<n-grid :cols="4" :x-gap="16" :y-gap="16" item-responsive responsive="screen">
|
||||
<!-- ① 服务状态与操作 -->
|
||||
<n-grid-item span="4 s:4 m:2">
|
||||
<n-card title="本机被控端" size="small">
|
||||
<n-space align="center" style="margin-bottom: 12px">
|
||||
<n-tag :type="agent.meta.type" size="medium" round>{{ agent.meta.label }}</n-tag>
|
||||
<n-text v-if="proc?.pid" depth="3" style="font-size: 12px">PID {{ proc.pid }}</n-text>
|
||||
<n-text v-if="proc?.uptime_ms" depth="3" style="font-size: 12px">
|
||||
· 已运行 {{ formatDuration(Math.round((proc.uptime_ms ?? 0) / 1000)) }}
|
||||
</n-text>
|
||||
<n-text v-if="proc?.restarts" depth="3" style="font-size: 12px">
|
||||
· 自动重启 {{ proc.restarts }} 次
|
||||
</n-text>
|
||||
</n-space>
|
||||
|
||||
<n-space style="margin-bottom: 14px">
|
||||
<n-button
|
||||
type="primary"
|
||||
:loading="busy"
|
||||
:disabled="agent.running || agent.externalInstance"
|
||||
@click="doAction(() => agent.start(), '启动')"
|
||||
>
|
||||
启动
|
||||
</n-button>
|
||||
<n-button :loading="busy" :disabled="!agent.running" @click="doAction(() => agent.stop(), '停止')">
|
||||
停止
|
||||
</n-button>
|
||||
<n-button :loading="busy" :disabled="!agent.running" @click="doAction(() => agent.restart(), '重启')">
|
||||
重启
|
||||
</n-button>
|
||||
<n-button :disabled="!cfg" quaternary @click="doConnectLocal">连接本机</n-button>
|
||||
</n-space>
|
||||
|
||||
<n-alert v-if="proc?.last_exit" type="default" style="margin-bottom: 12px">
|
||||
上次退出:{{ proc.last_exit }}
|
||||
</n-alert>
|
||||
|
||||
<div class="kv"><span>节点 ID</span><b>{{ cfg?.node_id || "-" }}</b></div>
|
||||
<div class="kv"><span>监听地址</span><b>{{ cfg?.url || "-" }}</b></div>
|
||||
<div class="kv">
|
||||
<span>预共享密钥</span>
|
||||
<b class="mono">
|
||||
{{ revealKey ? cfg?.key || "-" : "•".repeat(Math.min(cfg?.key?.length ?? 0, 16)) || "-" }}
|
||||
<n-button text size="tiny" style="margin-left: 6px" @click="revealKey = !revealKey">
|
||||
{{ revealKey ? "隐藏" : "显示" }}
|
||||
</n-button>
|
||||
<n-button
|
||||
text
|
||||
size="tiny"
|
||||
style="margin-left: 6px"
|
||||
:disabled="!cfg?.key"
|
||||
@click="copy(cfg?.key ?? '', '密钥')"
|
||||
>
|
||||
复制
|
||||
</n-button>
|
||||
</b>
|
||||
</div>
|
||||
<div class="kv"><span>认证模式</span><b>{{ cfg?.auth_mode || "-" }}</b></div>
|
||||
<div class="kv">
|
||||
<span>数据目录</span>
|
||||
<b class="mono ellipsis" :title="cfg?.data_dir">{{ cfg?.data_dir || "-" }}</b>
|
||||
</div>
|
||||
<div class="kv">
|
||||
<span>运行脚本</span>
|
||||
<b class="mono ellipsis" :title="proc?.script">{{ proc?.script || "-" }}</b>
|
||||
</div>
|
||||
<div class="kv">
|
||||
<span>被控端版本</span>
|
||||
<b>{{ agent.status?.installed_agent_version || "(未播种)" }}</b>
|
||||
</div>
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
|
||||
<!-- ② 行为设置 -->
|
||||
<n-grid-item span="4 s:4 m:2">
|
||||
<n-card title="运行方式" size="small">
|
||||
<div class="switch-row">
|
||||
<div>
|
||||
<div>开机自启</div>
|
||||
<div class="hint">
|
||||
{{ agent.status?.autostart.note }}
|
||||
</div>
|
||||
</div>
|
||||
<n-switch
|
||||
:value="agent.status?.autostart.enabled ?? false"
|
||||
:disabled="!agent.status?.autostart.supported || busy"
|
||||
:loading="busy"
|
||||
@update:value="doAutostart"
|
||||
/>
|
||||
</div>
|
||||
<n-divider style="margin: 12px 0" />
|
||||
<div class="switch-row">
|
||||
<div>
|
||||
<div>后台运行(关闭窗口时最小化到托盘)</div>
|
||||
<div class="hint">关闭后,点窗口的关闭按钮将直接退出应用并停止被控端</div>
|
||||
</div>
|
||||
<n-switch
|
||||
:value="agent.settings.close_to_tray"
|
||||
:disabled="busy"
|
||||
@update:value="(v: boolean) => doSetting('close_to_tray', v)"
|
||||
/>
|
||||
</div>
|
||||
<n-divider style="margin: 12px 0" />
|
||||
<div class="switch-row">
|
||||
<div>
|
||||
<div>启动应用时自动拉起被控端</div>
|
||||
<div class="hint">关闭后需手工点「启动」</div>
|
||||
</div>
|
||||
<n-switch
|
||||
:value="agent.settings.auto_start_agent"
|
||||
:disabled="busy"
|
||||
@update:value="(v: boolean) => doSetting('auto_start_agent', v)"
|
||||
/>
|
||||
</div>
|
||||
<n-divider style="margin: 12px 0" />
|
||||
<div class="switch-row">
|
||||
<div>
|
||||
<div>被控端异常退出时自动重启</div>
|
||||
<div class="hint">连续失败 5 次后停止重试并保留现场日志</div>
|
||||
</div>
|
||||
<n-switch
|
||||
:value="agent.settings.auto_restart"
|
||||
:disabled="busy"
|
||||
@update:value="(v: boolean) => doSetting('auto_restart', v)"
|
||||
/>
|
||||
</div>
|
||||
<n-divider style="margin: 12px 0" />
|
||||
<div class="kv"><span>自启落地位置</span><b class="mono ellipsis">{{ agent.status?.autostart.location || "-" }}</b></div>
|
||||
<div class="kv"><span>自启方式</span><b class="mono">仅服务模式(无窗口)</b></div>
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
|
||||
<!-- ③ 更新 -->
|
||||
<n-grid-item span="4 s:4 m:2">
|
||||
<n-card title="更新" size="small">
|
||||
<div class="kv"><span>控制台版本</span><b>v{{ agent.status?.app_version || "-" }}</b></div>
|
||||
<div class="kv">
|
||||
<span>被控端版本</span>
|
||||
<b>{{ agent.status?.installed_agent_version || "(未播种)" }}</b>
|
||||
</div>
|
||||
<div class="kv">
|
||||
<span>最新版本</span>
|
||||
<b>
|
||||
{{ agent.update ? `v${agent.update.latest_version}` : "(未检查)" }}
|
||||
<n-tag
|
||||
v-if="agent.update?.has_update"
|
||||
type="warning"
|
||||
size="tiny"
|
||||
style="margin-left: 6px"
|
||||
>
|
||||
有更新
|
||||
</n-tag>
|
||||
<n-tag
|
||||
v-else-if="agent.update"
|
||||
type="success"
|
||||
size="tiny"
|
||||
style="margin-left: 6px"
|
||||
>
|
||||
已是最新
|
||||
</n-tag>
|
||||
</b>
|
||||
</div>
|
||||
<div v-if="agent.update?.published_at" class="kv">
|
||||
<span>发布时间</span><b>{{ fmtTime(Date.parse(agent.update.published_at)) }}</b>
|
||||
</div>
|
||||
|
||||
<n-space style="margin-top: 12px">
|
||||
<n-button size="small" :loading="busy" @click="doCheckUpdate">检查更新</n-button>
|
||||
<n-popconfirm :show-icon="false" @positive-click="doApplyUpdate">
|
||||
<template #trigger>
|
||||
<n-button
|
||||
size="small"
|
||||
type="primary"
|
||||
:disabled="!agent.update?.agent_has_update || busy"
|
||||
>
|
||||
更新被控端
|
||||
</n-button>
|
||||
</template>
|
||||
将下载最新被控端并替换本机正在使用的那份(自动备份旧文件),随后重启服务。确定?
|
||||
</n-popconfirm>
|
||||
</n-space>
|
||||
<div class="hint" style="margin-top: 10px">
|
||||
「更新被控端」只替换本机被控端脚本(架构无关的 JS)。控制台自身(桌面应用)请从
|
||||
<n-button
|
||||
text
|
||||
size="tiny"
|
||||
:disabled="!agent.update"
|
||||
@click="openRelease"
|
||||
>
|
||||
Release 页面
|
||||
</n-button>
|
||||
下载安装包。
|
||||
</div>
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
|
||||
<!-- ④ 日志预览 -->
|
||||
<n-grid-item span="4 s:4 m:2">
|
||||
<n-card title="最近日志" size="small">
|
||||
<template #header-extra>
|
||||
<n-space>
|
||||
<n-button text size="tiny" @click="logs.load()">刷新</n-button>
|
||||
<n-button text size="tiny" @click="router.push({ name: 'logs' })">查看全部</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
<div v-if="recent.length" class="logs">
|
||||
<div v-for="l in recent" :key="l.seq" class="log-line" :class="l.stream">
|
||||
<span class="ts">{{ new Date(l.ts).toLocaleTimeString() }}</span>
|
||||
<span class="txt">{{ l.line }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<n-text v-else depth="3" style="font-size: 13px">暂无日志</n-text>
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
|
||||
<n-tooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<n-text depth="3" style="font-size: 12px; display: block; margin-top: 18px">
|
||||
提示:关闭窗口后控制台会缩到托盘继续运行 —— 从托盘菜单可显示窗口或退出。
|
||||
</n-text>
|
||||
</template>
|
||||
若不想后台常驻,可在「运行方式」里关闭「后台运行」
|
||||
</n-tooltip>
|
||||
</div>
|
||||
</n-spin>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wrap {
|
||||
max-width: 1100px;
|
||||
}
|
||||
.kv {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.kv > span {
|
||||
opacity: 0.6;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.kv > b {
|
||||
font-weight: 500;
|
||||
text-align: right;
|
||||
min-width: 0;
|
||||
}
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
.ellipsis {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
}
|
||||
.switch-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
opacity: 0.55;
|
||||
margin-top: 3px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.logs {
|
||||
max-height: 190px;
|
||||
overflow: auto;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
.log-line {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 1px 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.log-line .ts {
|
||||
opacity: 0.45;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.log-line.stderr .txt {
|
||||
color: var(--na-err);
|
||||
}
|
||||
.log-line.system .txt {
|
||||
color: var(--na-info);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,172 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 服务日志 —— 本机被控端(agent)的标准输出/错误与系统消息。
|
||||
*
|
||||
* 实时性靠后端事件推送(`na-agent-log`),历史靠 `agent_logs` 增量拉取;
|
||||
* 两条路用 `seq` 去重,故既不会丢历史、也不会重复。
|
||||
*/
|
||||
import { computed, nextTick, onMounted, ref, watch } from "vue";
|
||||
import {
|
||||
NButton,
|
||||
NCard,
|
||||
NCheckbox,
|
||||
NCheckboxGroup,
|
||||
NInput,
|
||||
NPopconfirm,
|
||||
NSpace,
|
||||
NSwitch,
|
||||
NTag,
|
||||
NText,
|
||||
useMessage,
|
||||
} from "naive-ui";
|
||||
import { useLogsStore } from "../stores/agent";
|
||||
|
||||
const logs = useLogsStore();
|
||||
const msg = useMessage();
|
||||
|
||||
const keyword = ref("");
|
||||
const streams = ref<string[]>(["stdout", "stderr", "system"]);
|
||||
const autoScroll = ref(true);
|
||||
const boxRef = ref<HTMLElement | null>(null);
|
||||
|
||||
const filtered = computed(() =>
|
||||
logs.lines.filter((l) => {
|
||||
if (!streams.value.includes(l.stream)) return false;
|
||||
if (!keyword.value) return true;
|
||||
return l.line.toLowerCase().includes(keyword.value.toLowerCase());
|
||||
}),
|
||||
);
|
||||
|
||||
async function scrollToBottom() {
|
||||
if (!autoScroll.value) return;
|
||||
await nextTick();
|
||||
const el = boxRef.value;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
|
||||
// 新日志到达时自动滚到底(仅在未暂停且开启自动滚动时)
|
||||
watch(() => logs.lines.length, scrollToBottom);
|
||||
|
||||
onMounted(async () => {
|
||||
await logs.bind();
|
||||
await scrollToBottom();
|
||||
});
|
||||
|
||||
function clear() {
|
||||
void logs.clear();
|
||||
msg.success("已清空日志缓冲");
|
||||
}
|
||||
|
||||
function exportLogs() {
|
||||
const text = logs.toText(filtered.value);
|
||||
logs.downloadText(text, `nodeagent-console-${new Date().toISOString().replace(/[:.]/g, "-")}.log`);
|
||||
msg.success(`已导出 ${filtered.value.length} 行`);
|
||||
}
|
||||
|
||||
function streamType(s: string) {
|
||||
if (s === "stderr") return "error" as const;
|
||||
if (s === "system") return "success" as const;
|
||||
return "default" as const;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wrap">
|
||||
<n-card size="small" title="本机服务日志">
|
||||
<template #header-extra>
|
||||
<n-text depth="3" style="font-size: 12px">
|
||||
{{ filtered.length }} / {{ logs.lines.length }} 行(缓冲上限 4000)
|
||||
</n-text>
|
||||
</template>
|
||||
|
||||
<n-space align="center" style="margin-bottom: 12px" :wrap="true">
|
||||
<n-input
|
||||
v-model:value="keyword"
|
||||
size="small"
|
||||
placeholder="过滤关键字…"
|
||||
clearable
|
||||
style="width: 220px"
|
||||
/>
|
||||
<n-checkbox-group v-model:value="streams">
|
||||
<n-space align="center">
|
||||
<n-checkbox value="stdout" label="标准输出" />
|
||||
<n-checkbox value="stderr" label="标准错误" />
|
||||
<n-checkbox value="system" label="系统消息" />
|
||||
</n-space>
|
||||
</n-checkbox-group>
|
||||
<n-space align="center">
|
||||
<n-text depth="3" style="font-size: 12px">自动滚动</n-text>
|
||||
<n-switch v-model:value="autoScroll" size="small" />
|
||||
</n-space>
|
||||
<n-space align="center">
|
||||
<n-text depth="3" style="font-size: 12px">暂停接收</n-text>
|
||||
<n-switch v-model:value="logs.paused" size="small" />
|
||||
</n-space>
|
||||
<n-button size="small" @click="logs.load()">重新拉取</n-button>
|
||||
<n-button size="small" @click="scrollToBottom">滚到底部</n-button>
|
||||
<n-button size="small" @click="exportLogs">导出</n-button>
|
||||
<n-popconfirm :show-icon="false" @positive-click="clear">
|
||||
<template #trigger>
|
||||
<n-button size="small" type="error" quaternary>清空</n-button>
|
||||
</template>
|
||||
将清空内存中的日志缓冲(磁盘上的审计日志不受影响)。确定?
|
||||
</n-popconfirm>
|
||||
</n-space>
|
||||
|
||||
<div ref="boxRef" class="log-box">
|
||||
<div v-if="!filtered.length" class="empty">
|
||||
暂无日志。被控端启动后,其输出会实时出现在这里。
|
||||
</div>
|
||||
<div v-for="l in filtered" :key="l.seq" class="row">
|
||||
<span class="ts">{{ new Date(l.ts).toLocaleTimeString() }}</span>
|
||||
<n-tag :type="streamType(l.stream)" size="tiny" :bordered="false" class="tag">
|
||||
{{ l.stream }}
|
||||
</n-tag>
|
||||
<span class="txt" :class="l.stream">{{ l.line }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wrap {
|
||||
max-width: 1100px;
|
||||
}
|
||||
.log-box {
|
||||
height: calc(100vh - 300px);
|
||||
min-height: 320px;
|
||||
overflow: auto;
|
||||
background: var(--na-sunken);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.ts {
|
||||
opacity: 0.4;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.tag {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.txt.stderr {
|
||||
color: var(--na-err);
|
||||
}
|
||||
.txt.system {
|
||||
color: var(--na-info);
|
||||
}
|
||||
.empty {
|
||||
opacity: 0.5;
|
||||
padding: 20px 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -93,7 +93,7 @@ const capGroups = computed(() => {
|
||||
指纹 {{ (conn.info.peer_cert_fp ?? "-").slice(0, 16) }}…
|
||||
</n-text>
|
||||
</div>
|
||||
<n-empty v-else description="尚未连接" size="small" />
|
||||
<n-empty :show-icon="false" v-else description="尚未连接" size="small" />
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
@@ -114,7 +114,7 @@ const capGroups = computed(() => {
|
||||
</div>
|
||||
<n-space>
|
||||
<n-button size="tiny" type="primary" @click="connectTo(n.id)">连接</n-button>
|
||||
<n-popconfirm @positive-click="nodeStore.remove(n.id)">
|
||||
<n-popconfirm :show-icon="false" @positive-click="nodeStore.remove(n.id)">
|
||||
<template #trigger>
|
||||
<n-button size="tiny" quaternary>删除</n-button>
|
||||
</template>
|
||||
@@ -123,7 +123,7 @@ const capGroups = computed(() => {
|
||||
</n-space>
|
||||
</div>
|
||||
</div>
|
||||
<n-empty v-else description="暂无保存的设备" size="small" />
|
||||
<n-empty :show-icon="false" v-else description="暂无保存的设备" size="small" />
|
||||
|
||||
<div class="add-row">
|
||||
<n-input v-model:value="newName" placeholder="名称" size="small" style="flex: 1" />
|
||||
@@ -149,7 +149,7 @@ const capGroups = computed(() => {
|
||||
</div>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
<n-empty v-else description="连接后展示" size="small" />
|
||||
<n-empty :show-icon="false" v-else description="连接后展示" size="small" />
|
||||
</n-card>
|
||||
|
||||
<n-card title="日志" size="small">
|
||||
@@ -195,11 +195,11 @@ const capGroups = computed(() => {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.09);
|
||||
border: 1px solid var(--na-border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.node.active {
|
||||
border-color: #63e2b7;
|
||||
border-color: var(--na-accent);
|
||||
}
|
||||
.node-name {
|
||||
font-size: 14px;
|
||||
@@ -219,7 +219,7 @@ const capGroups = computed(() => {
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
background: var(--na-sunken);
|
||||
}
|
||||
.logs {
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
|
||||
@@ -69,7 +69,7 @@ onUnmounted(stop);
|
||||
|
||||
<template>
|
||||
<div v-if="!conn.connected">
|
||||
<n-empty description="请先在「设备管理」页连接被控端" style="margin-top: 80px" />
|
||||
<n-empty :show-icon="false" description="请先在「设备管理」页连接被控端" style="margin-top: 80px" />
|
||||
</div>
|
||||
|
||||
<div v-else class="wrap">
|
||||
@@ -102,7 +102,7 @@ onUnmounted(stop);
|
||||
type="line"
|
||||
:percentage="Math.round(status?.cpu_pct ?? 0)"
|
||||
:height="10"
|
||||
:color="(status?.cpu_pct ?? 0) > 80 ? '#e88080' : '#63e2b7'"
|
||||
:color="(status?.cpu_pct ?? 0) > 80 ? 'var(--na-err)' : 'var(--na-ok)'"
|
||||
/>
|
||||
</div>
|
||||
<div class="gauge">
|
||||
@@ -113,7 +113,7 @@ onUnmounted(stop);
|
||||
type="line"
|
||||
:percentage="Math.round(status?.memory_pct ?? 0)"
|
||||
:height="10"
|
||||
:color="(status?.memory_pct ?? 0) > 85 ? '#e88080' : '#63e2b7'"
|
||||
:color="(status?.memory_pct ?? 0) > 85 ? 'var(--na-err)' : 'var(--na-ok)'"
|
||||
/>
|
||||
</div>
|
||||
<n-statistic
|
||||
@@ -138,11 +138,11 @@ onUnmounted(stop);
|
||||
type="line"
|
||||
:percentage="Math.round(d.used_pct ?? 0)"
|
||||
:height="8"
|
||||
:color="(d.used_pct ?? 0) > 90 ? '#e88080' : '#63e2b7'"
|
||||
:color="(d.used_pct ?? 0) > 90 ? 'var(--na-err)' : 'var(--na-ok)'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<n-empty v-else description="无磁盘数据" size="small" />
|
||||
<n-empty :show-icon="false" v-else description="无磁盘数据" size="small" />
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
|
||||
@@ -159,7 +159,7 @@ onUnmounted(stop);
|
||||
</b>
|
||||
</div>
|
||||
</div>
|
||||
<n-empty v-else description="无网络数据" size="small" />
|
||||
<n-empty :show-icon="false" v-else description="无网络数据" size="small" />
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
|
||||
@@ -128,11 +128,11 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div v-if="!conn.connected">
|
||||
<n-empty description="请先在「设备管理」页连接被控端" style="margin-top: 80px" />
|
||||
<n-empty :show-icon="false" description="请先在「设备管理」页连接被控端" style="margin-top: 80px" />
|
||||
</div>
|
||||
|
||||
<n-card v-else size="small">
|
||||
<n-alert v-if="listError" type="error" :show-icon="true" style="margin-bottom: 12px">
|
||||
<n-alert v-if="listError" type="error" style="margin-bottom: 12px">
|
||||
{{ listError }}
|
||||
</n-alert>
|
||||
|
||||
@@ -151,7 +151,7 @@ onMounted(() => {
|
||||
</n-button>
|
||||
<n-text depth="3" style="font-size: 12px">{{ filteredProcs.length }} / {{ procs.length }}</n-text>
|
||||
</n-space>
|
||||
<n-empty v-if="!filteredProcs.length" description="暂无进程数据" size="small" />
|
||||
<n-empty :show-icon="false" v-if="!filteredProcs.length" description="暂无进程数据" size="small" />
|
||||
<n-data-table
|
||||
v-else
|
||||
:columns="procCols"
|
||||
@@ -163,7 +163,7 @@ onMounted(() => {
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane name="svc" tab="服务">
|
||||
<n-alert v-if="!servicesAvailable" type="warning" :show-icon="true">
|
||||
<n-alert v-if="!servicesAvailable" type="warning">
|
||||
`system.service.list` 仅在被控端为 <b>Windows</b> 时可用;当前被控端为
|
||||
<b>{{ conn.nodeOs ?? "未知" }}</b>。
|
||||
</n-alert>
|
||||
@@ -173,7 +173,7 @@ onMounted(() => {
|
||||
<n-button size="small" :loading="loading" @click="loadServices">刷新</n-button>
|
||||
<n-text depth="3" style="font-size: 12px">{{ services.length }} 项</n-text>
|
||||
</n-space>
|
||||
<n-empty v-if="!services.length" description="暂无服务数据" size="small" />
|
||||
<n-empty :show-icon="false" v-if="!services.length" description="暂无服务数据" size="small" />
|
||||
<n-data-table
|
||||
v-else
|
||||
:columns="svcCols"
|
||||
|
||||
@@ -111,20 +111,20 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div v-if="!conn.connected">
|
||||
<n-empty description="请先在「设备管理」页连接被控端" style="margin-top: 80px" />
|
||||
<n-empty :show-icon="false" description="请先在「设备管理」页连接被控端" style="margin-top: 80px" />
|
||||
</div>
|
||||
|
||||
<div v-else class="wrap">
|
||||
<n-alert v-if="!available" type="warning" :show-icon="true" style="margin-bottom: 14px">
|
||||
<n-alert v-if="!available" type="warning" style="margin-bottom: 14px">
|
||||
软件能力(<code>app.list</code> / <code>app.install</code>)依赖被控端 <code>winget</code>,
|
||||
<b>仅在被控端为 Windows 时可用</b>;当前被控端为 <b>{{ conn.nodeOs ?? "未知" }}</b>,
|
||||
本页已禁用。
|
||||
</n-alert>
|
||||
<n-alert v-else type="info" :show-icon="true" style="margin-bottom: 14px">
|
||||
<n-alert v-else type="info" style="margin-bottom: 14px">
|
||||
软件安装依赖被控端 <code>winget</code>。安装可能耗时数分钟,已把调用超时放大到 10 分钟。
|
||||
</n-alert>
|
||||
|
||||
<n-alert v-if="error" type="error" :show-icon="true" style="margin-bottom: 14px">
|
||||
<n-alert v-if="error" type="error" style="margin-bottom: 14px">
|
||||
{{ error }}
|
||||
</n-alert>
|
||||
|
||||
@@ -157,7 +157,7 @@ onMounted(() => {
|
||||
<n-text depth="3" style="font-size: 12px">{{ filtered.length }}</n-text>
|
||||
</n-space>
|
||||
</template>
|
||||
<n-empty
|
||||
<n-empty :show-icon="false"
|
||||
v-if="!apps.length && !loading"
|
||||
:description="available ? '暂无数据(点刷新)' : '当前被控端非 Windows,不支持'"
|
||||
size="small"
|
||||
@@ -175,7 +175,7 @@ onMounted(() => {
|
||||
margin: 12px 0 0;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
background: var(--na-sunken);
|
||||
font-size: 12px;
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
|
||||
@@ -85,13 +85,13 @@ function reuse(c: string) {
|
||||
|
||||
<template>
|
||||
<div v-if="!conn.connected">
|
||||
<n-empty description="请先在「设备管理」页连接被控端" style="margin-top: 80px" />
|
||||
<n-empty :show-icon="false" description="请先在「设备管理」页连接被控端" style="margin-top: 80px" />
|
||||
</div>
|
||||
|
||||
<div v-else class="wrap">
|
||||
<n-card size="small" title="命令执行(system.shell.exec)">
|
||||
<template #header-extra>
|
||||
<n-text depth="3" style="font-size: 12px">⚠ 高权限操作,请谨慎</n-text>
|
||||
<n-text depth="3" style="font-size: 12px">高权限操作,请谨慎</n-text>
|
||||
</template>
|
||||
<n-input
|
||||
v-model:value="cmd"
|
||||
@@ -161,6 +161,6 @@ function reuse(c: string) {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.hist:hover code {
|
||||
color: #63e2b7;
|
||||
color: var(--na-accent);
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/hub",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/mcp",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -262,7 +262,7 @@ function resolveToolCall(toolName: string, input: Record<string, unknown>): Reso
|
||||
return { capability: CapabilityNames.KeyType, args: { text: input['text'] } };
|
||||
}
|
||||
if (action === 'press') {
|
||||
// v1.5:四种形式一次只用一种(hotkeys > hotkey > presets > preset > keys),
|
||||
// v1.5.0:四种形式一次只用一种(hotkeys > hotkey > presets > preset > keys),
|
||||
// 其余各端会做同样的校验与展开,这里只做薄校验后原样透传。
|
||||
const has = (k: string): boolean =>
|
||||
input[k] !== undefined && !(Array.isArray(input[k]) && (input[k] as unknown[]).length === 0);
|
||||
|
||||
@@ -257,7 +257,7 @@ export const TOOLS = [
|
||||
{
|
||||
name: 'na_await',
|
||||
description:
|
||||
'等待一个条件成立再返回(只读低危,v1.6)。\n' +
|
||||
'等待一个条件成立再返回(只读低危,v1.6.0)。\n' +
|
||||
'四类条件:window=等窗口出现/消失;control=等界面元素出现/消失(复用 screen.find);' +
|
||||
'process=等进程出现/消失;file=等文件出现/消失。\n' +
|
||||
'state=present(默认,等出现)或 absent(等消失)。\n' +
|
||||
@@ -275,8 +275,8 @@ export const TOOLS = [
|
||||
title: { type: 'string', description: 'window 条件:窗口标题(正则)' },
|
||||
process: { type: 'string', description: 'process 条件:进程名(正则)' },
|
||||
path: { type: 'string', description: 'file 条件:文件路径' },
|
||||
where: { type: 'object', description: 'v1.7 属性谓词(仅 UIA):{ enabled?, selected?, value?, toggle? }' },
|
||||
any_of: { type: 'array', maxItems: 8, description: 'v1.7 组合条件:每项一个条件对象,任一命中即算' },
|
||||
where: { type: 'object', description: 'v2.0.0 属性谓词(仅 UIA):{ enabled?, selected?, value?, toggle? }' },
|
||||
any_of: { type: 'array', maxItems: 8, description: 'v2.0.0 组合条件:每项一个条件对象,任一命中即算' },
|
||||
},
|
||||
required: ['condition'],
|
||||
additionalProperties: false,
|
||||
@@ -285,7 +285,7 @@ export const TOOLS = [
|
||||
{
|
||||
name: 'na_monitor',
|
||||
description:
|
||||
'定时采样并落盘,事后回看(v1.8,治间歇性问题)。\n' +
|
||||
'定时采样并落盘,事后回看(v2.0.0,治间歇性问题)。\n' +
|
||||
'action=start:起一个采样。source=port(target="host:port",测连通性与延迟)/' +
|
||||
'process(target=进程名,测存活)/command(target=命令,取退出码)/metric(CPU·内存,无需 target)。\n' +
|
||||
'action=report:读回样本序列与**摘要**(port/process 给「断了几次 + 最长连续中断」;' +
|
||||
@@ -309,7 +309,7 @@ export const TOOLS = [
|
||||
{
|
||||
name: 'na_log',
|
||||
description:
|
||||
'在被控端侧过滤日志,只回匹配行(v1.8,只读)。日志常几万行,整份拉回既慢又占带宽 —— ' +
|
||||
'在被控端侧过滤日志,只回匹配行(v2.0.0,只读)。日志常几万行,整份拉回既慢又占带宽 —— ' +
|
||||
'本工具把过滤下推到被控端(流式逐行读)。支持 pattern(正则)/level(ERROR|WARN|INFO|DEBUG)/\n' +
|
||||
'since(Unix ms)/offset/limit/tail(取末尾 N 条)。',
|
||||
inputSchema: {
|
||||
|
||||
Generated
-1185
File diff suppressed because it is too large.
Load diff
@@ -1,5 +0,0 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["nodeagent-client"]
|
||||
|
||||
[workspace.dependencies]
|
||||
@@ -1,23 +0,0 @@
|
||||
[package]
|
||||
name = "nodeagent-client"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "nodeagent 控制端 Rust 客户端(行为对齐 @nodeagent/client)"
|
||||
license = "MIT"
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.23.1"
|
||||
ed25519-dalek = { version = "3.0.0", features = ["pkcs8", "alloc"] }
|
||||
futures-util = "0.3.34"
|
||||
hmac = "0.13.0"
|
||||
rand = "0.10.3"
|
||||
rustls = "0.23.45"
|
||||
rustls-pemfile = "2.2.0"
|
||||
serde = { version = "1.0.229", features = ["derive"] }
|
||||
serde_json = "1.0.151"
|
||||
sha2 = "0.11.0"
|
||||
thiserror = "2.0.21"
|
||||
tokio = { version = "1.53.2", features = ["rt-multi-thread", "net", "time", "sync", "macros", "io-util"] }
|
||||
tokio-tungstenite = { version = "0.30.0", features = ["rustls-tls-webpki-roots"] }
|
||||
url = "2.5.8"
|
||||
webpki-roots = "1.0.9"
|
||||
@@ -1,53 +0,0 @@
|
||||
//! 连接一个真实 nodeagent 被控端的最小示例。
|
||||
//!
|
||||
//! 用法:`cargo run --example connect -- <url> <key> [client_id]`
|
||||
//! 例如:`cargo run --example connect -- ws://127.0.0.1:8765 test-psk-key`
|
||||
|
||||
use nodeagent_client::{Client, ClientEvent, ClientOptions};
|
||||
use serde_json::json;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let url = args
|
||||
.get(1)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "ws://127.0.0.1:8765".to_string());
|
||||
let key = args.get(2).cloned().unwrap_or_default();
|
||||
let client_id = args
|
||||
.get(3)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "rust-example".to_string());
|
||||
|
||||
let client = Client::new(ClientOptions {
|
||||
url: url.clone(),
|
||||
key,
|
||||
client_id,
|
||||
insecure: true,
|
||||
auto_reconnect: false,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
client.set_handler(std::sync::Arc::new(|evt| {
|
||||
if let ClientEvent::Log(msg) = evt {
|
||||
println!("[log] {msg}");
|
||||
}
|
||||
}));
|
||||
|
||||
let caps = client.connect().await?;
|
||||
println!("握手成功,{} 项能力", caps.len());
|
||||
println!("agent 版本: {:?}", client.agent_version());
|
||||
println!("证书指纹: {:?}", client.peer_cert_fingerprint());
|
||||
for c in caps.iter().take(12) {
|
||||
println!(" - {} (risk={}, v{})", c.name, c.risk, c.version);
|
||||
}
|
||||
|
||||
let res = client.invoke("system.info", json!({})).await?;
|
||||
println!("\nsystem.info → status={}", res.status);
|
||||
if let Some(data) = &res.data {
|
||||
println!("{}", serde_json::to_string_pretty(data)?);
|
||||
}
|
||||
|
||||
client.close();
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
//! M3 能力探针:对真实被控端逐项调用「文件 / 审计 / 软件 / 进程服务」能力,核对契约。
|
||||
//!
|
||||
//! 输出**字段契约**而非数据预览——顶层键 + 数组首元素的键,
|
||||
//! 便于与前端 `apps/console/src/types/index.ts` 的类型声明逐项比对。
|
||||
//!
|
||||
//! 用法:`cargo run --example probe -- <url> <key> [base_dir] [--full]`
|
||||
//! --full 额外打印完整 JSON(长字段截断到 400 字符)
|
||||
|
||||
use nodeagent_client::{Client, ClientOptions};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// 一行对象 → `a, b, c` 形式的键清单。
|
||||
fn keys_of(v: &Value) -> String {
|
||||
match v {
|
||||
Value::Object(m) => m.keys().cloned().collect::<Vec<_>>().join(", "),
|
||||
other => format!("<{}>", type_name(other)),
|
||||
}
|
||||
}
|
||||
|
||||
fn type_name(v: &Value) -> &'static str {
|
||||
match v {
|
||||
Value::Null => "null",
|
||||
Value::Bool(_) => "bool",
|
||||
Value::Number(_) => "number",
|
||||
Value::String(_) => "string",
|
||||
Value::Array(_) => "array",
|
||||
Value::Object(_) => "object",
|
||||
}
|
||||
}
|
||||
|
||||
/// 打印「字段契约」:顶层键 + 各数组字段的首元素键。
|
||||
fn contract(v: &Value, indent: &str) {
|
||||
if let Value::Object(map) = v {
|
||||
for (k, val) in map {
|
||||
match val {
|
||||
Value::Array(items) if !items.is_empty() => {
|
||||
println!("{indent}{k}: array[{}] → {{ {} }}", items.len(), keys_of(&items[0]));
|
||||
}
|
||||
other if other.is_object() => {
|
||||
println!("{indent}{k}: object → {{ {} }}", keys_of(other));
|
||||
}
|
||||
other => {
|
||||
let shown = match other {
|
||||
Value::String(s) if s.chars().count() > 60 => {
|
||||
format!("string(\"{}…\")", s.chars().take(60).collect::<String>())
|
||||
}
|
||||
Value::Array(a) => format!("array[{}]", a.len()),
|
||||
_ => format!("{}({other})", type_name(other)),
|
||||
};
|
||||
println!("{indent}{k}: {shown}");
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("{indent}{}", type_name(v));
|
||||
}
|
||||
}
|
||||
|
||||
async fn call(client: &Client, cap: &str, args: Value, full: bool) -> bool {
|
||||
match client.invoke(cap, args).await {
|
||||
Ok(r) if r.status == "ok" => {
|
||||
println!(" ✓ {cap}");
|
||||
if let Some(d) = &r.data {
|
||||
contract(d, " ");
|
||||
if full {
|
||||
let s = serde_json::to_string_pretty(d).unwrap_or_default();
|
||||
let s: String = s.chars().take(400).collect();
|
||||
println!(" ── full ── {s}");
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
Ok(r) => {
|
||||
let e = r.error.unwrap_or_default();
|
||||
println!(" ✗ {cap}: [{}] {}", e.name, e.message);
|
||||
false
|
||||
}
|
||||
Err(e) => {
|
||||
println!(" ✗ {cap}: {e}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let argv: Vec<String> = std::env::args().skip(1).collect();
|
||||
let full = argv.iter().any(|a| a == "--full");
|
||||
let pos: Vec<String> = argv.into_iter().filter(|a| !a.starts_with("--")).collect();
|
||||
|
||||
let url = pos.first().cloned().unwrap_or_else(|| "ws://127.0.0.1:8765".to_string());
|
||||
let key = pos.get(1).cloned().unwrap_or_default();
|
||||
let base = pos.get(2).cloned().unwrap_or_else(|| "/tmp/nodeagent-e2e".to_string());
|
||||
|
||||
let client = Client::new(ClientOptions {
|
||||
url,
|
||||
key,
|
||||
client_id: "probe".to_string(),
|
||||
insecure: true,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let caps = client.connect().await?;
|
||||
println!("连接成功,{} 项能力", caps.len());
|
||||
|
||||
// 平台探测——决定 platform-only 能力是否预期可用。
|
||||
let info = client.invoke("system.info", json!({})).await?;
|
||||
let os = info
|
||||
.data
|
||||
.as_ref()
|
||||
.and_then(|d| d.get("os"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("?")
|
||||
.to_string();
|
||||
let is_windows = os == "Windows";
|
||||
println!("被控端平台:{os}(platform-only 能力{}预期可用)\n", if is_windows { "" } else { "不" });
|
||||
|
||||
let mut pass = 0usize;
|
||||
let mut fail = 0usize;
|
||||
|
||||
println!("【文件 fs.*】");
|
||||
for (cap, args) in [
|
||||
("fs.list", json!({ "path": base })),
|
||||
("fs.stat", json!({ "path": format!("{base}/agent.json") })),
|
||||
("fs.read", json!({ "path": format!("{base}/agent.json"), "max_bytes": 2048 })),
|
||||
(
|
||||
"fs.write",
|
||||
json!({ "path": format!("{base}/probe.txt"), "data": "hello from rust probe", "append": false, "create_dirs": true }),
|
||||
),
|
||||
] {
|
||||
if call(&client, cap, args, full).await {
|
||||
pass += 1
|
||||
} else {
|
||||
fail += 1
|
||||
}
|
||||
}
|
||||
|
||||
println!("\n【审计 system.audit.*】");
|
||||
for (cap, args) in [
|
||||
("system.audit.list", json!({ "limit": 5 })),
|
||||
("system.audit.verify", json!({})),
|
||||
] {
|
||||
if call(&client, cap, args, full).await {
|
||||
pass += 1
|
||||
} else {
|
||||
fail += 1
|
||||
}
|
||||
}
|
||||
|
||||
println!("\n【软件 / 进程 / 服务】");
|
||||
let platform_only = ["app.list", "app.install", "system.service.list"];
|
||||
for (cap, args) in [
|
||||
("app.list", json!({})),
|
||||
("app.install", json!({ "package": "__nonexistent_probe__", "silent": true })),
|
||||
("system.process.list", json!({ "limit": 5 })),
|
||||
("system.service.list", json!({ "limit": 5 })),
|
||||
] {
|
||||
let ok = call(&client, cap, args, full).await;
|
||||
// platform-only 能力在非 Windows 被控端上失败属**预期**,不计入失败。
|
||||
if ok {
|
||||
pass += 1;
|
||||
} else if !is_windows && platform_only.contains(&cap) {
|
||||
println!(" ↑ 非 Windows 被控端,属预期");
|
||||
} else {
|
||||
fail += 1;
|
||||
}
|
||||
}
|
||||
|
||||
println!("\n【其他页面依赖】");
|
||||
for (cap, args) in [
|
||||
("system.info", json!({})),
|
||||
("system.status", json!({})),
|
||||
("system.shell.exec", json!({ "command": "echo probe-ok" })),
|
||||
] {
|
||||
if call(&client, cap, args, full).await {
|
||||
pass += 1
|
||||
} else {
|
||||
fail += 1
|
||||
}
|
||||
}
|
||||
|
||||
println!("\n═══ 通过 {pass} · 失败 {fail} ═══");
|
||||
client.close();
|
||||
if fail > 0 {
|
||||
std::process::exit(1);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,628 +0,0 @@
|
||||
//! 控制端客户端主体(对齐 `packages/client/src/client.ts` 的 `NodeAgentClient`)。
|
||||
//!
|
||||
//! 复刻行为:连接 → 握手(hello → challenge → auth → auth_ok)→ 泛型能力调用 →
|
||||
//! 事件通知(无 id 的 `event` 方法)→ 断线指数退避重连(1s → 30s,±15% 抖动)。
|
||||
//!
|
||||
//! 并发结构(监督者模式):
|
||||
//! - `connect()` 派生一个 `supervise` 任务,串行「建立+握手 → 等读循环断开信号 → 退避重连」。
|
||||
//! - `establish()` 负责单次连接 + 握手,并派生**读循环**与**写任务**两个子任务。
|
||||
//! - 读循环在流结束时只 `notify` 监督者、绝不回调 `establish`——从而打破
|
||||
//! `establish → spawn(read_loop) → establish` 的类型级递归,让每个 future 都可证 `Send`。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::stream::{SplitSink, SplitStream};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde_json::{json, Value};
|
||||
use tokio::sync::{mpsc, oneshot, Notify};
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
use crate::crypto::{compute_hmac, sign_nonce};
|
||||
use crate::error::{ClientError, ErrorCodes};
|
||||
use crate::tls;
|
||||
use crate::types::*;
|
||||
use crate::ulid::ulid;
|
||||
|
||||
/// WebSocket 流类型(TLS 下为 `MaybeTlsStream`)。
|
||||
type WsStream =
|
||||
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
|
||||
type WsSink = SplitSink<WsStream, Message>;
|
||||
type WsReader = SplitStream<WsStream>;
|
||||
|
||||
/// 控制面方法名(对齐 `protocol/src/methods.ts` 的 `Methods`)。
|
||||
const M_HELLO: &str = "hello";
|
||||
const M_AUTH: &str = "auth";
|
||||
const M_INVOKE: &str = "invoke";
|
||||
const M_EVENT: &str = "event";
|
||||
|
||||
/// 连接状态。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ConnState {
|
||||
Connected,
|
||||
Reconnecting,
|
||||
Closed,
|
||||
}
|
||||
|
||||
/// 对外事件:连接状态变化 / 被控端主动事件 / 日志。
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ClientEvent {
|
||||
State(ConnState),
|
||||
Event(Value),
|
||||
Log(String),
|
||||
}
|
||||
|
||||
/// 事件回调(`Arc` 便于共享给读循环)。
|
||||
pub type EventHandler = Arc<dyn Fn(ClientEvent) + Send + Sync>;
|
||||
|
||||
/// 认证模式。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AuthMode {
|
||||
Psk,
|
||||
Ed25519,
|
||||
}
|
||||
|
||||
/// 客户端选项。
|
||||
#[derive(Clone)]
|
||||
pub struct ClientOptions {
|
||||
/// `ws://` 或 `wss://` 地址。
|
||||
pub url: String,
|
||||
/// psk 预共享密钥。
|
||||
pub key: String,
|
||||
/// 控制端身份标识。
|
||||
pub client_id: String,
|
||||
/// 跳过自签证书校验(TLS 仍加密)。
|
||||
pub insecure: bool,
|
||||
/// 钉住的被控端证书指纹(sha256 hex,64 位)。
|
||||
pub cert_sha256: Option<String>,
|
||||
/// 认证模式。
|
||||
pub auth_mode: AuthMode,
|
||||
/// ed25519 模式:私钥(Base64 PKCS8 DER)。
|
||||
pub private_key: Option<String>,
|
||||
/// 握手超时(默认 10s)。
|
||||
pub handshake_timeout_ms: u64,
|
||||
/// 单次调用默认超时(默认 60s)。
|
||||
pub default_timeout_ms: u64,
|
||||
/// 断线后自动重连。
|
||||
pub auto_reconnect: bool,
|
||||
/// 重连退避上限(默认 30s)。
|
||||
pub max_reconnect_delay_ms: u64,
|
||||
}
|
||||
|
||||
impl Default for ClientOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
url: String::new(),
|
||||
key: String::new(),
|
||||
client_id: String::new(),
|
||||
insecure: false,
|
||||
cert_sha256: None,
|
||||
auth_mode: AuthMode::Psk,
|
||||
private_key: None,
|
||||
handshake_timeout_ms: 10_000,
|
||||
default_timeout_ms: 60_000,
|
||||
auto_reconnect: false,
|
||||
max_reconnect_delay_ms: 30_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 连接元信息(能力清单 / 授权 / 指纹 / 版本)。
|
||||
#[derive(Default, Clone)]
|
||||
struct Meta {
|
||||
capabilities: Vec<CapabilityDescriptor>,
|
||||
authorized: Option<Vec<String>>,
|
||||
peer_cert_fp: Option<String>,
|
||||
agent_version: Option<String>,
|
||||
agent_build: Option<BuildInfo>,
|
||||
}
|
||||
|
||||
/// 跨任务共享的可变状态。
|
||||
///
|
||||
/// 关键约束:`Client` 必须 `Sync`(多处 `&self` 跨 await)。这里的可变字段一律用
|
||||
/// `std` 的 `Mutex`/`RwLock`(天然 `Send + Sync`),并保证**不在持锁期间 await**:
|
||||
/// 发送半部分通过「专用写任务 + mpsc 通道」解耦,`pending` 的增删改全是同步短临界区。
|
||||
struct Shared {
|
||||
/// 到「写任务」的通道;写任务独占 sink 并转发消息(发送不跨 await 持锁)。
|
||||
write: RwLock<Option<mpsc::UnboundedSender<Message>>>,
|
||||
/// 待响应映射(id → oneshot 发送端);同步锁即可。
|
||||
pending: Mutex<HashMap<String, oneshot::Sender<Result<Value, ClientError>>>>,
|
||||
/// 读循环在流结束时通知监督者(「连接已断开」信号)。
|
||||
disconnected: Notify,
|
||||
meta: RwLock<Meta>,
|
||||
state: RwLock<ConnState>,
|
||||
reconnect_attempts: AtomicU32,
|
||||
stopped: AtomicBool,
|
||||
handler: RwLock<Option<EventHandler>>,
|
||||
}
|
||||
|
||||
/// 控制端客户端。
|
||||
#[derive(Clone)]
|
||||
pub struct Client {
|
||||
opts: Arc<ClientOptions>,
|
||||
shared: Arc<Shared>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
pub fn new(opts: ClientOptions) -> Self {
|
||||
Self {
|
||||
opts: Arc::new(opts),
|
||||
shared: Arc::new(Shared {
|
||||
write: RwLock::new(None),
|
||||
pending: Mutex::new(HashMap::new()),
|
||||
disconnected: Notify::new(),
|
||||
meta: RwLock::new(Meta::default()),
|
||||
state: RwLock::new(ConnState::Closed),
|
||||
reconnect_attempts: AtomicU32::new(0),
|
||||
stopped: AtomicBool::new(false),
|
||||
handler: RwLock::new(None),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置事件回调。
|
||||
pub fn set_handler(&self, handler: EventHandler) {
|
||||
*self.shared.handler.write().unwrap() = Some(handler);
|
||||
}
|
||||
|
||||
/// 建立连接并完成握手,返回能力清单。
|
||||
/// 派生监督任务后,仅等待**首次**握手结果(失败即返回错误,不自动重连)。
|
||||
pub async fn connect(&self) -> Result<Vec<CapabilityDescriptor>, ClientError> {
|
||||
self.shared.stopped.store(false, Ordering::SeqCst);
|
||||
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let this = self.clone();
|
||||
tokio::spawn(async move {
|
||||
this.supervise(Some(tx)).await;
|
||||
});
|
||||
|
||||
match rx.await {
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(ClientError::offline("连接监督任务异常退出")),
|
||||
}
|
||||
}
|
||||
|
||||
/// 调用一项能力(`args` 为 JSON 对象),用默认超时。
|
||||
pub async fn invoke(
|
||||
&self,
|
||||
capability: &str,
|
||||
args: Value,
|
||||
) -> Result<InvokeResult, ClientError> {
|
||||
self.invoke_with_timeout(capability, args, self.opts.default_timeout_ms)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 调用一项能力并显式指定 RPC 超时(长任务如 `app.install` / 长命令用)。
|
||||
pub async fn invoke_with_timeout(
|
||||
&self,
|
||||
capability: &str,
|
||||
args: Value,
|
||||
timeout_ms: u64,
|
||||
) -> Result<InvokeResult, ClientError> {
|
||||
let params = json!({ "capability": capability, "args": args });
|
||||
let value = self.request(M_INVOKE, params, timeout_ms).await?;
|
||||
serde_json::from_value::<InvokeResult>(value)
|
||||
.map_err(|e| ClientError::new(ErrorCodes::PARSE_ERROR, format!("解析调用结果失败: {e}")))
|
||||
}
|
||||
|
||||
/// 已获取的能力清单。
|
||||
pub fn list_capabilities(&self) -> Vec<CapabilityDescriptor> {
|
||||
self.shared.meta.read().unwrap().capabilities.clone()
|
||||
}
|
||||
|
||||
/// ed25519 模式下本次被授权的能力;psk 模式返回 `None`。
|
||||
pub fn list_authorized(&self) -> Option<Vec<String>> {
|
||||
self.shared.meta.read().unwrap().authorized.clone()
|
||||
}
|
||||
|
||||
/// 本次连接观测到的对端证书指纹(sha256 hex)。
|
||||
pub fn peer_cert_fingerprint(&self) -> Option<String> {
|
||||
self.shared.meta.read().unwrap().peer_cert_fp.clone()
|
||||
}
|
||||
|
||||
/// 被控端语义化版本。
|
||||
pub fn agent_version(&self) -> Option<String> {
|
||||
self.shared.meta.read().unwrap().agent_version.clone()
|
||||
}
|
||||
|
||||
/// 被控端构建信息。
|
||||
pub fn agent_build(&self) -> Option<BuildInfo> {
|
||||
self.shared.meta.read().unwrap().agent_build.clone()
|
||||
}
|
||||
|
||||
/// 当前连接状态。
|
||||
pub fn state(&self) -> ConnState {
|
||||
*self.shared.state.read().unwrap()
|
||||
}
|
||||
|
||||
/// 主动关闭连接(停止自动重连)。
|
||||
pub fn close(&self) {
|
||||
self.shared.stopped.store(true, Ordering::SeqCst);
|
||||
// 丢弃发送通道 → 写任务退出 → sink 释放 → 连接关闭;并唤醒监督者及时退出。
|
||||
*self.shared.write.write().unwrap() = None;
|
||||
self.shared.disconnected.notify_one();
|
||||
self.set_state(ConnState::Closed);
|
||||
self.emit_log("主动关闭");
|
||||
}
|
||||
|
||||
// ---------- 内部 ----------
|
||||
|
||||
/// 监督循环:反复「建立+握手 → 等断开 → 退避重连」。
|
||||
/// 首次握手结果经 `first_tx` 回传给 `connect()`;首次失败直接返回,不重连。
|
||||
async fn supervise(
|
||||
&self,
|
||||
first_tx: Option<oneshot::Sender<Result<Vec<CapabilityDescriptor>, ClientError>>>,
|
||||
) {
|
||||
let mut first_tx = first_tx;
|
||||
|
||||
loop {
|
||||
let established = match self.establish().await {
|
||||
Ok(caps) => {
|
||||
self.shared.reconnect_attempts.store(0, Ordering::SeqCst);
|
||||
self.set_state(ConnState::Connected);
|
||||
// 首次成功:把能力清单回传给 `connect()` 调用方。
|
||||
if let Some(tx) = first_tx.take() {
|
||||
let _ = tx.send(Ok(caps));
|
||||
}
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
// 首次失败:直接报错返回(不做自动重连,对齐 Node 行为)。
|
||||
if let Some(tx) = first_tx.take() {
|
||||
let _ = tx.send(Err(e));
|
||||
return;
|
||||
}
|
||||
self.emit_log(format!("重连失败: {e}"));
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
// 本次建立成功才有读循环会发「断开」信号;失败则直接进入退避。
|
||||
if established {
|
||||
self.shared.disconnected.notified().await;
|
||||
}
|
||||
|
||||
if self.shared.stopped.load(Ordering::SeqCst) {
|
||||
self.set_state(ConnState::Closed);
|
||||
return;
|
||||
}
|
||||
if !self.opts.auto_reconnect {
|
||||
self.set_state(ConnState::Closed);
|
||||
return;
|
||||
}
|
||||
|
||||
let delay = self.next_backoff_delay();
|
||||
self.set_state(ConnState::Reconnecting);
|
||||
self.emit_log(format!("将在 {}ms 后重连", delay.as_millis()));
|
||||
tokio::time::sleep(delay).await;
|
||||
|
||||
if self.shared.stopped.load(Ordering::SeqCst) {
|
||||
self.set_state(ConnState::Closed);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 建立一次连接:TCP/TLS/WS → split → 读循环 + 写任务 → 协议握手。
|
||||
async fn establish(&self) -> Result<Vec<CapabilityDescriptor>, ClientError> {
|
||||
// TLS 下抓取对端证书指纹(自签 + insecure 时留下的中间人空档,靠钉住兜底)。
|
||||
let captured: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
|
||||
|
||||
let (ws, _resp) = if self.opts.url.starts_with("wss://") {
|
||||
let config = tls::build_client_config(
|
||||
self.opts.insecure,
|
||||
self.opts.cert_sha256.clone(),
|
||||
captured.clone(),
|
||||
)?;
|
||||
let connector = tokio_tungstenite::Connector::Rustls(Arc::new(config));
|
||||
tokio_tungstenite::connect_async_tls_with_config(
|
||||
self.opts.url.as_str(),
|
||||
None,
|
||||
false,
|
||||
Some(connector),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| self.map_connect_error(e))?
|
||||
} else {
|
||||
tokio_tungstenite::connect_async(self.opts.url.as_str())
|
||||
.await
|
||||
.map_err(|e| self.map_connect_error(e))?
|
||||
};
|
||||
|
||||
let peer_fp = captured.lock().unwrap().clone();
|
||||
|
||||
let (sink, stream) = ws.split();
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
*self.shared.write.write().unwrap() = Some(tx);
|
||||
|
||||
// 写任务独占 sink;读循环独立跑;二者都只持有局部 Send 类型。
|
||||
tokio::spawn(async move { writer_task(sink, rx).await });
|
||||
let this = self.clone();
|
||||
tokio::spawn(async move {
|
||||
this.read_loop(stream).await;
|
||||
});
|
||||
|
||||
// 握手;失败则关闭连接(丢弃发送通道 → 写任务退出 → sink 释放 → 读循环结束)。
|
||||
let result = self.handshake(peer_fp).await;
|
||||
if result.is_err() {
|
||||
*self.shared.write.write().unwrap() = None;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// 握手:hello → challenge → auth → auth_ok;成功后写回元信息。
|
||||
async fn handshake(&self, peer_fp: Option<String>) -> Result<Vec<CapabilityDescriptor>, ClientError> {
|
||||
let challenge: ChallengeParams = {
|
||||
let value = self
|
||||
.request(
|
||||
M_HELLO,
|
||||
json!({ "protocol": PROTOCOL_VERSION, "client_id": self.opts.client_id }),
|
||||
self.opts.handshake_timeout_ms,
|
||||
)
|
||||
.await?;
|
||||
serde_json::from_value(value).map_err(|e| {
|
||||
ClientError::new(ErrorCodes::PARSE_ERROR, format!("解析 challenge 失败: {e}"))
|
||||
})?
|
||||
};
|
||||
|
||||
let auth_params = match self.opts.auth_mode {
|
||||
AuthMode::Ed25519 => {
|
||||
let private_key = self.opts.private_key.as_deref().ok_or_else(|| {
|
||||
ClientError::new(ErrorCodes::AUTH_FAILED, "ed25519 模式需要私钥,请先 keygen")
|
||||
})?;
|
||||
let signature = sign_nonce(private_key, &challenge.nonce).map_err(|e| {
|
||||
ClientError::new(ErrorCodes::AUTH_FAILED, format!("签名失败: {e}"))
|
||||
})?;
|
||||
json!({ "client_id": self.opts.client_id, "nonce": challenge.nonce, "signature": signature })
|
||||
}
|
||||
AuthMode::Psk => {
|
||||
let hmac = compute_hmac(&self.opts.key, &challenge.nonce);
|
||||
json!({ "client_id": self.opts.client_id, "nonce": challenge.nonce, "hmac": hmac })
|
||||
}
|
||||
};
|
||||
|
||||
let auth_ok: AuthOkParams = {
|
||||
let value = self
|
||||
.request(M_AUTH, auth_params, self.opts.handshake_timeout_ms)
|
||||
.await?;
|
||||
serde_json::from_value(value).map_err(|e| {
|
||||
ClientError::new(ErrorCodes::PARSE_ERROR, format!("解析 auth_ok 失败: {e}"))
|
||||
})?
|
||||
};
|
||||
|
||||
// v21 交叉确认:握手抓到的实际证书 vs 服务端自报 —— 不一致说明自报被篡改/冒充。
|
||||
if let (Some(fp), Some(self_reported)) = (
|
||||
peer_fp.as_ref(),
|
||||
auth_ok
|
||||
.build
|
||||
.as_ref()
|
||||
.and_then(|b| b.cert_sha256.as_ref()),
|
||||
) {
|
||||
if fp != self_reported {
|
||||
return Err(ClientError::cert_mismatch(
|
||||
"被控端自报的证书指纹与实际握手证书不一致,拒绝使用(可能被冒充)",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let capabilities = auth_ok.capabilities.clone();
|
||||
let authorized_len = auth_ok.authorized.as_ref().map(|a| a.len());
|
||||
{
|
||||
let mut meta = self.shared.meta.write().unwrap();
|
||||
meta.capabilities = auth_ok.capabilities.clone();
|
||||
meta.authorized = auth_ok.authorized.clone();
|
||||
meta.peer_cert_fp = peer_fp;
|
||||
meta.agent_version = auth_ok.agent_version.clone();
|
||||
meta.agent_build = auth_ok.build.clone();
|
||||
}
|
||||
|
||||
let capabilities_len = capabilities.len();
|
||||
let label = if auth_ok.auth_mode.as_deref() == Some("ed25519") {
|
||||
format!("ed25519,授权 {}/{capabilities_len} 项能力", authorized_len.unwrap_or(0))
|
||||
} else {
|
||||
format!("psk,被控端声明 {capabilities_len} 项能力")
|
||||
};
|
||||
self.emit_log(format!("握手成功({label})"));
|
||||
|
||||
Ok(capabilities)
|
||||
}
|
||||
|
||||
/// 底层请求(带超时与 id 匹配)。
|
||||
async fn request(
|
||||
&self,
|
||||
method: &str,
|
||||
params: Value,
|
||||
timeout_ms: u64,
|
||||
) -> Result<Value, ClientError> {
|
||||
let id = ulid();
|
||||
let (tx, rx) = oneshot::channel();
|
||||
|
||||
self.shared.pending.lock().unwrap().insert(id.clone(), tx);
|
||||
|
||||
let req = RpcRequest {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id: id.clone(),
|
||||
method: method.to_string(),
|
||||
params: Some(params),
|
||||
};
|
||||
let payload = serde_json::to_string(&req)
|
||||
.map_err(|e| ClientError::new(ErrorCodes::PARSE_ERROR, e.to_string()))?;
|
||||
|
||||
{
|
||||
let sender = self
|
||||
.shared
|
||||
.write
|
||||
.read()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.cloned()
|
||||
.ok_or_else(|| ClientError::offline("连接未就绪"))?;
|
||||
// 同步发送(不持锁 await):消息经通道转交写任务。
|
||||
sender
|
||||
.send(Message::Text(payload.into()))
|
||||
.map_err(|e| ClientError::offline(format!("发送失败: {e}")))?;
|
||||
}
|
||||
|
||||
match tokio::time::timeout(Duration::from_millis(timeout_ms), rx).await {
|
||||
Err(_) => {
|
||||
self.shared.pending.lock().unwrap().remove(&id);
|
||||
Err(ClientError::timeout(format!(
|
||||
"调用超时 ({timeout_ms}ms): {method}"
|
||||
)))
|
||||
}
|
||||
Ok(Err(_)) => Err(ClientError::offline("连接已断开")),
|
||||
Ok(Ok(result)) => result,
|
||||
}
|
||||
}
|
||||
|
||||
/// 读循环:分发响应与事件;流结束后清空 pending 并通知监督者。
|
||||
async fn read_loop(&self, mut stream: WsReader) {
|
||||
while let Some(Ok(msg)) = stream.next().await {
|
||||
if let Err(e) = self.handle_message(msg).await {
|
||||
self.emit_log(format!("消息处理失败: {e}"));
|
||||
}
|
||||
}
|
||||
// 流结束(对端关闭 / 网络断开)→ 清空 pending,由监督者决定是否重连。
|
||||
self.reject_all_pending();
|
||||
self.shared.disconnected.notify_one();
|
||||
}
|
||||
|
||||
/// 处理单条消息。
|
||||
async fn handle_message(&self, msg: Message) -> Result<(), ClientError> {
|
||||
// WebSocket ping 帧:回 pong(保持连接)。
|
||||
if msg.is_ping() {
|
||||
let pong = msg.into_data();
|
||||
if let Some(sender) = self.shared.write.read().unwrap().as_ref() {
|
||||
let _ = sender.send(Message::Pong(pong));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if !msg.is_text() && !msg.is_binary() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let text = match msg {
|
||||
Message::Text(t) => t.to_string(),
|
||||
Message::Binary(b) => match String::from_utf8(b.to_vec()) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return Ok(()),
|
||||
},
|
||||
_ => return Ok(()),
|
||||
};
|
||||
|
||||
let value: Value = match serde_json::from_str(&text) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
self.emit_log(format!("收到无法解析的消息: {}", &text[..text.len().min(200)]));
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
// 有 id → 响应;无 id → 通知(事件)。
|
||||
if let Some(id) = value.get("id").and_then(|v| v.as_str()) {
|
||||
if let Some(tx) = self.shared.pending.lock().unwrap().remove(id) {
|
||||
let result = self.parse_response(&value);
|
||||
let _ = tx.send(result);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let method = value.get("method").and_then(|m| m.as_str()).unwrap_or("");
|
||||
if method == M_EVENT {
|
||||
if let Some(params) = value.get("params").cloned() {
|
||||
self.emit_event(params);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 把响应消息解析为 `Result<Value, ClientError>`。
|
||||
fn parse_response(&self, value: &Value) -> Result<Value, ClientError> {
|
||||
if is_error_response(value) {
|
||||
let err = value.get("error").cloned().unwrap_or(Value::Null);
|
||||
let code = err
|
||||
.get("code")
|
||||
.and_then(|c| c.as_i64())
|
||||
.unwrap_or(ErrorCodes::PARSE_ERROR);
|
||||
let name = err
|
||||
.get("name")
|
||||
.and_then(|n| n.as_str())
|
||||
.unwrap_or("E_UNKNOWN")
|
||||
.to_string();
|
||||
let message = err
|
||||
.get("message")
|
||||
.and_then(|m| m.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
Err(ClientError {
|
||||
code,
|
||||
name,
|
||||
message,
|
||||
data: err.get("data").cloned(),
|
||||
})
|
||||
} else {
|
||||
value
|
||||
.get("result")
|
||||
.cloned()
|
||||
.ok_or_else(|| ClientError::new(ErrorCodes::PARSE_ERROR, "响应缺少 result 字段"))
|
||||
}
|
||||
}
|
||||
|
||||
/// 指数退避 + ±15% 抖动(1s → 30s)。
|
||||
fn next_backoff_delay(&self) -> Duration {
|
||||
let attempts = self.shared.reconnect_attempts.fetch_add(1, Ordering::SeqCst);
|
||||
let base = (1000u64 << attempts).min(self.opts.max_reconnect_delay_ms);
|
||||
let jitter = 0.85 + rand::random::<f64>() * 0.3; // 0.85..=1.15
|
||||
Duration::from_millis((base as f64 * jitter) as u64)
|
||||
}
|
||||
|
||||
fn reject_all_pending(&self) {
|
||||
let drained: Vec<oneshot::Sender<Result<Value, ClientError>>> = {
|
||||
let mut map = self.shared.pending.lock().unwrap();
|
||||
map.drain().map(|(_, tx)| tx).collect()
|
||||
};
|
||||
for tx in drained {
|
||||
let _ = tx.send(Err(ClientError::offline("连接已断开")));
|
||||
}
|
||||
}
|
||||
|
||||
fn map_connect_error(&self, e: tokio_tungstenite::tungstenite::Error) -> ClientError {
|
||||
let msg = e.to_string();
|
||||
if msg.contains("证书指纹不匹配") {
|
||||
ClientError::cert_mismatch(msg)
|
||||
} else {
|
||||
ClientError::offline(format!("无法连接被控端: {msg}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn set_state(&self, state: ConnState) {
|
||||
*self.shared.state.write().unwrap() = state;
|
||||
if let Some(h) = self.shared.handler.read().unwrap().as_ref() {
|
||||
h(ClientEvent::State(state));
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_event(&self, params: Value) {
|
||||
if let Some(h) = self.shared.handler.read().unwrap().as_ref() {
|
||||
h(ClientEvent::Event(params));
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_log(&self, msg: impl Into<String>) {
|
||||
if let Some(h) = self.shared.handler.read().unwrap().as_ref() {
|
||||
h(ClientEvent::Log(msg.into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 写任务:独占 sink,把 mpsc 通道里的消息逐条发出去。
|
||||
/// 通道关闭(`close()` 丢弃 sender)或发送失败时退出,sink 随之释放。
|
||||
async fn writer_task(mut sink: WsSink, mut rx: mpsc::UnboundedReceiver<Message>) {
|
||||
while let Some(msg) = rx.recv().await {
|
||||
if sink.send(msg).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
//! 密码学工具:HMAC / ed25519 签名 / 证书指纹 / nonce。
|
||||
//! 逐一对齐 `packages/protocol/src/{hmac,ed25519}.ts` 与 `net.ts`。
|
||||
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use base64::Engine as _;
|
||||
use hmac::{Hmac, KeyInit, Mac};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// 计算 HMAC-SHA256,返回 Base64(对齐 hmac.ts 的 `computeHmac`)。
|
||||
/// 密钥为预共享密钥字符串,nonce 以 UTF-8 编码。
|
||||
pub fn compute_hmac(key: &str, nonce: &str) -> String {
|
||||
// HMAC 接受任意长度 key,此处不可能失败,unwrap 仅为类型收敛。
|
||||
let mut mac = HmacSha256::new_from_slice(key.as_bytes()).expect("HMAC 接受任意长度 key");
|
||||
mac.update(nonce.as_bytes());
|
||||
STANDARD.encode(mac.finalize().into_bytes())
|
||||
}
|
||||
|
||||
/// 生成随机 nonce(32 字节,Base64),对齐 `generateNonce`。
|
||||
pub fn generate_nonce() -> String {
|
||||
let mut bytes = [0u8; 32];
|
||||
for b in bytes.iter_mut() {
|
||||
*b = rand::random::<u8>();
|
||||
}
|
||||
STANDARD.encode(bytes)
|
||||
}
|
||||
|
||||
/// 证书指纹:sha256(DER) 的十六进制小写 64 位(对齐 net.ts 的 `certFingerprint`)。
|
||||
pub fn cert_fingerprint(der: &[u8]) -> String {
|
||||
let digest = Sha256::digest(der);
|
||||
digest.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// 对 nonce 原文做 ed25519 签名,返回 Base64(对齐 ed25519.ts 的 `signNonce`)。
|
||||
/// 私钥为 Base64(PKCS8 DER)。
|
||||
pub fn sign_nonce(private_key_b64: &str, nonce: &str) -> Result<String, String> {
|
||||
use ed25519_dalek::pkcs8::DecodePrivateKey;
|
||||
use ed25519_dalek::Signer;
|
||||
|
||||
let der = STANDARD
|
||||
.decode(private_key_b64)
|
||||
.map_err(|e| format!("私钥 Base64 解码失败: {e}"))?;
|
||||
let signing_key = ed25519_dalek::SigningKey::from_pkcs8_der(&der)
|
||||
.map_err(|e| format!("PKCS8 私钥解析失败: {e}"))?;
|
||||
let sig = signing_key.sign(nonce.as_bytes());
|
||||
Ok(STANDARD.encode(sig.to_bytes()))
|
||||
}
|
||||
|
||||
/// 验签(对齐 `verifyNonce`);任何异常均视为失败(不抛出)。
|
||||
pub fn verify_nonce(public_key_b64: &str, nonce: &str, signature_b64: &str) -> bool {
|
||||
use ed25519_dalek::pkcs8::DecodePublicKey;
|
||||
use ed25519_dalek::Verifier;
|
||||
|
||||
let Ok(pk_der) = STANDARD.decode(public_key_b64) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(vk) = ed25519_dalek::VerifyingKey::from_public_key_der(&pk_der) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(sig_bytes) = STANDARD.decode(signature_b64) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(sig) = ed25519_dalek::Signature::from_slice(&sig_bytes) else {
|
||||
return false;
|
||||
};
|
||||
vk.verify(nonce.as_bytes(), &sig).is_ok()
|
||||
}
|
||||
|
||||
/// 公钥指纹(SHA-256 前 16 位 hex),对齐 `keyId`。
|
||||
pub fn key_id(public_key_b64: &str) -> String {
|
||||
let der = STANDARD.decode(public_key_b64).unwrap_or_default();
|
||||
let digest = Sha256::digest(&der);
|
||||
// 前 16 个 hex 字符 = 前 8 字节。
|
||||
digest.iter().take(8).map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use ed25519_dalek::pkcs8::{EncodePrivateKey, EncodePublicKey};
|
||||
|
||||
fn gen_keypair() -> (String, String) {
|
||||
let sk = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]);
|
||||
let pk = sk.verifying_key();
|
||||
(
|
||||
STANDARD.encode(pk.to_public_key_der().expect("编码公钥").as_bytes()),
|
||||
STANDARD.encode(sk.to_pkcs8_der().expect("编码私钥").as_bytes()),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hmac_matches_known_vector() {
|
||||
// 与 Node 版对拍的固定向量:HMAC-SHA256("key", "nonce")。
|
||||
let out = compute_hmac("secret", "hello");
|
||||
// 用独立的 hmac 重算并比对,避免自证。
|
||||
let mut mac = HmacSha256::new_from_slice(b"secret").unwrap();
|
||||
mac.update(b"hello");
|
||||
let expect = STANDARD.encode(mac.finalize().into_bytes());
|
||||
assert_eq!(out, expect);
|
||||
// Base64(HMAC-SHA256("secret","hello")) 的已知值(由 RFC 测试向量推得,供跨端核对)。
|
||||
assert_eq!(out, "iKqz7ejTrflNJquQ07r9SiCDBww7zOnAFO4EpEOEfAs=");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sign_and_verify_roundtrip() {
|
||||
let (pk, sk) = gen_keypair();
|
||||
let nonce = generate_nonce();
|
||||
let sig = sign_nonce(&sk, &nonce).unwrap();
|
||||
assert!(verify_nonce(&pk, &nonce, &sig));
|
||||
assert!(!verify_nonce(&pk, "tampered", &sig));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_id_is_16_hex() {
|
||||
let (pk, _sk) = gen_keypair();
|
||||
let id = key_id(&pk);
|
||||
assert_eq!(id.len(), 16);
|
||||
assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cert_fingerprint_sha256_der() {
|
||||
// 空 DER 的 sha256 是已知常量。
|
||||
assert_eq!(
|
||||
cert_fingerprint(b""),
|
||||
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
//! 局域网发现(对齐 `packages/client/src/discovery.ts`)。
|
||||
//!
|
||||
//! 只监听被控端 UDP 广播、不发送任何数据,因此不引入额外暴露面。
|
||||
//! M1 实现基础解析;v22 最小化广播的 HMAC 验签留待后续(需对齐 JSON 序列化顺序)。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::net::UdpSocket;
|
||||
|
||||
/// 发现到的被控端。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DiscoveredNode {
|
||||
pub node_id: String,
|
||||
/// 可达地址:取报文来源 IP(一定可达)。
|
||||
pub host: String,
|
||||
/// 被控端自报地址(多网卡时可能与来源不同,仅供参考)。
|
||||
pub advertised_host: String,
|
||||
pub port: u16,
|
||||
pub tls: bool,
|
||||
pub auth_mode: String,
|
||||
pub input_enabled: bool,
|
||||
pub platform: String,
|
||||
/// v22:详情是否通过 HMAC 验签。false = 明文广播,仅作展示、不可当凭据。
|
||||
pub authenticated: bool,
|
||||
/// 最近一次收到广播的时间(Unix 毫秒)。
|
||||
pub last_seen: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawBeacon {
|
||||
service: Option<String>,
|
||||
node_id: Option<String>,
|
||||
host: Option<String>,
|
||||
port: Option<u16>,
|
||||
tls: Option<bool>,
|
||||
auth_mode: Option<String>,
|
||||
input_enabled: Option<bool>,
|
||||
platform: Option<String>,
|
||||
minimal: Option<bool>,
|
||||
#[serde(default)]
|
||||
detail: Option<BeaconDetail>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct BeaconDetail {
|
||||
host: Option<String>,
|
||||
port: Option<u16>,
|
||||
tls: Option<bool>,
|
||||
auth_mode: Option<String>,
|
||||
input_enabled: Option<bool>,
|
||||
platform: Option<String>,
|
||||
}
|
||||
|
||||
/// 默认发现端口(对齐 methods.ts 的 `DEFAULT_DISCOVERY_PORT`)。
|
||||
pub const DEFAULT_DISCOVERY_PORT: u16 = 8766;
|
||||
|
||||
/// 默认被控端端口。
|
||||
const DEFAULT_AGENT_PORT: u16 = 8765;
|
||||
|
||||
/// 监听一段时间后返回发现的设备(对齐 `discoverOnce`)。
|
||||
pub async fn discover_once(wait_ms: u64, port: u16) -> Result<Vec<DiscoveredNode>, String> {
|
||||
let socket = UdpSocket::bind(("0.0.0.0", port))
|
||||
.await
|
||||
.map_err(|e| format!("发现监听失败(UDP {port}): {e}"))?;
|
||||
|
||||
let mut nodes: HashMap<String, DiscoveredNode> = HashMap::new();
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_millis(wait_ms);
|
||||
let mut buf = [0u8; 2048];
|
||||
|
||||
loop {
|
||||
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||
if remaining.is_zero() {
|
||||
break;
|
||||
}
|
||||
match tokio::time::timeout(remaining, socket.recv_from(&mut buf)).await {
|
||||
Ok(Ok((len, src))) => {
|
||||
if let Some(node) = parse_beacon(&buf[..len], src) {
|
||||
nodes.insert(node.node_id.clone(), node);
|
||||
}
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
|
||||
let now = now_ms();
|
||||
let mut list: Vec<DiscoveredNode> = nodes.into_values().collect();
|
||||
// 过滤 TTL 过期项(30s),按最近出现排序。
|
||||
list.retain(|n| now - n.last_seen <= 30_000);
|
||||
list.sort_by_key(|n| std::cmp::Reverse(n.last_seen));
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
fn parse_beacon(data: &[u8], src: std::net::SocketAddr) -> Option<DiscoveredNode> {
|
||||
let raw: RawBeacon = serde_json::from_slice(data).ok()?;
|
||||
if raw.service.as_deref() != Some("nodeagent") {
|
||||
return None;
|
||||
}
|
||||
let node_id = raw.node_id?;
|
||||
|
||||
// v22 最小化广播:顶层只剩存在性,真实信息在 detail 里。
|
||||
let detail = raw.minimal.unwrap_or(false).then_some(raw.detail).flatten();
|
||||
|
||||
Some(DiscoveredNode {
|
||||
node_id,
|
||||
// 地址永远取「报文来源 IP」—— 一定可达。
|
||||
host: src.ip().to_string(),
|
||||
advertised_host: detail
|
||||
.as_ref()
|
||||
.and_then(|d| d.host.clone())
|
||||
.or(raw.host)
|
||||
.unwrap_or_else(|| src.ip().to_string()),
|
||||
port: detail
|
||||
.as_ref()
|
||||
.and_then(|d| d.port)
|
||||
.or(raw.port)
|
||||
.unwrap_or(DEFAULT_AGENT_PORT),
|
||||
tls: detail.as_ref().and_then(|d| d.tls).or(raw.tls).unwrap_or(true),
|
||||
auth_mode: detail
|
||||
.as_ref()
|
||||
.and_then(|d| d.auth_mode.clone())
|
||||
.or(raw.auth_mode)
|
||||
.unwrap_or_else(|| "psk".to_string()),
|
||||
input_enabled: detail
|
||||
.as_ref()
|
||||
.and_then(|d| d.input_enabled)
|
||||
.or(raw.input_enabled)
|
||||
.unwrap_or(false),
|
||||
platform: detail
|
||||
.as_ref()
|
||||
.and_then(|d| d.platform.clone())
|
||||
.or(raw.platform)
|
||||
.unwrap_or_else(|| "unknown".to_string()),
|
||||
authenticated: false,
|
||||
last_seen: now_ms(),
|
||||
})
|
||||
}
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
//! 协议错误码与客户端错误类型(对齐 `packages/protocol/src/errors.ts`)。
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// 协议错误码常量(对齐 errors.ts 的 `ErrorCodes`)。
|
||||
#[allow(non_snake_case)]
|
||||
pub mod ErrorCodes {
|
||||
pub const PARSE_ERROR: i64 = -32700;
|
||||
pub const INVALID_REQUEST: i64 = -32600;
|
||||
pub const METHOD_NOT_FOUND: i64 = -32601;
|
||||
pub const PARAM_INVALID: i64 = -32602;
|
||||
pub const AUTH_FAILED: i64 = -32401;
|
||||
pub const AUTH_REQUIRED: i64 = -32402;
|
||||
pub const CAPABILITY_NOT_FOUND: i64 = -32403;
|
||||
pub const CAPABILITY_DISABLED: i64 = -32404;
|
||||
pub const NODE_OFFLINE: i64 = -32405;
|
||||
pub const ACL_DENIED: i64 = -32406;
|
||||
pub const RATE_LIMITED: i64 = -32407;
|
||||
pub const TIMEOUT: i64 = -32412;
|
||||
pub const EXECUTION_FAILED: i64 = -32419;
|
||||
pub const PROTOCOL_MISMATCH: i64 = -32420;
|
||||
pub const UNSUPPORTED_PLATFORM: i64 = -32421;
|
||||
/// v21:被控端证书指纹与控制端钉住的不一致(可能被冒充)。
|
||||
pub const CERT_MISMATCH: i64 = -32422;
|
||||
}
|
||||
|
||||
/// 错误码 → 名称。
|
||||
pub fn error_name(code: i64) -> &'static str {
|
||||
match code {
|
||||
ErrorCodes::PARSE_ERROR => "E_PARSE_ERROR",
|
||||
ErrorCodes::INVALID_REQUEST => "E_INVALID_REQUEST",
|
||||
ErrorCodes::METHOD_NOT_FOUND => "E_METHOD_NOT_FOUND",
|
||||
ErrorCodes::PARAM_INVALID => "E_PARAM_INVALID",
|
||||
ErrorCodes::AUTH_FAILED => "E_AUTH_FAILED",
|
||||
ErrorCodes::AUTH_REQUIRED => "E_AUTH_REQUIRED",
|
||||
ErrorCodes::CAPABILITY_NOT_FOUND => "E_CAPABILITY_NOT_FOUND",
|
||||
ErrorCodes::CAPABILITY_DISABLED => "E_CAPABILITY_DISABLED",
|
||||
ErrorCodes::NODE_OFFLINE => "E_NODE_OFFLINE",
|
||||
ErrorCodes::ACL_DENIED => "E_ACL_DENIED",
|
||||
ErrorCodes::RATE_LIMITED => "E_RATE_LIMITED",
|
||||
ErrorCodes::TIMEOUT => "E_TIMEOUT",
|
||||
ErrorCodes::EXECUTION_FAILED => "E_EXECUTION_FAILED",
|
||||
ErrorCodes::PROTOCOL_MISMATCH => "E_PROTOCOL_MISMATCH",
|
||||
ErrorCodes::UNSUPPORTED_PLATFORM => "E_UNSUPPORTED_PLATFORM",
|
||||
ErrorCodes::CERT_MISMATCH => "E_CERT_MISMATCH",
|
||||
_ => "E_UNKNOWN",
|
||||
}
|
||||
}
|
||||
|
||||
/// 客户端错误(携带协议错误码,对齐 client.ts 的 `ClientError`)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ClientError {
|
||||
pub code: i64,
|
||||
pub name: String,
|
||||
pub message: String,
|
||||
pub data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl ClientError {
|
||||
pub fn new(code: i64, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code,
|
||||
name: error_name(code).to_string(),
|
||||
message: message.into(),
|
||||
data: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_data(mut self, data: serde_json::Value) -> Self {
|
||||
self.data = Some(data);
|
||||
self
|
||||
}
|
||||
|
||||
/// 被控端离线/不可达。
|
||||
pub fn offline(message: impl Into<String>) -> Self {
|
||||
Self::new(ErrorCodes::NODE_OFFLINE, message)
|
||||
}
|
||||
|
||||
/// 调用超时。
|
||||
pub fn timeout(message: impl Into<String>) -> Self {
|
||||
Self::new(ErrorCodes::TIMEOUT, message)
|
||||
}
|
||||
|
||||
/// 证书指纹不一致。
|
||||
pub fn cert_mismatch(message: impl Into<String>) -> Self {
|
||||
Self::new(ErrorCodes::CERT_MISMATCH, message)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ClientError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}: {}", self.name, self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ClientError {}
|
||||
@@ -1,34 +0,0 @@
|
||||
//! nodeagent 控制端 Rust 客户端。
|
||||
//!
|
||||
//! 行为对齐 `packages/client`(Node 版),复刻:
|
||||
//! - 握手 hello → challenge → auth → auth_ok(psk HMAC / ed25519 签名)
|
||||
//! - 泛型能力调用 invoke(带超时与 id 匹配)
|
||||
//! - 事件通知(无 id 的 JSON-RPC notification,`event` 方法)
|
||||
//! - 断线指数退避重连(1s → 30s,±15% 抖动)
|
||||
//! - TLS 自签证书 + 指纹钉住(v21,E_CERT_MISMATCH)
|
||||
|
||||
pub mod client;
|
||||
pub mod crypto;
|
||||
pub mod discovery;
|
||||
pub mod error;
|
||||
pub mod tls;
|
||||
pub mod types;
|
||||
pub mod ulid;
|
||||
|
||||
pub use client::{Client, ClientEvent, ClientOptions, ConnState};
|
||||
pub use error::{ClientError, ErrorCodes};
|
||||
pub use types::{CapabilityDescriptor, InvokeResult};
|
||||
|
||||
#[cfg(test)]
|
||||
mod send_sync_checks {
|
||||
use super::*;
|
||||
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
|
||||
#[test]
|
||||
fn client_is_send_sync() {
|
||||
assert_send_sync::<Client>();
|
||||
assert_send_sync::<ClientOptions>();
|
||||
assert_send_sync::<ClientError>();
|
||||
}
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
//! TLS 配置:自签证书 + 证书指纹钉住(对齐 client.ts 的 TLS 处理,v21)。
|
||||
//!
|
||||
//! nodeagent 的安全模型:TLS 只负责**传输加密**,身份认证由应用层
|
||||
//! HMAC / ed25519 握手兜底。因此 `insecure` 跳过的是「证书链是否由可信 CA
|
||||
//! 签发」,TLS 层的签名仍真正验签(委托 rustls)。
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
|
||||
use rustls::crypto::{
|
||||
verify_tls12_signature, verify_tls13_signature, CryptoProvider, WebPkiSupportedAlgorithms,
|
||||
};
|
||||
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
|
||||
use rustls::{ClientConfig, DigitallySignedStruct, Error, SignatureScheme};
|
||||
|
||||
use crate::crypto::cert_fingerprint;
|
||||
use crate::error::{ClientError, ErrorCodes};
|
||||
|
||||
/// 构建 rustls `ClientConfig`。
|
||||
///
|
||||
/// - `insecure = false`:webpki 严格链验证(自签证书会被拒,与 Node `rejectUnauthorized=true` 一致)。
|
||||
/// - `insecure = true`:接受任意证书,但抓取对端证书 DER 的 sha256 指纹。
|
||||
/// - `expect_fp`:钉住的指纹(64 位小写 hex);给了就严格比对,不一致拒绝(`E_CERT_MISMATCH`)。
|
||||
/// - `captured`:回填本次握手实际抓到的指纹(对齐 `getPeerCertFingerprint`)。
|
||||
pub fn build_client_config(
|
||||
insecure: bool,
|
||||
expect_fp: Option<String>,
|
||||
captured: Arc<Mutex<Option<String>>>,
|
||||
) -> Result<ClientConfig, ClientError> {
|
||||
let expect_fp = expect_fp.map(|s| s.trim().to_ascii_lowercase());
|
||||
|
||||
if !insecure {
|
||||
let mut roots = rustls::RootCertStore::empty();
|
||||
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
||||
return Ok(ClientConfig::builder()
|
||||
.with_root_certificates(roots)
|
||||
.with_no_client_auth());
|
||||
}
|
||||
|
||||
let supported = default_supported_algorithms()?;
|
||||
let verifier = Arc::new(InsecureVerifier {
|
||||
expect_fp,
|
||||
captured,
|
||||
supported,
|
||||
});
|
||||
|
||||
Ok(ClientConfig::builder()
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(verifier)
|
||||
.with_no_client_auth())
|
||||
}
|
||||
|
||||
fn default_supported_algorithms() -> Result<WebPkiSupportedAlgorithms, ClientError> {
|
||||
let provider = CryptoProvider::get_default()
|
||||
.ok_or_else(|| ClientError::new(ErrorCodes::CERT_MISMATCH, "无默认 CryptoProvider"))?;
|
||||
Ok(provider.signature_verification_algorithms)
|
||||
}
|
||||
|
||||
/// 接受任意证书、但抓指纹的验证器。
|
||||
/// `verify_server_cert` 跳过链校验(只做指纹钉住),签名仍由 rustls 真正验签。
|
||||
#[derive(Debug)]
|
||||
struct InsecureVerifier {
|
||||
expect_fp: Option<String>,
|
||||
captured: Arc<Mutex<Option<String>>>,
|
||||
supported: WebPkiSupportedAlgorithms,
|
||||
}
|
||||
|
||||
impl ServerCertVerifier for InsecureVerifier {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
end_entity: &CertificateDer<'_>,
|
||||
_intermediates: &[CertificateDer<'_>],
|
||||
_server_name: &ServerName<'_>,
|
||||
_ocsp_response: &[u8],
|
||||
_now: UnixTime,
|
||||
) -> Result<ServerCertVerified, Error> {
|
||||
let fp = cert_fingerprint(end_entity.as_ref());
|
||||
|
||||
// 回填实际指纹(TOFU 钉住)。
|
||||
if let Ok(mut guard) = self.captured.lock() {
|
||||
*guard = Some(fp.clone());
|
||||
}
|
||||
|
||||
// 钉住比对:不一致 → 拒绝(对齐 client.ts 的 E_CERT_MISMATCH)。
|
||||
if let Some(expected) = &self.expect_fp {
|
||||
if expected != &fp {
|
||||
let expect_head = &expected[..expected.len().min(16)];
|
||||
let actual_head = &fp[..fp.len().min(16)];
|
||||
return Err(Error::General(format!(
|
||||
"证书指纹不匹配,拒绝连接:期望 {expect_head}… 实际 {actual_head}…(被控端可能重装、或中间人冒充)"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ServerCertVerified::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
message: &[u8],
|
||||
cert: &CertificateDer<'_>,
|
||||
dss: &DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, Error> {
|
||||
verify_tls12_signature(message, cert, dss, &self.supported)
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
message: &[u8],
|
||||
cert: &CertificateDer<'_>,
|
||||
dss: &DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, Error> {
|
||||
verify_tls13_signature(message, cert, dss, &self.supported)
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
|
||||
self.supported.supported_schemes()
|
||||
}
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
//! 协议消息类型(对齐 `packages/protocol/src/messages.ts`)。
|
||||
//!
|
||||
//! 所有对外字段名保持 snake_case 与 TS 侧一致,经 serde 序列化/反序列化。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// 协议版本(对齐 methods.ts 的 `PROTOCOL_VERSION`)。
|
||||
pub const PROTOCOL_VERSION: &str = "1.0";
|
||||
|
||||
/// JSON-RPC 2.0 请求信封(nodeagent 简化版)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RpcRequest {
|
||||
pub jsonrpc: String,
|
||||
/// ULID,响应回填相同 id。
|
||||
pub id: String,
|
||||
pub method: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub params: Option<Value>,
|
||||
}
|
||||
|
||||
/// JSON-RPC 2.0 成功响应。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RpcResponse {
|
||||
pub jsonrpc: String,
|
||||
pub id: String,
|
||||
pub result: Value,
|
||||
}
|
||||
|
||||
/// 协议错误对象。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RpcError {
|
||||
pub code: i64,
|
||||
pub name: String,
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub data: Option<Value>,
|
||||
}
|
||||
|
||||
/// JSON-RPC 2.0 错误响应。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RpcErrorResponse {
|
||||
pub jsonrpc: String,
|
||||
#[serde(default)]
|
||||
pub id: Option<String>,
|
||||
pub error: RpcError,
|
||||
}
|
||||
|
||||
/// 任意入站消息:成功响应或错误响应(无 id 的通知由上层单独处理)。
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum InboundMessage {
|
||||
Response(RpcResponse),
|
||||
Error(RpcErrorResponse),
|
||||
Notification {
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
method: String,
|
||||
#[serde(default)]
|
||||
params: Option<Value>,
|
||||
},
|
||||
}
|
||||
|
||||
/// 判断消息是否为错误响应。
|
||||
pub fn is_error_response(msg: &Value) -> bool {
|
||||
msg.get("error").is_some()
|
||||
}
|
||||
|
||||
// ---------- 握手 ----------
|
||||
|
||||
/// hello 入参。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HelloParams {
|
||||
pub protocol: String,
|
||||
pub client_id: String,
|
||||
}
|
||||
|
||||
/// challenge 出参。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChallengeParams {
|
||||
pub nonce: String,
|
||||
pub expires_at: i64,
|
||||
}
|
||||
|
||||
/// auth 入参。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuthParams {
|
||||
pub client_id: String,
|
||||
pub nonce: String,
|
||||
/// psk 模式:Base64(HMAC-SHA256(pre_shared_key, nonce))。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hmac: Option<String>,
|
||||
/// ed25519 模式:Base64(Ed25519_Sign(privkey, nonce))。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub signature: Option<String>,
|
||||
}
|
||||
|
||||
/// auth_ok 出参。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuthOkParams {
|
||||
pub capabilities: Vec<CapabilityDescriptor>,
|
||||
/// ed25519 模式:本次实际被授权的能力(全量子集)。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub authorized: Option<Vec<String>>,
|
||||
/// 本次连接采用的认证模式。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub auth_mode: Option<String>,
|
||||
/// v20:被控端语义化版本。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub agent_version: Option<String>,
|
||||
/// v20:被控端构建信息。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub build: Option<BuildInfo>,
|
||||
/// v20:被控端支持的协议版本。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub protocol: Option<String>,
|
||||
}
|
||||
|
||||
/// 被控端构建信息(v20)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BuildInfo {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hash: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub commit: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub built_at: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cert_sha256: Option<String>,
|
||||
}
|
||||
|
||||
// ---------- 调用 ----------
|
||||
|
||||
/// invoke 入参。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InvokeParams {
|
||||
pub capability: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub args: Option<Value>,
|
||||
}
|
||||
|
||||
/// 能力执行结果(业务层)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InvokeResult {
|
||||
pub status: String, // "ok" | "failed"
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub data: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<InvokeError>,
|
||||
}
|
||||
|
||||
impl InvokeResult {
|
||||
pub fn is_ok(&self) -> bool {
|
||||
self.status == "ok"
|
||||
}
|
||||
}
|
||||
|
||||
/// 能力执行失败的错误信息。
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct InvokeError {
|
||||
pub name: String,
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub data: Option<Value>,
|
||||
}
|
||||
|
||||
/// 能力描述(能力清单条目)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CapabilityDescriptor {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub description: String,
|
||||
pub risk: String, // "low" | "medium" | "high"
|
||||
pub params_schema: JsonSchema,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub returns_schema: Option<JsonSchema>,
|
||||
}
|
||||
|
||||
/// JSON Schema 子集(见 DEVELOPMENT.md 4.3)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JsonSchema {
|
||||
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
|
||||
pub schema_type: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub properties: Option<std::collections::BTreeMap<String, JsonSchema>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub required: Option<Vec<String>>,
|
||||
#[serde(rename = "additionalProperties", skip_serializing_if = "Option::is_none")]
|
||||
pub additional_properties: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub r#enum: Option<Vec<Value>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub default: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub minimum: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub maximum: Option<f64>,
|
||||
#[serde(rename = "minLength", skip_serializing_if = "Option::is_none")]
|
||||
pub min_length: Option<u64>,
|
||||
#[serde(rename = "maxLength", skip_serializing_if = "Option::is_none")]
|
||||
pub max_length: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pattern: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub items: Option<Box<JsonSchema>>,
|
||||
#[serde(rename = "minItems", skip_serializing_if = "Option::is_none")]
|
||||
pub min_items: Option<u64>,
|
||||
#[serde(rename = "maxItems", skip_serializing_if = "Option::is_none")]
|
||||
pub max_items: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
//! ULID 生成器(Crockford Base32,26 字符:10 位时间戳 + 16 位随机)。
|
||||
//! 对齐 `packages/protocol/src/ulid.ts`(自实现,避免额外依赖)。
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Crockford Base32 字母表(与 ulid.ts 完全一致)。
|
||||
const ENCODING: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
||||
|
||||
/// 生成一个 ULID(单调递增:同一毫秒内保证字典序递增)。
|
||||
pub fn ulid() -> String {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
ulid_at(now)
|
||||
}
|
||||
|
||||
/// 按指定时间戳生成 ULID(供测试注入确定性时间)。
|
||||
pub fn ulid_at(now: u64) -> String {
|
||||
// 时间戳部分:10 字符,低位在前地填充(不足 10 位高位补 '0')。
|
||||
let mut time_part = [b'0'; 10];
|
||||
let mut t = now;
|
||||
for i in (0..10).rev() {
|
||||
time_part[i] = ENCODING[(t % 32) as usize];
|
||||
t /= 32;
|
||||
}
|
||||
|
||||
// 随机部分:16 字符。256 % 32 == 0,取模无偏差。
|
||||
let mut rand_part = [b'0'; 16];
|
||||
for slot in rand_part.iter_mut() {
|
||||
*slot = ENCODING[(rand::random::<u8>() % 32) as usize];
|
||||
}
|
||||
|
||||
let mut out = String::with_capacity(26);
|
||||
out.push_str(std::str::from_utf8(&time_part).unwrap());
|
||||
out.push_str(std::str::from_utf8(&rand_part).unwrap());
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn length_is_26() {
|
||||
assert_eq!(ulid().len(), 26);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_valid_charset() {
|
||||
let s = ulid();
|
||||
let set: Vec<char> = ENCODING.iter().map(|&b| b as char).collect();
|
||||
assert!(s.chars().all(|c| set.contains(&c)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monotonic_same_millis_lexicographic() {
|
||||
// 同一毫秒内,两个 ULID 的时间前缀必须相同(保证字典序可比较)。
|
||||
let a = ulid_at(1_700_000_000_000);
|
||||
let b = ulid_at(1_700_000_000_000);
|
||||
assert_eq!(&a[..10], &b[..10]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct() {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for _ in 0..1000 {
|
||||
seen.insert(ulid());
|
||||
}
|
||||
assert_eq!(seen.len(), 1000);
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
//! 端到端:起一个 mock 被控端(WebSocket),验证 Rust client 的
|
||||
//! psk 握手(hello → challenge → auth → auth_ok)+ invoke 全链路。
|
||||
|
||||
use nodeagent_client::{Client, ClientOptions};
|
||||
use serde_json::{json, Value};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
/// 从 mock 被控端读取下一条文本消息并解析为 JSON。
|
||||
async fn read_json(
|
||||
ws: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>,
|
||||
) -> Value {
|
||||
use futures_util::StreamExt;
|
||||
while let Some(msg) = ws.next().await {
|
||||
if let Ok(Message::Text(t)) = msg {
|
||||
return serde_json::from_str(&t).expect("消息应为合法 JSON");
|
||||
}
|
||||
}
|
||||
panic!("mock 被控端连接提前关闭");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn psk_handshake_and_invoke_roundtrip() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
|
||||
// mock 被控端:应答 hello / auth / invoke。
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
|
||||
use futures_util::SinkExt;
|
||||
|
||||
// hello → challenge
|
||||
let hello = read_json(&mut ws).await;
|
||||
assert_eq!(hello["method"], "hello");
|
||||
let hello_id = hello["id"].as_str().unwrap();
|
||||
let nonce = "nonce-abc123";
|
||||
ws.send(Message::Text(
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": hello_id,
|
||||
"result": { "nonce": nonce, "expires_at": 4_102_444_800_000_i64 }
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// auth → auth_ok
|
||||
let auth = read_json(&mut ws).await;
|
||||
assert_eq!(auth["method"], "auth");
|
||||
assert_eq!(auth["params"]["nonce"], nonce);
|
||||
assert!(auth["params"]["hmac"].is_string(), "psk 模式应携带 hmac");
|
||||
let auth_id = auth["id"].as_str().unwrap();
|
||||
ws.send(Message::Text(
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": auth_id,
|
||||
"result": {
|
||||
"capabilities": [{
|
||||
"name": "system.ping",
|
||||
"version": "1.0",
|
||||
"description": "探活",
|
||||
"risk": "low",
|
||||
"params_schema": { "type": "object" }
|
||||
}],
|
||||
"authorized": null,
|
||||
"auth_mode": "psk",
|
||||
"agent_version": "1.0.0"
|
||||
}
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// invoke → result
|
||||
let invoke = read_json(&mut ws).await;
|
||||
assert_eq!(invoke["method"], "invoke");
|
||||
assert_eq!(invoke["params"]["capability"], "system.ping");
|
||||
let invoke_id = invoke["id"].as_str().unwrap();
|
||||
ws.send(Message::Text(
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": invoke_id,
|
||||
"result": { "status": "ok", "data": { "pong": true, "ts": 42 } }
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
// 控制端:连接 → 握手 → 校验元信息 → 调用。
|
||||
let client = Client::new(ClientOptions {
|
||||
url: format!("ws://{addr}"),
|
||||
key: "secret".to_string(),
|
||||
client_id: "control-1".to_string(),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let caps = client.connect().await.unwrap();
|
||||
assert_eq!(caps.len(), 1);
|
||||
assert_eq!(caps[0].name, "system.ping");
|
||||
assert_eq!(client.agent_version().as_deref(), Some("1.0.0"));
|
||||
assert_eq!(client.state(), nodeagent_client::ConnState::Connected);
|
||||
|
||||
let res = client.invoke("system.ping", json!({})).await.unwrap();
|
||||
assert_eq!(res.status, "ok");
|
||||
assert_eq!(res.data.unwrap()["pong"], true);
|
||||
|
||||
client.close();
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connect_to_closed_port_fails() {
|
||||
// 连接到一个必然拒绝的端口,应返回离线错误而非 panic。
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
drop(listener); // 立即关闭,确保端口无监听。
|
||||
|
||||
let client = Client::new(ClientOptions {
|
||||
url: format!("ws://{addr}"),
|
||||
key: "secret".to_string(),
|
||||
client_id: "control-1".to_string(),
|
||||
handshake_timeout_ms: 1_000,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let err = client.connect().await.unwrap_err();
|
||||
assert_eq!(err.code, nodeagent_client::ErrorCodes::NODE_OFFLINE);
|
||||
}
|
||||
+13
-13
@@ -10,7 +10,7 @@
|
||||
| 日期 | 2026-09-27 |
|
||||
| 状态 | 开发阶段 |
|
||||
| 目标端 | macOS(控制端)/ Windows(被控端) |
|
||||
| 配套文档 | [产品需求文档(PRD)](../PRD.md) |
|
||||
| 配套文档 | [产品需求文档(PRD)](PRD.md) |
|
||||
|
||||
---
|
||||
|
||||
@@ -309,9 +309,9 @@
|
||||
| 19 | `fs.write` | 🔴 | 写文件(分块 / 原子写) | v5 |
|
||||
| 20 | `window.list` | 🟢 | 枚举可见顶层窗口(标题/进程/精确矩形/是否前台) | v8 |
|
||||
| 21 | `window.focus` | 🟡 | 置前聚焦(Windows + macOS;越不过全屏独占,属预期) | v8 |
|
||||
| 22 | `screen.find` | 🟢 | 元素定位三引擎(UIA → OCR → 图像模板)+ **UIA 属性**与 `where` 谓词 | v8 / v1.7 |
|
||||
| 22 | `screen.find` | 🟢 | 元素定位三引擎(UIA → OCR → 图像模板)+ **UIA 属性**与 `where` 谓词 | v8 / v2.0.0 |
|
||||
| 23 | `screen.record` | 🟢 | 录屏为帧序列(有 ffmpeg 则封 mp4) | v11 |
|
||||
| 24 | `gui.await` | 🟢 | 等条件成立再返回(四类条件 × 出现/消失)+ `any_of` 组合 | v1.6 |
|
||||
| 24 | `gui.await` | 🟢 | 等条件成立再返回(四类条件 × 出现/消失)+ `any_of` 组合 | v1.6.0 |
|
||||
| 25 | `input.mouse.drag` | 🔴🔒 | 拖拽(拖文件 / 框选) | v11 |
|
||||
| 26 | `system.task.list` | 🟢 | 列出后台异步任务 | v10 |
|
||||
| 27 | `system.task.get` | 🟢 | 查询任务状态与输出(支持增量续读) | v10 |
|
||||
@@ -319,8 +319,8 @@
|
||||
| 29 | `clip.get` | 🟡 | 读剪贴板(文本或图片 PNG) | v10 |
|
||||
| 30 | `clip.set` | 🟡 | 写剪贴板(文本或图片) | v10 |
|
||||
| 31 | `system.audit.verify` | 🟢 | 审计链完整性校验(哈希链,防篡改) | v11 |
|
||||
| 32 | `system.audit.head` | 🟢 | 审计链头(chain head)—— 供**链外**留存 | v25 |
|
||||
| 33 | `system.audit.anchor` | 🟡 | 链头**追加**写锚点文件,防「有 root 者整链重写」 | v25 |
|
||||
| 32 | `system.audit.head` | 🟢 | 审计链头(chain head)—— 供**链外**留存 | v2.0.0 |
|
||||
| 33 | `system.audit.anchor` | 🟡 | 链头**追加**写锚点文件,防「有 root 者整链重写」 | v2.0.0 |
|
||||
| 34 | `system.metrics` | 🟢 | 成功指标聚合(数据源为审计日志) | v13 |
|
||||
| 35 | `event.watch` | 🟢 | 订阅事件(file / process / net),**主动推送** | v12 |
|
||||
| 36 | `event.unwatch` | 🟢 | 取消订阅(连接断开也会自动清理) | v12 |
|
||||
@@ -331,14 +331,14 @@
|
||||
| 41 | `system.net.status` | 🟢 | 网络现状 / 待确认变更 / 历史备份 | v16 |
|
||||
| 42 | `system.audio.get` | 🟢 | 读音量与静音状态(Core Audio COM) | v17 |
|
||||
| 43 | `system.audio.set` | 🟡 | 设音量 / 静音 | v17 |
|
||||
| 44 | `system.agent.restart` | 🔴 | 受控重启自身(借计划任务;**非管理员时自动降级**) | v7 / v23 |
|
||||
| 44 | `system.agent.restart` | 🔴 | 受控重启自身(借计划任务;**非管理员时自动降级**) | v7 / v2.0.0 |
|
||||
| 45 | `system.agent.update` | 🔴 | 拉取式自更新(校验 sha256,只需被控端能上网) | v19 |
|
||||
| 46 | `log.query` | 🟢 | 日志**在被控端侧过滤**后只回匹配行(pattern/level/since/tail) | v1.8 |
|
||||
| 47 | `monitor.start` | 🟢 | 启动定时采样(port/process/command/metric)并落盘 | v1.8 |
|
||||
| 48 | `monitor.report` | 🟢 | 回看样本序列 + **摘要**(断几次 / 最长断多久 / 值域) | v1.8 |
|
||||
| 49 | `monitor.stop` | 🟢 | 停止采样并返回最终摘要 | v1.8 |
|
||||
| 50 | `monitor.list` | 🟢 | 列出运行中的监控与历史样本文件 | v1.8 |
|
||||
| 51 | `monitor.delete` | 🟢 | 删除某监控的历史样本文件 | v1.8 |
|
||||
| 46 | `log.query` | 🟢 | 日志**在被控端侧过滤**后只回匹配行(pattern/level/since/tail) | v2.0.0 |
|
||||
| 47 | `monitor.start` | 🟢 | 启动定时采样(port/process/command/metric)并落盘 | v2.0.0 |
|
||||
| 48 | `monitor.report` | 🟢 | 回看样本序列 + **摘要**(断几次 / 最长断多久 / 值域) | v2.0.0 |
|
||||
| 49 | `monitor.stop` | 🟢 | 停止采样并返回最终摘要 | v2.0.0 |
|
||||
| 50 | `monitor.list` | 🟢 | 列出运行中的监控与历史样本文件 | v2.0.0 |
|
||||
| 51 | `monitor.delete` | 🟢 | 删除某监控的历史样本文件 | v2.0.0 |
|
||||
|
||||
> 图例:🟢 只读低危 · 🟡 需谨慎 · 🔴 高风险 · 🔒 受 `allow_input` 开关管控。
|
||||
> 本表为**总览**;权威定义(参数 schema / 返回值)以 `packages/protocol/src/capabilities/manifest/` 为准,能力数由单测守卫。
|
||||
@@ -745,7 +745,7 @@ nodeagent/
|
||||
│ └── install.ps1 # Windows 一键安装脚本
|
||||
├── docs/ # 文档
|
||||
├── README.md
|
||||
└── PRD.md
|
||||
└── PRD.md(已移入 docs/)
|
||||
```
|
||||
|
||||
| 包 | 职责 | 关键文件 |
|
||||
|
||||
File renamed without changes.
@@ -83,7 +83,7 @@
|
||||
| 无法 7×24 盯 | 臣只在被调用时"看一眼",问题在两次调用之间发生就看不到 |
|
||||
| 应用层内部状态不可见 | 如 WorkBuddy 的"当前正在等哪个请求",臣只能从日志反推 |
|
||||
|
||||
**缓解(✅ 已于 v1.8 实现)**:`monitor.*` 定时采样并落盘,事后回看摘要 —— 正是为治
|
||||
**缓解(✅ 已于 v2.0.0 实现)**:`monitor.*` 定时采样并落盘,事后回看摘要 —— 正是为治
|
||||
这类间歇性问题而做(原先只写"具备实现基础",现已成为能力):
|
||||
|
||||
```bash
|
||||
@@ -173,10 +173,10 @@ WorkBuddy 解析代理慢 + 部分请求走了不通的代理链路 → `Respons
|
||||
|
||||
明确这条边界,才能既用好它,又不至于在关键故障时**因为"以为能远程修"而耽误时间**。
|
||||
|
||||
## 杀软误拦(主流杀软 / 杀软 / 360)
|
||||
## 杀软误拦(主流杀毒软件)
|
||||
|
||||
**现象**:远程接管类工具的典型特征(未知 exe + 建计划任务 + 开防火墙 + 常驻监听 + 注入键鼠/截屏)
|
||||
**必然**触发杀软拦截。真机实证:主流杀软会拦下 `install.ps1` 执行、计划任务注册,或直接处置 `node.exe`,
|
||||
**必然**触发杀软拦截。实测:主流杀软会拦下 `install.ps1` 执行、计划任务注册,或直接处置 `node.exe`,
|
||||
表现为「下载成功但装不上」或「装上后 agent 起不来」。
|
||||
|
||||
**逐项放行清单**(把下面这些都加进杀软的信任/白名单,而不是整机关防护):
|
||||
@@ -186,9 +186,9 @@ WorkBuddy 解析代理慢 + 部分请求走了不通的代理链路 → `Respons
|
||||
| 安装目录 | 解压出来的整个文件夹(含 `node.exe`、`agent.mjs`、`install.ps1`) |
|
||||
| `node.exe` | 被控端自带的 Node 运行时(免装 Node 就是因为这个文件) |
|
||||
| `agent.mjs` | agent 主体(常驻监听 8765) |
|
||||
| 计划任务 `nodeagent` | 开机/登录自启任务;主流杀软的「启动项拦截」会静默拦掉它 |
|
||||
| 计划任务 `nodeagent` | 开机/登录自启任务;杀软的「启动项拦截」会静默拦掉它 |
|
||||
| 入站端口 8765/TCP | `install.ps1` 已建防火墙规则;杀软的**独立网络防护**可能仍拦 |
|
||||
| PowerShell 脚本执行 | 主流杀软「系统加固」会拦脚本解释执行;至少放行上面的安装目录 |
|
||||
| PowerShell 脚本执行 | 杀软的「系统加固」会拦脚本解释执行;至少放行上面的安装目录 |
|
||||
| 键鼠/截屏行为 | 开启 `allow_input` 后才会出现;若被拦,`screen.capture`/`input.*` 会失败 |
|
||||
|
||||
**自检命令**(在 Windows 上跑,确认三件事:任务在不在、进程活不活、端口听没听):
|
||||
|
||||
@@ -255,10 +255,10 @@ Start-Process powershell -ArgumentList '... Start-Sleep 3; Stop-Process ...; Sta
|
||||
|
||||
| 复盘时的短板 | 解决于 | 现在长什么样 |
|
||||
|---|---|---|
|
||||
| **自持**:无法受控重启、更新要人上手 | v7 / v19 / v23 | `system.agent.restart`(借计划任务,**非管理员身份自动降级**)+ `system.agent.update`(拉取式,校验 sha256) |
|
||||
| **语义**:只能「找到」,读不懂状态、操作后无法断言 | v1.6 / v1.7 | `gui.await` 等待条件(四类条件 × 出现/消失 + `any_of` 组合);`screen.find` 返回 **UIA 属性**(`enabled/value/selected/toggle`)并支持 `where` 谓词 —— 从「盲试」到「可断言」 |
|
||||
| **状态**:只有当下快照,间歇性问题无法回看 | v1.8 | `log.query` **在被控端侧过滤**日志;`monitor.*` 定时采样落盘 + 摘要回看(断了几次 / 最长断多久 / CPU·内存值域) |
|
||||
| **安全残余**(SECURITY §5) | v23 / v24 / v25 | 握手失败封禁(指数退避)· 连接上限与空闲断开 · 安装期密钥走 stdin · **零信任优先安装**(有公钥即 ed25519)· **审计链外部锚定**(防整链重写) |
|
||||
| **自持**:无法受控重启、更新要人上手 | v7 / v19 / v2.0.0 | `system.agent.restart`(借计划任务,**非管理员身份自动降级**)+ `system.agent.update`(拉取式,校验 sha256) |
|
||||
| **语义**:只能「找到」,读不懂状态、操作后无法断言 | v1.6.0 / v2.0.0 | `gui.await` 等待条件(四类条件 × 出现/消失 + `any_of` 组合);`screen.find` 返回 **UIA 属性**(`enabled/value/selected/toggle`)并支持 `where` 谓词 —— 从「盲试」到「可断言」 |
|
||||
| **状态**:只有当下快照,间歇性问题无法回看 | v2.0.0 | `log.query` **在被控端侧过滤**日志;`monitor.*` 定时采样落盘 + 摘要回看(断了几次 / 最长断多久 / CPU·内存值域) |
|
||||
| **安全残余**(SECURITY §5) | v2.0.0 | 握手失败封禁(指数退避)· 连接上限与空闲断开 · 安装期密钥走 stdin · **零信任优先安装**(有公钥即 ed25519)· **审计链外部锚定**(防整链重写) |
|
||||
|
||||
**当前规模**:51 项能力 · 269 单测 + 30 端到端 · Windows CI 13 步全绿。
|
||||
|
||||
|
||||
+22
-18
@@ -2,8 +2,9 @@
|
||||
|
||||
> 盘点日期:2026-10-10(**第四次更新**:语义/状态/安全三个方向全部落地后据实刷新)
|
||||
> 盘点方式:真机验证 + 本地跑单测 + `gh run list`(Windows 被控端曾于 10-10 00:15 离线)
|
||||
> 基线:远端 main(`9ad296b` 之后)|单测 **269 全绿** · e2e **30 全绿** · 能力 **51 项** · Windows CI 全绿
|
||||
> ⚠️ 全部**未发版**(皇上要求先攒着)—— 下次发版建议直接跳 **v2.0.0**
|
||||
> 基线:**`v2.0.0` 已发布**(2026-10-10,commit `457ead1`)|单测 **273 全绿** · e2e **30 全绿** ·
|
||||
> 能力 **51 项** · Windows CI / Rust CI 全绿
|
||||
> 📌 v2.0.0 发布时被控端整机离线 → v1.5.0 的四项与 v2.0.0 清理策略的真机验证**仍待补**(见 §九)
|
||||
> 适用范围:本文档随仓库公开,**不含任何真机地址与账户信息**(见 §6 脱敏约定)
|
||||
|
||||
---
|
||||
@@ -48,11 +49,11 @@
|
||||
|
||||
```
|
||||
0d62cb1 (tag v1.5.0) chore(release): v1.5.0
|
||||
1c836e0 feat(v1.5): 快捷键控制范围扩展(字符串热键/预设别名/长按/后台定向投递)
|
||||
1c836e0 feat(v1.5.0): 快捷键控制范围扩展(字符串热键/预设别名/长按/后台定向投递)
|
||||
0ee8231 feat(v1.2): 媒体键 WM_APPCOMMAND 通道 + 定向投递
|
||||
42ec556 (tag v1.4.3) chore(release): v1.4.3
|
||||
7ad820c (本地 HEAD) ci: 补全 Windows 真机验证 ← 未推送
|
||||
[工作区未提交] feat(v1.6): gui.await 等待条件 ← 在建
|
||||
[工作区未提交] feat(v1.6.0): gui.await 等待条件 ← 在建
|
||||
```
|
||||
|
||||
### 2.2 代码与测试
|
||||
@@ -66,7 +67,7 @@
|
||||
| e2e | 2 个套件(`run.mjs` 通用 29 例 / `windows.mjs` Windows 专属 13 例) |
|
||||
| **`gui.await` 覆盖** | **0** —— 单测、e2e、CLI、MCP 均无 `gui.await`/`GuiAwait` 命中 |
|
||||
|
||||
> ⚠️ 单测数从 162 增至 195(新增 33 例,来自 v1.5 快捷键的按键表覆盖断言),但**红了一例**:`tests/unit/capabilities.test.mjs:66` 的 `assert.equal(CAPABILITY_MANIFEST.length, 42)`,实际 43。**修法**:改为 43,或更稳妥地改成「断言每个 `CapabilityNames` 键都在 manifest 中」以免每加一能力都要改数。
|
||||
> ⚠️ 单测数从 162 增至 195(新增 33 例,来自 v1.5.0 快捷键的按键表覆盖断言),但**红了一例**:`tests/unit/capabilities.test.mjs:66` 的 `assert.equal(CAPABILITY_MANIFEST.length, 42)`,实际 43。**修法**:改为 43,或更稳妥地改成「断言每个 `CapabilityNames` 键都在 manifest 中」以免每加一能力都要改数。
|
||||
|
||||
**最大的三个文件**(后续拆分候选):
|
||||
|
||||
@@ -85,7 +86,7 @@
|
||||
| **屏幕与录制**(4) | `screen.info` `screen.capture` `screen.record` `screen.find` |
|
||||
| **输入**(6) | `input.mouse.move` `input.mouse.click` `input.mouse.scroll` `input.mouse.drag` `input.key.type` `input.key.press` |
|
||||
| **窗口**(2) | `window.list` `window.focus` |
|
||||
| **GUI 等待**(1) | **`gui.await`** 🆕 v1.6 在建未提交 —— 等条件成立再返回 |
|
||||
| **GUI 等待**(1) | **`gui.await`** 🆕 v1.6.0 在建未提交 —— 等条件成立再返回 |
|
||||
| **文件**(4) | `fs.list` `fs.stat` `fs.read` `fs.write`(分块 RPC + 原子写) |
|
||||
| **剪贴板**(2) | `clip.get` `clip.set` |
|
||||
| **异步任务**(3) | `system.task.list` `system.task.get` `system.task.kill` |
|
||||
@@ -95,7 +96,7 @@
|
||||
| **音频**(2) | `system.audio.get` `system.audio.set`(Core Audio COM) |
|
||||
| **网络变更**(3) | `system.net.apply` `system.net.confirm` `system.net.status` |
|
||||
|
||||
**`gui.await` 设计要点**(工作区在建,v1.6):
|
||||
**`gui.await` 设计要点**(工作区在建,v1.6.0):
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
@@ -189,7 +190,7 @@ await gui.await(condition:"control", text:"完成", timeout_ms:30000)
|
||||
3. **install.cmd / control.cmd 双击链路冒烟** —— 这两个脚本是用户唯一上手入口,编码或语法坏了只有真机 cmd.exe 测得出。
|
||||
- **收尾**:推送后可清理 `ci/workflow-unit-tests` 分支(贡献已并入,留着只会误导)。
|
||||
|
||||
### 4.2 v1.5.0 真机验证尚未做(v1.5 提交信息自陈)
|
||||
### 4.2 v1.5.0 真机验证尚未做(v1.5.0 提交信息自陈)
|
||||
|
||||
`1c836e0` 的提交信息里写明:
|
||||
|
||||
@@ -205,7 +206,7 @@ node scripts/verify.mjs --host <地址> --key <密钥> --report <报告.md>
|
||||
|
||||
### 4.3 大前端方案(`docs/console-plan.md`,已出文档待评审)
|
||||
|
||||
盘点期间新增了一份前端方案文档,臣不在此展开(详见该文档),仅提示它引入了**一个新包 `crates/nodeagent-client`(Rust)**,与现有 TypeScript 的 `@nodeagent/client` 并存 —— 意味着 client 层有两套实现。这本身是个需要皇上拍板的架构决策(详见该文档的待确认项)。
|
||||
盘点期间新增了一份前端方案文档,臣不在此展开(详见该文档),仅提示它引入了**一个新包 `crates/nodeagent-client`(Rust)**〔**2026-10 已退役**:改为 Node sidecar 方案,见 `docs/console-plan.md` 文末「实施决策」〕,与现有 TypeScript 的 `@nodeagent/client` 并存 —— 意味着 client 层有两套实现。这本身是个需要皇上拍板的架构决策(详见该文档的待确认项)。
|
||||
|
||||
---
|
||||
|
||||
@@ -220,8 +221,8 @@ node scripts/verify.mjs --host <地址> --key <密钥> --report <报告.md>
|
||||
| # | 改动 | 状态 | 说明 |
|
||||
|---|---|---|---|
|
||||
| 1 | **新增 `gui.await` 等待条件** | ✅ **已实现,未提交** | 四类条件(window/control/process/file)× 两态(present/absent);复用既有能力,零新增平台探测代码;超时**正常返回**不抛异常 |
|
||||
| 2 | `screen.find` 返回 UIA 属性 | ⬜ 未做 | 补 `name` / `value` / `enabled` / `selected` / `rect` / `automation_id` |
|
||||
| 3 | 条件组合(`and` / `or` / `not`) | ⬜ 未做 | `gui.await` 目前是单条件;组合需先有属性支持 |
|
||||
| 2 | `screen.find` 返回 UIA 属性 | ✅ **已完成**(v2.0.0) | 已补 `enabled` / `value` / `selected` / `toggle`;拿不到留 `null` 不猜 |
|
||||
| 3 | 条件组合(`and` / `or` / `not`) | ✅ **已完成**(v2.0.0) | 已实现 `any_of`(任一命中即算);`and`/`not` 暂不需要 |
|
||||
|
||||
#### `gui.await` 收尾清单(✅ **已于 v1.6.1 全部完成**)
|
||||
|
||||
@@ -305,7 +306,7 @@ node scripts/verify.mjs --host <地址> --key <密钥> --report <报告.md>
|
||||
| **2 个含PII 的 blob 匿名可直取** | 旧提交中的 blob 用 40 位完整 SHA 即可匿名读取,已连续 6 轮未消;内容含本机用户名与真实目录名 | 报 GitHub Support 申请 GC,或删库重建。**这是唯一的「真泄露」** |
|
||||
| **提交信息含真机内网地址** | 5 条 commit message 含内网 IP 与端口 | 公开且被搜索引擎收录。后续禁止在 message 写真机地址 |
|
||||
| **Windows 账户名** | 硬编码在 `network.ts:96` | 改为从配置或环境变量读取 |
|
||||
| **网卡名「Ethernet」+ 网段常量** | 测试与源码中多处 | 同上,注入式配置 |
|
||||
| ~~网卡名与网段常量~~ | 测试与源码中多处 | ✅ 已完成:示例统一为文档段 `Ethernet` / `192.168.1.x`(见 §九) |
|
||||
|
||||
### 6.2 尚未暴露,立即处置
|
||||
|
||||
@@ -389,6 +390,9 @@ node scripts/verify.mjs --host <地址> --key <密钥> --report <报告.md>
|
||||
| deploy 备份不再堆积 | ✅ | `.bak-<ts>` → 固定名 `.bak` + 清理历史;真机 17 个文件 → 2 个 |
|
||||
| 自重启任务 Running 僵尸清理 | ✅ 代码 / ⏳ 真机 | 清理条件加「LastRunTime 早于 1 小时」余量(防误杀当前宿主) |
|
||||
| **`SECURITY.md` §5「还没做的」清单** | ✅ **全部完成** | 见 §4.5~§4.8 |
|
||||
| **发版 v2.0.0** | ✅ 2026-10-10 | 37 个提交一次发出;资产 agent.mjs(sha256 `7164ea44…`) / win-x64.zip / latest.json / SHA256SUMS |
|
||||
| v1.5.0 四项 + v2.0.0 清理策略**真机验证** | ✅ **已完成**(VM · Win11 ARM) | A 组 15/0/1 · B1 四项全过 · B2 三防护全过 · B3 清理**验证并改进** · B4 锚点端到端 · B5 结论:x64 包在 ARM 不可用→已出 arm64 包。**共发现并修复 5 个真问题**(详见 `VM-VERIFICATION.md`) |
|
||||
| ↑ **可用 VM 补验证** | 📋 见 [`VM-VERIFICATION.md`](VM-VERIFICATION.md) | Parallels 27 + Win11 **ARM**:协议层(v23/v26/锚点)与输入层(v1.5 四项)**可直接验**;杀软/显示/性能**仍需真机** |
|
||||
|
||||
### 真机验证过程中抓到的真 bug(8 个,全部只有真机能发现)
|
||||
|
||||
@@ -397,7 +401,7 @@ node scripts/verify.mjs --host <地址> --key <密钥> --report <报告.md>
|
||||
| 1 | `install.cmd` 双击首装即炸(`for /f` 调 PS,`^|` 漏进 PowerShell) | **用户首装必炸** |
|
||||
| 2 | `pack.mjs` 三连(spawn `.bin/esbuild` ENOENT / `zip` / `ls -la`+`du -sh`) | 打包脚本全平台假设 |
|
||||
| 3 | `where` 的 PS 注入语法错(连修**两轮**才通:JSON 字面量 ✗ → `\"` 转义 ✗ → PS 单引号 ✓) | 跨语言转义 |
|
||||
| 4 | `screen.find` 的 where-only 被自己入口校验拦下 | v1.7 只改了一半 |
|
||||
| 4 | `screen.find` 的 where-only 被自己入口校验拦下 | v2.0.0 只改了一半 |
|
||||
| 5 | 自重启在非管理员下失败 → `deploy` 永远"半成功" | v7 起的既有缺陷 |
|
||||
| 6 | `parseLeadingTs` 吃掉 ISO 的 `Z` 时区标记(偏 8 小时) | 单测抓到 |
|
||||
| 7 | manifest 承诺的 `note` 字段代码没实现 | 承诺未落地 |
|
||||
@@ -412,12 +416,12 @@ node scripts/verify.mjs --host <地址> --key <密钥> --report <报告.md>
|
||||
| 源码注释脱敏(序 0.5) | ✅ | `input.ts` / `input-script.ts` 4 处软件名泛化 |
|
||||
| `.gitignore` 补 `keys/ *.key *.pem`(§6.2) | ✅ | `*.pub` 放行 |
|
||||
| `input.ts` U+FFFD 字面量(§6.2) | ✅ 复查 0 个 | 该描述已过期(早前重构时消失) |
|
||||
| v1.5.0 真机验证(序 3) | ⏳ 待被控端在线 | 四项:字符串热键 / 预设别名 / `hold_ms` / `route=post` |
|
||||
| 方向一第二步:`screen.find` 属性化 + 条件组合 | ⬜ 未做 | 需先有 UIA 属性(见 §五) |
|
||||
| 方向二:`monitor.*` / `log.query` / `fs.watch` | ⬜ 未做 | — |
|
||||
| 方向三:`install.cmd` 密钥 stdin、握手封禁、会话上限、审计锚定 | ⬜ 未做 | 安全项建议提前 |
|
||||
| v1.5.0 真机验证(序 3) | ✅ **已完成**(2026-10-11 VM) | 四项全过;含 `route=post` 实测命中 138 窗口 |
|
||||
| 方向一第二步:`screen.find` 属性化 + 条件组合 | ✅ **已完成**(v2.0.0) | 见 §五 的对应行 |
|
||||
| 方向二:`monitor.*` / `log.query` / `fs.watch` | ✅ **已完成**(v2.0.0) | `monitor.*` 5 项 + `log.query` 已实现;`fs.watch` 由 `event.watch` 覆盖 |
|
||||
| 方向三:`install.cmd` 密钥 stdin、握手封禁、会话上限、审计锚定 | ✅ **已完成**(v2.0.0,2026-10-11 真机验证) | 三防护三场景全过;锚定端到端 + 云盘链外留存已跑通 |
|
||||
| PII blob GC / 仓库重建(序 8) | ⏳ 需皇上决策 | — |
|
||||
| `ed25519` 默认化 | ⬜ 未做 | 需确认兼容策略 |
|
||||
| `ed25519` 默认化 | ✅ **已完成**(v2.0.0) | 「零信任优先安装」:有 client-acl.json 即走 ed25519(default_effect=deny) |
|
||||
| console-plan 评审(Rust client 取舍) | ⏳ 需皇上拍板 | — |
|
||||
|
||||
### Windows CI 扒出的 6 个真 bug(均已修复,v1.6.x)
|
||||
|
||||
+38
-7
@@ -116,7 +116,7 @@ nodeagent keygen --psk --id mac_02
|
||||
| 未登记的 client_id 用共享 `key` | ✅ 通过(**旧部署行为不变**) |
|
||||
| 已登记的 client_id 用共享 `key` | ❌ 拒绝(**专属钥匙优先**,不被共享 key 绕过) |
|
||||
|
||||
## 4.7 零信任优先安装(v24)
|
||||
## 4.7 零信任优先安装(v2.0.0)
|
||||
|
||||
安装时的认证模式选择,从「默认 psk + 可选 ed25519」改为**「能拿到控制端公钥就走 ed25519」**:
|
||||
|
||||
@@ -141,7 +141,7 @@ nodeagent keygen --id mac_01 # 输出一段 JSON(acl.clients 的一项
|
||||
> 真机验证(2026-10-09):CASE A 有文件 → `ed25519` + `default_effect=deny` + clients 完整;
|
||||
> CASE B 无文件 → `psk` 且**不写 acl 字段**;CASE C 缺 pubkey → 告警并回退 psk。
|
||||
|
||||
## 4.8 审计链外部锚定(v25)
|
||||
## 4.8 审计链外部锚定(v2.0.0)
|
||||
|
||||
**问题**:链内哈希只能发现"改一条"(其后全部需重算)。「有 root 的攻击者」可以
|
||||
**整链重写** —— 从零重建一份自洽的假日志,`system.audit.verify` 照样通过。
|
||||
@@ -171,6 +171,37 @@ nodeagent --node win audit anchor --note "每日例行" # 追加一个锚点
|
||||
**已知行为**:锚定调用自身也会写一条审计(server 层统一记录 invoke),所以
|
||||
"刚锚定就比对"通常显示"链已增长" —— 属预期,不是篡改。
|
||||
|
||||
### 外部化路径(2026-10 落地)
|
||||
|
||||
锚点的安全性**完全来自「它在攻击者碰不到的地方」** —— 与被审计日志同机的锚点等于没有锚。
|
||||
故分两层存放:
|
||||
|
||||
| 层 | 位置 | 能防住 |
|
||||
|---|---|---|
|
||||
| L1 | 控制端(Mac)本地 | 被控端单点被 root |
|
||||
| **L2** | **移动云盘 `…/Project/NodeAgent/anchors/`** | **控制端也被攻破**(锚点在腾讯云,被控端 root 也碰不到) |
|
||||
|
||||
**控制端工具** `scripts/anchor-audit.mjs`:
|
||||
|
||||
```bash
|
||||
# 每日:拉链头 → 生成锚点(再由云盘能力上传到链外)
|
||||
node scripts/anchor-audit.mjs collect --node win --out /tmp/anchor-$(date +%Y%m%d).jsonl
|
||||
|
||||
# 每月(或怀疑时):用链外锚点比对当前链
|
||||
node scripts/anchor-audit.mjs verify --anchors <从云盘下载的锚点文件> --node win
|
||||
# ok:false → 退出码 2,并给出「疑似整链重写 / 链头哈希不一致」的明细
|
||||
```
|
||||
|
||||
**为什么判定必须由控制端做**:被控端是**不可信方** —— 它若被 root,会重写日志、
|
||||
也会把自己的锚点改掉,然后回报"一切正常"。所以"锚点存哪、和什么比"只能由控制端掌握。
|
||||
|
||||
**判定逻辑只有一份**:`packages/protocol/src/audit-anchor.ts` 的 `judgeAnchors`(纯函数),
|
||||
被控端 `compareWithAnchors` 与控制端脚本**都调用它** —— 若两端各写一份,同一份日志会
|
||||
出现"被控端说一致、控制端说被改"的荒谬局面。
|
||||
|
||||
**云盘侧的两点约束(都正好合意)**:① 不支持覆盖/删除 → 锚点天然「追加不可改写」;
|
||||
② 一天一个文件(`nodeagent-anchor-YYYYMMDD.jsonl`)→ 保留历史锚点,可定位被改区间。
|
||||
|
||||
**如何真正用起来**:定期(建议每日)调用 `system.audit.anchor`,并用
|
||||
`system.audit.head --compare` 巡检;若把锚点文件指向**另一台机器/网盘同步目录**,
|
||||
外部锚定的价值才真正落地(本机文件被整盘重写时锚点也随之消失)。
|
||||
@@ -180,11 +211,11 @@ nodeagent --node win audit anchor --note "每日例行" # 追加一个锚点
|
||||
| 项 | 说明 |
|
||||
|---|---|
|
||||
| ~~发现报文最小化~~ | ✅ **v22 已做**(见 §4.5) |
|
||||
| ~~ed25519 默认化~~ | ✅ **v24 已做**(零信任优先安装,见 §4.7);另有 v22 的 `keys{}` 每客户端一把 PSK 作为轻量替代 |
|
||||
| ~~握手失败封禁~~ | ✅ **v23 已做**:连续失败达阈值 → 指数退避封禁(封顶 24h),成功即清零,支持白名单豁免 |
|
||||
| ~~会话上限~~ | ✅ **v23 已做**:并发上限 8 + 30 分钟空闲断开(有任务/订阅在跑时保守不踢) |
|
||||
| ~~安装期密钥传递~~ | ✅ **v23 已做**:`install.cmd` 改用管道喂 stdin(`-KeyFromStdin`),密钥不再出现在命令行 |
|
||||
| ~~审计链外部锚定~~ | ✅ **v25 已做**(见 §4.8):链头可读 + 锚点追加落盘 + 锚点比对 |
|
||||
| ~~ed25519 默认化~~ | ✅ **v2.0.0 已做**(零信任优先安装,见 §4.7);另有 v22 的 `keys{}` 每客户端一把 PSK 作为轻量替代 |
|
||||
| ~~握手失败封禁~~ | ✅ **v2.0.0 已做**:连续失败达阈值 → 指数退避封禁(封顶 24h),成功即清零,支持白名单豁免 |
|
||||
| ~~会话上限~~ | ✅ **v2.0.0 已做**:并发上限 8 + 30 分钟空闲断开(有任务/订阅在跑时保守不踢) |
|
||||
| ~~安装期密钥传递~~ | ✅ **v2.0.0 已做**:`install.cmd` 改用管道喂 stdin(`-KeyFromStdin`),密钥不再出现在命令行 |
|
||||
| ~~审计链外部锚定~~ | ✅ **v2.0.0 已做**(见 §4.8):链头可读 + 锚点追加落盘 + 锚点比对 |
|
||||
|
||||
## 6. 应急处置
|
||||
|
||||
|
||||
@@ -3,6 +3,50 @@
|
||||
> 为什么需要这份文档:agent 与 CLI/MCP 是**分开更新的**(CLI 随控制端装,agent 推/拉到被控端),
|
||||
> 新旧混用是常态而非例外。没有明文契约,升级顺序、什么算破坏性改动、出问题怎么定位全靠猜。
|
||||
|
||||
## 0. 版本号唯一性规范(2026-10-10 确立)
|
||||
|
||||
> **背景**:项目早期同时存在两套编号 —— 发布版本(`v1.1.0`/`v2.0.0`)与开发批次代号(`v1`~`v26`)。
|
||||
> 二者**从来不是一对一**(例:`v1.1.0` 首发时批次已推进到 `v12.4`),并列出现时无法判断哪个是"当前版本"。
|
||||
> 自 v2.0.0 起统一为下列规则。
|
||||
|
||||
### 唯一版本号
|
||||
|
||||
**`vX.Y.Z`(semver)是项目唯一的版本标识**,两个来源必须永远一致:
|
||||
|
||||
| 来源 | 位置 | 谁在读 |
|
||||
|---|---|---|
|
||||
| git tag | `v2.0.0` | 人 / GitHub Release |
|
||||
| `package.json` 的 `version` | 全部 workspace + `tauri.conf.json` | 构建期经 esbuild `--define` 注入 agent |
|
||||
|
||||
```bash
|
||||
nodeagent info # 被控端自报的 version 即来自这里
|
||||
git tag -l # 发布历史,唯一权威
|
||||
```
|
||||
|
||||
### 开发批次代号(已并入发布版本)
|
||||
|
||||
`v1`~`v26` 是**开发阶段的内部代号**(一批相关改动的集合),**不是版本号,也不再单独出现**:
|
||||
|
||||
| 批次 | 内容 | **归属发布版本** |
|
||||
|---|---|---|
|
||||
| v1 ~ v12.4 | 命令级接管 → 事件订阅 / GUI 宏 / Hub 并发(首发前的全部积累) | **v1.1.0** |
|
||||
| v12.2 ~ v15 | 等待语义 / macOS GUI 对齐 / 常驻助手 / 图像模板 | v1.2.1 ~ v1.4.x |
|
||||
| v16 ~ v18 | 网络两阶段提交 / 音频 / 构建指纹 | v1.4.x |
|
||||
| v19 ~ v22 | 拉取式自更新 / 证书钉住 / 每客户端 PSK / 发现最小化 | v1.5.0 ~ v1.6.0 |
|
||||
| v23 ~ v26、语义属性化、状态采样 | 连接层三防护 / 零信任安装 / 审计锚定 / UIA 属性 / monitor | **v2.0.0** |
|
||||
|
||||
> 上表按 tag 时间线与功能内容推定,**近似**而非精确(批次与发布本无严格边界)。
|
||||
> 判断"某个改动属于哪个版本"时,**以 CHANGELOG 的版本段落为准**,不以批次号为准。
|
||||
|
||||
### 书写规则(新代码 / 新文档)
|
||||
|
||||
| 场景 | 写法 | 例 |
|
||||
|---|---|---|
|
||||
| 引用发布版本 | `vX.Y.Z` | `v2.0.0 新增 gui.await` |
|
||||
| 需要区分同一版本内的多批改动 | 用**功能名**,不用批次号 | `审计锚定(v2.0.0)` |
|
||||
| 历史注释中的批次号 | 保留但**不得**再新增;见上表归属 | `v16:网络两阶段提交` |
|
||||
|
||||
|
||||
## 1. 版本号
|
||||
|
||||
- 单一来源:**根 `package.json` 的 `version`**,构建时经 esbuild `--define` 注入 agent。
|
||||
|
||||
Loaded 100 of 143 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user