Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b6b063e2b | ||
|
|
cf51e0415a | ||
|
|
c062d1cc8b | ||
|
|
0b858bb343 | ||
|
|
a769479a2a | ||
|
|
5e32d7fa55 | ||
|
|
eebfb45f73 | ||
|
|
419f6dca13 | ||
|
|
1a2d1c7825 | ||
|
|
b6e58dbcc4 | ||
|
|
5bacade667 | ||
|
|
451d44af72 | ||
|
|
2d5be044ee | ||
|
|
9baf3b0443 | ||
|
|
dadd861fdd | ||
|
|
a7827ba986 | ||
|
|
0074d09f08 | ||
|
|
b7f44f7a64 | ||
|
|
654d694ff6 | ||
|
|
6152a68b4a | ||
|
|
fbd42e1c3b | ||
|
|
5a2c5f9c55 | ||
|
|
aad137f04a | ||
|
|
560b3c44e4 | ||
|
|
68ea3cd20e | ||
|
|
cb468df7da | ||
|
|
1249c90725 | ||
|
|
6b2fa29710 | ||
|
|
a75c5c9bc5 | ||
|
|
3680218010 | ||
|
|
141f479e4a | ||
|
|
9f95560268 | ||
|
|
240c52c44f | ||
|
|
0782d901a3 | ||
|
|
9af6e0f73a | ||
|
|
00659165b1 | ||
|
|
704fb8cfc9 | ||
|
|
618ebc986f | ||
|
|
c7d596e087 | ||
|
|
630ea7dd7a | ||
|
|
7d781d132e | ||
|
|
2bf45fdf98 | ||
|
|
590f62e968 | ||
|
|
b34e8c73e0 | ||
|
|
b9236dfaf1 | ||
|
|
00992e5c1c | ||
|
|
c9e34b93a2 | ||
|
|
9384b64c42 | ||
|
|
2437b0e863 | ||
|
|
56f64e1cc1 | ||
|
|
eec76ed91e | ||
|
|
6adf11c85a | ||
|
|
73ed06f5f1 | ||
|
|
79969e9d5d | ||
|
|
a1c51d62a0 | ||
|
|
bce11514a9 | ||
|
|
530bb4fca7 | ||
|
|
ae02252a1d | ||
|
|
ad90634a18 | ||
|
|
02887e265b | ||
|
|
c20e71bddc | ||
|
|
e616e80819 | ||
|
|
71f2cd2597 | ||
|
|
1471708f35 | ||
|
|
456e452b30 | ||
|
|
4e6eb32929 | ||
|
|
591e44721a | ||
|
|
2528f6b709 | ||
|
|
ad569d4e6d | ||
|
|
2eb6a6f8bb | ||
|
|
a0be43d76c | ||
|
|
9ae1326be2 | ||
|
|
1ab9ed22c7 | ||
|
|
5e6231493e | ||
|
|
f85acad5db | ||
|
|
f558e3e7df | ||
|
|
87b74fd015 | ||
|
|
b2404291b0 | ||
|
|
e816c1fd39 | ||
|
|
a6be917637 | ||
|
|
8664ec2926 | ||
|
|
22bc732334 |
No files matched your search
@@ -0,0 +1,28 @@
|
||||
name: Console Release
|
||||
|
||||
# 控制台发布工作流(tag 触发)。
|
||||
#
|
||||
# 为什么不把 `tags` 直接写进 console-windows.yml 的 push 里
|
||||
# ------------------------------------------------
|
||||
# 那个工作流带 `paths` 过滤。`paths` 与 `tags` 同时出现时,tag 推送会被
|
||||
# 路径判定挡掉而**静默不触发** —— 这是 GitHub Actions 的经典坑,
|
||||
# 且失败形态是「什么都没发生」,最难察觉。
|
||||
# 故拆成:日常验证走 console-windows(分支 + paths),发布走本工作流(只认 tag)。
|
||||
#
|
||||
# 交付物去向:本工作流构建两台架构的安装包,再由 console-windows 里的
|
||||
# `publish` job 补挂到 `scripts/release.mjs` 创建的那个 Release 上。
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
# 发布需要写 Release
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
console:
|
||||
uses: ./.github/workflows/console-windows.yml
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -0,0 +1,408 @@
|
||||
name: Console Windows
|
||||
|
||||
# 控制台(apps/console)的 Windows 安装包构建。
|
||||
#
|
||||
# 为什么单独一个工作流、而不塞进 windows-e2e
|
||||
# ----------------------------------------
|
||||
# windows-e2e 是「被控端在 Windows 上的行为验证」,跑一遍约 4 分钟、含安装/卸载真机冒烟;
|
||||
# 而控制台打包要装 Rust 工具链、编 Tauri(首次冷编 10 分钟级)。两者目标不同、
|
||||
# 耗时量级不同,混在一起会让「改一行前端也要等 15 分钟」。
|
||||
#
|
||||
# ⚠️ 在此之前,控制台的 Windows 侧只被验证到 prepare:node 与自举(见 windows-e2e),
|
||||
# **从未真跑过 tauri build**。而 Rust 里那些 `cfg!(windows)` 分支
|
||||
# (taskkill 收进程树、PowerShell 计划任务、node.exe 路径)在 macOS 上
|
||||
# 只被类型检查、从未被编译成 Windows 目标,更没有产物级验证 —— 故本工作流是它们的第一道真闸。
|
||||
#
|
||||
# 架构(为什么必须两套)
|
||||
# --------------------
|
||||
# 被控端发布包曾出过「arm64 包里装的是 x64 node.exe」的事故,后果是
|
||||
# **在 ARM Windows 上能 listen、但 TLS 握手不完成**,且 macOS 侧 100% 复现不出。
|
||||
# 控制台同理:它内含 node 运行时,且要用它**托起本机被控端**(即 TLS 服务端)。
|
||||
# 故 x64 与 arm64 必须各出一套,且都要能装到对应架构的 Windows 上。
|
||||
#
|
||||
# 这里用 GitHub 的 ARM64 Windows runner(`windows-11-arm`,公共仓库免费)
|
||||
# **原生构建**,而不是在 x64 runner 上交叉编译 —— 交叉编译需要 VS 的 ARM64 工具链,
|
||||
# 且 `prepare-console.mjs` 的自检要真执行 node.exe(x64 机器跑不了 arm64 二进制),
|
||||
# 会逼着引入「跳过自检」这类削弱验证的开关。原生构建则自检照跑、无特殊分支。
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'apps/console/**'
|
||||
- 'apps/agent/**'
|
||||
- 'packages/**'
|
||||
- 'scripts/prepare-console.mjs'
|
||||
- 'scripts/lib/bundle-agent.mjs'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- '.github/workflows/console-windows.yml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'apps/console/**'
|
||||
- '.github/workflows/console-windows.yml'
|
||||
workflow_dispatch:
|
||||
# 供 `console-release.yml` 在 tag 推送时调用。
|
||||
#
|
||||
# 为什么不直接把 `tags` 加进上面的 push:`paths` 与 `tags` 同时存在时,
|
||||
# tag 推送会因为「路径过滤」判定不通过而**静默不触发**(这是 GitHub Actions
|
||||
# 的经典坑)。拆成「主工作流只跑分支 + 发布工作流只认 tag」最不容易出错。
|
||||
workflow_call:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: 控制台安装包 · ${{ matrix.arch }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 60
|
||||
|
||||
strategy:
|
||||
# 一条架构挂了不要拖停另一条 —— 否则「arm64 失败」会掩盖「x64 到底成没成」
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: x64
|
||||
runner: windows-latest
|
||||
# x64 给两种安装形态:msi(企业批量部署)+ nsis(普通用户双击)
|
||||
bundles: msi,nsis
|
||||
pe: '0x8664'
|
||||
- arch: arm64
|
||||
runner: windows-11-arm
|
||||
# arm64 只出 nsis:WiX 的 arm64 MSI 支持面较窄,不值得为它引入不确定性
|
||||
bundles: nsis
|
||||
pe: '0xaa64'
|
||||
|
||||
steps:
|
||||
- name: 检出代码
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: 安装 pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 12
|
||||
|
||||
- name: 安装 Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: 安装 Rust 工具链
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: 缓存 Rust 构建产物
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
# 只缓存 src-tauri(控制台壳),不牵动仓库根的 Rust 工作区
|
||||
workspaces: apps/console/src-tauri
|
||||
|
||||
- name: 安装依赖
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# ⚠️ 这条断言是必需项,不是「顺手检查」。
|
||||
# ARM runner 的镜像里确实存在 x64 模拟的组件(社区实测 python 即为 AMD64),
|
||||
# 一旦 Node 也是模拟版,process.arch 会是 x64 → prepare-console 会把 x64 的 node.exe
|
||||
# 打进 arm64 安装包 → **装到真机上能启动、TLS 握手不完成**,且本机 CI 全绿。
|
||||
# 故先断言「我到底在什么架构上编」,再往下走。
|
||||
- name: 断言构建机架构与目标一致
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$arch = node -p "process.arch"
|
||||
Write-Host "node process.arch = $arch"
|
||||
Write-Host "runner = ${{ matrix.runner }}"
|
||||
if ($arch -ne '${{ matrix.arch }}') {
|
||||
throw "架构不符:期望 ${{ matrix.arch }},实际 $arch —— 打进包的 node.exe 会是错的架构"
|
||||
}
|
||||
|
||||
- name: 构建 workspace 包(含被控端)
|
||||
# agent 的 dist 是 prepare-console 打包被控端的前置产物,顺序不能调
|
||||
run: pnpm --filter "./packages/*" --filter "@nodeagent/agent" build
|
||||
|
||||
# prepare-console 的前置检查要求 sidecar.mjs 已存在;它自己也会调 esbuild 打 agent.mjs
|
||||
- name: 构建 sidecar 并准备随包资源(node + agent.mjs)
|
||||
working-directory: apps/console
|
||||
run: |
|
||||
pnpm sidecar:build
|
||||
pnpm prepare:node
|
||||
|
||||
# ⚠️ 交付物级断言(与 pack.mjs 的双架构校验同源思路):
|
||||
# 不只看「脚本跑完了」,而是直接读最终要打进包的那个 node.exe 的 PE 头。
|
||||
# 中间逻辑再自洽,也可能因为上游给了错架构的二进制而产出错包。
|
||||
- name: 断言随包 node.exe 的 PE 架构
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$node = 'apps/console/src-tauri/runtime/node.exe'
|
||||
if (-not (Test-Path $node)) { throw "缺少随包 Node 运行时:$node" }
|
||||
$bytes = [IO.File]::ReadAllBytes((Resolve-Path $node))
|
||||
$peOff = [BitConverter]::ToUInt32($bytes, 0x3C)
|
||||
$machine = [BitConverter]::ToUInt16($bytes, $peOff + 4)
|
||||
$actual = '0x{0:x4}' -f $machine
|
||||
Write-Host "node.exe PE Machine = $actual(体积 $([math]::Round($bytes.Length/1MB,1)) MB)"
|
||||
if ($actual -ne '${{ matrix.pe }}') {
|
||||
throw "随包 node.exe 架构错误:期望 ${{ matrix.pe }},实际 $actual —— 严禁发布该包"
|
||||
}
|
||||
|
||||
- name: 打包安装包(${{ matrix.bundles }})
|
||||
working-directory: apps/console
|
||||
# beforeBuildCommand(vite build)由 tauri 自己触发;资源靠 --config 注入,
|
||||
# 默认 conf 里绝不出现 runtime 路径(bundle.resources 是编译期契约,声明了不存在的文件 build.rs 会直接失败)
|
||||
run: pnpm tauri build --config src-tauri/tauri.bundle.gen.json --bundles ${{ matrix.bundles }}
|
||||
|
||||
- name: 验证产物自举
|
||||
# 真 spawn 产物内的 node 去跑产物内的 sidecar,走一次 JSON-RPC
|
||||
# (这在 macOS 上抓出过 strip 破坏签名导致 SIGKILL 的问题,Windows 侧同样值得钉住)
|
||||
run: node apps/console/scripts/verify-bundle.mjs --runtime
|
||||
|
||||
# ⚠️ 断言的是**最终交付物**,不是中间产物。
|
||||
#
|
||||
# 这一版是本步的第二次实现。首版写成「安装包体积 > 50MB」,理由是
|
||||
# 「含 Node 运行时(约 80MB),故不可能是个小文件」—— 这个推理把
|
||||
# **未压缩的目录形态**(.app ≈ 92MB)当成了**压缩安装包的体积**。
|
||||
# 2026-10-11 首个 run 两个 job 双双死在这一行:实测 msi 34.2MB、
|
||||
# nsis 19.5~23.2MB(LZMA 下 node.exe 自身就能压到 1/3~1/4),阈值必然误杀。
|
||||
#
|
||||
# 更根本的问题是:**体积阈值属于「中间逻辑自洽」式断言** —— 包大
|
||||
# 不等于 node.exe 真在里面,包小也不等于没在里面(压缩率会随版本漂)。
|
||||
# 故改为**解开包、查内容**,一次钉死三件事:
|
||||
# ① 三个随包资源(node.exe / sidecar.mjs / agent.mjs)必须都在 ——
|
||||
# 缺任一,装到真机上就是「应用能开、但托不起被控端」;
|
||||
# ② node.exe 的 PE Machine 必须等于目标架构 —— 最终产物的架构断言,
|
||||
# 直接对治「arm64 包里装的是 x64 node.exe」那类只在真机暴露的事故;
|
||||
# ③ node.exe 解压后体积必须 > 40MB —— 挡住「塞了个占位文件」。
|
||||
# 附带收益:真装一遍本身就是安装器冒烟(比只读目录更强)。
|
||||
- name: 断言安装包内容(随包 node.exe 齐全且架构正确)
|
||||
shell: pwsh
|
||||
# 静默安装理论上会自行结束;给个上限,免得万一卡住把 job 拖满 60 分钟
|
||||
timeout-minutes: 8
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$expect = '${{ matrix.pe }}'
|
||||
$bundle = 'apps/console/src-tauri/target/release/bundle'
|
||||
|
||||
function Get-PeMachine([string]$path) {
|
||||
$fs = [System.IO.File]::OpenRead($path)
|
||||
try {
|
||||
$br = [System.IO.BinaryReader]::new($fs)
|
||||
$fs.Position = 0x3C
|
||||
$peOff = $br.ReadInt32()
|
||||
$fs.Position = $peOff
|
||||
if ($br.ReadUInt32() -ne 0x00004550) { throw "不是 PE 文件:$path" }
|
||||
return '0x{0:x4}' -f $br.ReadUInt16()
|
||||
} finally { $fs.Dispose() }
|
||||
}
|
||||
|
||||
# 在候选目录里找出真正装了 node.exe 的那个(安装位置随安装形态而异)
|
||||
function Find-PayloadRoot([string[]]$candidates) {
|
||||
foreach ($c in $candidates) {
|
||||
if (-not (Test-Path $c)) { continue }
|
||||
$hit = Get-ChildItem -Path $c -Recurse -Filter 'node.exe' -ErrorAction SilentlyContinue |
|
||||
Select-Object -First 1
|
||||
if ($hit) { return $c }
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
# 安装器可能「自己先退出、再由子进程继续装」,故给它一段收敛时间
|
||||
function Wait-PayloadRoot([string[]]$candidates, [int]$seconds = 30) {
|
||||
$deadline = (Get-Date).AddSeconds($seconds)
|
||||
do {
|
||||
$r = Find-PayloadRoot $candidates
|
||||
if ($r) { return $r }
|
||||
Start-Sleep -Seconds 1
|
||||
} while ((Get-Date) -lt $deadline)
|
||||
return $null
|
||||
}
|
||||
|
||||
# 解开后的目录里必须三样齐全,且 node.exe 架构要对
|
||||
function Assert-Payload([string]$root, [string]$label) {
|
||||
$node = Get-ChildItem -Path $root -Recurse -Filter 'node.exe' -ErrorAction SilentlyContinue |
|
||||
Select-Object -First 1
|
||||
$side = Get-ChildItem -Path $root -Recurse -Filter 'sidecar.mjs' -ErrorAction SilentlyContinue |
|
||||
Select-Object -First 1
|
||||
$agent = Get-ChildItem -Path $root -Recurse -Filter 'agent.mjs' -ErrorAction SilentlyContinue |
|
||||
Select-Object -First 1
|
||||
$listing = (Get-ChildItem -Path $root -Recurse -File -ErrorAction SilentlyContinue |
|
||||
Select-Object -First 40 -ExpandProperty FullName) -join "`n"
|
||||
if (-not $side) { throw "$label 内未找到 sidecar.mjs`n实际内容:`n$listing" }
|
||||
if (-not $agent) {
|
||||
throw "$label 内未找到 agent.mjs —— 被控端未随包,装上后启动不了本机被控端`n实际内容:`n$listing"
|
||||
}
|
||||
if (-not $node) {
|
||||
throw "$label 内未找到 node.exe —— 随包 Node 运行时未打入,装上后一启动就找不到 node`n实际内容:`n$listing"
|
||||
}
|
||||
$mb = [math]::Round($node.Length / 1MB, 1)
|
||||
$machine = Get-PeMachine $node.FullName
|
||||
Write-Host " node.exe $mb MB PE Machine=$machine"
|
||||
Write-Host " sidecar.mjs $([math]::Round($side.Length / 1KB)) KB"
|
||||
Write-Host " agent.mjs $([math]::Round($agent.Length / 1KB)) KB"
|
||||
if ($mb -lt 40) { throw "$label 内 node.exe 仅 $mb MB —— 体积异常,疑似占位文件" }
|
||||
if ($machine -ne $expect) {
|
||||
throw "$label 内 node.exe 架构错误:期望 $expect,实际 $machine —— 严禁发布该包"
|
||||
}
|
||||
}
|
||||
|
||||
$msis = @(Get-ChildItem -Path "$bundle/msi/*.msi" -ErrorAction SilentlyContinue)
|
||||
$nsis = @(Get-ChildItem -Path "$bundle/nsis/*.exe" -ErrorAction SilentlyContinue)
|
||||
if (($msis.Count + $nsis.Count) -eq 0) { throw "未产出任何安装包($bundle 下为空)" }
|
||||
|
||||
$checked = 0
|
||||
|
||||
foreach ($m in $msis) {
|
||||
Write-Host "── MSI:$($m.Name) $([math]::Round($m.Length / 1MB, 1)) MB"
|
||||
# 管理安装(/a)= 只解包不注册,正合此处用途
|
||||
$dest = Join-Path $PWD 'pkg-extract/msi'
|
||||
Remove-Item -Recurse -Force $dest -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Force -Path $dest | Out-Null
|
||||
$log = Join-Path $PWD 'msi-extract.log'
|
||||
# ⚠️ 必须用 Start-Process -Wait -PassThru 取退出码,**不能**用 `& msiexec.exe`:
|
||||
# msiexec 是 GUI 子系统程序,脚本里 `&` 调它**既不等待、也不设 `$LASTEXITCODE`**
|
||||
# (实测:整个步骤 0.8 秒就跑到下一行,`$LASTEXITCODE` 为空 → `$null -ne 0` 判真 → 当场抛错)。
|
||||
# 参数拼成**单个字符串**传递,避免 -ArgumentList 数组被 PowerShell 自动加引号而破坏 /D= 语义。
|
||||
$argLine = "/a `"$($m.FullName)`" /qn TARGETDIR=`"$dest`" /l*v `"$log`""
|
||||
$p = Start-Process -FilePath 'msiexec.exe' -ArgumentList $argLine -Wait -PassThru
|
||||
Write-Host " msiexec 退出码 = $($p.ExitCode)"
|
||||
# 3010 = 成功但需重启,不是失败
|
||||
if ($p.ExitCode -ne 0 -and $p.ExitCode -ne 3010) {
|
||||
$tail = (Get-Content $log -Tail 30 -ErrorAction SilentlyContinue) -join "`n"
|
||||
throw "msiexec /a 失败(退出码 $($p.ExitCode)):$($m.Name)`n$tail"
|
||||
}
|
||||
$root = Wait-PayloadRoot @($dest)
|
||||
if (-not $root) { throw "MSI 管理安装后未在 $dest 下找到 node.exe" }
|
||||
Assert-Payload $root $m.Name
|
||||
$checked++
|
||||
}
|
||||
|
||||
foreach ($n in $nsis) {
|
||||
Write-Host "── NSIS:$($n.Name) $([math]::Round($n.Length / 1MB, 1)) MB"
|
||||
# /S 静默、/D 指定目录:两者都由 NSIS **运行时自身**在 .onInit 之前解析
|
||||
# (核心参数,不依赖 Tauri 模板),故对任何 NSIS 安装包都成立。
|
||||
# /D= 必须是最后一个参数且**不加引号**,故这里用仓库目录下的相对路径
|
||||
# (不含空格),而不是通常带空格的用户目录。
|
||||
$dest = Join-Path $PWD 'pkg-extract/nsis'
|
||||
Remove-Item -Recurse -Force $dest -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Force -Path $dest | Out-Null
|
||||
# 同 MSI:安装器也是 GUI 子系统程序,必须 Start-Process -Wait 才拿得到退出码。
|
||||
# 单个字符串传参 → `/D=` 保持原样不加引号(NSIS 的硬要求)。
|
||||
$p = Start-Process -FilePath $n.FullName -ArgumentList "/S /D=$dest" -Wait -PassThru
|
||||
Write-Host " 安装器退出码 = $($p.ExitCode)"
|
||||
if ($p.ExitCode -ne 0 -and $p.ExitCode -ne 3010) {
|
||||
throw "NSIS 静默安装失败(退出码 $($p.ExitCode)):$($n.Name)"
|
||||
}
|
||||
# /D= 理论上一律生效,但若模板在 .onInit 里改写了 $INSTDIR 就未必;
|
||||
# 故把默认安装位置一并列为候选,避免「装成功了却因为找不到目录而误报」。
|
||||
$root = Wait-PayloadRoot @(
|
||||
$dest,
|
||||
(Join-Path $env:LOCALAPPDATA 'nodeagent-console'),
|
||||
(Join-Path $env:LOCALAPPDATA 'Programs/nodeagent-console'),
|
||||
(Join-Path $env:ProgramFiles 'nodeagent-console')
|
||||
)
|
||||
if (-not $root) {
|
||||
throw "NSIS 静默安装后未找到随包 node.exe —— 已查:$dest 与默认安装位置"
|
||||
}
|
||||
Write-Host " 安装位置:$root"
|
||||
Assert-Payload $root $n.Name
|
||||
$checked++
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "✓ 已核对 $checked 个安装包:node.exe / sidecar.mjs / agent.mjs 齐全,node.exe 架构 = $expect"
|
||||
|
||||
- name: 上传安装包
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: nodeagent-console-windows-${{ matrix.arch }}
|
||||
path: |
|
||||
apps/console/src-tauri/target/release/bundle/msi/*.msi
|
||||
apps/console/src-tauri/target/release/bundle/nsis/*.exe
|
||||
# 一条架构没产出就报错,避免「jobs 全绿但产物不全」
|
||||
if-no-files-found: error
|
||||
# 交付流水线 §4:CI artifact 公开可下载,留存越久暴露面越大,30 → 7
|
||||
retention-days: 7
|
||||
|
||||
# 发布:tag 推送时把两个架构的安装包补挂到 release.mjs 建好的 Release 上。
|
||||
#
|
||||
# 为什么必须有这一步(2026-10-17 真机教训)
|
||||
# --------------------------------------
|
||||
# `scripts/release.mjs` 只产出 agent / cli / mcp,控制台的 NSIS/MSI
|
||||
# **从未进入过 Release** —— 后果是:用户从 Release 页面下载到的永远是旧控制台,
|
||||
# 而当天修的 6 个真机缺陷只存在于 CI artifact(retention 7 天,到期自动删除)。
|
||||
# 一句话:**修复发出去了,但没人拿得到。**
|
||||
publish:
|
||||
name: 补挂安装包到 Release
|
||||
# 只有 tag 才发布;分支构建(日常验证)一律跳过
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: 下载两个架构的安装包
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
pattern: nodeagent-console-windows-*
|
||||
|
||||
- name: 列出待发布文件(先看清再上传)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "--- dist 实际内容 ---"
|
||||
find dist -type f | sort | head -40
|
||||
echo "--- 安装包 ---"
|
||||
find dist -type f \( -name '*.exe' -o -name '*.msi' \) -printf '%10s %p\n' | sort -k2
|
||||
|
||||
# 期望值必须与上面的 build 矩阵一致:
|
||||
# x64 → msi + nsis(2 个)
|
||||
# arm64 → 仅 nsis(1 个,WiX 的 arm64 MSI 支持面窄,刻意不出)
|
||||
# 合计 3 个。
|
||||
#
|
||||
# ⚠️ 这里曾写死「两架构各一套(exe+msi)= 4 个」,与矩阵漂移后**每次必红** ——
|
||||
# 红门禁等于没有门禁(本项目反复踩的同一类坑:断言与实现各改各的)。
|
||||
# 故改为**按架构**断言,并在缺失时报出具体是哪个架构,便于一眼定位。
|
||||
fail=0
|
||||
for spec in "x64:2" "arm64:1"; do
|
||||
arch=${spec%%:*}; want=${spec##*:}
|
||||
dir="dist/nodeagent-console-windows-$arch"
|
||||
# ⚠️ 变量引用一律用 ${var} 形式:`$dir)` 这种「$var + 全角括号」在
|
||||
# set -u 下会被 bash 当成变量名的一部分 → "unbound variable",
|
||||
# 而写法在本地肉眼完全正常(本地实测复现过)。
|
||||
if [ ! -d "${dir}" ]; then
|
||||
echo "::error::缺少 ${arch} 的产物目录(${dir})—— 该架构构建失败或 artifact 名称变更"
|
||||
fail=1
|
||||
continue
|
||||
fi
|
||||
got=$(find "${dir}" -type f \( -name '*.exe' -o -name '*.msi' \) | wc -l)
|
||||
echo " ${arch}: ${got} / ${want}"
|
||||
if [ "${got}" -lt "${want}" ]; then
|
||||
echo "::error::${arch} 安装包不足(${got} < ${want})—— 产物缺失,或矩阵已变更但本断言未同步"
|
||||
fail=1
|
||||
fi
|
||||
done
|
||||
[ "${fail}" -eq 0 ] || exit 1
|
||||
echo "✓ 两架构安装包齐全"
|
||||
|
||||
- name: 挂到对应 Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# release.mjs 在本地跑、与本工作流并行,故先等 Release 出现(最多 10 分钟)
|
||||
for i in $(seq 1 60); do
|
||||
if gh release view "$TAG" >/dev/null 2>&1; then echo "✓ Release $TAG 已存在"; break; fi
|
||||
echo "等待 Release $TAG 创建… ($i/60)"; sleep 10
|
||||
done
|
||||
if ! gh release view "$TAG" >/dev/null 2>&1; then
|
||||
echo "::warning::Release $TAG 尚未创建,安装包仅留在 CI artifact。请先跑 scripts/release.mjs"
|
||||
exit 0
|
||||
fi
|
||||
find dist -type f \( -name '*.exe' -o -name '*.msi' \) -print0 \
|
||||
| xargs -0 gh release upload "$TAG" --clobber
|
||||
echo "✓ 已挂上的资产:"
|
||||
gh release view "$TAG" --json assets -q '.assets[].name'
|
||||
@@ -0,0 +1,129 @@
|
||||
# 交付流水线左移门禁(见 docs/DELIVERY-PIPELINE.md §1 §2 §8)
|
||||
#
|
||||
# 本文件只做「快且能阻断」的检查:密钥、依赖漏洞、类型、覆盖率、提交规范。
|
||||
# 真机链路(install.cmd / control.cmd 双击、Windows 能力)留在 windows-e2e.yml
|
||||
# —— 那一环跑得慢但价值最高,不可被这条更快的流水线替代。
|
||||
#
|
||||
# ⚠️ 阈值说明(别照搬通用 80%):详见 docs/DELIVERY-PIPELINE.md §2。
|
||||
# 全量覆盖率当前基线约 66.9%,故全量只观测;阻断只针对**变更文件**(≥70%)。
|
||||
|
||||
name: Quality Gate
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# ① 代码审计:密钥 + 依赖漏洞
|
||||
audit:
|
||||
name: 代码审计(密钥 + 依赖)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
# gitleaks 扫历史提交,需要完整历史
|
||||
fetch-depth: 0
|
||||
|
||||
# 密钥扫描:nodeagent 的密钥面比一般项目大 ——
|
||||
# PSK、Ed25519 私钥、TLS 自签证书、PSK.txt、agent.json
|
||||
- name: 密钥与敏感信息扫描
|
||||
uses: gitleaks/gitleaks-action@v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
# 与本仓库其他工作流保持一致(console-windows.yml / rust.yml / windows-e2e.yml 均为 12)
|
||||
version: 12
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
# 依赖漏洞:High 及以上阻断
|
||||
- name: 依赖漏洞扫描(SCA)
|
||||
# ⚠️ 必须显式指定公共 registry(2026-10-17)
|
||||
# 仓库 `.npmrc` 指向 registry.npmmirror.com(镜像加速),但**镜像不实现
|
||||
# audit 接口**,`pnpm audit` 会直接报 ERR_PNPM_AUDIT_ENDPOINT_NOT_EXISTS。
|
||||
# 结果就是这道门禁从加上那天起**一直是红的**,等于没有门禁。
|
||||
run: pnpm audit --audit-level=high --registry=https://registry.npmjs.org
|
||||
|
||||
# ② 质量门禁:类型 + 覆盖率
|
||||
gate:
|
||||
name: 质量门禁(类型 + 覆盖率)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
# 覆盖率门禁要算「变更文件」,需与基线做 diff
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
# 与本仓库其他工作流保持一致(console-windows.yml / rust.yml / windows-e2e.yml 均为 12)
|
||||
version: 12
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
# ⚠️ 必须先全量构建,再查类型(2026-10-17)
|
||||
#
|
||||
# 各包 import 的是**同仓其它包的编译产物**(dist/*.d.ts),依赖链是
|
||||
# `protocol → client → cli / mcp / agent`。CI 全新检出没有 dist,
|
||||
# 直接 typecheck 会逐层报 `TS2307: Cannot find module '@nodeagent/xxx'`;
|
||||
# 而本地因为 dist 还在,完全看不出来 —— 典型「本地绿 / CI 红」。
|
||||
#
|
||||
# 一开始只想「只构建 protocol 就够」,实测不够:cli 又报找不到 `@nodeagent/client`。
|
||||
# 与其逐个补依赖(下次加包还会漏),不如直接用 `-r build` 把整条链建出来。
|
||||
# 顺带:单测 import 的也是 dist/,这一步同时保证了覆盖率不为空(原步骤在 typecheck 之后,
|
||||
# 顺序本就矛盾 —— 既然后面也要 build,不如提前一次到位)。
|
||||
- name: 构建 workspace(typecheck 与单测都依赖 dist)
|
||||
run: pnpm -r build
|
||||
|
||||
- run: pnpm typecheck
|
||||
|
||||
- name: 采集覆盖率
|
||||
run: |
|
||||
mkdir -p coverage
|
||||
node --test --experimental-test-coverage \
|
||||
--test-reporter=lcov \
|
||||
--test-reporter-destination=coverage/lcov.info \
|
||||
"tests/unit/**/*.test.mjs"
|
||||
|
||||
- name: 覆盖率门禁
|
||||
run: node scripts/ci/check-coverage.mjs --lcov coverage/lcov.info --base origin/main --threshold 70
|
||||
|
||||
# coverage/ 已在 .gitignore 中,此处留档便于人工查看
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: coverage
|
||||
path: coverage/
|
||||
retention-days: 7
|
||||
|
||||
# ③ 提交信息规范
|
||||
commits:
|
||||
name: 提交信息规范
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: 校验 Conventional Commits
|
||||
run: node scripts/ci/check-commit-msg.mjs --base ${{ github.event.pull_request.base.sha }}
|
||||
@@ -12,11 +12,17 @@ on:
|
||||
paths:
|
||||
- 'apps/console/src-tauri/**'
|
||||
- 'apps/console/sidecar/**'
|
||||
- 'scripts/prepare-console.mjs'
|
||||
- 'scripts/lib/bundle-agent.mjs'
|
||||
- 'apps/agent/**'
|
||||
- '.github/workflows/rust.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'apps/console/src-tauri/**'
|
||||
- 'apps/console/sidecar/**'
|
||||
- 'scripts/prepare-console.mjs'
|
||||
- 'scripts/lib/bundle-agent.mjs'
|
||||
- 'apps/agent/**'
|
||||
- '.github/workflows/rust.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -58,14 +64,50 @@ jobs:
|
||||
version: 9
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 构建 workspace 包(sidecar 依赖其 dist)
|
||||
run: pnpm -r --filter "./packages/*" build
|
||||
# ⚠️ 顺序有依赖: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:
|
||||
|
||||
@@ -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"
|
||||
# 真机教训(虚拟化平台 + 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: |
|
||||
|
||||
@@ -54,3 +54,7 @@ keys/
|
||||
|
||||
# 本地 agent 工作数据(memory / automations 等,属本机状态,不入库)
|
||||
.workbuddy/
|
||||
|
||||
# 被控端 / 控制端本机运行配置(含真实密钥与节点地址,绝不入库)
|
||||
config.json
|
||||
agent.json
|
||||
@@ -0,0 +1,25 @@
|
||||
# gitleaks 误报台账(按指纹精确忽略,不影响任何规则)
|
||||
#
|
||||
# ## 为什么用 .gitleaksignore 而不是 .gitleaks.toml 的 allowlist
|
||||
# - `.gitleaks.toml` 的顶层 `[allowlist].paths = ['tests/**']` 会对**所有规则**放行,
|
||||
# 于是 `private-key`(私钥块)、`aws-access-token` 等在测试目录下也失效 ——
|
||||
# 而「为了造样本把真实私钥贴进 fixture」恰恰是最常见的泄露方式。
|
||||
# - `[[rules]]` 重定义 `generic-api-key` 会**替换**内置规则(gitleaks 要求重写 regex),
|
||||
# 等于自己维护一份正则,反而更容易漏。
|
||||
# - 指纹是 `<commit>:<file>:<rule>:<line>`,**只忽略这一条**,其余一概照旧。
|
||||
#
|
||||
# ## 逐条核对记录(2026-10-17)
|
||||
# 4 条命中全部位于测试文件,值均为明显的递增占位(含 `0123456789abcdef` 模式):
|
||||
# sec-e2e-key-0123456789abcdef0123456789
|
||||
# test-key-0123456789abcdef
|
||||
# fs-key-0123456789abcdef0123456789abcdef
|
||||
# e2e-test-key-0123456789abcdef0123456789abcdef
|
||||
# 已逐一核对:**均不等于被控端真实 PSK,均不含 64 位十六进制串** → 判定为误报。
|
||||
#
|
||||
# ## 若日后有人在此文件里追加指纹
|
||||
# 必须先做同样的核对:确认该值不是真实凭据。**这个文件不是「让 CI 变绿」的开关。**
|
||||
#
|
||||
0d59d6032b96c42f9eda380c4b6c2e95d253f856:tests/e2e/run.mjs:generic-api-key:906
|
||||
f4c605cc6d26e85c81b50e10da3a466f755d8221:tests/unit/hmac.test.mjs:generic-api-key:6
|
||||
4ff1b4fd9021755d7ed060da5baadc5618dcbc3d:tests/e2e/run.mjs:generic-api-key:30
|
||||
77c024703abf5ac07e63f73c551f623539ff0856:tests/e2e/run.mjs:generic-api-key:21
|
||||
+987
-3
File diff suppressed because it is too large.
Load diff
@@ -168,7 +168,7 @@ nodeagent --node win audit anchor --note "每日例行" # 追加一个锚点
|
||||
> 真正发挥价值的前提:把锚点文件指向**另一台机器 / 网盘同步目录**。
|
||||
> 本机文件被整盘重写时,锚点也随之消失 —— 那就等于没锚。
|
||||
|
||||
**51 项能力** · **41 个 MCP 工具** · **269 项单元测试** · **30 项端到端用例** · **3 项 Rust 壳单测**(真跑 Node sidecar)(CI 在真实 Windows / Linux runner 上验证)
|
||||
**52 项能力** · **42 个 MCP 工具** · **483 项单元测试** · **50 项端到端用例**(34 通用 + 16 Windows 专属) · **23 项 Rust 壳单测**(真跑 Node sidecar)(CI 在真实 Windows / Linux runner 上验证)
|
||||
|
||||
> 关键里程碑:**GUI 语义**(`window.list` + `screen.find`,UIA 找不到自动降级 OCR)
|
||||
> 让 AI 从「看得到画面但读不懂界面」变成「按名字取坐标点下去」。
|
||||
@@ -195,7 +195,7 @@ pnpm test:rust # Rust 控制端客户端(需 cargo)
|
||||
1. 在控制端构建安装包(**需在项目目录执行**):
|
||||
|
||||
```bash
|
||||
cd <项目目录> # 例如 ~/WorkBuddy/NodeAgent/nodeagent
|
||||
cd <项目目录> # 例如 ~/projects/nodeagent
|
||||
pnpm pack:win
|
||||
|
||||
# 或从任意目录调用:
|
||||
@@ -343,8 +343,17 @@ nodeagent pull "C:\big.iso" --out ./big.iso # 下载(大文件自动
|
||||
协议客户端由 **Node sidecar**(`apps/console/sidecar/`)承担(复用 `@nodeagent/client`,
|
||||
Tauri 侧经 Cargo path 依赖),**不引入第二套协议实现**。
|
||||
|
||||
**打开即用、全程不碰命令行**:应用自带 Node 运行时与被控端脚本,
|
||||
默认页面就是「本机服务」——点按钮即可启动 / 停止 / 重启本机被控端,
|
||||
并支持检查更新、查看日志、后台常驻(托盘)、开机自启。
|
||||
|
||||
**界面**:导航为**纯文字**(不使用图标);配色为两套中性灰阶主题 ——
|
||||
「简约白」与「高级灰」,侧栏底部一键切换。
|
||||
|
||||
| 页面 | 能力 |
|
||||
|---|---|
|
||||
| **本机服务**(默认) | 本机被控端启停/重启、连接本机、节点 ID 与密钥、开机自启、后台运行、异常自动重启、检查并应用更新 |
|
||||
| **服务日志** | 被控端 stdout/stderr 实时推送、关键字过滤、暂停接收、导出、清空 |
|
||||
| 设备管理 | 连接/断开、保存多台设备、能力清单与握手元信息、实时日志 |
|
||||
| 状态总览 | CPU / 内存 / 磁盘 / 网卡,3s 轮询 |
|
||||
| 终端 | `system.shell.exec`(可设超时)、历史回放 |
|
||||
@@ -354,15 +363,35 @@ 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`)。
|
||||
|
||||
⚠️ 平台差异:`app.*` 与 `system.service.list` **仅在被控端为 Windows 时可用**,
|
||||
非 Windows 被控端会返回 `E_UNSUPPORTED_PLATFORM`。控制台连接后会探测
|
||||
`system.info.os` 并**事前禁用**这些入口,而不是发出注定失败的请求。
|
||||
|
||||
⚠️ 控制台**已自带** Node 运行时与被控端脚本,目标机器无需预装 Node.js,也无需先手工安装被控端;
|
||||
但**开机自启与被控端的图形能力**要求运行在**登录用户会话**中(Windows 计划任务用
|
||||
`Interactive` + 登录用户;SYSTEM 会话 0 没有交互桌面,截图/窗口类能力会失败)。
|
||||
|
||||
## 安全模型
|
||||
|
||||
三层防护,逐层收窄权限:
|
||||
@@ -451,10 +480,28 @@ nodeagent keygen [--id mac_01] # 生成 Ed25519 密钥 + AC
|
||||
```bash
|
||||
nodeagent restart [--delay 2000] # 受控重启被控端(配置变更后让它生效)
|
||||
nodeagent deploy <agent.mjs> [--path <远端路径>] # 一键升级(备份 → 上传 → 重启 → 复验)
|
||||
nodeagent rollback [--dry-run] # 从 LKG 回滚(指纹比对 → 覆盖 → 重启 → 复核)
|
||||
nodeagent daemon [start|stop|status] # 常驻连接池(批量操作省去每次握手)
|
||||
nodeagent fanout <能力名> [--nodes a,b] [--args JSON] # 多设备并发下发并汇总
|
||||
```
|
||||
|
||||
> **回滚的能力圈边界**:`rollback` 是 RPC 命令,**需被控端可达**——救的是「新版本能启动但需回退」。
|
||||
> 新版本「完全起不来」(无监听可连)时:被控端看门狗会在启动连续失败 N 次后自动从 LKG 恢复
|
||||
> (`scripts/startup-guard.mjs`);仍不行再走带外恢复(`docs/DELIVERY-PIPELINE.md` 附录 C)。
|
||||
> 自动回滚刻意不做,理由见该文 §6。
|
||||
|
||||
**看门狗(被控端自启链上的一层)**:被控端由外层 supervisor 看守,启动连续失败 N 次(默认 3)自动从 LKG 恢复入口文件:
|
||||
|
||||
| 平台 | supervisor | 接入方式 |
|
||||
|---|---|---|
|
||||
| Windows | 计划任务 | `scripts/install.ps1` 把启动动作包一层 `scripts/startup-guard.mjs`(`RestartCount 10`) |
|
||||
| Linux | systemd | `scripts/install-linux.sh` 的 unit `ExecStart` 包一层 |
|
||||
| macOS | launchd | 无安装器,手工接法见 `docs/DEVELOPMENT.md` §7.4 |
|
||||
|
||||
安全边界:**无 LKG / 无指纹匹配的备份时只记日志放行,绝不擦除安装目录**;恢复目标恒为 LKG
|
||||
(单一、已验证,不做「择优回滚」)。看门狗自身「裸环境可跑」——零项目依赖,只依赖
|
||||
Node ≥22 内置模块 + 同目录纯函数核心,随安装包/便携包分发。
|
||||
|
||||
### 长任务与剪贴板
|
||||
|
||||
```bash
|
||||
@@ -506,7 +553,7 @@ nodeagent mouse drag 300 200 700 500 # 拖拽(拖文件/框选
|
||||
| `na_clip` | 读写剪贴板文本或图片 |
|
||||
| `na_fs_list` · `na_fs_read` · `na_fs_write` · `na_fs_stat` | 读目录 / 读文件 / 写文件 / 元信息 |
|
||||
| `na_restart` | 受控重启被控端 |
|
||||
| `na_audit` · `na_discover` · `na_nodes` · `na_use` | 查审计、发现设备、多设备切换 |
|
||||
| `na_audit` · `na_audit_l2` · `na_discover` · `na_nodes` · `na_use` | 查审计(`na_audit_l2` = 系统级 L2,仅 Windows 被控端)、发现设备、多设备切换 |
|
||||
| `na_event_watch` · `na_event_poll` · `na_event_list` · `na_event_unwatch` | 事件订阅;`notify:true` 走 MCP 日志通知实时推送,否则用 `na_event_poll` 拉取 |
|
||||
| `na_macro_run` | 回放 GUI 宏(步骤序列,逐步校验) |
|
||||
| `na_metrics` | 成功指标(闭环率 / 装软件率 / P95 / 拦截率)+ 达标判定 |
|
||||
@@ -640,14 +687,15 @@ nodeagent/
|
||||
| **批次 15** | `screen.find` **图像模板匹配**(纯图标/无文字控件盲区)+ 一堆真机根因修复 | ✅ |
|
||||
| **批次 16 ~ 26** | 网络两阶段提交 · 音频控制 · 构建指纹 · 自更新 · 证书钉住 · 每客户端 PSK · 连接层三防护 · 零信任安装 · 审计锚定 | ✅ |
|
||||
| **批次 27 ~ 29** | **语义可断言**(`gui.await` + UIA 属性 + `where` 谓词 + `any_of`)· **状态可回看**(`log.query` + `monitor.*`) | ✅ |
|
||||
| **发布** | **v2.0.0**(2026-10-10):51 项能力 · 273 单测 · Windows/Rust CI 全绿 | ✅ 已发布 |
|
||||
| **发布** | **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 虚拟机补验证**(含逐条命令与结论标注规则)
|
||||
- [`docs/DEPLOY-LINUX.md`](docs/DEPLOY-LINUX.md) —— **Linux 被控端部署**(Debian/Ubuntu/NAS 系统;含实测能力清单与排查)
|
||||
- [`docs/VM-VERIFICATION.md`](docs/VM-VERIFICATION.md) —— **无 Windows 真机时,用宿主虚拟化平台的 Win11 ARM 虚拟机补验证**(含逐条命令与结论标注规则)
|
||||
- [`CHANGELOG.md`](CHANGELOG.md) —— **变更日志**(未发布版本的内容汇总 / 历史版本)
|
||||
- [`docs/ROADMAP.md`](docs/ROADMAP.md) —— **当前进度盘点与下一步路线**(2026-10-07 基线:待办收尾 / 语义可断言 / 状态采样 / 安全残余)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/agent",
|
||||
"version": "2.0.1",
|
||||
"version": "2.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
@@ -20,7 +20,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@nodeagent/protocol": "workspace:*",
|
||||
"selfsigned": "^2.4.1",
|
||||
"selfsigned": "^5.5.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
+71
-5
@@ -5,6 +5,7 @@ import { agentDir } from './config.js';
|
||||
import {
|
||||
judgeAnchors,
|
||||
parseAnchorsFromJsonl,
|
||||
AUDIT_TYPES,
|
||||
type AnchorComparison,
|
||||
type AnchorRecord,
|
||||
} from '@nodeagent/protocol';
|
||||
@@ -24,8 +25,44 @@ export type AuditType =
|
||||
| 'net.deny'
|
||||
/** v19:agent 自更新(拉取式) */
|
||||
| 'agent.update'
|
||||
/**
|
||||
* v2.1.0:**L2 系统级审计事件**(Windows Security 日志归一化副本)。
|
||||
* 真源注释见 `packages/protocol/src/audit-types.ts`——不复用 `agent.start`:
|
||||
* L2 策略操作与进程生命周期无关,混入会污染两边的统计与筛选。
|
||||
*/
|
||||
| 'agent.l2'
|
||||
/** v2.0.0:连接空闲被主动断开 */
|
||||
| 'session.idle_close';
|
||||
| '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 {
|
||||
@@ -41,6 +78,13 @@ export interface AuditEntry {
|
||||
error?: string | null;
|
||||
/** 参数摘要(sha256 前缀),用于比对而不泄露内容 */
|
||||
args_digest?: string;
|
||||
/**
|
||||
* v2.1.0(A-P0-4):参数序列化后的**总字节数**。
|
||||
* 与 `args_digest_truncated` 同时出现即表示「摘要只覆盖前 64KB」。
|
||||
*/
|
||||
args_bytes?: number;
|
||||
/** v2.1.0(A-P0-4):摘要是否为截断哈希(序列化超过 64KB 时只哈希前 64KB)。 */
|
||||
args_truncated?: boolean;
|
||||
/** 参数预览(默认不记录;开启后自动脱敏) */
|
||||
args_preview?: string;
|
||||
reason?: string;
|
||||
@@ -137,12 +181,34 @@ function sanitize(value: unknown, depth = 0): unknown {
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 参数摘要阈值(字节):超过即只哈希前 64KB。
|
||||
*
|
||||
* ⚠️ A-P0-4(2026-10-25):原实现每次 invoke 都全量 `JSON.stringify` + sha256。
|
||||
* `fs.write` 的 data 可达数 MB(协议单帧,ws 默认 maxPayload 隐式放行)——
|
||||
* 同步序列化 + 哈希一个 8MB 字符串 ≈ 数十毫秒级 CPU,且**阻塞事件循环**:
|
||||
* 期间 GUI 注入(`input.*`)、事件推送、保活 ping 全部延迟。
|
||||
* 截断后:摘要口径从「全量哈希」变为「前 64KB 哈希」,**只影响审计摘要,
|
||||
* 不影响任何安全属性**(摘要用途是比对与去泄密,从来不是完整性证明)。
|
||||
* 总长记入 `args_bytes`,截断记入 `args_truncated`,语义不断代。
|
||||
*/
|
||||
const ARGS_DIGEST_MAX_BYTES = 64 * 1024;
|
||||
|
||||
/** 生成参数摘要与(可选)脱敏预览 —— 字段名与 AuditEntry 对齐,便于展开。 */
|
||||
export function describeArgs(args: unknown): { args_digest: string; args_preview?: string } {
|
||||
export function describeArgs(args: unknown): {
|
||||
args_digest: string;
|
||||
args_bytes?: number;
|
||||
args_truncated?: boolean;
|
||||
args_preview?: string;
|
||||
} {
|
||||
const json = JSON.stringify(args ?? {});
|
||||
const digest = `sha256:${createHash('sha256').update(json).digest('hex').slice(0, 16)}`;
|
||||
if (!opts.logArgs) return { args_digest: digest };
|
||||
return { args_digest: digest, args_preview: JSON.stringify(sanitize(args)) };
|
||||
const bytes = Buffer.byteLength(json, 'utf8');
|
||||
// 截断按**字节**切(多字节字符下按字符切会得到非确定性的 64KB 边界)
|
||||
const payload = bytes > ARGS_DIGEST_MAX_BYTES ? Buffer.from(json, 'utf8').subarray(0, ARGS_DIGEST_MAX_BYTES) : json;
|
||||
const digest = `sha256:${createHash('sha256').update(payload).digest('hex').slice(0, 16)}`;
|
||||
const base = bytes > ARGS_DIGEST_MAX_BYTES ? { args_bytes: bytes, args_truncated: true } : {};
|
||||
if (!opts.logArgs) return { args_digest: digest, ...base };
|
||||
return { args_digest: digest, ...base, args_preview: JSON.stringify(sanitize(args)) };
|
||||
}
|
||||
|
||||
/** 写入一条审计记录。任何异常都被吞掉 —— 审计绝不阻断主流程。 */
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
import { IS_WINDOWS, execCommand } from '../util/exec.js';
|
||||
import { psLit } from '../util/quote.js';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
@@ -108,7 +109,9 @@ export async function appInstall(args: Args): Promise<unknown> {
|
||||
);
|
||||
}
|
||||
|
||||
const target = id ? `--id ${quote(id)} --exact` : `${quote(pkg)}`;
|
||||
// ⚠️ package / id 来自控制端,且命令经 `powershell.exe -Command` 执行
|
||||
// → 必须用 PS 单引号字面量(旧的 `"…"` + `\"` 写法可被 `;` / `$(…)` 逃逸,同类 P0)。
|
||||
const target = id ? `--id ${psLit(id)} --exact` : `${psLit(pkg)}`;
|
||||
const flags = [
|
||||
silent ? '--silent' : '',
|
||||
'--accept-package-agreements',
|
||||
@@ -134,10 +137,6 @@ export async function appInstall(args: Args): Promise<unknown> {
|
||||
};
|
||||
}
|
||||
|
||||
function quote(s: string): string {
|
||||
return `"${s.replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
|
||||
function extractVersion(output: string): string | null {
|
||||
const m = /(\d+\.\d+(?:\.\d+)*(?:-\w+)?)/.exec(output);
|
||||
return m ? m[1]! : null;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
import { IS_WINDOWS, execCommand } from '../util/exec.js';
|
||||
import { runPowerShellSmart } from '../util/ps-helper.js';
|
||||
import { shLit } from '../util/quote.js';
|
||||
import { assertGuiPlatform } from '../util/platform.js';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
@@ -132,7 +134,9 @@ async function runWindows(body: string, timeoutMs = 30_000): Promise<string> {
|
||||
|
||||
/** macOS:osascript 原生音量控制(静音用 `output muted`,音量用 `output volume`)。 */
|
||||
async function runMac(script: string): Promise<string> {
|
||||
const r = await execCommand({ command: `osascript -e ${JSON.stringify(script)}`, timeoutMs: 15_000 });
|
||||
// ⚠️ AppleScript 源码里含用户可控文本 → 必须走 shell 单引号字面量。
|
||||
// 曾用 JSON.stringify(双引号)→ bash 会在双引号内展开 `$(...)` 与反引号(注入面)。
|
||||
const r = await execCommand({ command: `osascript -e ${shLit(script)}`, timeoutMs: 15_000 });
|
||||
if (r.exit_code !== 0) {
|
||||
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '音频控制失败(osascript)', {
|
||||
detail: (r.stderr || r.stdout).slice(0, 300),
|
||||
@@ -160,6 +164,8 @@ async function macState(): Promise<{ muted: boolean; volume: number }> {
|
||||
const AUDIO_BACKEND_WIN = 'NAudio/CoreAudioAPI';
|
||||
|
||||
export async function audioGet(_args: Args): Promise<unknown> {
|
||||
// 该能力仅在 Windows/macOS 有实现(Linux 端未实现)
|
||||
assertGuiPlatform('audio.get');
|
||||
if (IS_WINDOWS) {
|
||||
const state = parseAudioState(await runWindows('[NAAudio]::Get()'));
|
||||
return { ...state, platform: 'windows', backend: AUDIO_BACKEND_WIN };
|
||||
@@ -175,6 +181,8 @@ export async function audioGet(_args: Args): Promise<unknown> {
|
||||
// ---------------- system.audio.set ----------------
|
||||
|
||||
export async function audioSet(args: Args): Promise<unknown> {
|
||||
// 该能力仅在 Windows/macOS 有实现(Linux 端未实现)
|
||||
assertGuiPlatform('audio.set');
|
||||
const muteArg = args['mute'];
|
||||
const volumeArg = args['volume'];
|
||||
const hasMute = typeof muteArg === 'boolean';
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { join } from 'node:path';
|
||||
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
import { execCommand } from '../util/exec.js';
|
||||
import { assertWindows } from '../util/platform.js';
|
||||
import { agentDir } from '../config.js';
|
||||
import { audit } from '../audit.js';
|
||||
|
||||
/**
|
||||
* L2 系统级审计兜底(2026-10-17)。
|
||||
*
|
||||
* ## 它补的是 L1 结构上记不到的那块
|
||||
* L1 是「agent 自己记的」:只看得见经过自己 RPC 的调用。
|
||||
* 攻击者绕过 agent 直接跑系统命令、或干脆把 agent 停掉 —— L1 全都看不到。
|
||||
* L2 采集**操作系统**的审计事件,补的正是这块。
|
||||
*
|
||||
* ## ⚠️ 信任边界(不要夸大它的作用)
|
||||
* 采集器与被审计对象**同进程同权限** —— 攻击者能停 agent 就能停采集器。
|
||||
* 故本地 `audit-l2.log` 只是一份**归一化副本**,防篡改价值有限。
|
||||
* 真正的独立性来自**把记录转发出机**(与 SECURITY.md §4.8 的锚定同一思路)。
|
||||
* 本模块不实现自动转发,只提供导出接口。
|
||||
*
|
||||
* ## 为什么 Windows 侧选内置 auditpol 而不是 Sysmon
|
||||
* - Sysmon 是第三方驱动:需单独分发、需重启生效、版本行为有差异;
|
||||
* - 内置高级审计策略是 Windows 自带的,零依赖即可立起来;
|
||||
* - 策略:检测到装了 Sysmon 就并用,没装也不尝试下载(不在本仓库职责内)。
|
||||
*
|
||||
* ## 平台现状
|
||||
* - Windows:**已落地**(status / enable / collect / verify)
|
||||
* - Linux:仅设计(auditd,需预装)—— 见 docs/AUDIT-L2.md
|
||||
* - macOS:仅设计,**ESF 需 Apple entitlement,个人自签无法启用** —— 不写假装能用的代码
|
||||
*/
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
/** 归一化后的事件类别(跨平台枚举,详见 docs/AUDIT-L2.md §4)。 */
|
||||
export type L2Kind =
|
||||
| 'process.create'
|
||||
| 'process.exit'
|
||||
| 'auth.success'
|
||||
| 'auth.failure'
|
||||
| 'file.access';
|
||||
|
||||
export interface L2Entry {
|
||||
/** 事件发生时间(来自 OS,不是采集时刻) */
|
||||
ts: number;
|
||||
src: 'windows-security' | 'sysmon' | 'linux-auditd' | 'macos-esf';
|
||||
/** OS 原始事件 ID —— 保留原始值以便人工核对 */
|
||||
event_id: number | string;
|
||||
kind: L2Kind;
|
||||
subject?: string;
|
||||
object?: string;
|
||||
detail?: Record<string, unknown>;
|
||||
/** 采集时刻。与 ts 相差过大说明期间离线或 agent 未运行 */
|
||||
collected_at: number;
|
||||
prev?: string;
|
||||
hash?: string;
|
||||
}
|
||||
|
||||
const GENESIS = 'genesis';
|
||||
|
||||
/** L2 独立落盘,不与 L1 混 —— 混了就无法区分「agent 记的」与「OS 记的」。 */
|
||||
export function l2FilePath(): string {
|
||||
return join(agentDir(), 'audit-l2.log');
|
||||
}
|
||||
|
||||
function hashEntry(record: Record<string, unknown>, prev: string): string {
|
||||
const keys = Object.keys(record).sort();
|
||||
const body = JSON.stringify(record, keys);
|
||||
return createHash('sha256').update(`${prev}|${body}`).digest('hex').slice(0, 32);
|
||||
}
|
||||
|
||||
function lastHashOf(): string {
|
||||
try {
|
||||
const p = l2FilePath();
|
||||
if (!existsSync(p)) return GENESIS;
|
||||
const lines = readFileSync(p, 'utf8').split('\n').filter((l) => l.trim());
|
||||
const last = lines[lines.length - 1];
|
||||
if (!last) return GENESIS;
|
||||
return (JSON.parse(last) as L2Entry).hash ?? GENESIS;
|
||||
} catch {
|
||||
return GENESIS;
|
||||
}
|
||||
}
|
||||
|
||||
/** 追加一批 L2 记录(带独立哈希链)。 */
|
||||
function appendL2(entries: Omit<L2Entry, 'prev' | 'hash' | 'collected_at'>[]): number {
|
||||
const p = l2FilePath();
|
||||
mkdirSync(agentDir(), { recursive: true });
|
||||
let prev = lastHashOf();
|
||||
const now = Date.now();
|
||||
let n = 0;
|
||||
for (const e of entries) {
|
||||
const full = { ...e, collected_at: now, prev } as unknown as Record<string, unknown>;
|
||||
const hash = hashEntry(full, prev);
|
||||
(full as Record<string, unknown>)['hash'] = hash;
|
||||
appendFileSync(p, `${JSON.stringify(full)}\n`, 'utf8');
|
||||
prev = hash;
|
||||
n += 1;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// ---------------- Windows:Security 事件 → 归一化 ----------------
|
||||
|
||||
/** Windows 事件 ID → 归一化类别。 */
|
||||
const WIN_EVENT_KIND: Record<number, L2Kind> = {
|
||||
4688: 'process.create',
|
||||
4689: 'process.exit',
|
||||
4624: 'auth.success',
|
||||
4625: 'auth.failure',
|
||||
4663: 'file.access',
|
||||
};
|
||||
|
||||
const WIN_WATCHED_IDS = Object.keys(WIN_EVENT_KIND).map(Number);
|
||||
|
||||
/**
|
||||
* 采集 Windows Security 日志。
|
||||
*
|
||||
* 只用 PowerShell 内置 cmdlet(Get-WinEvent),不引外部依赖。
|
||||
* @param sinceMs 起始时间(Unix ms);默认 24 小时内
|
||||
* @param limit 上限,防止一次拉爆(Security 日志可能极多)
|
||||
*/
|
||||
async function collectWindows(sinceMs: number, limit: number): Promise<Omit<L2Entry, 'prev' | 'hash' | 'collected_at'>[]> {
|
||||
// 用 ISO8601 传时间,避免区域设置导致的日期解析差异
|
||||
const sinceIso = new Date(sinceMs).toISOString();
|
||||
const idList = WIN_WATCHED_IDS.join(',');
|
||||
const ps = [
|
||||
`$ErrorActionPreference = 'Stop'`,
|
||||
`$ids = @(${idList})`,
|
||||
`$since = (Get-Date '${sinceIso}').ToUniversalTime()`,
|
||||
`$evs = Get-WinEvent -FilterHashtable @{LogName='Security'; Id=$ids; StartTime=$since} -MaxEvents ${limit} -ErrorAction SilentlyContinue`,
|
||||
`if ($null -eq $evs) { Write-Output '[]'; exit 0 }`,
|
||||
`$out = foreach ($e in $evs) {`,
|
||||
` $x = @{ id = $e.Id; t = $e.TimeCreated.ToUniversalTime().ToString('o'); msg = $e.Message }`,
|
||||
` try { $x.sub = $e.Properties[0].Value.ToString() } catch { $x.sub = '' }`,
|
||||
` $x | ConvertTo-Json -Compress -Depth 3`,
|
||||
`}`,
|
||||
`Write-Output ($out -join "\`n")`,
|
||||
].join('\n');
|
||||
|
||||
const r = await execCommand({ command: ps, timeoutMs: 60_000 });
|
||||
if (r.exit_code !== 0) {
|
||||
// 常见:权限不足(非管理员读不了 Security 日志)
|
||||
throw new CapabilityError(
|
||||
ErrorCodes.EXECUTION_FAILED,
|
||||
'读取 Windows Security 日志失败(常见原因:未以管理员运行,或审计策略未启用)',
|
||||
{ detail: (r.stderr || r.stdout).slice(0, 300) },
|
||||
);
|
||||
}
|
||||
const text = r.stdout.trim();
|
||||
if (!text || text === '[]') return [];
|
||||
|
||||
const out: Omit<L2Entry, 'prev' | 'hash' | 'collected_at'>[] = [];
|
||||
for (const line of text.split('\n')) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const o = JSON.parse(line) as { id?: number; t?: string; msg?: string; sub?: string };
|
||||
const id = Number(o.id);
|
||||
const kind = WIN_EVENT_KIND[id];
|
||||
if (!kind) continue;
|
||||
const ts = o.t ? Date.parse(o.t) : Date.now();
|
||||
// 从 Message 里抠进程名(4688 的「新进程名称」),拿不到就留空 —— 不猜
|
||||
// 括号不可省:TS 不允许 ?? 与 || 无括号混用
|
||||
const obj =
|
||||
(/新进程名称:\s*(.+)/.exec(o.msg ?? '')?.[1]?.trim() ??
|
||||
/进程名称:\s*(.+)/.exec(o.msg ?? '')?.[1]?.trim()) ||
|
||||
undefined;
|
||||
out.push({
|
||||
ts: Number.isFinite(ts) ? ts : Date.now(),
|
||||
src: 'windows-security',
|
||||
event_id: id,
|
||||
kind,
|
||||
subject: o.sub || undefined,
|
||||
object: obj,
|
||||
detail: { raw_message_head: (o.msg ?? '').slice(0, 300) },
|
||||
});
|
||||
} catch {
|
||||
/* 跳过解析失败的单条,不影响整体 */
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------- 对外能力 ----------------
|
||||
|
||||
export async function auditL2(args: Args): Promise<unknown> {
|
||||
const action = (args['action'] as string | undefined) ?? 'status';
|
||||
|
||||
// 平台门控走统一 assertWindows(与 app.* / system.service.list 同一形状):
|
||||
// ① 错误信息格式统一(能力名 + 未实现平台 + 受支持平台);
|
||||
// ② 让 console-contract 的平台门控守卫扫得到本能力(裸 throw 会漏检 → 控制台
|
||||
// WINDOWS_ONLY_CAPS 少列一项 → 非 Windows 被控端上按钮照常显示,点了才报错)。
|
||||
// 平台现状与 Linux/macOS 未实现的原因见文件头注释与 docs/AUDIT-L2.md。
|
||||
assertWindows('system.audit.l2');
|
||||
|
||||
if (action === 'status') return l2Status();
|
||||
if (action === 'enable') return await l2Enable();
|
||||
if (action === 'collect') {
|
||||
const since = typeof args['since'] === 'number' ? args['since'] : Date.now() - 24 * 3600 * 1000;
|
||||
const limit = Math.max(1, Math.min(5000, (args['limit'] as number | undefined) ?? 1000));
|
||||
const entries = await collectWindows(since, limit);
|
||||
const written = appendL2(entries);
|
||||
audit({ type: 'agent.l2', status: 'ok', reason: `l2.collect written=${written} since=${new Date(since).toISOString()}` });
|
||||
return {
|
||||
collected: entries.length,
|
||||
written,
|
||||
since,
|
||||
limit,
|
||||
file: l2FilePath(),
|
||||
note:
|
||||
'本地 audit-l2.log 只是归一化副本;真正防篡改依赖把记录转发出机(见 docs/AUDIT-L2.md §2)',
|
||||
};
|
||||
}
|
||||
if (action === 'verify') return verifyL2();
|
||||
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `action 只支持 status|enable|collect|verify,收到 ${action}`);
|
||||
}
|
||||
|
||||
/** 检查 Windows 审计策略是否已启用(并提示命令行记录开关)。 */
|
||||
async function l2Status(): Promise<unknown> {
|
||||
const ps = [
|
||||
`$ErrorActionPreference='Stop'`,
|
||||
`$o = auditpol /get /category:* /r`,
|
||||
// /r 输出 CSV:Machine Name,Policy Target,Subcategory,Subcategory GUID,Inclusion Setting,Exclusion Setting
|
||||
`$rows = $o | ConvertFrom-Csv`,
|
||||
`$want = @('Process Creation','Logon','Process Termination')`,
|
||||
`$got = @()`,
|
||||
`foreach ($w in $want) {`,
|
||||
` $m = $rows | Where-Object { $_.Subcategory -eq $w } | Select-Object -First 1`,
|
||||
` if ($m) { $got += ($w + '=' + $m.'Inclusion Setting') } else { $got += ($w + '=not found') }`,
|
||||
`}`,
|
||||
`Write-Output ($got -join '; ')`,
|
||||
`$legacy = (Get-ItemProperty 'HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Lsa' -Name SCENoApplyLegacyAuditPolicy -ErrorAction SilentlyContinue).SCENoApplyLegacyAuditPolicy`,
|
||||
`Write-Output ("SCENoApplyLegacyAuditPolicy=" + $(if ($null -eq $legacy) { 'not set' } else { $legacy }))`,
|
||||
`$cmdline = (Get-ItemProperty 'HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Policies\\System\\Audit' -Name ProcessCreationIncludeCmdLine_Enabled -ErrorAction SilentlyContinue).ProcessCreationIncludeCmdLine_Enabled`,
|
||||
`Write-Output ("ProcessCreationIncludeCmdLine_Enabled=" + $(if ($null -eq $cmdline) { 'not set' } else { $cmdline }))`,
|
||||
].join('\n');
|
||||
|
||||
const r = await execCommand({ command: ps, timeoutMs: 60_000 });
|
||||
const out = r.stdout || '';
|
||||
const sysmon = await detectSysmon();
|
||||
|
||||
return {
|
||||
platform: 'windows',
|
||||
supported: true,
|
||||
auditpol: out.trim(),
|
||||
// 不设它,组策略的旧式类别策略会盖掉 subcategory 设置 ——
|
||||
// 表现为「auditpol 显示已启用,但事件日志里没有 4688」
|
||||
legacy_policy_disabled: /SCENoApplyLegacyAuditPolicy=1/.test(out),
|
||||
cmdline_logging: /ProcessCreationIncludeCmdLine_Enabled=1/.test(out),
|
||||
sysmon_installed: sysmon,
|
||||
file: l2FilePath(),
|
||||
file_exists: existsSync(l2FilePath()),
|
||||
note: r.exit_code !== 0 ? 'auditpol 查询未成功返回(可能需要管理员权限)' : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** 启用 Windows 高级审计策略(需管理员)。 */
|
||||
async function l2Enable(): Promise<unknown> {
|
||||
const ps = [
|
||||
`$ErrorActionPreference='Stop'`,
|
||||
// ① 让「高级审计策略」生效,覆盖旧式类别策略
|
||||
`New-ItemProperty -Path 'HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Lsa' -Name SCENoApplyLegacyAuditPolicy -Value 1 -PropertyType DWORD -Force | Out-Null`,
|
||||
// ② 命令行记录:不开的话 4688 只有进程名,看不到参数
|
||||
`New-Item -Path 'HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Policies\\System\\Audit' -Force | Out-Null`,
|
||||
`New-ItemProperty -Path 'HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Policies\\System\\Audit' -Name ProcessCreationIncludeCmdLine_Enabled -Value 1 -PropertyType DWORD -Force | Out-Null`,
|
||||
// ③ 开启关注的 subcategory
|
||||
`auditpol /set /subcategory:"进程创建" /success:enable /failure:enable`,
|
||||
`auditpol /set /subcategory:"登录" /success:enable /failure:enable`,
|
||||
`auditpol /set /subcategory:"进程终止" /success:enable`,
|
||||
`Write-Output 'enabled'`,
|
||||
].join('\n');
|
||||
|
||||
const r = await execCommand({ command: ps, timeoutMs: 90_000 });
|
||||
if (r.exit_code !== 0) {
|
||||
throw new CapabilityError(
|
||||
ErrorCodes.EXECUTION_FAILED,
|
||||
'启用审计策略失败(需要管理员权限)。未提权时请先用管理员身份运行。',
|
||||
{ detail: (r.stderr || r.stdout).slice(0, 300) },
|
||||
);
|
||||
}
|
||||
audit({ type: 'agent.l2', status: 'ok', reason: 'l2.enable windows audit policy' });
|
||||
return {
|
||||
enabled: true,
|
||||
platform: 'windows',
|
||||
policies: ['进程创建 4688', '登录 4624/4625', '进程终止 4689'],
|
||||
cmdline_logging: true,
|
||||
hint: '策略生效后需要一段时间才会产生事件;用 action=collect 拉取',
|
||||
};
|
||||
}
|
||||
|
||||
/** 检测 Sysmon 是否安装(装了就并用其日志,没装不尝试下载)。 */
|
||||
async function detectSysmon(): Promise<boolean> {
|
||||
const r = await execCommand({
|
||||
command: `$ErrorActionPreference='SilentlyContinue'; (Get-Service Sysmon -ErrorAction SilentlyContinue).Name`,
|
||||
timeoutMs: 20_000,
|
||||
});
|
||||
return r.stdout.trim() === 'Sysmon';
|
||||
}
|
||||
|
||||
// ---------------- L2 链校验 ----------------
|
||||
|
||||
export interface L2VerifyResult {
|
||||
ok: boolean;
|
||||
checked: number;
|
||||
broken_at?: { line: number; reason: string };
|
||||
}
|
||||
|
||||
/** 校验 L2 链完整性(与 L1 同构算法,独立文件独立链)。 */
|
||||
export function verifyL2(): L2VerifyResult {
|
||||
const p = l2FilePath();
|
||||
if (!existsSync(p)) return { ok: true, checked: 0 };
|
||||
let expected: string | null = null;
|
||||
let checked = 0;
|
||||
let line = 0;
|
||||
try {
|
||||
for (const raw of readFileSync(p, 'utf8').split('\n')) {
|
||||
if (!raw.trim()) continue;
|
||||
line += 1;
|
||||
let rec: L2Entry;
|
||||
try {
|
||||
rec = JSON.parse(raw) as L2Entry;
|
||||
} catch {
|
||||
return { ok: false, checked, broken_at: { line, reason: 'JSON 解析失败(条目被破坏)' } };
|
||||
}
|
||||
const { hash, ...body } = rec;
|
||||
if (!hash) continue;
|
||||
const recalc = hashEntry(body as unknown as Record<string, unknown>, rec.prev ?? GENESIS);
|
||||
if (recalc !== hash) {
|
||||
return { ok: false, checked, broken_at: { line, reason: '内容与哈希不符(条目被篡改)' } };
|
||||
}
|
||||
if (expected !== null && rec.prev !== expected) {
|
||||
return { ok: false, checked, broken_at: { line, reason: '链断裂(prev 与上一条哈希不匹配)' } };
|
||||
}
|
||||
expected = hash;
|
||||
checked += 1;
|
||||
}
|
||||
} catch {
|
||||
return { ok: false, checked, broken_at: { line, reason: '读取 L2 文件失败' } };
|
||||
}
|
||||
return { ok: true, checked };
|
||||
}
|
||||
|
||||
/** 读取 L2 记录(供导出转发 —— 真正的独立性来自这里,而非本地副本)。 */
|
||||
export function readL2(limit = 200): L2Entry[] {
|
||||
const p = l2FilePath();
|
||||
if (!existsSync(p)) return [];
|
||||
const out: L2Entry[] = [];
|
||||
try {
|
||||
const lines = readFileSync(p, 'utf8').split('\n').filter((l) => l.trim());
|
||||
for (const l of lines.slice(-Math.max(1, limit))) {
|
||||
try {
|
||||
out.push(JSON.parse(l) as L2Entry);
|
||||
} catch {
|
||||
/* 跳过损坏行 */
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* 读失败返回已有部分 */
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
import { IS_WINDOWS, execCommand } from '../util/exec.js';
|
||||
import { assertGuiPlatform } from '../util/platform.js';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
@@ -79,6 +80,8 @@ async function writeClipboardWindows(b64: string, expect: string): Promise<void>
|
||||
// ---------------- clip.get ----------------
|
||||
|
||||
export async function clipGet(args: Args = {}): Promise<unknown> {
|
||||
// 该能力仅在 Windows/macOS 有实现(Linux 端未实现)
|
||||
assertGuiPlatform('clipboard.get');
|
||||
const format = (args['format'] as string | undefined) ?? 'auto';
|
||||
|
||||
if (IS_WINDOWS) {
|
||||
@@ -132,6 +135,8 @@ async function getImageWindows(): Promise<Record<string, unknown> | null> {
|
||||
// ---------------- clip.set ----------------
|
||||
|
||||
export async function clipSet(args: Args): Promise<unknown> {
|
||||
// 该能力仅在 Windows/macOS 有实现(Linux 端未实现)
|
||||
assertGuiPlatform('clipboard.set');
|
||||
const image = args['image_base64'] as string | undefined;
|
||||
const text = args['text'] as string | undefined;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
import { IS_WINDOWS, execCommand } from '../util/exec.js';
|
||||
import { shLit } from '../util/quote.js';
|
||||
import { agentDir } from '../config.js';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { CapabilityNames } from '@nodeagent/protocol';
|
||||
import type { AgentConfig } from '../config.js';
|
||||
import { systemInfo, systemStatus, processList, serviceList, shellExec, auditList, auditVerify, auditHead, auditAnchor, metricsReport, agentRestart } from './system.js';
|
||||
import { auditL2 } from './audit-l2.js';
|
||||
import { appList, appInstall } from './app.js';
|
||||
import { screenInfo, screenCapture } from './screen.js';
|
||||
import { windowList, windowFocus, screenFind } from './window.js';
|
||||
@@ -19,6 +20,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;
|
||||
}
|
||||
@@ -70,6 +80,8 @@ export function createCapabilityRegistry(cfg: AgentConfig): Record<string, Capab
|
||||
// v2.0.0 审计链外部锚定(防整链重写)
|
||||
[CapabilityNames.AuditHead]: auditHead,
|
||||
[CapabilityNames.AuditAnchor]: auditAnchor,
|
||||
// L2 系统级审计兜底(见 docs/AUDIT-L2.md)
|
||||
[CapabilityNames.AuditL2]: auditL2,
|
||||
// v13 成功指标
|
||||
[CapabilityNames.Metrics]: metricsReport,
|
||||
// v5 文件传输
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
buildPostBody,
|
||||
} from './input-script.js';
|
||||
import { IS_WINDOWS, execCommand } from '../util/exec.js';
|
||||
import { PS_DPI_AWARE } from '../util/dpi.js';
|
||||
import { assertGuiPlatform } from '../util/platform.js';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
@@ -213,15 +215,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,745) 实际落到 (1104,1439)。
|
||||
PS_DPI_AWARE,
|
||||
`$code = @'`,
|
||||
CS_CODE,
|
||||
`'@`,
|
||||
`Add-Type -TypeDefinition $code`,
|
||||
...body,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function runPs(body: string[], timeoutMs = 30_000): Promise<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),
|
||||
@@ -239,6 +257,7 @@ const MOUSE_FLAGS: Record<string, { down: number; up: number }> = {
|
||||
};
|
||||
|
||||
export async function mouseMove(args: Args): Promise<unknown> {
|
||||
assertGuiPlatform('input.mouseMove');
|
||||
assertAllowed('input.mouse.move');
|
||||
requireWindows('input.mouse.move');
|
||||
const x = args['x'] as number;
|
||||
@@ -266,6 +285,7 @@ export async function mouseMove(args: Args): Promise<unknown> {
|
||||
}
|
||||
|
||||
export async function mouseClick(args: Args): Promise<unknown> {
|
||||
assertGuiPlatform('input.mouseClick');
|
||||
assertAllowed('input.mouse.click');
|
||||
requireWindows('input.mouse.click');
|
||||
const button = (args['button'] as string) ?? 'left';
|
||||
@@ -285,9 +305,11 @@ export async function mouseClick(args: Args): Promise<unknown> {
|
||||
}
|
||||
await runPs(body);
|
||||
|
||||
// 返回实际点击位置
|
||||
// 返回实际点击位置。
|
||||
// ⚠️ 读回光标位置同样要 DPI 感知,否则这里读到的是**逻辑**值(200% 下是真实值的一半),
|
||||
// 于是「主叫方传物理坐标 → 返回被腰斩的坐标」,看起来像点错位置。真机 2026-10-12 排查。
|
||||
const pos = await execCommand({
|
||||
command: `Add-Type -AssemblyName System.Windows.Forms; $p = [System.Windows.Forms.Cursor]::Position; "{0},{1}" -f $p.X, $p.Y`,
|
||||
command: `${PS_DPI_AWARE}; Add-Type -AssemblyName System.Windows.Forms; $p = [System.Windows.Forms.Cursor]::Position; "{0},{1}" -f $p.X, $p.Y`,
|
||||
timeoutMs: 20_000,
|
||||
});
|
||||
const [cx, cy] = pos.stdout.trim().split(',').map(Number);
|
||||
@@ -295,6 +317,7 @@ export async function mouseClick(args: Args): Promise<unknown> {
|
||||
}
|
||||
|
||||
export async function mouseScroll(args: Args): Promise<unknown> {
|
||||
assertGuiPlatform('input.mouseScroll');
|
||||
assertAllowed('input.mouse.scroll');
|
||||
requireWindows('input.mouse.scroll');
|
||||
const delta = args['delta'] as number;
|
||||
@@ -314,6 +337,7 @@ export async function mouseScroll(args: Args): Promise<unknown> {
|
||||
* 用于拖文件、框选、拖动滑块/窗口。整段动作在一次进程内完成(避免多次 spawn 抖动)。
|
||||
*/
|
||||
export async function mouseDrag(args: Args): Promise<unknown> {
|
||||
assertGuiPlatform('input.mouseDrag');
|
||||
assertAllowed('input.mouse.drag');
|
||||
requireWindows('input.mouse.drag');
|
||||
const x1 = args['from_x'] as number;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,8 @@ import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
import { IS_WINDOWS, execCommand } from '../util/exec.js';
|
||||
import { PS_DPI_AWARE } from '../util/dpi.js';
|
||||
import { assertGuiPlatform } from '../util/platform.js';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
@@ -87,6 +89,8 @@ async function tryMuxMp4(dir: string, fps: number): Promise<string | null> {
|
||||
* —— 避免把大体积视频塞进单条 RPC 响应。
|
||||
*/
|
||||
export async function screenRecord(args: Args): Promise<unknown> {
|
||||
// 该能力仅在 Windows/macOS 有实现(Linux 端需 X11/Wayland 后端,尚未实现)
|
||||
assertGuiPlatform('screen.record');
|
||||
if (!IS_WINDOWS) {
|
||||
throw new CapabilityError(ErrorCodes.UNSUPPORTED_PLATFORM, 'screen.record 当前仅支持 Windows 被控端', {
|
||||
platform: process.platform,
|
||||
@@ -109,9 +113,10 @@ export async function screenRecord(args: Args): Promise<unknown> {
|
||||
let height = region?.height ?? 0;
|
||||
|
||||
if (!region) {
|
||||
// ⚠️ 这里必须先设 DPI 感知,`VirtualScreen` 才是**物理**尺寸(见 util/dpi.ts)。
|
||||
// 统一走 PS_DPI_AWARE,避免各能力各写一份 P/Invoke 壳而漂移。
|
||||
const probe = await execCommand({
|
||||
command:
|
||||
'Add-Type -AssemblyName System.Windows.Forms; Add-Type -TypeDefinition \'using System.Runtime.InteropServices; public class NADPIR { [DllImport("user32.dll")] public static extern bool SetProcessDPIAware(); }\'; [void][NADPIR]::SetProcessDPIAware(); $vs = [System.Windows.Forms.SystemInformation]::VirtualScreen; Write-Output ("{0},{1},{2},{3}" -f $vs.X, $vs.Y, $vs.Width, $vs.Height)',
|
||||
command: `${PS_DPI_AWARE}; Add-Type -AssemblyName System.Windows.Forms; $vs = [System.Windows.Forms.SystemInformation]::VirtualScreen; Write-Output ("{0},{1},{2},{3}" -f $vs.X, $vs.Y, $vs.Width, $vs.Height)`,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
const nums = probe.stdout.trim().split(',').map(Number);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
import { IS_WINDOWS, execCommand } from '../util/exec.js';
|
||||
import { shLit } from '../util/quote.js';
|
||||
import { PS_DPI_AWARE } from '../util/dpi.js';
|
||||
import { assertGuiPlatform } from '../util/platform.js';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
@@ -25,22 +28,37 @@ const MAX_CAPTURE_BYTES = 40 * 1024 * 1024;
|
||||
|
||||
// ---------------- screen.info ----------------
|
||||
|
||||
/**
|
||||
* listDisplays 下发给 PowerShell 的脚本。
|
||||
*
|
||||
* 导出**仅供单测断言**(检查 DPI 感知确实注入且位于坐标 API 之前)——
|
||||
* 真机 2026-10-12 的「缺省整屏只截到左上 1/4」就是这里漏了 DPI 感知,
|
||||
* 源码扫描式守卫抓不到(文件里别处也引用了 PS_DPI_AWARE)。
|
||||
*/
|
||||
export function buildListDisplaysScript(): string {
|
||||
return [
|
||||
// ⚠️ 必须最先执行:不设 DPI 感知时,`Screen.Bounds` 返回的是**逻辑**尺寸,
|
||||
// 而 `CopyFromScreen` 又按**物理**像素裁剪 → 缺省整屏截图只截到左上 1/4
|
||||
//(200% 缩放下 2560×1440 的桌面报成 1280×720)。真机 2026-10-12 实证。
|
||||
PS_DPI_AWARE,
|
||||
'Add-Type -AssemblyName System.Windows.Forms',
|
||||
'$s = [System.Windows.Forms.Screen]::AllScreens',
|
||||
// 读取 DPI 缩放(HKCU AppliedDPI;读不到则按 100% 处理)。
|
||||
// 注意:设了 DPI 感知后 Bounds 已是**物理**像素,scale 仅作参考信息保留。
|
||||
'$dpi = (Get-ItemProperty -Path "HKCU:\\Control Panel\\Desktop\\WindowMetrics" -Name AppliedDPI -ErrorAction SilentlyContinue).AppliedDPI',
|
||||
'$scale = if ($dpi) { [math]::Round($dpi / 96.0, 2) } else { 1.0 }',
|
||||
'$out = @()',
|
||||
'for ($i = 0; $i -lt $s.Count; $i++) {',
|
||||
' $d = $s[$i]',
|
||||
' $out += [pscustomobject]@{ id = $i; name = $d.DeviceName; width = $d.Bounds.Width; height = $d.Bounds.Height; is_primary = $d.Primary; scale = $scale; x = $d.Bounds.X; y = $d.Bounds.Y }',
|
||||
'}',
|
||||
'$out | ConvertTo-Json -Compress',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function listDisplays(): Promise<Display[]> {
|
||||
if (IS_WINDOWS) {
|
||||
const script = [
|
||||
'Add-Type -AssemblyName System.Windows.Forms',
|
||||
'$s = [System.Windows.Forms.Screen]::AllScreens',
|
||||
// 读取 DPI 缩放(HKCU AppliedDPI;读不到则按 100% 处理)
|
||||
'$dpi = (Get-ItemProperty -Path "HKCU:\\Control Panel\\Desktop\\WindowMetrics" -Name AppliedDPI -ErrorAction SilentlyContinue).AppliedDPI',
|
||||
'$scale = if ($dpi) { [math]::Round($dpi / 96.0, 2) } else { 1.0 }',
|
||||
'$out = @()',
|
||||
'for ($i = 0; $i -lt $s.Count; $i++) {',
|
||||
' $d = $s[$i]',
|
||||
' $out += [pscustomobject]@{ id = $i; name = $d.DeviceName; width = $d.Bounds.Width; height = $d.Bounds.Height; is_primary = $d.Primary; scale = $scale; x = $d.Bounds.X; y = $d.Bounds.Y }',
|
||||
'}',
|
||||
'$out | ConvertTo-Json -Compress',
|
||||
].join('\n');
|
||||
const r = await execCommand({ command: script, timeoutMs: 30_000 });
|
||||
const r = await execCommand({ command: buildListDisplaysScript(), timeoutMs: 30_000 });
|
||||
if (r.exit_code !== 0) {
|
||||
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '读取显示器信息失败', { detail: r.stderr.slice(0, 500) });
|
||||
}
|
||||
@@ -65,6 +83,8 @@ async function listDisplays(): Promise<Display[]> {
|
||||
}
|
||||
|
||||
export async function screenInfo(_args: Args): Promise<unknown> {
|
||||
// 该能力仅在 Windows/macOS 有实现(Linux 端需 X11/Wayland 后端,尚未实现)
|
||||
assertGuiPlatform('screen.info');
|
||||
const displays = await listDisplays();
|
||||
return {
|
||||
displays: displays.map(({ id, name, width, height, is_primary, scale }) => ({
|
||||
@@ -98,6 +118,8 @@ interface CaptureResult {
|
||||
}
|
||||
|
||||
export async function screenCapture(args: Args): Promise<unknown> {
|
||||
// 该能力仅在 Windows/macOS 有实现(Linux 端需 X11/Wayland 后端,尚未实现)
|
||||
assertGuiPlatform('screen.capture');
|
||||
const opts: CaptureOpts = {
|
||||
display_id: (args['display_id'] as number) ?? 0,
|
||||
format: (args['format'] as 'png' | 'jpeg') ?? 'jpeg',
|
||||
@@ -106,27 +128,61 @@ export async function screenCapture(args: Args): Promise<unknown> {
|
||||
region: args['region'] as CaptureOpts['region'],
|
||||
};
|
||||
|
||||
/**
|
||||
* ⚠️ S-01(v2.0.3):region 的合法性检查必须放在**枚举显示器之前**。
|
||||
*
|
||||
* 两道闸的由来:被控端会照 region 直接 `New-Object Bitmap $w,$h`(32bpp ≈ 4×w×h 字节),
|
||||
* 一个 100000×100000 的请求足以吃光内存(≈40GB)→ 进程被杀。
|
||||
* 收敛为「单边 ≤ 16384(常见位图/纹理上限)+ 总像素 ≤ 32M(≈128MB)」。
|
||||
*
|
||||
* 放在枚举之前有两个理由:① 校验不依赖显示器信息,没理由先付出枚举成本;
|
||||
* ② 真要挡住的就是这种请求 —— 让它在任何副作用之前就失败(fail fast)。
|
||||
*/
|
||||
if (opts.region) assertRegionSane(opts.region);
|
||||
|
||||
const displays = await listDisplays();
|
||||
const display = displays[opts.display_id] ?? displays[0];
|
||||
if (!display) {
|
||||
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, `显示器 ${opts.display_id} 不存在`);
|
||||
}
|
||||
|
||||
// region 缺省 = 整个目标屏;带上多屏偏移
|
||||
// region 缺省 = 整个目标屏;带上多屏偏移。
|
||||
// 缺省值也要过同一道闸:显示器自报异常尺寸时同样不能放行。
|
||||
const region = opts.region ?? { x: display.x, y: display.y, width: display.width, height: display.height };
|
||||
if (region.width < 1 || region.height < 1) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'region 宽高必须 ≥ 1', { region });
|
||||
}
|
||||
assertRegionSane(region);
|
||||
|
||||
if (IS_WINDOWS) return captureWindows(opts, region);
|
||||
return capturePosix(opts, region);
|
||||
}
|
||||
|
||||
async function captureWindows(
|
||||
opts: CaptureOpts,
|
||||
/** region 上界校验(S-01)。缺省值与显式传入走**同一份**判据,避免两处漂移。 */
|
||||
function assertRegionSane(region: NonNullable<CaptureOpts['region']>): void {
|
||||
if (region.width < 1 || region.height < 1) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'region 宽高必须 ≥ 1', { region });
|
||||
}
|
||||
const MAX_EDGE = 16384;
|
||||
const MAX_PIXELS = 33_554_432;
|
||||
if (region.width > MAX_EDGE || region.height > MAX_EDGE || region.width * region.height > MAX_PIXELS) {
|
||||
throw new CapabilityError(
|
||||
ErrorCodes.PARAM_INVALID,
|
||||
`region 过大:单边须 ≤ ${MAX_EDGE} 且总像素 ≤ ${MAX_PIXELS}(当前 ${region.width}×${region.height})`,
|
||||
{ region, max_edge: MAX_EDGE, max_pixels: MAX_PIXELS },
|
||||
);
|
||||
}
|
||||
if (![region.x, region.y, region.width, region.height].every((n) => Number.isFinite(n))) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'region 必须都是有限数字', { region });
|
||||
}
|
||||
}
|
||||
|
||||
/** captureWindows 下发给 PowerShell 的脚本(导出**仅供单测断言**,同 buildListDisplaysScript)。 */
|
||||
export function buildCaptureScript(
|
||||
region: { x: number; y: number; width: number; height: number },
|
||||
): Promise<CaptureResult> {
|
||||
const script = [
|
||||
opts: { scale: number; format: string; quality: number },
|
||||
): string {
|
||||
return [
|
||||
// ⚠️ 与 listDisplays 同理:region 是物理像素,进程也必须是 DPI 感知,
|
||||
// 否则「region 语义(物理)」与「进程坐标观(逻辑)」两套口径并存。
|
||||
PS_DPI_AWARE,
|
||||
"$ErrorActionPreference = 'Stop'",
|
||||
'Add-Type -AssemblyName System.Drawing',
|
||||
`$x = ${region.x}; $y = ${region.y}; $w = ${region.width}; $h = ${region.height}`,
|
||||
@@ -157,8 +213,17 @@ async function captureWindows(
|
||||
'$ms.Dispose(); $bmp.Dispose()',
|
||||
'Write-Output ("{0}|{1}|{2}" -f [Convert]::ToBase64String($bytes), $bmpW, $bmpH)',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const r = await execCommand({ command: script, timeoutMs: 60_000, maxOutputBytes: MAX_CAPTURE_BYTES });
|
||||
async function captureWindows(
|
||||
opts: CaptureOpts,
|
||||
region: { x: number; y: number; width: number; height: number },
|
||||
): Promise<CaptureResult> {
|
||||
const r = await execCommand({
|
||||
command: buildCaptureScript(region, opts),
|
||||
timeoutMs: 60_000,
|
||||
maxOutputBytes: MAX_CAPTURE_BYTES,
|
||||
});
|
||||
if (r.exit_code !== 0) {
|
||||
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '截屏失败', { detail: r.stderr.slice(0, 500) });
|
||||
}
|
||||
@@ -197,7 +262,7 @@ async function capturePosix(
|
||||
|
||||
// macOS 用 screencapture;区域用 -R x,y,w,h
|
||||
const cmd =
|
||||
`screencapture -x -t ${ext} -R ${region.x},${region.y},${region.width},${region.height} ${JSON.stringify(file)}`;
|
||||
`screencapture -x -t ${ext} -R ${region.x},${region.y},${region.width},${region.height} ${shLit(file)}`;
|
||||
const r = await execCommand({ command: cmd, timeoutMs: 30_000 });
|
||||
if (r.exit_code !== 0) {
|
||||
throw new CapabilityError(ErrorCodes.UNSUPPORTED_PLATFORM, '当前平台截屏不可用(需要 macOS 屏幕录制权限)', {
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { copyFileSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
copyFileSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
renameSync,
|
||||
statSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { join, dirname, basename } from 'node:path';
|
||||
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
import { IS_WINDOWS } from '../util/exec.js';
|
||||
import { agentDir } from '../config.js';
|
||||
import { audit } from '../audit.js';
|
||||
import { agentRestart } from './system.js';
|
||||
import { httpGet, resolveProxy } from '../net/index.js';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
@@ -21,59 +31,204 @@ type Args = Record<string, unknown>;
|
||||
* 1. **哈希必须钉死**:`sha256` 必填,且**逐字节校验下载内容**后才替换。
|
||||
* 没有它,GitHub 账号被盗 / DNS 被污染 / 中间人 = 直接拿下被控端。
|
||||
* 2. 只接受 http/https;拒绝 file:// 等其它 scheme。
|
||||
* 3. 大小上限(默认 64MB)+ 下载超时,避免被喂垃圾撑爆磁盘。
|
||||
* 4. **先备份再原子替换**(同目录 rename),失败可回滚。
|
||||
* 3. 大小上限(默认 64MB,content-length 预检 + 流式中断双闸)+ 下载超时。
|
||||
* 4. **先备份再替换**(同目录备份;Windows 上就地覆写,不用 rename —— 真机踩过),
|
||||
* 失败可回滚;写后校验不一致自动回滚。
|
||||
* 5. `dry_run` 只下载校验、不动现有文件 —— 用于「先确认能不能更新」。
|
||||
* 6. 动作全程进审计(能力调用本身已记录 `invoke`,这里再加一条 `agent.update`)。
|
||||
*
|
||||
* ## 为什么不用 `fetch` 的代理能力
|
||||
* Node 内置 `fetch` 不支持 HTTP 代理(需要 undici 的 ProxyAgent,而 undici 未对外暴露)。
|
||||
* 国内访问 GitHub 不稳时,正确做法是**把 url 指向自建镜像 / 加速服务**(本能力接受任意
|
||||
* http(s) 地址,天然支持),而不是在 agent 里再塞一套代理隧道。
|
||||
* ## 出网:经 `net/proxy`(v2.1.0,已批准的方案 A)
|
||||
* 历史立场(v19~v2.0.3)是「改用镜像地址,而不是在 agent 里塞代理隧道」——
|
||||
* 真机教训证明该立场不成立:境内无透明代理的机器上,**即使配好了代理**,
|
||||
* Node 内置 fetch 也不读代理环境变量、更读不到 Windows/macOS 系统代理,
|
||||
* 「自更新」必然失败(与控制台 sidecar 2026-10-13 的同一缺陷)。
|
||||
* 故 v2.1.0 起复用 sidecar 已验证的出网方案(`apps/console/sidecar/proxy.mjs`
|
||||
* 的 TS 移植,见 `net/proxy.ts`):
|
||||
* `NA_PROXY` → 目标协议环境变量 → 系统代理(reg query / scutil);
|
||||
* PAC / socks **明确报错不静默降级**;整个重定向链共享一个超时预算。
|
||||
*/
|
||||
|
||||
const MAX_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
/** 下载到内存(带大小上限与超时)。 */
|
||||
async function download(url: string, timeoutMs: number): Promise<Buffer> {
|
||||
const ac = new AbortController();
|
||||
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
||||
/** 出网诊断信息(返回结构与 CapabilityError.data 共用,保证成功/失败口径一致)。 */
|
||||
interface OutboundInfo {
|
||||
via: string;
|
||||
proxy: string | null;
|
||||
proxy_source: string | null;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
/** 组一条可操作的失败线索:走了哪条路、代理从哪来、为什么不走代理。 */
|
||||
function outboundHint(o: OutboundInfo, timeoutMs: number): Record<string, unknown> {
|
||||
return {
|
||||
via: o.via,
|
||||
proxy: o.proxy,
|
||||
proxy_source: o.proxy_source,
|
||||
timeout_ms: timeoutMs,
|
||||
notes: o.notes,
|
||||
hint: o.proxy
|
||||
? '代理已生效但仍失败:请确认代理本身能访问更新源(浏览器里试同一个地址)'
|
||||
: '未使用代理。若本机需要代理才能访问更新源,请在系统里开启「系统代理」,或设置环境变量 NA_PROXY=http://127.0.0.1:<端口> 后重启被控端',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为「主动中止」类错误 —— **只认真正的 abort**。
|
||||
*
|
||||
* ⚠️ 别把 `ECONNRESET` 之类也塞进来(sidecar 第一版就这么写的,真机上立刻现形):
|
||||
* 那次耗时 82.9s、预算 120s,连接是被对端**中途重置**的,却被报成
|
||||
* 「请求超时(120000ms 内未完成)」—— 一句话把用户引到完全错误的方向上。
|
||||
* 此处按 sidecar `main.mjs:146-151` **同款移植**,不许重新发明。
|
||||
*/
|
||||
export function isAbortLike(err: unknown): boolean {
|
||||
const name = (err as { name?: string } | null)?.name ?? '';
|
||||
const code = (err as { code?: string } | null)?.code ?? '';
|
||||
const msg = String((err as { message?: unknown } | null)?.message ?? '');
|
||||
return name === 'AbortError' || code === 'ABORT_ERR' || /\babort(ed)?\b/i.test(msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把底层网络错误翻译成「用户能据以行动」的一句话。
|
||||
*
|
||||
* 按 sidecar `main.mjs:154-165` **同款移植**(含真机教训:ECONNRESET 不是超时)。
|
||||
*/
|
||||
export function describeNetError(err: unknown, timeoutMs: number): string {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const code = (err as { code?: string } | null)?.code ?? '';
|
||||
if (isAbortLike(err)) return `请求超时(${timeoutMs}ms 内未完成)`;
|
||||
if (code === 'ECONNRESET' || code === 'EPIPE') {
|
||||
return `${msg}(连接被对端中途断开;若走了代理,多半是代理或线路的问题)`;
|
||||
}
|
||||
if (code === 'ENOTFOUND' || code === 'EAI_AGAIN' || code === 'ECONNREFUSED') {
|
||||
return `${msg}(域名解析失败或目标拒绝连接;若配了代理,请核对代理地址与端口)`;
|
||||
}
|
||||
// 2026-10-27 真机补:境内直连 GitHub 的**最常见**失败形态就是 TCP 层 ETIMEDOUT
|
||||
// (不是我们自己的 timeoutMs,也不是 ECONNRESET)。原先它落到下面兜底分支,
|
||||
// 用户只看到一串 IP:443 超时,不知道该干什么 —— 而这时唯一有效的动作就是配代理。
|
||||
if (code === 'ETIMEDOUT' || code === 'EHOSTUNREACH' || code === 'ENETUNREACH') {
|
||||
return `${msg}(连接超时/网络不可达;若目标在境外或需要代理,请设置 NA_PROXY=<代理地址> 或配置系统代理后重试)`;
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
/** 决议代理;任何异常都退化成「直连」—— 探测本身绝不该成为新的失败点。 */
|
||||
async function resolveProxySafe(targetUrl: string): Promise<{ proxy: URL | null; info: OutboundInfo }> {
|
||||
try {
|
||||
const res = await fetch(url, { signal: ac.signal, redirect: 'follow' });
|
||||
if (!res.ok) {
|
||||
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, `下载失败: HTTP ${res.status} ${res.statusText}`, {
|
||||
url,
|
||||
});
|
||||
}
|
||||
const declared = Number(res.headers.get('content-length') ?? '0');
|
||||
if (declared > MAX_BYTES) {
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `文件过大(${declared} 字节 > 上限 ${MAX_BYTES})`, { url });
|
||||
}
|
||||
const reader = res.body?.getReader();
|
||||
if (!reader) {
|
||||
return Buffer.from(await res.arrayBuffer());
|
||||
}
|
||||
const parts: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value) {
|
||||
total += value.length;
|
||||
if (total > MAX_BYTES) {
|
||||
await reader.cancel();
|
||||
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `下载超过上限 ${MAX_BYTES} 字节,已中断`, { url });
|
||||
}
|
||||
parts.push(value);
|
||||
const r = await resolveProxy({ targetUrl });
|
||||
return {
|
||||
proxy: r.proxy,
|
||||
info: {
|
||||
via: r.proxy ? `经代理 ${r.proxy.href}` : '直连',
|
||||
proxy: r.proxy ? r.proxy.href : null,
|
||||
proxy_source: r.source,
|
||||
notes: r.notes,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
proxy: null,
|
||||
info: {
|
||||
via: '直连',
|
||||
proxy: null,
|
||||
proxy_source: null,
|
||||
notes: [`代理探测异常(${err instanceof Error ? err.message : String(err)}),按直连处理`],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载到内存(带大小上限与超时;经代理出网)。
|
||||
*
|
||||
* 超时语义:`timeoutMs` 是**整条重定向链的总预算**(与 sidecar 一致:链共享一个
|
||||
* AbortController,防 5 跳 × 30s 穿过 RPC 超时)。
|
||||
*
|
||||
* @returns 下载内容 + 出网诊断(成功/失败都带,保证「代理没配 / 哈希不符 / 超时」三类可分)
|
||||
*/
|
||||
async function download(
|
||||
url: string,
|
||||
timeoutMs: number,
|
||||
): Promise<{ buf: Buffer; outbound: OutboundInfo }> {
|
||||
const { proxy, info } = await resolveProxySafe(url);
|
||||
let res;
|
||||
try {
|
||||
res = await httpGet(url, { proxy, timeoutMs, maxBytes: MAX_BYTES });
|
||||
} catch (err) {
|
||||
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, `下载失败: ${describeNetError(err, timeoutMs)}`, {
|
||||
url,
|
||||
...outboundHint(info, timeoutMs),
|
||||
});
|
||||
}
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
throw new CapabilityError(
|
||||
ErrorCodes.EXECUTION_FAILED,
|
||||
`下载失败: HTTP ${res.status} ${res.statusText}`,
|
||||
{ url, ...outboundHint(info, timeoutMs) },
|
||||
);
|
||||
}
|
||||
const declared = Number(res.headers['content-length'] ?? '0');
|
||||
if (declared > MAX_BYTES) {
|
||||
throw new CapabilityError(
|
||||
ErrorCodes.PARAM_INVALID,
|
||||
`文件过大(${declared} 字节 > 上限 ${MAX_BYTES})`,
|
||||
{ url, ...outboundHint(info, timeoutMs) },
|
||||
);
|
||||
}
|
||||
if (res.body.length > MAX_BYTES) {
|
||||
throw new CapabilityError(
|
||||
ErrorCodes.PARAM_INVALID,
|
||||
`下载超过上限 ${MAX_BYTES} 字节,已中断`,
|
||||
{ url, ...outboundHint(info, timeoutMs) },
|
||||
);
|
||||
}
|
||||
return { buf: res.body, outbound: info };
|
||||
}
|
||||
|
||||
/**
|
||||
* 备份清理计划(纯函数,供单测):
|
||||
* 按 mtime **新→旧**排序,保留最近 `keep` 个,返回**应删除**的名单。
|
||||
*
|
||||
* 为什么需要:selfupdate 的备份是 `<script>.bak-<ts>`、永不清理 ——
|
||||
* 推送式 deploy 踩过的坑(真机堆了 15+ 个 bak)在拉取式路径复发。
|
||||
* 保留最近 3 个:再往前的版本可由更早的备份或重新发布获得,磁盘不无限堆积。
|
||||
*/
|
||||
export function planBackupCleanup(files: { name: string; mtime_ms: number }[], keep = 3): string[] {
|
||||
const sorted = [...files].sort((a, b) => b.mtime_ms - a.mtime_ms);
|
||||
// 反过来给「最旧先删」:万一删到一半失败,已删掉的也是价值最低的那些
|
||||
return sorted.slice(Math.max(0, keep)).reverse().map((f) => f.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理入口同目录的旧 selfupdate 备份(保留最近 3 个)。
|
||||
*
|
||||
* 删除失败一律忽略(被杀软占位的 .bak 删不掉不影响正确性)。
|
||||
* @returns 实际删除的文件名(供审计/返回值观察)
|
||||
*/
|
||||
function cleanupOldBackups(scriptPath: string, keep = 3): string[] {
|
||||
try {
|
||||
const dir = dirname(scriptPath);
|
||||
const prefix = `${basename(scriptPath)}.bak-`;
|
||||
const files: { name: string; mtime_ms: number }[] = [];
|
||||
for (const name of readdirSync(dir)) {
|
||||
if (!name.startsWith(prefix)) continue;
|
||||
try {
|
||||
files.push({ name, mtime_ms: statSync(join(dir, name)).mtimeMs });
|
||||
} catch {
|
||||
/* 文件可能已消失,跳过 */
|
||||
}
|
||||
}
|
||||
return Buffer.concat(parts);
|
||||
} catch (err) {
|
||||
if (err instanceof CapabilityError) throw err;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, `下载失败: ${msg}`, { url });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
const removed: string[] = [];
|
||||
for (const name of planBackupCleanup(files, keep)) {
|
||||
try {
|
||||
unlinkSync(join(dir, name));
|
||||
removed.push(name);
|
||||
} catch {
|
||||
/* 删不掉不影响正确性 */
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,9 +264,9 @@ export async function agentUpdate(args: Args): Promise<unknown> {
|
||||
const dir = join(agentDir(), 'update');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
|
||||
// ① 下载 + ② 校验哈希(先校验再落盘,避免半成品污染)
|
||||
// ① 下载(经代理出网)+ ② 校验哈希(先校验再落盘,避免半成品污染)
|
||||
const t0 = Date.now();
|
||||
const buf = await download(url, timeoutMs);
|
||||
const { buf, outbound } = await download(url, timeoutMs);
|
||||
const got = createHash('sha256').update(buf).digest('hex');
|
||||
if (!got.startsWith(expect) && got !== expect) {
|
||||
audit({ type: 'agent.update', status: 'failed', reason: `hash mismatch url=${url} expect=${expect} got=${got.slice(0, 12)}` });
|
||||
@@ -119,6 +274,7 @@ export async function agentUpdate(args: Args): Promise<unknown> {
|
||||
expected: expect,
|
||||
actual: got.slice(0, 12),
|
||||
bytes: buf.length,
|
||||
...outboundHint(outbound, timeoutMs),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -146,7 +302,7 @@ export async function agentUpdate(args: Args): Promise<unknown> {
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
}
|
||||
audit({ type: 'agent.update', status: 'ok', reason: `dry_run url=${url} hash=${got.slice(0, 12)}` });
|
||||
audit({ type: 'agent.update', status: 'ok', reason: `dry_run url=${url} hash=${got.slice(0, 12)} via=${outbound.via}` });
|
||||
return {
|
||||
dry_run: true,
|
||||
verified: true,
|
||||
@@ -155,10 +311,15 @@ export async function agentUpdate(args: Args): Promise<unknown> {
|
||||
current_hash: prevHash,
|
||||
current_bytes: prevBytes,
|
||||
note: '仅下载并校验,未改动任何文件',
|
||||
via: outbound.via,
|
||||
proxy: outbound.proxy,
|
||||
proxy_source: outbound.proxy_source,
|
||||
outbound_notes: outbound.notes,
|
||||
};
|
||||
}
|
||||
|
||||
// ③ 备份 + ④ 原子替换(同目录 rename,失败时旧文件不受影响)
|
||||
// ③ 备份 + ④ 替换(同目录备份;就地覆写不用 rename —— Windows 不允许替换
|
||||
// 正被运行进程打开的文件,真机踩过:rename 必失败)
|
||||
const backup = `${scriptPath}.bak-${Date.now()}`;
|
||||
try {
|
||||
copyFileSync(scriptPath, backup);
|
||||
@@ -200,13 +361,18 @@ export async function agentUpdate(args: Args): Promise<unknown> {
|
||||
/* 清理失败不影响正确性 */
|
||||
}
|
||||
|
||||
// ⑤ 备份清理:保留最近 3 个,其余删(A-P0-3:selfupdate 路径此前永不清理)
|
||||
const removedBackups = cleanupOldBackups(scriptPath, 3);
|
||||
|
||||
audit({
|
||||
type: 'agent.update',
|
||||
status: 'ok',
|
||||
reason: `url=${url} ${prevHash}->${got.slice(0, 12)} bytes=${buf.length} backup=${backup}`,
|
||||
reason:
|
||||
`url=${url} ${prevHash}->${got.slice(0, 12)} bytes=${buf.length} backup=${backup} via=${outbound.via}` +
|
||||
(removedBackups.length > 0 ? ` cleaned=${removedBackups.length}` : ''),
|
||||
});
|
||||
|
||||
// ⑤ 受控重启(复用 v7;Windows 走计划任务、POSIX 走 detached,均不依赖本进程存活)
|
||||
// ⑥ 受控重启(复用 v7;Windows 走计划任务、POSIX 走 detached,均不依赖本进程存活)
|
||||
let restarted = false;
|
||||
if (doRestart) {
|
||||
await agentRestart({ delay_ms: 1500, reason: 'self-update' });
|
||||
@@ -227,5 +393,10 @@ export async function agentUpdate(args: Args): Promise<unknown> {
|
||||
? '已替换并重启;请用 system.info 的 build.hash 复核(应等于 incoming_hash)。若异常,把 backup_path 覆盖回入口即可回滚'
|
||||
: '已替换但未重启(restart=false);重启后才会生效',
|
||||
platform: IS_WINDOWS ? 'windows' : 'posix',
|
||||
// v2.1.0 新增(只增字段):出网诊断
|
||||
via: outbound.via,
|
||||
proxy: outbound.proxy,
|
||||
proxy_source: outbound.proxy_source,
|
||||
outbound_notes: outbound.notes,
|
||||
};
|
||||
}
|
||||
@@ -10,7 +10,9 @@ import { loadAgentConfig } from '../config.js';
|
||||
import { readAudit, readAuditAll, verifyAudit, computeAuditHead, anchorAudit, compareWithAnchors } from '../audit.js';
|
||||
import { computeMetrics } from '../metrics.js';
|
||||
import { allPsShellStats } from '../util/ps-helper.js';
|
||||
import { lkgStatus } from '../lkg.js';
|
||||
import { startTask } from './task.js';
|
||||
import { psLit } from '../util/quote.js';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
@@ -150,6 +152,10 @@ export async function systemInfo(args: Args): Promise<unknown> {
|
||||
network: {
|
||||
allow_from: configAllowFrom(),
|
||||
},
|
||||
// 交付流水线 §6:最后已知可用版本(回滚锚点)。
|
||||
// matches=false 表示「当前版本还没被证明能启动」—— 刚更新完、尚未完成一次成功
|
||||
// 启动时会是这种情况,控制端应据此暂缓向其余设备放量。
|
||||
lkg: lkgStatus(),
|
||||
};
|
||||
|
||||
if (fields && fields.length > 0) {
|
||||
@@ -536,7 +542,7 @@ async function scheduleWindowsRestart(p: RestartParams): Promise<unknown> {
|
||||
//
|
||||
// 现策略:非 Running **或** LastRunTime **早于"本 agent 启动时刻"** → 清。
|
||||
//
|
||||
// ⚠️ 2026-10-11 真机(Parallels Win11 ARM)修正:原先用「早于 1 小时」作阈值,
|
||||
// ⚠️ 2026-10-11 真机(虚拟化平台上的 Win11 ARM)修正:原先用「早于 1 小时」作阈值,
|
||||
// 实测发现**频繁 restart(间隔 < 1h)时旧任务照样累积**(v26 想治的僵尸只是从
|
||||
// "永不清理"变成"1 小时后才清")。根因是阈值选得太粗:
|
||||
// 阈值应表达「**这个任务是不是我(当前 agent)的宿主**」——
|
||||
@@ -545,7 +551,9 @@ async function scheduleWindowsRestart(p: RestartParams): Promise<unknown> {
|
||||
`$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 杀不掉它,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]{
|
||||
@@ -337,11 +353,12 @@ export async function screenFind(args: Args): Promise<unknown> {
|
||||
// ---------- UIA 引擎 ----------
|
||||
const uiaFind = async (): Promise<Record<string, unknown>[]> => {
|
||||
const ct = controlType ?? '';
|
||||
// ⚠️ 全部来自控制端,一律 psLit(详见文件顶部 windowFocus 处的 P0 说明)
|
||||
const body = `
|
||||
$text = ${JSON.stringify(text)}
|
||||
$text = ${psLit(text)}
|
||||
$limit = ${Number(limit)}
|
||||
$ct = ${JSON.stringify(controlType ?? '')}
|
||||
$winTitle = ${JSON.stringify(windowTitle ?? '')}
|
||||
$ct = ${psLit(controlType ?? '')}
|
||||
$winTitle = ${psLit(windowTitle ?? '')}
|
||||
# v2.0.0 属性过滤:where 只含 enabled/selected/value/toggle 四类键(白名单式)。
|
||||
#
|
||||
# ⚠️⚠️ 这里踩过**两次**同一个坑,真机 2026-10-09 连修两轮才通:
|
||||
@@ -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) {
|
||||
|
||||
+11
-3
@@ -13,7 +13,7 @@ export interface TlsMaterial {
|
||||
* 确保自签证书存在(首次生成并缓存到 ~/.nodeagent/certs/)。
|
||||
* v1 用自签证书做传输加密;控制端可校验证书指纹或临时跳过校验。
|
||||
*/
|
||||
export function ensureCert(): TlsMaterial {
|
||||
export async function ensureCert(): Promise<TlsMaterial> {
|
||||
const dir = join(agentDir(), 'certs');
|
||||
const certPath = join(dir, 'cert.pem');
|
||||
const keyPath = join(dir, 'key.pem');
|
||||
@@ -23,8 +23,16 @@ export function ensureCert(): TlsMaterial {
|
||||
}
|
||||
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const pems = selfsigned.generate([{ name: 'commonName', value: 'nodeagent' }], {
|
||||
days: 3650,
|
||||
// selfsigned 5.x:`generate` 改为**异步**(旧版同步返回)。
|
||||
// 升级动因不是追新,而是安全:2.x 依赖 node-forge 1.4.0,
|
||||
// 该版本有 high 级 advisory 且**上游无修复版本**(patched_versions: None);
|
||||
// 5.x 已整体换成 @peculiar/x509 + pkijs,node-forge 被彻底移除。
|
||||
// v5 把 `days` 换成了 `notAfterDate`(绝对时间),语义相同但必须换算 ——
|
||||
// 这是升级时的**静默不兼容点**:老写法编译期报 TS2353,能拦住;
|
||||
// 但若只改类型不改值,就会签出一个有效期不对的证书,运行期才暴露。
|
||||
const notAfterDate = new Date(Date.now() + 3650 * 24 * 60 * 60 * 1000);
|
||||
const pems = await selfsigned.generate([{ name: 'commonName', value: 'nodeagent' }], {
|
||||
notAfterDate,
|
||||
keySize: 2048,
|
||||
algorithm: 'sha256',
|
||||
});
|
||||
|
||||
@@ -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,12 +280,28 @@ 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 };
|
||||
|
||||
+150
-2
@@ -1,12 +1,15 @@
|
||||
import os from 'node:os';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { warmupWindowHelper } from './capabilities/window.js';
|
||||
import { CAPABILITY_MANIFEST } from '@nodeagent/protocol';
|
||||
import { loadAgentConfig, agentConfigPath } from './config.js';
|
||||
import { agentDir, loadAgentConfig, agentConfigPath } from './config.js';
|
||||
import { ensureCert } from './certs.js';
|
||||
import { createAgentServer } from './server.js';
|
||||
import { initAudit, audit, auditFilePath } from './audit.js';
|
||||
import { startBeacon, DEFAULT_DISCOVERY_PORT, type Beacon } from './discovery.js';
|
||||
import { startHubClient, type HubClient } from './hub-mode.js';
|
||||
import { recordLKG } from './lkg.js';
|
||||
|
||||
function listLocalIps(): string[] {
|
||||
const out: string[] = [];
|
||||
@@ -18,6 +21,143 @@ function listLocalIps(): string[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 看门狗失败计数归零(v2.1.0,决策 R-1;scripts/startup-guard.mjs)。
|
||||
*
|
||||
* 看门狗用 `<数据目录>/state/startup-state.json` 记录「连续启动失败次数」,
|
||||
* 达到阈值即从 LKG 恢复入口。计数必须在**启动成功后**归零 —— 与 LKG 同一
|
||||
* 铁律:不把「启动中」当成功,否则坏版本每崩一次都会被上一次的成功洗白,
|
||||
* 阈值形同虚设。写入失败一律吞掉:重置计数不能影响启动(与 recordLKG 同哲学)。
|
||||
*/
|
||||
function resetStartupGuardState(): void {
|
||||
try {
|
||||
const dir = join(agentDir(), 'state');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(dir, 'startup-state.json'),
|
||||
`${JSON.stringify({ failures: 0, updated_at: new Date().toISOString() })}\n`,
|
||||
'utf8',
|
||||
);
|
||||
} catch {
|
||||
/* 重置失败不影响启动 */
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// v2.0.3:进程级最后一道防线(崩溃链兜底)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// ## 为什么需要
|
||||
// 真机缺陷链的共同根因:`ws.on('message')` 里用 `void handleMessage(...)` 启动异步处理,
|
||||
// 未挂 `.catch`;而全仓**没有任何** `unhandledRejection` 处理器 → Node ≥22 的默认策略是
|
||||
// **直接终止进程**。于是一条畸形报文就能把被控端打死(外部只见 `code=1` / 连接断开),
|
||||
// 且因为 `verifyHmac`(psk 默认路径)没有 try/catch,连**未认证**的报文都能触发。
|
||||
//
|
||||
// 根治在源头(`server.ts` 已改 `.catch(...)` + 逐处报文类型校验),这里补的是
|
||||
// 「将来某处再漏一个 await」时的兜底 —— 有证据地活着,胜过静默地死。
|
||||
//
|
||||
// ## 为什么**不**退出进程
|
||||
// 被控端是常驻服务,「可用性」优先于「出错即死」。若这里改成 `process.exit(1)`,
|
||||
// 就退化回「一条畸形报文打死服务」—— 正是本次要修的东西。
|
||||
// 代价(可能带着不一致的状态继续跑)由两点对冲:
|
||||
// ① 源头已有报文校验与 `.catch`;② 这里每次都写审计 + 日志,问题不会被掩盖。
|
||||
//
|
||||
// ## 为什么要限流审计
|
||||
// 畸形报文可以高频重复(扫描/爆破)→ 无限写 `agent.error` 会把审计日志冲爆、把
|
||||
// 正常记录挤掉。故日志每次都打(stdout 由控制台的环形缓冲兜底),但**审计按时间窗限流**。
|
||||
|
||||
let procErrCount = 0;
|
||||
let lastProcErrAuditAt = 0;
|
||||
/** 两条 `agent.error` 审计之间的最小间隔 */
|
||||
const PROC_ERR_AUDIT_INTERVAL_MS = 10_000;
|
||||
|
||||
function reportProcessError(kind: string, detail: string): void {
|
||||
procErrCount += 1;
|
||||
console.error(
|
||||
`[${new Date().toISOString()}] [ERROR] ${kind}(已拦截,进程继续运行;累计 ${procErrCount} 次):${detail}`,
|
||||
);
|
||||
const now = Date.now();
|
||||
if (now - lastProcErrAuditAt < PROC_ERR_AUDIT_INTERVAL_MS) return;
|
||||
lastProcErrAuditAt = now;
|
||||
audit({
|
||||
type: 'agent.error',
|
||||
status: 'failed',
|
||||
reason: `${kind}: ${detail}(累计 ${procErrCount} 次,审计按 ${PROC_ERR_AUDIT_INTERVAL_MS / 1000}s 限流)`,
|
||||
});
|
||||
}
|
||||
|
||||
process.on('unhandledRejection', (reason: unknown) => {
|
||||
reportProcessError(
|
||||
'未处理的 Promise 拒绝',
|
||||
reason instanceof Error ? `${reason.name}: ${reason.message}` : String(reason),
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// stdout / stderr 断管容错(2026-10-17 真机两次踩到)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// ## 现象
|
||||
// 端口仍然 `OPEN`,但**所有** RPC 都超时(握手都不回)。持续数分钟不自愈,
|
||||
// 只能靠重开控制台恢复。
|
||||
//
|
||||
// ## 根因
|
||||
// 被控端由控制台 spawn 时,stdout/stderr 是**管道**而非终端。用户退出控制台
|
||||
// (尤其选「保留被控端」)后,管道**读端消失**,而 agent 仍在往这个死管道写日志:
|
||||
// - Node 对未监听 'error' 的 stream 会把 `EPIPE` **抛成未捕获异常** → 进程退出;
|
||||
// - 更糟的是写端在无读者时可能**阻塞**,把事件循环一起卡住 ——
|
||||
// 于是端口保住了(OS 层 socket 还在),但 JS 再也不执行了。
|
||||
// 这解释了为什么「退出控制台后被控端还在,却谁都连不上」:它活着,但哑了。
|
||||
//
|
||||
// ## 做法
|
||||
// ① 监听 'error' 吞掉 EPIPE,不让它升级成未捕获异常;
|
||||
// ② 一旦判定通道已断,就**短路后续写**,不再往死管道里堆字节。
|
||||
//
|
||||
// 日志不会因此丢失:审计走文件(`audit.log`),控制台在时照常收到管道输出。
|
||||
// 宁可少一行 stdout,也不能让常驻服务因为「没人看日志」而假死。
|
||||
let stdoutDead = false;
|
||||
let stderrDead = false;
|
||||
|
||||
function markDead(e: NodeJS.ErrnoException, isErr: boolean): void {
|
||||
if (e.code === 'EPIPE' || e.code === 'ERR_STREAM_DESTROYED' || e.code === 'ERR_STREAM_WRITE_AFTER_END') {
|
||||
if (isErr) stderrDead = true;
|
||||
else stdoutDead = true;
|
||||
return;
|
||||
}
|
||||
// 其他写错误也一律吞掉:日志通道的故障不该打死被控端
|
||||
if (isErr) stderrDead = true;
|
||||
else stdoutDead = true;
|
||||
}
|
||||
|
||||
process.stdout.on('error', (e: NodeJS.ErrnoException) => markDead(e, false));
|
||||
process.stderr.on('error', (e: NodeJS.ErrnoException) => markDead(e, true));
|
||||
|
||||
const rawLog = console.log.bind(console);
|
||||
const rawErr = console.error.bind(console);
|
||||
console.log = (...a: unknown[]): void => {
|
||||
if (stdoutDead) return;
|
||||
try {
|
||||
rawLog(...a);
|
||||
} catch {
|
||||
stdoutDead = true;
|
||||
}
|
||||
};
|
||||
console.error = (...a: unknown[]): void => {
|
||||
if (stderrDead) return;
|
||||
try {
|
||||
rawErr(...a);
|
||||
} catch {
|
||||
stderrDead = true;
|
||||
}
|
||||
};
|
||||
|
||||
process.on('uncaughtException', (err: unknown) => {
|
||||
reportProcessError(
|
||||
'未捕获异常',
|
||||
err instanceof Error ? `${err.name}: ${err.message}` : String(err),
|
||||
);
|
||||
});
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const { config, isNew } = loadAgentConfig();
|
||||
initAudit({
|
||||
@@ -28,7 +168,7 @@ async function main(): Promise<void> {
|
||||
});
|
||||
// v6:Hub 模式下不监听本地端口,改为主动外连 Hub(穿 NAT)
|
||||
const useHub = config.hub?.enabled === true;
|
||||
const tls = !useHub && config.tls ? ensureCert() : null;
|
||||
const tls = !useHub && config.tls ? await ensureCert() : null;
|
||||
const server = useHub ? null : await createAgentServer(config, tls);
|
||||
|
||||
let hubClient: HubClient | null = null;
|
||||
@@ -76,6 +216,14 @@ async function main(): Promise<void> {
|
||||
},
|
||||
(msg) => console.log(`[WARN] ${msg}`),
|
||||
);
|
||||
// 交付流水线 §6:此刻脚本已加载、配置已读、证书已签、端口已绑、心跳已起 ——
|
||||
// 走到这里说明「这个版本至少能启动」,故记为最后已知可用版本(LKG)。
|
||||
// 注意**不在更新写完时就记**:那会把尚未验证的版本当成已知可用,LKG 就失去意义。
|
||||
recordLKG();
|
||||
// 同理(v2.1.0 看门狗):启动成功才把「连续启动失败」计数归零。
|
||||
// 顺序在 recordLKG 之后、启动横幅之前 —— 两者都是「起来了」的注脚。
|
||||
resetStartupGuardState();
|
||||
|
||||
console.log('');
|
||||
console.log(' nodeagent 被控端已启动');
|
||||
console.log(' ─────────────────────────────────────────────');
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { join, basename } from 'node:path';
|
||||
import { agentDir } from './config.js';
|
||||
import { buildInfo } from './capabilities/system.js';
|
||||
|
||||
/**
|
||||
* 最后已知可用版本(Last-Known-Good, LKG)—— 交付流水线 §6 的回滚锚点。
|
||||
*
|
||||
* ## 为什么需要(本项目特有风险,通用发布规范里没有)
|
||||
* 对 Web 服务,发布失败可以回滚、可以摘流量。对 nodeagent:
|
||||
* **回滚通道本身就是被更新的那个程序**。一次失败的更新会让一台物理不可达的
|
||||
* 远程设备永久脱管 —— 这是本项目最严重的上线失败形态。
|
||||
*
|
||||
* ## 记什么、什么时候记
|
||||
* 关键设计:**只在「本次启动确实起来了」之后才记**。
|
||||
*
|
||||
* 若在「更新刚写完」时就记 LKG,等于把还没验证的版本当成已知可用 ——
|
||||
* 那 LKG 就失去了意义。故由 `index.ts` 在服务器 listen 成功、打印完启动信息后
|
||||
* 调用 `recordLKG()`:此刻能走到这一行,说明脚本能被 Node 加载、配置能读、
|
||||
* 证书能签、端口能绑。**能启动 ≠ 功能全对,但能启动 = 至少不是「打死」状态。**
|
||||
*
|
||||
* ## 明确不做的事:自动回滚
|
||||
* 本文刻意**不实现**「启动失败自动回滚」。理由:
|
||||
* - 远程设备上无法区分「更新本身坏了」与「网络抖动 / 端口被占 / 依赖服务没起」;
|
||||
* - 自动回滚可能触发**回滚循环**(回滚→启动→又判定失败→再回滚),
|
||||
* 在无人值守设备上比「停在坏版本」更难恢复;
|
||||
* - 被控端一旦自作主张改自己的程序,控制端将失去对「当前跑的是什么」的掌控。
|
||||
*
|
||||
* 因此 LKG 的定位是**给回滚提供确定的目标**,而不是替人做回滚决策:
|
||||
* 控制端 `nodeagent rollback` 读 LKG → 覆盖回入口文件 → 重启。
|
||||
*/
|
||||
|
||||
export interface LkgRecord {
|
||||
/** 入口脚本(agent.mjs)的 sha256 前 12 位 —— 与 system.info 的 build.hash 同口径 */
|
||||
hash: string;
|
||||
/**
|
||||
* v2.1.0 新增(只增字段):入口脚本的 sha256 **全量 64 位**。
|
||||
*
|
||||
* 为什么加:回滚时控制端要用「枚举备份 + 指纹匹配」定位内容,12 位前缀存在
|
||||
* 理论碰撞风险(1/16¹²,可接受但非零);全量比对消除该风险。
|
||||
* 老记录(无此字段)回落 12 位前缀比对,见 `lkgHashMatches`。
|
||||
*/
|
||||
hash_full?: string;
|
||||
bytes: number;
|
||||
script_path: string;
|
||||
/**
|
||||
* v2.1.0 新增(只增字段):回滚内容候选的文件名模式(`<basename>.bak*`)。
|
||||
* 覆盖推送式 deploy 的固定名 `.bak` 与拉取式 selfupdate 的时间戳名 `.bak-<ts>`
|
||||
* 两套命名,供控制端枚举候选(`fs.list` + 过滤)。
|
||||
*/
|
||||
backup_glob?: string;
|
||||
/** 记录时刻(Unix ms) */
|
||||
recorded_at: number;
|
||||
/** 当时的 node 版本,便于判断「是不是 Node 运行时升级导致的」 */
|
||||
node_version: string;
|
||||
platform: string;
|
||||
}
|
||||
|
||||
export function lkgFilePath(): string {
|
||||
return join(agentDir(), 'agent-lkg.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算当前入口脚本指纹。
|
||||
* 与 `capabilities/system.ts` 的 buildInfo() 同口径(都是脚本内容的 sha256 前 12 位),
|
||||
* 故可与 `nodeagent info` 里的 `build.hash` 直接比对。
|
||||
*/
|
||||
function currentScriptHash(): { hash: string; hash_full: string; bytes: number; path: string } | null {
|
||||
const p = process.argv[1] ?? '';
|
||||
if (!p) return null;
|
||||
try {
|
||||
const buf = readFileSync(p);
|
||||
const full = createHash('sha256').update(buf).digest('hex');
|
||||
return {
|
||||
hash: full.slice(0, 12),
|
||||
hash_full: full,
|
||||
bytes: buf.length,
|
||||
path: p,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* LKG 指纹比对(v2.1.0):有 `hash_full` 按**全量**比对,老记录回落 12 位前缀。
|
||||
*
|
||||
* 纯函数、不猜:两边都拿不出可比的值时返回 false。
|
||||
* 供 rollback 的「备份指纹匹配」与任何需要判断「这份内容是不是 LKG」的场景共用,
|
||||
* 避免各写一份比对规则(两份规则必然漂移)。
|
||||
*/
|
||||
export function lkgHashMatches(rec: LkgRecord | null, candidateFullHash: string | undefined | null): boolean {
|
||||
if (!rec || !candidateFullHash) return false;
|
||||
if (rec.hash_full) return rec.hash_full === candidateFullHash;
|
||||
return rec.hash === candidateFullHash.slice(0, 12);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录当前版本为 LKG。
|
||||
*
|
||||
* 任何失败都被吞掉 —— 写入失败不能影响启动。
|
||||
* @returns 写入的记录,失败返回 null
|
||||
*/
|
||||
export function recordLKG(): LkgRecord | null {
|
||||
const cur = currentScriptHash();
|
||||
if (!cur) return null;
|
||||
const rec: LkgRecord = {
|
||||
hash: cur.hash,
|
||||
hash_full: cur.hash_full,
|
||||
bytes: cur.bytes,
|
||||
script_path: cur.path,
|
||||
backup_glob: `${basename(cur.path)}.bak*`,
|
||||
recorded_at: Date.now(),
|
||||
node_version: process.version,
|
||||
platform: process.platform,
|
||||
};
|
||||
try {
|
||||
mkdirSync(agentDir(), { recursive: true });
|
||||
writeFileSync(lkgFilePath(), `${JSON.stringify(rec, null, 2)}\n`, 'utf8');
|
||||
return rec;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 LKG 记录;不存在或损坏返回 null(不作任何猜测)。
|
||||
*
|
||||
* 向后兼容:v2.1.0 之前写的记录没有 `hash_full` / `backup_glob` —— 原样读回,
|
||||
* 由消费方按 `lkgHashMatches` 的 12 位前缀回落处理,不在这里猜测补齐。
|
||||
*/
|
||||
export function readLKG(): LkgRecord | null {
|
||||
try {
|
||||
const raw = readFileSync(lkgFilePath(), 'utf8');
|
||||
const rec = JSON.parse(raw) as LkgRecord;
|
||||
if (!rec || typeof rec.hash !== 'string') return null;
|
||||
return rec;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前版本与 LKG 是否一致。
|
||||
*
|
||||
* 用途:更新后复核 —— 若「当前 hash ≠ LKG」且刚做过更新,
|
||||
* 说明新版本**尚未被证明能启动**,控制端应暂缓放量到其余设备(交付流水线 §6 分批策略)。
|
||||
*/
|
||||
export function lkgStatus(): {
|
||||
lkg: LkgRecord | null;
|
||||
current_hash: string | null;
|
||||
matches: boolean;
|
||||
/** LKG 与当前版本的字节差,便于快速判断是「升级」还是「回退」 */
|
||||
byte_delta: number | null;
|
||||
} {
|
||||
const lkg = readLKG();
|
||||
const cur = currentScriptHash();
|
||||
const current_hash = cur?.hash ?? null;
|
||||
return {
|
||||
lkg,
|
||||
current_hash,
|
||||
matches: !!lkg && lkg.hash === current_hash,
|
||||
byte_delta: lkg && cur ? cur.bytes - lkg.bytes : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 供审计/排障:LKG 文件是否存在及其大小(不读内容,避免大文件 IO)。 */
|
||||
export function lkgFileInfo(): { path: string; exists: boolean; bytes: number } {
|
||||
const p = lkgFilePath();
|
||||
try {
|
||||
return { path: p, exists: true, bytes: statSync(p).size };
|
||||
} catch {
|
||||
return { path: p, exists: false, bytes: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/** 让 system.info 能顺带暴露构建指纹(与 buildInfo 同源,避免两处口径漂移)。 */
|
||||
export { buildInfo };
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 被控端出网模块(桶文件)—— 导出面收敛。
|
||||
*
|
||||
* 目前只有代理出网(`proxy.ts`)。将来若新增其它出网能力(如镜像探测),
|
||||
* 也从这里统一导出,调用方只依赖本文件,不直接深入子模块。
|
||||
*
|
||||
* 依赖纪律:本目录下的模块**只依赖 node 内置模块**,不 import 项目内任何模块 ——
|
||||
* 保证可被单测独立加载、依赖方向严格单向(capabilities/* → net/)。
|
||||
*/
|
||||
|
||||
export {
|
||||
normalizeProxyUrl,
|
||||
isBypassed,
|
||||
isLoopbackTarget,
|
||||
pickEnvProxy,
|
||||
parseScutilProxy,
|
||||
parseRegQueryProxy,
|
||||
detectSystemProxy,
|
||||
systemProxy,
|
||||
__resetSystemProxyCache,
|
||||
resolveProxy,
|
||||
tunnelConnection,
|
||||
httpGet,
|
||||
type SystemProxyInfo,
|
||||
type ProxyResolution,
|
||||
type HttpResponse,
|
||||
type CommandRunner,
|
||||
} from './proxy.js';
|
||||
@@ -0,0 +1,618 @@
|
||||
/**
|
||||
* 出站代理支持 —— 被控端「拉取式自更新」的出网模块。
|
||||
*
|
||||
* ## 为什么必须自己实现(这是缺陷,不是优化)
|
||||
*
|
||||
* Node 内置 `fetch`(undici)**默认不读任何代理环境变量** —— 实测:设了 `HTTPS_PROXY`
|
||||
* 仍然 `fetch failed`。能开的只有 `NODE_USE_ENV_PROXY=1`(Node 22.x 实验性、24 正式),
|
||||
* 而它**只能读环境变量**,读不到 Windows 系统代理(WinINET)或 macOS 系统代理
|
||||
* —— 而中文用户用 Clash / v2rayN 时配置的恰恰是「系统代理」。
|
||||
* 结果:境内用户即使代理一切正常,`system.agent.update` 也**必然**报 `fetch failed`。
|
||||
* (真机取证见 apps/console/sidecar/proxy.mjs 顶部:VM 上 DNS 与 TCP 443 都通、
|
||||
* 却 `fetch failed`;同机经代理取 `latest.json` 返回 302。)
|
||||
*
|
||||
* 另外 `fetch(url, { dispatcher })` 也不通:`dispatcher` 必须是 undici 的 Dispatcher 实例,
|
||||
* 而 Node 没有把 undici 暴露成可导入的模块(只能整包 vendored 进来,会把 agent.mjs
|
||||
* 撑胖一大截,且 undici 依赖 `.wasm` 解析器、esbuild 单文件打包有风险)。
|
||||
*
|
||||
* ## 做法
|
||||
*
|
||||
* 不用 `fetch`,改用 `node:http` / `node:https` 自己发请求:
|
||||
* - **https 目标经代理**:先向代理发 `CONNECT host:443` 建隧道,再在隧道 socket 上做 TLS
|
||||
* (通过 `createConnection` 交给 `http.request` —— 注意这里刻意用 `http` 而非 `https`,
|
||||
* 因为我们交出去的**已经是 TLS socket**,用 `https.request` 会二次握手);
|
||||
* - **http 目标经代理**:按 HTTP 代理规范发**绝对形态**请求行(`GET http://… HTTP/1.1`);
|
||||
* - **无代理**:与改动前一致,直连(走 `https.request`)。
|
||||
*
|
||||
* ## 代理来源优先级
|
||||
*
|
||||
* ① `NA_PROXY`(显式覆盖,便于排查与将来做配置项)
|
||||
* ② 目标协议对应的环境变量(`HTTPS_PROXY`/`https_proxy` → `ALL_PROXY`/`all_proxy` → …)
|
||||
* ③ **系统代理**(Windows 读注册表 WinINET;macOS 读 `scutil --proxy`)
|
||||
*
|
||||
* `NO_PROXY` / `no_proxy` 命中的主机一律直连。只支持 **HTTP 代理**(`http(s)://`)——
|
||||
* HTTPS 目标经 HTTP 代理走 CONNECT,是通用做法;`socks5://` 会**明确**记一条 note
|
||||
* 而不是静默直连(静默正是本次缺陷最恶劣的地方)。
|
||||
*
|
||||
* ## 依赖纪律
|
||||
*
|
||||
* 本模块**只依赖 node 内置模块**(http/https/tls/child_process),不 import 项目内
|
||||
* 任何模块 —— 保证可被单测独立加载、可被将来其它出网能力复用;依赖方向严格单向
|
||||
* (selfupdate → net/proxy),杜绝环。
|
||||
*
|
||||
* ## 安全
|
||||
*
|
||||
* 只做「出站取数据」,不引入任何新依赖;TLS 证书默认校验,不提供跳过开关。
|
||||
*/
|
||||
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import tls from 'node:tls';
|
||||
import { execFile } from 'node:child_process';
|
||||
|
||||
/** 代理来源优先级(按目标协议分列)。`NA_PROXY` 恒为第一优先。 */
|
||||
const ENV_ORDER: Record<string, string[]> = {
|
||||
'https:': ['NA_PROXY', 'HTTPS_PROXY', 'https_proxy', 'ALL_PROXY', 'all_proxy', 'HTTP_PROXY', 'http_proxy'],
|
||||
'http:': ['NA_PROXY', 'HTTP_PROXY', 'http_proxy', 'ALL_PROXY', 'all_proxy'],
|
||||
};
|
||||
const NO_PROXY_KEYS = ['NO_PROXY', 'no_proxy'];
|
||||
const REDIRECT_STATUS = new Set([301, 302, 303, 307, 308]);
|
||||
const UA = 'nodeagent-agent';
|
||||
|
||||
/** 系统代理探测结果(`url` 为 `host:port` 或完整 URL;`bypass` 为逗号分隔绕过列表)。 */
|
||||
export interface SystemProxyInfo {
|
||||
url: string | null;
|
||||
bypass: string;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
/** 代理决议结果。 */
|
||||
export interface ProxyResolution {
|
||||
proxy: URL | null;
|
||||
source: string | null;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
/** `httpGet` 的单次响应(跟随重定向后的最终响应)。 */
|
||||
export interface HttpResponse {
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: Record<string, unknown>;
|
||||
body: Buffer;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/** 可注入的只读命令 runner(单测用假 runner,不必真去读注册表)。 */
|
||||
export type CommandRunner = (file: string, args: string[]) => Promise<string | null>;
|
||||
|
||||
/**
|
||||
* 把环境里/配置里写的各种代理形态归一成可用的 URL。
|
||||
*
|
||||
* 接受 `http://127.0.0.1:7890`、`127.0.0.1:7890`(补 `http://`)、带账密的 URL;
|
||||
* 其余(`socks5://`、空串、无法解析)返回 `null` —— 由调用方把「看到了但用不了」
|
||||
* 明确报出来,**不许静默直连**。
|
||||
*/
|
||||
export function normalizeProxyUrl(raw: unknown): URL | null {
|
||||
if (typeof raw !== 'string') return null;
|
||||
const s = raw.trim();
|
||||
if (!s) return null;
|
||||
let u: URL;
|
||||
try {
|
||||
u = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(s) ? s : `http://${s}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
|
||||
if (!u.hostname) return null;
|
||||
return u;
|
||||
}
|
||||
|
||||
/** 代理 URL 的主机端口(缺省按代理自身协议补 80/443)。 */
|
||||
function proxyEndpoint(u: URL): { host: string; port: number } {
|
||||
return { host: u.hostname, port: u.port ? Number(u.port) : u.protocol === 'https:' ? 443 : 80 };
|
||||
}
|
||||
|
||||
/** 代理 URL 里的账密 → `Proxy-Authorization` 头(没有则 null)。 */
|
||||
function proxyAuthHeader(u: URL): string | null {
|
||||
if (!u.username) return null;
|
||||
const raw = `${decodeURIComponent(u.username)}:${decodeURIComponent(u.password)}`;
|
||||
return 'Basic ' + Buffer.from(raw).toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* `NO_PROXY` 命中判定。支持:精确主机、后缀(`example.com` 或 `.example.com` 都匹配
|
||||
* `a.example.com`)、`host:port`、`*`。大小写不敏感。
|
||||
*
|
||||
* ⚠️ 刻意**不支持 CIDR**(如 `169.254/16`):本模块的目标只有公网域名,
|
||||
* 而 CIDR 主机名判定极易写错、错了就是「该走代理的没走」。遇到疑似 CIDR 条目
|
||||
* 由调用方记 note(见 `pickEnvProxy` 的 notes 语义)。
|
||||
*/
|
||||
export function isBypassed(targetUrl: string, noProxy: string): boolean {
|
||||
if (!noProxy) return false;
|
||||
let host: string;
|
||||
let port: string;
|
||||
try {
|
||||
const u = new URL(targetUrl);
|
||||
host = u.hostname.toLowerCase();
|
||||
port = u.port || (u.protocol === 'https:' ? '443' : '80');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
for (const rawEntry of String(noProxy).split(',')) {
|
||||
const entry = rawEntry.trim().toLowerCase();
|
||||
if (!entry) continue;
|
||||
if (entry === '*') return true;
|
||||
let eHost = entry;
|
||||
let ePort: string | null = null;
|
||||
const i = entry.lastIndexOf(':');
|
||||
// 排除 IPv6 字面量(`[::1]:8080`)里的冒号误判
|
||||
if (i > 0 && !entry.slice(0, i).includes(':')) {
|
||||
eHost = entry.slice(0, i);
|
||||
ePort = entry.slice(i + 1);
|
||||
}
|
||||
if (ePort && ePort !== port) continue;
|
||||
const bare = eHost.replace(/^\./, '');
|
||||
if (!bare) continue;
|
||||
if (host === bare || host.endsWith(`.${bare}`)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 从环境变量里挑代理(纯函数,便于单测)。 */
|
||||
export function pickEnvProxy(
|
||||
env: Record<string, string | undefined>,
|
||||
targetUrl: string,
|
||||
): { url: URL | null; source: string | null; notes: string[] } {
|
||||
let proto = 'https:';
|
||||
try {
|
||||
proto = new URL(targetUrl).protocol;
|
||||
} catch {
|
||||
/* 保持默认 */
|
||||
}
|
||||
const keys = ENV_ORDER[proto] ?? ENV_ORDER['https:']!;
|
||||
const notes: string[] = [];
|
||||
for (const k of keys) {
|
||||
const v = env?.[k];
|
||||
if (!v) continue;
|
||||
const u = normalizeProxyUrl(v);
|
||||
if (u) return { url: u, source: k, notes };
|
||||
notes.push(`${k}=${String(v).trim()} 不是受支持的代理协议(仅支持 http/https 代理),已忽略`);
|
||||
}
|
||||
return { url: null, source: null, notes };
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 macOS `scutil --proxy` 的输出。
|
||||
*
|
||||
* 形如:
|
||||
* <dictionary> {
|
||||
* HTTPSEnable : 1
|
||||
* HTTPSPort : 7890
|
||||
* HTTPSProxy : 127.0.0.1
|
||||
* ExceptionsList : <array> { 0 : *.local 1 : 169.254/16 }
|
||||
* }
|
||||
*
|
||||
* 注意 macOS 上 HTTPS 与 HTTP 是**分别开关**的;这里 https 优先,取不到再退 HTTP。
|
||||
* `ProxyAutoConfigEnable : 1`(PAC 自动配置脚本)**无法解析** —— 返回 notes 说明,
|
||||
* 让上层给出「请显式设 HTTPS_PROXY」这类可操作提示,而不是含糊的 fetch failed。
|
||||
*/
|
||||
export function parseScutilProxy(text: string): SystemProxyInfo | null {
|
||||
if (typeof text !== 'string' || !text.includes('{')) return null;
|
||||
const scalar = (key: string): string | null => {
|
||||
const m = new RegExp(`^\\s*${key}\\s*:\\s*(.+?)\\s*$`, 'm').exec(text);
|
||||
return m ? m[1]!.trim() : null;
|
||||
};
|
||||
const isOn = (v: string | null): boolean => v === '1' || v === 'true';
|
||||
const notes: string[] = [];
|
||||
|
||||
// ExceptionsList 是 <array> 块里的 “序号 : 值” 行 —— 收集成逗号分隔的绕过列表
|
||||
const bypass: string[] = [];
|
||||
const arrIdx = text.indexOf('ExceptionsList');
|
||||
if (arrIdx >= 0) {
|
||||
const body = text.slice(arrIdx, text.indexOf('}', arrIdx) + 1);
|
||||
const re = /^\s*\d+\s*:\s*(.+?)\s*$/gm;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(body)) !== null) bypass.push(m[1]!.trim());
|
||||
}
|
||||
|
||||
for (const [flag, hostKey, portKey, dflt] of [
|
||||
['HTTPSEnable', 'HTTPSProxy', 'HTTPSPort', '443'],
|
||||
['HTTPEnable', 'HTTPProxy', 'HTTPPort', '80'],
|
||||
] as const) {
|
||||
if (!isOn(scalar(flag))) continue;
|
||||
const host = scalar(hostKey);
|
||||
if (!host) continue;
|
||||
const port = scalar(portKey) ?? dflt;
|
||||
return { url: `${host}:${port}`, bypass: bypass.join(','), notes };
|
||||
}
|
||||
|
||||
if (isOn(scalar('ProxyAutoConfigEnable'))) {
|
||||
notes.push('系统代理使用 PAC 自动配置脚本(ProxyAutoConfigEnable=1),本程序无法解析脚本内容,已按「无代理」处理');
|
||||
}
|
||||
return { url: null, bypass: bypass.join(','), notes };
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 Windows `reg query …\Internet Settings` 的输出。
|
||||
*
|
||||
* 形如(`REG_SZ` / `REG_DWORD` 是**类型名,不随系统语言变化**,可安全按 token 解析):
|
||||
* ProxyEnable REG_DWORD 0x1
|
||||
* ProxyOverride REG_SZ *.local;<local>
|
||||
* ProxyServer REG_SZ 127.0.0.1:7890 (也可能是 http=…;https=… 分协议形态)
|
||||
*
|
||||
* `ProxyEnable=0x0` 视为**显式关闭** → 无代理(哪怕 ProxyServer 有值)。
|
||||
* `<local>` 是「本机地址直连」的占位符,不是域名,从绕过列表里剔除。
|
||||
*/
|
||||
export function parseRegQueryProxy(text: string): SystemProxyInfo | null {
|
||||
if (typeof text !== 'string' || !text.includes('REG_')) return null;
|
||||
const value = (name: string): string | null => {
|
||||
const m = new RegExp(`^\\s*${name}\\s+REG_(?:SZ|EXPAND_SZ|DWORD|BINARY)\\s+(.*?)\\s*$`, 'm').exec(text);
|
||||
return m ? m[1]!.trim() : null;
|
||||
};
|
||||
const notes: string[] = [];
|
||||
|
||||
const enable = value('ProxyEnable');
|
||||
if (enable !== null && /^0x0+$/i.test(enable)) return { url: null, bypass: '', notes };
|
||||
|
||||
const server = value('ProxyServer');
|
||||
if (!server) return { url: null, bypass: '', notes };
|
||||
|
||||
const override = value('ProxyOverride');
|
||||
const bypass = override
|
||||
? override
|
||||
.split(';')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s && !/^<local>$/i.test(s))
|
||||
.join(',')
|
||||
: '';
|
||||
|
||||
// 分协议形态:http=127.0.0.1:7890;https=127.0.0.1:7891 —— https 优先
|
||||
let url: string | null = server;
|
||||
if (server.includes('=')) {
|
||||
const map = new Map<string, string>();
|
||||
for (const part of server.split(';')) {
|
||||
const i = part.indexOf('=');
|
||||
if (i > 0) map.set(part.slice(0, i).trim().toLowerCase(), part.slice(i + 1).trim());
|
||||
}
|
||||
url = map.get('https') ?? map.get('http') ?? null;
|
||||
if (!url) notes.push(`系统代理仅配置了非 http(s) 协议(ProxyServer=${server})`);
|
||||
}
|
||||
return { url, bypass, notes };
|
||||
}
|
||||
|
||||
/**
|
||||
* 跑一个只读命令,失败/超时一律返回 null(探测系统代理**绝不允许**把自更新搞挂)。
|
||||
*
|
||||
* `windowsHide: true` —— 被控端由计划任务/服务拉起时是「无控制台」进程,
|
||||
* 若再 spawn 控制台程序(reg.exe),Windows 会**新开一个控制台** → 黑窗闪一下。
|
||||
*/
|
||||
function runQuiet(file: string, args: string[]): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
execFile(
|
||||
file,
|
||||
args,
|
||||
{ windowsHide: true, timeout: 5000, encoding: 'utf8', maxBuffer: 1024 * 1024 },
|
||||
(err, stdout) => resolve(err ? null : String(stdout)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 探测系统代理。`run` 可注入(单测用假 runner,不必真去读注册表)。
|
||||
*
|
||||
* 任何异常都吞掉返回 null —— 探测失败绝不能成为新的失败点。
|
||||
*/
|
||||
export async function detectSystemProxy(
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
run: CommandRunner = runQuiet,
|
||||
): Promise<SystemProxyInfo | null> {
|
||||
try {
|
||||
if (platform === 'win32') {
|
||||
const out = await run('reg', [
|
||||
'query',
|
||||
'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings',
|
||||
]);
|
||||
return out ? parseRegQueryProxy(out) : null;
|
||||
}
|
||||
if (platform === 'darwin') {
|
||||
const out = await run('scutil', ['--proxy']);
|
||||
return out ? parseScutilProxy(out) : null;
|
||||
}
|
||||
// Linux:桌面环境各异(gsettings / 环境变量 / PAC),不做猜测 —— 只认环境变量
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 系统代理只探测一次(同一进程内结果不变;用户改代理需重启被控端)。 */
|
||||
let systemProxyPromise: Promise<SystemProxyInfo | null> | undefined;
|
||||
export function systemProxy(): Promise<SystemProxyInfo | null> {
|
||||
if (!systemProxyPromise) systemProxyPromise = detectSystemProxy().catch(() => null);
|
||||
return systemProxyPromise;
|
||||
}
|
||||
|
||||
/** 单测用:清掉系统代理缓存。 */
|
||||
export function __resetSystemProxyCache(): void {
|
||||
systemProxyPromise = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 目标是否为「本机回环 / 链路本地」地址。
|
||||
*
|
||||
* 为什么强制直连(对 sidecar 原版的有意加强):代理另一侧的 `localhost` **不是**
|
||||
* 本机的 localhost —— 把发往 `127.0.0.1:<自建镜像/测试源站>` 的请求交给代理,
|
||||
* 必然失败且报错形态费解(代理返回它自己那侧的 404 / 拒连)。
|
||||
* CI 与开发机普遍设置 `HTTP_PROXY`(本仓库 e2e 就这样踩到过:测试源站在回环,
|
||||
* agent 继承沙箱代理后拿到 404)。公开域名的行为**不变**(本函数只命中回环)。
|
||||
*/
|
||||
export function isLoopbackTarget(targetUrl: string): boolean {
|
||||
let host: string;
|
||||
try {
|
||||
host = new URL(targetUrl).hostname.toLowerCase();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (host === 'localhost') return true;
|
||||
// IPv6 回环:URL 会把 [::1] 规范化成 ::1
|
||||
if (host === '::1' || host === '[::1]') return true;
|
||||
// IPv4 回环段 127.0.0.0/8(只认 0-255 的合法字节,不把 127.999 也算进去)
|
||||
const m = /^127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
||||
if (m) {
|
||||
const ok = m.slice(1).every((octet) => Number(octet) <= 255);
|
||||
if (ok) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 决议本次请求该用哪个代理。
|
||||
*
|
||||
* @param opts `system` 显式传入时不做探测(单测用);传 `null` 表示「确认没有系统代理」。
|
||||
*/
|
||||
export async function resolveProxy(
|
||||
opts: {
|
||||
targetUrl: string;
|
||||
env?: Record<string, string | undefined>;
|
||||
system?: SystemProxyInfo | null;
|
||||
} = { targetUrl: '' },
|
||||
): Promise<ProxyResolution> {
|
||||
const { targetUrl } = opts;
|
||||
const env = opts.env ?? process.env;
|
||||
const notes: string[] = [];
|
||||
const noProxy = NO_PROXY_KEYS.map((k) => env?.[k]).find(Boolean) ?? '';
|
||||
if (noProxy && isBypassed(targetUrl, noProxy)) {
|
||||
return { proxy: null, source: null, notes: [`目标命中 NO_PROXY=${noProxy},按直连处理`] };
|
||||
}
|
||||
// 回环/链路本地目标恒直连(理由见 isLoopbackTarget:代理的另一侧没有这台机器的 localhost)
|
||||
if (isLoopbackTarget(targetUrl)) {
|
||||
return { proxy: null, source: null, notes: ['目标为本机回环地址,按直连处理(代理另一侧的 localhost 不是本机)'] };
|
||||
}
|
||||
|
||||
const fromEnv = pickEnvProxy(env ?? {}, targetUrl);
|
||||
notes.push(...fromEnv.notes);
|
||||
if (fromEnv.url) return { proxy: fromEnv.url, source: fromEnv.source, notes };
|
||||
|
||||
const sys = opts.system === undefined ? await systemProxy() : opts.system;
|
||||
if (sys?.url) {
|
||||
notes.push(...(sys.notes ?? []));
|
||||
if (sys.bypass && isBypassed(targetUrl, sys.bypass)) {
|
||||
notes.push(`目标命中系统代理的绕过列表(${sys.bypass}),按直连处理`);
|
||||
return { proxy: null, source: null, notes };
|
||||
}
|
||||
const u = normalizeProxyUrl(String(sys.url));
|
||||
if (u) return { proxy: u, source: 'system', notes };
|
||||
notes.push(`系统代理 ${sys.url} 不受支持(仅支持 http/https 代理),已按直连处理`);
|
||||
return { proxy: null, source: null, notes };
|
||||
}
|
||||
notes.push(...(sys?.notes ?? []));
|
||||
return { proxy: null, source: null, notes };
|
||||
}
|
||||
|
||||
/**
|
||||
* 造一个 `createConnection`:向代理发 CONNECT 建隧道,https 目标再在隧道上叠 TLS。
|
||||
*
|
||||
* 返回的 socket 交给 `http.request` 使用(那已经是 TLS socket,故用 `http` 模块)。
|
||||
*
|
||||
* ## ⚠️ 为什么必须自己管 `signal`(真机踩过)
|
||||
* `createConnection` 的回调**返回之前**,外层 `http.request` 还没建立 —— 此时
|
||||
* 外层请求上的 `signal` / `timeout` 都**无人受理**。真机形态:代理 TCP 能连上、却对
|
||||
* CONNECT 或随后的 TLS 握手**永不响应**,于是「75s 超时」形同虚设,实际挂了 109s。
|
||||
* 所以:CONNECT 请求要带 signal,**拿到 socket 与 tlsSock 之后还要自己挂 abort**,
|
||||
* 失败/成功时都要摘掉监听,避免泄漏与误伤。
|
||||
*/
|
||||
export function tunnelConnection(
|
||||
proxy: URL,
|
||||
target: { protocol: string; hostname: string; port: string },
|
||||
signal: AbortSignal | undefined,
|
||||
): (opts: http.RequestOptions, cb: (err: Error | null, socket?: tls.TLSSocket) => void) => void {
|
||||
const pe = proxyEndpoint(proxy);
|
||||
return (_opts, cb) => {
|
||||
let settled = false;
|
||||
let tunnelSock: import('node:net').Socket | null = null;
|
||||
let tlsSock: tls.TLSSocket | null = null;
|
||||
|
||||
const detach = (): void => {
|
||||
if (signal) signal.removeEventListener('abort', onAbort);
|
||||
if (tunnelSock) tunnelSock.removeListener('error', onSockError);
|
||||
};
|
||||
const fail = (err: Error): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
detach();
|
||||
// 半开的隧道必须销毁,否则这个悬挂连接会一直占着(点几次就攒一堆)
|
||||
try {
|
||||
tlsSock?.destroy();
|
||||
tunnelSock?.destroy();
|
||||
} catch {
|
||||
/* 已经没了 */
|
||||
}
|
||||
cb(err);
|
||||
};
|
||||
const onSockError = (e: Error): void => fail(e);
|
||||
const onAbort = (): void => fail(signal?.reason instanceof Error ? signal.reason : new Error('请求已中止'));
|
||||
|
||||
if (signal?.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
|
||||
const port = target.port || (target.protocol === 'https:' ? '443' : '80');
|
||||
const headers: Record<string, string> = { host: `${target.hostname}:${port}` };
|
||||
const auth = proxyAuthHeader(proxy);
|
||||
if (auth) headers['proxy-authorization'] = auth;
|
||||
|
||||
const req = http.request({
|
||||
host: pe.host,
|
||||
port: pe.port,
|
||||
method: 'CONNECT',
|
||||
path: `${target.hostname}:${port}`,
|
||||
headers,
|
||||
agent: false,
|
||||
signal,
|
||||
});
|
||||
req.once('connect', (res, socket, head) => {
|
||||
tunnelSock = socket;
|
||||
if (res.statusCode !== 200) {
|
||||
fail(new Error(`代理拒绝 CONNECT(HTTP ${res.statusCode})`));
|
||||
return;
|
||||
}
|
||||
if (head && head.length) socket.unshift(head);
|
||||
if (target.protocol !== 'https:') {
|
||||
settled = true;
|
||||
detach();
|
||||
cb(null, socket as unknown as tls.TLSSocket);
|
||||
return;
|
||||
}
|
||||
socket.once('error', onSockError);
|
||||
tlsSock = tls.connect({ socket, servername: target.hostname });
|
||||
tlsSock.once('secureConnect', () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
detach();
|
||||
cb(null, tlsSock!);
|
||||
});
|
||||
tlsSock.once('error', onSockError);
|
||||
});
|
||||
req.once('error', onSockError);
|
||||
req.end();
|
||||
};
|
||||
}
|
||||
|
||||
/** 单跳响应(`url` 由 httpGet 在跟随重定向后补上)。 */
|
||||
type SingleResponse = Omit<HttpResponse, 'url'>;
|
||||
|
||||
/** 单跳请求(不跟随重定向)。 */
|
||||
function once(
|
||||
urlStr: string,
|
||||
{ proxy, timeoutMs, signal, maxBytes }: { proxy: URL | null; timeoutMs: number; signal: AbortSignal; maxBytes?: number },
|
||||
): Promise<SingleResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const u = new URL(urlStr);
|
||||
const isHttps = u.protocol === 'https:';
|
||||
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
||||
reject(new Error(`不支持的协议:${u.protocol}`));
|
||||
return;
|
||||
}
|
||||
// ⚠️ 手搭 options,**不要**把 URL 对象当第一参再覆盖字段 —— 传 URL 时 Node 会用
|
||||
// URL 里的 host/port/path 覆盖后面的同名字段(原型实测踩过:代理形同无效、退化成直连)。
|
||||
const opts: http.RequestOptions = {
|
||||
method: 'GET',
|
||||
signal,
|
||||
timeout: timeoutMs,
|
||||
host: u.hostname,
|
||||
port: u.port || (isHttps ? 443 : 80),
|
||||
path: `${u.pathname}${u.search}`,
|
||||
headers: { accept: '*/*', 'user-agent': UA, host: u.host },
|
||||
};
|
||||
|
||||
let req: http.ClientRequest;
|
||||
if (proxy && !isHttps) {
|
||||
// http 目标经代理 → 绝对形态请求行,且请求行交给代理
|
||||
const pe = proxyEndpoint(proxy);
|
||||
opts.host = pe.host;
|
||||
opts.port = pe.port;
|
||||
opts.path = u.href;
|
||||
const auth = proxyAuthHeader(proxy);
|
||||
if (auth) (opts.headers as Record<string, string>)['proxy-authorization'] = auth;
|
||||
req = http.request(opts);
|
||||
} else if (proxy) {
|
||||
opts.createConnection = tunnelConnection(proxy, u, signal) as never;
|
||||
req = http.request(opts);
|
||||
} else {
|
||||
req = (isHttps ? https : http).request(opts);
|
||||
}
|
||||
|
||||
req.once('timeout', () => req.destroy(new Error(`请求超时(${timeoutMs}ms 无响应)`)));
|
||||
req.once('response', (res) => {
|
||||
// 上限第一闸:content-length 声明超限 → 根本不开始读 body
|
||||
const declared = Number(res.headers['content-length'] ?? '0');
|
||||
if (maxBytes !== undefined && declared > maxBytes) {
|
||||
res.destroy(new Error(`响应体超过上限 ${maxBytes} 字节(content-length=${declared}),已中断`));
|
||||
return;
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
res.on('data', (c: Buffer) => {
|
||||
// 上限第二闸(流式):服务端谎报/不报 content-length 时,收到即累计,
|
||||
// 超限立刻断,不让无界数据先把被控端内存吃爆
|
||||
total += c.length;
|
||||
if (maxBytes !== undefined && total > maxBytes) {
|
||||
res.destroy(new Error(`响应体超过上限 ${maxBytes} 字节,已中断`));
|
||||
return;
|
||||
}
|
||||
chunks.push(c);
|
||||
});
|
||||
res.once('error', reject);
|
||||
res.once('end', () =>
|
||||
resolve({
|
||||
status: res.statusCode ?? 0,
|
||||
statusText: res.statusMessage ?? '',
|
||||
headers: res.headers as Record<string, unknown>,
|
||||
body: Buffer.concat(chunks),
|
||||
}),
|
||||
);
|
||||
});
|
||||
req.once('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GET 一个 URL(跟随重定向)。
|
||||
*
|
||||
* 与 `fetch(url, {redirect:'follow'})` 的差异:不自动解压(不发 `accept-encoding`,
|
||||
* 服务端会回 identity)、不解析 cookies —— 这两条对本用途都无影响。
|
||||
*
|
||||
* 整个重定向链共享**同一个**超时预算与 abort 信号(而不是每跳各自计时),
|
||||
* 否则 5 跳 × 30s = 150s 会穿过上层的 RPC 超时。
|
||||
*
|
||||
* @param maxBytes 响应体流式上限(累计字节);超限立即中断。缺省不设限。
|
||||
*/
|
||||
export async function httpGet(
|
||||
url: string,
|
||||
{
|
||||
proxy = null,
|
||||
timeoutMs = 30_000,
|
||||
maxRedirects = 5,
|
||||
maxBytes,
|
||||
}: { proxy?: URL | null; timeoutMs?: number; maxRedirects?: number; maxBytes?: number } = {},
|
||||
): Promise<HttpResponse> {
|
||||
const ac = new AbortController();
|
||||
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
||||
try {
|
||||
let current = String(url);
|
||||
for (let hop = 0; ; hop += 1) {
|
||||
const res = await once(current, { proxy, timeoutMs, signal: ac.signal, maxBytes });
|
||||
const loc = res.headers?.['location'];
|
||||
if (REDIRECT_STATUS.has(res.status) && typeof loc === 'string' && hop < maxRedirects) {
|
||||
current = new URL(loc, current).href;
|
||||
continue;
|
||||
}
|
||||
return { ...res, url: current };
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
+142
-27
@@ -32,6 +32,7 @@ import { createCapabilityRegistry } from './capabilities/index.js';
|
||||
import { audit, describeArgs } from './audit.js';
|
||||
import { disposeWatchesByOwner, watchCount } from './events.js';
|
||||
import { runningTaskCount } from './capabilities/task.js';
|
||||
import { createRateLimiter } from './util/rate-limit.js';
|
||||
import type { AgentConfig } from './config.js';
|
||||
import type { TlsMaterial } from './certs.js';
|
||||
|
||||
@@ -74,25 +75,37 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
const capabilityRegistry = createCapabilityRegistry(cfg);
|
||||
/** v3:本次实例采用的认证模式(psk 向后兼容 / ed25519 零信任) */
|
||||
const authMode: 'psk' | 'ed25519' = cfg.auth_mode === 'ed25519' ? 'ed25519' : 'psk';
|
||||
const aclPolicy = cfg.acl ?? { default_effect: 'deny' as const, clients: [] };
|
||||
/** 只要配置了 ACL 就启用能力级授权(无论 psk 还是 ed25519)。 */
|
||||
const hasAcl = Boolean(cfg.acl?.clients?.length);
|
||||
/**
|
||||
* ACL 策略归一化。
|
||||
*
|
||||
* ⚠️ A-01(真机实测,P0):`clients` 必须在**这里**补齐成数组。
|
||||
* 原实现只写 `cfg.acl ?? {…, clients: []}` —— 当用户配置 `{"acl":{"default_effect":"deny"}}`
|
||||
* (很自然的写法,省略空的 clients)时,`cfg.acl` 非空 → `??` 不兜底 →
|
||||
* `aclPolicy.clients` 为 `undefined` → 后面无条件执行 `.find()` → **每次 invoke 都崩掉被控端进程**。
|
||||
*/
|
||||
const aclPolicy = {
|
||||
default_effect: cfg.acl?.default_effect === 'allow' ? ('allow' as const) : ('deny' as const),
|
||||
clients: Array.isArray(cfg.acl?.clients) ? cfg.acl.clients : [],
|
||||
};
|
||||
/**
|
||||
* 只要**配置了 ACL 对象**就启用能力级授权(无论 psk 还是 ed25519)。
|
||||
*
|
||||
* ⚠️ A-02(真机实测,P1):判据必须是「对象是否存在」,**不能**用 `clients.length`。
|
||||
* 原实现 `Boolean(cfg.acl?.clients?.length)` 在 `{"acl":{"default_effect":"deny","clients":[]}}`
|
||||
* 时得到 `false` → 整段 ACL 校验被跳过 → **fail-open 全放行**(配置写着默认拒绝却全通)。
|
||||
* 空 clients 的正确语义是「没有任何调用方被授权」,即拒绝一切(由 default_effect 决定)。
|
||||
*/
|
||||
const hasAcl = Boolean(cfg.acl);
|
||||
|
||||
/** 按调用方的滑动窗口限速(每分钟)。 */
|
||||
const rateBuckets = new Map<string, number[]>();
|
||||
function allowByRate(clientId: string, limitPerMin?: number, bucket?: string): boolean {
|
||||
if (!limitPerMin || limitPerMin <= 0) return true;
|
||||
const key = bucket ? `${clientId}|${bucket}` : clientId;
|
||||
const now = Date.now();
|
||||
const recent = (rateBuckets.get(key) ?? []).filter((t) => now - t < 60_000);
|
||||
if (recent.length >= limitPerMin) {
|
||||
rateBuckets.set(key, recent);
|
||||
return false;
|
||||
}
|
||||
recent.push(now);
|
||||
rateBuckets.set(key, recent);
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* 按调用方的滑动窗口限速(每分钟)。
|
||||
*
|
||||
* ⚠️ A-P0-2(2026-10-25):原实现 `allowByRate(clientId, limit, String(limit))`
|
||||
* 把**限速值**当桶名 —— 同限速值的不同能力共用一个桶(互相挤占/漏过),
|
||||
* 且 `rateBuckets` 只增不删(psk 模式 client_id 自报 → 可枚举造键 → 内存无界增长)。
|
||||
* 现抽到 `util/rate-limit.ts`:桶名 = 能力名;过期键顺手删 + 容量上限淘汰最旧。
|
||||
*/
|
||||
const rateLimiter = createRateLimiter();
|
||||
const levelOrder: Record<LogLevel, number> = { debug: 0, info: 1, warn: 2 };
|
||||
const log = (level: LogLevel, msg: string): void => {
|
||||
if (levelOrder[level] >= levelOrder[cfg.log_level]) {
|
||||
@@ -114,14 +127,37 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
|
||||
function handleHello(ws: WebSocket, state: ConnState, req: RpcRequest): void {
|
||||
const params = req.params as HelloParams | undefined;
|
||||
const proto = params?.protocol ?? '';
|
||||
// ⚠️ B-01(真机实测,P1):`protocol` 必须先做**类型**校验再 `.split()`。
|
||||
// 原实现 `params?.protocol ?? ''` 对 `protocol: 123` 得到数字 → `proto.split` 抛
|
||||
// TypeError → 未捕获的 rejection → Node ≥22 直接终止被控端进程(**免认证即可打崩**)。
|
||||
const rawProto = params?.protocol;
|
||||
if (typeof rawProto !== 'string' || rawProto === '') {
|
||||
log('warn', `握手失败:protocol 非法(${typeof rawProto})`);
|
||||
audit({
|
||||
type: 'request.rejected',
|
||||
remote: state.remote,
|
||||
status: 'denied',
|
||||
reason: `hello.protocol 类型非法: ${typeof rawProto}`,
|
||||
});
|
||||
sendError(ws, req.id, ErrorCodes.PARAM_INVALID, 'hello 的 protocol 必须是形如 "1.0" 的字符串', {
|
||||
got: typeof rawProto,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const proto = rawProto;
|
||||
if (proto.split('.')[0] !== PROTOCOL_VERSION.split('.')[0]) {
|
||||
audit({
|
||||
type: 'request.rejected',
|
||||
remote: state.remote,
|
||||
status: 'denied',
|
||||
reason: `协议版本不兼容: 客户端 ${proto}`,
|
||||
});
|
||||
sendError(ws, req.id, ErrorCodes.PROTOCOL_MISMATCH, `协议版本不兼容: 客户端 ${proto},服务端 ${PROTOCOL_VERSION}`);
|
||||
return;
|
||||
}
|
||||
state.nonce = generateNonce();
|
||||
state.nonceExpiresAt = Date.now() + NONCE_TTL_MS;
|
||||
state.clientId = params?.client_id ?? null;
|
||||
state.clientId = typeof params?.client_id === 'string' ? params.client_id : null;
|
||||
// 新一轮握手开始:清除上一轮的认证态与授权
|
||||
// (Hub 模式下同一连接会被不同控制端先后复用,必须重置,否则存在越权风险)
|
||||
state.authenticated = false;
|
||||
@@ -147,12 +183,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 +203,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 +216,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'
|
||||
@@ -220,11 +274,17 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
|
||||
async function handleInvoke(ws: WebSocket, state: ConnState, req: RpcRequest): Promise<void> {
|
||||
if (!state.authenticated) {
|
||||
audit({
|
||||
type: 'request.rejected',
|
||||
remote: state.remote,
|
||||
status: 'denied',
|
||||
reason: '未完成认证即调用能力',
|
||||
});
|
||||
sendError(ws, req.id, ErrorCodes.AUTH_REQUIRED, '未完成认证,请先握手');
|
||||
return;
|
||||
}
|
||||
const params = req.params as InvokeParams | undefined;
|
||||
const name = params?.capability ?? '';
|
||||
const name = typeof params?.capability === 'string' ? params.capability : '';
|
||||
|
||||
// 能力级 ACL:只要被控端配置了 ACL 即执行(deny 优先 → allow → 默认拒绝)。
|
||||
// 注:psk 模式下 client_id 由客户端自报,此为「配置级约束」;对抗恶意调用方仍需 ed25519 绑定身份。
|
||||
@@ -252,6 +312,7 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
}
|
||||
|
||||
// v3+:按调用方滑动窗口限速(ACL 的 rate_limits 按能力覆盖,未命中回退 max_calls_per_min)
|
||||
// 注:aclPolicy.clients 已在 createAgentCore 里归一成数组(A-01),这里取不到就是 undefined。
|
||||
const clientRule = aclPolicy.clients.find((c) => c.client_id === state.clientId);
|
||||
|
||||
// v11:来源 IP 白/黑名单 + 生效时段(仅在已配置该 client 规则时生效)
|
||||
@@ -294,8 +355,11 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
}
|
||||
}
|
||||
|
||||
// 限速键 = `${认证身份 ?? 远端地址}:${能力名}`:
|
||||
// ① 不同能力各占一桶(同限速值不再互挤/漏过);
|
||||
// ② 未认证身份(psk 自报缺失)时退化按远端地址,不用空串把所有人混为一桶。
|
||||
const rateLimit = clientRule ? rateLimitFor(clientRule, name) : undefined;
|
||||
if (!allowByRate(state.clientId ?? '', rateLimit, String(rateLimit ?? ''))) {
|
||||
if (!rateLimiter.allow(state.clientId ?? state.remote, rateLimit, name)) {
|
||||
log('warn', `限速拒绝 client_id=${state.clientId} capability=${name}`);
|
||||
audit({
|
||||
type: 'rate.limited',
|
||||
@@ -315,6 +379,14 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
const cap = findCapability(name);
|
||||
const handler = capabilityRegistry[name];
|
||||
if (!cap || !handler) {
|
||||
audit({
|
||||
type: 'request.rejected',
|
||||
client_id: state.clientId ?? undefined,
|
||||
remote: state.remote,
|
||||
capability: name,
|
||||
status: 'denied',
|
||||
reason: `不支持的能力: ${name}`,
|
||||
});
|
||||
sendError(ws, req.id, ErrorCodes.CAPABILITY_NOT_FOUND, `不支持的能力: ${name}`, { capability: name });
|
||||
return;
|
||||
}
|
||||
@@ -322,6 +394,15 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
const args = (params?.args ?? {}) as Record<string, unknown>;
|
||||
const errors = validate(args, cap.params_schema);
|
||||
if (errors.length > 0) {
|
||||
// ⚠️ U-01:拒绝必须留痕(探测行为若不留审计,事后无法复盘被谁、用什么参数试过)
|
||||
audit({
|
||||
type: 'request.rejected',
|
||||
client_id: state.clientId ?? undefined,
|
||||
remote: state.remote,
|
||||
capability: name,
|
||||
status: 'denied',
|
||||
reason: `参数校验失败: ${errors.slice(0, 3).join('; ')}`,
|
||||
});
|
||||
sendError(ws, req.id, ErrorCodes.PARAM_INVALID, '参数校验失败', { errors });
|
||||
return;
|
||||
}
|
||||
@@ -333,6 +414,7 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
// v12:把连接上下文交给能力处理器(事件订阅需要「谁订阅的」与「往哪推」)
|
||||
const data = await handler(finalArgs, {
|
||||
owner: state.connId,
|
||||
clientId: state.clientId ?? '',
|
||||
emit: (evt: unknown) => send(ws, { jsonrpc: '2.0', method: 'event', params: evt }),
|
||||
});
|
||||
const duration = Date.now() - started;
|
||||
@@ -379,10 +461,18 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
try {
|
||||
req = JSON.parse(text) as RpcRequest;
|
||||
} catch {
|
||||
audit({ type: 'request.rejected', remote: states.get(ws)?.remote, status: 'denied', reason: '无法解析 JSON' });
|
||||
sendError(ws, null, ErrorCodes.PARSE_ERROR, '无法解析 JSON');
|
||||
return;
|
||||
}
|
||||
if (req?.jsonrpc !== '2.0' || typeof req.id !== 'string' || typeof req.method !== 'string') {
|
||||
// 信封非法:可能是扫描器/协议错误,也留一条痕(原实现完全静默)
|
||||
audit({
|
||||
type: 'request.rejected',
|
||||
remote: states.get(ws)?.remote,
|
||||
status: 'denied',
|
||||
reason: `请求信封无效(jsonrpc=${typeof req?.jsonrpc} id=${typeof req?.id} method=${typeof req?.method})`,
|
||||
});
|
||||
sendError(ws, typeof req?.id === 'string' ? req.id : null, ErrorCodes.INVALID_REQUEST, '请求信封无效');
|
||||
return;
|
||||
}
|
||||
@@ -401,6 +491,12 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
return;
|
||||
case Methods.Capabilities:
|
||||
if (!state.authenticated) {
|
||||
audit({
|
||||
type: 'request.rejected',
|
||||
remote: state.remote,
|
||||
status: 'denied',
|
||||
reason: '未认证即拉取能力清单',
|
||||
});
|
||||
sendError(ws, req.id, ErrorCodes.AUTH_REQUIRED, '未完成认证');
|
||||
return;
|
||||
}
|
||||
@@ -410,6 +506,13 @@ 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}`);
|
||||
}
|
||||
}
|
||||
@@ -481,7 +584,19 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
// v2.0.0:任何入站消息都算活跃(空闲断开以"完全静默"为准,不是"没有调用能力")
|
||||
const st = states.get(ws);
|
||||
if (st) st.lastActiveAt = Date.now();
|
||||
void handleMessage(ws, raw.toString());
|
||||
// ⚠️ 崩溃链根因(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}`);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Windows **DPI 感知** PowerShell 前置段(物理像素坐标的唯一真源)。
|
||||
*
|
||||
* ## 为什么必须有
|
||||
*
|
||||
* PowerShell 默认是「**DPI 不感知**」进程。在高 DPI(如 200% 缩放)下,这种进程里
|
||||
* **同时存在两套坐标系**,而且互不提醒:
|
||||
*
|
||||
* | 调用 | 该进程看到的 |
|
||||
* |---|---|
|
||||
* | `[System.Windows.Forms.Screen]::Bounds` | **逻辑**尺寸(物理 2560×1440 → 报 1280×720) |
|
||||
* | `[System.Windows.Forms.SystemInformation]::VirtualScreen` | **逻辑**(同上) |
|
||||
* | `CopyFromScreen(0,0,1280,720)` | **物理**裁剪 → 只截到屏幕左上 1/4 |
|
||||
* | `SetCursorPos(552,745)` | 落点实际在物理 (1104, 1490)(被系统 ×2) |
|
||||
* | UIA(`screen.find`) | **物理**(与进程 DPI 感知无关) |
|
||||
*
|
||||
* 于是最要命的一条链路会静默断掉:`screen.find` 返回物理坐标 → 直接喂给
|
||||
* `input.mouse.click` → **点到两倍远的地方**(还可能被屏幕边界截断)。
|
||||
* 「找得到、点不准」,而且不报任何错。
|
||||
*
|
||||
* ## 用法
|
||||
*
|
||||
* 在 **任何** `System.Windows.Forms` / 截屏 / 光标调用**之前**执行本段;
|
||||
* 同一进程内只需一次(`SetProcessDPIAware` 幂等)。新起的 PowerShell 每次都要带上。
|
||||
*
|
||||
* ## 真机实证(2026-10-12)
|
||||
*
|
||||
* 某虚拟化平台上的 Win11 ARM 虚拟机,宿主缩放 200%,物理 2560×1440:
|
||||
* - 缺省 `screen.capture` 只拿到 1280×720(= 屏幕左上 1/4),且 `screen.info` 报 1280×720
|
||||
* - `input.mouse.move 100,100` → DPI 感知读回 **(200,200)**
|
||||
* - `input.mouse.move 552,745` → 实际落点 **(1104, 1439)**(截断到屏幕底部)
|
||||
* - 补上本段后:`screen.capture` 拿到完整 2560×1440;`screen.find` 的物理坐标
|
||||
* 可直接用于 `input.mouse.click`(同一坐标一次点中)
|
||||
*
|
||||
* ⚠️ 不要删。这不是「锦上添花」的兼容代码,而是坐标口径统一的**前提**。
|
||||
* 守卫见 `tests/unit/dpi-awareness.test.mjs`。
|
||||
*/
|
||||
export const PS_DPI_AWARE = [
|
||||
// Add-Type 现编译一个小 P/Invoke 壳;类名固定,每进程只应出现一次。
|
||||
`Add-Type -TypeDefinition 'using System.Runtime.InteropServices; public class NADpiAware { [DllImport("user32.dll")] public static extern bool SetProcessDPIAware(); }'`,
|
||||
`[void][NADpiAware]::SetProcessDPIAware()`,
|
||||
].join('\n');
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 平台能力守卫(跨平台最小实现)。
|
||||
*
|
||||
* ## 为什么要统一
|
||||
* 各能力原本各自判断平台,风格不一:
|
||||
* - `darwin.ts` 有 `assertDarwin()`
|
||||
* - `window.ts` 写 `if (platform === 'darwin') return macXxx()`,其余平台**静默走到 Windows 实现**,
|
||||
* 于是在 Linux 上表现为 `spawn /bin/bash ENOENT` 这类**误导性错误**(像是环境缺依赖,
|
||||
* 实际是"本平台根本没实现")。
|
||||
*
|
||||
* 统一后的语义:**没实现就明确说"不支持当前平台"**(`E_UNSUPPORTED_PLATFORM`),
|
||||
* 并带上「支持哪些平台」,让调用方一眼看懂,也让能力清单的"声明可用"不至于撒谎。
|
||||
*
|
||||
* ## 用法
|
||||
* ```ts
|
||||
* assertPlatform('screen.capture', 'win32', 'darwin');
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
|
||||
/** 人类可读的平台名(错误信息里用)。 */
|
||||
const PLATFORM_LABEL: Record<string, string> = {
|
||||
win32: 'Windows',
|
||||
darwin: 'macOS',
|
||||
linux: 'Linux',
|
||||
};
|
||||
|
||||
export function platformLabel(p: NodeJS.Platform): string {
|
||||
return PLATFORM_LABEL[p] ?? p;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前平台是否在允许集合内。
|
||||
*
|
||||
* 与 `assertPlatform` 的分工:这个用于**条件注册/条件分支**,那个用于**进入能力时兜底拒绝**。
|
||||
*/
|
||||
export function isPlatform(...allowed: NodeJS.Platform[]): boolean {
|
||||
return allowed.includes(process.platform);
|
||||
}
|
||||
|
||||
/**
|
||||
* 断言当前平台受支持;否则抛 `E_UNSUPPORTED_PLATFORM`。
|
||||
*
|
||||
* @param capability 能力名(写进错误信息,便于定位)
|
||||
* @param allowed 支持该能力的平台(如 `'win32', 'darwin'`)
|
||||
*/
|
||||
export function assertPlatform(capability: string, ...allowed: NodeJS.Platform[]): void {
|
||||
if (isPlatform(...allowed)) return;
|
||||
const supported = allowed.map(platformLabel).join(' / ');
|
||||
throw new CapabilityError(
|
||||
ErrorCodes.UNSUPPORTED_PLATFORM,
|
||||
`${capability} 未在 ${platformLabel(process.platform)} 上实现(仅支持 ${supported})`,
|
||||
{
|
||||
platform: process.platform,
|
||||
supported,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅 Windows 的能力(PowerShell / Win32 实现)。
|
||||
*
|
||||
* 覆盖:截屏与录屏、键鼠注入、剪贴板、音量、软件装卸(winget)、窗口与 UIA 语义。
|
||||
* 注意 **不含** `system.info` / `system.status` / `system.process.list` / `shell.exec` / `fs.*` ——
|
||||
* 这些有分平台实现,在 Linux 上可用。
|
||||
*/
|
||||
export function assertWindows(capability: string): void {
|
||||
assertPlatform(capability, 'win32');
|
||||
}
|
||||
|
||||
/**
|
||||
* 图形界面类能力(Windows 或 macOS 有实现,Linux 目前没有)。
|
||||
*
|
||||
* 用于 `screen.*` / `window.*` / `input.*` 这类:在 Windows 走 Win32/UIA、
|
||||
* 在 macOS 走 AppleScript/Vision,**Linux 端尚未实现**(需 X11/Wayland 后端)。
|
||||
*/
|
||||
export function assertGuiPlatform(capability: string): void {
|
||||
assertPlatform(capability, 'win32', 'darwin');
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { execCommand } from './exec.js';
|
||||
import { isSafeBareWindowsPathArg } from './quote.js';
|
||||
|
||||
/**
|
||||
* PowerShell 常驻助手(v14)。
|
||||
@@ -438,8 +439,18 @@ export async function runPowerShellSmart(opts: RunPsOptions): Promise<{ stdout:
|
||||
const dir = mkdtempSync(join(tmpdir(), 'na-ps-'));
|
||||
const scriptPath = join(dir, 'run.ps1');
|
||||
writeFileSync(scriptPath, `\uFEFF${prelude}\n${body}`, 'utf8');
|
||||
// ⚠️ 这里**只能裸拼**路径:`-File` 的引号由 powershell.exe 自己的命令行解析器处理,
|
||||
// 塞 PS 单引号字面量进去不保证被剥掉。既然裸拼,就必须**显式**确认路径里没有
|
||||
// 会改变解析的字符(引号/$/&/|/^/…)—— 正常它由 mkdtempSync 生成,但 TMP 被设成
|
||||
// 怪路径时前提就破了,而且破了也是静默的(与 W-01 同类)。破前提就当场报错。
|
||||
if (!isSafeBareWindowsPathArg(scriptPath)) {
|
||||
throw new Error(
|
||||
`PowerShell 临时脚本路径含可能改变命令行解析的字符,拒绝拼接:${JSON.stringify(scriptPath)}` +
|
||||
'(请检查 TMP/TEMP 或用户目录名)',
|
||||
);
|
||||
}
|
||||
const r = await execCommand({
|
||||
command: `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ${JSON.stringify(scriptPath)}`,
|
||||
command: `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ${scriptPath}`,
|
||||
timeoutMs,
|
||||
});
|
||||
if (r.exit_code !== 0) {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* 跨平台「安全字面量」工具 —— 把不可信字符串安全地嵌进 shell / PowerShell 脚本。
|
||||
*
|
||||
* ## 为什么必须有这个文件
|
||||
*
|
||||
* 本项目真机踩到过 **P0 级静默 RCE**:把 `JSON.stringify(title)` 直接插进 PowerShell
|
||||
* 双引号串,payload `x"; New-Item …; #` 就执行了任意命令 —— 而接口还返回「正常的业务错误」,
|
||||
* 调用方完全看不出异常(见 `window.ts` 的历史注释与 bug 报告 W-01)。
|
||||
*
|
||||
* 两条铁律(违反任一条都会重新引入漏洞):
|
||||
*
|
||||
* 1. **PowerShell 里 `\` 不是转义符**(那是 C/JS 的规则)。JSON 产出的 `\"` 会被 PS 解析成
|
||||
* 「字面反斜杠 + 字符串结束引号」,后面的内容就成了新语句。
|
||||
* 2. **PS 双引号串会做变量插值与子表达式展开**(`$x`、`$(...)`、反引号),
|
||||
* 而**单引号串什么都不展开** —— 这是唯一正确的选择。
|
||||
*
|
||||
* 因此:**任何**嵌入 PowerShell 的不可信值都必须走 `psLit()`;
|
||||
* 嵌入 bash/sh 的走 `shLit()`;嵌进命令行参数数组的(`spawn(file, args)`)根本不需要引用,
|
||||
* 那是首选方案,不要拼字符串。
|
||||
*/
|
||||
|
||||
/** PowerShell 单引号字面量:内部单引号翻倍,其余字符(含 `$`、反引号、反斜杠)全部原样。 */
|
||||
export function psLit(v: string): string {
|
||||
return `'${v.replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
/** PowerShell 数组字面量:`@('a','b')`。空数组产出 `@()`。 */
|
||||
export function psLitArray(values: readonly string[]): string {
|
||||
return `@(${values.map(psLit).join(',')})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* POSIX shell 单引号字面量:把 `'` 拆成 `'\''`(闭合 → 转义单引号 → 重开)。
|
||||
* 单引号内不会有变量展开、命令替换或反斜杠转义。
|
||||
*/
|
||||
export function shLit(v: string): string {
|
||||
return `'${v.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验单引号字面量的形状(供调用侧自检):首尾是 `'`,且内部**没有落单的单引号**。
|
||||
*
|
||||
* 原理:正确转义后内部每个 `'` 都成对(`''`),把 `''` 全部消去后不应再残留任何 `'`。
|
||||
* 只做形状检查,不替代真机验证。
|
||||
*/
|
||||
export function assertSingleQuotedLiteral(lit: string): boolean {
|
||||
if (!lit.startsWith("'") || !lit.endsWith("'")) return false;
|
||||
return !lit.slice(1, -1).replace(/''/g, '').includes("'");
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows 原生命令行参数的「裸传」前提校验。
|
||||
*
|
||||
* 有些位置**不能用** `psLit()`:例如 `powershell.exe -File <path>` —— 这一段的引号由
|
||||
* `powershell.exe` 自己的命令行解析器处理,塞 PS 单引号字面量进去不保证被剥掉。
|
||||
* 这种位置只能把路径**裸着**拼进命令行,于是必须保证它**本身含不了任何会改变语义的字符**。
|
||||
*
|
||||
* 真例(v2.0.3 审查发现,与 W-01 同类):`util/ps-helper.ts` 把 `mkdtempSync` 产出的临时脚本
|
||||
* 路径直接拼进 `-File`。那条路径由本进程生成、正常含不了引号,但**这是隐含前提**——
|
||||
* 一旦 `TMP`/`TEMP` 被设成怪路径(或用户目录名含特殊字符),前提就破了,而且是静默的。
|
||||
* 故把前提**显式化**:不满足就当场报错,而不是把可疑字符串拼进命令行。
|
||||
*/
|
||||
export function isSafeBareWindowsPathArg(v: string): boolean {
|
||||
if (v === '' || v.includes('\0')) return false;
|
||||
// 引号/反引号/$/&/|/</>/(/)/%/^/!/换行:任何一个都能改变 cmd.exe 或 PS 的解析
|
||||
if (/["'`$&|<>()%^!\r\n\t]/.test(v)) return false;
|
||||
// 结尾是单个反斜杠会在双引号包裹时吃掉闭引号(cmd 的经典怪癖)
|
||||
return !/\\$/.test(v);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 按调用方的滑动窗口限速器(每分钟)。
|
||||
*
|
||||
* ## 为什么从 server.ts 抽出来(A-P0-2,2026-10-25)
|
||||
* 原实现有两处缺陷,且逻辑埋在 `createAgentCore` 闭包里无法单测:
|
||||
*
|
||||
* 1. **桶 key 用错值**:原 `allowByRate(clientId, limit, String(limit))` 第三参
|
||||
* (bucket)传的是**限速值本身**。于是「screen.capture ≤ 5/min」与
|
||||
* 「app.install ≤ 5/min」两条同值规则共用 `clientId|5` 一个桶:
|
||||
* 截图 5 次后装软件也被限速(误伤),且两者总量仍按 5/min(漏过)。
|
||||
* 正确口径:key = `${身份}:${能力名}`。
|
||||
* 2. **桶只增不删**:`rateBuckets` Map 无任何清扫,key 永久留存。而 psk 模式下
|
||||
* `client_id` 由客户端**自报**(配置级约束)→ 外部可枚举任意 client_id 造键
|
||||
* → 长期运行内存无界增长。现在:每次允许判定顺手删过期键 + 容量上限淘汰最旧。
|
||||
*
|
||||
* ## 语义
|
||||
* - 窗口 60s 滑动:只数「最近 60 秒内」的调用;
|
||||
* - `limitPerMin` 为空/≤0 表示不限速(直接放行,**不建桶** —— 与旧行为一致);
|
||||
* - 身份用「认证身份 ?? 远端地址」:psk 自报 client_id 不可信,但限速键仍以它为纲
|
||||
* (配了限速就说明该 client_id 已在 ACL 里登记),无身份的调用方退化按远端地址。
|
||||
*/
|
||||
|
||||
/** 桶容量上限:超出淘汰最旧(Map 保持插入顺序,首键即最旧)。 */
|
||||
const DEFAULT_MAX_KEYS = 10_000;
|
||||
|
||||
const WINDOW_MS = 60_000;
|
||||
|
||||
export interface RateLimiter {
|
||||
/**
|
||||
* 请求一次调用配额。
|
||||
*
|
||||
* @param identity 调用方身份(认证身份 ?? 远端地址)
|
||||
* @param limitPerMin 每分钟上限;空/≤0 = 不限速
|
||||
* @param bucket 桶名(能力名)—— 不同能力各占一桶,互不挤占
|
||||
*/
|
||||
allow(identity: string, limitPerMin: number | undefined, bucket: string): boolean;
|
||||
/** 当前桶数(供测试与观测)。 */
|
||||
size(): number;
|
||||
}
|
||||
|
||||
export interface RateLimiterOptions {
|
||||
/** 桶容量上限(超出淘汰最旧)。默认 10_000。 */
|
||||
maxKeys?: number;
|
||||
/** 时钟注入(单测用假时钟推进 60s 窗口,不必真等)。 */
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export function createRateLimiter(opts: RateLimiterOptions = {}): RateLimiter {
|
||||
const maxKeys = opts.maxKeys ?? DEFAULT_MAX_KEYS;
|
||||
const now = opts.now ?? Date.now;
|
||||
const buckets = new Map<string, number[]>();
|
||||
|
||||
return {
|
||||
allow(identity: string, limitPerMin: number | undefined, bucket: string): boolean {
|
||||
if (!limitPerMin || limitPerMin <= 0) return true;
|
||||
const key = `${identity}|${bucket}`;
|
||||
const t = now();
|
||||
|
||||
// 清扫①:过期键直接删(只增不删 = 内存无界增长;自报 client_id 可枚举造键)
|
||||
for (const [k, times] of buckets) {
|
||||
if (times.length === 0 || t - times[times.length - 1]! >= WINDOW_MS) buckets.delete(k);
|
||||
}
|
||||
// 清扫②:容量上限,超出淘汰最旧(插入序 = 时间序,首键最旧)
|
||||
while (buckets.size >= maxKeys) {
|
||||
const oldest = buckets.keys().next();
|
||||
if (oldest.done) break;
|
||||
buckets.delete(oldest.value);
|
||||
}
|
||||
|
||||
const recent = (buckets.get(key) ?? []).filter((ts) => t - ts < WINDOW_MS);
|
||||
if (recent.length >= limitPerMin) {
|
||||
buckets.set(key, recent);
|
||||
return false;
|
||||
}
|
||||
recent.push(t);
|
||||
buckets.set(key, recent);
|
||||
return true;
|
||||
},
|
||||
size(): number {
|
||||
return buckets.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/cli",
|
||||
"version": "2.0.1",
|
||||
"version": "2.1.0",
|
||||
"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,
|
||||
|
||||
@@ -0,0 +1,576 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { CapabilityNames, type InvokeResult } from '@nodeagent/protocol';
|
||||
import { printJson, probeOnce, withClient } from '../core.js';
|
||||
|
||||
/**
|
||||
* `nodeagent rollback` —— 从 LKG(最后已知可用版本)恢复被控端(ARCH T03 / PRD P0-2)。
|
||||
*
|
||||
* ## 能力圈边界(必须向用户讲清楚,见 DELIVERY-PIPELINE §6 与 A-P0-6)
|
||||
* 本命令本质是 **RPC 命令,要求被控端可达**。它覆盖的场景是
|
||||
* 「新版本能启动但需要回退(行为异常 / 主动避险)」;
|
||||
* 「新版本进程完全起不来」时没有监听可连,CLI 无能为力 —— 那种场景走:
|
||||
* ① 看门狗自动恢复(scripts/startup-guard.mjs:启动连续失败 N 次自动从 LKG 恢复);
|
||||
* ② 物理/带外恢复(DELIVERY-PIPELINE 附录 C:备份文件手工还原)。
|
||||
* 本命令**不提供自动回滚**:无法归因、回滚循环、失控三问见 DELIVERY-PIPELINE §6。
|
||||
*
|
||||
* ## 执行流程(对齐 deploy 的成熟姿势,见 cmdDeploy)
|
||||
* ① system.info → agent_script / build.hash / lkg
|
||||
* ② 无 LKG → 报错退出(exit 2),零改动
|
||||
* ③ current == LKG.hash → 「当前即 LKG」提示退出(提示 --backup 逃生门)
|
||||
* ④ fs.list 枚举备份候选(`<basename>.bak` / `<basename>.bak-<ts>`),mtime 新→早
|
||||
* ⑤ 流式读 + sha256 指纹匹配(全量 hash_full 优先,老记录回落 12 位前缀)
|
||||
* —— 无匹配 → 列候选指纹报错退出(exit 2,不动任何文件)
|
||||
* ⑥ 覆盖写入(同 deploy:首块 append:false + 续块 append:true)→ 回读校验
|
||||
* ├─ 一致 → ⑦
|
||||
* └─ 不一致 → 用回滚源再覆盖一次(还原到回滚源内容=原状)→ 报错退出(exit 1)
|
||||
* ⑦ system.agent.restart(独立连接,吞连接中断)
|
||||
* ⑧ probeOnce 轮询直到 build.hash == 期望指纹(90s 墙钟)
|
||||
*
|
||||
* ## --dry-run
|
||||
* 只做 ①~⑤(含匹配结论),**零写入**:无 fs.write / 无 restart。
|
||||
*/
|
||||
|
||||
// ---------- 公开类型 ----------
|
||||
|
||||
/** LKG 记录(system.info.lkg.lkg)。hash_full/backup_glob 为 v2.1.0 只增字段,老记录没有。 */
|
||||
export interface LkgInfo {
|
||||
/** 入口脚本 sha256 前 12 位(与 system.info 的 build.hash 同口径) */
|
||||
hash: string;
|
||||
/** sha256 全量 64 位(v2.1.0 起记录;老记录无此字段,回落 12 位前缀比对) */
|
||||
hash_full?: string;
|
||||
bytes: number;
|
||||
script_path: string;
|
||||
/** 回滚内容候选的文件名模式(`<basename>.bak*`,v2.1.0 起记录) */
|
||||
backup_glob?: string;
|
||||
recorded_at: number;
|
||||
node_version: string;
|
||||
platform: string;
|
||||
}
|
||||
|
||||
/** system.info 里 rollback 关心的字段(其余字段忽略)。 */
|
||||
export interface SystemInfoLike {
|
||||
agent_script?: string;
|
||||
build?: { hash?: string };
|
||||
lkg?: {
|
||||
lkg: LkgInfo | null;
|
||||
current_hash: string | null;
|
||||
matches: boolean;
|
||||
byte_delta: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
/** rollback 可注入的最小客户端面(NodeAgentClient 结构上满足它)。 */
|
||||
export interface RollbackClient {
|
||||
invoke<T = unknown>(
|
||||
capability: string,
|
||||
args: Record<string, unknown>,
|
||||
timeoutMs?: number,
|
||||
): Promise<InvokeResult<T>>;
|
||||
}
|
||||
|
||||
/** rollback 命令的选项面(Options 的子集,便于单测构造)。 */
|
||||
export interface RollbackOptions {
|
||||
/** 高级逃生门:直接把该远端路径当回滚源(跳过 LKG 与指纹匹配) */
|
||||
backup?: string;
|
||||
/** 只读比对,零写入 */
|
||||
dryRun?: boolean;
|
||||
/** 原始 JSON 输出 */
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
/** planRollback 的判定结果。 */
|
||||
export type RollbackAction = 'no-lkg' | 'already-lkg' | 'explicit' | 'match';
|
||||
|
||||
export interface RollbackPlan {
|
||||
action: RollbackAction;
|
||||
/** 期望的回滚后指纹:LKG 指纹(match)/ 备份指纹(explicit,调用方算好后回填)/ 空(no-lkg) */
|
||||
targetHash: string;
|
||||
/** 被控端入口脚本路径(回滚写入目标) */
|
||||
scriptPath: string;
|
||||
}
|
||||
|
||||
/** 备份候选(fs.list 的一条)。 */
|
||||
export interface BackupCandidate {
|
||||
path: string;
|
||||
name: string;
|
||||
type: string;
|
||||
size: number;
|
||||
mtime: number;
|
||||
}
|
||||
|
||||
/** 指纹比对条目(候选 + 其远端内容指纹)。 */
|
||||
export interface HashedCandidate {
|
||||
path: string;
|
||||
hash: string;
|
||||
mtime: number;
|
||||
}
|
||||
|
||||
/** runRollback 的结构化结果(不直接碰进程退出码,便于单测断言)。 */
|
||||
export interface RollbackResult {
|
||||
/** 0 成功/已是 LKG;1 执行失败;2 前提不满足 */
|
||||
code: 0 | 1 | 2;
|
||||
plan: RollbackPlan;
|
||||
/** 命中/指定的回滚源(未找到为 null) */
|
||||
source: { path: string; hash: string } | null;
|
||||
/** 候选指纹清单(无匹配时供排查) */
|
||||
candidates: HashedCandidate[];
|
||||
/** 写后校验失败后是否已执行还原写 */
|
||||
restored: boolean;
|
||||
/** 是否需要进入「重启 + 复核」阶段(dry-run 恒为 false) */
|
||||
needsRestart: boolean;
|
||||
/** 失败原因(成功为 undefined) */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ---------- 纯函数 ----------
|
||||
|
||||
/**
|
||||
* 由入口文件名生成备份候选的本地过滤正则。
|
||||
*
|
||||
* 覆盖两套命名(ARCH A-P0-3):
|
||||
* - deploy 推送式:固定名 `<basename>.bak`(覆盖写)
|
||||
* - selfupdate 拉取式:时间戳名 `<basename>.bak-<ts>`
|
||||
*
|
||||
* 平台相关字符串(Windows 反斜杠路径、文件名)全部收敛进本函数:
|
||||
* basename 的切分兼容 `\` 与 `/`,正则元字符一律转义。
|
||||
*/
|
||||
export function backupNameRegex(basename: string): RegExp {
|
||||
const base = remoteBasename(basename);
|
||||
const esc = base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return new RegExp(`^${esc}\\.bak(-.*)?$`);
|
||||
}
|
||||
|
||||
/** 取远端路径的 basename(兼容 Windows 反斜杠)。 */
|
||||
export function remoteBasename(p: string): string {
|
||||
return p.split(/[\\/]/).pop() ?? '';
|
||||
}
|
||||
|
||||
/** 取远端路径的目录部分(兼容 Windows 反斜杠);无目录时返回空串。 */
|
||||
export function remoteDirname(p: string): string {
|
||||
const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'));
|
||||
return i > 0 ? p.slice(0, i) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 指纹比对:全量相等,或老记录(12 位)按前缀比。
|
||||
*
|
||||
* 为什么允许前缀:LkgRecord 老数据只有 12 位 hash(A-P0-3/R-2),
|
||||
* 控制端不得要求被控端先升级才能回滚 —— 向后兼容,不猜。
|
||||
*/
|
||||
export function hashMatches(got: string, want: string): boolean {
|
||||
if (!got || !want) return false;
|
||||
if (got === want) return true;
|
||||
return want.length < got.length && got.startsWith(want);
|
||||
}
|
||||
|
||||
/**
|
||||
* 双向前缀匹配:短的一方是长的一方的前缀即算命中。
|
||||
*
|
||||
* 为什么需要它(v2.1.0 QA 缺陷单):指纹有两个口径同时在场 ——
|
||||
* · `build.hash` / 老 LKG 的 `hash` 是 **12 位**前缀;
|
||||
* · `hash_full` / 流式 sha256 结果是 **64 位**全量。
|
||||
* 写后校验与重启复核是「拿 64 位实测值去比期望值」,而期望值可能是 12 位(老
|
||||
* 记录)或 64 位(新记录)。用严格 === 会让其中一种口径必然失败:
|
||||
* · 12 位期望 ↔ 64 位实测 → 老记录回滚永远走「写后校验失败」;
|
||||
* · 64 位期望 ↔ 12 位实测 → 新记录回滚成功后被误报复核失败(退出码 1)。
|
||||
* 因此校验方向必须容忍两种口径,而「挑回滚源」方向仍用单向 hashMatches
|
||||
* (hash_full 在手时不许前缀糊弄,见 tests/unit/qa-v210-supplement.test.mjs C⑥)。
|
||||
*/
|
||||
export function hashMatchesEither(a: string, b: string): boolean {
|
||||
if (!a || !b) return false;
|
||||
if (a === b) return true;
|
||||
return a.length < b.length ? b.startsWith(a) : a.startsWith(b);
|
||||
}
|
||||
|
||||
/**
|
||||
* 回滚计划(纯函数):决定 action 与期望指纹。
|
||||
*
|
||||
* - `no-lkg`:无 LKG 记录且未指定 --backup → 前提不满足
|
||||
* - `already-lkg`:当前指纹已是 LKG → 零写入退出(提示 --backup 逃生门)
|
||||
* - `explicit`:给了 --backup → 以备份自身为源与期望指纹(targetHash 由调用方回填)
|
||||
* - `match`:枚举备份 + 指纹匹配定位 LKG 内容
|
||||
*/
|
||||
export function planRollback(info: SystemInfoLike, backupPath?: string): RollbackPlan {
|
||||
const lkg = info.lkg?.lkg ?? null;
|
||||
const scriptPath = info.agent_script ?? lkg?.script_path ?? '';
|
||||
if (backupPath) {
|
||||
return { action: 'explicit', targetHash: '', scriptPath };
|
||||
}
|
||||
if (!lkg) {
|
||||
return { action: 'no-lkg', targetHash: '', scriptPath };
|
||||
}
|
||||
const current = info.build?.hash ?? '';
|
||||
if (current && lkg.hash && current === lkg.hash) {
|
||||
return { action: 'already-lkg', targetHash: lkg.hash, scriptPath };
|
||||
}
|
||||
return { action: 'match', targetHash: lkg.hash_full ?? lkg.hash, scriptPath };
|
||||
}
|
||||
|
||||
/**
|
||||
* 从「已算好指纹」的候选里挑回滚源(纯函数;mtime 新→早的排序在外层做)。
|
||||
*
|
||||
* 为什么排序在外层:本函数只负责「第一个指纹匹配者」,与时间序解耦,可独立测。
|
||||
*/
|
||||
export function pickBackupByHash(
|
||||
cands: HashedCandidate[],
|
||||
wantHash: string,
|
||||
): { path: string; hash: string } | null {
|
||||
if (!wantHash) return null;
|
||||
for (const c of cands) {
|
||||
if (hashMatches(c.hash, wantHash)) return { path: c.path, hash: c.hash };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------- 远端 IO(client 注入 → 可假测) ----------
|
||||
|
||||
/** 分块大小:与 cmdDeploy 一致(512KB),单条 RPC 的安全分块 */
|
||||
const CHUNK = 512 * 1024;
|
||||
|
||||
/**
|
||||
* 流式计算远端文件指纹(fs.read 分块 + sha256)。
|
||||
*
|
||||
* ⚠️ offset 前进量必须用**本次返回字节数**,不能用返回记录里的起始 offset
|
||||
* (拿它前进会原地打转 —— cmdDeploy 的真机教训:备份 33B→268KB 且卡死 200s)。
|
||||
*/
|
||||
export async function hashRemoteFile(
|
||||
client: RollbackClient,
|
||||
path: string,
|
||||
): Promise<{ hash: string; bytes: number }> {
|
||||
const hash = createHash('sha256');
|
||||
let offset = 0;
|
||||
for (;;) {
|
||||
const r = await client.invoke<{ data?: string; bytes?: number; eof?: boolean }>(
|
||||
CapabilityNames.FsRead,
|
||||
{ path, offset, max_bytes: CHUNK, encoding: 'base64' },
|
||||
);
|
||||
if (r.status !== 'ok') {
|
||||
throw new Error(`读取失败: ${r.error?.name ?? 'E_EXECUTION_FAILED'}: ${r.error?.message ?? '未知错误'}`);
|
||||
}
|
||||
const d = r.data ?? {};
|
||||
const chunk = d.data ?? '';
|
||||
if (chunk) hash.update(Buffer.from(chunk, 'base64'));
|
||||
const n = d.bytes ?? 0;
|
||||
offset += n;
|
||||
if (d.eof || n === 0) break;
|
||||
}
|
||||
return { hash: hash.digest('hex'), bytes: offset };
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式复制远端文件(src → dst),返回复制内容的指纹与字节数。
|
||||
*
|
||||
* 写姿势与 cmdDeploy 的上传段完全一致:首块 `append:false`(截断写),
|
||||
* 续块 `append:true`。base64 必须显式声明 —— 默认 utf8 会把二进制当文本读写坏
|
||||
* (真机踩过:备份 33 字节的垃圾)。
|
||||
*/
|
||||
async function copyRemoteFile(
|
||||
client: RollbackClient,
|
||||
srcPath: string,
|
||||
dstPath: string,
|
||||
): Promise<{ hash: string; bytes: number }> {
|
||||
const hash = createHash('sha256');
|
||||
let offset = 0;
|
||||
let first = true;
|
||||
for (;;) {
|
||||
const r = await client.invoke<{ data?: string; bytes?: number; eof?: boolean }>(
|
||||
CapabilityNames.FsRead,
|
||||
{ path: srcPath, offset, max_bytes: CHUNK, encoding: 'base64' },
|
||||
);
|
||||
if (r.status !== 'ok') {
|
||||
throw new Error(`读取失败: ${r.error?.name ?? 'E_EXECUTION_FAILED'}: ${r.error?.message ?? '未知错误'}`);
|
||||
}
|
||||
const d = r.data ?? {};
|
||||
const chunk = d.data ?? '';
|
||||
const n = d.bytes ?? 0;
|
||||
if (chunk) {
|
||||
hash.update(Buffer.from(chunk, 'base64'));
|
||||
const w = await client.invoke(CapabilityNames.FsWrite, {
|
||||
path: dstPath,
|
||||
data: chunk,
|
||||
encoding: 'base64',
|
||||
append: !first,
|
||||
});
|
||||
if (w.status !== 'ok') {
|
||||
throw new Error(`写入失败: ${w.error?.name ?? 'E_EXECUTION_FAILED'}: ${w.error?.message ?? '未知错误'}`);
|
||||
}
|
||||
first = false;
|
||||
}
|
||||
offset += n;
|
||||
if (d.eof || n === 0) break;
|
||||
}
|
||||
// 空文件也要落到目标(否则目标保留旧内容,与「复制」语义相反)
|
||||
if (first) {
|
||||
const w = await client.invoke(CapabilityNames.FsWrite, { path: dstPath, data: '' });
|
||||
if (w.status !== 'ok') {
|
||||
throw new Error(`写入失败: ${w.error?.name ?? 'E_EXECUTION_FAILED'}: ${w.error?.message ?? '未知错误'}`);
|
||||
}
|
||||
}
|
||||
return { hash: hash.digest('hex'), bytes: offset };
|
||||
}
|
||||
|
||||
// ---------- 主编排(①~⑥,client 注入 → 可假测) ----------
|
||||
|
||||
/**
|
||||
* 执行 rollback 的 ①~⑥ 阶段(不含重启与复核)。
|
||||
*
|
||||
* 为什么与 cmdRollback 分开:重启/复核要真实网络(probeOnce 反复重连),
|
||||
* 单测只覆盖「决策 + 写入 + 校验」这段编排 —— 故 client 注入、
|
||||
* 进程退出码用返回值表达(code),由 cmdRollback 翻译成 process.exitCode。
|
||||
*/
|
||||
export async function runRollback(
|
||||
client: RollbackClient,
|
||||
opts: RollbackOptions,
|
||||
): Promise<RollbackResult> {
|
||||
const say = (line: string): void => {
|
||||
if (!opts.json) console.log(line);
|
||||
};
|
||||
|
||||
// ① system.info
|
||||
const infoRes = await client.invoke<SystemInfoLike>(CapabilityNames.SystemInfo, {});
|
||||
if (infoRes.status !== 'ok') {
|
||||
return {
|
||||
code: 2,
|
||||
plan: { action: 'no-lkg', targetHash: '', scriptPath: '' },
|
||||
source: null,
|
||||
candidates: [],
|
||||
restored: false,
|
||||
needsRestart: false,
|
||||
error: `无法读取被控端信息: ${infoRes.error?.message ?? '未知错误'}`,
|
||||
};
|
||||
}
|
||||
const info = infoRes.data ?? {};
|
||||
const plan = planRollback(info, opts.backup);
|
||||
|
||||
const lkg = info.lkg?.lkg ?? null;
|
||||
if (lkg) {
|
||||
say(`LKG 记录 : ${lkg.hash}${lkg.hash_full ? `(全量 ${lkg.hash_full.slice(0, 16)}…)` : ''} ${lkg.bytes} 字节`);
|
||||
say(` 记录于 ${new Date(lkg.recorded_at).toLocaleString('zh-CN')} · node ${lkg.node_version} · ${lkg.platform}`);
|
||||
}
|
||||
const current = info.build?.hash ?? 'unknown';
|
||||
say(`当前指纹 : ${current}${info.agent_script ? `\n入口路径 : ${info.agent_script}` : ''}`);
|
||||
|
||||
// ② 无 LKG(且未给 --backup)
|
||||
if (plan.action === 'no-lkg') {
|
||||
return {
|
||||
code: 2,
|
||||
plan,
|
||||
source: null,
|
||||
candidates: [],
|
||||
restored: false,
|
||||
needsRestart: false,
|
||||
error: '被控端没有 LKG 记录(尚未成功启动过或记录已丢失)—— 无回滚目标,零改动',
|
||||
};
|
||||
}
|
||||
|
||||
// ③ 当前即 LKG
|
||||
if (plan.action === 'already-lkg') {
|
||||
say('✓ 当前版本就是 LKG,无需回滚');
|
||||
say(' 如需回退到更早的备份,用逃生门: nodeagent rollback --backup <远端备份路径>');
|
||||
return { code: 0, plan, source: null, candidates: [], restored: false, needsRestart: false };
|
||||
}
|
||||
|
||||
if (!plan.scriptPath) {
|
||||
return {
|
||||
code: 2,
|
||||
plan,
|
||||
source: null,
|
||||
candidates: [],
|
||||
restored: false,
|
||||
needsRestart: false,
|
||||
error: '无法确定被控端入口路径(system.info 未上报 agent_script,LKG 也无 script_path)',
|
||||
};
|
||||
}
|
||||
|
||||
// ④ 枚举备份候选(fs.list + 本地正则过滤;正则才是权威判据)
|
||||
const dir = remoteDirname(plan.scriptPath);
|
||||
const base = remoteBasename(plan.scriptPath);
|
||||
const glob = lkg?.backup_glob ?? `${base}.bak*`;
|
||||
const listRes = await client.invoke<{ entries?: BackupCandidate[] }>(
|
||||
CapabilityNames.FsList,
|
||||
{ path: dir, pattern: glob, max_entries: 500 },
|
||||
);
|
||||
if (listRes.status !== 'ok') {
|
||||
return {
|
||||
code: 2,
|
||||
plan,
|
||||
source: null,
|
||||
candidates: [],
|
||||
restored: false,
|
||||
needsRestart: false,
|
||||
error: `枚举备份失败: ${listRes.error?.message ?? '未知错误'}`,
|
||||
};
|
||||
}
|
||||
const re = backupNameRegex(base);
|
||||
const cands: BackupCandidate[] = (listRes.data?.entries ?? [])
|
||||
.filter((e) => e.type === 'file' && re.test(e.name ?? ''))
|
||||
.sort((a, b) => (b.mtime ?? 0) - (a.mtime ?? 0)); // mtime 新→早
|
||||
if (cands.length === 0) {
|
||||
return {
|
||||
code: 2,
|
||||
plan,
|
||||
source: null,
|
||||
candidates: [],
|
||||
restored: false,
|
||||
needsRestart: false,
|
||||
error: `未找到备份候选(${dir} 下无 ${glob})—— 回滚内容不存在,零改动`,
|
||||
};
|
||||
}
|
||||
|
||||
// ⑤ 指纹匹配(流式读 + sha256)
|
||||
const hashed: HashedCandidate[] = [];
|
||||
for (const c of cands) {
|
||||
try {
|
||||
const h = await hashRemoteFile(client, c.path);
|
||||
hashed.push({ path: c.path, hash: h.hash, mtime: c.mtime });
|
||||
} catch (err) {
|
||||
say(` ⚠️ 跳过无法读取的候选 ${c.path}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
say(`备份候选 ${hashed.length} 个(mtime 新→早),指纹比对中…`);
|
||||
|
||||
let source: { path: string; hash: string } | null;
|
||||
let expectHash: string;
|
||||
if (plan.action === 'explicit') {
|
||||
// 逃生门:--backup 指定的路径即回滚源;期望指纹 = 它自身的内容指纹
|
||||
const backupPath = opts.backup as string;
|
||||
try {
|
||||
const h = await hashRemoteFile(client, backupPath);
|
||||
source = { path: backupPath, hash: h.hash };
|
||||
expectHash = h.hash;
|
||||
say(`逃生门 : 指定备份 ${backupPath}(指纹 ${h.hash.slice(0, 12)})`);
|
||||
} catch (err) {
|
||||
return {
|
||||
code: 2,
|
||||
plan,
|
||||
source: null,
|
||||
candidates: hashed,
|
||||
restored: false,
|
||||
needsRestart: false,
|
||||
error: `无法读取指定备份 ${backupPath}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
source = pickBackupByHash(hashed, plan.targetHash);
|
||||
if (!source) {
|
||||
// 无匹配:列候选指纹供排查,零改动退出
|
||||
for (const h of hashed) say(` · ${h.path} ${h.hash.slice(0, 12)}`);
|
||||
return {
|
||||
code: 2,
|
||||
plan,
|
||||
source: null,
|
||||
candidates: hashed,
|
||||
restored: false,
|
||||
needsRestart: false,
|
||||
error: '没有备份的指纹与 LKG 匹配 —— 已列出各候选指纹,未改动任何文件',
|
||||
};
|
||||
}
|
||||
expectHash = plan.targetHash;
|
||||
say(`✓ 命中回滚源: ${source.path}(指纹 ${source.hash.slice(0, 12)})`);
|
||||
}
|
||||
|
||||
if (opts.dryRun) {
|
||||
say('--dry-run:只读比对完成,未写入、未重启');
|
||||
return { code: 0, plan, source, candidates: hashed, restored: false, needsRestart: false };
|
||||
}
|
||||
|
||||
// ⑥ 覆盖写入 + 写后校验(不一致 → 用回滚源还原 → 报错)
|
||||
try {
|
||||
const written = await copyRemoteFile(client, source.path, plan.scriptPath);
|
||||
const back = await hashRemoteFile(client, plan.scriptPath);
|
||||
if (back.hash !== written.hash || !hashMatchesEither(back.hash, expectHash)) {
|
||||
say('✗ 写后校验失败,正在用回滚源还原…');
|
||||
await copyRemoteFile(client, source.path, plan.scriptPath);
|
||||
return {
|
||||
code: 1,
|
||||
plan,
|
||||
source,
|
||||
candidates: hashed,
|
||||
restored: true,
|
||||
needsRestart: false,
|
||||
error:
|
||||
`写后校验失败(期望 ${expectHash.slice(0, 12)},实得 ${back.hash.slice(0, 12)})—— ` +
|
||||
`已用回滚源还原 ${plan.scriptPath};请检查磁盘/杀软/路径权限后重试`,
|
||||
};
|
||||
}
|
||||
say(`✓ 已写入 ${plan.scriptPath} 并通过写后校验(指纹 ${back.hash.slice(0, 12)})`);
|
||||
return { code: 0, plan, source, candidates: hashed, restored: false, needsRestart: true };
|
||||
} catch (err) {
|
||||
return {
|
||||
code: 1,
|
||||
plan,
|
||||
source,
|
||||
candidates: hashed,
|
||||
restored: false,
|
||||
needsRestart: false,
|
||||
error: `回滚写入失败: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 重启与复核(⑦⑧,真实网络) ----------
|
||||
|
||||
/** 触发受控重启 + 轮询复核指纹上线(90s 墙钟)。返回是否复核通过。 */
|
||||
export async function restartAndVerify(expectHash: string): Promise<boolean> {
|
||||
// ⑦ 独立连接触发重启,吞掉连接中断(重启会切断本连接,属预期行为;
|
||||
// cmdDeploy 真机教训:当成失败抛出会把成功的部署误判为失败)
|
||||
try {
|
||||
const r = await withClient((c) =>
|
||||
c.invoke(CapabilityNames.AgentRestart, { delay_ms: 1500, reason: 'rollback' }),
|
||||
);
|
||||
if (r.status === 'failed') {
|
||||
console.error(`✗ 重启失败: ${r.error?.message ?? '未知错误'}`);
|
||||
return false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(`(重启切断了连接,属预期:${err instanceof Error ? err.message : String(err)})`);
|
||||
}
|
||||
console.log('✓ 已触发重启,复核回滚版本上线…');
|
||||
|
||||
// ⑧ probeOnce 轮询(永不抛出的探测;withClient 连不上会 fail 退出,不能用它)
|
||||
let lastSeen = '连不上';
|
||||
const deadline = Date.now() + 90_000;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((t) => setTimeout(t, 1500));
|
||||
const p = await probeOnce();
|
||||
if (!p.ok) continue;
|
||||
lastSeen = p.buildHash ?? 'unknown';
|
||||
if (hashMatchesEither(p.buildHash ?? '', expectHash)) {
|
||||
console.log(`✓ 回滚完成并复核通过:指纹 ${p.buildHash},PID ${p.pid ?? '?'},能力 ${p.caps} 项`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
console.error(`✗ 90s 内未复核到回滚版本(期望 ${expectHash.slice(0, 12)},最后见到 ${lastSeen})`);
|
||||
console.error(' 排查:被控端可能没起来 —— 看 <数据目录>/logs/startup-guard.log;');
|
||||
console.error(' 仍不行则按 docs/DELIVERY-PIPELINE.md 附录 C 走带外恢复');
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------- CLI 入口 ----------
|
||||
|
||||
/** CLI 主编排:连接被控端 → 执行回滚 →(非 dry-run)重启复核。 */
|
||||
export async function cmdRollback(opts: RollbackOptions): Promise<void> {
|
||||
const result = await withClient((c) => runRollback(c, opts));
|
||||
|
||||
if (opts.json) {
|
||||
printJson({
|
||||
code: result.code,
|
||||
action: result.plan.action,
|
||||
target_hash: result.plan.targetHash,
|
||||
script_path: result.plan.scriptPath,
|
||||
source: result.source,
|
||||
candidates: result.candidates.map((c) => ({ path: c.path, hash: c.hash })),
|
||||
restored: result.restored,
|
||||
error: result.error,
|
||||
});
|
||||
} else if (result.error) {
|
||||
console.error(`✗ ${result.error}`);
|
||||
}
|
||||
|
||||
if (result.code === 0 && result.needsRestart && result.source) {
|
||||
const ok = await restartAndVerify(result.plan.targetHash || result.source.hash);
|
||||
if (!ok) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
process.exitCode = result.code;
|
||||
}
|
||||
@@ -231,6 +231,51 @@ export async function cmdAuditAnchor(opts: Options): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* L2 系统级审计兜底(见 docs/AUDIT-L2.md)。
|
||||
* nodeagent audit l2 [status|enable|collect|verify] [--since <ms>] [--limit N]
|
||||
*/
|
||||
export async function cmdAuditL2(action: string | undefined, opts: Options): Promise<void> {
|
||||
const args: Record<string, unknown> = { action: action ?? 'status' };
|
||||
if (opts.since !== undefined) args['since'] = Number(opts.since);
|
||||
if (opts.limit !== undefined) args['limit'] = Number(opts.limit);
|
||||
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.AuditL2, args, opts.json, (data) => {
|
||||
const d = data as Record<string, unknown>;
|
||||
if (d['collected'] !== undefined) {
|
||||
console.log(`✓ 已采集 ${d['collected']} 条,写入 ${d['written']} 条`);
|
||||
console.log(` 文件:${d['file']}`);
|
||||
if (typeof d['note'] === 'string') console.log(` ⚠️ ${d['note']}`);
|
||||
return;
|
||||
}
|
||||
if (d['ok'] !== undefined) {
|
||||
const ok = d['ok'] === true;
|
||||
const broken = d['broken_at'] as { line: number; reason: string } | undefined;
|
||||
console.log(
|
||||
ok
|
||||
? `✓ L2 链完整(校验 ${d['checked']} 条)`
|
||||
: `✗ L2 链异常:第 ${broken?.line} 行 —— ${broken?.reason}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (d['enabled'] === true) {
|
||||
console.log(`✓ 已启用 Windows 审计策略:${(d['policies'] as string[]).join(' / ')}`);
|
||||
if (typeof d['hint'] === 'string') console.log(` ${d['hint']}`);
|
||||
return;
|
||||
}
|
||||
// status
|
||||
console.log(`平台 : ${d['platform']}`);
|
||||
console.log(`审计策略 : ${d['auditpol']}`);
|
||||
console.log(`已禁用旧式策略覆盖 : ${d['legacy_policy_disabled'] ? '是' : '否(⚠️ 会导致 4688 不产生)'}`);
|
||||
console.log(`记录命令行 : ${d['cmdline_logging'] ? '是' : '否(只能看到进程名)'}`);
|
||||
console.log(`Sysmon : ${d['sysmon_installed'] ? '已安装(可并用)' : '未安装(不自动下载)'}`);
|
||||
console.log(`L2 文件 : ${d['file']}${d['file_exists'] ? '' : '(尚未生成)'}`);
|
||||
if (typeof d['note'] === 'string') console.log(`⚠️ ${d['note']}`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function cmdAuditVerify(opts: Options): Promise<void> {
|
||||
await withClient((c) =>
|
||||
callAndPrint(c, CapabilityNames.AuditVerify, {}, opts.json, (data) => {
|
||||
|
||||
@@ -14,6 +14,11 @@ export const HELP = `nodeagent —— 跨机 AI 接管框架(控制端 CLI)
|
||||
nodeagent audit [--limit 20] [--type invoke|auth|acl|agent] [--client-id X] [--since <ms>]
|
||||
查询被控端审计日志 (v3+)
|
||||
nodeagent audit verify 校验审计链完整性(防篡改检测)(v11)
|
||||
nodeagent audit l2 [status|enable|collect|verify] [--since <ms>] [--limit N]
|
||||
L2 系统级审计兜底:采集 OS 自身审计事件,
|
||||
补 L1 记不到的那块(agent 被停/被绕过时)(见 docs/AUDIT-L2.md)
|
||||
仅 Windows 已落地;Linux 需预装 auditd;
|
||||
macOS ESF 受 Apple entitlement 限制不可用
|
||||
nodeagent metrics [--since <ms>] 成功指标:闭环率/装软件率/P95/拦截率 (v13)
|
||||
nodeagent audio [get] 查看被控端静音状态与主音量 (v17)
|
||||
nodeagent audio set [--mute on|off] [--volume 0-100] 设置静音 / 音量
|
||||
@@ -62,6 +67,14 @@ export const HELP = `nodeagent —— 跨机 AI 接管框架(控制端 CLI)
|
||||
nodeagent deploy <agent.mjs> [--path <远端路径>] [--check] [--force]
|
||||
一键升级被控端 (v11/v18):比指纹 → 备份 → 上传 → 重启
|
||||
→ 轮询校验;--check 只比对不部署
|
||||
nodeagent rollback [--dry-run] [--backup <远端备份路径>]
|
||||
从 LKG(最后已知可用版本)回滚被控端 (v2.1.0):
|
||||
指纹比对定位备份 → 覆盖回入口 → 受控重启 → 复核上线
|
||||
⚠️ 需被控端可达(RPC 命令):新版本「完全起不来」时
|
||||
没有监听可连,请依赖看门狗自动恢复(启动连续失败 N 次,
|
||||
见 docs/DELIVERY-PIPELINE.md §6)或带外恢复(附录 C)
|
||||
自动回滚刻意不做(无法归因/回滚循环/失控)
|
||||
--dry-run 只读比对零写入;--backup 为高级逃生门
|
||||
nodeagent group [add <组名> <设备...> | remove <组名>] 设备分组管理 (v12)
|
||||
nodeagent fanout <能力名> [--nodes a,b|@组名] [--args JSON]
|
||||
多设备并发调用并汇总 (v11)
|
||||
|
||||
+16
-1
@@ -34,12 +34,13 @@ import { HELP } from './help.js';
|
||||
import { parseOptions } from './options.js';
|
||||
import { cmdConnect, cmdDiscover, cmdKeygen, cmdNodes, cmdRemove, cmdUse } from './cmd/conn.js';
|
||||
import {
|
||||
cmdApps, cmdAudio, cmdAudit, cmdAuditAnchor, cmdAuditHead, cmdAuditVerify, cmdExec, cmdInfo, cmdInstall, cmdInvoke, cmdList,
|
||||
cmdApps, cmdAudio, cmdAudit, cmdAuditAnchor, cmdAuditHead, cmdAuditL2, cmdAuditVerify, cmdExec, cmdInfo, cmdInstall, cmdInvoke, cmdList,
|
||||
cmdMetrics, cmdPs, cmdRestart, cmdServices, cmdStatus, cmdUpdate,
|
||||
} from './cmd/system.js';
|
||||
import {
|
||||
cmdBg, cmdClip, cmdDeploy, cmdEvents, cmdFanout, cmdLog, cmdMacro, cmdMonitor, cmdRecord, cmdTask, cmdTasks,
|
||||
} from './cmd/ops.js';
|
||||
import { cmdRollback } from './cmd/rollback.js';
|
||||
import { cmdAwait, cmdKey, cmdMouse, cmdScreen, cmdScreenshot } from './cmd/gui.js';
|
||||
import { cmdCat, cmdLs, cmdPull, cmdPush, cmdStat } from './cmd/fs.js';
|
||||
import { cmdNet } from './cmd/net.js';
|
||||
@@ -60,6 +61,16 @@ async function main(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// ⚠️ `--help` 是**选项**、不是位置参数。
|
||||
// parseArgs 开了 `strict: false`,`--help` / `-h` 会被收进 `values`(作 boolean),
|
||||
// `positionals` 里压根没有它 —— 原来靠「`positionals[0] === undefined` 时打印帮助」
|
||||
// **偶然**蒙对,一旦前面多一个位置参数就会掉进「未知命令」。
|
||||
// L-01 总闸守卫(tests/unit/cli-options.test.mjs)扫出这类「承诺了却没接上」的选项。
|
||||
if (opts.help) {
|
||||
console.log(HELP);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (command) {
|
||||
case 'connect': {
|
||||
const host = positionals[0];
|
||||
@@ -250,6 +261,9 @@ async function main(): Promise<void> {
|
||||
await cmdDeploy(f, opts);
|
||||
return;
|
||||
}
|
||||
case 'rollback':
|
||||
await cmdRollback(opts);
|
||||
return;
|
||||
case 'install': {
|
||||
const pkg = positionals[0];
|
||||
if (!pkg) fail('用法: nodeagent install <包名|ID>');
|
||||
@@ -278,6 +292,7 @@ async function main(): Promise<void> {
|
||||
if (positionals[0] === 'verify') await cmdAuditVerify(opts);
|
||||
else if (positionals[0] === 'head') await cmdAuditHead(opts);
|
||||
else if (positionals[0] === 'anchor') await cmdAuditAnchor(opts);
|
||||
else if (positionals[0] === 'l2') await cmdAuditL2(positionals[1], opts);
|
||||
else await cmdAudit(opts);
|
||||
return;
|
||||
case 'discover':
|
||||
|
||||
+19
-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' },
|
||||
@@ -116,6 +127,8 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
|
||||
'forget-cert': { type: 'boolean', default: false },
|
||||
check: { type: 'boolean', default: false },
|
||||
force: { type: 'boolean', default: false },
|
||||
// v2.1.0 rollback 逃生门(--backup <远端备份路径>)
|
||||
backup: { type: 'string' },
|
||||
mute: { type: 'string' },
|
||||
volume: { type: 'string' },
|
||||
seconds: { type: 'string' },
|
||||
@@ -126,6 +139,8 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
|
||||
});
|
||||
return {
|
||||
opts: {
|
||||
help: Boolean(values['help']),
|
||||
noTls: Boolean(values['no-tls']),
|
||||
json: Boolean(values['json']),
|
||||
insecure: Boolean(values['insecure']),
|
||||
port: values['port'] as string | undefined,
|
||||
@@ -165,7 +180,9 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
|
||||
discoveryPort: values['discovery-port'] as string | undefined,
|
||||
node: values['node'] as string | undefined,
|
||||
delay: values['delay'] as string | undefined,
|
||||
timeoutMs: values['timeout-ms'] as string | undefined,
|
||||
// 两个写法都接受:`--timeout` 是帮助文本/`await` 命令里的正式写法,
|
||||
// `--timeout-ms` 是早期在 `bg` 命令里引入的别名。二者同时给时以 `--timeout` 为准。
|
||||
timeoutMs: (values['timeout'] ?? values['timeout-ms']) as string | undefined,
|
||||
kill: Boolean(values['kill']),
|
||||
offset: values['offset'] as string | undefined,
|
||||
set: values['set'] as string | undefined,
|
||||
@@ -193,6 +210,7 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
|
||||
forgetCert: Boolean(values['forget-cert']),
|
||||
check: Boolean(values['check']),
|
||||
force: Boolean(values['force']),
|
||||
backup: values['backup'] as string | undefined,
|
||||
mute: values['mute'] as string | undefined,
|
||||
volume: values['volume'] as string | undefined,
|
||||
seconds: values['seconds'] as string | undefined,
|
||||
|
||||
@@ -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;
|
||||
@@ -92,6 +96,8 @@ export interface Options {
|
||||
/** v18 部署校验 */
|
||||
check?: boolean;
|
||||
force?: boolean;
|
||||
/** v2.1.0 rollback 逃生门:直接指定远端备份路径为回滚源 */
|
||||
backup?: string;
|
||||
/** v17 音频 */
|
||||
mute?: string;
|
||||
volume?: string;
|
||||
|
||||
+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,7 +1,7 @@
|
||||
{
|
||||
"name": "nodeagent-console",
|
||||
"private": true,
|
||||
"version": "2.0.1",
|
||||
"version": "2.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -9,7 +9,10 @@
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"sidecar:dev": "node sidecar/main.mjs",
|
||||
"sidecar:build": "node sidecar/build.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",
|
||||
@@ -26,7 +29,7 @@
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"typescript": "~6.0.3",
|
||||
"vite": "^8.0.16",
|
||||
"vite": "^8.3.5",
|
||||
"vue-tsc": "^3.3.5"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -13,6 +13,11 @@
|
||||
* {"jsonrpc":"2.0","id":2,"method":"disconnect"}
|
||||
* {"jsonrpc":"2.0","id":3,"method":"invoke","params":{capability,args,timeout_ms}}
|
||||
* {"jsonrpc":"2.0","id":4,"method":"state"}
|
||||
* {"jsonrpc":"2.0","id":5,"method":"check_update","params":{console_version,url?,timeout_ms?}}
|
||||
* {"jsonrpc":"2.0","id":6,"method":"download","params":{url,sha256,dest,timeout_ms?}}
|
||||
* ⚠️ `timeout_ms` 由 Rust 侧下发,**必须小于**它在 `sidecar.rs::request` 上的 RPC 超时,
|
||||
* 否则永远看到的是「RPC 超时」这种假失败(拿不到真正的原因)。两侧口径在
|
||||
* apps/console/src-tauri/src/lib.rs 的 `UPDATE_CHECK_TIMEOUT_MS` / `UPDATE_RPC_TIMEOUT_SECS` 里成对定义。
|
||||
* 响应(本进程 → Rust):{"jsonrpc":"2.0","id":N,"result":{...}} 或 {"...","error":{code,message,data}}
|
||||
* 通知(无 id,本进程主动推):
|
||||
* {"jsonrpc":"2.0","method":"event","params":{"kind":"state","state":"connected"}}
|
||||
@@ -25,7 +30,11 @@
|
||||
*/
|
||||
|
||||
import { createInterface } from 'node:readline';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdir, readFile, writeFile, unlink } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
import { NodeAgentClient } from '@nodeagent/client';
|
||||
import { httpGet, resolveProxy } from './proxy.mjs';
|
||||
|
||||
/** @type {NodeAgentClient | null} */
|
||||
let client = null;
|
||||
@@ -73,6 +82,88 @@ function stateSnapshot() {
|
||||
|
||||
// ---------------- 方法实现 ----------------
|
||||
|
||||
/**
|
||||
* 比较两个 `x.y.z` 版本号:a > b 返回正、相等返回 0、a < b 返回负。
|
||||
*
|
||||
* 按**数值**逐段比较,不做字符串比较 —— 否则 `"2.0.10" < "2.0.9"`(字符串序),
|
||||
* 到两位数的补丁号时会误判「无更新」。
|
||||
*/
|
||||
function cmpVersion(a, b) {
|
||||
const pa = String(a).split('.').map((x) => parseInt(x, 10) || 0);
|
||||
const pb = String(b).split('.').map((x) => parseInt(x, 10) || 0);
|
||||
for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
|
||||
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
|
||||
if (d !== 0) return d > 0 ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 出网超时(毫秒)的裁剪。参数由 Rust 侧下发,**默认值只作兜底** —— 真实口径以
|
||||
* `lib.rs` 的 `UPDATE_CHECK_TIMEOUT_MS` / `UPDATE_DOWNLOAD_TIMEOUT_MS` 为准。
|
||||
*/
|
||||
function clampTimeoutMs(raw, dflt, min = 5_000, max = 1_800_000) {
|
||||
const n = typeof raw === 'number' && Number.isFinite(raw) ? Math.floor(raw) : dflt;
|
||||
return Math.max(min, Math.min(max, n));
|
||||
}
|
||||
|
||||
/**
|
||||
* 决议代理是否可用;**任何异常都退化成「直连」**。
|
||||
*
|
||||
* 代理只是为了「本来连不上时能连上」,它本身绝不该成为新的失败点:
|
||||
* 探测系统代理要跑 `reg`/`scutil`,被安全软件拦、被超时都是可能的,
|
||||
* 那种情况下退回直连(与改动前行为一致)比让「检查更新」直接报错要好。
|
||||
*/
|
||||
async function resolveProxyOrDirect(targetUrl) {
|
||||
try {
|
||||
return await resolveProxy({ targetUrl });
|
||||
} catch (err) {
|
||||
return { proxy: null, source: null, notes: [`代理探测异常(${err instanceof Error ? err.message : String(err)}),按直连处理`] };
|
||||
}
|
||||
}
|
||||
|
||||
/** 组一条可操作的失败线索:走了哪条路、代理从哪来、为什么不走代理。 */
|
||||
function outboundHint({ proxy, source, notes, timeoutMs }) {
|
||||
return {
|
||||
via: proxy ? `经代理 ${proxy.href}` : '直连',
|
||||
proxy: proxy ? proxy.href : null,
|
||||
proxy_source: source,
|
||||
timeout_ms: timeoutMs,
|
||||
notes,
|
||||
hint: proxy
|
||||
? '代理已生效但仍失败:请确认代理本身能访问 GitHub(浏览器里试同一个地址)'
|
||||
: '未使用代理。若本机需要代理才能访问 GitHub,请在系统里开启「系统代理」,或设置环境变量 HTTPS_PROXY=http://127.0.0.1:<端口> 后重启控制台',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为「主动中止」类错误 —— **只认真正的 abort**。
|
||||
*
|
||||
* ⚠️ 别把 `ECONNRESET` 之类也塞进来(第一版就这么写的,真机上立刻现形):
|
||||
* 那次耗时 82.9s、预算 120s,连接是被对端**中途重置**的,却被报成
|
||||
* 「请求超时(120000ms 内未完成)」—— 一句话把用户引到完全错误的方向上。
|
||||
*/
|
||||
function isAbortLike(err) {
|
||||
const name = err?.name ?? '';
|
||||
const code = err?.code ?? '';
|
||||
const msg = String(err?.message ?? '');
|
||||
return name === 'AbortError' || code === 'ABORT_ERR' || /\babort(ed)?\b/i.test(msg);
|
||||
}
|
||||
|
||||
/** 把底层网络错误翻译成「用户能据以行动」的一句话。 */
|
||||
function describeNetError(err, timeoutMs) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const code = err?.code ?? '';
|
||||
if (isAbortLike(err)) return `请求超时(${timeoutMs}ms 内未完成)`;
|
||||
if (code === 'ECONNRESET' || code === 'EPIPE') {
|
||||
return `${msg}(连接被对端中途断开;若走了代理,多半是代理或线路的问题)`;
|
||||
}
|
||||
if (code === 'ENOTFOUND' || code === 'EAI_AGAIN' || code === 'ECONNREFUSED') {
|
||||
return `${msg}(域名解析失败或目标拒绝连接;若配了代理,请核对代理地址与端口)`;
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
const methods = {
|
||||
/** 建立连接并完成握手。返回与旧 Rust client 的 ConnectInfo 同构的元信息。 */
|
||||
async connect(params) {
|
||||
@@ -155,6 +246,130 @@ const methods = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 检查更新:拉项目最新 Release 的 `latest.json`,与本应用版本比对。
|
||||
*
|
||||
* ## 为什么放在 sidecar 而不是 Rust 壳
|
||||
* 本壳的既定分工是「**进程管理 + 消息转发**,协议与 IO 逻辑集中在 Node 一侧」
|
||||
* (见文件头注释)。更新检查要做 HTTPS + JSON + 后续大概率还要下载文件校验哈希,
|
||||
* 在 Rust 侧等于再引一套 HTTP 栈;而 Node 有内置 `fetch`,一行到位。**单一实现**优先。
|
||||
*
|
||||
* `latest.json` 由发版脚本产出(含 version / commit / agent.url / agent.sha256),
|
||||
* 走 `releases/latest/download/` 这个**稳定别名**,无需调 GitHub API、不占速率配额。
|
||||
*/
|
||||
async check_update(params) {
|
||||
const consoleVersion = String(params?.console_version ?? '0.0.0');
|
||||
const url = String(
|
||||
params?.url ?? 'https://github.com/jkooe/nodeagent/releases/latest/download/latest.json',
|
||||
);
|
||||
const timeoutMs = clampTimeoutMs(params?.timeout_ms, 120_000);
|
||||
|
||||
const out = await resolveProxyOrDirect(url);
|
||||
log('info', `检查更新:${out.proxy ? `经代理 ${out.proxy.href}(来源 ${out.source})` : '直连'}`);
|
||||
for (const n of out.notes) log('warn', `检查更新:${n}`);
|
||||
|
||||
const t0 = Date.now();
|
||||
let meta;
|
||||
try {
|
||||
const res = await httpGet(url, { proxy: out.proxy, timeoutMs });
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
throw new Error(`拉取 latest.json 失败:HTTP ${res.status} ${res.statusText}`);
|
||||
}
|
||||
meta = JSON.parse(res.body.toString('utf8'));
|
||||
} catch (err) {
|
||||
const msg = describeNetError(err, timeoutMs);
|
||||
const where = out.proxy ? `经代理 ${out.proxy.href}` : '直连';
|
||||
throw Object.assign(
|
||||
new Error(`检查更新失败:${msg}(${where},耗时 ${Date.now() - t0}ms)`),
|
||||
{ data: { url, ...outboundHint({ ...out, timeoutMs }) } },
|
||||
);
|
||||
}
|
||||
|
||||
const latest = String(meta?.version ?? '');
|
||||
if (!latest) {
|
||||
throw new Error('latest.json 缺少 version 字段(发版产物异常)');
|
||||
}
|
||||
const agent = meta?.agent ?? {};
|
||||
return {
|
||||
console_version: consoleVersion,
|
||||
latest_version: latest,
|
||||
has_update: cmpVersion(latest, consoleVersion) > 0,
|
||||
published_at: meta?.built_at ?? null,
|
||||
commit: meta?.commit ?? null,
|
||||
capabilities: meta?.capabilities ?? null,
|
||||
agent: {
|
||||
url: agent.url ?? null,
|
||||
sha256: agent.sha256 ?? null,
|
||||
bytes: agent.bytes ?? null,
|
||||
},
|
||||
release_url: `https://github.com/jkooe/nodeagent/releases/tag/v${latest}`,
|
||||
source: url,
|
||||
// 让界面/日志能一眼看出这次是直连还是走了代理(真机复验与用户自查都需要)
|
||||
via_proxy: out.source,
|
||||
via: out.proxy ? `经代理 ${out.proxy.href}` : '直连',
|
||||
checked_at: Date.now(),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 下载文件并做 sha256 校验(校验不通过**不落盘**,避免半成品污染数据目录)。
|
||||
* 供「更新本机被控端」使用。
|
||||
*/
|
||||
async download(params) {
|
||||
const url = String(params?.url ?? '');
|
||||
const expect = String(params?.sha256 ?? '').toLowerCase();
|
||||
const dest = String(params?.dest ?? '');
|
||||
if (!url || !dest) {
|
||||
throw Object.assign(new Error('download 需要 url 与 dest'), { code: -32602 });
|
||||
}
|
||||
if (!/^[0-9a-f]{12,64}$/.test(expect)) {
|
||||
throw Object.assign(
|
||||
new Error('download 需要 sha256(12~64 位十六进制)—— 不校验哈希等于接受任意内容'),
|
||||
{ code: -32602 },
|
||||
);
|
||||
}
|
||||
|
||||
const timeoutMs = clampTimeoutMs(params?.timeout_ms, 300_000);
|
||||
|
||||
const out = await resolveProxyOrDirect(url);
|
||||
log('info', `下载更新包:${out.proxy ? `经代理 ${out.proxy.href}(来源 ${out.source})` : '直连'}`);
|
||||
|
||||
const t0 = Date.now();
|
||||
let buf;
|
||||
try {
|
||||
const res = await httpGet(url, { proxy: out.proxy, timeoutMs });
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
||||
}
|
||||
buf = res.body;
|
||||
} catch (err) {
|
||||
const msg = describeNetError(err, timeoutMs);
|
||||
const where = out.proxy ? `经代理 ${out.proxy.href}` : '直连';
|
||||
throw Object.assign(new Error(`下载失败:${msg}(${where},耗时 ${Date.now() - t0}ms)`), {
|
||||
data: { url, ...outboundHint({ ...out, timeoutMs }) },
|
||||
});
|
||||
}
|
||||
|
||||
const got = createHash('sha256').update(buf).digest('hex');
|
||||
if (got !== expect && !got.startsWith(expect)) {
|
||||
throw Object.assign(new Error('哈希校验失败,已丢弃(未写入任何文件)'), {
|
||||
data: { expected: expect.slice(0, 12), actual: got.slice(0, 12), bytes: buf.length },
|
||||
});
|
||||
}
|
||||
|
||||
await mkdir(dirname(dest), { recursive: true });
|
||||
await writeFile(dest, buf);
|
||||
// 写后复核:防止截断/半写(磁盘满等)
|
||||
const written = createHash('sha256')
|
||||
.update(await readFile(dest))
|
||||
.digest('hex');
|
||||
if (written !== got) {
|
||||
await unlink(dest).catch(() => {});
|
||||
throw new Error('写入后校验不一致,已删除(请检查磁盘空间)');
|
||||
}
|
||||
return { path: dest, sha256: got, bytes: buf.length };
|
||||
},
|
||||
|
||||
state: () => stateSnapshot(),
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,523 @@
|
||||
/**
|
||||
* 出站代理支持 —— 只为「检查更新 / 下载更新包」这两条 HTTPS 出网路径服务。
|
||||
*
|
||||
* ## 为什么必须自己实现(这是缺陷,不是优化)
|
||||
*
|
||||
* Node 内置 `fetch`(undici)**默认不读任何代理环境变量** —— 实测:设了 `HTTPS_PROXY`
|
||||
* 仍然 `fetch failed`。能开的只有 `NODE_USE_ENV_PROXY=1`(Node 22.x 实验性、24 正式),
|
||||
* 而它**只能读环境变量**,读不到 Windows 系统代理(WinINET)或 macOS 系统代理
|
||||
* —— 而中文用户用 Clash / v2rayN 时配置的恰恰是「系统代理」,不是环境变量。
|
||||
* 结果:境内用户即使代理一切正常,「检查更新」也**必然**报 `fetch failed`。
|
||||
* (真机取证 2026-10-13:VM 上 DNS 与 TCP 443 都通、但 `fetch failed`;同一台机器
|
||||
* 直连 GitHub 被阻断、经代理取 `latest.json` 返回 302。)
|
||||
*
|
||||
* 另外 `fetch(url, { dispatcher })` 也不通:`dispatcher` 必须是 undici 的 Dispatcher 实例,
|
||||
* 而 Node 没有把 undici 暴露成可导入的模块(只能整包 vendored 进来,那会把 sidecar
|
||||
* 从 227KB 撑胖一大截,且 undici 依赖 `.wasm` 解析器、esbuild 单文件打包有风险)。
|
||||
*
|
||||
* ## 做法
|
||||
*
|
||||
* 不用 `fetch`,改用 `node:http` / `node:https` 自己发请求:
|
||||
* - **https 目标经代理**:先向代理发 `CONNECT host:443` 建隧道,再在隧道 socket 上做 TLS
|
||||
* (通过 `createConnection` 交给 `http.request` —— 注意这里刻意用 `http` 而非 `https`,
|
||||
* 因为我们交出去的**已经是 TLS socket**,用 `https.request` 会二次握手);
|
||||
* - **http 目标经代理**:按 HTTP 代理规范发**绝对形态**请求行(`GET http://… HTTP/1.1`);
|
||||
* - **无代理**:与改动前一致,直连(走 `https.request`)。
|
||||
*
|
||||
* ## 代理来源优先级
|
||||
*
|
||||
* ① `NA_PROXY`(显式覆盖,便于排查与将来做配置项)
|
||||
* ② 目标协议对应的环境变量(`HTTPS_PROXY`/`https_proxy` → `ALL_PROXY`/`all_proxy` → …)
|
||||
* ③ **系统代理**(Windows 读注册表 WinINET;macOS 读 `scutil --proxy`)
|
||||
*
|
||||
* `NO_PROXY` / `no_proxy` 命中的主机一律直连。只支持 **HTTP 代理**(`http(s)://`)——
|
||||
* HTTPS 目标经 HTTP 代理走 CONNECT,是通用做法;`socks5://` 会**明确**记一条 note
|
||||
* 而不是静默直连(静默正是本次缺陷最恶劣的地方)。
|
||||
*
|
||||
* ## 安全
|
||||
*
|
||||
* 只做「出站取数据」,不引入任何新依赖;TLS 证书默认校验,不提供跳过开关。
|
||||
*/
|
||||
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import tls from 'node:tls';
|
||||
import { execFile } from 'node:child_process';
|
||||
|
||||
/** 代理来源优先级(按目标协议分列)。`NA_PROXY` 恒为第一优先。 */
|
||||
const ENV_ORDER = {
|
||||
'https:': ['NA_PROXY', 'HTTPS_PROXY', 'https_proxy', 'ALL_PROXY', 'all_proxy', 'HTTP_PROXY', 'http_proxy'],
|
||||
'http:': ['NA_PROXY', 'HTTP_PROXY', 'http_proxy', 'ALL_PROXY', 'all_proxy'],
|
||||
};
|
||||
const NO_PROXY_KEYS = ['NO_PROXY', 'no_proxy'];
|
||||
const REDIRECT_STATUS = new Set([301, 302, 303, 307, 308]);
|
||||
const UA = 'nodeagent-console-sidecar';
|
||||
|
||||
/**
|
||||
* 把环境里/配置里写的各种代理形态归一成可用的 URL。
|
||||
*
|
||||
* 接受 `http://127.0.0.1:7890`、`127.0.0.1:7890`(补 `http://`)、带账密的 URL;
|
||||
* 其余(`socks5://`、空串、无法解析)返回 `null` —— 由调用方把「看到了但用不了」
|
||||
* 明确报出来,**不许静默直连**。
|
||||
*
|
||||
* @param {unknown} raw
|
||||
* @returns {URL | null}
|
||||
*/
|
||||
export function normalizeProxyUrl(raw) {
|
||||
if (typeof raw !== 'string') return null;
|
||||
const s = raw.trim();
|
||||
if (!s) return null;
|
||||
let u;
|
||||
try {
|
||||
u = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(s) ? s : `http://${s}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
|
||||
if (!u.hostname) return null;
|
||||
return u;
|
||||
}
|
||||
|
||||
/** 代理 URL 的主机端口(缺省按代理自身协议补 80/443)。 */
|
||||
function proxyEndpoint(u) {
|
||||
return { host: u.hostname, port: u.port ? Number(u.port) : u.protocol === 'https:' ? 443 : 80 };
|
||||
}
|
||||
|
||||
/** 代理 URL 里的账密 → `Proxy-Authorization` 头(没有则 null)。 */
|
||||
function proxyAuthHeader(u) {
|
||||
if (!u.username) return null;
|
||||
const raw = `${decodeURIComponent(u.username)}:${decodeURIComponent(u.password)}`;
|
||||
return 'Basic ' + Buffer.from(raw).toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* `NO_PROXY` 命中判定。支持:精确主机、后缀(`example.com` 或 `.example.com` 都匹配
|
||||
* `a.example.com`)、`host:port`、`*`。大小写不敏感。
|
||||
*
|
||||
* ⚠️ 刻意**不支持 CIDR**(如 `169.254/16`):本模块的目标只有公网域名,
|
||||
* 而 CIDR 主机名判定极易写错、错了就是「该走代理的没走」。遇到疑似 CIDR 条目
|
||||
* 由调用方记 note(见 `pickEnvProxy` 的 notes 语义)。
|
||||
*
|
||||
* @param {string} targetUrl
|
||||
* @param {string} noProxy
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isBypassed(targetUrl, noProxy) {
|
||||
if (!noProxy) return false;
|
||||
let host;
|
||||
let port;
|
||||
try {
|
||||
const u = new URL(targetUrl);
|
||||
host = u.hostname.toLowerCase();
|
||||
port = u.port || (u.protocol === 'https:' ? '443' : '80');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
for (const rawEntry of String(noProxy).split(',')) {
|
||||
const entry = rawEntry.trim().toLowerCase();
|
||||
if (!entry) continue;
|
||||
if (entry === '*') return true;
|
||||
let eHost = entry;
|
||||
let ePort = null;
|
||||
const i = entry.lastIndexOf(':');
|
||||
// 排除 IPv6 字面量(`[::1]:8080`)里的冒号误判
|
||||
if (i > 0 && !entry.slice(0, i).includes(':')) {
|
||||
eHost = entry.slice(0, i);
|
||||
ePort = entry.slice(i + 1);
|
||||
}
|
||||
if (ePort && ePort !== port) continue;
|
||||
const bare = eHost.replace(/^\./, '');
|
||||
if (!bare) continue;
|
||||
if (host === bare || host.endsWith(`.${bare}`)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从环境变量里挑代理(纯函数,便于单测)。
|
||||
*
|
||||
* @param {Record<string, string | undefined>} env
|
||||
* @param {string} targetUrl
|
||||
* @returns {{ url: URL | null, source: string | null, notes: string[] }}
|
||||
*/
|
||||
export function pickEnvProxy(env, targetUrl) {
|
||||
let proto = 'https:';
|
||||
try {
|
||||
proto = new URL(targetUrl).protocol;
|
||||
} catch {
|
||||
/* 保持默认 */
|
||||
}
|
||||
const keys = ENV_ORDER[proto] ?? ENV_ORDER['https:'];
|
||||
const notes = [];
|
||||
for (const k of keys) {
|
||||
const v = env?.[k];
|
||||
if (!v) continue;
|
||||
const u = normalizeProxyUrl(v);
|
||||
if (u) return { url: u, source: k, notes };
|
||||
notes.push(`${k}=${String(v).trim()} 不是受支持的代理协议(仅支持 http/https 代理),已忽略`);
|
||||
}
|
||||
return { url: null, source: null, notes };
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 macOS `scutil --proxy` 的输出。
|
||||
*
|
||||
* 形如:
|
||||
* <dictionary> {
|
||||
* HTTPSEnable : 1
|
||||
* HTTPSPort : 7890
|
||||
* HTTPSProxy : 127.0.0.1
|
||||
* ExceptionsList : <array> { 0 : *.local 1 : 169.254/16 }
|
||||
* }
|
||||
*
|
||||
* 注意 macOS 上 HTTPS 与 HTTP 是**分别开关**的;这里 https 优先,取不到再退 HTTP。
|
||||
* `ProxyAutoConfigEnable : 1`(PAC 自动配置脚本)**无法解析** —— 返回 notes 说明,
|
||||
* 让上层给出「请显式设 HTTPS_PROXY」这类可操作提示,而不是含糊的 fetch failed。
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {{ url: string | null, bypass: string, notes: string[] } | null}
|
||||
*/
|
||||
export function parseScutilProxy(text) {
|
||||
if (typeof text !== 'string' || !text.includes('{')) return null;
|
||||
const scalar = (key) => {
|
||||
const m = new RegExp(`^\\s*${key}\\s*:\\s*(.+?)\\s*$`, 'm').exec(text);
|
||||
return m ? m[1].trim() : null;
|
||||
};
|
||||
const isOn = (v) => v === '1' || v === 'true';
|
||||
const notes = [];
|
||||
|
||||
// ExceptionsList 是 <array> 块里的 “序号 : 值” 行 —— 收集成逗号分隔的绕过列表
|
||||
const bypass = [];
|
||||
const arrIdx = text.indexOf('ExceptionsList');
|
||||
if (arrIdx >= 0) {
|
||||
const body = text.slice(arrIdx, text.indexOf('}', arrIdx) + 1);
|
||||
const re = /^\s*\d+\s*:\s*(.+?)\s*$/gm;
|
||||
let m;
|
||||
while ((m = re.exec(body)) !== null) bypass.push(m[1].trim());
|
||||
}
|
||||
|
||||
for (const [flag, hostKey, portKey, dflt] of [
|
||||
['HTTPSEnable', 'HTTPSProxy', 'HTTPSPort', '443'],
|
||||
['HTTPEnable', 'HTTPProxy', 'HTTPPort', '80'],
|
||||
]) {
|
||||
if (!isOn(scalar(flag))) continue;
|
||||
const host = scalar(hostKey);
|
||||
if (!host) continue;
|
||||
const port = scalar(portKey) ?? dflt;
|
||||
return { url: `${host}:${port}`, bypass: bypass.join(','), notes };
|
||||
}
|
||||
|
||||
if (isOn(scalar('ProxyAutoConfigEnable'))) {
|
||||
notes.push('系统代理使用 PAC 自动配置脚本(ProxyAutoConfigEnable=1),本程序无法解析脚本内容,已按「无代理」处理');
|
||||
}
|
||||
return { url: null, bypass: bypass.join(','), notes };
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 Windows `reg query …\Internet Settings` 的输出。
|
||||
*
|
||||
* 形如(`REG_SZ` / `REG_DWORD` 是**类型名,不随系统语言变化**,可安全按 token 解析):
|
||||
* ProxyEnable REG_DWORD 0x1
|
||||
* ProxyOverride REG_SZ *.local;<local>
|
||||
* ProxyServer REG_SZ 127.0.0.1:7890 (也可能是 http=…;https=… 分协议形态)
|
||||
*
|
||||
* `ProxyEnable=0x0` 视为**显式关闭** → 无代理(哪怕 ProxyServer 有值)。
|
||||
* `<local>` 是「本机地址直连」的占位符,不是域名,从绕过列表里剔除。
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {{ url: string | null, bypass: string, notes: string[] } | null}
|
||||
*/
|
||||
export function parseRegQueryProxy(text) {
|
||||
if (typeof text !== 'string' || !text.includes('REG_')) return null;
|
||||
const value = (name) => {
|
||||
const m = new RegExp(`^\\s*${name}\\s+REG_(?:SZ|EXPAND_SZ|DWORD|BINARY)\\s+(.*?)\\s*$`, 'm').exec(text);
|
||||
return m ? m[1].trim() : null;
|
||||
};
|
||||
const notes = [];
|
||||
|
||||
const enable = value('ProxyEnable');
|
||||
if (enable !== null && /^0x0+$/i.test(enable)) return { url: null, bypass: '', notes };
|
||||
|
||||
const server = value('ProxyServer');
|
||||
if (!server) return { url: null, bypass: '', notes };
|
||||
|
||||
const override = value('ProxyOverride');
|
||||
const bypass = override
|
||||
? override
|
||||
.split(';')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s && !/^<local>$/i.test(s))
|
||||
.join(',')
|
||||
: '';
|
||||
|
||||
// 分协议形态:http=127.0.0.1:7890;https=127.0.0.1:7891 —— https 优先
|
||||
let url = server;
|
||||
if (server.includes('=')) {
|
||||
const map = new Map();
|
||||
for (const part of server.split(';')) {
|
||||
const i = part.indexOf('=');
|
||||
if (i > 0) map.set(part.slice(0, i).trim().toLowerCase(), part.slice(i + 1).trim());
|
||||
}
|
||||
url = map.get('https') ?? map.get('http') ?? null;
|
||||
if (!url) notes.push(`系统代理仅配置了非 http(s) 协议(ProxyServer=${server})`);
|
||||
}
|
||||
return { url, bypass, notes };
|
||||
}
|
||||
|
||||
/** 跑一个只读命令,失败/超时一律返回 null(探测系统代理**绝不允许**把检查更新搞挂)。 */
|
||||
function runQuiet(file, args) {
|
||||
return new Promise((resolve) => {
|
||||
execFile(
|
||||
file,
|
||||
args,
|
||||
// windowsHide:本进程是被 CREATE_NO_WINDOW 拉起的「无控制台」进程,
|
||||
// 若再 spawn 控制台程序(reg.exe),Windows 会**新开一个控制台** → 黑窗闪一下。
|
||||
{ windowsHide: true, timeout: 5000, encoding: 'utf8', maxBuffer: 1024 * 1024 },
|
||||
(err, stdout) => resolve(err ? null : String(stdout)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 探测系统代理。`run` 可注入(单测用假 runner,不必真去读注册表)。
|
||||
*
|
||||
* @param {NodeJS.Platform} [platform]
|
||||
* @param {(file: string, args: string[]) => Promise<string | null>} [run]
|
||||
* @returns {Promise<{ url: string | null, bypass: string, notes: string[] } | null>}
|
||||
*/
|
||||
export async function detectSystemProxy(platform = process.platform, run = runQuiet) {
|
||||
try {
|
||||
if (platform === 'win32') {
|
||||
const out = await run('reg', [
|
||||
'query',
|
||||
'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings',
|
||||
]);
|
||||
return out ? parseRegQueryProxy(out) : null;
|
||||
}
|
||||
if (platform === 'darwin') {
|
||||
const out = await run('scutil', ['--proxy']);
|
||||
return out ? parseScutilProxy(out) : null;
|
||||
}
|
||||
// Linux:桌面环境各异(gsettings / 环境变量 / PAC),不做猜测 —— 只认环境变量
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 系统代理只探测一次(同一进程内结果不变;用户改代理需重启控制台)。 */
|
||||
let systemProxyPromise;
|
||||
export function systemProxy() {
|
||||
if (!systemProxyPromise) systemProxyPromise = detectSystemProxy().catch(() => null);
|
||||
return systemProxyPromise;
|
||||
}
|
||||
|
||||
/** 单测用:清掉系统代理缓存。 */
|
||||
export function __resetSystemProxyCache() {
|
||||
systemProxyPromise = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 决议本次请求该用哪个代理。
|
||||
*
|
||||
* @param {{ targetUrl: string, env?: Record<string, string | undefined>, system?: unknown }} opts
|
||||
* `system` 显式传入时不做探测(单测用);传 `null` 表示「确认没有系统代理」。
|
||||
* @returns {Promise<{ proxy: URL | null, source: string | null, notes: string[] }>}
|
||||
*/
|
||||
export async function resolveProxy({ targetUrl, env = process.env, system } = {}) {
|
||||
const notes = [];
|
||||
const noProxy = NO_PROXY_KEYS.map((k) => env?.[k]).find(Boolean) ?? '';
|
||||
if (noProxy && isBypassed(targetUrl, noProxy)) {
|
||||
return { proxy: null, source: null, notes: [`目标命中 NO_PROXY=${noProxy},按直连处理`] };
|
||||
}
|
||||
|
||||
const fromEnv = pickEnvProxy(env ?? {}, targetUrl);
|
||||
notes.push(...fromEnv.notes);
|
||||
if (fromEnv.url) return { proxy: fromEnv.url, source: fromEnv.source, notes };
|
||||
|
||||
const sys = system === undefined ? await systemProxy() : system;
|
||||
if (sys?.url) {
|
||||
notes.push(...(sys.notes ?? []));
|
||||
if (sys.bypass && isBypassed(targetUrl, sys.bypass)) {
|
||||
notes.push(`目标命中系统代理的绕过列表(${sys.bypass}),按直连处理`);
|
||||
return { proxy: null, source: null, notes };
|
||||
}
|
||||
const u = normalizeProxyUrl(String(sys.url));
|
||||
if (u) return { proxy: u, source: 'system', notes };
|
||||
notes.push(`系统代理 ${sys.url} 不受支持(仅支持 http/https 代理),已按直连处理`);
|
||||
return { proxy: null, source: null, notes };
|
||||
}
|
||||
notes.push(...(sys?.notes ?? []));
|
||||
return { proxy: null, source: null, notes };
|
||||
}
|
||||
|
||||
/**
|
||||
* 造一个 `createConnection`:向代理发 CONNECT 建隧道,https 目标再在隧道上叠 TLS。
|
||||
*
|
||||
* 返回的 socket 交给 `http.request` 使用(那已经是 TLS socket,故用 `http` 模块)。
|
||||
*
|
||||
* ## ⚠️ 为什么必须自己管 `signal`(真机踩过)
|
||||
* `createConnection` 的回调**返回之前**,外层 `http.request` 还没建立 —— 此时
|
||||
* 外层请求上的 `signal` / `timeout` 都**无人受理**。真机形态:代理 TCP 能连上、却对
|
||||
* CONNECT 或随后的 TLS 握手**永不响应**,于是「75s 超时」形同虚设,实际挂了 109s。
|
||||
* 所以:CONNECT 请求要带 signal,**拿到 socket 与 tlsSock 之后还要自己挂 abort**,
|
||||
* 失败/成功时都要摘掉监听,避免泄漏与误伤。
|
||||
*/
|
||||
export function tunnelConnection(proxy, target, signal) {
|
||||
const pe = proxyEndpoint(proxy);
|
||||
return (_opts, cb) => {
|
||||
let settled = false;
|
||||
let tunnelSock = null;
|
||||
let tlsSock = null;
|
||||
|
||||
const detach = () => {
|
||||
if (signal) signal.removeEventListener('abort', onAbort);
|
||||
if (tunnelSock) tunnelSock.removeListener('error', onSockError);
|
||||
};
|
||||
const fail = (err) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
detach();
|
||||
// 半开的隧道必须销毁,否则这个悬挂连接会一直占着(用户点几次就攒一堆)
|
||||
try {
|
||||
tlsSock?.destroy();
|
||||
tunnelSock?.destroy();
|
||||
} catch {
|
||||
/* 已经没了 */
|
||||
}
|
||||
cb(err);
|
||||
};
|
||||
const onSockError = (e) => fail(e);
|
||||
const onAbort = () => fail(signal?.reason instanceof Error ? signal.reason : new Error('请求已中止'));
|
||||
|
||||
if (signal?.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
|
||||
const port = target.port || (target.protocol === 'https:' ? 443 : 80);
|
||||
const headers = { host: `${target.hostname}:${port}` };
|
||||
const auth = proxyAuthHeader(proxy);
|
||||
if (auth) headers['proxy-authorization'] = auth;
|
||||
|
||||
const req = http.request({
|
||||
host: pe.host,
|
||||
port: pe.port,
|
||||
method: 'CONNECT',
|
||||
path: `${target.hostname}:${port}`,
|
||||
headers,
|
||||
agent: false,
|
||||
signal,
|
||||
});
|
||||
req.once('connect', (res, socket, head) => {
|
||||
tunnelSock = socket;
|
||||
if (res.statusCode !== 200) {
|
||||
fail(new Error(`代理拒绝 CONNECT(HTTP ${res.statusCode})`));
|
||||
return;
|
||||
}
|
||||
if (head && head.length) socket.unshift(head);
|
||||
if (target.protocol !== 'https:') {
|
||||
settled = true;
|
||||
detach();
|
||||
cb(null, socket);
|
||||
return;
|
||||
}
|
||||
socket.once('error', onSockError);
|
||||
tlsSock = tls.connect({ socket, servername: target.hostname });
|
||||
tlsSock.once('secureConnect', () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
detach();
|
||||
cb(null, tlsSock);
|
||||
});
|
||||
tlsSock.once('error', onSockError);
|
||||
});
|
||||
req.once('error', onSockError);
|
||||
req.end();
|
||||
};
|
||||
}
|
||||
|
||||
/** 单跳请求(不跟随重定向)。 */
|
||||
function once(urlStr, { proxy, timeoutMs, signal }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const u = new URL(urlStr);
|
||||
const isHttps = u.protocol === 'https:';
|
||||
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
||||
reject(new Error(`不支持的协议:${u.protocol}`));
|
||||
return;
|
||||
}
|
||||
// ⚠️ 手搭 options,**不要**把 URL 对象当第一参再覆盖字段 —— 传 URL 时 Node 会用
|
||||
// URL 里的 host/port/path 覆盖后面的同名字段(原型实测踩过:代理形同无效、退化成直连)。
|
||||
const opts = {
|
||||
method: 'GET',
|
||||
signal,
|
||||
timeout: timeoutMs,
|
||||
host: u.hostname,
|
||||
port: u.port || (isHttps ? 443 : 80),
|
||||
path: `${u.pathname}${u.search}`,
|
||||
headers: { accept: '*/*', 'user-agent': UA, host: u.host },
|
||||
};
|
||||
|
||||
let req;
|
||||
if (proxy && !isHttps) {
|
||||
// http 目标经代理 → 绝对形态请求行,且请求行交给代理
|
||||
const pe = proxyEndpoint(proxy);
|
||||
opts.host = pe.host;
|
||||
opts.port = pe.port;
|
||||
opts.path = u.href;
|
||||
const auth = proxyAuthHeader(proxy);
|
||||
if (auth) opts.headers['proxy-authorization'] = auth;
|
||||
req = http.request(opts);
|
||||
} else if (proxy) {
|
||||
opts.createConnection = tunnelConnection(proxy, u, signal);
|
||||
req = http.request(opts);
|
||||
} else {
|
||||
req = (isHttps ? https : http).request(opts);
|
||||
}
|
||||
|
||||
req.once('timeout', () => req.destroy(new Error(`请求超时(${timeoutMs}ms 无响应)`)));
|
||||
req.once('response', (res) => {
|
||||
const chunks = [];
|
||||
res.on('data', (c) => chunks.push(c));
|
||||
res.once('error', reject);
|
||||
res.once('end', () =>
|
||||
resolve({ status: res.statusCode, statusText: res.statusMessage, headers: res.headers, body: Buffer.concat(chunks) }),
|
||||
);
|
||||
});
|
||||
req.once('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GET 一个 URL(跟随重定向)。
|
||||
*
|
||||
* 与 `fetch(url, {redirect:'follow'})` 的差异:不自动解压(不发 `accept-encoding`,
|
||||
* 服务端会回 identity)、不解析 cookies —— 这两条对本用途都无影响。
|
||||
*
|
||||
* 整个重定向链共享**同一个**超时预算与 abort 信号(而不是每跳各自计时),
|
||||
* 否则 5 跳 × 30s = 150s 会穿过上层的 RPC 超时。
|
||||
*
|
||||
* @param {string} url
|
||||
* @param {{ proxy?: URL | null, timeoutMs?: number, maxRedirects?: number }} [opts]
|
||||
* @returns {Promise<{ status: number, statusText: string, headers: Record<string, unknown>, body: Buffer, url: string }>}
|
||||
*/
|
||||
export async function httpGet(url, { proxy = null, timeoutMs = 30_000, maxRedirects = 5 } = {}) {
|
||||
const ac = new AbortController();
|
||||
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
||||
try {
|
||||
let current = String(url);
|
||||
for (let hop = 0; ; hop += 1) {
|
||||
const res = await once(current, { proxy, timeoutMs, signal: ac.signal });
|
||||
const loc = res.headers?.location;
|
||||
if (REDIRECT_STATUS.has(res.status) && typeof loc === 'string' && hop < maxRedirects) {
|
||||
current = new URL(loc, current).href;
|
||||
continue;
|
||||
}
|
||||
return { ...res, url: current };
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
@@ -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
+18
-1
@@ -2101,13 +2101,14 @@ checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nodeagent-console"
|
||||
version = "2.0.0"
|
||||
version = "2.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-single-instance",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
@@ -3519,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"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "nodeagent-console"
|
||||
version = "2.0.0"
|
||||
description = "nodeagent 桌面控制台(主控 + 被控一体)"
|
||||
version = "2.1.0"
|
||||
description = "nodeagent 桌面控制台(主控 + 被控一体,含本机服务托管)"
|
||||
authors = ["jkooe"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -18,8 +18,13 @@ 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"
|
||||
# ⚠️ 2026-10:不再依赖 crates/nodeagent-client —— 协议实现改由 Node sidecar 承担
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,299 @@
|
||||
//! 开机自启管理 —— 「随系统启动」开关的落地实现。
|
||||
//!
|
||||
//! ## 设计:自启的是**控制台的仅服务模式**,不是裸 node
|
||||
//!
|
||||
//! 两个候选方案,各有硬伤:
|
||||
//! - **直接自启 `node agent.mjs`**:Windows 上会弹一个黑色控制台窗口(schtasks 无法指定
|
||||
//! `CREATE_NO_WINDOW`),且进程无人托管 —— 崩了不会拉起、也无法从界面停止。
|
||||
//! - **自启本控制台(`--agent-only`)**:本应用是 GUI 子系统程序,**不会有控制台窗口**;
|
||||
//! 且它本来就负责「托管被控端」(启动/停止/自动重启/日志),语义完全对齐。
|
||||
//!
|
||||
//! 故选后者。`--agent-only` 模式下应用不显示主窗口,直接进托盘并拉起被控端。
|
||||
//!
|
||||
//! ## 平台差异
|
||||
//! | 平台 | 机制 | 位置 |
|
||||
//! |---|---|---|
|
||||
//! | macOS | LaunchAgent(用户级,无需提权) | `~/Library/LaunchAgents/<label>.plist` |
|
||||
//! | Windows | 计划任务(登录时触发,Interactive 登录类型) | 任务名 `<label>` |
|
||||
//!
|
||||
//! Windows 用**登录用户 + Interactive** 而非 SYSTEM:被控端的图形能力(截图 / 键鼠 / 窗口)
|
||||
//! 必须跑在交互式会话里,SYSTEM(会话 0)没有桌面 —— 这一点在真机验证时已实证。
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
/// 自启项标识(macOS 的 launchd label / Windows 的计划任务名)。
|
||||
const LABEL: &str = "cn.jkooe.nodeagent-console";
|
||||
|
||||
/// 仅服务模式命令行参数(无窗口、直接进托盘并拉起被控端)。
|
||||
pub const AGENT_ONLY_FLAG: &str = "--agent-only";
|
||||
|
||||
/// 当前可执行文件路径。
|
||||
///
|
||||
/// ⚠️ 出口经 `unverbatim`:Windows 上 `current_exe()` 可能带 `\\?\` 前缀,
|
||||
/// 而这条路径会**被写进计划任务的执行路径**(`New-ScheduledTaskAction -Execute`)。
|
||||
/// 带前缀的路径不是「一定不能用」,但它会以原样进入 PowerShell 字符串与任务定义 ——
|
||||
/// 与 2026-10-13「verbatim 路径交给外部程序」那类真机缺陷同源,统一在出口净化。
|
||||
fn app_exe() -> Result<PathBuf, String> {
|
||||
std::env::current_exe()
|
||||
.map(crate::sidecar::unverbatim)
|
||||
.map_err(|e| format!("取当前程序路径失败:{e}"))
|
||||
}
|
||||
|
||||
/// macOS 上 LaunchAgent plist 的位置。
|
||||
fn macos_plist_path() -> Result<PathBuf, String> {
|
||||
let home = std::env::var_os("HOME")
|
||||
.map(PathBuf::from)
|
||||
.ok_or("拿不到 HOME")?;
|
||||
Ok(home.join("Library").join("LaunchAgents").join(format!("{LABEL}.plist")))
|
||||
}
|
||||
|
||||
/// 生成 macOS LaunchAgent plist。
|
||||
///
|
||||
/// `RunAtLoad` 保证登录即拉起;**不用** `KeepAlive` —— 让「退出」按钮能真正退出,
|
||||
/// 用户想恢复就重新登录或再开应用。进程级别的崩溃自愈由控制台的 `AgentManager` 负责,
|
||||
/// 不该在这里再叠一层(两层自愈会互相打架)。
|
||||
fn macos_plist(exe: &Path) -> String {
|
||||
let exe = xml_escape(&exe.to_string_lossy());
|
||||
format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<!-- 由 nodeagent 控制台生成 —— 请勿手工编辑(界面上的「开机自启」开关会覆写本文件) -->
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>{LABEL}</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>{exe}</string>
|
||||
<string>{AGENT_ONLY_FLAG}</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>ProcessType</key>
|
||||
<string>Interactive</string>
|
||||
</dict>
|
||||
</plist>
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
fn xml_escape(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
|
||||
/// 当前用户的 uid(macOS 的 `launchctl bootstrap gui/<uid>` 需要它)。
|
||||
///
|
||||
/// 用 `id -u` 现取,**不硬编码 501** —— 501 只是首个本地用户的常见值,
|
||||
/// 多用户机器上并不成立。
|
||||
fn current_uid() -> Option<String> {
|
||||
let out = Command::new("id").arg("-u").output().ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
if s.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(s)
|
||||
}
|
||||
}
|
||||
|
||||
/// macOS:加载 / 卸载 LaunchAgent。
|
||||
///
|
||||
/// 新版 macOS 推荐 `launchctl bootstrap gui/<uid>`,旧版用 `load -w`;两条都试,
|
||||
/// 任一路径成功即算成功(不同 macOS 版本上可用的那条不一样)。
|
||||
fn macos_launchctl(plist: &Path, enable: bool) -> Result<String, String> {
|
||||
let mut notes: Vec<String> = Vec::new();
|
||||
let uid = current_uid();
|
||||
let domain = uid.map(|u| format!("gui/{u}"));
|
||||
|
||||
let attempts: Vec<Vec<String>> = if enable {
|
||||
let mut v = vec![vec!["load".into(), "-w".into(), plist.to_string_lossy().to_string()]];
|
||||
if let Some(d) = &domain {
|
||||
v.push(vec!["bootstrap".into(), d.clone(), plist.to_string_lossy().to_string()]);
|
||||
}
|
||||
v
|
||||
} else {
|
||||
let mut v = vec![vec!["unload".into(), "-w".into(), plist.to_string_lossy().to_string()]];
|
||||
if let Some(d) = &domain {
|
||||
v.push(vec!["bootout".into(), format!("{d}/{LABEL}")]);
|
||||
}
|
||||
v
|
||||
};
|
||||
|
||||
for args in attempts {
|
||||
match Command::new("launchctl").args(&args).output() {
|
||||
Ok(o) if o.status.success() => return Ok(format!("launchctl {} 成功", args[0])),
|
||||
Ok(o) => notes.push(format!(
|
||||
"launchctl {} 失败:{}",
|
||||
args[0],
|
||||
String::from_utf8_lossy(&o.stderr).trim()
|
||||
)),
|
||||
Err(e) => notes.push(format!("launchctl {} 无法执行:{e}", args[0])),
|
||||
}
|
||||
}
|
||||
if enable {
|
||||
Err(notes.join(";"))
|
||||
} else {
|
||||
// 卸载失败不致命:plist 已删,下次登录不会再起
|
||||
Ok(format!("(卸载命令未全部成功,但配置已移除){}", notes.join(";")))
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows:注册 / 注销计划任务。
|
||||
///
|
||||
/// 用 PowerShell 的 `New-ScheduledTask*` + `Register-ScheduledTask` 而非 `schtasks /TR` ——
|
||||
/// 后者要拼一层引号转义(路径含空格时尤其容易出错),前者用参数对象,无转义问题。
|
||||
fn windows_schtasks(exe: &Path, enable: bool) -> Result<String, String> {
|
||||
if enable {
|
||||
let script = format!(
|
||||
r#"$ErrorActionPreference='Stop'
|
||||
$exe = '{exe}'
|
||||
$action = New-ScheduledTaskAction -Execute $exe -Argument '{AGENT_ONLY_FLAG}'
|
||||
$trigger = New-ScheduledTaskTrigger -AtLogOn
|
||||
$user = "$env:USERDOMAIN\$env:USERNAME"
|
||||
$principal = New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Highest
|
||||
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit ([TimeSpan]::Zero)
|
||||
Register-ScheduledTask -TaskName '{LABEL}' -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null
|
||||
Write-Output ('OK ' + $user)"#,
|
||||
exe = exe.display().to_string().replace('\'', "''"),
|
||||
);
|
||||
run_powershell(&script)
|
||||
} else {
|
||||
let script = format!(
|
||||
r#"$ErrorActionPreference='Stop'
|
||||
Unregister-ScheduledTask -TaskName '{LABEL}' -Confirm:$false
|
||||
Write-Output 'OK removed'"#
|
||||
);
|
||||
run_powershell(&script)
|
||||
}
|
||||
}
|
||||
|
||||
fn run_powershell(script: &str) -> Result<String, String> {
|
||||
let out = Command::new("powershell")
|
||||
.args(["-NoProfile", "-NonInteractive", "-Command", script])
|
||||
.output()
|
||||
.map_err(|e| format!("无法执行 PowerShell:{e}"))?;
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||
if out.status.success() {
|
||||
Ok(stdout)
|
||||
} else {
|
||||
Err(if stderr.is_empty() { stdout } else { stderr })
|
||||
}
|
||||
}
|
||||
|
||||
/// 自启是否已启用。
|
||||
///
|
||||
/// ## 为什么要缓存(2026-10-17 真机)
|
||||
/// Windows 上 `schtasks /Query` 实测**约 2 秒**(`Get-ScheduledTask` 同样 2s),
|
||||
/// 而这个函数每次 `agent_status` 都会被调用 —— 于是每刷新一次界面就白等 2 秒,
|
||||
/// 叠加多次刷新后表现为「按钮转圈不结束、PID 不再更新」。
|
||||
///
|
||||
/// 自启开关是低频状态(用户手动切换才会变),故缓存 5 秒即可:
|
||||
/// 切换自启后紧接着的那次 `status()` 可能读到旧值,但 `set_autostart` 本身
|
||||
/// 会把结果写回缓存(见 `invalidate_cache`),界面不会看到错的状态。
|
||||
fn is_enabled_uncached() -> bool {
|
||||
if cfg!(target_os = "macos") {
|
||||
macos_plist_path().map(|p| p.exists()).unwrap_or(false)
|
||||
} else if cfg!(windows) {
|
||||
Command::new("schtasks")
|
||||
.args(["/Query", "/TN", LABEL])
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// 自启状态缓存:`(取值时刻 ms, 值)`。0 表示从未查过。
|
||||
static AUTOSTART_CACHE: std::sync::Mutex<(u64, bool)> = std::sync::Mutex::new((0, false));
|
||||
const AUTOSTART_TTL_MS: u64 = 5_000;
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn is_enabled() -> bool {
|
||||
{
|
||||
let c = AUTOSTART_CACHE.lock().unwrap();
|
||||
if c.0 != 0 && now_ms().saturating_sub(c.0) < AUTOSTART_TTL_MS {
|
||||
return c.1;
|
||||
}
|
||||
}
|
||||
let v = is_enabled_uncached();
|
||||
*AUTOSTART_CACHE.lock().unwrap() = (now_ms(), v);
|
||||
v
|
||||
}
|
||||
|
||||
/// 切换自启后立刻让缓存失效 —— 否则用户点完开关,界面会显示 5 秒前的旧值。
|
||||
pub fn invalidate_cache() {
|
||||
*AUTOSTART_CACHE.lock().unwrap() = (0, false);
|
||||
}
|
||||
|
||||
/// 读取自启状态的完整快照(供界面展示)。
|
||||
pub fn status() -> Value {
|
||||
let exe = app_exe();
|
||||
let supported = cfg!(any(target_os = "macos", windows));
|
||||
let where_ = if cfg!(target_os = "macos") {
|
||||
macos_plist_path().map(|p| p.to_string_lossy().to_string()).unwrap_or_default()
|
||||
} else if cfg!(windows) {
|
||||
format!("计划任务 {LABEL}")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
json!({
|
||||
"enabled": is_enabled(),
|
||||
"supported": supported,
|
||||
"location": where_,
|
||||
"app_exe": exe.map(|p| p.to_string_lossy().to_string()).unwrap_or_default(),
|
||||
"mode": AGENT_ONLY_FLAG,
|
||||
"note": if supported {
|
||||
"开机自启将以「仅服务模式」拉起本控制台:不显示窗口、直接进托盘并托管本机被控端。"
|
||||
} else {
|
||||
"当前平台暂不支持——请在系统设置里自行添加登录项。"
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// 开启 / 关闭开机自启。
|
||||
pub fn set(enabled: bool) -> Result<Value, String> {
|
||||
if !cfg!(any(target_os = "macos", windows)) {
|
||||
return Err("当前平台暂不支持自动配置开机自启".to_string());
|
||||
}
|
||||
let exe = app_exe()?;
|
||||
|
||||
let detail = if cfg!(target_os = "macos") {
|
||||
let plist = macos_plist_path()?;
|
||||
if enabled {
|
||||
if let Some(dir) = plist.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| format!("创建 LaunchAgents 目录失败:{e}"))?;
|
||||
}
|
||||
std::fs::write(&plist, macos_plist(&exe)).map_err(|e| format!("写入 plist 失败:{e}"))?;
|
||||
macos_launchctl(&plist, true)?
|
||||
} else {
|
||||
if plist.exists() {
|
||||
std::fs::remove_file(&plist).map_err(|e| format!("删除 plist 失败:{e}"))?;
|
||||
}
|
||||
macos_launchctl(&plist, false)?
|
||||
}
|
||||
} else {
|
||||
windows_schtasks(&exe, enabled)?
|
||||
};
|
||||
|
||||
// 刚改过自启状态,缓存必然过期 —— 不清就会把 5 秒前的旧值回给界面
|
||||
invalidate_cache();
|
||||
|
||||
let mut snap = status();
|
||||
snap["detail"] = json!(detail);
|
||||
Ok(snap)
|
||||
}
|
||||
@@ -4,18 +4,46 @@
|
||||
//! 本层只做**进程管理 + 消息转发**,真正的协议实现只有 TS 一份(`@nodeagent/client`),
|
||||
//! 由 `sidecar.rs` 拉起的 Node 进程承载。原先「Rust 版 client 与 TS 版并存」的漂移风险就此消除。
|
||||
//!
|
||||
//! command 面(**签名与语义保持不变,前端无需改动**):
|
||||
//! `agent.rs` 是同一思路的延伸:本机**被控端**也由本层托管(启动 / 停止 / 重启 / 日志 /
|
||||
//! 崩溃自愈),用户在界面上点按钮即可,全程不必碰命令行。
|
||||
//!
|
||||
//! ## command 面
|
||||
//! 连接类(签名与语义从未变过):
|
||||
//! - `connect` / `disconnect` / `invoke_capability` / `get_capabilities` / `get_state`
|
||||
//!
|
||||
//! Tauri 事件面(名字不变):`na-state` / `na-log` / `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::sync::Mutex;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
|
||||
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
|
||||
use tauri::{AppHandle, Emitter, Manager, State};
|
||||
|
||||
use agent::{AgentEvent, AgentManager};
|
||||
use sidecar::{Sidecar, SidecarEvent};
|
||||
|
||||
/// 全局状态。
|
||||
@@ -23,10 +51,32 @@ use sidecar::{Sidecar, SidecarEvent};
|
||||
/// `connect` 成功后缓存「能力清单 / 版本 / 连接态」——这样 `get_capabilities`、
|
||||
/// `get_state` 能保持**同步**(无需每次往返 sidecar),签名与改造前一致。
|
||||
struct AppState {
|
||||
sidecar: tokio::sync::Mutex<Option<std::sync::Arc<Sidecar>>>,
|
||||
sidecar: tokio::sync::Mutex<Option<Arc<Sidecar>>>,
|
||||
capabilities: Mutex<Value>,
|
||||
agent_version: Mutex<Option<String>>,
|
||||
conn_state: Mutex<String>,
|
||||
/// 本机被控端管理器(在 `setup` 里建,因为需要 AppHandle 来桥接事件)
|
||||
agent: Mutex<Option<Arc<AgentManager>>>,
|
||||
settings: Mutex<settings::Settings>,
|
||||
/// 控制台自身配置目录(设置存这里)
|
||||
config_dir: PathBuf,
|
||||
/// 控制台版本(由 `package_info` 注入,用于更新比对;不在代码里写死)
|
||||
app_version: String,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
fn agent(&self) -> Result<Arc<AgentManager>, String> {
|
||||
self.agent
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.map(Arc::clone)
|
||||
.ok_or_else(|| "被控端管理器尚未初始化".to_string())
|
||||
}
|
||||
|
||||
fn settings(&self) -> settings::Settings {
|
||||
self.settings.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// 连接成功后回传给前端的元信息(字段名与改造前一致)。
|
||||
@@ -40,22 +90,41 @@ struct ConnectInfo {
|
||||
client_id: String,
|
||||
}
|
||||
|
||||
impl Default for AppState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
sidecar: tokio::sync::Mutex::new(None),
|
||||
capabilities: Mutex::new(Value::Array(vec![])),
|
||||
agent_version: Mutex::new(None),
|
||||
conn_state: Mutex::new("未连接".to_string()),
|
||||
}
|
||||
}
|
||||
/// 本机被控端的三条路径。
|
||||
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<std::sync::Arc<Sidecar>, String> {
|
||||
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(std::sync::Arc::clone(sc));
|
||||
if sc.is_alive() {
|
||||
return Ok(Arc::clone(sc));
|
||||
}
|
||||
// ⚠️ 缓存里那个 sidecar **已经退出**,绝不能继续复用。
|
||||
//
|
||||
// 为什么以前没发现:往已退出进程的管道里写,在 Windows 上未必立刻报错
|
||||
// (管道缓冲区吞下就返回成功),于是请求会一路干等到 `request` 的超时。
|
||||
// 真机实证(2026-10-13,Windows 11 ARM64):控制台从 10-12 起一直开着,
|
||||
// 其 sidecar 早已退出;用户点「检查更新」等满 45 秒,界面报
|
||||
// 「检查更新失败:check_update 超时」—— 而同一台机器上**手工拉起 sidecar
|
||||
// 跑同一个请求只要 7 秒**(两相对照即锁定「复用死实例」)。
|
||||
// 丢掉缓存往下走,重新拉起一个。
|
||||
eprintln!("[sidecar] 检测到已退出的实例,正在重建");
|
||||
*guard = None;
|
||||
}
|
||||
|
||||
// 打包后 node 与 sidecar.mjs 随 resource 分发;开发期用系统 node + 仓库内脚本
|
||||
@@ -65,7 +134,7 @@ async fn ensure_sidecar(app: &AppHandle, state: &AppState) -> Result<std::sync::
|
||||
|
||||
// 事件桥接:sidecar 通知 → Tauri event(名字与改造前一致)
|
||||
let app_events = app.clone();
|
||||
let on_event = std::sync::Arc::new(move |evt: SidecarEvent| match evt {
|
||||
let on_event = Arc::new(move |evt: SidecarEvent| match evt {
|
||||
SidecarEvent::State(s) => {
|
||||
let _ = app_events.emit("na-state", s);
|
||||
}
|
||||
@@ -81,12 +150,15 @@ async fn ensure_sidecar(app: &AppHandle, state: &AppState) -> Result<std::sync::
|
||||
});
|
||||
|
||||
let sc = Sidecar::spawn(&node, &script, on_event)?;
|
||||
// 连接态由事件驱动:先置「已就绪」,真正连接后会被 connected 覆盖
|
||||
*state.conn_state.lock().unwrap() = "未连接".to_string();
|
||||
*guard = Some(std::sync::Arc::clone(&sc));
|
||||
*guard = Some(Arc::clone(&sc));
|
||||
Ok(sc)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 连接类 command(签名与语义保持不变)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 建立到被控端的连接并完成握手(默认 psk 模式、接受自签证书、开启自动重连)。
|
||||
#[tauri::command]
|
||||
async fn connect(
|
||||
@@ -113,7 +185,6 @@ async fn connect(
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 缓存快照,供同步 command 使用
|
||||
let caps = snap.get("capabilities").cloned().unwrap_or_else(|| json!([]));
|
||||
let ver = snap.get("agent_version").and_then(Value::as_str).map(str::to_string);
|
||||
*state.capabilities.lock().unwrap() = caps.clone();
|
||||
@@ -137,7 +208,7 @@ async fn connect(
|
||||
/// 断开连接(停止自动重连)。
|
||||
#[tauri::command]
|
||||
async fn disconnect(state: State<'_, AppState>) -> Result<(), String> {
|
||||
let sc = { state.sidecar.lock().await.as_ref().map(std::sync::Arc::clone) };
|
||||
let sc = { state.sidecar.lock().await.as_ref().map(Arc::clone) };
|
||||
if let Some(sc) = sc {
|
||||
sc.request("disconnect", json!({}), 10).await?;
|
||||
}
|
||||
@@ -160,7 +231,7 @@ async fn invoke_capability(
|
||||
.lock()
|
||||
.await
|
||||
.as_ref()
|
||||
.map(std::sync::Arc::clone)
|
||||
.map(Arc::clone)
|
||||
.ok_or_else(|| "未连接".to_string())?
|
||||
};
|
||||
let mut params = json!({ "capability": capability, "args": args.unwrap_or_else(|| json!({})) });
|
||||
@@ -184,17 +255,561 @@ fn get_state(state: State<'_, AppState>) -> String {
|
||||
state.conn_state.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 本机服务(被控端)command
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 本机服务总览:进程状态 + 配置 + 自启 + 设置 + 版本。
|
||||
#[tauri::command]
|
||||
async fn agent_status(app: AppHandle, state: State<'_, AppState>) -> Result<Value, String> {
|
||||
// 管理器可能尚未建(极早的调用);此时给一个纯配置视图,而不是报错
|
||||
let mgr = state.agent.lock().unwrap().clone();
|
||||
let snap = match &mgr {
|
||||
Some(m) => m.snapshot().await,
|
||||
None => json!({ "state": agent::state::STOPPED }),
|
||||
};
|
||||
|
||||
// 数据目录以「实际使用的」为准;管理器还没跑过就用默认值
|
||||
let data_dir = snap
|
||||
.get("data_dir")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(agent::agent_data_dir);
|
||||
let cfg = agent::read_agent_config(&data_dir);
|
||||
|
||||
let resource_dir = app.path().resource_dir().ok();
|
||||
Ok(json!({
|
||||
"manager": snap,
|
||||
"config": cfg,
|
||||
"autostart": autostart::status(),
|
||||
"settings": settings::to_json(&state.settings()),
|
||||
"app_version": state.app_version,
|
||||
"config_dir": state.config_dir.to_string_lossy(),
|
||||
"installed_agent_version": agent::installed_agent_version(&data_dir),
|
||||
"bundled_agent": agent::resolve_bundled_agent(resource_dir.as_deref())
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_default(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// 启动本机被控端。`auto_restart` 缺省时取设置里的值。
|
||||
#[tauri::command]
|
||||
async fn agent_start(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
auto_restart: Option<bool>,
|
||||
) -> Result<Value, String> {
|
||||
let paths = resolve_agent_paths(&app, &state)?;
|
||||
let ar = auto_restart.unwrap_or_else(|| state.settings().auto_restart);
|
||||
state
|
||||
.agent()?
|
||||
.start(&paths.node, &paths.script, &paths.data_dir, ar)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 停止本机被控端(幂等)。
|
||||
#[tauri::command]
|
||||
async fn agent_stop(state: State<'_, AppState>) -> Result<Value, String> {
|
||||
state.agent()?.stop().await
|
||||
}
|
||||
|
||||
/// 退出控制台 —— 由前端在用户确认后调用。
|
||||
///
|
||||
/// `keep_agent`:
|
||||
/// - `true` 保留被控端继续运行(它将成为孤儿进程;下次启动会被「复用」逻辑接管)
|
||||
/// - `false` 先停掉被控端再退出(退出后不留任何后台进程)
|
||||
///
|
||||
/// 为什么不直接在托盘菜单里 exit:退出是有**副作用**的决定(留不留下一个
|
||||
/// 正在监听端口的后台进程),必须让用户明确选择,而不是替用户猜。
|
||||
#[tauri::command]
|
||||
async fn quit_app(app: AppHandle, state: State<'_, AppState>, keep_agent: bool) -> Result<(), String> {
|
||||
if !keep_agent {
|
||||
// 尽力而为:停不掉也要让用户能退出,否则控制台会卡在确认框后面出不来
|
||||
if let Ok(mgr) = state.agent() {
|
||||
let _ = mgr.stop().await;
|
||||
}
|
||||
}
|
||||
app.exit(0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 重启本机被控端。
|
||||
#[tauri::command]
|
||||
async fn agent_restart(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
auto_restart: Option<bool>,
|
||||
) -> Result<Value, String> {
|
||||
let paths = resolve_agent_paths(&app, &state)?;
|
||||
let ar = auto_restart.unwrap_or_else(|| state.settings().auto_restart);
|
||||
state
|
||||
.agent()?
|
||||
.restart(&paths.node, &paths.script, &paths.data_dir, ar)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 取本机服务日志(增量:传上次拿到的最大 `seq`)。
|
||||
#[tauri::command]
|
||||
fn agent_logs(state: State<'_, AppState>, since: Option<u64>) -> Result<Value, String> {
|
||||
Ok(state.agent()?.logs_since(since.unwrap_or(0)))
|
||||
}
|
||||
|
||||
/// 清空日志缓冲。
|
||||
#[tauri::command]
|
||||
fn agent_logs_clear(state: State<'_, AppState>) -> Result<(), String> {
|
||||
state.agent()?.clear_logs();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 开机自启状态。
|
||||
#[tauri::command]
|
||||
fn autostart_status() -> Value {
|
||||
autostart::status()
|
||||
}
|
||||
|
||||
/// 开关开机自启。
|
||||
#[tauri::command]
|
||||
fn autostart_set(enabled: bool) -> Result<Value, String> {
|
||||
autostart::set(enabled)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 更新
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 检查更新:比对最新 Release 与本控制台版本,并附上本机被控端已安装版本。
|
||||
#[tauri::command]
|
||||
async fn check_update(app: AppHandle, state: State<'_, AppState>) -> Result<Value, String> {
|
||||
let sc = ensure_sidecar(&app, &state).await?;
|
||||
// 超时**成对**下发:内层(sidecar 出网)必须小于外层(RPC),
|
||||
// 否则用户只会看到「check_update 超时」这种丢掉真实原因的假失败。
|
||||
// 见 sidecar.rs 的 UPDATE_CHECK_TIMEOUT_MS / UPDATE_CHECK_RPC_TIMEOUT_SECS。
|
||||
let mut snap = sc
|
||||
.request(
|
||||
"check_update",
|
||||
json!({
|
||||
"console_version": state.app_version,
|
||||
"timeout_ms": sidecar::UPDATE_CHECK_TIMEOUT_MS,
|
||||
}),
|
||||
sidecar::UPDATE_CHECK_RPC_TIMEOUT_SECS,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 本机被控端的版本(数据目录里那份),供界面区分「应用要更新」与「被控端要更新」
|
||||
let data_dir = agent::agent_data_dir();
|
||||
let installed_version = agent::installed_agent_version(&data_dir).unwrap_or_default();
|
||||
let latest = snap
|
||||
.get("latest_version")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let agent_has_update =
|
||||
!installed_version.is_empty() && agent::cmp_version(&latest, &installed_version) > 0;
|
||||
snap["installed_agent_version"] = json!(installed_version);
|
||||
snap["agent_has_update"] = json!(agent_has_update);
|
||||
Ok(snap)
|
||||
}
|
||||
|
||||
/// 应用更新:下载 → 校验 → 替换本机被控端脚本 → 重启。
|
||||
#[tauri::command]
|
||||
async fn apply_update(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
url: String,
|
||||
sha256: String,
|
||||
version: String,
|
||||
) -> Result<Value, String> {
|
||||
let sc = ensure_sidecar(&app, &state).await?;
|
||||
let paths = resolve_agent_paths(&app, &state)?;
|
||||
let mgr = state.agent()?;
|
||||
|
||||
// ① 下载到暂存文件(**校验通过才落盘**;不直接覆盖在跑的脚本)
|
||||
let staged = paths
|
||||
.data_dir
|
||||
.join("runtime")
|
||||
.join(format!("agent.mjs.staged-{}", std::process::id()));
|
||||
let dl = sc
|
||||
.request(
|
||||
"download",
|
||||
json!({
|
||||
"url": url,
|
||||
"sha256": sha256,
|
||||
"dest": staged.to_string_lossy(),
|
||||
"timeout_ms": sidecar::UPDATE_DOWNLOAD_TIMEOUT_MS,
|
||||
}),
|
||||
sidecar::UPDATE_DOWNLOAD_RPC_TIMEOUT_SECS,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// ② 停被控端(Windows 上正被 node 打开的文件不能重命名/替换)
|
||||
let was_running = mgr.snapshot().await["state"] == json!(agent::state::RUNNING);
|
||||
if was_running {
|
||||
mgr.stop().await?;
|
||||
}
|
||||
|
||||
// ③ 原子替换:先改名旧文件做备份,再落新文件
|
||||
let backup = paths.script.with_extension(format!("mjs.bak-{}", now_secs()));
|
||||
let replace = (|| -> Result<(), String> {
|
||||
if paths.script.exists() {
|
||||
std::fs::rename(&paths.script, &backup)
|
||||
.map_err(|e| format!("备份现有脚本失败:{e}"))?;
|
||||
}
|
||||
std::fs::rename(&staged, &paths.script).map_err(|e| {
|
||||
// 失败则回滚
|
||||
let _ = std::fs::rename(&backup, &paths.script);
|
||||
format!("替换脚本失败(已回滚):{e}")
|
||||
})
|
||||
})();
|
||||
if let Err(e) = replace {
|
||||
let _ = std::fs::remove_file(&staged);
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
// ④ 记版本:这是「控制台启动时不要把已更新的被控端降级」的依据
|
||||
agent::mark_installed_version(&paths.data_dir, &version, "console")?;
|
||||
mgr.push_log(
|
||||
"system",
|
||||
format!("已更新本机被控端到 v{version}(备份:{})", backup.display()),
|
||||
);
|
||||
|
||||
// ⑤ 按需重新拉起
|
||||
let mut restarted = false;
|
||||
if was_running || state.settings().auto_start_agent {
|
||||
match mgr
|
||||
.start(&paths.node, &paths.script, &paths.data_dir, state.settings().auto_restart)
|
||||
.await
|
||||
{
|
||||
Ok(_) => restarted = true,
|
||||
Err(e) => mgr.push_log("system", format!("更新后启动失败:{e}")),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"updated": true,
|
||||
"version": version,
|
||||
"bytes": dl.get("bytes").cloned().unwrap_or(json!(0)),
|
||||
"sha256": dl.get("sha256").cloned().unwrap_or(json!("")),
|
||||
"backup": backup.to_string_lossy(),
|
||||
"restarted": restarted,
|
||||
}))
|
||||
}
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 设置与系统信息
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 读设置。
|
||||
#[tauri::command]
|
||||
fn settings_get(state: State<'_, AppState>) -> Value {
|
||||
settings::to_json(&state.settings())
|
||||
}
|
||||
|
||||
/// 合并式写设置(只传要改的字段)。
|
||||
#[tauri::command]
|
||||
fn settings_set(state: State<'_, AppState>, patch: Value) -> Result<Value, String> {
|
||||
let next = settings::merge(&state.config_dir, &patch)?;
|
||||
*state.settings.lock().unwrap() = next.clone();
|
||||
Ok(settings::to_json(&next))
|
||||
}
|
||||
|
||||
/// 控制台自身信息(版本、数据目录、平台),供「关于」与故障上报使用。
|
||||
#[tauri::command]
|
||||
fn system_info(app: AppHandle, state: State<'_, AppState>) -> Value {
|
||||
let resource_dir = app.path().resource_dir().ok();
|
||||
// 先取 node 路径,再把 resource_dir 消费成字符串 —— 顺序反了会「借用已移动的值」
|
||||
let node = sidecar::resolve_node(resource_dir.as_deref())
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
json!({
|
||||
"app_version": state.app_version,
|
||||
"config_dir": state.config_dir.to_string_lossy(),
|
||||
"agent_data_dir": agent::agent_data_dir().to_string_lossy(),
|
||||
"resource_dir": resource_dir.map(|p| p.to_string_lossy().to_string()).unwrap_or_default(),
|
||||
"platform": std::env::consts::OS,
|
||||
"arch": std::env::consts::ARCH,
|
||||
"node": node,
|
||||
"agent_only": std::env::args().any(|a| a == autostart::AGENT_ONLY_FLAG),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 托盘与启动模式
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 显示主窗口(托盘菜单与第二次启动都用它)。
|
||||
fn show_main(app: &AppHandle) {
|
||||
if let Some(w) = app.get_webview_window("main") {
|
||||
let _ = w.show();
|
||||
let _ = w.unminimize();
|
||||
let _ = w.set_focus();
|
||||
}
|
||||
}
|
||||
|
||||
/// 在后台拉起被控端(忽略错误 —— 失败详情在日志里)。
|
||||
fn spawn_start_agent(app: &AppHandle, auto_restart: bool) {
|
||||
let app2 = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let state = app2.state::<AppState>();
|
||||
let mgr = match state.agent() {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
eprintln!("[console] 被控端管理器不可用:{e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if mgr.snapshot().await["state"] == json!(agent::state::RUNNING) {
|
||||
return;
|
||||
}
|
||||
match resolve_agent_paths(&app2, &state) {
|
||||
Ok(p) => {
|
||||
if let Err(e) = mgr.start(&p.node, &p.script, &p.data_dir, auto_restart).await {
|
||||
mgr.push_log("system", format!("自动启动失败:{e}"));
|
||||
}
|
||||
}
|
||||
Err(e) => mgr.push_log("system", format!("自动启动失败(路径解析):{e}")),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn build_tray(app: &AppHandle) -> tauri::Result<()> {
|
||||
let show = MenuItem::with_id(app, "show", "显示主窗口", true, None::<&str>)?;
|
||||
let start = MenuItem::with_id(app, "agent_start", "启动本机被控端", true, None::<&str>)?;
|
||||
let stop = MenuItem::with_id(app, "agent_stop", "停止本机被控端", true, None::<&str>)?;
|
||||
let restart = MenuItem::with_id(app, "agent_restart", "重启本机被控端", true, None::<&str>)?;
|
||||
let sep1 = PredefinedMenuItem::separator(app)?;
|
||||
let sep2 = PredefinedMenuItem::separator(app)?;
|
||||
let quit = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?;
|
||||
|
||||
let menu = Menu::with_items(app, &[&show, &sep1, &start, &stop, &restart, &sep2, &quit])?;
|
||||
|
||||
let mut builder = TrayIconBuilder::with_id("na-tray")
|
||||
.menu(&menu)
|
||||
.show_menu_on_left_click(false)
|
||||
.tooltip("nodeagent 控制台")
|
||||
.on_menu_event(|app, event| {
|
||||
let id = event.id().as_ref().to_string();
|
||||
match id.as_str() {
|
||||
"show" => show_main(app),
|
||||
"agent_start" => {
|
||||
let ar = app.state::<AppState>().settings().auto_restart;
|
||||
spawn_start_agent(app, ar);
|
||||
}
|
||||
"agent_stop" => {
|
||||
let app2 = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let state = app2.state::<AppState>();
|
||||
if let Ok(mgr) = state.agent() {
|
||||
let _ = mgr.stop().await;
|
||||
}
|
||||
});
|
||||
}
|
||||
"agent_restart" => {
|
||||
let app2 = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let state = app2.state::<AppState>();
|
||||
if let Ok(mgr) = state.agent() {
|
||||
if let Ok(p) = resolve_agent_paths(&app2, &state) {
|
||||
let ar = state.settings().auto_restart;
|
||||
let _ = mgr.restart(&p.node, &p.script, &p.data_dir, ar).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// 2026-10-17「退出前确认」需求:
|
||||
// 原实现直接 `app.exit(0)`,而 `kill_on_drop(false)` 会让被控端**成为孤儿
|
||||
// 继续监听** —— 用户以为关了控制台就干净了,实际还有个看不见的进程在跑。
|
||||
//
|
||||
// 现在改为**先问一句**:发事件给前端,由它弹三选一
|
||||
// (保留被控端退出 / 停止被控端退出 / 取消),再回调 `quit_app`。
|
||||
// 之所以走前端而不是加 tauri-plugin-dialog:前端已有 n-dialog-provider,
|
||||
// 不加依赖、风格统一,且能复用既有的 message 视觉。
|
||||
"quit" => {
|
||||
let _ = tauri::Emitter::emit(app, "na-quit-requested", ());
|
||||
// 把窗口唤出来,否则用户在最小化状态下看不到确认框
|
||||
if let Some(w) = tauri::Manager::get_webview_window(app, "main") {
|
||||
let _ = w.show();
|
||||
let _ = w.set_focus();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
// 左键单击托盘图标 → 唤出主窗口(符合习惯)
|
||||
if let TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
show_main(tray.app_handle());
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(icon) = app.default_window_icon() {
|
||||
builder = builder.icon(icon.clone());
|
||||
}
|
||||
builder.build(app)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 单实例
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// ## 为什么必须有(真机缺陷,2026-10-13)
|
||||
// 控制台此前**完全没有单实例保护**。开机自启会拉起一个 `--agent-only` 实例(无窗口、驻托盘),
|
||||
// 用户再手动双击图标 → **第二个进程**。而「本机被控端」是**进程内状态**
|
||||
// (`AgentManager` 各持自己的 `child: Option<Child>` / `pid`),两个进程互相看不见 →
|
||||
// 同一台机器上跑起**两个被控端**,抢同一个 8765 端口。
|
||||
//
|
||||
// 后果按先后顺序分两种,正是用户报的两个症状:
|
||||
// - **A(两个被控端)**:两边都抢到了不同时机 → 同机并存两个被控端进程;
|
||||
// - **B(后开的起不来)**:先占住端口的那一侧赢了,后启动的一侧在 `AgentManager::start`
|
||||
// 的端口检查处失败,界面表现为「应用端无法启动」(本机服务起不来)。
|
||||
//
|
||||
// ## 为什么不能简单「已有实例就退出」
|
||||
// 自启实例是**无窗口**的(`--agent-only`)。若第二次启动只是静默退出,用户双击图标会
|
||||
// **什么都不发生** —— 比缺陷本身更难排查。正确语义是:
|
||||
// 第二次启动 → 把「用户想看到界面」这个意图**交给已有实例**(让它显示并聚焦窗口)→ 新进程再退出。
|
||||
//
|
||||
// `tauri-plugin-single-instance` 正好是这个语义:回调在**已有实例**里执行,新进程由插件负责收尾退出。
|
||||
// 它必须注册为**第一个**插件,才能在其他插件与本项目的 `setup`(会拉起被控端)之前完成抢占判定。
|
||||
|
||||
/// 第二次启动时执行(在**已有实例**的进程里)。
|
||||
fn on_second_instance(app: &AppHandle, argv: Vec<String>) {
|
||||
// 第二次启动若自己就带 `--agent-only`(例如自启被重复触发),**不要弹窗** ——
|
||||
// 那正是「仅服务模式」要避免的行为。只提示已有实例,不做别的。
|
||||
if argv.iter().any(|a| a == autostart::AGENT_ONLY_FLAG) {
|
||||
return;
|
||||
}
|
||||
// 已有实例可能是「自启的隐藏实例」或「窗口已关到托盘」→ 两种都要能唤出来。
|
||||
show_main(app);
|
||||
// 顺带通知前端刷一次状态(第二次启动往往发生在「用户发现没反应、想确认一下」的时刻)。
|
||||
// 前端没监听也无害。
|
||||
let _ = app.emit("na-second-instance", json!({ "argv": argv }));
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let agent_only = std::env::args().any(|a| a == autostart::AGENT_ONLY_FLAG);
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| {
|
||||
on_second_instance(app, argv);
|
||||
}))
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.manage(AppState::default())
|
||||
.setup(move |app| {
|
||||
let handle = app.handle().clone();
|
||||
let config_dir = handle
|
||||
.path()
|
||||
.app_config_dir()
|
||||
.unwrap_or_else(|_| PathBuf::from("."));
|
||||
let app_version = handle.package_info().version.to_string();
|
||||
let loaded = settings::load(&config_dir);
|
||||
|
||||
// 事件桥接:被控端管理器 → Tauri event
|
||||
let app_events = handle.clone();
|
||||
let emit: agent::AgentEmit = Arc::new(move |evt: AgentEvent| match evt {
|
||||
AgentEvent::Log(l) => {
|
||||
let _ = app_events.emit("na-agent-log", l);
|
||||
}
|
||||
AgentEvent::State(s) => {
|
||||
let _ = app_events.emit("na-agent-state", s);
|
||||
}
|
||||
});
|
||||
|
||||
let manager = AgentManager::new(emit);
|
||||
manager.push_log(
|
||||
"system",
|
||||
format!(
|
||||
"控制台 v{app_version} 就绪({} / {})",
|
||||
std::env::consts::OS,
|
||||
std::env::consts::ARCH
|
||||
),
|
||||
);
|
||||
|
||||
app.manage(AppState {
|
||||
sidecar: tokio::sync::Mutex::new(None),
|
||||
capabilities: Mutex::new(json!([])),
|
||||
agent_version: Mutex::new(None),
|
||||
conn_state: Mutex::new("未连接".to_string()),
|
||||
agent: Mutex::new(Some(Arc::clone(&manager))),
|
||||
settings: Mutex::new(loaded.clone()),
|
||||
config_dir,
|
||||
app_version,
|
||||
});
|
||||
|
||||
build_tray(&handle)?;
|
||||
|
||||
// 仅服务模式:不显示窗口(开机自启时不该弹任何窗口出来)
|
||||
if agent_only {
|
||||
if let Some(w) = handle.get_webview_window("main") {
|
||||
let _ = w.hide();
|
||||
}
|
||||
manager.push_log("system", "以「仅服务模式」启动(无窗口,常驻托盘)");
|
||||
}
|
||||
|
||||
// 自动拉起被控端(默认开)——「运行应用之后就有服务」的期望
|
||||
if loaded.auto_start_agent {
|
||||
spawn_start_agent(&handle, loaded.auto_restart);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|window, event| {
|
||||
// 关窗 → 最小化到托盘(可在设置里关掉,改为直接退出)
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
let app = window.app_handle();
|
||||
let state = app.state::<AppState>();
|
||||
let close_to_tray = state.settings().close_to_tray;
|
||||
if close_to_tray {
|
||||
api.prevent_close();
|
||||
let _ = window.hide();
|
||||
if let Ok(mgr) = state.agent() {
|
||||
mgr.push_log("system", "窗口已隐藏到托盘(被控端继续运行;从托盘菜单可退出)");
|
||||
}
|
||||
} else {
|
||||
// 用户关掉了「最小化到托盘」→ 点 X 等于退出应用。
|
||||
// 这条路同样会留下孤儿被控端,故**也必须问一句**,
|
||||
// 否则「退出确认」只在托盘菜单生效,从 X 走就绕过去了。
|
||||
api.prevent_close();
|
||||
let _ = tauri::Emitter::emit(window.app_handle(), "na-quit-requested", ());
|
||||
}
|
||||
}
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// 连接
|
||||
connect,
|
||||
disconnect,
|
||||
invoke_capability,
|
||||
get_capabilities,
|
||||
get_state
|
||||
get_state,
|
||||
// 本机服务
|
||||
agent_status,
|
||||
agent_start,
|
||||
agent_stop,
|
||||
quit_app,
|
||||
agent_restart,
|
||||
agent_logs,
|
||||
agent_logs_clear,
|
||||
autostart_status,
|
||||
autostart_set,
|
||||
// 更新
|
||||
check_update,
|
||||
apply_update,
|
||||
// 设置与信息
|
||||
settings_get,
|
||||
settings_set,
|
||||
system_info,
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
@@ -204,7 +819,7 @@ pub fn run() {
|
||||
if let tauri::RunEvent::Exit = event {
|
||||
let state = app.state::<AppState>();
|
||||
let sc = tauri::async_runtime::block_on(async {
|
||||
state.sidecar.lock().await.as_ref().map(std::sync::Arc::clone)
|
||||
state.sidecar.lock().await.as_ref().map(Arc::clone)
|
||||
});
|
||||
if let Some(sc) = sc {
|
||||
tauri::async_runtime::block_on(async {
|
||||
|
||||
@@ -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!({}))
|
||||
}
|
||||
@@ -19,12 +19,186 @@ 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::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::process::{Child, ChildStdin, Command};
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 更新链路的超时口径(「检查更新 / 下载更新包」)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// 两层超时必须**成对**看:内层是 sidecar 自己的出网超时(下发给它),
|
||||
// 外层是本壳等 RPC 的超时。**内层必须小于外层**,否则本壳先放弃,
|
||||
// 用户只会看到「check_update 超时」——一条**丢掉真实原因**的假失败
|
||||
// (2026-10-13 真机排查就被这种假失败带偏过一轮)。
|
||||
//
|
||||
// 下面那条单测断言的就是这层关系:改一侧忘了另一侧,测试直接红。
|
||||
|
||||
/// 「检查更新」下发给 sidecar 的**出网超时**(毫秒)。
|
||||
///
|
||||
/// ## 为什么从 20s 抬到 120s(2026-10-13 实测)
|
||||
/// 取 `latest.json` 要**跟随两次重定向**(`releases/latest/download/…` → 版本化 URL →
|
||||
/// 资产 CDN)。整条链的耗时波动极大,真机连续多次实测:
|
||||
/// **2.0s / 10.0s / 39.7s / 68.6s / >109s**(同一台机器、同一个代理)。
|
||||
/// 原来的 20s 是「假定能直连 GitHub」时定的值 —— 对真实链路**必然**超时,
|
||||
/// 于是用户看到的失败原因永远是「超时」,而不是「到底为什么连不上」。
|
||||
///
|
||||
/// 为什么敢给到 2 分钟:这是**用户手动点**的动作,界面上按钮有 loading 态
|
||||
/// (`LocalServiceView.vue` 的 `:loading="busy"`),等待是可见的;
|
||||
/// 而「假失败」会让用户以为功能坏了。宁可慢,不要假失败。
|
||||
pub(crate) const UPDATE_CHECK_TIMEOUT_MS: u64 = 120_000;
|
||||
|
||||
/// `check_update` 的 RPC 超时(秒)。必须 > `UPDATE_CHECK_TIMEOUT_MS`(120s)。
|
||||
pub(crate) const UPDATE_CHECK_RPC_TIMEOUT_SECS: u64 = 150;
|
||||
|
||||
/// 「下载更新包」下发给 sidecar 的出网超时(毫秒)。被控端约 1MB,300s 足够。
|
||||
pub(crate) const UPDATE_DOWNLOAD_TIMEOUT_MS: u64 = 300_000;
|
||||
|
||||
/// `download` 的 RPC 超时(秒)。必须 > `UPDATE_DOWNLOAD_TIMEOUT_MS`(300s)。
|
||||
pub(crate) const UPDATE_DOWNLOAD_RPC_TIMEOUT_SECS: u64 = 330;
|
||||
|
||||
/// **编译期不变式**:内层出网超时必须小于外层 RPC 超时。
|
||||
///
|
||||
/// ## 为什么放在 `const _` 里而不是写成 `#[test]`
|
||||
/// ① 这层关系一旦破坏,症状是「用户看到假失败、且失败原因指向别处」——
|
||||
/// 让**构建**直接失败,比等某条单测被跑到更早、更不可能被绕过
|
||||
/// (CI 的 `cargo clippy` / `cargo build` 都是闸门,而单测可能被 filter 掉)。
|
||||
/// ② 两侧都是常量,写成断言函数会被 `clippy::assertions_on_constants` 拦下
|
||||
/// (`-D warnings` 下即失败)—— clippy 给的正是本写法。
|
||||
///
|
||||
/// 注入验证(2026-10-13):把 `UPDATE_CHECK_RPC_TIMEOUT_SECS` 改成 50、
|
||||
/// 或把 `UPDATE_CHECK_TIMEOUT_MS` 改回 20_000,**构建**即失败并打印下面的文案。
|
||||
const _: () = {
|
||||
assert!(
|
||||
UPDATE_CHECK_TIMEOUT_MS < UPDATE_CHECK_RPC_TIMEOUT_SECS * 1000,
|
||||
"check_update:出网超时必须小于 RPC 超时(否则用户只会看到「check_update 超时」这种丢掉真实原因的假失败)"
|
||||
);
|
||||
assert!(
|
||||
UPDATE_DOWNLOAD_TIMEOUT_MS < UPDATE_DOWNLOAD_RPC_TIMEOUT_SECS * 1000,
|
||||
"download:出网超时必须小于 RPC 超时"
|
||||
);
|
||||
// 60s 下限:真机上取 latest.json 的重定向链实测出现过 68.6s(三次为 2.0 / 10.0 / 68.6s),
|
||||
// 回落到「假定能直连」时代的 20s 必然超时。
|
||||
assert!(
|
||||
UPDATE_CHECK_TIMEOUT_MS >= 60_000,
|
||||
"检查更新的出网超时不得低于 60s"
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 路径净化:**交给外部进程**(node)的路径必须先去掉 Windows verbatim 前缀
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 去掉 Windows 的 verbatim 前缀(`\\?\` 与 `\\?\UNC\`)。
|
||||
///
|
||||
/// ## 为什么非做不可(2026-10-13 真机缺陷的根因)
|
||||
/// Tauri 的 `resource_dir()` 返回的是**规范化之后的 verbatim 路径**
|
||||
/// (`\\?\C:\Users\…\nodeagent-console`)。把它当**参数**交给 node 时,
|
||||
/// node 启动流程会在入口模块的 `realpathSync` 里把它拆成 `C:` 与剩余部分:
|
||||
///
|
||||
/// ```text
|
||||
/// Error: EISDIR: illegal operation on a directory, lstat 'C:'
|
||||
/// at Object.realpathSync (node:fs:27xx)
|
||||
/// ```
|
||||
///
|
||||
/// → **exit 1,且一行 JS 都来不及执行**(连 sidecar 自己的启动日志都写不出来)。
|
||||
///
|
||||
/// ## 真机 A/B —— 二维四格,精确到是「哪一维」出问题
|
||||
/// Windows 11 ARM64;同一个 `node.exe`、同一份最小脚本 `mini.mjs`;用
|
||||
/// `ProcessStartInfo` 下发,**精确对应 Rust `Command::new(prog).arg(script)` 的形态**:
|
||||
///
|
||||
/// | # | `program` | 脚本参数 | 结果 |
|
||||
/// |---|---|---|---|
|
||||
/// | G1 | `C:\…\node.exe` | `C:\…\mini.mjs` | exit 0,正常输出(**= 修复后的形态**) |
|
||||
/// | G2 | `C:\…\node.exe` | `\\?\C:\…\mini.mjs` | **exit 1**,`EISDIR … lstat 'C:'` |
|
||||
/// | G3 | `\\?\C:\…\node.exe` | `C:\…\mini.mjs` | exit 0,正常输出 |
|
||||
/// | G4 | `\\?\C:\…\node.exe` | `\\?\C:\…\mini.mjs` | **exit 1**,`EISDIR … lstat 'C:'` |
|
||||
///
|
||||
/// **结论**:炸的是「**脚本参数**带 verbatim」,**不是**「可执行文件带 verbatim」——
|
||||
/// G3 证明 `program` 位置带前缀 node 照常启动;真机上也有旁证:被控端与 sidecar 进程的
|
||||
/// `program` 都带 `\\?\`(见 `Get-CimInstance Win32_Process` 的 CommandLine)却长期存活。
|
||||
/// 因此 `sidecar_command` 里**净化 `script` 才是关键修复**,净化 `node` 属防御性
|
||||
/// (幂等、无副作用;且 `\\?\` 路径不适合出现在计划任务 `-Execute` 之类场景)。
|
||||
///
|
||||
/// 另有一次更贴近原症状的观测(同一台机器,直接用真 `sidecar.mjs`):
|
||||
/// 普通路径 **2.5 秒**返回 `check_update` 结果并常驻;
|
||||
/// `\\?\` 路径 **0.5 秒**即退出、exit 1、**零 JS 执行**(连自带启动日志都没写出来)。
|
||||
///
|
||||
/// 控制台侧前后两种表现都由这一条派生:
|
||||
/// - 修存活检测**之前**:请求写进已死进程的管道 → 干等到 RPC 超时(当时 45 秒) → 「check_update 超时」;
|
||||
/// - 修存活检测**之后**:stdout 立即 EOF → 「sidecar 已退出(stdout 关闭),请重试」。
|
||||
///
|
||||
/// ## 为什么单测 / CI / 开发模式全都测不出来
|
||||
/// 开发期 `resolve_sidecar` 走的是 `CARGO_MANIFEST_DIR` 拼出来的**普通路径**;
|
||||
/// 只有**打包之后**才会从 `resource_dir()` 拿到 verbatim 路径。
|
||||
/// 于是「本地全绿 + CI 全绿 + 真机必挂」。
|
||||
///
|
||||
/// 纯字符串处理,**任何平台都可安全调用**(非 Windows 上一般原样返回),
|
||||
/// 因此可以用 Windows 形态的字符串在 macOS / Linux 上直接验证 Windows 分支。
|
||||
///
|
||||
/// ## 为什么用 `to_str()` 而不是 `to_string_lossy()`
|
||||
/// 后者会把**非法字节替换成 U+FFFD** 再用替换后的串重建路径 —— 那是**损坏路径**,
|
||||
/// 比不净化更糟:原本只是 node 会报 EISDIR,改完会变成「指向一个不存在的目录」,
|
||||
/// 更难排查。故非 UTF-8 一律**原样返回**(Windows 上是含未配对代理项的路径;
|
||||
/// Unix 上是含非法字节的文件名 —— 两者都不该被我们的「净化」悄悄改写)。
|
||||
pub(crate) fn unverbatim(path: PathBuf) -> PathBuf {
|
||||
let fixed: Option<PathBuf> = path.to_str().and_then(|s| {
|
||||
let rest = s.strip_prefix(r"\\?\")?;
|
||||
Some(match rest.strip_prefix("UNC\\") {
|
||||
// \\?\UNC\server\share\x → \\server\share\x(UNC 必须还原成双反斜杠)
|
||||
Some(unc) => PathBuf::from(format!(r"\\{unc}")),
|
||||
// \\?\C:\x → C:\x
|
||||
None => PathBuf::from(rest),
|
||||
})
|
||||
});
|
||||
fixed.unwrap_or(path)
|
||||
}
|
||||
|
||||
/// 构造「把 sidecar 拉起来」的那条命令 —— **唯一**把路径交给 node 的地方。
|
||||
///
|
||||
/// 「必须去掉 verbatim 前缀」这条纪律只在这里守一次;单测直接断言本函数产出的
|
||||
/// `program` / `args`,因此**在 macOS / Linux 上也守得住 Windows 分支**(守的是
|
||||
/// 「最终下发文本」,不是「源码里出现过某个常量」)。
|
||||
/// `CREATE_NO_WINDOW`(`0x0800_0000`):进程创建时不新开控制台窗口。
|
||||
///
|
||||
/// ## 为什么必须显式加(2026-10-13 真机发现的既有缺陷)
|
||||
/// 控制台自身是 **GUI 子系统**程序(不占控制台)。GUI 父进程 spawn 一个**控制台子系统**
|
||||
/// 程序(`node.exe`)时,Windows 会**新建一个控制台**;Windows 11 上它由
|
||||
/// **WindowsTerminal.exe** 承载 → 用户屏幕上多出一个常驻黑窗。
|
||||
///
|
||||
/// 修复 verbatim 缺陷**之前**,sidecar 被拉起后 0.5 秒即退,黑窗一闪而过(所以一直没被发现);
|
||||
/// 修复**之后** sidecar 常驻,黑窗就留在屏幕上不走了 —— 真机截图实证(窗口标题即
|
||||
/// `C:\Users\…\nodeagent-console\node.exe`,进程名 `WindowsTerminal`)。
|
||||
///
|
||||
/// 这与「消灭命令行窗口」那条既有目标直接冲突(`AgentManager::start` 早已加了此标志),
|
||||
/// 故 `sidecar_command` 必须同样带上,两边**共用本常量**以免值漂移。
|
||||
///
|
||||
/// ⚠️ **本项没有单测守卫**,原因如实记档:`creation_flags` 只有 setter、**没有 getter**
|
||||
/// (`std::process::Command` 与 `tokio::process::Command` 都如此),断言不到「到底带没带」;
|
||||
/// 而任何「扫源码看有没有写过这个常量」的做法都违反铁律 1(守卫要落在最终下发值上)。
|
||||
/// 因此这条纪律靠**真机截图对照**把关(修复前有黑窗 / 修复后无黑窗)。
|
||||
#[cfg(windows)]
|
||||
pub(crate) const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
|
||||
fn sidecar_command(node: &Path, script: &Path) -> Command {
|
||||
let mut cmd = Command::new(unverbatim(node.to_path_buf()));
|
||||
cmd.arg(unverbatim(script.to_path_buf()))
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true); // app 退出时不留孤儿进程
|
||||
|
||||
// ⚠️ 消灭黑窗:不加这一行,用户每点一次「检查更新」都会多出一个常驻黑窗
|
||||
// (详见 `CREATE_NO_WINDOW` 的说明)。
|
||||
#[cfg(windows)]
|
||||
{
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
/// sidecar 主动推上来的事件(与 `main.mjs` 的 `params.kind` 一一对应)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SidecarEvent {
|
||||
@@ -46,6 +220,15 @@ pub struct Sidecar {
|
||||
stdin: tokio::sync::Mutex<ChildStdin>,
|
||||
pending: Pending,
|
||||
next_id: AtomicU64,
|
||||
/// 「本实例已不可用」的标记(stdout 关闭 / 写入失败)。
|
||||
///
|
||||
/// ⚠️ 为什么不能只靠 `child.try_wait()`:往**已退出进程的管道**里写在 Windows 上
|
||||
/// 未必立刻报错(管道缓冲区吞下就返回成功),于是请求会一路干等到 `request` 的超时。
|
||||
/// 真机实证(2026-10-13,Windows 11 ARM64):sidecar 早已退出,控制台却仍复用缓存里的
|
||||
/// `Arc<Sidecar>`,点「检查更新」等满 RPC 超时(当时 45 秒) → 界面显示「检查更新失败:check_update 超时」;
|
||||
/// 而同一台机器上**手工拉起 sidecar 跑同一个请求只要 7 秒**。
|
||||
/// 所以判定「它死了」要多路取证:进程状态 ∨ stdout EOF ∨ 写入失败。
|
||||
dead: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Sidecar {
|
||||
@@ -57,12 +240,13 @@ impl Sidecar {
|
||||
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 退出时不留孤儿进程
|
||||
// 进程边界:先净化一次(用于报错信息与实际下发文本一致)。
|
||||
// 真正「不得带 verbatim 前缀」这道纪律由 `sidecar_command` 兜底 ——
|
||||
// 去掉它会复现真机缺陷(node 启动即 EISDIR 退出),单测会红。
|
||||
let node = unverbatim(node.to_path_buf());
|
||||
let script = unverbatim(script.to_path_buf());
|
||||
|
||||
let mut child = sidecar_command(&node, &script)
|
||||
.spawn()
|
||||
.map_err(|e| format!("启动 sidecar 失败({} {}):{e}", node.display(), script.display()))?;
|
||||
|
||||
@@ -71,10 +255,12 @@ impl Sidecar {
|
||||
let stderr = child.stderr.take().ok_or("sidecar stderr 不可用")?;
|
||||
|
||||
let pending: Pending = Arc::new(Mutex::new(HashMap::new()));
|
||||
let dead = Arc::new(AtomicBool::new(false));
|
||||
|
||||
// stdout:逐行读,分派「响应」与「通知」
|
||||
{
|
||||
let pending = Arc::clone(&pending);
|
||||
let dead = Arc::clone(&dead);
|
||||
tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(stdout).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
@@ -124,6 +310,16 @@ impl Sidecar {
|
||||
on_event(evt);
|
||||
}
|
||||
}
|
||||
|
||||
// 走到这里 = stdout 关闭(EOF 或读取出错)= sidecar 进程已结束。
|
||||
dead.store(true, Ordering::SeqCst);
|
||||
// 顺手唤醒**所有还在等响应**的请求,让它们立刻失败,
|
||||
// 而不是干等各自的 timeout(见 UPDATE_CHECK_RPC_TIMEOUT_SECS / UPDATE_DOWNLOAD_RPC_TIMEOUT_SECS)。
|
||||
// 没有这一步,sidecar 死掉的那一刻就会有请求白等好几分钟。
|
||||
let mut waiting = pending.lock().unwrap();
|
||||
for (_, tx) in waiting.drain() {
|
||||
let _ = tx.send(Err("sidecar 已退出(stdout 关闭),请重试".to_string()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -140,11 +336,34 @@ impl Sidecar {
|
||||
stdin: tokio::sync::Mutex::new(stdin),
|
||||
pending,
|
||||
next_id: AtomicU64::new(1),
|
||||
dead,
|
||||
}))
|
||||
}
|
||||
|
||||
/// 本实例是否仍然可用(子进程还在跑,且未被判定失效)。
|
||||
///
|
||||
/// `try_wait()` 返回 `Ok(None)` = 仍在运行;返回 `Some(_)` = 已退出。
|
||||
/// 任何拿不准的情况一律按「不可用」处理 —— 宁可付一次 sidecar 冷启动的代价,
|
||||
/// 也不要让用户的每个操作都干等到 timeout(那正是真机上报「更新失败」的形态)。
|
||||
pub fn is_alive(&self) -> bool {
|
||||
if self.dead.load(Ordering::SeqCst) {
|
||||
return false;
|
||||
}
|
||||
let mut guard = self.child.lock().unwrap();
|
||||
match guard.as_mut() {
|
||||
Some(child) => matches!(child.try_wait(), Ok(None)),
|
||||
None => false, // shutdown() 已经把 child 取走了
|
||||
}
|
||||
}
|
||||
|
||||
/// 发一个请求并等响应(`timeout_secs` 兜底,避免 sidecar 卡住时本壳一起挂)。
|
||||
pub async fn request(&self, method: &str, params: Value, timeout_secs: u64) -> Result<Value, String> {
|
||||
// 入口先判存活 —— 这是「秒失败」的那一道。
|
||||
// 只靠写入报错是不够的:Windows 上往已关闭的管道写可能被缓冲区吞掉而"成功",
|
||||
// 之后就会一路干等到 timeout(真机形态:check_update 的 RPC 超时,当时 45s)。宁可在入口直接拒绝。
|
||||
if !self.is_alive() {
|
||||
return Err(format!("sidecar 不可用(进程已退出),{method} 已放弃"));
|
||||
}
|
||||
let id = self.next_id.fetch_add(1, Ordering::SeqCst);
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.pending.lock().unwrap().insert(id, tx);
|
||||
@@ -152,11 +371,20 @@ impl Sidecar {
|
||||
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}"))?;
|
||||
// 写入/刷新失败 = 管道对面没人了 → 立刻置 dead,让下一次 ensure_sidecar 重建。
|
||||
// 否则后续每个请求都会重演「写进去、永远没响应、干等到超时」。
|
||||
if let Err(e) = stdin.write_all(format!("{msg}\n").as_bytes()).await {
|
||||
self.dead.store(true, Ordering::SeqCst);
|
||||
return Err(format!(
|
||||
"写入 sidecar 失败:{e}(sidecar 可能已退出,下次操作会自动重建)"
|
||||
));
|
||||
}
|
||||
if let Err(e) = stdin.flush().await {
|
||||
self.dead.store(true, Ordering::SeqCst);
|
||||
return Err(format!(
|
||||
"flush sidecar 失败:{e}(sidecar 可能已退出,下次操作会自动重建)"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), rx).await {
|
||||
@@ -177,6 +405,7 @@ impl Sidecar {
|
||||
// MutexGuard 会存活到 if 块结束 → 跨 await 持有 std Mutex(clippy::await_holding_lock,
|
||||
// 且真会阻塞 executor)。
|
||||
let child = self.child.lock().unwrap().take();
|
||||
self.dead.store(true, Ordering::SeqCst); // 主动关闭同样视为不可用
|
||||
if let Some(mut child) = child {
|
||||
let _ = child.kill().await;
|
||||
}
|
||||
@@ -190,14 +419,20 @@ impl Sidecar {
|
||||
/// 解析 node 可执行文件。
|
||||
///
|
||||
/// 优先级:环境变量 `NODEAGENT_CONSOLE_NODE` → 随包 resource 的 `node` → 系统 `node`。
|
||||
///
|
||||
/// ⚠️ 出口一律经 `unverbatim`:`resource_dir()` 在 Windows 上给的是 verbatim 路径。
|
||||
/// 对本函数而言这是**防御性**净化 —— 真机四格实验(见 `unverbatim`)证明 `program`
|
||||
/// 位置带 `\\?\` 本身无害(G3 照常启动)。之所以仍然净化,是为了守住「凡从本模块
|
||||
/// 出去的路径都干净」这一条**单一纪律**:靠「记住哪些位置安全」来省这一步,
|
||||
/// 迟早会有人把它挪到参数位置而不自知(那才是 G2/G4 的必炸形态)。
|
||||
pub fn resolve_node(resource_dir: Option<&Path>) -> Result<PathBuf, String> {
|
||||
if let Ok(p) = std::env::var("NODEAGENT_CONSOLE_NODE") {
|
||||
if !p.is_empty() {
|
||||
return Ok(PathBuf::from(p));
|
||||
return Ok(unverbatim(PathBuf::from(p)));
|
||||
}
|
||||
}
|
||||
if let Some(dir) = resource_dir {
|
||||
let bundled = dir.join(if cfg!(windows) { "node.exe" } else { "node" });
|
||||
let bundled = unverbatim(dir.join(if cfg!(windows) { "node.exe" } else { "node" }));
|
||||
if bundled.exists() {
|
||||
return Ok(bundled);
|
||||
}
|
||||
@@ -227,14 +462,18 @@ fn which(cmd: &str) -> Option<PathBuf> {
|
||||
///
|
||||
/// 优先级:环境变量 `NODEAGENT_CONSOLE_SIDECAR` → 随包 resource 的 `sidecar.mjs` →
|
||||
/// 开发期的 `apps/console/sidecar/dist/sidecar.mjs`(相对本 crate)。
|
||||
///
|
||||
/// ⚠️ 出口一律经 `unverbatim` —— **这一条就是 2026-10-13 真机缺陷的修复点**:
|
||||
/// 打包后 `resource_dir()` 给的是 `\\?\C:\…`,交给 node 当参数会让 node 在启动期
|
||||
/// `realpathSync` 里炸成 `EISDIR ... lstat 'C:'` 并 exit 1(sidecar 秒退)。
|
||||
pub fn resolve_sidecar(resource_dir: Option<&Path>) -> Result<PathBuf, String> {
|
||||
if let Ok(p) = std::env::var("NODEAGENT_CONSOLE_SIDECAR") {
|
||||
if !p.is_empty() && Path::new(&p).exists() {
|
||||
return Ok(PathBuf::from(p));
|
||||
return Ok(unverbatim(PathBuf::from(p)));
|
||||
}
|
||||
}
|
||||
if let Some(dir) = resource_dir {
|
||||
let bundled = dir.join("sidecar.mjs");
|
||||
let bundled = unverbatim(dir.join("sidecar.mjs"));
|
||||
if bundled.exists() {
|
||||
return Ok(bundled);
|
||||
}
|
||||
@@ -268,6 +507,113 @@ mod tests {
|
||||
resolve_node(None).expect("需要 node 才能跑 sidecar 单测")
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 「交给 node 的路径不得带 Windows verbatim 前缀」—— 2026-10-13 真机缺陷
|
||||
//
|
||||
// 现象(真机):控制台点「检查更新」,sidecar 被拉起后**存活约 0.5 秒即消失**、
|
||||
// 退出码 1,且一行 JS 都没执行(连自带的启动日志都写不出来);界面报
|
||||
// 「sidecar 已退出(stdout 关闭),请重试」(修存活检测之前报「等满 RPC 超时」,当时 45 秒)。
|
||||
// 根因:`resource_dir()` 给的是 verbatim 路径(`\\?\C:\…`),当参数交给 node 后,
|
||||
// node 启动期的 `realpathSync` 把 `\\?\C:` 拆成 `C:` → `EISDIR` → exit 1。
|
||||
// 为什么本地/CI 全绿:开发模式走的是仓库相对路径(普通路径),只有打包后才暴露。
|
||||
// 因此这里的守卫**必须落在「最终下发给 node 的 program / args」上**,
|
||||
// 而不是「源码里有没有调用过某个函数」—— 这样在 macOS / Linux 上也能守住 Windows 分支。
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn unverbatim_去掉各类_windows_前缀() {
|
||||
// 常见的盘符形态
|
||||
assert_eq!(
|
||||
unverbatim(PathBuf::from(r"\\?\C:\Users\u\AppData\Local\x\node.exe")),
|
||||
PathBuf::from(r"C:\Users\u\AppData\Local\x\node.exe")
|
||||
);
|
||||
// UNC 形态:\\?\UNC\server\share\x → \\server\share\x
|
||||
assert_eq!(
|
||||
unverbatim(PathBuf::from(r"\\?\UNC\srv\share\sidecar.mjs")),
|
||||
PathBuf::from(r"\\srv\share\sidecar.mjs")
|
||||
);
|
||||
// 普通路径原样返回(含 POSIX)
|
||||
assert_eq!(
|
||||
unverbatim(PathBuf::from(r"C:\a\b.mjs")),
|
||||
PathBuf::from(r"C:\a\b.mjs")
|
||||
);
|
||||
assert_eq!(
|
||||
unverbatim(PathBuf::from("/Users/x/y.mjs")),
|
||||
PathBuf::from("/Users/x/y.mjs")
|
||||
);
|
||||
// 幂等:净化两次 == 净化一次(spawn 与 sidecar_command 都会调)
|
||||
let once = unverbatim(PathBuf::from(r"\\?\C:\a\b.mjs"));
|
||||
assert_eq!(unverbatim(once.clone()), once);
|
||||
}
|
||||
|
||||
/// 反例守卫:**非 UTF-8 路径必须原样返回,不得被 lossy 重建损坏**。
|
||||
///
|
||||
/// ⚠️ 构造必须**同时**含 `\\?\` 前缀与非法字节,否则测不出差异 ——
|
||||
/// 不带前缀时新旧实现都会落到「原样返回」那一步(`to_string_lossy` 也安全),
|
||||
/// 只有在「剥前缀 → `PathBuf::from(rest)`」这一步才会把 U+FFFD 写进路径。
|
||||
/// 若把实现退回 `to_string_lossy()`,本用例立刻变红:那是把「node 报 EISDIR」
|
||||
/// 换成「指向一个不存在的路径」,更难排查 —— 宁可原样返回。
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn unverbatim_对非_utf8_的_verbatim_路径必须原样返回不得损坏() {
|
||||
use std::ffi::OsStr;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
// 0xff / 0xfe 永不出现在合法 UTF-8 序列里;前缀形态按 Windows 写法给全
|
||||
let raw = OsStr::from_bytes(b"\\\\?\\C:\\na-\xff\xfe-weird.mjs");
|
||||
let p = PathBuf::from(raw);
|
||||
assert!(p.to_str().is_none(), "构造出来的路径应确实是非 UTF-8");
|
||||
assert_eq!(
|
||||
unverbatim(p.clone()),
|
||||
p,
|
||||
"非 UTF-8 路径被改写了(很可能退回了 to_string_lossy)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unverbatim_对_resource_dir_拼接后的路径同样生效() {
|
||||
// 真实形态就是「resource_dir().join("sidecar.mjs")」——前缀在目录上,拼完仍在最前
|
||||
let joined = unverbatim(Path::new(r"\\?\C:\app").join("sidecar.mjs"));
|
||||
let s = joined.to_string_lossy().into_owned();
|
||||
assert!(!s.contains(r"\\?\"), "拼接后仍带 verbatim 前缀:{s}");
|
||||
assert!(s.starts_with(r"C:\app"), "应剥成普通路径,实际:{s}");
|
||||
assert!(s.ends_with("sidecar.mjs"), "文件名不该被动:{s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn 交给_node_的路径必须去掉_verbatim_前缀() {
|
||||
// 直接断言**最终下发给 node 的文本**(program 与 args),而不是源码长相。
|
||||
// 去掉 `sidecar_command` 里的 unverbatim → 本用例立刻变红。
|
||||
let cmd = sidecar_command(
|
||||
Path::new(r"\\?\C:\Users\u\AppData\Local\nodeagent-console\node.exe"),
|
||||
Path::new(r"\\?\C:\Users\u\AppData\Local\nodeagent-console\sidecar.mjs"),
|
||||
);
|
||||
let std_cmd = cmd.as_std();
|
||||
|
||||
let prog = std_cmd.get_program().to_string_lossy().into_owned();
|
||||
assert!(
|
||||
!prog.contains(r"\\?\"),
|
||||
"program 仍带 verbatim 前缀(示例:{prog})"
|
||||
);
|
||||
assert_eq!(
|
||||
prog, r"C:\Users\u\AppData\Local\nodeagent-console\node.exe",
|
||||
"program 应剥成普通路径"
|
||||
);
|
||||
|
||||
let args: Vec<String> = std_cmd
|
||||
.get_args()
|
||||
.map(|a| a.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
assert!(
|
||||
!args.iter().any(|a| a.contains(r"\\?\")),
|
||||
"脚本参数仍带 verbatim 前缀 → node 启动即 EISDIR 退出(真机表现为「sidecar 秒退」):{args:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![r"C:\Users\u\AppData\Local\nodeagent-console\sidecar.mjs".to_string()],
|
||||
"脚本参数应为唯一一个、且已净化的路径"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn 启动后_state_请求应返回未连接() {
|
||||
let seen: Arc<AtomicUsize> = Arc::new(AtomicUsize::new(0));
|
||||
@@ -312,4 +658,76 @@ mod tests {
|
||||
}
|
||||
sc.shutdown().await;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 「sidecar 退出后不得被复用」—— 2026-10-13 真机缺陷
|
||||
//
|
||||
// 现象:控制台点「检查更新」等满 RPC 超时(当时 45 秒),界面报「check_update 超时」;
|
||||
// 而同一台机器上手工拉起 sidecar 跑同一个请求只要 7 秒。
|
||||
// 根因:控制台缓存里的 Arc<Sidecar> 指向早已退出的子进程,
|
||||
// ensure_sidecar 只判「缓存里有没有」,不判「那进程还活着没」。
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// 把 sidecar 的子进程直接杀掉,模拟真机上它意外退出。
|
||||
fn kill_child(sc: &Sidecar) {
|
||||
let mut guard = sc.child.lock().unwrap();
|
||||
if let Some(child) = guard.as_mut() {
|
||||
let _ = child.start_kill();
|
||||
}
|
||||
}
|
||||
|
||||
/// 等 is_alive 变为 false(stdout 任务观察到 EOF 会置 dead)。
|
||||
async fn wait_dead(sc: &Sidecar) {
|
||||
for _ in 0..150 {
|
||||
if !sc.is_alive() {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn 启动后_is_alive_应为真_shutdown_后应为假() {
|
||||
let sc = Sidecar::spawn(&node_bin(), &dev_sidecar_src(), Arc::new(|_| {}))
|
||||
.expect("sidecar 应能启动");
|
||||
assert!(sc.is_alive(), "刚启动的 sidecar 应判为存活");
|
||||
sc.shutdown().await;
|
||||
assert!(!sc.is_alive(), "shutdown 之后必须判为不存活");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn 子进程被杀后_is_alive_必须变假() {
|
||||
let sc = Sidecar::spawn(&node_bin(), &dev_sidecar_src(), Arc::new(|_| {}))
|
||||
.expect("sidecar 应能启动");
|
||||
assert!(sc.is_alive());
|
||||
|
||||
kill_child(&sc);
|
||||
wait_dead(&sc).await;
|
||||
|
||||
assert!(
|
||||
!sc.is_alive(),
|
||||
"子进程已退出,is_alive 仍为真 → ensure_sidecar 会继续复用死实例(就是真机那个缺陷)"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn 对已退出的_sidecar_发请求必须秒失败而不是干等超时() {
|
||||
let sc = Sidecar::spawn(&node_bin(), &dev_sidecar_src(), Arc::new(|_| {}))
|
||||
.expect("sidecar 应能启动");
|
||||
|
||||
kill_child(&sc);
|
||||
wait_dead(&sc).await;
|
||||
|
||||
// 故意给 30 秒超时:正确实现应当在入口就拒绝(毫秒级)。
|
||||
// 首版缺陷的表现恰恰是「干等到超时」,所以这里**断言耗时**而不只是断言出错。
|
||||
let t0 = std::time::Instant::now();
|
||||
let r = sc.request("state", json!({}), 30).await;
|
||||
let cost = t0.elapsed();
|
||||
|
||||
assert!(r.is_err(), "对已退出的 sidecar 发请求必须失败");
|
||||
assert!(
|
||||
cost < std::time::Duration::from_secs(5),
|
||||
"应快速失败,实际耗时 {cost:?} —— 若接近 30s 说明仍在干等超时"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "nodeagent-console",
|
||||
"version": "2.0.1",
|
||||
"version": "2.1.0",
|
||||
"identifier": "cn.jkooe.nodeagent-console",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
|
||||
@@ -1,19 +1,45 @@
|
||||
<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 QuitGuard from "./components/QuitGuard.vue";
|
||||
import { cssVars } from "./theme";
|
||||
import { useThemeStore } from "./stores/theme";
|
||||
|
||||
const theme = useThemeStore();
|
||||
|
||||
/**
|
||||
* 把调色板写成 `--na-*` CSS 变量挂到根元素。
|
||||
*
|
||||
* 色值只在 `theme/index.ts` 定义一份,此处在运行期注入给自定义样式用 ——
|
||||
* 免得「TS 调色板」与「CSS 硬编码」各写一份后静默漂移(换主题时一半新一半旧,
|
||||
* 不报错、类型也抓不到)。
|
||||
*
|
||||
* 同时设 `color-scheme`:不设的话滚动条、`<input>` 等原生控件会一直是浅色,
|
||||
* 在「高级灰」下白得刺眼。
|
||||
*/
|
||||
watchEffect(() => {
|
||||
const { palette, name } = theme.preset;
|
||||
const root = document.documentElement;
|
||||
for (const [k, v] of Object.entries(cssVars(palette))) {
|
||||
root.style.setProperty(k, v);
|
||||
}
|
||||
root.style.colorScheme = palette.scheme;
|
||||
root.dataset.theme = name;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-config-provider :theme="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>
|
||||
<!-- 必须在 dialog-provider 内部:它要用 useDialog() 弹退出确认 -->
|
||||
<quit-guard />
|
||||
<app-shell />
|
||||
</n-dialog-provider>
|
||||
</n-message-provider>
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
/** 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,82 @@ 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 });
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出控制台(由前端的确认框调用)。
|
||||
*
|
||||
* `keepAgent = true` 保留被控端继续运行 —— 它将成为孤儿进程,
|
||||
* 下次启动控制台时会被「复用」逻辑接管;`false` 则先停掉再退出。
|
||||
*/
|
||||
export function quitApp(keepAgent: boolean) {
|
||||
return invoke<void>("quit_app", { keepAgent });
|
||||
}
|
||||
|
||||
/** 增量取日志(传上次拿到的最大 seq)。 */
|
||||
export function agentLogs(since = 0) {
|
||||
return invoke<AgentLogPage>("agent_logs", { since });
|
||||
}
|
||||
|
||||
/** 清空日志缓冲。 */
|
||||
export function agentLogsClear() {
|
||||
return invoke<void>("agent_logs_clear");
|
||||
}
|
||||
|
||||
/** 开机自启状态。 */
|
||||
export function autostartStatus() {
|
||||
return invoke<AutostartStatus>("autostart_status");
|
||||
}
|
||||
|
||||
/** 开关开机自启。 */
|
||||
export function autostartSet(enabled: boolean) {
|
||||
return invoke<AutostartStatus>("autostart_set", { enabled });
|
||||
}
|
||||
|
||||
/** 检查更新(比对最新 Release)。 */
|
||||
export function checkUpdate() {
|
||||
return invoke<UpdateInfo>("check_update");
|
||||
}
|
||||
|
||||
/** 应用更新:下载 → 校验 → 替换本机被控端脚本 → 重启。 */
|
||||
export function applyUpdate(url: string, sha256: string, version: string) {
|
||||
return invoke<ApplyUpdateResult>("apply_update", { url, sha256, version });
|
||||
}
|
||||
|
||||
/** 读设置。 */
|
||||
export function settingsGet() {
|
||||
return invoke<ConsoleSettings>("settings_get");
|
||||
}
|
||||
|
||||
/** 合并式写设置(只传要改的字段)。 */
|
||||
export function settingsSet(patch: Partial<ConsoleSettings>) {
|
||||
return invoke<ConsoleSettings>("settings_set", { patch });
|
||||
}
|
||||
|
||||
/** 控制台自身信息。 */
|
||||
export function systemInfo() {
|
||||
return invoke<ConsoleSystemInfo>("system_info");
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 退出确认(2026-10-17 需求方提出)。
|
||||
*
|
||||
* ## 它解决的误解
|
||||
* 旧行为:点托盘「退出」→ 控制台立刻消失,但被控端因 `kill_on_drop(false)`
|
||||
* **成为孤儿继续监听端口**。用户以为「关了窗口就干净了」,实际还有个看不见的
|
||||
* 进程在跑 —— 下次启动还会撞上「端口已被占用」。
|
||||
*
|
||||
* ## 为什么做成三选一
|
||||
* 「留不留一个正在监听端口的后台进程」是有副作用的决定,必须由用户明确选择:
|
||||
* - 保留被控端 → 远程仍可接管(下次启动控制台会自动复用它)
|
||||
* - 停止被控端 → 彻底清净,但本机不再可被远程接管
|
||||
* - 关闭弹窗 → 取消退出
|
||||
*
|
||||
* ## 为什么不在 Rust 里弹系统对话框
|
||||
* 前端已有 `n-dialog-provider`,复用它就不必新增 `tauri-plugin-dialog` 依赖,
|
||||
* 视觉也与全应用一致。Rust 侧只负责「发一个事件 + 提供 quit_app 命令」。
|
||||
*/
|
||||
import { onMounted, onUnmounted } from "vue";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import { useDialog } from "naive-ui";
|
||||
import { h } from "vue";
|
||||
import { quitApp } from "../api";
|
||||
|
||||
const dialog = useDialog();
|
||||
let unlisten: UnlistenFn | null = null;
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
unlisten = await listen("na-quit-requested", () => {
|
||||
const d = dialog.warning({
|
||||
title: "退出控制台",
|
||||
closable: true,
|
||||
maskClosable: true,
|
||||
content: () =>
|
||||
h("div", { style: "line-height:1.7" }, [
|
||||
h("div", "退出后本机被控端要怎么处理?"),
|
||||
h(
|
||||
"div",
|
||||
{ style: "margin-top:6px;font-size:12px;opacity:.72" },
|
||||
"保留:仍然可以被远程接管(下次打开控制台会自动复用它);停止:彻底结束,退出后不再有后台进程。",
|
||||
),
|
||||
]),
|
||||
positiveText: "保留被控端,退出",
|
||||
negativeText: "停止被控端,退出",
|
||||
onPositiveClick: () => {
|
||||
d.loading = true;
|
||||
void quitApp(true);
|
||||
},
|
||||
onNegativeClick: () => {
|
||||
d.loading = true;
|
||||
void quitApp(false);
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
/* 事件注册失败不应阻断应用启动 */
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
unlisten?.();
|
||||
unlisten = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 纯逻辑组件,不渲染任何可见内容 -->
|
||||
<span style="display: none" />
|
||||
</template>
|
||||
@@ -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,281 @@
|
||||
/**
|
||||
* 本机服务 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);
|
||||
/** 启停/设置等毫秒级操作的全局忙标志(**不要**给网络类操作复用,见 checkUpdate 注释) */
|
||||
const busy = ref(false);
|
||||
/** 更新检查/下载专用忙标志 —— 与 `busy` 隔离,避免慢操作锁死整页按钮 */
|
||||
const updateBusy = 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();
|
||||
return r;
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : String(e);
|
||||
return null;
|
||||
} finally {
|
||||
// ⚠️ 刷新必须放在 finally(2026-10-17 真机缺陷):
|
||||
// 原来 `await refresh()` 写在 try 里,一旦动作**抛错**就被跳过,
|
||||
// 界面状态会永远停在动作发生前的旧值。
|
||||
//
|
||||
// 真机症状:点「启动」→ 后端答「被控端已在运行」(说明它其实是 running)
|
||||
// → 界面却一直显示「启动中」,「停止 / 重启」永久灰着,用户无从操作。
|
||||
// 根因就是那次 start 抛了错,refresh 被跳过;而 `na-agent-state` 推送
|
||||
// 在 WebView 就绪前发出,前端没收到 —— 两条更新通道同时失效。
|
||||
// refresh() 自身已 try/catch,放这里不会掩盖原始错误。
|
||||
await refresh();
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查更新。
|
||||
*
|
||||
* ⚠️ **刻意不用全局 `busy`**(2026-10-17 真机缺陷):
|
||||
* `check_update` 要访问网络,境内可能一路等到超时(原 120s / RPC 150s)。
|
||||
* 而 `busy` 是**全页共享**的(所有按钮 `:loading="busy"`),于是用户点一下
|
||||
* 「检查更新」,整页按钮跟着转两分半 —— 看起来像「点什么都卡死」。
|
||||
*
|
||||
* 更新检查是**只读、可等待、与启停无关**的操作,本就不该锁住其他按钮。
|
||||
* 故它自成一套 `updateBusy`;全局 `busy` 只服务毫秒级的启停与设置。
|
||||
*/
|
||||
async function checkUpdate() {
|
||||
updateBusy.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 {
|
||||
updateBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用更新(仅更新本机被控端;控制台自身需重新下载安装包)。
|
||||
*
|
||||
* 同样走 `updateBusy` 而非全局 `busy`:下载更新的 RPC 超时是 330s,
|
||||
* 若占用全局 busy,用户会看到「下载更新时整页按钮全部转圈五分钟」。
|
||||
*/
|
||||
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;
|
||||
}
|
||||
updateBusy.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const r = await api.applyUpdate(url, sha, u.latest_version);
|
||||
await refresh();
|
||||
if (r) await checkUpdate();
|
||||
return r;
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : String(e);
|
||||
return null;
|
||||
} finally {
|
||||
updateBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 一键连接本机被控端(用配置里的地址与密钥)。 */
|
||||
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,
|
||||
updateBusy,
|
||||
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 };
|
||||
});
|
||||
@@ -14,11 +14,12 @@ export interface AgentEvent {
|
||||
* 仅在被控端为 Windows 时可用(其余平台返回 E_UNSUPPORTED_PLATFORM)。
|
||||
* 前端据此**事前门控**,而不是发出注定失败的请求再解释错误。
|
||||
*/
|
||||
export const WINDOWS_ONLY_CAPS = ["app.list", "app.install", "system.service.list"] as const;
|
||||
export const WINDOWS_ONLY_CAPS = ["app.list", "app.install", "system.service.list", "system.audit.l2"] as const;
|
||||
|
||||
export const useConnStore = defineStore("conn", () => {
|
||||
const state = ref<string>("closed");
|
||||
const url = ref(localStorage.getItem("na.url") || "ws://127.0.0.1:8765");
|
||||
// 默认 wss:被控端默认 tls=true(明文 ws 连 TLS 端口必然握手失败,2026-10-17 真机踩过)
|
||||
const url = ref(localStorage.getItem("na.url") || "wss://127.0.0.1:8765");
|
||||
const key = ref(localStorage.getItem("na.key") || "");
|
||||
const info = ref<ConnectInfo | null>(null);
|
||||
const logs = ref<string[]>([]);
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -109,6 +109,29 @@ export interface SystemInfo {
|
||||
ps_helper?: PsHelperInfo;
|
||||
network?: NetworkInfo;
|
||||
build?: BuildInfo;
|
||||
/** 最后已知可用版本 LKG(交付流水线 §6 回滚锚点) */
|
||||
lkg?: LkgInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 最后已知可用版本(Last-Known-Good)。
|
||||
*
|
||||
* 只在「本次启动真正起来了」之后才由被控端写入 ——
|
||||
* 故 `matches=false` 意味着当前版本**尚未被证明能启动**(刚更新完、还没完成一次成功启动),
|
||||
* 界面应据此提示「暂缓向其余设备放量」。
|
||||
*/
|
||||
export interface LkgInfo {
|
||||
lkg?: {
|
||||
hash?: string;
|
||||
bytes?: number;
|
||||
script_path?: string;
|
||||
recorded_at?: number;
|
||||
node_version?: string;
|
||||
platform?: string;
|
||||
};
|
||||
current_hash?: string;
|
||||
matches?: boolean;
|
||||
byte_delta?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -204,7 +227,12 @@ export type AuditType =
|
||||
| "net.change"
|
||||
| "net.deny"
|
||||
| "agent.update"
|
||||
| "session.idle_close";
|
||||
// v2.1.0:L2 系统级审计事件(Windows Security 日志归一化副本)
|
||||
| "agent.l2"
|
||||
| "session.idle_close"
|
||||
// v2.0.3:被拒请求统一留痕(U-01)+ 进程级兜底错误
|
||||
| "request.rejected"
|
||||
| "agent.error";
|
||||
|
||||
export interface AuditEntry {
|
||||
ts?: number;
|
||||
@@ -217,6 +245,10 @@ export interface AuditEntry {
|
||||
error?: string | null;
|
||||
/** 参数摘要(sha256 前缀)——默认只记摘要,不记内容 */
|
||||
args_digest?: string;
|
||||
/** v2.1.0(A-P0-4):参数序列化总字节数;与 args_truncated 同时出现表示摘要只覆盖前 64KB */
|
||||
args_bytes?: number;
|
||||
/** v2.1.0(A-P0-4):摘要是否为截断哈希(序列化超过 64KB 时只哈希前 64KB) */
|
||||
args_truncated?: boolean;
|
||||
/** 参数预览(仅 `log_args:true` 时记录,键含敏感词自动脱敏) */
|
||||
args_preview?: string;
|
||||
reason?: string;
|
||||
@@ -247,3 +279,175 @@ export interface AuditVerifyResult {
|
||||
/** 是**对象**而非数字 */
|
||||
broken_at?: AuditBrokenAt | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// L2 系统级审计(system.audit.l2,仅 Windows)—— 契约来源:manifest system.ts
|
||||
// AuditL2 returns_schema(四种 action 的顶层属性并集;未出现的属性即本次不返回)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** L2 策略状态 / 采集 / 启用 / 校验的返回(按 action 只出现对应字段)。 */
|
||||
export interface AuditL2Result {
|
||||
// status
|
||||
platform?: string;
|
||||
supported?: boolean;
|
||||
auditpol?: string;
|
||||
legacy_policy_disabled?: boolean;
|
||||
cmdline_logging?: boolean;
|
||||
sysmon_installed?: boolean;
|
||||
file?: string;
|
||||
file_exists?: boolean;
|
||||
note?: string;
|
||||
// enable
|
||||
enabled?: boolean;
|
||||
policies?: string[];
|
||||
hint?: string;
|
||||
// collect
|
||||
collected?: number;
|
||||
written?: number;
|
||||
since?: number;
|
||||
limit?: number;
|
||||
// verify
|
||||
ok?: boolean;
|
||||
checked?: number;
|
||||
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;
|
||||
/**
|
||||
* true = 复用了**非本控制台启动**的被控端(退出控制台时选择「保留」而留下的孤儿)。
|
||||
*
|
||||
* 此时它已在监听、可直接「连接本机」使用;但拿不到子进程句柄,
|
||||
* 故「重启」会先按 PID 收掉再自己拉起(即把控制权收回本控制台)。
|
||||
*/
|
||||
external?: boolean;
|
||||
}
|
||||
|
||||
/** 被控端配置(读自 `<数据目录>/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;
|
||||
/** 本次出网走的代理来源(`HTTPS_PROXY` / `system` / `NA_PROXY`…),直连为 null。
|
||||
* 2026-10-13 加:境内「检查更新」失败的排查线索,必须能在界面上看到。 */
|
||||
via_proxy?: string | null;
|
||||
/** 人类可读的出网路径描述(「直连」/「经代理 http://…」)。 */
|
||||
via?: string | null;
|
||||
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;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { h, onMounted, ref } from "vue";
|
||||
import { computed, h, onMounted, ref } from "vue";
|
||||
import {
|
||||
NCard,
|
||||
NDataTable,
|
||||
@@ -11,11 +11,14 @@ import {
|
||||
NTag,
|
||||
NEmpty,
|
||||
NAlert,
|
||||
NPopconfirm,
|
||||
NDescriptions,
|
||||
NDescriptionsItem,
|
||||
useMessage,
|
||||
type DataTableColumns,
|
||||
} from "naive-ui";
|
||||
import { useConnStore } from "../stores/conn";
|
||||
import type { AuditEntry, AuditListResult, AuditVerifyResult } from "../types";
|
||||
import type { AuditEntry, AuditListResult, AuditVerifyResult, AuditL2Result } from "../types";
|
||||
import { formatTime } from "../utils/format";
|
||||
|
||||
const conn = useConnStore();
|
||||
@@ -28,19 +31,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> = [
|
||||
@@ -117,6 +130,80 @@ async function exportJsonl() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- L2 系统级审计(system.audit.l2,仅 Windows)----------
|
||||
|
||||
/**
|
||||
* L2 补的是 L1 结构上记不到的那块:攻击者绕过 agent 直接操作、或把 agent 停掉时,
|
||||
* 上面的审计表全盲。L2 采集操作系统自己的审计事件(Windows Security 日志)归一化落盘。
|
||||
*
|
||||
* ⚠️ 信任边界:采集器与 agent 同权限,本地 audit-l2.log 只是归一化副本,
|
||||
* 防篡改依赖把记录转发出机(界面文案已如实说明,不夸大)。
|
||||
*/
|
||||
const l2Loading = ref(false);
|
||||
const l2Result = ref<AuditL2Result | null>(null);
|
||||
const l2SinceHours = ref(24);
|
||||
const l2Limit = ref(1000);
|
||||
|
||||
async function runL2(action: "status" | "enable" | "collect" | "verify") {
|
||||
l2Loading.value = true;
|
||||
try {
|
||||
const args: Record<string, unknown> = { action };
|
||||
if (action === "collect") {
|
||||
args.since = Date.now() - l2SinceHours.value * 3600 * 1000;
|
||||
args.limit = l2Limit.value;
|
||||
}
|
||||
l2Result.value = await conn.ok<AuditL2Result>("system.audit.l2", args);
|
||||
} catch (e) {
|
||||
// 平台不支持时错误信息里已带「仅支持 Windows」—— 原样透出,不包装
|
||||
msg.error(`L2 ${action} 失败:${e}`);
|
||||
l2Result.value = null;
|
||||
} finally {
|
||||
l2Loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 把 L2 返回(四 action 属性并集,只出现本次相关字段)摊成描述列表。 */
|
||||
const l2Rows = computed(() => {
|
||||
const r = l2Result.value;
|
||||
if (!r) return [];
|
||||
const rows: { label: string; text: string }[] = [];
|
||||
const push = (label: string, v: string | number | boolean | undefined | null) => {
|
||||
if (v === undefined || v === null) return;
|
||||
rows.push({ label, text: typeof v === "boolean" ? (v ? "是" : "否") : String(v) });
|
||||
};
|
||||
// status
|
||||
push("平台", r.platform);
|
||||
push("受支持", r.supported);
|
||||
push("禁用旧版策略", r.legacy_policy_disabled);
|
||||
push("命令行记录", r.cmdline_logging);
|
||||
push("已装 Sysmon", r.sysmon_installed);
|
||||
push("副本文件", r.file);
|
||||
push("副本存在", r.file_exists);
|
||||
// enable
|
||||
push("已启用", r.enabled);
|
||||
push("已开启子类", r.policies?.join("、"));
|
||||
push("提示", r.hint);
|
||||
// collect
|
||||
push("采集到", r.collected);
|
||||
push("已落盘", r.written);
|
||||
push("起始时间", r.since != null ? formatTime(r.since) : undefined);
|
||||
push("条数上限", r.limit);
|
||||
// verify
|
||||
push("校验通过", r.ok);
|
||||
push("已校验", r.checked);
|
||||
if (r.broken_at) {
|
||||
push(
|
||||
"首个断点",
|
||||
r.broken_at.file
|
||||
? `${r.broken_at.file} 第 ${r.broken_at.line ?? "?"} 行${r.broken_at.reason ? `(${r.broken_at.reason})` : ""}`
|
||||
: `第 ${r.broken_at.line ?? "?"} 行${r.broken_at.reason ? `(${r.broken_at.reason})` : ""}`,
|
||||
);
|
||||
}
|
||||
// 说明类放最后
|
||||
push("说明", r.note);
|
||||
return rows;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (conn.connected) load();
|
||||
});
|
||||
@@ -124,7 +211,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 +228,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 +241,6 @@ onMounted(() => {
|
||||
<n-alert
|
||||
v-if="verify"
|
||||
:type="verify.ok ? 'success' : 'error'"
|
||||
:show-icon="true"
|
||||
style="margin-bottom: 12px"
|
||||
>
|
||||
完整性校验:{{ verify.ok ? "通过" : "发现问题" }} · 已校验 {{ verify.checked }} 条
|
||||
@@ -165,9 +251,90 @@ 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>
|
||||
|
||||
<n-card size="small" style="margin-top: 12px">
|
||||
<template #header>
|
||||
<n-space align="center">
|
||||
<span>L2 系统级审计</span>
|
||||
<n-text depth="3" style="font-size: 12px">操作系统自己的审计事件(Windows Security 日志)</n-text>
|
||||
<n-tag size="small" :type="conn.isWindows ? 'info' : 'warning'">
|
||||
{{ conn.isWindows ? "当前被控端受支持" : "仅 Windows" }}
|
||||
</n-tag>
|
||||
</n-space>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<n-space align="center">
|
||||
<n-button size="small" :loading="l2Loading" :disabled="!conn.platformReady" @click="runL2('status')">
|
||||
查状态
|
||||
</n-button>
|
||||
<n-button size="small" :loading="l2Loading" :disabled="!conn.platformReady" @click="runL2('collect')">
|
||||
拉取采集
|
||||
</n-button>
|
||||
<n-button size="small" :loading="l2Loading" :disabled="!conn.platformReady" @click="runL2('verify')">
|
||||
校验 L2 链
|
||||
</n-button>
|
||||
<n-popconfirm :show-icon="false" @positive-click="runL2('enable')">
|
||||
<template #trigger>
|
||||
<n-button size="small" type="warning" :loading="l2Loading" :disabled="!conn.platformReady">
|
||||
启用策略
|
||||
</n-button>
|
||||
</template>
|
||||
将开启 Windows 高级审计策略(进程创建 4688 / 登录 4624/4625 / 进程终止 4689)并记录命令行。
|
||||
需要<strong>管理员权限</strong>,未提权会被拒。确定?
|
||||
</n-popconfirm>
|
||||
</n-space>
|
||||
</template>
|
||||
|
||||
<n-alert v-if="conn.nodeOs && !conn.isWindows" type="warning" style="margin-bottom: 12px">
|
||||
被控端当前为 {{ conn.nodeOs }} —— L2 采集仅落地 Windows(Linux auditd / macOS ESF 未实现,原因见
|
||||
docs/AUDIT-L2.md)。按钮已禁用,避免点了才报错。
|
||||
</n-alert>
|
||||
<n-alert v-else type="default" style="margin-bottom: 12px">
|
||||
L1 只记录经过 agent 的调用;攻击者绕过 agent、或把 agent 停掉时 L1 全盲,L2 补的正是这块。
|
||||
本地副本防篡改价值有限,真正的独立性依赖把记录<strong>转发出机</strong>。
|
||||
</n-alert>
|
||||
|
||||
<!-- 采集参数始终可见:早前它在 `v-if="l2Rows.length"` 里面,
|
||||
必须先出一次结果才出现输入框 —— 用户想「先设窗口 72h 再采集」做不到,
|
||||
只能先点一次默认参数再改。参数与结果的生命周期无关,故拆开。 -->
|
||||
<n-space align="center" style="margin-bottom: 8px">
|
||||
<n-text depth="3" style="font-size: 12px">采集窗口(小时)</n-text>
|
||||
<n-input-number
|
||||
v-model:value="l2SinceHours"
|
||||
:min="1"
|
||||
:max="720"
|
||||
:step="1"
|
||||
size="small"
|
||||
style="width: 110px"
|
||||
/>
|
||||
<n-text depth="3" style="font-size: 12px">条数上限</n-text>
|
||||
<n-input-number
|
||||
v-model:value="l2Limit"
|
||||
:min="1"
|
||||
:max="5000"
|
||||
:step="100"
|
||||
size="small"
|
||||
style="width: 120px"
|
||||
/>
|
||||
</n-space>
|
||||
|
||||
<n-space v-if="l2Rows.length" vertical size="small">
|
||||
<n-descriptions :column="2" size="small" label-placement="left" bordered>
|
||||
<n-descriptions-item v-for="row in l2Rows" :key="row.label" :label="row.label">
|
||||
{{ row.text }}
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</n-space>
|
||||
<n-empty
|
||||
v-else-if="!l2Loading"
|
||||
:show-icon="false"
|
||||
description="点「查状态」查看被控端审计策略是否已启用"
|
||||
size="small"
|
||||
/>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -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,451 @@
|
||||
<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);
|
||||
/** 更新检查/下载独立忙标志 —— 网络类操作不该锁住启停按钮(见 stores/agent.ts 注释) */
|
||||
const updateBusy = computed(() => agent.updateBusy);
|
||||
|
||||
/** 最近 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(prefixOnce(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(prefixOnce(agent.error, "更新失败:"));
|
||||
else if (r) msg.success(`已更新到 v${r.version}(${r.restarted ? "并已重启" : "未自动启动"})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误信息里若已带同一前缀就不再叠加。
|
||||
*
|
||||
* ⚠️ 2026-10-13:sidecar 侧把失败原因写成「检查更新失败:<真实原因>(经代理 …, 耗时 …)」,
|
||||
* 而这里原本又拼一层 → 用户看到「检查更新失败:检查更新失败:请求超时…」。
|
||||
* 但 `ensure_sidecar` 之类来自 Rust 壳的错误**没有**前缀,所以前缀本身要保留,只是不重复。
|
||||
*/
|
||||
function prefixOnce(text: string, prefix: string) {
|
||||
return text.startsWith(prefix) ? text : prefix + text;
|
||||
}
|
||||
|
||||
async function doConnectLocal() {
|
||||
try {
|
||||
await agent.connectLocal();
|
||||
msg.success("已连接本机被控端");
|
||||
} catch (e) {
|
||||
msg.error(`连接失败:${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await agent.bind();
|
||||
// `bind()` 内部有 `bound` 守卫:第二次进入本页会直接 return,**不会刷新**。
|
||||
// 而 `na-agent-state` 推送可能在前端监听就绪前就发出(WebView 尚未 ready),
|
||||
// 于是状态会一直停在旧值(真机症状:后端已是 running,界面永远「启动中」)。
|
||||
// 故每次进入本页都显式拉一次 —— 这是最便宜的状态自愈。
|
||||
await agent.refresh();
|
||||
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-tag v-if="proc?.external" type="warning" size="small" round>
|
||||
已复用外部实例
|
||||
</n-tag>
|
||||
</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>
|
||||
<!-- 出网路径:境内「检查更新」失败时最关键的一条排查线索(直连 / 哪个代理) -->
|
||||
<div v-if="agent.update?.via" class="kv">
|
||||
<span>出网</span>
|
||||
<b class="mono ellipsis">{{ agent.update.via }}</b>
|
||||
</div>
|
||||
|
||||
<n-space style="margin-top: 12px">
|
||||
<n-button size="small" :loading="updateBusy" @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 || updateBusy"
|
||||
>
|
||||
更新被控端
|
||||
</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>
|
||||
@@ -22,7 +22,7 @@ const nodeStore = useNodeStore();
|
||||
const msg = useMessage();
|
||||
|
||||
const newName = ref("");
|
||||
const newUrl = ref("ws://127.0.0.1:8765");
|
||||
const newUrl = ref("wss://127.0.0.1:8765");
|
||||
const newKey = ref("");
|
||||
|
||||
async function doConnect() {
|
||||
@@ -73,7 +73,7 @@ const capGroups = computed(() => {
|
||||
<n-card title="连接被控端" size="small">
|
||||
<n-space vertical>
|
||||
<n-input-group>
|
||||
<n-input v-model:value="conn.url" placeholder="ws://127.0.0.1:8765" style="flex: 3" />
|
||||
<n-input v-model:value="conn.url" placeholder="wss://127.0.0.1:8765" style="flex: 3" />
|
||||
<n-input
|
||||
v-model:value="conn.key"
|
||||
type="password"
|
||||
@@ -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.1",
|
||||
"version": "2.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/mcp",
|
||||
"version": "2.0.1",
|
||||
"version": "2.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
@@ -14,7 +14,7 @@
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.12.0",
|
||||
"@modelcontextprotocol/sdk": "^1.32.1",
|
||||
"@nodeagent/client": "workspace:*",
|
||||
"@nodeagent/protocol": "workspace:*"
|
||||
}
|
||||
|
||||
@@ -147,6 +147,7 @@ const TOOL_TO_CAPABILITY: Record<string, string> = {
|
||||
na_service_list: CapabilityNames.ServiceList,
|
||||
na_screenshot: CapabilityNames.ScreenCapture,
|
||||
na_audit: CapabilityNames.AuditList,
|
||||
na_audit_l2: CapabilityNames.AuditL2,
|
||||
na_fs_list: CapabilityNames.FsList,
|
||||
na_fs_read: CapabilityNames.FsRead,
|
||||
na_fs_write: CapabilityNames.FsWrite,
|
||||
|
||||
@@ -165,6 +165,27 @@ export const TOOLS = [
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_audit_l2',
|
||||
description:
|
||||
'L2 系统级审计兜底(仅 Windows):采集操作系统自己的审计事件(Windows Security 日志),补 na_audit 结构上记不到的那块 —— ' +
|
||||
'攻击者绕过 agent 直接操作、或把 agent 停掉时 na_audit 全盲。' +
|
||||
'action: status=查策略是否启用 / enable=开启(需管理员)/ collect=拉取并归一化落盘 / verify=校验 L2 链。' +
|
||||
'⚠️ 本地副本防篡改价值有限,真正的独立性依赖把记录转发出机。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
action: {
|
||||
type: 'string',
|
||||
enum: ['status', 'enable', 'collect', 'verify'],
|
||||
description: 'status(默认)/ enable / collect / verify',
|
||||
},
|
||||
since: { type: 'integer', description: 'collect 的起始时间(Unix ms),默认 24 小时内' },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 5000, description: 'collect 条数上限,默认 1000' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'na_discover',
|
||||
description:
|
||||
|
||||
@@ -0,0 +1,570 @@
|
||||
# ARCH-2026-10-25:nodeagent「全面完善」—— 代码/架构审查 + 增量设计 + 任务分解
|
||||
|
||||
> **文档性质**:阶段 2 产出(架构师高见远)。基线 `3680218`(v2.0.3)。
|
||||
> 范围权威定义:`docs/PRD-2026-10-25-comprehensive-improvement.md`(需求方已批准:P0+P1 全做、P2 推后、版本 v2.1.0、selfupdate 出网走方案 A、L2 同期限 MCP+控制台)。
|
||||
> 工程铁律来源:项目记忆(契约三段 / 错误规范 / 边界 / Windows 陷阱 / 守卫铁律 / LKG 时机 / 审计哈希口径 / 建文件禁 git add -A / 播种兼容 / git push 代理)。
|
||||
> **所有结论均有 `文件:行号` 证据**;凡未找到证据的一律进「待确认」不写成事实。
|
||||
|
||||
---
|
||||
|
||||
## Part 0:审查方法与基线事实
|
||||
|
||||
### 0.1 审查范围(实读文件)
|
||||
|
||||
| 区域 | 文件 |
|
||||
|---|---|
|
||||
| 被控端 | `apps/agent/src/index.ts`、`server.ts`、`audit.ts`、`lkg.ts`、`config.ts`、`events.ts`、`discovery.ts`、`hub-mode.ts`、`capabilities/{index,system,selfupdate,audit-l2,fs,task,monitor,input,window,screen,record,clipboard,app,network,audio,log,await}.ts`、`util/{exec,ps-helper,quote,dpi,platform}.ts` |
|
||||
| 协议 | `packages/protocol/src/{validate,acl,errors,messages,methods,net,ed25519,hmac,hotkeys,ulid,audit-types,audit-anchor}.ts`、`capabilities/{names,schemas}.ts`、`capabilities/manifest/*.ts` |
|
||||
| 控制端 | `apps/cli/src/{index,options,core,help,types}.ts`、`cmd/*.ts`;`apps/mcp/src/index.ts`、`tools/defs.ts`;`apps/console/src/**`、`src-tauri/src/agent.rs`(节选);`sidecar/{main,proxy}.mjs` |
|
||||
| 脚本/测试 | `scripts/lib/version-write.mjs`、`scripts/uninstall.ps1`、`install.ps1`(节选);`tests/unit/*`(43 个)、`tests/e2e/{run,windows}.mjs`(节选) |
|
||||
|
||||
### 0.2 基线实测(复扫确认,PRD §1.1 数据属实)
|
||||
|
||||
| 项 | 值 | 取证 |
|
||||
|---|---|---|
|
||||
| 能力数 | 52 | manifest 实点(51 + `system.audit.l2`,`names.ts:53`) |
|
||||
| MCP 工具 | 41 | `apps/mcp/src/tools/defs.ts` 实点(`mcp-tools.test.mjs:25` 断言 41) |
|
||||
| 单测 | 357 | PRD 实跑记载(本轮未重跑,以 PRD 为基线) |
|
||||
| e2e | 33 + Windows 13 | `tests/e2e/run.mjs` + `windows.mjs` |
|
||||
| L2 在 MCP/控制台 | 0 命中 | `apps/mcp/src grep l2` = 0;`apps/console/src grep l2` = 0 |
|
||||
| CLI rollback | 不存在 | `apps/cli/src grep rollback` 仅 net.ts 类型注释;`index.ts` 无 case |
|
||||
| L2 returns_schema | 空壳 | `manifest/system.ts:321` `returns_schema: { type: 'object' }` |
|
||||
| selfupdate 裸 fetch | 存在 | `selfupdate.ts:42` |
|
||||
|
||||
---
|
||||
|
||||
## Part A:代码/架构审查报告(问题清单)
|
||||
|
||||
> 严重度定义:**P0 = 安全/正确性缺陷**(含 fail-open 守卫缺陷、可无线利用的崩溃/误伤);**P1 = 重要缺口**(功能缺口、观测污染、性能热点、可维护性硬伤);**P2 = 优化**。
|
||||
> 「新」= PRD 需求池之外本轮新发现。
|
||||
|
||||
### A.1 P0(4 条新发现 + 2 条对 PRD 已知项的补充证据)
|
||||
|
||||
#### A-P0-1(新)参数校验的 `additionalProperties:false` 可被原型键绕过 —— fail-open 方向
|
||||
|
||||
- **证据**:`packages/protocol/src/validate.ts:69` `if (!(key in schema.properties))`。
|
||||
- **机理**:`in` 运算符沿原型链查找。`schema.properties` 是普通对象字面量,继承 `Object.prototype`。调用方传入 `{"constructor": 1}`、`{"toString": 1}`、`{"valueOf": 1}` 等键时,`key in properties` 为 `true` → **不判 additional property** → 校验放行。
|
||||
- **触发条件**:任何已认证调用方对**任何**能力传上述键(manifest 大量能力声明了 `additionalProperties: false`,如 `manifest/system.ts:24`)。
|
||||
- **现状危害评估**:当前各 handler 均按名读取已知键,多余键被忽略,**暂无直接可利用路径**;但这是「fail-open 方向的守卫缺陷」—— 与本仓刚修过的 B-03 是**同一错误模式在不同文件复发**:`server.ts:228` 已用 `Object.prototype.hasOwnProperty.call(cfg.keys, clientId)` 修过「`client_id:"constructor"` 沿原型链取到函数当密钥打死进程」。
|
||||
- **修复方向**:改为 `Object.prototype.hasOwnProperty.call(schema.properties, key)`;补单测「传 `constructor` 键必须被判 additional property」(守卫要会红:注入故障 = 改回 `in`,测试必须失败)。
|
||||
|
||||
#### A-P0-2(新)限速桶 key 用「限速值」而非「能力名」—— 同上限值的能力互相挤占;桶只增不删
|
||||
|
||||
- **证据**:`apps/agent/src/server.ts:364` `allowByRate(state.clientId ?? '', rateLimit, String(rateLimit ?? ''))` —— 第三参 `bucket` 传的是 `String(rateLimit)`(限速值本身);`server.ts:101-113` 桶内只过滤当次 60s 窗口,**key 永久留存**(`rateBuckets` Map 无任何清扫)。
|
||||
- **机理一(正确性)**:ACL 支持按能力限速(`rateLimitFor(clientRule, name)`,精确 > glob > 全局)。当某 client 配了「screen.capture ≤ 5/min、app.install ≤ 5/min」两条同值规则时,两者 key 都是 `clientId|5` → **共用一个桶**:截图 5 次后装软件也被限速(误伤),且两者总量仍按 5/min(漏过)。
|
||||
- **机理二(可用性/内存)**:psk 模式下 `client_id` 由客户端自报(`server.ts:295` 注释自陈「配置级约束」)→ 外部可枚举任意 client_id 发起调用 → `rateBuckets` 键无界增长(每个键一个数组,长期运行即内存泄漏);且没有限速配置时 `rateLimit` 为 undefined → `String(undefined)` = `"undefined"` → 所有无差别调用共用一个 key(此时 `allowByRate` 第一行 `if (!limitPerMin) return true` 直接放行,不写桶,故只在前一种情况受害)。
|
||||
- **修复方向**:key 改 `${clientId}|${name}`(能力名);加桶清扫(每次 `allowByRate` 顺手删过期键,或 5 分钟定时);给桶总数设上限(如 10_000,超出淘汰最旧)。单测:同值两能力互不干扰;过期键被清。
|
||||
|
||||
#### A-P0-3(新,PRD P0-2 的前提缺口)LKG 只有指纹没有「内容指针」,且 selfupdate 备份永不清理
|
||||
|
||||
- **证据**:`apps/agent/src/lkg.ts:34-44` `LkgRecord` 只有 `hash`(12 位)/`bytes`/`script_path`/`recorded_at`/`node_version`/`platform` —— **没有任何字段指向「这个版本的内容在哪」**。而 `docs/DELIVERY-PIPELINE.md:233` 承诺「`nodeagent rollback` 读 LKG → 覆盖回入口 → 重启」却从未定义内容来源。
|
||||
- **备份现状(两套命名、一个不清理)**:
|
||||
- 推送式 deploy:固定名 `<target>.bak`,每次部署**覆盖**(`apps/cli/src/cmd/ops.ts:279`,注释记载「真机堆了 15+ 个 bak」后改覆盖);
|
||||
- 拉取式 selfupdate:`<script>.bak-<ts>`(`apps/agent/src/capabilities/selfupdate.ts:162`),**永不清理** —— `ops.ts:336-351` 的清理命令只针对 `${target}.bak-*` 且由 CLI 在部署时触发;selfupdate 路径自己不清,也没有任何其他地方清。
|
||||
- 后果:多次自更新后目录里堆 N 个 ~1MB 备份(deploy 踩过的坑在 selfupdate 路径复发),且**回滚时无法自动判断哪个备份是 LKG**。
|
||||
- **修复方向**:见 B-P0-2(rollback 用「枚举备份 + 指纹匹配」定位内容;LkgRecord 增加 `hash_full` 与 `backup_glob` 只增字段;selfupdate 补「保留最近 3 个」清理)。
|
||||
|
||||
#### A-P0-4(新)`describeArgs` 对大参数同步序列化,阻塞被控端事件循环
|
||||
|
||||
- **证据**:`apps/agent/src/audit.ts:172-177` `JSON.stringify(args ?? {})` 每次 invoke 必做(`server.ts:414` 调用);`fs.write` 的 `data` 可达数 MB(协议单帧,ws 默认 maxPayload 隐式放行)。
|
||||
- **机理**:同步 `JSON.stringify` + sha256 一个 8MB 字符串 ≈ 数十毫秒级 CPU 占用,且**阻塞事件循环** —— 期间 GUI 注入(`input.*`)、事件推送、保活 ping 全部延迟。输入控制类能力对延迟敏感(README 自陈助手预热就是为了省 1-3s)。
|
||||
- **触发条件**:任何带大 payload 的调用(`fs.write` 大文件、`clip.set` 图片、`key.type` 长文本)。
|
||||
- **修复方向**:超过阈值(建议 64KB)只对前 64KB 求摘要并记录总长度(`args_digest` 语义从「全量哈希」改为「截断哈希」需在审计文档注明口径——**只影响审计摘要,不影响安全属性**)。单测:大参数不抛、摘要稳定、耗时阈值不设内容判据(守卫铁律:禁时长阈值当判据 → 用「只序列化前 N 字节」的结构断言)。
|
||||
|
||||
#### A-P0-5(PRD P0-1 补充证据)selfupdate 的失败信息零诊断,与 sidecar 已验证方案差距明确
|
||||
|
||||
- **证据**:`selfupdate.ts:71-77` catch 只包 `url`;无 ECONNRESET/ENOTFOUND/ABORT 分类。对照 `apps/console/sidecar/main.mjs:146-165` 已有 `isAbortLike`/`describeNetError` 成熟实现(注释记载第一版把 ECONNRESET 误报成「超时」的真机教训)。
|
||||
- **触发条件**:境内/代理环境 `fetch failed`(Node fetch 不读代理环境变量,`sidecar/proxy.mjs:5-12` 有真机取证)。
|
||||
- 本体修复见 B-P0-1;此条登记为「错误分类必须同款移植,不许重新发明」。
|
||||
|
||||
#### A-P0-6(PRD P0-2 补充证据)rollback 的能力圈边界:「完全起不来」时 RPC 通道本身不存在
|
||||
|
||||
- **证据**:`index.ts:258-261` 启动失败 `process.exit(1)`;`lkg.ts:22-28` 明确「能启动 = 至少不是打死状态」,反证「打死的版本」根本不会运行 → 没有监听 → CLI 无法 invoke。
|
||||
- **机理**:`nodeagent rollback` 本质是 RPC 命令,**要求被控端可达**。它覆盖的场景是「新版本能启动但需要回退(行为异常/主动避险)」;「新版本进程完全起不来」只能走 `docs/DELIVERY-PIPELINE.md` 附录 C 的物理/带外恢复(备份文件 + LKG 文件手工还原)。
|
||||
- **修复方向**:不是代码修复,是**契约澄清** —— rollback 的帮助文本/README/CHANGELOG 必须写明这一边界,不得暗示「失联也能一条命令救回」。是否要做「启动失败 N 次自动恢复 LKG」的看门狗,与 DELIVERY-PIPELINE §6「刻意不做自动回滚」冲突 → 列为待决策点 R-1。
|
||||
|
||||
### A.2 P1(5 条新发现)
|
||||
|
||||
#### A-P1-1(新)L2 的 collect/enable 把审计写成了 `agent.start` —— 事件类型与事实不符,污染生命周期查询与指标
|
||||
|
||||
- **证据**:`apps/agent/src/capabilities/audit-l2.ts:207`(collect)与 `:286`(enable)均 `audit({ type: 'agent.start', status: 'ok', reason: 'l2.…' })`。
|
||||
- **机理**:`agent.start` 语义是「agent 进程启动」(`index.ts:166` 写入)。L2 动作混入后:`nodeagent audit --type agent`、控制台审计页筛「agent 生命周期」、`computeMetrics` 按 type 的统计都会把「采集了一次 L2」当成「agent 启动过一次」——**观测污染**,且不报错(这正是 C-01 同类漂移)。
|
||||
- **修复方向**:`packages/protocol/src/audit-types.ts`(唯一真源)新增 `agent.l2` → `apps/agent/src/audit.ts:13-46` 联合类型同步(编译期 AssertExact 自动守卫)→ `apps/console/src/types/index.ts:218-233` 前端联合同步 → `audit-filter.test.mjs` 正反守卫自动覆盖。三处同步是契约三段铁律的落地。
|
||||
|
||||
#### A-P1-2(新)monitor 的 tick 无重入保护 —— 慢采样源会并发堆积
|
||||
|
||||
- **证据**:`apps/agent/src/capabilities/monitor.ts:235` `timer: setInterval(() => { void tick(rec); }, intervalMs)` —— 前一个 `tick`(内含 `await takeSample`,command 源要 exec 一整条命令)未返回时,下一个 interval 照常触发。
|
||||
- **触发条件**:`source=command` + 命令耗时 > interval(interval 下限 500ms,`monitor.ts:210`),或多监控并发。
|
||||
- **后果**:并发 `execCommand` 数量无上限(进程风暴);样本文件 append 交错(`appendFileSync` 同步不会写坏行,但样本时间序失真)。
|
||||
- **修复方向**:`tick` 加 in-flight 守卫(跳过本轮并记 `skipped` 观测字段);`takeSample` 对 command 源显式给超时(与 `system.shell.exec` 口径一致)。
|
||||
|
||||
#### A-P1-3(新)事件 watcher 的 exec 失败全静默 —— 「没有事件」与「采不到」不可区分
|
||||
|
||||
- **证据**:`apps/agent/src/events.ts:128`(process watch)与 `:163`(net watch)均 `if (err) return;`。
|
||||
- **触发条件**:`tasklist`/`ps`/`netstat` 不存在、被杀软拦、超时(exec 无 timeout 选项——用的是 `node:child_process` 的 `exec`,`maxBuffer: 4MB`,**无超时**)。
|
||||
- **后果**:用户订阅了进程启停,实际采了 0 个样本,界面显示「0 事件」—— 与「真没有进程启停」完全同形。
|
||||
- **修复方向**:失败时 `push(rec, 'watch_error', …)` 推一条(file watch 已有 `watch_error` 先例,`events.ts:113-115`);给 exec 加 `timeout`。
|
||||
|
||||
#### A-P1-4(新)`readAudit` 的 `total` 与 manifest 描述不符 —— 跨轮转文件时偏小
|
||||
|
||||
- **证据**:`apps/agent/src/audit.ts:346-372`:`total` 只累加「已读文件」的条数,而 `:368` 凑够 `limit` 即 `break`,后续轮转文件不再读;manifest 描述是「本次读取的总条数(未截断前)」(`packages/protocol/src/capabilities/manifest/system.ts:234`)。
|
||||
- **后果**:控制台「共 N 条」在记录量大时偏小(展示偏差,不影响安全与功能正确性)。
|
||||
- **修复方向**(二选一,建议前者):① 把 total 改为「扫描到的候选文件全量条数」(提前退出只影响 entries 不影响 total——需要把 break 后的 total 补算改为近似值并注明);② 改 manifest 描述为「已扫描文件的总条数」。属契约描述与实现对齐,登记 P1 偏轻。
|
||||
|
||||
#### A-P1-5(PRD P0-1 设计输入)被控端无任何出网模块,方案 A 是「新设一个被控端子系统」
|
||||
|
||||
- **证据**:`apps/agent/src` 无 net/ 目录;`grep httpGet|resolveProxy apps/agent/src` = 0。sidecar 的方案(`apps/console/sidecar/proxy.mjs`,524 行,含 16 用例守卫 `tests/unit/sidecar-proxy.test.mjs`)是 .mjs + JSDoc;被控端是 TS + esbuild 单文件打包。
|
||||
- **关注点**:① 移植必须保语法但改类型;② 被控端由计划任务/服务拉起时**环境变量不可见**(Windows 计划任务默认不继承用户 shell 的 HTTPS_PROXY)→ 系统代理探测(reg query)才是主力路径,文档必须写;③ agent.mjs 单文件打包,新增模块不要引外部依赖(铁律:零原生编译、被控端零依赖部署)。
|
||||
- 设计见 B-P0-1。
|
||||
|
||||
### A.3 P2(登记,本轮不修)
|
||||
|
||||
| # | 项 | 证据 | 说明 |
|
||||
|---|---|---|---|
|
||||
| A-P2-1 | `task.ts:117` 死分支 `rec.state = code === 0 ? 'done' : 'done'` | 实读 | 意图应为非零也 done(exit_code 区分)或 failed;现状无害但坏味道 |
|
||||
| A-P2-2 | `validate.ts:38` 每次调用 `new RegExp(schema.pattern)` | 实读 | 热路径重复编译;可缓存(pattern 来自 manifest,进程内不变) |
|
||||
| A-P2-3 | ws `maxPayload` 未显式声明 | `server.ts:637` `new WebSocketServer({ server })` | 依赖 ws 默认值(100MiB)——隐式契约;建议显式 16MiB 并在 manifest fs.write 注明 |
|
||||
| A-P2-4 | `readAudit total` 语义 | 见 A-P1-4 | 若选方案②则降为 P2 |
|
||||
| A-P2-5 | 大文件拆分 | PRD P2-2 已登记(`mcp/src/index.ts` 530 行、`window.ts` 665 行) | 清单驱动小步拆,纯移动 |
|
||||
| A-P2-6 | 协议 fuzz / TLS 夹具 / 覆盖率 / 分支保护 / Linux-macOS L2 | PRD P2-3~P2-7 | 全部推后 |
|
||||
|
||||
### A.4 未发现新违律的点(留档,避免下一轮重复审)
|
||||
|
||||
- Windows 陷阱铁律:`exec.ts:44-69` 数组传参不拼 shell 字符串;`ps-helper.ts:240` `windowsHide: true`;`selfupdate.ts:170-172` 就地覆写不 rename(注释有真机记录);`version-write.mjs` 的 CRLF 纪律完整。
|
||||
- 守卫铁律:`lkg.test.mjs`、`mcp-tools.test.mjs`(sha 序列)、`console-commands.test.mjs`(invoke⇄handler 双向)、`cli-options.test.mjs`(L-01 总闸)均为「会红」的断言式守卫。
|
||||
- 错误规范:`server.ts:434-458` 协议级错误 throw / 能力内部 CapabilityError → `{status:'failed'}` 信封,分层正确。
|
||||
- 审计哈希口径:`audit.ts:107-111` 与 `audit-l2.ts:67-71` 均为「对含 prev 的记录求哈希」,同构。
|
||||
|
||||
---
|
||||
|
||||
## Part B:增量设计方案(P0/P1 逐项,文件级)
|
||||
|
||||
### B-P0-1 被控端自更新走代理出网(方案 A)
|
||||
|
||||
#### 模块边界与依赖方向
|
||||
|
||||
```
|
||||
apps/agent/src/net/proxy.ts (新增,~450 行 TS;只依赖 node 内置模块)
|
||||
▲
|
||||
│ 单向依赖(selfupdate → proxy)
|
||||
apps/agent/src/capabilities/selfupdate.ts (改 download())
|
||||
```
|
||||
|
||||
- `net/proxy.ts` **不 import 任何项目内模块**(不碰 config/audit/capabilities)—— 保证可被单测独立加载、可被将来其它出网能力复用;依赖方向严格单向,杜绝环。
|
||||
- `sidecar/proxy.mjs` → TS 移植映射表:
|
||||
|
||||
| proxy.mjs 导出 | proxy.ts 导出(签名) |
|
||||
|---|---|
|
||||
| `normalizeProxyUrl(raw)` | `normalizeProxyUrl(raw: unknown): URL \| null` |
|
||||
| `isBypassed(targetUrl, noProxy)` | 同 |
|
||||
| `pickEnvProxy(env, targetUrl)` | `pickEnvProxy(env: Record<string,string\|undefined>, targetUrl: string): {url,source,notes}` |
|
||||
| `parseScutilProxy(text)` / `parseRegQueryProxy(text)` | 同 |
|
||||
| `detectSystemProxy(platform, run)` | `detectSystemProxy(platform = process.platform, run = runQuiet)`(`run` 可注入假 runner) |
|
||||
| `resolveProxy({targetUrl, env, system})` | 同 |
|
||||
| `httpGet(url, {proxy, timeoutMs, maxRedirects})` | 同,返回 `{status,statusText,headers,body,url}` |
|
||||
| `tunnelConnection(proxy, target, signal)` | 同(CONNECT 隧道 + 自管 abort,保留 proxy.mjs:359-364 的真机教训注释) |
|
||||
|
||||
#### `selfupdate.ts` 改动
|
||||
|
||||
```ts
|
||||
// 改:download(url, timeoutMs)
|
||||
async function download(url: string, timeoutMs: number): Promise<{ buf: Buffer; via: OutboundInfo }> {
|
||||
const out = await resolveProxy({ targetUrl: url }); // NA_PROXY → env → 系统代理;PAC/SOCKS 记 note 不静默
|
||||
const res = await httpGet(url, { proxy: out.proxy, timeoutMs });
|
||||
// 保留:!res.ok → CapabilityError(EXECUTION_FAILED);content-length 预检;流式上限
|
||||
// 错误分类同款移植 sidecar/main.mjs:146-165(isAbortLike / describeNetError)
|
||||
}
|
||||
```
|
||||
|
||||
- **超时/取消语义**:`timeoutMs`(现有参数,默认 120s,范围 5s~600s,`selfupdate.ts:85`)作为**整条重定向链的总预算**下发给 `httpGet`(与 sidecar 一致:链共享一个 AbortController,防 5 跳 × 30s 穿过 RPC 超时)。能力一旦启动无外部取消通道(协议无取消方法),超时即断由 AbortController 保证。
|
||||
- **返回结构(只增)**:`updated/dry_run/bytes/previous/current_hash/incoming_hash/backup_path/restarted` 之外新增 `via`、`proxy`、`proxy_source`、`outbound_notes`;manifest `AgentUpdate.returns_schema`(`manifest/system.ts:423-435`)同步补这四个属性(契约三段:实现 → manifest → 前端类型;console 的 `UpdateInfo` 已有同名字段可对齐)。
|
||||
- **失败信封**:`CapabilityError.data` 带 `{url, via, proxy, proxy_source, timeout_ms, notes, hint}`——「代理没配 / 哈希不符 / 超时」三类可分(PRD G1 的可诊断验收)。
|
||||
- **安全属性不变**(逐条对照):仍只 http/https(`selfupdate.ts:100-102` 保留);sha256 仍必填且逐字节校验(`:116`);先校验后落盘(`:126` 之前不写目标);写后校验 + 不一致回滚(`:183-195`);TLS 证书默认校验(`tls.connect` 不传 `rejectUnauthorized:false`,与 proxy.mjs:39 纪律一致)。
|
||||
- **备份清理(配套 A-P0-3)**:替换成功后,扫入口同目录 `basename(script).bak-*`,按 mtime 保留**最近 3 个**,其余删(删除失败忽略——被杀软占位的 .bak 删不掉不影响正确性)。抽成纯函数 `planBackupCleanup(files: {name,mtime_ms}[], keep=3): string[]` 供单测。
|
||||
- **agent.json 不加配置项**(NA_PROXY 环境变量 + 系统探测已覆盖;YAGNI)。Windows 计划任务拉起时环境变量不可见的问题写进 DEVELOPMENT.md(系统代理是主力路径)。
|
||||
|
||||
#### 单测(新增 `tests/unit/agent-proxy.test.mjs`,≥16 用例,口径对标 sidecar-proxy.test.mjs)
|
||||
|
||||
1. `NA_PROXY` 优先于一切;2. env 顺序(https: 列表逐键命中);3. `HTTPS_PROXY` 大小写/裸主机补 `http://`;4. `NO_PROXY` 精确/后缀/`host:port`/`*`;5. socks5 记 note 且 `proxy=null`(不静默直连);6. scutil 解析(HTTPSEnable 优先 + ExceptionsList);7. reg 解析(ProxyEnable=0 → 无代理;分协议形态 https 优先);8. PAC → note;9. `<local>` 剔除;10. CIDR 条目不猜(note);11. `resolveProxy` 系统 bypass 命中直连;12. `httpGet` 重定向链共享预算(假 server 验证 abort 一次断全链);13. CONNECT 非 200 → 明确错误;14. http 目标经代理走绝对形态(假代理服务器断言请求行);15. `planBackupCleanup` 保留最近 3;16. 下载上限中断(content-length 超限 + 流式超限两条)。
|
||||
|
||||
#### 兼容性影响
|
||||
|
||||
- L0 被控端变更 → 必须发版(v2.1.0 MINOR 成立:新增返回字段 + 行为修复,无破坏)。
|
||||
- 旧控制端调 `system.agent.update`:不认识新返回字段,忽略即可(VERSIONING §3.3「返回结构只加不减」)。
|
||||
- 控制台会把被控端播种到同版本(`seed_agent_script`)——v2.1.0 发布时被控端随之播种,自更新链路在发布后即带代理能力;**发布顺序**:先发 v2.1.0(agent 含代理)再让设备自更新,天然兼容(旧版 agent 的 update 能力参数不变)。
|
||||
|
||||
### B-P0-2 `nodeagent rollback`(LKG 回滚入口)
|
||||
|
||||
#### 语义定义(含能力圈边界,见 A-P0-6)
|
||||
|
||||
- 目标版本:**LKG**(`system.info.lkg.lkg.hash`)。`--backup <远端路径>` 为高级逃生门(当前已是 LKG 但需回退到更早备份时用)。
|
||||
- 前提:被控端**可达**(RPC 命令)。进程完全起不来的场景走 DELIVERY-PIPELINE 附录 C(物理/带外),帮助文本明示。
|
||||
- **不提供自动回滚**(维持 DELIVERY-PIPELINE §6 取舍,本项只补手动入口)。
|
||||
|
||||
#### 数据结构(`apps/agent/src/lkg.ts` 只增两字段)
|
||||
|
||||
```ts
|
||||
export interface LkgRecord {
|
||||
hash: string; // 前 12 位(既有,展示/比对口径不变)
|
||||
hash_full?: string; // 新增:sha256 全量 64 位 —— 备份指纹匹配用全值,消除 12 位前缀碰撞的理论风险
|
||||
bytes: number;
|
||||
script_path: string;
|
||||
backup_glob?: string; // 新增:回滚内容候选的文件名模式(`<basename>.bak*`),供控制端枚举
|
||||
recorded_at: number;
|
||||
node_version: string;
|
||||
platform: string;
|
||||
}
|
||||
```
|
||||
|
||||
`recordLKG()` 写全量哈希;`readLKG()` 对老文件(无 `hash_full`)回落 12 位比对(向后兼容,不猜)。
|
||||
|
||||
#### 指纹匹配算法(CLI 侧,只用既有能力,零协议变更)
|
||||
|
||||
1. `fs.list` 入口所在目录 → 过滤 `^<basename>\.bak(-.*)?$`(deploy 固定名 + selfupdate 时间戳名)→ 按 `mtime` 新→旧;
|
||||
2. 对每个候选:`fs.read` 分块(512KB,`encoding: 'base64'`)流式计算 sha256;
|
||||
3. 与 `lkg.hash_full ?? lkg.hash` 比对(LKG 只有 12 位时按前缀比);
|
||||
4. 首个命中 = 回滚源;全部落空 → 列出各候选指纹,报错退出(**不动任何文件**)。
|
||||
|
||||
#### 执行流程(对齐 deploy 的成熟姿势)
|
||||
|
||||
```
|
||||
① system.info → agent_script / build.hash / lkg
|
||||
② 无 LKG ───────────────→ 报错退出(exit 2),零改动
|
||||
③ current == LKG.hash ──→ 「当前即 LKG」提示退出(提示 --backup 逃生门)
|
||||
④ 枚举备份候选(fs.list + 过滤)
|
||||
⑤ 指纹匹配(流式读 + sha256)── 无匹配 → 列候选指纹报错退出
|
||||
⑥ 覆盖写入:fs.write append:false 首块 + append:true 续块(同 cmdDeploy ops.ts:321-334)
|
||||
写后校验:fs.read 回读算 sha256
|
||||
├─ 一致 → ⑦
|
||||
└─ 不一致 → 用「回滚源自身」再覆盖一次(还原到回滚源内容=原状)→ 报错退出(带 backup 路径)
|
||||
⑦ system.agent.restart(独立连接,吞连接中断,同 ops.ts:355-365)
|
||||
⑧ probeOnce 轮询直到 build.hash == LKG.hash(90s 墙钟,同 ops.ts:368-389)
|
||||
```
|
||||
|
||||
#### CLI 设计
|
||||
|
||||
- 新文件 `apps/cli/src/cmd/rollback.ts`,导出:
|
||||
- `planRollback(info): {action:'no-lkg'|'already-lkg'|'explicit'|'match', targetHash, scriptPath}`(纯函数)
|
||||
- `pickBackupByHash(cands, wantHash): {path,hash}|null`(纯函数,mtime 排序在外层)
|
||||
- `hashRemoteFile(client, path): Promise<{hash,bytes}>`(流式,client 注入 → 可假测)
|
||||
- `cmdRollback(opts)`(主编排)
|
||||
- 改 `apps/cli/src/index.ts`:`case 'rollback': await cmdRollback(opts)`;
|
||||
- 改 `apps/cli/src/options.ts` + `types.ts`:新增 `backup: string` 选项(`dry-run` 已声明于 `options.ts:124`,L-01 总闸自动覆盖);
|
||||
- 改 `apps/cli/src/help.ts`:命令速查加 `rollback` 行(L-01 守卫:HELP 里每个 `--flag` 必须在 parseArgs 声明——新增 `--backup` 两边同步)。
|
||||
- 输出三段式(PRD §3.2):LKG 记录(hash/bytes/recorded_at/node_version)→ 指纹比对 → 执行结果与复核提示;`--dry-run` 只做 ①~⑤(含匹配结论),零写入。
|
||||
- 退出码:0 成功/已是 LKG;1 执行失败(含写后校验失败);2 前提不满足(无 LKG/无匹配备份)。
|
||||
|
||||
#### 单测(`tests/unit/rollback.test.mjs`,PRD 验收②三条全覆盖)
|
||||
|
||||
1. **无 LKG**:`planRollback` 返回 `no-lkg`,`cmdRollback` 不发起任何 invoke(假 client 断言零调用);
|
||||
2. **指纹已匹配**:`already-lkg` 分支零写入、退出 0;
|
||||
3. **写后校验失败自动回滚**:假 client 让第二次 `fs.read`(回读校验)返回被篡改内容 → 断言流程执行了「还原写」且最终 `fs.read` 到的内容 == 回滚源内容,退出码 1;
|
||||
4. `pickBackupByHash`:mtime 新→早优先、前缀/全量两种比对、空候选返回 null;
|
||||
5. dry-run:只读 invoke(system.info/fs.list/fs.read),无 fs.write/restart。
|
||||
|
||||
#### e2e/真机(PRD 验收③)
|
||||
|
||||
- `tests/e2e/run.mjs`:起被控端 → `deploy` 一份新 agent.mjs(或 update dry-run 后手工替换)→ `rollback --dry-run`(只读断言)→ 实滚 → probe 复核 `build.hash == LKG`。端口/临时 HOME 遵守既有纪律。
|
||||
- 真机矩阵(阶段 4):Windows x64 必跑一遍「更新→回滚→复核」闭环。
|
||||
|
||||
### B-P1-1 MCP 新增 `na_audit_l2`(41→42)
|
||||
|
||||
- `apps/mcp/src/tools/defs.ts`:在 `na_audit` 之后插入 `na_audit_l2`:
|
||||
|
||||
```ts
|
||||
{
|
||||
name: 'na_audit_l2',
|
||||
description: 'L2 系统级审计兜底(仅 Windows 被控端):采集操作系统自己的审计事件(进程创建/登录),' +
|
||||
'补 L1 结构上记不到的部分(攻击者绕过 agent 或停掉 agent 时 L1 全盲)。' +
|
||||
'action=status 查策略/enable 开启(需管理员)/collect 采集落盘/verify 校验 L2 链。' +
|
||||
'⚠️ 信任边界:本地 audit-l2.log 只是归一化副本,防篡改依赖把记录转发出机,不可当作不可篡改证据。',
|
||||
inputSchema: { type:'object', properties: {
|
||||
action: { type:'string', enum:['status','enable','collect','verify'], description:'默认 status' },
|
||||
since: { type:'integer', description:'collect 起始时间(Unix ms),默认 24h 内' },
|
||||
limit: { type:'integer', description:'collect 条数上限,默认 1000,最大 5000' },
|
||||
}, additionalProperties: false },
|
||||
}
|
||||
```
|
||||
|
||||
- `apps/mcp/src/index.ts`:`TOOL_TO_CAPABILITY` 加 `na_audit_l2: CapabilityNames.AuditL2`;`resolveToolCall` 加 case(`action` 默认 `status`,`since/limit` 直传)。
|
||||
- 非 Windows:被控端抛 `E_UNSUPPORTED_PLATFORM`(`audit-l2.ts:190-198`)→ 既有 invoke 归一逻辑转成 error result → **明确平台错误而非空结果**(PRD 验收②);单测断言错误名。
|
||||
- `tests/unit/mcp-tools.test.mjs`:41→42 + `BASELINE_SEQ_SHA` 更新(顺序即呈现顺序,插在 `na_audit` 后)。
|
||||
- **决策记录**:PRD 给了「新工具 vs na_audit 增参」二选一 —— 选**新工具**:工具表只增(旧宿主零副作用)、错误面隔离(平台不支持时不影响 na_audit)、信任边界文案有独立载体。
|
||||
|
||||
### B-P1-2 控制台 L2 审计视图
|
||||
|
||||
- `apps/console/src/views/AuditView.vue`:既有审计页内**增加 L2 分区**(不新增顶级路由,PRD §3.2):
|
||||
- 上部 L2 状态卡:`platform/supported/auditpol/legacy_policy_disabled/cmdline_logging/sysmon_installed/file/file_exists`(与 `audit-l2.ts:247-259` 返回逐字段对齐);
|
||||
- 下部操作行:collect(since/limit 输入 + 按钮)、verify(按钮);结果表列 `ts/kind/subject/object/event_id`(归一化 schema,`docs/AUDIT-L2.md` §4);
|
||||
- 非 Windows:整区置灰 + 文案(复用既有门控模式)。
|
||||
- `apps/console/src/stores/conn.ts:17`:`WINDOWS_ONLY_CAPS` 增加 `'system.audit.l2'`(该数组是事前门控真源,README:389-391 的 app./service 同款机制)。
|
||||
- `apps/console/src/types/index.ts`:新增 `L2Status`/`L2Entry`/`L2VerifyResult`/`L2CollectResult` 类型(与 B-P1-4 补全后的 returns_schema 对齐)。
|
||||
- **无需新 Tauri 命令**(走既有 `invoke_capability`,`api/index.ts` 泛型通道)→ `console-commands.test.mjs` 双向守卫不受影响,零 Rust 改动。
|
||||
- 新增 `tests/unit/console-l2.test.mjs`:① AuditView 引用的每个 L2 字段名都能在 manifest `AuditL2.returns_schema` 顶层属性中找到(B-P1-4 落地后此守卫才生效——反向守卫同 console-contract 思路);② `WINDOWS_ONLY_CAPS` 含 `system.audit.l2`;③ 界面纪律源码守卫(不引图标库、只用 `--na-*` 变量——扫描新增代码块)。
|
||||
|
||||
### B-P1-4 `system.audit.l2` returns_schema 补全
|
||||
|
||||
- `packages/protocol/src/capabilities/manifest/system.ts:296-322`:`returns_schema` 从 `{ type: 'object' }` 补为四种 action 返回的**顶层属性并集**(校验器不支持 oneOf/anyOf,用并集 + description 说明「按 action 出现」,与仓库现有风格一致):
|
||||
|
||||
```ts
|
||||
returns_schema: {
|
||||
type: 'object',
|
||||
description: '按 action 返回:status→策略状态;enable→启用结果;collect→采集结果;verify→链校验结果',
|
||||
properties: {
|
||||
// status
|
||||
platform: { type: 'string' }, supported: { type: 'boolean' },
|
||||
auditpol: { type: 'string' },
|
||||
legacy_policy_disabled: { type: 'boolean' }, cmdline_logging: { type: 'boolean' },
|
||||
sysmon_installed: { type: 'boolean' },
|
||||
file: { type: 'string' }, file_exists: { type: 'boolean' }, note: { type: 'string' },
|
||||
// enable
|
||||
enabled: { type: 'boolean' }, policies: { type: 'array', items: { type: 'string' } }, hint: { type: 'string' },
|
||||
// collect
|
||||
collected: { type: 'integer' }, written: { type: 'integer' }, since: { type: 'integer' }, limit: { type: 'integer' },
|
||||
// verify
|
||||
ok: { type: 'boolean' }, checked: { type: 'integer' },
|
||||
broken_at: { type: 'object', properties: { line: { type: 'integer' }, reason: { type: 'string' } } },
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
- 与 `audit-l2.ts` 四个返回形状(`:247-259` / `:287-293` / `:208-216` / `:314-346`)逐字段核对;**运行时返回不变**(纯文档性契约补齐 → 旧控制端零影响,符合 VERSIONING §3.3)。
|
||||
- `console-contract.test.mjs` 的反向守卫对该能力从「跳过空壳」转为生效;若其实现按名单跳过,需把 `system.audit.l2` 移出豁免名单(或显式记录豁免理由——PRD 验收②的「或」分支)。
|
||||
|
||||
### B-P1-5 L2 e2e 覆盖
|
||||
|
||||
- `tests/e2e/run.mjs`(跨平台,Linux runner 可跑):`system.audit.l2 status` 在非 Windows 必须抛 `E_UNSUPPORTED_PLATFORM`(明确平台错误,不返回空结果)——1 条。
|
||||
- `tests/e2e/windows.mjs`(Windows 专属):① status 返回结构可解析(字段齐全);② collect(since/limit)对空 Security 日志不崩、返回 `collected/written/file`;③ collect 后 verify 返回 `ok:true`;④ 自造 L2 链文件篡改后 verify 报 broken_at(可在单测层做,e2e 做 ①~③)——3 条。
|
||||
- 纪律:不新增端口常量冲突、临时 HOME 隔离遵守既有模式。
|
||||
|
||||
### B-P1-6 `na-gui` 陈旧计划任务清理
|
||||
|
||||
- 抽纯函数到 `scripts/lib/scheduled-tasks.mjs`:`TASK_WHITELIST = ['nodeagent', 'na-gui', 'nodeagent-console']`(白名单,禁通配删除);`planTaskCleanup(existing: string[]): string[]`(返回需删的名单,只删白名单内且存在的)。
|
||||
- `scripts/uninstall.ps1`:卸载流程末尾对白名单逐个 `Unregister-ScheduledTask`(存在性检查 + 动作进日志;纯 ASCII + CRLF + BOM 纪律,参照 `tests/unit/ps1-encoding.test.mjs` 守卫)。
|
||||
- `scripts/control.ps1`:新增 `cleanup` 子命令(复用同一函数块),供「拿到端口占用提示后自救」。
|
||||
- 验证:`tests/unit/na-gui-cleanup.test.mjs` 测纯函数(建名单→计划删除→断言只删白名单);ps1 侧加源码守卫(uninstall.ps1 必须包含白名单清理调用与日志)。真机验证(阶段 4):Windows 上建同名任务→跑 uninstall→断言已删。
|
||||
|
||||
### B-P1-3 文档回填(CHANGELOG / README / DEVELOPMENT / ROADMAP + 数字守卫)
|
||||
|
||||
- **CHANGELOG.md**:`[未发布]` 段重组 —— 当前内容(控制台去命令行化)归位到 `v2.0.3` 已发布节之下方?不:`[未发布]` 现有主题是**未归位**的控制台批次。按 PRD 验收②:`git log v2.0.3..HEAD` 全部提交主题进未发布段,分节:L2 系统级审计(`7d781d1`)、交付流水线(`2bf45fd`)、Linux 适配(`00992e5`)、质量门禁(`141f479`)、console-release(`9f95560`)、console 修复批次(`240c52c/0782d90/9af6e0f/0065916/704fb8c/3680218`)+ 本轮 v2.1.0 新增节(selfupdate 代理 / rollback / MCP L2 / 控制台 L2 / na-gui 清理 / L2 returns_schema+e2e / 文档数字)。
|
||||
- **README.md**:`51 项能力 · 41 个 MCP 工具 · 269 单测 · 30 e2e · 3 Rust`(:171)→ `52 · 42 · 357 · 33+13 · 18`;MCP 工具表加 `na_audit_l2`;命令速查「自持与运维」加 `nodeagent rollback [--dry-run]`;「控制台安装包只作为 CI artifact」行(:387)已被 `9f95560` 修掉 → 删。
|
||||
- **docs/DEVELOPMENT.md**:补 L2 能力条目(params/returns 落定后契约)、selfupdate 代理出网机制(NA_PROXY→env→系统代理 + 计划任务环境变量不可见的告诫)、rollback 能力圈边界。
|
||||
- **docs/ROADMAP.md**:台账刷新(本轮 P0/P1 标记完成)。
|
||||
- **数字守卫(把 PRD 验收③的「或至少人工核对」升级为断言)**:新增 `tests/unit/docs-numbers.test.mjs`:manifest 实点数 vs README「52 项能力」、defs.ts 工具名数 vs README「42」、`grep -c "test("`? 不——单测数不扫源码(会脆),改为:README 数字段与 `docs/ARCH/PRD 基线表` 的一致性由**人工**登记 + 守卫只覆盖「能力数/工具数」两个可从代码数出的值(铁律:守卫要能注入故障变红——改动 manifest 增能力不改 README → 红)。
|
||||
|
||||
---
|
||||
|
||||
## Part C:任务列表(工程师执行顺序)
|
||||
|
||||
> 铁律遵守:≤5 任务;每任务 ≥3 文件;T01 = 基础设施(版本/配置/入口);被控端(L0)只增不改既有行为;每个 P0/P1 完工即补「会红的守卫」。
|
||||
|
||||
### T01 项目基础设施:版本号统一写入 v2.1.0 + CHANGELOG 骨架
|
||||
|
||||
- **Source Files**:
|
||||
- `scripts/lib/version-write.mjs`(不修改,作为写入工具被调用)
|
||||
- 版本落点(一律经 version-write 统一写,禁手改散点):根 `package.json`、各 workspace `package.json`、`apps/console/src-tauri/tauri.conf.json`、`Cargo.toml`、`Cargo.lock`
|
||||
- `CHANGELOG.md`(`[未发布]` 段重组为 v2.1.0 骨架)
|
||||
- `tests/unit/version-write.test.mjs`(若钉了具体版本号则同步)
|
||||
- **Dependencies**:无
|
||||
- **Priority**:P0
|
||||
- **验收标准**:① `node scripts/lib/version-write.mjs` 写入后 `git diff --stat` 覆盖全部 11 处且无散点遗漏;② `pnpm build` 后 `system.info` 自报 v2.1.0;③ CHANGELOG 未发布段涵盖 `git log v2.0.3..HEAD` 全部主题;④ 工作区干净后才允许进入发版流程(铁律 7)。
|
||||
- **预估改动量**:~100 行(CHANGELOG 重组为主)
|
||||
|
||||
### T02 被控端 L0:selfupdate 代理出网(P0-1)+ L2 returns_schema(P1-4)+ 备份清理(A-P0-3)
|
||||
|
||||
- **Source Files**:
|
||||
- 新增 `apps/agent/src/net/proxy.ts`
|
||||
- 新增 `apps/agent/src/net/index.ts`(桶文件,导出面收敛)
|
||||
- 改 `apps/agent/src/capabilities/selfupdate.ts`(download 走代理 + 诊断字段 + 备份清理 + 错误分类)
|
||||
- 改 `packages/protocol/src/capabilities/manifest/system.ts`(AuditL2 returns_schema 补全;AgentUpdate returns 加 via/proxy/proxy_source/outbound_notes)
|
||||
- 新增 `tests/unit/agent-proxy.test.mjs`(≥16 用例)
|
||||
- 新增 `tests/unit/selfupdate-backup-cleanup.test.mjs`(planBackupCleanup + 下载上限,可并入 agent-proxy)
|
||||
- **Dependencies**:T01(版本随包)
|
||||
- **Priority**:P0
|
||||
- **验收标准**:① 单测 16+ 全绿(含 NO_PROXY/socks 报错/PAC note/重定向共享预算);② `system.agent.update` 带 `--dry-run` 在配代理的离线环境(本地 HTTPS 源站 + 本地代理)端到端成功;③ 哈希不符/代理不可达时不落盘且错误带 via/hint;④ 357 单测 + e2e 33 全绿回归;⑤ manifest 变更后 `capabilities.test.mjs`/`console-contract.test.mjs` 守卫全绿。
|
||||
- **预估改动量**:~700 行(proxy.ts 450 + selfupdate 120 + manifest 40 + 测试 200)
|
||||
|
||||
### T03 控制端 L1:`nodeagent rollback`(P0-2)
|
||||
|
||||
- **Source Files**:
|
||||
- 新增 `apps/cli/src/cmd/rollback.ts`
|
||||
- 改 `apps/cli/src/index.ts`(注册 case)、`apps/cli/src/options.ts`(`backup` 选项)、`apps/cli/src/types.ts`(Options 字段)、`apps/cli/src/help.ts`(用法 + 速查行)
|
||||
- 改 `apps/agent/src/lkg.ts`(`hash_full`/`backup_glob` 只增字段,老记录回落 12 位)
|
||||
- 改 `tests/unit/lkg.test.mjs`(补 hash_full 用例)
|
||||
- 新增 `tests/unit/rollback.test.mjs`(5+ 用例,含写后校验失败回滚)
|
||||
- **Dependencies**:T01(独立于 T02,可并行)
|
||||
- **Priority**:P0
|
||||
- **验收标准**:① `--dry-run` 只读(零写 invoke);② 单测覆盖无 LKG/指纹已匹配/写后校验失败自动回滚;③ e2e 走「更新→回滚→复核」闭环(T05 补 e2e 时联调);④ L-01 总闸(help flag ⇄ parseArgs)全绿;⑤ README/HELP 文案写明能力圈边界(可达性前提)。
|
||||
- **预估改动量**:~450 行(rollback.ts 250 + 测试 150 + 周边 50)
|
||||
|
||||
### T04 三端暴露 L2:MCP `na_audit_l2`(P1-1)+ 控制台 L2 视图(P1-2)+ L2 审计类型修正(A-P1-1)
|
||||
|
||||
- **Source Files**:
|
||||
- 改 `apps/mcp/src/tools/defs.ts`(na_audit_l2)、`apps/mcp/src/index.ts`(映射 + 分发)
|
||||
- 改 `tests/unit/mcp-tools.test.mjs`(41→42 + BASELINE_SEQ_SHA)
|
||||
- 改 `apps/console/src/views/AuditView.vue`(L2 分区)、`apps/console/src/stores/conn.ts`(WINDOWS_ONLY_CAPS +system.audit.l2)、`apps/console/src/types/index.ts`(L2 类型)
|
||||
- 改 `packages/protocol/src/audit-types.ts` + `apps/agent/src/audit.ts` + `apps/console/src/types/index.ts`(新增 `agent.l2` 类型三处同步);改 `apps/agent/src/capabilities/audit-l2.ts`(collect/enable 审计改 `agent.l2`)
|
||||
- 新增 `tests/unit/console-l2.test.mjs`(字段名 ⇄ returns_schema 反向守卫 + 门控 + 界面纪律)
|
||||
- **Dependencies**:T02(manifest returns_schema 先落,前端守卫才有比对真源);与 T03 并行
|
||||
- **Priority**:P1
|
||||
- **验收标准**:① MCP 工具表 42、序列 sha 更新、`na_audit_l2` 在非 Windows 返回明确平台错误(单测断言错误名);② 控制台 L2 区在 macOS 被控端置灰;③ `agent.l2` 类型三处同步后 `audit-filter.test.mjs` 自动覆盖;④ console-commands 双向守卫不回归。
|
||||
- **预估改动量**:~500 行(前端 250 + MCP 80 + 类型同步 50 + 测试 120)
|
||||
|
||||
### T05 收口:L2 e2e(P1-5)+ na-gui 清理(P1-6)+ 文档回填与数字守卫(P1-3)
|
||||
|
||||
- **Source Files**:
|
||||
- 改 `tests/e2e/run.mjs`(L2 平台错误用例)、`tests/e2e/windows.mjs`(L2 status/collect/verify 3 条)
|
||||
- 新增 `scripts/lib/scheduled-tasks.mjs`(纯函数);改 `scripts/uninstall.ps1`、`scripts/control.ps1`(白名单清理 + cleanup 子命令)
|
||||
- 新增 `tests/unit/na-gui-cleanup.test.mjs` + `tests/unit/docs-numbers.test.mjs`
|
||||
- 改 `CHANGELOG.md`(v2.1.0 节定稿)、`README.md`(数字/工具表/速查)、`docs/DEVELOPMENT.md`(L2 + 代理 + rollback 边界)、`docs/ROADMAP.md`
|
||||
- **Dependencies**:T02、T03、T04
|
||||
- **Priority**:P1
|
||||
- **验收标准**:① e2e 新增 4 条全绿(33+13 → 37+13 口径);② na-gui 清理只删白名单(单测 + 真机各一遍);③ README/DEVELOPMENT/CHANGELOG 数字与实测一致且 `docs-numbers.test.mjs` 能对「增能力不改 README」变红;④ 全量 357+ 单测 / e2e / Rust 18 回归。
|
||||
- **预估改动量**:~600 行(e2e 150 + 脚本 80 + 测试 120 + 文档 250)
|
||||
|
||||
### 任务依赖图
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
T01[T01 基础设施<br/>版本 v2.1.0 + CHANGELOG 骨架]
|
||||
T02[T02 被控端 L0<br/>selfupdate 代理 + L2 returns_schema + 备份清理]
|
||||
T03[T03 控制端 L1<br/>nodeagent rollback]
|
||||
T04[T04 三端暴露 L2<br/>MCP + 控制台 + agent.l2 类型]
|
||||
T05[T05 收口<br/>L2 e2e + na-gui 清理 + 文档回填]
|
||||
T01 --> T02
|
||||
T01 --> T03
|
||||
T02 --> T04
|
||||
T01 --> T04
|
||||
T02 --> T05
|
||||
T03 --> T05
|
||||
T04 --> T05
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part D:多端适配差距分析
|
||||
|
||||
### D.1 能力矩阵现状(证据:各能力平台分支实读)
|
||||
|
||||
| 能力/组 | Windows | macOS | Linux | 证据 |
|
||||
|---|---|---|---|---|
|
||||
| system.info/status/process.list/shell.exec/task.*/metrics/audit.*(L1) | ✅ | ✅ | ✅ | 无平台分支 |
|
||||
| system.service.list | ✅ | ❌ E_UNSUPPORTED_PLATFORM | ❌ 同 | `system.ts:379-386` |
|
||||
| system.audit.l2 | ✅(auditpol 已落地) | ❌ 明确报错(ESF entitlement 硬限) | ❌ 明确报错(auditd 需预装) | `audit-l2.ts:190-198` |
|
||||
| app.list / app.install | ✅(注册表 + winget) | ❌ | ❌ | `app.ts:14` |
|
||||
| screen.info / screen.capture | ✅ | ✅(需屏幕录制权限) | ⚠️ 依桌面环境 | `screen.ts:268`(macOS 权限提示) |
|
||||
| screen.record | ✅(帧序列 + ffmpeg 可选) | ❌ | ❌ | `record.ts:95` |
|
||||
| screen.find | ✅ UIA→OCR→图像模板 | ✅ Vision OCR(无 UIA,`where` 明确报错) | ❌ | `window.ts:312`、`darwin.ts:18-19`、README:128-129 |
|
||||
| window.list / window.focus | ✅ | ✅ AppleScript(需辅助功能权限) | ❌ | `window.ts:154-190`、`darwin.ts:14-17` |
|
||||
| input.*(键鼠) | ✅(默认禁) | ❌ **只读** | ❌ | `input.ts:39` |
|
||||
| clip.get/set(文本) | ✅ | ✅ | ✅ | 无分支 |
|
||||
| clip 图片 | ✅ | ❌ | ❌ | `clipboard.ts:176` |
|
||||
| event.watch(file/process/net) | ✅ | ✅ | ✅ | `events.ts:124-165` 平台分流 |
|
||||
| gui.await | ✅ | ✅(复用 window/screen 的平台面) | 部分(file/process 可,window/control 随平台) | `await.ts` 复用 |
|
||||
| log.query / monitor.* | ✅ | ✅ | ✅ | 无分支 |
|
||||
| fs.* / audio.* | ✅ | ✅ | fs ✅ / audio ❌(仅 Win+macOS) | `audio.ts:176,209` |
|
||||
| net.apply/confirm/status | ✅ | ❌ | ❌ | `network.ts:308` |
|
||||
| system.agent.update/restart | ✅ | ✅ | ✅ | 无分支 |
|
||||
|
||||
### D.2 差距结论与处置
|
||||
|
||||
1. **本轮补(P1 范围内)**:
|
||||
- L2 的**暴露面**补齐(MCP 工具 + 控制台视图 + returns_schema)—— Windows 已实现,补的是「三端可见性」,不改平台实现面。
|
||||
- 非 Windows 的 L2 入口**事前禁用 + 明确原因**(控制台 WINDOWS_ONLY_CAPS 机制;MCP 错误名可断言)。
|
||||
2. **登记 P2(推后)**:
|
||||
- Linux L2(auditd 需预装,属部署前置条件, AUDIT-L2.md §3.2 已论证「不写假装能用的代码」);
|
||||
- macOS L2(ESF entitlement 不可自签启用);
|
||||
- macOS `input.*` 写入(当前只读;需 AX 授权 + 实现成本,PRD §2.2 诉求⑤已列后续);
|
||||
- macOS/Linux `screen.record`、`net.*`、图片剪贴板;
|
||||
- Windows arm64 CI runner(GitHub 不提供,人工真机流程化)。
|
||||
3. **本轮顺带修正的文档漂移**:README「控制台安装包仅 CI artifact」已被 `9f95560` 修复(P1-3 回填时删该行);README:171 数字全量过期(P1-3)。
|
||||
|
||||
---
|
||||
|
||||
## Part E:设计风险与需再决策点
|
||||
|
||||
| # | 风险/决策 | 影响 | 建议 |
|
||||
|---|---|---|---|
|
||||
| R-1 | **rollback 能力圈边界**:被控端「进程完全起不来」时 RPC 通道不存在,CLI rollback 只能救「能启动但需回退」。PRD G1 用户故事表述为「更新后失联→一条命令恢复」 | 若预期「失联也能远程救」,需追加「启动失败 N 次由看门狗恢复 LKG」——与 DELIVERY-PIPELINE §6「刻意不做自动回滚」直接冲突,且会重新引入回滚循环风险 | 维持手动 rollback + 文档明示边界;物理/带外路径(附录 C)不变。是否加看门狗请需求方明确否决或排期 |
|
||||
| R-2 | **LKG 匹配精度**:LkgRecord 现只有 12 位 hash,备份匹配按前缀比(碰撞概率 1/16¹²,可接受但非零) | 备份指纹匹配的正确性 | 已在 B-P0-2 设计 `hash_full` 只增字段;老记录回落 12 位。零风险落地 |
|
||||
| R-3 | **MCP L2 形态**:PRD 给「新工具 / na_audit 增参」二选一 | 已代选新工具(工具表只增、错误隔离、文案载体) | 若倾向合并进 na_audit,返工量 ~1 小时(T04 内) |
|
||||
| R-4 | **计划任务环境变量不可见**:Windows 计划任务拉起的 agent 读不到用户 shell 的 HTTPS_PROXY | 方案 A 的主力路径实际是「系统代理探测」(reg query WinINET),环境变量只在交互 shell 手工启动时生效 | 文档明示;不在 agent.json 加代理配置项(YAGLI/YAGNI)。若要求配置项,+0.5 天 |
|
||||
| R-5 | **selfupdate 备份保留数**(设计取 3) | 保留太少 → 回退链断;太多 → 磁盘堆积(A-P0-3) | 3 个 + 可被将来配置化;真机验证后定稿 |
|
||||
| R-6 | **`agent.l2` 新审计类型**:新增即改「审计类型唯一真源」三处 | 漏一处即漂移(C-01 教训) | 编译期 AssertExact + audit-filter 双向守卫兜底;T04 内必须三处同步 |
|
||||
|
||||
---
|
||||
|
||||
## 附:关键调用流
|
||||
|
||||
### selfupdate 代理出网(B-P0-1)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant CLI as 控制端 (CLI/MCP/console)
|
||||
participant S as agent server.ts
|
||||
participant SU as selfupdate.ts
|
||||
participant PX as net/proxy.ts
|
||||
participant NET as node:http/https
|
||||
|
||||
CLI->>S: invoke system.agent.update {url, sha256, timeout_ms}
|
||||
S->>SU: agentUpdate(args)
|
||||
SU->>PX: resolveProxy({targetUrl: url})
|
||||
PX->>PX: NA_PROXY → env → 系统代理(reg/scutil)
|
||||
PX-->>SU: {proxy, source, notes}
|
||||
SU->>PX: httpGet(url, {proxy, timeoutMs})
|
||||
PX->>NET: CONNECT 隧道 / 绝对形态 / 直连
|
||||
NET-->>PX: 响应字节流(链共享 AbortController)
|
||||
PX-->>SU: {status, body}
|
||||
SU->>SU: sha256 校验(不符→丢弃,不落盘)
|
||||
SU->>SU: 备份 → 就地覆写 → 写后校验(不符→回滚)
|
||||
SU->>S: agentRestart({delay_ms:1500})
|
||||
SU-->>S: {updated, via, proxy, proxy_source, incoming_hash, ...}
|
||||
S-->>CLI: {status:'ok', data}
|
||||
```
|
||||
|
||||
### rollback(B-P0-2)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant CLI as cmdRollback
|
||||
participant C as NodeAgentClient
|
||||
participant AG as 被控端
|
||||
|
||||
CLI->>C: invoke system.info {}
|
||||
C->>AG: system.info
|
||||
AG-->>CLI: {agent_script, build.hash, lkg:{lkg, matches}}
|
||||
alt 无 LKG
|
||||
CLI-->>CLI: 报错退出(2),零改动
|
||||
else current == LKG.hash
|
||||
CLI-->>CLI: 「当前即 LKG」退出(0),提示 --backup
|
||||
end
|
||||
CLI->>C: invoke fs.list {path: 入口目录}
|
||||
C->>AG: fs.list
|
||||
AG-->>CLI: 候选 *.bak / *.bak-*
|
||||
loop 每个候选(mtime 新→早)
|
||||
CLI->>C: invoke fs.read {path, encoding:'base64', offset} 分块
|
||||
C->>AG: fs.read
|
||||
AG-->>CLI: chunk
|
||||
CLI->>CLI: sha256 累加
|
||||
end
|
||||
alt 无匹配
|
||||
CLI-->>CLI: 列候选指纹报错退出(2)
|
||||
end
|
||||
opt --dry-run
|
||||
CLI-->>CLI: 输出比对结论,零写入
|
||||
end
|
||||
CLI->>C: invoke fs.write {path: 入口, append:false/true} 覆盖
|
||||
C->>AG: fs.write
|
||||
AG-->>CLI: ok
|
||||
CLI->>C: invoke fs.read 回读校验
|
||||
alt 校验失败
|
||||
CLI->>C: invoke fs.write 用回滚源还原
|
||||
CLI-->>CLI: 报错退出(1)
|
||||
end
|
||||
CLI->>C: invoke system.agent.restart(独立连接,吞中断)
|
||||
loop probeOnce 轮询(90s 墙钟)
|
||||
CLI->>AG: 握手 + system.info
|
||||
AG-->>CLI: build.hash
|
||||
end
|
||||
CLI-->>CLI: 复核 build.hash == LKG.hash → 成功
|
||||
```
|
||||
@@ -0,0 +1,155 @@
|
||||
# L2 系统级审计兜底(设计 + Windows 落地)
|
||||
|
||||
> 本文是 `docs/DELIVERY-PIPELINE.md` 与 `docs/SECURITY.md` §4.8 的配套件。
|
||||
> 编写日期:2026-10-17。基线提交:`2bf45fd`。
|
||||
|
||||
---
|
||||
|
||||
## 1. L1 与 L2 的分工(先界定问题)
|
||||
|
||||
| | L1(已实现) | L2(本文) |
|
||||
|---|---|---|
|
||||
| 记录者 | **nodeagent 自己**(`apps/agent/src/audit.ts`) | **操作系统** |
|
||||
| 落盘位置 | `%NODEAGENT_HOME%/audit.log` | OS 审计子系统(Windows 事件日志 / Linux auditd / macOS ESF) |
|
||||
| 防篡改 | 哈希链 + 外部锚定 | 由 OS 内核写入,agent 无权改写 |
|
||||
| 覆盖内容 | 「谁调用了什么能力」 | 「这台机器上发生了什么」(进程创建、登录、文件访问…) |
|
||||
|
||||
### 为什么还需要 L2
|
||||
|
||||
L1 的哈希链能发现**链内**篡改,外部锚定能发现**整链重写**。但两者都有一个前提:
|
||||
**agent 还在记录**。
|
||||
|
||||
一旦攻击者拿到被控端的 root:
|
||||
|
||||
1. 可以直接**停掉 agent**(或让它不再写审计),此后发生的一切 L1 都看不到;
|
||||
2. 即使不停,L1 记的是「agent 知道的事」——攻击者绕过 agent、直接用系统命令做的事,
|
||||
L1 **结构上就记不到**(它只看得见经过自己 RPC 的调用)。
|
||||
|
||||
L2 补的就是这块:**agent 不在场时,机器自己留下了记录**。
|
||||
|
||||
---
|
||||
|
||||
## 2. 必须先说清的信任边界(不要夸大 L2 的作用)
|
||||
|
||||
**L2 采集器本身不提供独立性。**
|
||||
|
||||
采集器是 agent 的一部分,与被审计对象同权限、同进程。攻击者既然能停 agent,
|
||||
就能停采集器。**真正的独立性来自「记录不留在本地」**:
|
||||
|
||||
```
|
||||
OS 审计子系统 ──(只读)──> 采集器 ──> ① 本地 audit-l2.log(副本,仅便利)
|
||||
└─> ② 转发出机(控制端 / 云盘) ← 独立性在这里
|
||||
```
|
||||
|
||||
- ① 只是**归一化副本**,方便查询,防篡改价值有限(我们写的,我们能改)。
|
||||
- ② 才是 L2 的意义所在 —— 与 §4.8 的锚定思路一致:**留在攻击者碰不到的地方**。
|
||||
|
||||
> 因此本文的落地范围是「①采集与归一化 + ②导出接口」,
|
||||
> **自动转发**属于部署侧配置(与锚点外发共用同一条通道),不在本文实现。
|
||||
|
||||
---
|
||||
|
||||
## 3. 三端设计
|
||||
|
||||
### 3.1 Windows —— **本次落地**
|
||||
|
||||
Windows 有两条可选路径:
|
||||
|
||||
| 方案 | 依赖 | 覆盖 | 取舍 |
|
||||
|---|---|---|---|
|
||||
| **A. 内置高级审计策略**(`auditpol.exe`) | **零依赖** | 进程创建 4688、登录 4624/4625、进程退出 4689、对象访问 4663 | ✅ **选它**:无需安装任何东西 |
|
||||
| B. Sysmon(Sysinternals) | 需下载安装 Sysmon 本体 + 配置 XML | 更丰富(镜像加载、命名管道、WMI、DNS) | 可选增强:检测到已装则并用 |
|
||||
|
||||
**为什么先选 A**:Sysmon 是第三方驱动,需单独分发、需重启生效、且不同版本行为有差异;
|
||||
内置策略是 Windows 自带的、开箱即用的那层。能用零依赖方案先立起来,Sysmon 作为可选增强。
|
||||
|
||||
**启用方式**(需管理员):
|
||||
|
||||
```cmd
|
||||
:: 让「高级审计策略」覆盖旧的「类别级策略」—— 否则 subcategory 设置不生效
|
||||
auditpol /set /subcategory:"进程创建" /success:enable /failure:enable
|
||||
auditpol /set /subcategory:"登录" /success:enable /failure:enable
|
||||
auditpol /set /subcategory:"进程终止" /success:enable
|
||||
reg add HKLM\SYSTEM\CurrentControlSet\Control\Lsa /v SCENoApplyLegacyAuditPolicy /t REG_DWORD /d 1 /f
|
||||
```
|
||||
|
||||
> ⚠️ `SCENoApplyLegacyAuditPolicy` 是关键:不设这个,组策略里的旧式类别策略会盖掉
|
||||
> subcategory 设置,表现为「auditpol 显示已启用但事件日志里没有 4688」。
|
||||
|
||||
**采集**:`Get-WinEvent -FilterHashtable @{LogName='Security'; Id=4688,4624,4625; StartTime=…}`
|
||||
(PowerShell 层,避免额外依赖)。
|
||||
|
||||
### 3.2 Linux —— 设计,本次不落地
|
||||
|
||||
- 机制:`auditd` + `auditctl` 规则。
|
||||
- 规则示例:`-a always,exit -F arch=b64 -S execve`(进程执行)、`-w /etc/passwd -p wa`(关键文件)。
|
||||
- 读取:`ausearch` 或 `/var/log/audit/audit.log`。
|
||||
- **为什么本次不落地**:需要 `auditd` 已安装且运行;容器/精简发行版常常没有。
|
||||
属部署侧前置条件,与 Windows 的「零依赖」不同。
|
||||
|
||||
### 3.3 macOS —— 设计,**存在硬性限制**
|
||||
|
||||
- 机制:Endpoint Security Framework(ESF)+ OpenBSM。
|
||||
- ⚠️ **ESF 属 Apple 受限权限**:需向 Apple 申请 `com.apple.developer.endpoint-security.client`
|
||||
entitlement,且客户端必须是**签名的 System Extension**。个人/自签分发**无法启用**。
|
||||
- OpenBSM(`auditd`)在 macOS 上仍可用但自 macOS 11 起被弱化,事件覆盖有限。
|
||||
- **结论**:macOS 侧本文**只出设计不落地**,如实标注可行性限制,不写「假装能用」的代码。
|
||||
|
||||
---
|
||||
|
||||
## 4. 归一化 schema
|
||||
|
||||
L2 记录与 L1 分开落盘(`audit-l2.log`),schema 独立 —— 因为来源与可信度不同,
|
||||
混进 L1 会让「agent 记的」与「OS 记的」无法区分。
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"ts": 1760000000000, // 事件发生时间(来自 OS,不是采集时刻)
|
||||
"src": "windows-security", // 来源:windows-security | sysmon | linux-auditd | macos-esf
|
||||
"event_id": 4688, // OS 原始事件 ID(保留原始,便于人工核对)
|
||||
"kind": "process.create", // 归一化后的类别
|
||||
"subject": "DOMAIN\\User", // 主体
|
||||
"object": "C:\\Windows\\...", // 客体(进程映像 / 文件路径 / 目标)
|
||||
"detail": { "...": "..." }, // 平台特有字段(命令行、PID、父进程…)
|
||||
"collected_at": 1760000001234, // 采集时刻 —— ts 与它相差过大说明曾离线
|
||||
"prev": "...", "hash": "..." // 与 L1 同构的哈希链
|
||||
}
|
||||
```
|
||||
|
||||
`kind` 取值(归一化枚举):
|
||||
|
||||
| kind | Windows | Linux | macOS |
|
||||
|---|---|---|---|
|
||||
| `process.create` | 4688 | `execve` | ESF `exec` |
|
||||
| `process.exit` | 4689 | — | ESF `exit` |
|
||||
| `auth.success` | 4624 | — | ESF `authentication` |
|
||||
| `auth.failure` | 4625 | — | — |
|
||||
| `file.access` | 4663 | `-w ... -p wa` | ESF `open` |
|
||||
|
||||
---
|
||||
|
||||
## 5. 落地范围(本次)
|
||||
|
||||
| 项 | 状态 |
|
||||
|---|---|
|
||||
| 设计文档(三端 + 信任边界 + 可行性限制) | ✅ 本文 |
|
||||
| Windows **状态检查**(策略是否已启用) | ✅ `system.audit.l2 action=status` |
|
||||
| Windows **启用**(auditpol + 关 legacy 覆盖) | ✅ `action=enable`(需管理员) |
|
||||
| Windows **采集**(Security 日志 → 归一化 → 落盘) | ✅ `action=collect` |
|
||||
| L2 链校验 | ✅ `action=verify` |
|
||||
| Linux auditd | ⚠️ 仅设计,未落地 |
|
||||
| macOS ESF | ⚠️ 仅设计,**受 Apple entitlement 限制,不可自签启用** |
|
||||
| 自动转发出机 | ❌ 不在本文范围(与锚点外发共用通道) |
|
||||
|
||||
---
|
||||
|
||||
## 6. 已知限制(如实记录,不掩饰)
|
||||
|
||||
1. **采集器不提供独立性** —— 同进程同权限,攻击者能停 agent 就能停采集器。
|
||||
真正独立性依赖转发出机(§2)。
|
||||
2. **Windows 启用需管理员** —— 已提权时可用;未提权返回明确错误,不静默失败。
|
||||
3. **4688 默认不含完整命令行** —— 需额外开
|
||||
`HKLM\Software\Microsoft\Windows\CurrentVersion\Policies\System\Audit\ProcessCreationIncludeCmdLine_Enabled`
|
||||
才能记命令行,否则只能看到进程名。采集器会检测并提示。
|
||||
4. **量大** —— Security 日志可能极多;采集必须带 `since` 与 `limit`,默认窗口 24 小时、上限 1000 条。
|
||||
5. **Sysmon 未自动安装** —— 检测到则并用,未检测到不尝试下载(不在本仓库职责内)。
|
||||
@@ -0,0 +1,429 @@
|
||||
# nodeagent 交付流水线规范
|
||||
|
||||
> 本文是《代码审计 → 上线 完整交付方案》v1.0(2026-10-14)在 **nodeagent** 项目上的落地适配。
|
||||
> 适配日期:2026-10-17。基线提交:`590f62e`。
|
||||
|
||||
---
|
||||
|
||||
## 0. 适配原则(先说清楚为什么不能照搬)
|
||||
|
||||
原方案面向 **Web/容器服务**(同类多项目实践)——有服务端、有镜像、
|
||||
有多套环境、有线上流量。nodeagent 与之有三处根本差异,照搬会产生**假门禁**(跑得很好但守不住任何东西)。
|
||||
|
||||
因此本文对每一阶段给出三档判定:
|
||||
|
||||
| 判定 | 含义 | 处置 |
|
||||
|---|---|---|
|
||||
| **适用** | 原样落地即可 | 直接实施 |
|
||||
| **改造后适用** | 目标一致,但形态不同 | 换等价手段,并说明为什么等价 |
|
||||
| **不适用** | 本项目不存在该风险面 | **必须给出替代手段**,不允许只写「N/A」了事 |
|
||||
|
||||
> **为什么「不适用」也必须给替代**:原方案每项都对应一类真实风险。若某类风险在本项目不存在,
|
||||
> 说明风险换了形态,替代手段才是真正守它的东西;直接跳过等于承认该风险无人看守。
|
||||
|
||||
### nodeagent 项目画像
|
||||
|
||||
| 维度 | 事实 |
|
||||
|---|---|
|
||||
| 形态 | monorepo:TypeScript(agent/cli/mcp/hub/protocol)+ Rust(Tauri 控制台壳) |
|
||||
| 交付物 | Windows 安装包 zip(含 `node.exe` + `agent.mjs`)、macOS CLI 三件套、macOS Tauri 控制台 app |
|
||||
| 运行位置 | 常驻在**用户自己的机器**上(Windows/Linux/macOS),非服务器 |
|
||||
| 有无服务端 | **无**(点对点直连 + 可选 Hub 中转,Hub 不在本仓库) |
|
||||
| 有无容器 | **无** |
|
||||
| 更新方式 | 被控端**拉取式自更新**(`system.agent.update` 读 `latest.json` + sha256) |
|
||||
| 现有测试 | 单测 **341**(全绿)/ 协议 e2e / Windows 真机 e2e / Rust `cargo test` |
|
||||
|
||||
---
|
||||
|
||||
## 1. 代码审计(四维扫描 + 漏洞分级)
|
||||
|
||||
原方案四维:SAST / SCA / 密钥扫描 / 容器镜像。
|
||||
|
||||
| 维度 | 判定 | 现状 | 落地动作 |
|
||||
|---|---|---|---|
|
||||
| SAST 静态安全 | **适用** | ❌ 无 | 引入 Semgrep(TS/JS 规则集)进 CI,阻断 High 及以上 |
|
||||
| SCA 依赖漏洞 | **适用** | ❌ 无 | `pnpm audit --audit-level=high` 进 CI;锁文件已入库 ✅ |
|
||||
| 密钥与敏感信息 | **适用** | ⚠️ **有巡检无门禁** | 每日定时任务已做隐私巡检(`security-reports/`),但**不阻断合并** → 加 gitleaks 进 CI |
|
||||
| 容器镜像 | **不适用** | — | 无容器。**替代:交付物断言**(见下) |
|
||||
|
||||
### 替代:交付物断言(比镜像扫描更贴本项目的那一层)
|
||||
|
||||
nodeagent 的「制品风险」不在镜像层,而在**分发包本身**。已有两道断言,本文将其升格为正式门禁:
|
||||
|
||||
1. **PE 架构断言**(`scripts/lib/zip-pe-arch.mjs`):解出最终 zip 内的 `node.exe` 读 PE 头,
|
||||
与期望架构比对。
|
||||
- 缘起(2026-10-11 事故):arm64 包里装进了 x64 的 `node.exe`,该包在 ARM Windows 上
|
||||
**能启动、能 listen,只是 TLS 握手不完成** —— 单测/CI/macOS 侧全都抓不到,
|
||||
且两个公开包内 `node.exe` 的 sha256 完全相同。
|
||||
2. **打包期守卫**(`scripts/pack.mjs`):`.ps1` 必须带 UTF-8 BOM、`.cmd` 必须纯 ASCII + CRLF。
|
||||
这两条各自对应一次真机事故(PowerShell 5.1 解析失败 / CMD 代码页截断)。
|
||||
|
||||
> **为什么这两条比通用镜像扫描更要紧**:镜像扫的是「第三方引入的漏洞」,
|
||||
> 而这两条守的是「我们自己把错误的东西发了出去」——后者是本项目历史上真实发生过的。
|
||||
|
||||
### 准入判据(硬门禁)
|
||||
|
||||
- Critical / High 漏洞数 **必须为 0**,一票否决。
|
||||
- Medium 登记并附限期修复计划;Low 可接受但须留痕。
|
||||
- **黄金法则沿用**:密钥一旦提交即视为泄露,删除提交只是止损,**立即轮换**才是根治。
|
||||
nodeagent 的密钥面比一般项目大:PSK、Ed25519 私钥、TLS 自签证书 —— 轮换路径已在
|
||||
`docs/SECURITY.md` §6 应急处置中定义。
|
||||
|
||||
---
|
||||
|
||||
## 2. 质量门禁(Quality Gate)
|
||||
|
||||
| 指标 | 原方案阈值 | 现状 | 落地动作 |
|
||||
|---|---|---|---|
|
||||
| 新增代码覆盖率 | ≥ 80% | ❌ **未采集** | `node --test --experimental-test-coverage` 采集,设阈值(见下) |
|
||||
| 圈复杂度 ≤ 10/方法 | — | ❌ 未检 | 暂不接入(见说明) |
|
||||
| 重复率 ≤ 3% | — | ❌ 未检 | 暂不接入 |
|
||||
| 坏味道新增 0 | — | ❌ 未检 | 暂不接入 |
|
||||
| 阻断性 Bug 0 | — | ✅ `typecheck` + `clippy -D warnings` | 保持 |
|
||||
|
||||
**关于覆盖率阈值的现实校准**:原方案 80% 面向新项目。nodeagent 存量 341 个单测集中在
|
||||
协议/编码/安全守卫,**部分模块(GUI 注入、平台脚本)无法在 CI 上无头验证**。
|
||||
故采用**增量口径**:
|
||||
|
||||
- 全量覆盖率**先只观测、不阻断**(出数字,建立基线);
|
||||
- **新增/修改文件的行覆盖率 ≥ 70% 才阻断**;
|
||||
- 无法无头验证的模块显式标注豁免,并在文档中列出理由。
|
||||
|
||||
> 直接照搬 80% 全量阈值会导致两种后果:要么长期挂 `--skip`(门禁形同虚设),
|
||||
> 要么逼迫团队为 GUI 注入代码写无意义的假断言。**门禁的价值在于守得住,不在于数字好看。**
|
||||
|
||||
**关于圈复杂度/重复率**:需引入 SonarQube,但对本地优先的单人项目运维成本高于收益,
|
||||
本文标记为**可选项**,不进阻断门禁。
|
||||
|
||||
---
|
||||
|
||||
## 3. 自动化测试(金字塔分层)
|
||||
|
||||
| 层 | 判定 | 现状 | 说明 |
|
||||
|---|---|---|---|
|
||||
| 单元测试 | **适用** | ✅ **341 全绿** | `pnpm test:unit` |
|
||||
| 集成测试 | **适用** | ✅ 协议 e2e | `pnpm test:e2e`(TLS / ed25519 / 发现 / 重连 / 文件 / Hub) |
|
||||
| E2E 关键路径 | **适用** | ✅ **Windows 真机 e2e** | `pnpm test:windows`(含 `install.cmd` 双击链路)——**强于原方案**(真机而非模拟) |
|
||||
| DAST 动态扫描 | **不适用** | — | 无对外 Web 服务面。**替代:协议健壮性测试** |
|
||||
|
||||
### 替代:协议健壮性测试(对应「运行态注入」这一类风险)
|
||||
|
||||
DAST 守的是「外部输入打进来会怎样」。nodeagent 的对外输入面是 **WebSocket RPC**,
|
||||
已有等价覆盖(`tests/e2e/run.mjs` 内):
|
||||
|
||||
- 畸形 `hello`(protocol 传数字 / 传对象)
|
||||
- 畸形 `auth`(hmac 传数字 / 对象 + nonce 为 null)
|
||||
- **未认证直接 invoke**(U-01:必须留审计且不崩)
|
||||
- 未知能力 / 参数非法 / ACL 拒绝 / 超频
|
||||
|
||||
> 这比通用 DAST 更精准:DAST 是黑盒猜测,这里是针对本项目协议状态机的定向攻击面覆盖。
|
||||
|
||||
**缺口**:畸形报文目前是**枚举式**用例,未做随机化 fuzz。建议后续加一轮
|
||||
「随机字节 + 随机 JSON 结构」的模糊测试 → 记为可选改进项。
|
||||
|
||||
---
|
||||
|
||||
## 4. CI/CD 构建与制品
|
||||
|
||||
| 项 | 判定 | 现状 |
|
||||
|---|---|---|
|
||||
| 流水线 lint→test→build→scan | **适用** | ⚠️ 有 test/build/typecheck,**缺 scan** |
|
||||
| 制品仓库 | **改造后适用** | ✅ GitHub Release(非镜像仓库) |
|
||||
| 版本号 = 语义化 + git SHA | **适用** | ✅ 语义化版本;`release.mjs` 统一写入 11 处 |
|
||||
| 签名与追溯 | **改造后适用** | ⚠️ 有 sha256 + `latest.json`,**无 cosign/SBOM** |
|
||||
|
||||
### 签名与追溯:容器 cosign 不适用,改为「分发包完整性」三件套
|
||||
|
||||
cosign 签的是容器镜像。nodeagent 分发的是 zip + `agent.mjs`,等价保障是:
|
||||
|
||||
1. **sha256 入库**:`SHA256SUMS.txt` 随包发布(已有)。
|
||||
2. **包内容清单**:zip 内文件列表与体积随 release 记录,用于事后核对「发出去的到底是什么」。
|
||||
3. **可验签**:`system.agent.update` **强制校验 sha256**(不校验等于开放远程代码执行)。
|
||||
这是本项目**最强的一道**——被控端只认哈希,不认「看起来对」的包。
|
||||
|
||||
> **待补(可选)**:对 `agent.mjs` 做 detached GPG 签名,供高安全场景人工验签。
|
||||
> 注意这不能替代 sha256 —— 被控端目前不支持验签,加签名只服务于人工审计场景。
|
||||
|
||||
### 现有 CI 的已知待改项
|
||||
|
||||
- `retention-days: 30` → 建议 **7**(CI artifact 公开可下载,留存越久暴露面越大)。
|
||||
- 三个 workflow 均未集成密钥/依赖扫描(见 §1)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 分级环境发布(dev / staging / prod)
|
||||
|
||||
**判定:不适用**,但必须给替代。
|
||||
|
||||
nodeagent 无服务端、无多环境:`agent` 跑在用户自己的机器上,控制端是本机 CLI。
|
||||
「dev/staging/prod」这类环境隔离在物理上不存在。
|
||||
|
||||
### 替代:真机矩阵验证
|
||||
|
||||
风险换成了形态 —— 不是「哪个环境先炸」,而是「哪类机器先炸」。nodeagent 已多次栽在
|
||||
**平台差异**上(CRLF、路径分隔符、verbatim 路径、ARM Windows 的 TLS、PowerShell 5.1 的 BOM)。
|
||||
|
||||
故替代为**真机矩阵**:
|
||||
|
||||
| 平台 | 架构 / 形态 | 验证方式 |
|
||||
|---|---|---|
|
||||
| Windows | x64 | `tests/e2e/windows.mjs`(`windows-latest` runner) |
|
||||
| Windows | **arm64** | 真机验证(CI 无 arm64 Windows runner,必须人工) |
|
||||
| macOS | CLI 三件套 | 本机安装冒烟 |
|
||||
| macOS | Tauri 控制台 | `console-windows.yml` 构建 + `verify-bundle.mjs` |
|
||||
| Linux | systemd 被控端 | `docs/DEPLOY-LINUX.md` + 真机验证 |
|
||||
|
||||
> **矩阵里最弱的一环是 Windows arm64** —— CI 覆盖不到,而它恰恰是历史上出过
|
||||
> 「能启动但 TLS 握手不完成」这一最难发现故障的平台。本文将其列为**发版前必查项**。
|
||||
|
||||
---
|
||||
|
||||
## 6. 生产发布策略
|
||||
|
||||
| 原方案策略 | 判定 | 说明 |
|
||||
|---|---|---|
|
||||
| 滚动发布 | 不适用 | 无多实例 |
|
||||
| 蓝绿部署 | 不适用 | 无流量切换 |
|
||||
| 金丝雀 | **改造后适用** | 改为**设备分批**:先一台试点 |
|
||||
| Feature Flag | 适用(有限) | 能力级 ACL 可视为开关 |
|
||||
|
||||
### 改造:金丝雀 → 设备分批自更新
|
||||
|
||||
被控端是**拉取式**更新(读 `latest.json`),天然支持分批:
|
||||
|
||||
1. **试点**:选 1 台可达且物理可及的设备先更新,观察 ≥ 30 分钟。
|
||||
2. **放量**:确认无异常后,其余设备再触发更新。
|
||||
3. **回滚**:每设备保留上一版本备份(`agent.mjs.bak-<ts>`)+ `latest.json` 可回退。
|
||||
|
||||
### ⚠️ 本项目特有风险:发布即失联(原方案未覆盖)
|
||||
|
||||
这是 nodeagent **最严重**的上线失败形态,且原方案完全没有对应条款:
|
||||
|
||||
> 一次失败的 agent 更新,会让一台**物理不可达**的远程设备**永久脱管**。
|
||||
> 对 Web 服务,发布失败可以回滚、可以摘流量;对被控端,**回滚通道本身就是被更新的那个程序**。
|
||||
|
||||
缓解措施(部分已有,本文整合为强制项):
|
||||
|
||||
| 措施 | 状态 |
|
||||
|---|---|
|
||||
| 更新前备份当前 `agent.mjs` | ✅ 已有 |
|
||||
| **先备份再原子替换**(同目录 rename,失败可回滚) | ✅ 已有 |
|
||||
| **sha256 强制校验**(不校验不落盘) | ✅ 已有 |
|
||||
| 更新后**重启并轮询校验新指纹**上线 | ✅ 已有(`deploy` 闭环) |
|
||||
| **保留「最后已知可用版本」标记(LKG)** | ✅ 已有(`apps/agent/src/lkg.ts`) |
|
||||
| **控制端手动回滚入口**(`nodeagent rollback`,读 LKG → 覆盖回入口 → 重启 → 复核) | ✅ **v2.1.0 新增**(需被控端可达) |
|
||||
| **启动期自动恢复**(看门狗:启动连续失败 N 次 → 从 LKG 恢复) | ✅ **v2.1.0 新增**(`scripts/startup-guard.mjs`,三端 supervisor 已接入) |
|
||||
| **运行期自动回滚**(更新后健康检查失败即自动回滚到备份) | ⚠️ **刻意不做**,见下 |
|
||||
|
||||
### LKG 的设计(v2.0.x 落地,v2.1.0 补全量指纹)
|
||||
|
||||
- **写入时机**:agent **成功启动后**(服务器已 listen、心跳已起)才写 `agent-lkg.json`。
|
||||
**不在更新写完时就写** —— 那会把尚未验证的版本当成已知可用,LKG 就失去意义。
|
||||
- **内容**:`hash`(与 `system.info` 的 `build.hash` 同口径,前 12 位)、
|
||||
`hash_full`(sha256 **全量 64 位**,v2.1.0 起记录 —— 备份指纹匹配用全值,
|
||||
消除 12 位前缀碰撞的理论风险)、`bytes`、`script_path`、
|
||||
`backup_glob`(v2.1.0 起,回滚内容候选的文件名模式 `<basename>.bak*`)、
|
||||
`recorded_at`、`node_version`、`platform`。
|
||||
**老记录(无 `hash_full`/`backup_glob`)不猜**:消费方回落 12 位前缀比对与
|
||||
`<basename>.bak*` 约定,向后兼容。
|
||||
- **暴露**:`system.info.lkg = { lkg, current_hash, matches, byte_delta }`。
|
||||
`matches=false` = 当前版本还没被证明能启动 → 控制端应**暂缓向其余设备放量**。
|
||||
|
||||
### 回滚通道:手动 rollback + 看门狗自动恢复 + 边界明示(v2.1.0 定稿)
|
||||
|
||||
| 场景 | 通道 | 前提 |
|
||||
|---|---|---|
|
||||
| 新版本能启动但需回退(行为异常 / 主动避险) | **手动** `nodeagent rollback [--dry-run] [--backup <远端路径>]`:指纹比对定位备份 → 覆盖回入口 → 受控重启 → 复核 `build.hash == LKG` | **被控端可达**(RPC 命令;`--dry-run` 只读零写) |
|
||||
| 新版本进程**完全起不来**(无监听可连,CLI 救不了) | **看门狗自动恢复** `scripts/startup-guard.mjs`:supervisor 每次拉起先过守卫,启动连续失败 N 次(默认 3)自动从 LKG 恢复入口 | 外层 supervisor 在位(Windows 计划任务 / Linux systemd / macOS launchd,三端均已接入) |
|
||||
| agent.mjs 自身损坏到 Node 无法加载 / Node 运行时缺失 | **带外恢复**(见附录 C):备份文件 + LKG 文件手工还原 | 物理或远程桌面可达 |
|
||||
|
||||
**为什么恢复目标只有 LKG 一个**:回滚循环(回滚 → 启动 → 又判定失败 → 再回滚)在
|
||||
无人值守设备上比「停在坏版本」更难恢复。看门狗因此只做「回到上一个已知能启动的版本」
|
||||
这一件事,恢复后计数归零;**不做多版本链式回滚、不做版本选择**。
|
||||
|
||||
> **边界(明示,任何文案不得暗示「失联也能一条命令救回」)**:
|
||||
> ① `nodeagent rollback` 是 RPC 命令,**需被控端可达** —— 它救的是「新版本能启动但需回退」;
|
||||
> ② 看门狗自身也是 Node 脚本 —— **agent.mjs 损坏到 Node 无法解析、或 Node 运行时缺失时,
|
||||
> 守卫同样无法运行**,只能带外恢复(附录 C);
|
||||
> ③ 看门狗只守护「启动」这一时刻;运行期崩溃由外层 supervisor 的重启策略负责
|
||||
> (systemd `Restart=always` / 计划任务 `RestartCount=10`)。
|
||||
|
||||
**看门狗的判据(为什么不会误回滚)**:
|
||||
|
||||
- 只计**启动期**死亡(子进程存活 < 30s 宽限期);运行期死亡(计划内重启杀的是长命进程)不计;
|
||||
- 端口已被别的实例服务 → 判定「使命达成」,退 0 不计数(防 supervisor 重启造成的撞车被误记成启动失败);
|
||||
- 无 LKG / 无指纹匹配的备份 → **记日志后放行,绝不擦除 install-dir**;
|
||||
- 计数在 agent **listen 成功后**由 agent 自身归零(与 LKG 同一铁律:不把「启动中」当成功);
|
||||
- 恢复动作全量记 `<数据目录>/logs/startup-guard.log`(JSONL:时间、动作、命中备份、结果)。
|
||||
|
||||
### 为什么**运行期**不做自动回滚(明确的设计取舍,v2.1.0 收窄)
|
||||
|
||||
原三问(无法归因 / 回滚循环 / 失控)在**启动期**已被看门狗以受控方式回答
|
||||
(判据见上:只看启动期死亡、单一 LKG 目标、恢复后归零、全程日志、无匹配不动盘)。
|
||||
但**运行期**仍维持原取舍、刻意不做:
|
||||
|
||||
1. **无法归因**:远程设备上区分不出「更新本身坏了」与「网络抖动 / 端口被占 / 依赖服务未起」。
|
||||
2. **回滚循环**:见上 —— 运行期自动回滚会把「停一次服务」放大成「来回换版本」。
|
||||
3. **失控**:被控端一旦在运行期自作主张改自己的程序,控制端就失去对「当前跑的到底是什么」的掌控。
|
||||
|
||||
故定位为:**LKG 提供确定的回滚目标;启动期决策交给看门狗(有界、可审计),
|
||||
运行期决策交给人**(`nodeagent rollback`)。写入期的闭环(备份、哈希校验、写后校验、
|
||||
不一致即回滚)由 `system.agent.update` 既有实现承担,本文不改动它。
|
||||
|
||||
---
|
||||
|
||||
## 7. 上线监控与复盘
|
||||
|
||||
| 支柱 | 判定 | nodeagent 对应 |
|
||||
|---|---|---|
|
||||
| 指标 | **改造后适用** | `system.metrics`:闭环成功率 / P95 时延 / 安全拦截率 / 装软件成功率 |
|
||||
| 日志 | **适用** | 审计日志 JSONL(哈希链 + 外部锚定),见 `docs/SECURITY.md` §4.8 |
|
||||
| 链路追踪 | 不适用 | 点对点调用,无分布式链路 |
|
||||
| 告警 | 改造后适用 | 无值班通道;改为**发版后主动复核** |
|
||||
|
||||
### 发布后复核清单(替代 Prometheus 盯盘)
|
||||
|
||||
| 项 | 命令 | 期望 |
|
||||
|---|---|---|
|
||||
| 能力数未减少 | `nodeagent list` | 与发版前一致 |
|
||||
| 审计链完整 | `nodeagent audit verify` | `ok: true` |
|
||||
| 锚点自洽 | `nodeagent audit anchor --compare` | 无「整链重写」告警 |
|
||||
| 核心能力可用 | `node scripts/verify.mjs --host … --key …` | 全绿 |
|
||||
| 指标未恶化 | `nodeagent metrics` | 闭环率 ≥ 95%、拦截率 100% |
|
||||
|
||||
### 复盘
|
||||
|
||||
已有载体:`bug-report-2026-10-13.html`(缺陷台账)、`docs/RETROSPECTIVE.md`(短板复盘)、
|
||||
`security-reports/YYYY-MM-DD.md`(每日巡检)。
|
||||
|
||||
**待补**:原方案要求「任何事故/回滚必须写复盘 —— 时间线、根因、改进项、责任人」。
|
||||
本项目为单人项目,「责任人」不适用,改为**改进项是否进入守卫**(即:是否变成一条会红的断言)。
|
||||
|
||||
> 这是本项目复盘最有价值的一问:**没有变成断言的复盘,等于没有复盘**。
|
||||
> 历史上多次同类事故复发(CRLF、路径、编码)都栽在这一点上。
|
||||
|
||||
---
|
||||
|
||||
## 8. 落地清单(Action Checklist)
|
||||
|
||||
按优先级排序,✅ 表示本次已落地:
|
||||
|
||||
- [x] 建立 `.gitignore` 密钥基线(`PSK.txt` / `keys/` / `*.key` / `*.pem` / `config.json` / `agent.json`)— **已有**
|
||||
- [x] 密钥扫描(gitleaks)进 CI 并阻断合并 — **本次落地**(`quality-gate.yml`)
|
||||
- [x] 依赖漏洞扫描(`pnpm audit --audit-level=high`)进 CI — **本次落地**
|
||||
- [ ] SAST(Semgrep TS/JS 规则集)进 CI — **未做**:需先定规则集,否则噪声会淹没真问题
|
||||
- [x] 覆盖率采集接入 CI(全量观测 + 变更文件 ≥70% 阻断)— **本次落地**(`scripts/ci/check-coverage.mjs`)
|
||||
- [x] 提交信息规范强制(Conventional Commits + CI 校验)— **本次落地**(`scripts/ci/check-commit-msg.mjs`)
|
||||
- [x] 交付物 PE 架构断言 — **已有**,升格为门禁
|
||||
- [x] 打包期 `.ps1` BOM / `.cmd` ASCII+CRLF 守卫 — **已有**
|
||||
- [x] CI artifact `retention-days` 30 → 7 — **本次落地**
|
||||
- [x] 保留「最后已知可用版本」标记(LKG)— **本次落地**
|
||||
- [ ] 分支保护:main 需 PR + CI 全绿 — **需仓库设置**(当前 protection 返回 404,已知 P1)
|
||||
- [ ] 发版前 Windows arm64 真机必查(CI 覆盖不到)— **流程项**,已写入本文档
|
||||
- [ ] 协议模糊测试(随机报文 fuzz)— **可选改进项**
|
||||
|
||||
### 本次落地的实测证据
|
||||
|
||||
| 项 | 证据 |
|
||||
|---|---|
|
||||
| 覆盖率门禁 | 三条故障注入均 `exit=1`(阈值 100 / 全量 99 / lcov 缺失);正常路径 `exit=0` |
|
||||
| 覆盖率基线 | 全量 **66.9%**(6399/9560 行,54 文件)—— 印证「照搬 80% 全量不现实」 |
|
||||
| 提交规范校验 | 反例 3 类均红(无类型 / wip / 类型拼错);Merge 与无 scope 正确豁免;**历史 20 条提交一次通过** |
|
||||
| LKG | 新增 8 条单测全绿;含「篡改 hash 必须检出 matches=false」故障注入 |
|
||||
| 契约守卫 | 新增 `lkg` 字段后,既有契约测试**立刻报红**(`SystemInfo` 缺声明)→ 补接口后转绿。这本身是门禁有效的实证 |
|
||||
| 全量单测 | **349 全绿**(落地前 341) |
|
||||
| 门禁会红的验证 | 覆盖率 3 注入 + LKG 1 注入,均实测为真红,非摆设 |
|
||||
|
||||
---
|
||||
|
||||
## 附录 A:CI 骨架(GitHub Actions,适配 nodeagent)
|
||||
|
||||
```yaml
|
||||
# .github/workflows/quality-gate.yml
|
||||
name: Quality Gate
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with: { fetch-depth: 0 }
|
||||
# 密钥扫描:历史 + 增量(nodeagent 密钥面:PSK / ed25519 / TLS 私钥)
|
||||
- uses: gitleaks/gitleaks-action@v2
|
||||
env: { GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} }
|
||||
# 依赖漏洞
|
||||
- uses: pnpm/action-setup@v4
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm audit --audit-level=high
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22 }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm typecheck
|
||||
# 覆盖率:全量出数字(观测),增量阈值由 coverage-gate 判定
|
||||
- run: node --test --experimental-test-coverage --test-reporter=lcov \
|
||||
--test-reporter-destination=coverage/lcov.info "tests/unit/**/*.test.mjs"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with: { name: coverage, path: coverage/, retention-days: 7 }
|
||||
```
|
||||
|
||||
> **注**:`windows-e2e.yml` 保留原样 —— 它跑的是真机链路(`install.cmd` / `control.cmd` 双击),
|
||||
> 是本项目 CI 里最有价值的一环,不可被「更快」的 Linux 流水线替代。
|
||||
|
||||
## 附录 B:提交信息规范
|
||||
|
||||
项目**实际已在用** Conventional Commits(`git log` 可验证),但**无强制**。补齐校验:
|
||||
|
||||
```
|
||||
<type>(<scope>): <subject>
|
||||
|
||||
type: feat | fix | docs | style | refactor | perf | test | chore | ci
|
||||
scope: agent | cli | mcp | hub | protocol | console | pack | release | linux | …
|
||||
```
|
||||
|
||||
示例(取自实际提交):
|
||||
|
||||
```
|
||||
feat(linux): Linux 被控端适配 —— 安装器 + systemd 自启 + 平台守卫 + 部署文档
|
||||
fix(console): sidecar 补 CREATE_NO_WINDOW(不再弹常驻黑窗)
|
||||
chore(privacy): 公开仓库隐私脱敏 —— 清除可定位到个人与设备的字面量
|
||||
```
|
||||
|
||||
## 附录 C:回滚检查清单(nodeagent 版)
|
||||
|
||||
**触发回滚的条件(满足任一)**
|
||||
|
||||
- 目标设备**失联**(更新后无法连接,且已排除网络问题)—— **最高优先级**
|
||||
- `nodeagent list` 能力数少于发版前
|
||||
- `nodeagent audit verify` 返回 `ok: false`
|
||||
- `verify.mjs` 出现失败项
|
||||
|
||||
**回滚动作**
|
||||
|
||||
1. **被控端可达**(能连上)→ `nodeagent rollback`(指纹比对定位 LKG 备份 → 覆盖回入口 → 受控重启 → 复核 `build.hash == LKG`;`--dry-run` 可先只读比对)。
|
||||
2. **被控端起不来**(连不上)→ 看门狗应已介入:启动连续失败 3 次后自动从 LKG 恢复,过程见 `<数据目录>/logs/startup-guard.log`。若看门狗未恢复(无 LKG / 无指纹匹配的备份)或状态异常,登录目标机(物理或带外方式),用备份恢复:`agent.mjs.bak`(或 `agent.mjs.bak-<ts>`)→ `agent.mjs`。
|
||||
3. 若需回退发布通道:把 `latest.json` 指回上一版本 URL + sha256。
|
||||
4. 重启 agent(计划任务或 `run-temp.cmd`)。
|
||||
5. 复核:`verify.mjs` 全绿 → 记录时间线 → 写复盘 → **把根因变成一条会红的断言**。
|
||||
|
||||
> 手动回滚与看门狗的分工:rollback 是「人决策、被控端可达」;看门狗是「机器决策、只限启动期」。
|
||||
> 两者都只认 LKG 一个目标;边界(agent.mjs/Node 损坏时两者都无能为力)见 §6 的明示块。
|
||||
|
||||
---
|
||||
|
||||
## 变更记录
|
||||
|
||||
| 日期 | 内容 |
|
||||
|---|---|
|
||||
| 2026-10-17 | 初版。基于《代码审计 → 上线 完整交付方案》v1.0 适配,基线 `590f62e`,单测 341 全绿。 |
|
||||
| 2026-10-25 | v2.1.0:§6 定稿回滚通道 —— 手动 `nodeagent rollback`(需被控端可达)+ 看门狗自动恢复(`scripts/startup-guard.mjs`,启动连续失败 N 次从 LKG 恢复,三端 supervisor 接入)+ 边界明示(agent.mjs/Node 损坏时守卫也无法运行,只能带外恢复);「运行期自动回滚」维持刻意不做(三问论证收窄到运行期)。LKG 记录补 `hash_full`/`backup_glob` 只增字段(老记录回落 12 位前缀)。附录 C 同步。 |
|
||||
@@ -0,0 +1,137 @@
|
||||
# Linux 被控端部署(Debian 12 / Ubuntu / NAS 系统等 systemd 系统)
|
||||
|
||||
> 适用:把一台 Linux 机器作为**被控端**,让 Mac/Linux 控制端接管。
|
||||
> 本文的能力清单是 **2026-10-13 在 Debian 12 容器里实测**得出的,不是推测。
|
||||
|
||||
---
|
||||
|
||||
## 0. 一句话
|
||||
|
||||
Linux 被控端 = **命令 / 状态 / 文件 / 审计**(服务器型用途);
|
||||
**没有 GUI 能力**(截图、键鼠、窗口、剪贴板、音量均为 Windows/macOS 实现,
|
||||
Linux 端未实现 —— 调用会明确返回 `E_UNSUPPORTED_PLATFORM`,不假装可用)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 快速开始(以 NAS 设备为例)
|
||||
|
||||
```bash
|
||||
# ① 把仓库放到 NAS 上(任选其一)
|
||||
# - git clone https://github.com/jkooe/nodeagent.git
|
||||
# - 或从控制端 scp 整个目录过去
|
||||
|
||||
# ② 生成被控端载荷(在**控制端**或任意有 Node 22+ 的机器上执行一次)
|
||||
node scripts/pack.mjs # → release/agent.mjs
|
||||
|
||||
# ③ 在 NAS 上执行安装(需要 sudo;会自动注册 systemd 服务并开机自启)
|
||||
cd nodeagent
|
||||
sudo bash scripts/install-linux.sh --node-id nas-01
|
||||
|
||||
# ④ 抄下屏幕上打印的「密钥」与「连接命令」,回控制端执行:
|
||||
# nodeagent connect <NAS的IP> --port 8765 --key <密钥> --insecure
|
||||
```
|
||||
|
||||
**装完即自启**:`systemctl status nodeagent` 应为 `active (running)`;
|
||||
重启 NAS 后也会自动拉起(`systemctl is-enabled nodeagent` → `enabled`)。
|
||||
|
||||
### 三种安装模式
|
||||
|
||||
| 模式 | 命令 | 程序目录 | 自启方式 | 适用 |
|
||||
|---|---|---|---|---|
|
||||
| **系统级**(默认) | `sudo bash scripts/install-linux.sh` | `/opt/nodeagent` | 系统 systemd(`multi-user.target`) | NAS / 服务器,需 sudo |
|
||||
| **用户级** | `bash scripts/install-linux.sh --user` | `~/.local/share/nodeagent` | 用户 systemd + `linger` | 无 sudo 的场景 |
|
||||
| **不注册服务** | `sudo bash scripts/install-linux.sh --no-service` | 同上(按模式定) | 自行安排(supervisor / cron `@reboot` / nohup) | systemd 受限的系统 |
|
||||
|
||||
其他参数:`--port 8765`、`--allow-input`(**Linux 上无实际输入能力**,仅留存配置)、
|
||||
`--uninstall [--purge]`。
|
||||
|
||||
---
|
||||
|
||||
## 2. 运行依赖(脚本会自动处理能处理的)
|
||||
|
||||
| 依赖 | 是否必需 | 说明 |
|
||||
|---|---|---|
|
||||
| **Node ≥ 22** | 必需 | 脚本优先用系统 Node;**没有就自动下载** `node-v22.x-linux-<arch>` 到 `<安装目录>/runtime/`(用 npmmirror,国内快)。NAS 上通常没有 Node → 走自动下载 ✓ |
|
||||
| **systemd** | 默认必需 | 用于开机自启。没有就加 `--no-service`(脚本会给出替代启动命令) |
|
||||
| **bash** | 必需 | 安装脚本与部分能力用它。**Alpine 等只有 busybox 的系统需先装 bash**(`apk add bash`) |
|
||||
| **procps(`ps`)** | **`system.process.list` 需要** | Debian/Ubuntu 自带 ✓;**精简容器镜像(如 `node:*-slim`)默认没有** → 表现为「进程列表为空」。装:`apt-get install -y procps` |
|
||||
| **iproute2(`ip`)** | 可选 | 仅用于安装时显示本机 IP;缺失时脚本回退 `hostname -I` / `ifconfig`,再不行显示占位符 ✓ |
|
||||
| **curl 或 wget** | 下载运行时时需要 | 若已装 Node 22+ 则不需要 |
|
||||
|
||||
> ⚠️ 实测踩到的坑:在 **Alpine** 容器里测会看到 `spawn /bin/bash ENOENT`,
|
||||
> 极易误判成「Linux 不支持」——**实际是镜像没有 bash**。
|
||||
> 同理 `node:22-slim` 里 `process.list` 返回空是**没有 procps**,不是代码问题。
|
||||
|
||||
---
|
||||
|
||||
## 3. Linux 上真实可用的能力(实测)
|
||||
|
||||
在同一台(Debian 12)机器上跑 `node scripts/verify.mjs` 的结果:
|
||||
|
||||
| 能力 | Linux | 说明 |
|
||||
|---|---|---|
|
||||
| `system.info` | ✅ | 返回 OS 名 "Linux"、CPU 核数、主机名 |
|
||||
| `system.status` | ✅ | CPU / 内存 / 磁盘(多磁盘正常识别) |
|
||||
| `system.process.list` | ✅** | 需 `procps`;缺则返回空列表 |
|
||||
| `system.shell.exec` | ✅ | 含非零退出码回传、**超时强制终止**(实测 exit=124) |
|
||||
| `system.service.list` | ⚠️ | Windows 服务概念,Linux 上退化为 systemd 单元(部分可用) |
|
||||
| `system.audit.list` / 审计链 | ✅ | 与平台无关 |
|
||||
| `system.task.*` | ✅ | 异步长任务框架 |
|
||||
| `fs.*`(list/read/write/stat) | ✅ | 路径按 POSIX 语义(区分大小写) |
|
||||
| `log.query` / `monitor.*` | ⚠️ 部分 | `monitor` 有 Windows 专属实现,部分子能力不可用 |
|
||||
| `event.watch`(file/process/net) | ✅ | 与平台无关 |
|
||||
| `system.agent.update`(自更新) | ✅ | 与平台无关 |
|
||||
| `screen.*` / `input.*` / `window.*` / `clipboard.*` / `audio.*` / `record.*` | ❌ | **未实现** → 明确返回 `E_UNSUPPORTED_PLATFORM` |
|
||||
| `app.list` / `app.install` | ❌ | winget 专属 → 同样明确拒绝 |
|
||||
|
||||
**汇总对比**:本机 macOS 15 通过/0 失败;Linux(Debian 12)**9 通过 / 1 失败(缺 procps)/ 6 跳过**(跳过的都是 GUI 类)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 管理命令
|
||||
|
||||
```bash
|
||||
# 系统级
|
||||
systemctl status nodeagent
|
||||
journalctl -u nodeagent -f # 实时日志
|
||||
systemctl restart nodeagent
|
||||
sudo bash scripts/install-linux.sh --uninstall # 卸载(保留配置与审计日志)
|
||||
sudo bash scripts/install-linux.sh --uninstall --purge # 连数据目录一起删
|
||||
|
||||
# 用户级:把上面命令里的 systemctl / journalctl 加 --user
|
||||
```
|
||||
|
||||
**配置与数据**(`~/.nodeagent`,与 agent 侧 `agentDir()` 一致):
|
||||
|
||||
| 文件 | 内容 |
|
||||
|---|---|
|
||||
| `agent.json` | 节点 ID / 端口 / **密钥**(权限 600)/ 认证模式 |
|
||||
| `audit.log` | 审计日志(JSONL,10MB 轮转留 5 份) |
|
||||
| `audit-anchors.jsonl` | 审计链锚点(启用锚定后产生) |
|
||||
|
||||
---
|
||||
|
||||
## 5. 故障排查(按实测顺序)
|
||||
|
||||
| 现象 | 原因 | 处理 |
|
||||
|---|---|---|
|
||||
| `未检测到 systemd` | 容器/非 systemd 系统 | 加 `--no-service`,或改用 supervisor |
|
||||
| `spawn /bin/bash ENOENT` | **系统没有 bash**(Alpine 等) | `apk add bash` / `apt-get install -y bash` |
|
||||
| `进程列表为空` | **没有 `ps`**(slim 镜像) | `apt-get install -y procps` |
|
||||
| 脚本"跑到一半就没了" | `set -e` 下某个命令替换失败(如缺 `ip`) | 已在 v2.0.3+ 加固为多级回退;旧版请升级脚本 |
|
||||
| 控制端连不上,被控端却显示已监听 | 防火墙 | Debian: `ufw allow 8765/tcp`;firewalld: `firewall-cmd --add-port=8765/tcp --permanent && firewall-cmd --reload` |
|
||||
| `E_AUTH_FAILED` | 密钥不一致 | 以被控端 `~/.nodeagent/agent.json` 的 `key` 为准 |
|
||||
| GUI 能力报 `E_UNSUPPORTED_PLATFORM` | **正常**(Linux 无 GUI 实现) | 无需处理;这是「诚实拒绝」而非故障 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 建议(服务器型被控端)
|
||||
|
||||
1. **尽快启用审计链外部锚定** —— 把链头存到 NAS 之外(云盘/另一台机器)。
|
||||
本机锚点等于没锚:被控端被 root 后锚点也会一起被改。
|
||||
```bash
|
||||
node scripts/anchor-audit.mjs collect --out /tmp/anchor-$(date +%Y%m%d).jsonl
|
||||
# 再把该文件上传到云盘,之后用 verify 子命令比对
|
||||
```
|
||||
2. **不要开 `--allow-input`** —— Linux 上无实际输入能力,开着只是加大配置面。
|
||||
3. 若机器只有公网无内网互通,考虑用 **Hub 中转**(被控端主动外连,穿 NAT)。
|
||||
+102
-3
@@ -62,7 +62,7 @@
|
||||
- Hub 中枢(多设备注册/发现/路由)→ 预留接口
|
||||
- Android / Linux 端 → 后续
|
||||
- 公网穿透 → 后续(v1 仅局域网)
|
||||
- 画面串流(远程桌面级)→ 属 OnlyInOne 领域
|
||||
- 画面串流(远程桌面级)→ 属另一独立项目领域
|
||||
|
||||
### 1.4 分阶段路线
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
|
||||
| 术语 | 定义 |
|
||||
|---|---|
|
||||
| **控制端** | 皇上的 MacBook,发起调用的 AI/CLI 所在端 |
|
||||
| **控制端** | 用户的 MacBook,发起调用的 AI/CLI 所在端 |
|
||||
| **被控端** | Windows 机器,执行命令/装软件/查状态的端 |
|
||||
| **Agent** | 常驻在 Windows 上的后台服务,接收并执行控制端指令 |
|
||||
| **能力** | 一个可被远程调用的原子操作(执行命令、装软件、查状态) |
|
||||
@@ -469,7 +469,7 @@
|
||||
| macOS 控制端 | Keychain | `security add-generic-password -s nodeagent -a <client_id> -w <key>` |
|
||||
| Windows 被控端 | DPAPI / 凭据管理器 | `ConvertTo-SecureString` 或 `node-dpapi` |
|
||||
|
||||
**铁律**:密钥不明文落盘、不写日志、不经网络传输(HMAC 只传摘要,不传密钥原文)。**与 OnlyInOne「公钥上云 / 私钥永不上传」约定一致。**
|
||||
**铁律**:密钥不明文落盘、不写日志、不经网络传输(HMAC 只传摘要,不传密钥原文)。**与另一项目「公钥上云 / 私钥永不上传」的约定一致。**
|
||||
|
||||
### 6.3 演进路径
|
||||
|
||||
@@ -728,6 +728,105 @@ const timer = setTimeout(() => {
|
||||
| 证书 | 自签证书(两端固定校验,防中间人) |
|
||||
| 连接方向 | 控制端主动 → 被控端监听 |
|
||||
|
||||
### 7.4 被控端自启与看门狗(v2.1.0,决策 R-1)
|
||||
|
||||
被控端的启动链是:**外层 supervisor → 看门狗(`scripts/startup-guard.mjs`)→ agent**。
|
||||
看门狗解决 `nodeagent rollback` 覆盖不到的场景:进程**完全起不来**时没有监听可连,
|
||||
RPC 命令无能为力;看门狗跑在 supervisor 链上,启动连续失败 N 次(默认 3)自动从
|
||||
LKG 恢复入口文件。判据与边界见 `docs/DELIVERY-PIPELINE.md` §6。
|
||||
|
||||
| 平台 | supervisor | 接入点(仓库文件) |
|
||||
|---|---|---|
|
||||
| Windows | 计划任务 `nodeagent` | `scripts/install.ps1` 的 `New-ScheduledTaskAction`(启动动作包一层 guard,`RestartCount 10`;guard 向 agent 注入 `NODEAGENT_HOME`,不依赖任务环境变量) |
|
||||
| Linux | systemd unit `nodeagent` | `scripts/install-linux.sh` 的 `ExecStart`(包一层 guard,`Restart=always` 负责重启) |
|
||||
| macOS | launchd LaunchAgent | **无安装器** —— 手工按下方 plist 接入(仓库不含 macOS 被控端安装器) |
|
||||
|
||||
**Windows / Linux 的接入由安装器自动完成**,无需手工配置;`install.ps1` 找不到看门狗
|
||||
脚本时按原样直启 agent(兜底,不因缺脚本而装不上)。
|
||||
|
||||
**macOS 手工接入(launchd)**——被控端以 LaunchAgent 形式常驻时,把
|
||||
`ProgramArguments` 包一层看门狗(`~/Library/LaunchAgents/com.nodeagent.agent.plist`):
|
||||
|
||||
```xml
|
||||
<?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">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key><string>com.nodeagent.agent</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/opt/nodeagent/runtime/bin/node</string>
|
||||
<string>/opt/nodeagent/scripts/startup-guard.mjs</string>
|
||||
<string>--data-dir</string><string>/Users/Shared/.nodeagent</string>
|
||||
<string>--lkg-dir</string><string>/opt/nodeagent</string>
|
||||
<string>--install-dir</string><string>/opt/nodeagent</string>
|
||||
<string>--threshold</string><string>3</string>
|
||||
<string>--</string>
|
||||
<string>/opt/nodeagent/runtime/bin/node</string>
|
||||
<string>/opt/nodeagent/agent.mjs</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key><string>/opt/nodeagent</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict><key>NODEAGENT_HOME</key><string>/Users/Shared/.nodeagent</string></dict>
|
||||
<key>RunAtLoad</key><true/>
|
||||
<!-- 退出码非 0 才拉起(0 = 服务已由别的实例提供 / 已干净停止);
|
||||
配合 ThrottleInterval 防崩溃启动风暴 -->
|
||||
<key>KeepAlive</key>
|
||||
<dict><key>SuccessfulExit</key><false/></dict>
|
||||
<key>ThrottleInterval</key><integer>10</integer>
|
||||
<key>StandardOutPath</key><string>/opt/nodeagent/logs/agent.out.log</string>
|
||||
<key>StandardErrorPath</key><string>/opt/nodeagent/logs/agent.err.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
```
|
||||
|
||||
加载:`launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.nodeagent.agent.plist`。
|
||||
看门狗日志(恢复动作、失败计数)在 `<data-dir>/logs/startup-guard.log`(JSONL)。
|
||||
|
||||
**铁律**:看门狗自身必须「裸环境可跑」——零项目依赖、不 import 项目内模块,
|
||||
只依赖 Node ≥22 内置模块 + 同目录 `lib/startup-guard-core.mjs`(纯函数)。
|
||||
分发时两个文件必须保持相对位置一起拷贝(pack.mjs / install-linux.sh 已保证)。
|
||||
|
||||
### 与 `nodeagent rollback` 的分工(v2.1.0 回滚双通道)
|
||||
|
||||
| 通道 | 触发者 | 覆盖场景 | 边界 |
|
||||
|---|---|---|---|
|
||||
| 手动 rollback | 控制端 RPC(`nodeagent rollback`) | 新版本**能启动**但需回退 | 需被控端可达(有监听可连) |
|
||||
| 看门狗自动恢复 | supervisor 链上守卫 | 新版本**完全起不来**(启动连续失败 N 次) | 只恢复到 LKG;无 LKG/无匹配放行不动盘 |
|
||||
|
||||
**为什么运行期仍不做自动回滚**(v2.1.0 刻意收窄,引述 `docs/DELIVERY-PIPELINE.md` §6):
|
||||
① 无法归因 —— 远程设备上「更新后健康检查失败」分不清是更新本身坏了还是
|
||||
网络抖动 / 端口被占 / 依赖服务没起;② 回滚循环 —— 自动回滚可能触发
|
||||
「回滚→启动→又判定失败→再回滚」,无人值守设备上比停在坏版本更难恢复;
|
||||
③ 失控 —— 被控端一旦在运行期自作主张改自己的程序,控制端失去对
|
||||
「当前跑的是什么」的掌控。故运行期的决策权留给控制端(rollback),
|
||||
启动期则由看门狗兜底(目标只有一个:LKG)。
|
||||
|
||||
### 7.5 selfupdate 代理出网(v2.1.0,P0-1)
|
||||
|
||||
真机症状:被控端自更新下载 `fetch failed`。根因不是网络差 —— Node 的 `fetch`
|
||||
(undici)**不读** `HTTPS_PROXY` 等环境变量,而境内访问 GitHub 必经代理。
|
||||
`apps/agent/src/net/proxy.ts`(sidecar 已验证方案的 TS 移植,只依赖 node 内置模块)
|
||||
接管 `system.agent.update` 的下载:
|
||||
|
||||
- **代理来源优先级**:`NA_PROXY` → 目标协议环境变量(`http_proxy` / `https_proxy` /
|
||||
`HTTPS_PROXY` ...,大小写都认)→ 系统代理(Windows `reg query WinINET` /
|
||||
macOS `scutil --proxy`)。
|
||||
- **PAC 与 socks5 明确报 note、不静默降级**:PAC 需 JS 运行时解析、
|
||||
socks5 无内置客户端 —— 报出来让人知道「为什么没走代理」,而不是
|
||||
悄悄直连后给一个莫名的超时。
|
||||
- **回环目标恒直连**(有意的设计偏离):代理另一侧的 localhost 不是本机 ——
|
||||
CI/dev 机设了 `HTTP_PROXY` 时,发往 `127.0.0.1` 自建镜像的请求会被送进
|
||||
远程代理而 404。
|
||||
- **重定向链共享超时预算**(防 5 跳 × 30s 穿过 RPC 超时);
|
||||
**CONNECT 隧道 abort 自管**(外层 request 建立前 signal 无人受理 ——
|
||||
真机 75s 配置挂 109s 的教训);TLS 默认校验证书。
|
||||
- `maxBytes` 双闸(content-length 预检 + 流式中断),保住「64MB 上限」。
|
||||
- ⚠️ **计划任务环境变量不可见**:Windows 计划任务的启动动作**不带**用户环境变量,
|
||||
在 `install.ps1` 里设 `NA_PROXY` 对任务里的 agent 无效 —— 要么写进任务的
|
||||
`EnvironmentVariables`,要么配在 agent.json 的 `update` 段(被控端自己的配置,
|
||||
不依赖任何外部环境)。
|
||||
|
||||
---
|
||||
|
||||
## 第 8 章 工程结构
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
# 增量 PRD:nodeagent「全面完善」
|
||||
|
||||
> **文档性质**:增量 PRD(简单规格,不做竞品分析)。基线:`3680218`(v2.0.3)。
|
||||
> 撰写:许清楚(产品经理)|日期:2026-10-25|状态:待需求方确认范围后进入阶段 2。
|
||||
|
||||
## 1. 项目信息
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| Language | 简体中文 |
|
||||
| Programming Language | TypeScript(pnpm monorepo)+ Rust(Tauri 2 壳) |
|
||||
| Project Name | nodeagent |
|
||||
| 原始需求复述 | 「请帮我全面完善这个项目:审查现有代码与架构,指出存在的问题和改进空间;补充缺失的核心功能与边界情况处理;优化代码结构、性能与可维护性;完善必要的文档、注释与测试用例;增加多端适配;并给出清晰、可落地的修改方案和代码示例。」 |
|
||||
|
||||
### 1.1 基线实测证据(2026-10-25,HEAD `3680218`)
|
||||
|
||||
| 项 | 实测值 | 取证方式 |
|
||||
|---|---|---|
|
||||
| 单元测试 | **357 全绿** | `pnpm test:unit` 实跑 |
|
||||
| 端到端 | e2e **33**(`tests/e2e/run.mjs`)+ Windows 专属 **13**(`windows.mjs`) | 用例计数 |
|
||||
| Rust 单测 | 18 | CHANGELOG 记载 |
|
||||
| 能力数 | **52**(51 + `system.audit.l2`) | manifest 实点 |
|
||||
| MCP 工具 | 41 | `apps/mcp/src/tools/defs.ts` 计数 |
|
||||
| 全量覆盖率 | **66.9%**(6399/9560 行) | `docs/DELIVERY-PIPELINE.md` §8 实测记载 |
|
||||
| 覆盖率门禁 | **已落地**:变更文件行覆盖 ≥70% 阻断 | `scripts/ci/check-coverage.mjs` + `quality-gate.yml` |
|
||||
|
||||
### 1.2 对「历史待改进项」的核实结论(先纠偏,再规划)
|
||||
|
||||
主理人转来的 6 条待改进项来自历史审计,其中 **3 条已被近期提交修复或部分修复**。本 PRD 按仓库现状重新基线化,避免把已完工项排进本轮:
|
||||
|
||||
| # | 历史待改进项 | 核实结论 | 证据 |
|
||||
|---|---|---|---|
|
||||
| 1 | L2 系统级审计兜底未落地,docs/AUDIT-L2.md 暴露 L2 参数是 P1 风险 | ❌ **已落地**。Windows auditpol 方案已实现并发版待发;文档现为已实现功能的设计说明,「未落地暴露」风险消除。Linux/macOS 仍仅设计(macOS 受 Apple entitlement 硬限) | 提交 `7d781d1`;`apps/agent/src/capabilities/audit-l2.ts`;`tests/unit/audit-l2.test.mjs`;manifest `system.audit.l2` |
|
||||
| 2 | 被控端自更新 `selfupdate.ts` 裸 fetch,境内失败 | ✅ **确认存在,未修** | `apps/agent/src/capabilities/selfupdate.ts:42` 裸 `fetch(url,…)`;CHANGELOG「检查更新在境内必然失败」节自陈「本次未改」 |
|
||||
| 3 | 陈旧计划任务 `na-gui` 抢占 8765 | ✅ **确认遗留,未清理** | `scripts/uninstall.ps1` 无清理逻辑;`apps/console/src-tauri/src/agent.rs:1084` 测试注释引为动机;控制台侧只做了单实例保护+占用报错提示 |
|
||||
| 4 | 契约漂移 6 项(缺 seq/session_id/risk 等字段) | ⚠️ **仓库内未找到该台账**,无法确认。已发现的相关漂移见需求池 P1-6/待确认问题 Q3 | 全仓 grep `session_id`/`seq:`/`risk` 于 protocol 源 0 命中;`tests/unit/console-contract.test.mjs` 20 用例守卫在役 |
|
||||
| 5 | 控制台未接 release.mjs、无自更新通道 | ❌ **接入 Release 已完成**;「控制台自身自更新」仍是真缺口(派生项) | 提交 `9f95560`;`.github/workflows/console-release.yml`(tag 触发,publish job 挂 4 个安装包);`latest.json` 仅含 agent 资产(`scripts/release.mjs` 无 console 字样) |
|
||||
| 6 | 覆盖率 66.9%,变更文件门禁 ≥70% | ✅ **门禁已落地**(含故障注入验证);66.9% 为存量基线,非缺陷 | `docs/DELIVERY-PIPELINE.md` §2/§8;`quality-gate.yml` |
|
||||
|
||||
## 2. 产品定义
|
||||
|
||||
### 2.1 产品目标(本次「全面完善」,可度量)
|
||||
|
||||
| # | 目标 | 衡量标准 |
|
||||
|---|---|---|
|
||||
| G1 | **两条「自我修复」通道恢复可用**:被控端拉取式自更新(境内/代理环境)与更新失败后的 LKG 回滚 | 自更新在配置代理后可拉取成功、失败时报真实原因(带 via/proxy 诊断字段);`nodeagent rollback` 可从 LKG 恢复并复核上线 |
|
||||
| G2 | **三端暴露完整一致**:52 项能力在 agent / CLI / MCP 三端无缺口,文档数字与实现一致 | MCP 补齐 L2 工具(41→42);README/CHANGELOG/DEVELOPMENT 的能力数、测试数与实测一致;CHANGELOG 覆盖全部未发布提交 |
|
||||
| G3 | **契约与测试守住新增面**:新能力有契约、有测试、有文档三件套 | `system.audit.l2` 的 `returns_schema` 补齐(消除空壳);L2 有 e2e 覆盖(当前 0);变更文件覆盖率门禁继续有效(≥70%) |
|
||||
|
||||
### 2.2 范围界定(关键:6 条诉求 → 做 / 不做 / 后续)
|
||||
|
||||
> nodeagent 是**已上线框架**,agent 与 CLI/MCP 分开更新、新旧混用是常态(`docs/VERSIONING.md`)。
|
||||
> 任何改动按「影响面」分三层,L0 层一律遵守兼容性四铁律(只增不改 / 不加必填参数 / 返回结构只加不减 / 能力集合为唯一事实源)。
|
||||
|
||||
**影响面分层**
|
||||
|
||||
| 层 | 范围 | 兼容性成本 | 版本动作 |
|
||||
|---|---|---|---|
|
||||
| L0 | 被控端 `apps/agent/**`(agent.mjs 运行时) | 高——旧控制端可能仍在跑 | 被控端变更必须 `pnpm release patch|minor`,否则 agent 谎报版本 |
|
||||
| L1 | 控制端 CLI / MCP / 控制台(`apps/cli`、`apps/mcp`、`apps/console`) | 低——只增工具/命令/页面,旧端忽略新能力 | 随发版走;不触发 agent 版本 |
|
||||
| L2 | 文档 / 测试 / CI / 脚本 | 无 | 随时可改 |
|
||||
|
||||
**诉求映射**
|
||||
|
||||
| 用户诉求 | 本次做 | 本次不做 | 后续版本做 |
|
||||
|---|---|---|---|
|
||||
| ① 审查代码与架构、指出问题 | 按本 PRD 需求池做**靶向审查**(阶段 2 架构师主导),产出缺陷台账并逐条转守卫 | 全仓重写、架构重构、换框架 | — |
|
||||
| ② 补充缺失核心功能与边界情况 | selfupdate 代理出网(L0);LKG 回滚入口(L1);MCP L2 工具(L1);控制台 L2 视图(L1);`na-gui` 清理(L2 脚本) | Linux auditd 落地(需预装,属部署前置);macOS ESF(Apple entitlement 不可自签启用);Sysmon 自动安装(第三方驱动) | Linux/macOS L2、Sysmon 并用增强 |
|
||||
| ③ 优化代码结构、性能与可维护性 | 清单驱动的小步拆分(MCP index/defs、agent 大文件);单一真源收敛;无行为变化 | 大爆炸重构;为拆而拆 | 按坏味道数据持续治理 |
|
||||
| ④ 完善文档、注释与测试 | CHANGELOG 回填未发布提交;README/DEVELOPMENT 数字与 L2 补章;L2 e2e;`returns_schema` 补全 | 重写历史文档 | 协议模糊测试(fuzz) |
|
||||
| ⑤ 增加多端适配 | 控制台 L2 页面(Windows 门控,复用既有 `WINDOWS_ONLY_CAPS` 机制);Linux 部署文档现状同步 | macOS 被控端 GUI 写入能力(现为只读);Windows arm64 CI runner(GitHub 不提供,只能人工) | arm64 真机验证流程化 |
|
||||
| ⑥ 修改方案和代码示例 | 阶段 2/3 产出,PRD 只定范围、优先级与验收 | — | — |
|
||||
|
||||
### 2.3 用户故事
|
||||
|
||||
1. **被控端运维者**:我在境内有一台无人值守的 Windows 被控端。「我想让它拉取自更新时能走系统代理,并且更新失败时能看到真实原因(是代理没配、还是哈希不符),而不是一句 `fetch failed`。」另外,「万一更新后失联,我希望能有一条命令把它恢复到上一个已知能启动的版本,而不是只能物理到场。」
|
||||
2. **控制端使用者**:我在控制台审计页除了 L1(agent 自己记的)还想看到 **L2 系统级审计**(进程创建/登录事件)的状态与采集结果,「这样攻击者绕过 agent 直接操作机器时我也留有证据」;非 Windows 被控端该入口应事前禁用并说明原因。
|
||||
3. **MCP / AI 接入方**:我通过 MCP 接入 nodeagent,「希望 52 项能力全部有对应工具——特别是 L2 审计兜底,我不想为了一个能力再去装 CLI」。
|
||||
4. **项目维护者**:「每次发版前,README 的能力数/测试数、CHANGELOG 的未发布段必须与实现一致;每个新能力都要有契约(returns_schema)、有测试、有文档三件套,否则我无法相信门禁。」
|
||||
|
||||
## 3. 技术规范
|
||||
|
||||
### 3.1 需求池
|
||||
|
||||
> 每条含:问题描述 / 现状证据 / 期望行为 / 验收标准 / 预估影响面。
|
||||
> **P0 = 安全或正确性缺陷,必修;P1 = 重要缺口;P2 = 优化项。**
|
||||
|
||||
#### P0(必修)
|
||||
|
||||
**P0-1 被控端自更新在境内必然失败(裸 fetch 不读代理)**
|
||||
|
||||
- 问题:被控端唯一的无人值守修复通道在无透明代理的网络下不可用,且失败信息不含真实原因。
|
||||
- 证据:`apps/agent/src/capabilities/selfupdate.ts:42` `fetch(url, { signal, redirect:'follow' })`;CHANGELOG「检查更新在境内必然失败」节记录三条根因(Node fetch 不读代理环境变量 / 读不到系统代理 / 超时过短)并自陈「本次未改,属被控端变更」。
|
||||
- 期望行为:复用 `apps/console/sidecar/proxy.mjs` 已验证的出网方案(`NA_PROXY` → 环境变量 → 系统代理,`NO_PROXY` 命中直连;只支持 HTTP 代理,PAC/socks 明确提示不静默降级),在 agent 侧落地等价能力;超时成对下发;失败信息带 via/proxy/hint。**不削弱任何既有安全属性**(sha256 强制校验、先校验再落盘、写后校验、备份回滚全部保留)。
|
||||
- 验收标准:① 有代理可达时自更新端到端成功(真机或离线本地代理服务器 + 本地 HTTPS 源站);② 代理不可达时报明确错误且不落盘;③ 新增单测覆盖来源优先级、NO_PROXY、错误映射(参照 sidecar-proxy.test.mjs 的 16 用例口径);④ 回归 357 单测 + e2e 全绿。
|
||||
- 影响面:L0 被控端(PATCH 起,视范围可并入 MINOR)。
|
||||
|
||||
**P0-2 LKG 已落地但控制端无回滚入口(恢复路径断裂)**
|
||||
|
||||
- 问题:`docs/DELIVERY-PIPELINE.md` §6 明确「LKG 提供确定的回滚目标,决策权留给控制端(`nodeagent rollback` 读 LKG → 覆盖回入口 → 重启)」,但 CLI 不存在 `rollback` 命令(`apps/cli/src/` grep `rollback` 仅命中 net.ts 的类型注释)。「发布即失联」场景下,设计好的最后一环没有工具兑现。
|
||||
- 证据:`apps/agent/src/lkg.ts` + `system.info.lkg` + `tests/unit/lkg.test.mjs`(8 例)已在役;CLI 无入口;`docs/DELIVERY-PIPELINE.md` §6 的承诺与实现脱节。
|
||||
- 期望行为:`nodeagent rollback`:读 LKG → 与当前 agent 指纹比对 → 用 LKG 指向的备份覆盖入口(沿用 deploy/selfupdate 的「就地覆写 + 写后校验」既定姿势)→ 受控重启 → 复核 `system.info.build.hash` 等于 LKG 记录。**不提供自动回滚**(DELIVERY-PIPELINE §6 已论证:无法归因、回滚循环、失控——本项只补手动入口,不改该取舍)。
|
||||
- 验收标准:① 命令存在且有 `--dry-run`(只读比对不改动);② 单测覆盖「无 LKG / 指纹已匹配 / 覆盖后校验失败回滚」;③ e2e 或真机走一次「更新→回滚→复核」闭环;④ 帮助文本与 README 同步。
|
||||
- 影响面:L1 控制端(新增命令,只增不改)。
|
||||
|
||||
#### P1(重要缺口)
|
||||
|
||||
**P1-1 MCP 未暴露 `system.audit.l2`(三端暴露不完整)**
|
||||
|
||||
- 问题:L2 已随 agent 落地、CLI 已有 `nodeagent audit l2`(`apps/cli/src/cmd/system.ts:236`),但 MCP 41 个工具中无 L2(`apps/mcp/src/` grep `l2` 0 命中)——AI 接入方完全看不到 L2 能力,与「三端完整」目标相悖。
|
||||
- 证据:`apps/mcp/src/tools/defs.ts`(41 工具,`na_audit` 仅覆盖 L1 查询);`resolveToolCall` 分发结构。
|
||||
- 期望行为:新增 `na_audit_l2` 工具(action=status/enable/collect/verify + since/limit),或在 `na_audit` 上增 `l2` 参数面——二选一,需与 MCP 工具表守卫(`tests/unit/mcp-tools.test.mjs` 工具名序列断言)同步。
|
||||
- 验收标准:① 工具定义 + 分发 + 单测(工具名序列守卫同步更新);② `na_audit_l2` 在非 Windows 被控端返回明确平台错误而非空结果;③ 旧宿主不感知新工具无副作用(MCP 工具表只增)。
|
||||
- 影响面:L1(MINOR:新增工具)。
|
||||
|
||||
**P1-2 控制台无 L2 视图**
|
||||
|
||||
- 问题:控制台审计页只有 L1 查询/校验/导出,L2 状态与采集结果无界面入口。
|
||||
- 证据:`apps/console/src` grep `l2` 0 命中;审计页见 README「审计」行。
|
||||
- 期望行为:审计页增加 L2 区(status 面板:策略是否启用、两个关键开关 `SCENoApplyLegacyAuditPolicy` / `ProcessCreationIncludeCmdLine_Enabled` 的提示;collect/verify 操作与结果);沿用既有平台门控机制(`conn.ts` 的 `WINDOWS_ONLY_CAPS` 同款思路,非 Windows 事前禁用并说明)。
|
||||
- 验收标准:① 前端 `invoke` 新命令经 Rust 注册(契约守卫 `console-commands.test.mjs` 自动覆盖 invoke⇄handler、emit⇄listen 双向一致);② 界面在 macOS 被控端下禁用 L2 入口;③ 不加图标、配色走 `--na-*` 变量(遵守既定界面纪律)。
|
||||
- 影响面:L1(MINOR:新页面元素,协议零变更)。
|
||||
|
||||
**P1-3 文档与实现脱节(CHANGELOG 落后约 10 个提交、README 数字过期、DEVELOPMENT 无 L2)**
|
||||
|
||||
- 问题:README 仍写「51 项能力 · 269 单测 · 30 e2e · 3 项 Rust 单测」(实际 52 / 357 / 33+13 / 18);CHANGELOG「未发布」段未覆盖 `7d781d1`(L2)、`2bf45fd`(交付流水线)、`00992e5`(Linux 适配)、`141f479`(质量门禁)、`9f95560`(console-release)及 6 个 console 修复;`docs/DEVELOPMENT.md`、`docs/REMOTE-LIMITS.md` 无 L2 章节。
|
||||
- 证据:上述各文件实测。
|
||||
- 期望行为:CHANGELOG 回填(L2 / 流水线 / 门禁 / console-release / console 修复批次各一节,注明版本归属);README 数字同步;DEVELOPMENT 补 L2 能力条目与 returns_schema 落定后的契约;ROADMAP 台账刷新。
|
||||
- 验收标准:① 文档中能力数/单测数/e2e 数与 `pnpm test:unit`、manifest 实测一致;② CHANGELOG 未发布段涵盖 `git log v2.0.3..HEAD` 全部提交的主题;③ 数字一致性有守卫(参照 version-write.test.mjs 的「仓库版本号一致性」思路扩展到能力数/测试数,或至少人工核对记录在案)。
|
||||
- 影响面:L2(无兼容性成本)。
|
||||
|
||||
**P1-4 `system.audit.l2` 的 returns_schema 是空壳(契约不完整)**
|
||||
|
||||
- 问题:manifest 中该能力 `returns_schema: { type: 'object' }`,无属性枚举——第三方接入方与前端无契约可依,且 `console-contract.test.mjs` 的反向守卫按设计跳过空壳能力,「契约加了字段前端没跟上」这类漂移对它无效。
|
||||
- 证据:`packages/protocol/src/capabilities/manifest/system.ts` 中 AuditL2 条目。
|
||||
- 期望行为:补齐四种 action 各自的返回结构(status 的策略/开关状态、enable 的结果、collect 的条目与归一化 schema、verify 的链校验结果),与 `docs/AUDIT-L2.md` §4 的归一化 schema 对齐;补全后既有守卫自动生效。
|
||||
- 验收标准:① returns_schema 完整枚举顶层属性;② `console-contract.test.mjs` 对该能力反向守卫生效(或显式记录豁免理由);③ 旧控制端不受影响(返回结构只增不减——这里是补文档性契约,不改运行时返回)。
|
||||
- 影响面:L0/L1 交界(manifest 变更,随被控端发版;运行时行为不变)。
|
||||
|
||||
**P1-5 L2 无 e2e 覆盖**
|
||||
|
||||
- 问题:`tests/e2e/run.mjs` 与 `windows.mjs` grep `l2` 均 0 命中——L2 只有单测(归一化/链校验/独立落盘),无「经真实 RPC 调用」的链路验证。
|
||||
- 证据:上述 grep;`tests/unit/audit-l2.test.mjs` 覆盖的是模块级函数。
|
||||
- 期望行为:e2e 增加 `system.audit.l2` 用例:status 在无策略环境返回可解析结构;collect 对空 Security 日志不崩、带 since/limit;verify 对自造 L2 链文件判定正确;非 Windows 平台(CI 的 Linux runner)明确报平台不支持。
|
||||
- 验收标准:① 新增用例进 `run.mjs`(跨平台可跑部分)+ `windows.mjs`(Windows 专属部分);② 全绿;③ 与 33+13 基线不冲突(端口/临时 HOME 隔离遵守既有纪律)。
|
||||
- 影响面:L2(测试)。
|
||||
|
||||
**P1-6 `na-gui` 陈旧计划任务清理(影响面窄、成本低)**
|
||||
|
||||
- 问题:历史测试机残留的 `na-gui` 计划任务会抢占 8765,导致控制台被控端起不来;当前只有单实例保护与「如何找出占用者」的报错提示,**没有清理路径**——用户拿到提示后仍要自己动手。
|
||||
- 证据:`apps/console/src-tauri/src/agent.rs:1084`(测试注释引为动机);`scripts/uninstall.ps1` 无相关清理;CHANGELOG「控制台单实例保护」节自陈该任务「也在抢同一个端口」。
|
||||
- 期望行为:`uninstall.ps1` / `control.ps1` 卸载与修复路径中,对 `na-gui`(及 nodeagent 自建的已知任务名)做存在性检查并清理,动作进日志。
|
||||
- 验收标准:① 清理逻辑只针对白名单任务名,不做通配删除;② 有单测或脚本级验证(建同名任务→跑清理→断言已删);③ 不动其他计划任务。
|
||||
- 影响面:L2 脚本(仅安装/卸载链路,不动协议)。
|
||||
|
||||
#### P2(优化项)
|
||||
|
||||
| # | 项 | 问题/证据 | 期望 | 影响面 |
|
||||
|---|---|---|---|---|
|
||||
| P2-1 | 控制台自身自更新通道 | `latest.json` 仅含 agent 资产(`scripts/release.mjs`),控制台 `check_update` 只能比对版本+更新内置 agent.mjs,无法下载/应用控制台安装包(sidecar `main.mjs:260`) | `latest.json` 增补 console 安装包资产(url+sha256),sidecar 增下载+校验+拉起安装器;**必须**沿用「sha256 不校验不落盘」铁律 | L1(大特性,需单独一轮) |
|
||||
| P2-2 | 大文件拆分 | `apps/mcp/src/index.ts` 530 行、`tools/defs.ts` 618 行;`apps/agent/src/capabilities/window.ts` 963 行(ROADMAP 记载) | 按领域拆模块,纯移动不改行为;每次拆分跑全量测试 | L0/L1(PATCH 级) |
|
||||
| P2-3 | 协议模糊测试 | DELIVERY-PIPELINE §3:畸形报文是枚举式,未做随机化 fuzz | 加一轮「随机字节+随机 JSON 结构」fuzz,断言「不崩、不留未审计入口」 | L2(测试) |
|
||||
| P2-4 | sidecar CONNECT 隧道 TLS 叠层离线用例 | CHANGELOG 自陈:无受信任证书的本地 TLS 源站夹具,该路径无回归守卫 | 造证书夹具或采购测试证书,补离线用例 | L2(测试) |
|
||||
| P2-5 | 存量覆盖率提升 | 全量 66.9%(DELIVERY-PIPELINE §8) | 只观测不设全量硬指标;靠变更门禁(≥70%)自然抬升;GUI 注入等无头不可验模块显式豁免并记录理由 | L2 |
|
||||
| P2-6 | 分支保护 | `main` protection 返回 404(DELIVERY-PIPELINE §8 已知 P1,非代码项) | 仓库设置开启「PR + CI 全绿 + 禁止直推」;需维护者在 GitHub 侧操作 | 流程 |
|
||||
| P2-7 | Linux/macOS L2 与 Sysmon 并用 | AUDIT-L2.md §3.2/§3.3:Linux 需预装 auditd;macOS ESF 受 entitlement 限制;Sysmon 为第三方驱动 | 后续版本按「部署侧前置条件」成熟度逐端评估;不写「假装能用」的代码 | L0(后续) |
|
||||
|
||||
### 3.2 UI 设计草案(仅本轮新增部分)
|
||||
|
||||
- **审计页 L2 区**(控制台,`apps/console`):在既有「审计」页内增加分区,不新增顶级路由。
|
||||
- 上部:L2 状态卡(策略是否启用、`SCENoApplyLegacyAuditPolicy`、`ProcessCreationIncludeCmdLine_Enabled` 两个关键开关的✓/✗与一句提示);
|
||||
- 下部:操作行(collect:since/limit 输入 + 「采集」按钮;verify:「校验 L2 链」按钮)与结果表(归一化条目:ts/kind/subject/object/event_id);
|
||||
- 非 Windows 被控端:整区置灰 + 文案说明(复用既有平台门控模式,与软件页/服务页一致);
|
||||
- 界面纪律:纯文字导航不用图标;配色只吃 `--na-*` 变量;`emit`/`listen` 事件名双向一致(契约守卫自动覆盖)。
|
||||
- **CLI**:`nodeagent rollback [--dry-run]` 输出三段式:LKG 记录(hash/bytes/recorded_at)→ 与当前指纹比对 → 执行结果与复核提示;复用既有 `deploy` 的输出风格。
|
||||
- **MCP**:`na_audit_l2` 工具描述写清信任边界(本地副本、防篡改靠转发出机),避免 AI 误用为「不可篡改证据」。
|
||||
|
||||
### 3.3 待确认问题(需需求方确认,PRD 不擅自假设)
|
||||
|
||||
| # | 问题 | 背景与建议 |
|
||||
|---|---|---|
|
||||
| Q1 | **版本策略**:本轮含被控端变更(P0-1 selfupdate)+ 新能力面(P1-1/P1-4 L2 契约与 MCP 工具),按 `docs/VERSIONING.md` 新能力 = MINOR。建议一次性 **v2.1.0** 发版(而非 2.0.4),L2 从「已在 agent 未发版」转为正式发布能力。是否批准? | 若拆成 2.0.4(selfupdate)+ 2.1.0(L2 周边)两发,则中间态 agent 与文档会再次短期脱节 |
|
||||
| Q2 | **selfupdate 代理范围**:A) 全量复用 sidecar 方案(环境变量 + 系统代理发现 + 明确错误,被控端新增出网模块);B) 最小集(仅 `NA_PROXY`/`HTTPS_PROXY` 环境变量 + 现有超时放大);C) 不改代码,仅文档引导用镜像 URL。建议 A——B 留「用户明明配了系统代理却白搭」的坑,C 把问题推给用户 | A 的代价:被控端新增约 150~250 行 + 单测;需评估 agent 包体积预算 |
|
||||
| Q3 | **「契约漂移 6 项(缺 seq/session_id/risk 等字段)」的出处**:仓库内未找到该台账(protocol 源 grep 三字段 0 命中;console-contract 守卫 20 用例在役)。请提供台账来源;若指「审计条目应有 seq/session_id/risk 字段」,则给 `AuditEntry` 加字段属返回结构只增(可行),但需一并定义消费方(控制台审计页/MCP na_audit)与脱敏口径——是否纳入本轮 P1? | 在澄清前,本 PRD 只将「L2 returns_schema 空壳」(P1-4)与「文档数字漂移」(P1-3)列为实证漂移 |
|
||||
| Q4 | **L2 是否本期接 MCP/控制台**(P1-1/P1-2):agent 已落地、CLI 已有入口;MCP 与控制台补齐属「只增」。建议本期做——否则 AI 接入方与图形用户完全看不到 52 项中的第 52 项 | 若砍,则 G2「三端完整」目标降级为「agent+CLI 完整」,需需求方确认接受 |
|
||||
| Q5 | **na-gui 清理是否本期做**(P1-6):影响面仅历史测试机,但属「用户拿到报错后仍无法自救」的最后一公里。建议做(成本约半天) | — |
|
||||
| Q6 | **P2 项是否全部出到后续版本**:P2-1(控制台自更新)是较大的独立特性,建议明确排期到 v2.2.0,不在本轮夹带 | 避免本轮范围膨胀 |
|
||||
|
||||
## 4. 附:本轮交付物流转(供阶段 2/3 参考)
|
||||
|
||||
1. 阶段 2(架构师):按需求池逐条出「修改方案 + 代码示例」,P0 先行;契约类改动(P1-4)先落 manifest 再动实现。
|
||||
2. 阶段 3(开发):P0 → P1 → P2;每个 P0/P1 项完工即补「会红的守卫」(本项目铁律:没有变成断言的修复等于没有修复)。
|
||||
3. 阶段 4(测试):回归 357 单测 + e2e 33+13 + Rust 18;新增 L2 e2e;发版前按 DELIVERY-PIPELINE §5 真机矩阵(Windows x64 必跑、arm64 人工必查)与 §7 发布后复核清单执行。
|
||||
4. 发版:按 Q1 结论执行 `pnpm release`;被控端变更一律升号,控制台变更重新构建分发安装包(console-release.yml 已随 tag 自动挂 4 包)。
|
||||
+9
-9
@@ -7,7 +7,7 @@
|
||||
| 文档版本 | v1.0(已认可) |
|
||||
| 作者 | 司南(产品经理 Agent) |
|
||||
| 日期 | 2026-09-27 |
|
||||
| 客户 | 皇上(个人开发者) |
|
||||
| 客户 | 个人开发者 |
|
||||
| 项目名 | nodeagent |
|
||||
| 状态 | ✅ 已认可,进入开发 |
|
||||
| 参考文件 | 《NodeBuddy-开发文档.zip》(仅作参考,非任务书) |
|
||||
@@ -16,12 +16,12 @@
|
||||
|
||||
## 1. 背景与痛点
|
||||
|
||||
**核心痛点一句话**:皇上在 MacBook 上使用 AI(WorkBuddy),AI 只能操作 Mac 本机;当需要「在 Windows 上装软件 / 查状态」时,AI 无法跨过去接管 Windows 完成。
|
||||
**核心痛点一句话**:用户在 MacBook 上使用 AI(WorkBuddy),AI 只能操作 Mac 本机;当需要「在 Windows 上装软件 / 查状态」时,AI 无法跨过去接管 Windows 完成。
|
||||
|
||||
| # | 痛点 | 具体表现 |
|
||||
|---|---|---|
|
||||
| P1 | 跨机断点 | AI Agent 跑在 Mac 上,无法触达 Windows 执行操作 |
|
||||
| P2 | 手动替代 | 装软件、查状态需皇上亲自切换到 Windows 操作,打断工作流 |
|
||||
| P2 | 手动替代 | 装软件、查状态需用户亲自切换到 Windows 操作,打断工作流 |
|
||||
| P3 | 无统一入口 | 每次都要用远程桌面/SSH 手动拼命令,无法被 AI 自动编排 |
|
||||
|
||||
## 2. 目标与成功指标
|
||||
@@ -47,7 +47,7 @@
|
||||
|
||||
| 术语 | 定义 |
|
||||
|---|---|
|
||||
| 控制端 | 皇上的 MacBook,发起调用的 AI/CLI 所在端 |
|
||||
| 控制端 | 用户的 MacBook,发起调用的 AI/CLI 所在端 |
|
||||
| 被控端 | Windows 机器,执行命令/装软件/查状态的端 |
|
||||
| Agent | 常驻在 Windows 上的后台服务,接收并执行控制端指令 |
|
||||
| 能力 | 一个可被调用的原子操作(执行命令、装软件、查状态) |
|
||||
@@ -77,7 +77,7 @@
|
||||
| 3 | Hub 中枢(多设备注册/发现/路由) | 后续(预留接口) |
|
||||
| 4 | Android / Linux 端 | 后续 |
|
||||
| 5 | 公网穿透 | 后续(v1 仅局域网) |
|
||||
| 6 | 画面串流(远程桌面级) | 属 OnlyInOne 领域,不做 |
|
||||
| 6 | 画面串流(远程桌面级) | 属另一独立项目领域,不做 |
|
||||
|
||||
## 5. 关键决策记录
|
||||
|
||||
@@ -91,13 +91,13 @@
|
||||
|
||||
## 6. 用户角色与核心场景
|
||||
|
||||
**角色**:皇上(MacBook 用户,WorkBuddy 重度用户)
|
||||
**角色**:MacBook 用户(WorkBuddy 重度用户)
|
||||
|
||||
| 场景 | 描述 |
|
||||
|---|---|
|
||||
| S1 装软件 | 皇上在 Mac 上对 AI 说「在 Windows 上装个 VSCode」,AI 通过框架调用 Windows 完成安装并回报结果 |
|
||||
| S2 查状态 | 皇上问「Windows 现在 CPU/内存/磁盘怎么样」,AI 查询并返回 |
|
||||
| S3 执行命令 | 皇上让 AI 在 Windows 上跑一条命令(如重启某服务、查日志) |
|
||||
| S1 装软件 | 用户在 Mac 上对 AI 说「在 Windows 上装个 VSCode」,AI 通过框架调用 Windows 完成安装并回报结果 |
|
||||
| S2 查状态 | 用户问「Windows 现在 CPU/内存/磁盘怎么样」,AI 查询并返回 |
|
||||
| S3 执行命令 | 用户让 AI 在 Windows 上跑一条命令(如重启某服务、查日志) |
|
||||
|
||||
## 7. 功能需求(FR)
|
||||
|
||||
|
||||
+19
-19
@@ -15,7 +15,7 @@
|
||||
| **改系统设置** | ⚠️ 多数可 | 注册表、服务、计划任务、防火墙规则等可用命令改 |
|
||||
| **改网络配置** | ⚠️ **高风险** | 能做,但**可能把自己弄断**(详见 §2.1) |
|
||||
| **操作 GUI** | ❌ 基本不可 | 鼠标键盘默认禁用,且即使开启也难判读界面状态(详见 §2.2) |
|
||||
| **需要人工判断的决策** | ❌ 不可 | 选哪个代理节点、订阅是否有效 —— 臣无从判断(详见 §2.3) |
|
||||
| **需要人工判断的决策** | ❌ 不可 | 选哪个代理节点、订阅是否有效 —— AI 无从判断(详见 §2.3) |
|
||||
| **物理动作** | ❌ 不可 | 插拔网线、按电源键、换硬盘 |
|
||||
|
||||
---
|
||||
@@ -51,17 +51,17 @@
|
||||
|
||||
### 2.2 无 GUI 交互能力
|
||||
|
||||
**问题**:`input.*`(鼠标键盘)出于安全考虑**默认禁用**;即使开启,臣也难以完成 GUI 任务。
|
||||
**问题**:`input.*`(鼠标键盘)出于安全考虑**默认禁用**;即使开启,AI 也难以完成 GUI 任务。
|
||||
|
||||
| 场景 | 为什么做不到 |
|
||||
|---|---|
|
||||
| 代理软件换节点 / 更新订阅 | 需在 v2rayN 界面里点选,且**节点可用性关乎上游服务**,臣无法判断 |
|
||||
| 代理软件换节点 / 更新订阅 | 需在代理客户端界面里点选,且**节点可用性关乎上游服务**,AI 无法判断 |
|
||||
| UAC 提权弹窗 | 需人点「是」,agent 无法代点(安全设计) |
|
||||
| 安装向导(下一步 → 完成) | 界面状态判读不可靠 |
|
||||
| 浏览器登录 / 扫码 | 需要人的凭据与设备 |
|
||||
|
||||
**臣能做的替代**:用**命令行等价操作**绕过 GUI(如 `netsh`、注册表、服务命令)。
|
||||
但**第三方 GUI 软件的内部状态**(如 v2rayN 里"当前选中哪个节点")通常没有命令行接口。
|
||||
**AI 能做的替代**:用**命令行等价操作**绕过 GUI(如 `netsh`、注册表、服务命令)。
|
||||
但**第三方 GUI 软件的内部状态**(如代理客户端里"当前选中哪个节点")通常没有命令行接口。
|
||||
|
||||
---
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
|
||||
- 代理**订阅是否过期**、**节点是否可用** —— 需要人的账号与常识
|
||||
- 哪个软件该装、哪个进程能杀 —— 需要业务判断
|
||||
- 皇上看到的报错在**什么操作后出现** —— 臣无法感知复现路径
|
||||
- 用户看到的报错在**什么操作后出现** —— AI 无法感知复现路径
|
||||
|
||||
---
|
||||
|
||||
@@ -80,8 +80,8 @@
|
||||
| 限制 | 影响 |
|
||||
|---|---|
|
||||
| 只能看**当下快照** | 间歇性问题(如抖动、偶发超时)容易漏判 |
|
||||
| 无法 7×24 盯 | 臣只在被调用时"看一眼",问题在两次调用之间发生就看不到 |
|
||||
| 应用层内部状态不可见 | 如 WorkBuddy 的"当前正在等哪个请求",臣只能从日志反推 |
|
||||
| 无法 7×24 盯 | AI 只在被调用时"看一眼",问题在两次调用之间发生就看不到 |
|
||||
| 应用层内部状态不可见 | 如 WorkBuddy 的"当前正在等哪个请求",AI 只能从日志反推 |
|
||||
|
||||
**缓解(✅ 已于 v2.0.0 实现)**:`monitor.*` 定时采样并落盘,事后回看摘要 —— 正是为治
|
||||
这类间歇性问题而做(原先只写"具备实现基础",现已成为能力):
|
||||
@@ -140,27 +140,27 @@ nodeagent --node win monitor report probe # 直接给出:断了几次 / 最
|
||||
| 网络适配器 | Ethernet Up / 1 Gbps | 链路正常 |
|
||||
| 国内网络(百度) | HTTP 200 / 0.24s | 基础联网正常 |
|
||||
| `copilot.tencent.com` | HTTP 200 / 0.35s(IPv4) | 后端可达 |
|
||||
| v2rayN + xray 进程 | 均在运行 | 代理软件活着 |
|
||||
| xray 监听 | `0.0.0.0:10808` | 代理端口在听 |
|
||||
| **系统代理** | **`ProxyEnable=0`,但 `ProxyServer` 残留 `127.0.0.1:10808`** | ⚠️ 配置矛盾 |
|
||||
| 走 10808 访问外网 | 超时(HTTP 000 / 20s) | ⚠️ **上游节点不通** |
|
||||
| 代理客户端 + 代理内核进程 | 均在运行 | 代理软件活着 |
|
||||
| 代理内核监听 | `0.0.0.0:<代理端口>` | 代理端口在听 |
|
||||
| **系统代理** | **`ProxyEnable=0`,但 `ProxyServer` 残留 `127.0.0.1:<代理端口>`** | ⚠️ 配置矛盾 |
|
||||
| 走该代理端口访问外网 | 超时(HTTP 000 / 20s) | ⚠️ **上游节点不通** |
|
||||
| **`resolveProxy` RPC** | **耗时 1737ms(慢)** | ⚠️ 代理解析卡顿 |
|
||||
| **MarketplaceInitGate** | **15s 安全超时后强放行** | ⚠️ 启动期被拖慢 |
|
||||
| WorkBuddy 连接 | 4 条 IPv6 到腾讯(已建立) | 部分链路正常 |
|
||||
| TCP `CloseWait` | 24 条 | 偏多,疑似连接被中途中断 |
|
||||
|
||||
**结论(高度可能)**:
|
||||
系统存在**「代理配置残留但未启用,且 xray 上游节点实际不通」**的矛盾状态 ——
|
||||
系统存在**「代理配置残留但未启用,且代理内核上游节点实际不通」**的矛盾状态 ——
|
||||
WorkBuddy 解析代理慢 + 部分请求走了不通的代理链路 → `Response timed out`。
|
||||
|
||||
**为什么臣没有直接修**:
|
||||
- 需要的动作是「在 v2rayN 界面换可用节点 / 更新订阅」→ **属 GUI + 人工判断**(§2.2 / §2.3)
|
||||
- 若改为「清空系统代理残留」→ 属网络配置变更,虽有价值但**需皇上确认**(§2.1 风险)
|
||||
**为什么 AI 没有直接修**:
|
||||
- 需要的动作是「在代理客户端界面换可用节点 / 更新订阅」→ **属 GUI + 人工判断**(§2.2 / §2.3)
|
||||
- 若改为「清空系统代理残留」→ 属网络配置变更,虽有价值但**需用户确认**(§2.1 风险)
|
||||
|
||||
**建议的下一步**(人工为主):
|
||||
1. 打开 v2rayN → 测试节点延迟 → 换一个可用节点(或更新订阅)
|
||||
2. 确认 v2rayN 的「自动配置系统代理」策略与实际需求一致
|
||||
3. 观察是否仍有 `Response timed out`;若仍有,可让臣**持续采样**再定位
|
||||
1. 打开代理客户端 → 测试节点延迟 → 换一个可用节点(或更新订阅)
|
||||
2. 确认代理客户端的「自动配置系统代理」策略与实际需求一致
|
||||
3. 观察是否仍有 `Response timed out`;若仍有,可让 AI **持续采样**再定位
|
||||
|
||||
---
|
||||
|
||||
|
||||
+14
-14
@@ -4,7 +4,7 @@
|
||||
> 2026-10-07 逐项核查:P0 全部解决、P1 仅「定时采样」未做、P2 已补大半。
|
||||
> **当前状态与下一步路线以 [`ROADMAP.md`](./ROADMAP.md) 为准**,本文保留作为「问题从哪来」的历史记录。
|
||||
|
||||
> 基于 2026-09-27 ~ 09-28 的真实使用(Windows 真机部署 + v2rayN 故障排查 + 重装前信息采集)
|
||||
> 基于 2026-09-27 ~ 09-28 的真实使用(Windows 真机部署 + 代理客户端故障排查 + 重装前信息采集)
|
||||
> 所有问题均来自**实际踩坑**,非理论推演。
|
||||
|
||||
---
|
||||
@@ -12,7 +12,7 @@
|
||||
## 一、最痛的问题:GUI 语义能力缺失(P0)
|
||||
|
||||
### 场景还原
|
||||
排查 v2rayN 节点问题时,臣需要**切换活动节点**:
|
||||
排查代理客户端节点问题时,AI 需要**切换活动节点**:
|
||||
|
||||
| 尝试 | 结果 |
|
||||
|---|---|
|
||||
@@ -20,7 +20,7 @@
|
||||
| 重新算坐标再点 | ❌ 又点到第 3 行 |
|
||||
| 改用键盘 `↑`/`↓`/`Enter` | ✅ 成功 |
|
||||
|
||||
**问题本质**:臣有 `screen.capture`(能**看**)和 `input.mouse.*`(能**点**),但**没有把「看到的界面元素」映射为「可点击坐标」的能力**。
|
||||
**问题本质**:AI 有 `screen.capture`(能**看**)和 `input.mouse.*`(能**点**),但**没有把「看到的界面元素」映射为「可点击坐标」的能力**。
|
||||
|
||||
每次都要:截图 → 人眼估算像素 → 换算缩放的 region 偏移 → 点击 → 再截图验证。**误差极大,反复试错**。
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
|
||||
2. **截图附带坐标网格**
|
||||
- `screen.capture` 可选叠加坐标标尺(每 100px 标线)
|
||||
- 臣看图即可精确推算坐标,无需盲试
|
||||
- AI 看图即可精确推算坐标,无需盲试
|
||||
|
||||
3. **鼠标相对定位**
|
||||
- `input.mouse.move` 支持 `--relative dx dy`(当前只有绝对坐标)
|
||||
@@ -69,7 +69,7 @@
|
||||
- 让「写配置文件」这类高频操作**默认安全**
|
||||
|
||||
3. **结构化返回**(不靠文本传递)
|
||||
- 现在 `exec` 的输出是**纯文本 + 尾部附加 `[退出码 0 · 410ms]`**,导致臣解析 JSON 时被污染
|
||||
- 现在 `exec` 的输出是**纯文本 + 尾部附加 `[退出码 0 · 410ms]`**,导致 AI 解析 JSON 时被污染
|
||||
- 应改为**结构化字段**:`{ stdout, stderr, exit_code, duration_ms }` —— 后端本来就有,只是 CLI 拼接成文本了
|
||||
|
||||
---
|
||||
@@ -77,13 +77,13 @@
|
||||
## 三、进程与生命周期:自断生路(P0)
|
||||
|
||||
### 场景还原
|
||||
臣用 `system.shell.exec` 执行「杀旧 agent + 启新 agent」来让配置生效:
|
||||
AI 用 `system.shell.exec` 执行「杀旧 agent + 启新 agent」来让配置生效:
|
||||
|
||||
```
|
||||
Start-Process powershell -ArgumentList '... Start-Sleep 3; Stop-Process ...; Start-Process node.exe ...'
|
||||
```
|
||||
|
||||
**结果**:**分离的 PowerShell 被 agent 的进程树清理逻辑一起杀掉了** → 新 agent 从未启动 → 端口僵死 → **臣彻底失联**,只能请皇上手动恢复。
|
||||
**结果**:**分离的 PowerShell 被 agent 的进程树清理逻辑一起杀掉了** → 新 agent 从未启动 → 端口僵死 → **AI 彻底失联**,只能请用户手动恢复。
|
||||
|
||||
### 改进方向
|
||||
|
||||
@@ -93,7 +93,7 @@ Start-Process powershell -ArgumentList '... Start-Sleep 3; Stop-Process ...; Sta
|
||||
```
|
||||
- 由 agent **自己**实现:fork 一个「延时重启器」(脱离进程组),或注册一次性计划任务
|
||||
- 重启后**主动回连/上报**,让控制端知道「我回来了」
|
||||
- 这样 90% 的配置变更新场景都不再需要皇上介入
|
||||
- 这样 90% 的配置变更新场景都不再需要用户介入
|
||||
|
||||
2. **健康检查与自愈**
|
||||
- agent 内置看门狗:若 N 秒内未响应,自我重启
|
||||
@@ -104,16 +104,16 @@ Start-Process powershell -ArgumentList '... Start-Sleep 3; Stop-Process ...; Sta
|
||||
- 或者:`exec` 增加 `detach: true` 参数
|
||||
|
||||
4. **改端口/改配置前先"预演"**
|
||||
- 支持用一个临时配置在**旁路端口**试启动,成功再切换 —— 这正是这次出事后臣总结的正确做法
|
||||
- 支持用一个临时配置在**旁路端口**试启动,成功再切换 —— 这正是这次出事后 AI 总结的正确做法
|
||||
|
||||
---
|
||||
|
||||
## 四、可观测性与长时任务(P1)
|
||||
|
||||
### 场景还原
|
||||
- 代理不通问题**反复发作**,但臣只能看**当下快照**(`netstat`、`Get-Process`),**无法看到 5 分钟前的状态**
|
||||
- 代理不通问题**反复发作**,但 AI 只能看**当下快照**(`netstat`、`Get-Process`),**无法看到 5 分钟前的状态**
|
||||
- 想跑「持续 5 分钟、每 2 秒记录一次连接状态」的采样 → **`exec` 默认 30 秒超时**,长命令直接被砍
|
||||
- 排查 360 拦截时,需要「退出 360 前后对比」→ **臣无法安排这个时序**
|
||||
- 排查第三方杀软拦截时,需要「退出杀软前后对比」→ **AI 无法安排这个时序**
|
||||
|
||||
### 改进方向
|
||||
|
||||
@@ -132,12 +132,12 @@ Start-Process powershell -ArgumentList '... Start-Sleep 3; Stop-Process ...; Sta
|
||||
- 专治「间歇性问题」—— 这次代理不通就是典型的间歇性/条件性问题
|
||||
|
||||
3. **事件订阅(push 模型)**
|
||||
- 现在全是**拉取式**(臣问一次,答一次)
|
||||
- 现在全是**拉取式**(AI 问一次,答一次)
|
||||
- 应该支持:`subscribe { events: ['process.crash','net.change','file.change'] }`
|
||||
- 让被控端**主动推**异常,而不是等臣去查
|
||||
- 让被控端**主动推**异常,而不是等 AI 去查
|
||||
|
||||
4. **日志聚合查询**
|
||||
- 这次查 v2rayN 日志,臣只能 `Get-Content -Tail N` 手工翻
|
||||
- 这次查代理客户端日志,AI 只能 `Get-Content -Tail N` 手工翻
|
||||
- 应提供 `log.query { path, since, level, pattern, limit }`,由被控端侧做高效过滤
|
||||
|
||||
---
|
||||
|
||||
+48
-12
@@ -168,7 +168,7 @@ await gui.await(condition:"control", text:"完成", timeout_ms:30000)
|
||||
| 方案 | 代价 | 效果 |
|
||||
|---|---|---|
|
||||
| **A. 只清未来**(最低) | 0 | 新增代码与提交信息一律脱敏,历史不动。仓库公开仍有痕迹 |
|
||||
| **B. 清注释 + 清未来**(臣建议) | 半小时 | 源码注释泛化为「主流播放器」「全屏游戏」,不动历史 |
|
||||
| **B. 清注释 + 清未来**(建议) | 半小时 | 源码注释泛化为「主流播放器」「全屏游戏」,不动历史 |
|
||||
| **C. B + 改写历史** | 需 `git filter-repo` + force push | 连历史一并清,但**旧 blob 已被匿名抓取过,改写不等于收回** |
|
||||
|
||||
> ⚠️ 关键认知:**已推送到公开仓库并被匿名抓取过的内容,改写历史无法收回**。旧 blob 只能靠 GitHub Support GC,而 Support GC 本身也非保证。因此现实目标应是「**止血 + 不再恶化**」,即方案 B。
|
||||
@@ -206,7 +206,7 @@ node scripts/verify.mjs --host <地址> --key <密钥> --report <报告.md>
|
||||
|
||||
### 4.3 大前端方案(`docs/console-plan.md`,已出文档待评审)
|
||||
|
||||
盘点期间新增了一份前端方案文档,臣不在此展开(详见该文档),仅提示它引入了**一个新包 `crates/nodeagent-client`(Rust)**〔**2026-10 已退役**:改为 Node sidecar 方案,见 `docs/console-plan.md` 文末「实施决策」〕,与现有 TypeScript 的 `@nodeagent/client` 并存 —— 意味着 client 层有两套实现。这本身是个需要皇上拍板的架构决策(详见该文档的待确认项)。
|
||||
盘点期间新增了一份前端方案文档,本文不在此展开(详见该文档),仅提示它引入了**一个新包 `crates/nodeagent-client`(Rust)**〔**2026-10 已退役**:改为 Node sidecar 方案,见 `docs/console-plan.md` 文末「实施决策」〕,与现有 TypeScript 的 `@nodeagent/client` 并存 —— 意味着 client 层有两套实现。这本身是个需要用户拍板的架构决策(详见该文档的待确认项)。
|
||||
|
||||
---
|
||||
|
||||
@@ -270,7 +270,7 @@ node scripts/verify.mjs --host <地址> --key <密钥> --report <报告.md>
|
||||
|
||||
### 方向二:状态 —— 治间歇性问题
|
||||
|
||||
**判断**:复盘 §四那条判断仍成立 —— 代理不通、杀软拦截都是**间歇性**的,而臣只能看当下快照。`event.watch` 解决「有事件时通知」,没解决「持续记录并回看」。
|
||||
**判断**:复盘 §四那条判断仍成立 —— 代理不通、杀软拦截都是**间歇性**的,而 AI 只能看当下快照。`event.watch` 解决「有事件时通知」,没解决「持续记录并回看」。
|
||||
|
||||
| 能力 | 设计 | 验收标准 |
|
||||
|---|---|---|
|
||||
@@ -292,7 +292,7 @@ node scripts/verify.mjs --host <地址> --key <密钥> --report <报告.md>
|
||||
| **安装期密钥传递** | `install.cmd` 用命令行 `-Key` 传参,**同机其他用户 `ps` 可见** | 改走 stdin 或临时文件(600 权限,读后即删)。这是唯一还没堵住的密钥暴露面 |
|
||||
| **审计链外部锚定** | 哈希链可被「有 root 的攻击者」整链重写 | 定期把链头哈希同步到独立位置(如 Hub 或另一台机器),重写即暴露 |
|
||||
|
||||
**臣另建议一项**:**ed25519 默认化**。`SECURITY.md` §2 已写明 PSK 模式的固有限制(`client_id` 自报 → ACL 身份维度形同虚设)。v22 的每客户端独立 PSK 只是缓解 —— 持有共享 key 的人仍可冒充未登记的 client_id。新装默认应走 ed25519,PSK 降级为「兼容存量」并在安装时明确告警。
|
||||
**另建议一项**:**ed25519 默认化**。`SECURITY.md` §2 已写明 PSK 模式的固有限制(`client_id` 自报 → ACL 身份维度形同虚设)。v22 的每客户端独立 PSK 只是缓解 —— 持有共享 key 的人仍可冒充未登记的 client_id。新装默认应走 ed25519,PSK 降级为「兼容存量」并在安装时明确告警。
|
||||
|
||||
---
|
||||
|
||||
@@ -338,18 +338,18 @@ node scripts/verify.mjs --host <地址> --key <密钥> --report <报告.md>
|
||||
| 5 | 方向三:`install.cmd` 密钥改 stdin + 握手封禁 + 会话上限 | 1~2 天 | 无(安全项建议提前插队) |
|
||||
| 6 | 方向二:`monitor.*` + `log.query` + `fs.watch` | 2~3 天 | 无 |
|
||||
| 7 | `.gitignore` 补密钥规则 + 源码真机常量注入化 | 分散 | 无 |
|
||||
| 8 | PII blob 报 Support GC / 决定是否重建仓库 | — | **需皇上决策** |
|
||||
| 8 | PII blob 报 Support GC / 决定是否重建仓库 | — | **需用户决策** |
|
||||
| 9 | `ed25519` 默认化 | 1~2 天 | 需确认向后兼容策略 |
|
||||
| 10 | `console-plan.md` 评审:Rust client 与 TS client 双实现取舍 | — | **需皇上拍板** |
|
||||
| 10 | `console-plan.md` 评审:Rust client 与 TS client 双实现取舍 | — | **需用户拍板** |
|
||||
|
||||
**臣的建议**:
|
||||
**建议**:
|
||||
|
||||
- **序 0 立刻做**。5 分钟的事,但不做则单测红、CI 必红。注意建议顺手把断言改成「每个 `CapabilityNames` 键都在 manifest 中」——否则每加一能力都要改数字,这类「硬编码计数」会反复咬人。
|
||||
- **序 0.5 紧接着做**。只改注释,20 分钟,零风险,但能阻止「设备软件清单」继续扩散。`gui.await` 也正好是新代码,此时立脱敏规矩最自然。
|
||||
- **序 1 是当前主线**。`gui.await` 实现已完成(170 行 + 57 行契约 + 注册),设计也站得住(超时正常返回、轮询期异常不中断、复用既有能力零新增探测代码)。差的只是收尾四件事:单测、CLI/MCP 入口、版本号、真机验证。**建议一次做完再提交,不要分批**。
|
||||
- **序 3 优先级高于新功能**。v1.5.0 是**未经真机验证就发的版**,其中「后台定向投递」只在媒体键通道验证过、等价推断未经实测。
|
||||
- **序 5 可提前**。`install.cmd` 密钥经命令行传参(同机 `ps` 可见)是当下唯一未堵住的密钥暴露路径,改动局限在一个脚本内。
|
||||
- **序 8、10 需皇上决策**:前者关系到公开历史能否部分收回,后者关系到是否长期维护两套 client 实现。
|
||||
- **序 8、10 需用户决策**:前者关系到公开历史能否部分收回,后者关系到是否长期维护两套 client 实现。
|
||||
|
||||
> **一条经验**:`gui.await` 一次加能力就让单测红,原因是能力数被硬编码成 `42`。这类「计数断言」在持续加能力的项目里必然反复失效。**改为集合断言**(每个能力名都在 manifest 中)比每次改数字更省事,也更不容易漏检。
|
||||
|
||||
@@ -392,7 +392,7 @@ node scripts/verify.mjs --host <地址> --key <密钥> --report <报告.md>
|
||||
| **`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 四项)**可直接验**;杀软/显示/性能**仍需真机** |
|
||||
| ↑ **可用 VM 补验证** | 📋 见 [`VM-VERIFICATION.md`](VM-VERIFICATION.md) | 宿主虚拟化平台 + Win11 **ARM**:协议层(v23/v26/锚点)与输入层(v1.5 四项)**可直接验**;杀软/显示/性能**仍需真机** |
|
||||
|
||||
### 真机验证过程中抓到的真 bug(8 个,全部只有真机能发现)
|
||||
|
||||
@@ -411,7 +411,7 @@ node scripts/verify.mjs --host <地址> --key <密钥> --report <报告.md>
|
||||
| 事项 | 状态 | 说明 |
|
||||
|---|---|---|
|
||||
| `gui.await` 收尾(断言/单测/入口/版本号/本地真机/发版) | ✅ | v1.6.0 |
|
||||
| CI 工作流推送(需皇上授权 `workflow` 作用域) | ✅ | 皇上授权后推送成功 |
|
||||
| CI 工作流推送(需用户授权 `workflow` 作用域) | ✅ | 用户授权后推送成功 |
|
||||
| **Windows CI 上线后修 6 个真 bug** | ✅ | 见下表 |
|
||||
| 源码注释脱敏(序 0.5) | ✅ | `input.ts` / `input-script.ts` 4 处软件名泛化 |
|
||||
| `.gitignore` 补 `keys/ *.key *.pem`(§6.2) | ✅ | `*.pub` 放行 |
|
||||
@@ -420,9 +420,9 @@ node scripts/verify.mjs --host <地址> --key <密钥> --report <报告.md>
|
||||
| 方向一第二步:`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) | ⏳ 需皇上决策 | — |
|
||||
| PII blob GC / 仓库重建(序 8) | ⏳ 需用户决策 | — |
|
||||
| `ed25519` 默认化 | ✅ **已完成**(v2.0.0) | 「零信任优先安装」:有 client-acl.json 即走 ed25519(default_effect=deny) |
|
||||
| console-plan 评审(Rust client 取舍) | ⏳ 需皇上拍板 | — |
|
||||
| console-plan 评审(Rust client 取舍) | ⏳ 需用户拍板 | — |
|
||||
|
||||
### Windows CI 扒出的 6 个真 bug(均已修复,v1.6.x)
|
||||
|
||||
@@ -445,3 +445,39 @@ node scripts/verify.mjs --host <地址> --key <密钥> --report <报告.md>
|
||||
6. 发版/打包脚本不得假设 unix 命令存在(`zip`/`ls`/`du`/`node_modules/.bin/*`)
|
||||
7. CI 断言会随属性变化过期,且方向可能反了
|
||||
8. 工作区有他人未提交文件时禁用 `git add -A` / `commit -am`
|
||||
|
||||
---
|
||||
|
||||
## 十、v2.1.0「全面完善」:已落地与遗留(2026-10-26 收口)
|
||||
|
||||
本轮(`docs/ARCH-2026-10-25` / `docs/PRD-2026-10-25`)范围的全部落地情况:
|
||||
计划项从路线划掉,未做的按 P2 登记在案 —— 不塞进 v2.1.0。
|
||||
|
||||
### 已落地(从计划划掉)
|
||||
|
||||
| 项 | 状态 | 落点 |
|
||||
|---|---|---|
|
||||
| 版本统一 v2.1.0 + CHANGELOG 骨架 | ✅ | `a75c5c9` |
|
||||
| selfupdate 代理出网(NA_PROXY→env→系统代理) | ✅ | `apps/agent/src/net/proxy.ts` |
|
||||
| LKG 内容指针(`hash_full` / `backup_glob`) | ✅ | `apps/agent/src/lkg.ts` |
|
||||
| 控制端 rollback(ARCH T03) | ✅ | `apps/cli/src/cmd/rollback.ts` |
|
||||
| 看门狗启动自动恢复(决策 R-1) | ✅ | `scripts/startup-guard.mjs` + 三端 supervisor 接入 |
|
||||
| L2 三端暴露(MCP `na_audit_l2` + 控制台 + `agent.l2`) | ✅ | `5a2c5f9` |
|
||||
| na-gui 陈旧计划任务白名单清理 | ✅ | `scripts/lib/scheduled-tasks.mjs` + `control.ps1 cleanup` |
|
||||
| L2 e2e + 文档数字守卫 | ✅ | `tests/e2e/*`、`tests/unit/docs-numbers.test.mjs` |
|
||||
| 架构审查三 P0(validate 原型链 / 限速桶 key / describeArgs) | ✅ | `68ea3cd`、`560b3c4` |
|
||||
|
||||
### 遗留(P2 登记,明确不塞进 v2.1.0)
|
||||
|
||||
| 项 | 优先级 | 为什么现在不做 |
|
||||
|---|---|---|
|
||||
| 控制台自更新(检查更新/自升级) | P2 · v2.2.0 | 控制台手动重装成本低;且需先定「更新通道与回滚」策略,不与本轮被控端通道混 |
|
||||
| Linux L2(auditd) | P2 | 需预装 auditd + 规则管理,属被控端环境依赖,超出「零依赖立起来」原则 |
|
||||
| macOS L2(ESF) | P2 | Apple entitlement 硬限,个人自签证书无法启用;除非走 Developer ID + 公证分发 |
|
||||
| macOS 被控端安装器 | P2 | 当前无安装器(launchd 手工接入见 `docs/DEVELOPMENT.md` §7.4);macOS 被控端场景少,先文档兜住 |
|
||||
| 看门狗日志轮换 | P2 | `startup-guard.log`(JSONL)无轮换;长期运行设备需运维关注,先以告警文案兜住 |
|
||||
|
||||
### 门禁数字(2026-10-26,Mac 实测)
|
||||
|
||||
52 项能力 · 42 个 MCP 工具 · 467 项单元测试 · 50 项端到端(34 通用 + 16 Windows 专属)·
|
||||
23 项 Rust 壳单测 —— README 数字由 `tests/unit/docs-numbers.test.mjs` 钉死,腐烂即红。
|
||||
+248
-19
@@ -1,6 +1,6 @@
|
||||
# VM 验证清单(Parallels Desktop 27 + Windows 11 ARM)
|
||||
# VM 验证清单(宿主虚拟化平台 + Windows 11 ARM)
|
||||
|
||||
> 用途:Windows 真机不可用时,用 **Parallels 27 的 Windows 11 ARM 虚拟机**提前跑掉
|
||||
> 用途:Windows 真机不可用时,用**虚拟化平台上的 Windows 11 ARM 虚拟机**提前跑掉
|
||||
> **架构无关**的验证项,把"等机器"的时间省下来。
|
||||
>
|
||||
> ⚠️ **本清单的结论必须按 §5 的规则标注**:VM 验过的项写「VM 已验」,并注明
|
||||
@@ -16,28 +16,28 @@
|
||||
| **输入注入层** | v1.5 四项(字符串热键/预设别名/长按/后台定向投递) | ✅ 可验(走 Win32 `SendInput`/`WM_APPCOMMAND`,ARM Windows 同样完整) |
|
||||
| **安装与自持** | `install.cmd` 自提权、计划任务注册、受控重启 | ✅ 可验(VM 里也是真 Windows,机制一致) |
|
||||
| **系统能力** | 进程/服务/文件/剪贴板/命令执行/`winget` | ✅ 可验 |
|
||||
| **刻意不可验** | 第三方杀软拦截(火绒等) | ❌ VM 里只有 Defender |
|
||||
| **刻意不可验** | 第三方杀软拦截 | ❌ VM 里只有 Defender |
|
||||
| | 显示 / DPI / 多显示器 / 坐标校准 | ❌ VM 显示栈不同 |
|
||||
| | 性能指标(P95 时延等) | ❌ VM 数字不可比 |
|
||||
| **需注意** | `win-x64` 包在 ARM Windows 上走 **Prism 模拟** | ⚠️ 能跑但慢;**这本身是值得记录的兼容性结论**(见 B5) |
|
||||
| **需注意** | `win-x64` 包在 ARM Windows 上走 **Prism 模拟** | ❌ **实测结论(2026-10-11):能启动、能 listen,但 TLS 握手不完成**(控制端只见 `E_TIMEOUT`)→ 被控端实际不可用。ARM Windows **必须**用 `nodeagent-win-arm64.zip`(见 B5) |
|
||||
|
||||
---
|
||||
|
||||
## 2. 前置准备
|
||||
|
||||
```
|
||||
① Parallels 27 装 Windows 11 ARM(Pro/Enterprise)
|
||||
① 虚拟化软件里装 Windows 11 ARM(Pro/Enterprise)
|
||||
└ Apple silicon 只能跑 ARM 版 Windows(x86_64 仅 Pro/Business 的早期预览,性能显著更低)
|
||||
|
||||
② 网络 → 桥接(Bridged),不要用默认「共享网络」
|
||||
└ 共享/NAT 下 VM 拿不到同网段 IP → UDP 广播发现(v4)测不了,Mac 也未必能直连
|
||||
|
||||
③ VM 内装 Node:优先 **ARM64 版**(避开 Prism 模拟层)
|
||||
└ 若想验分发包,另见 B5(用项目打的 win-x64 包)
|
||||
└ 若想验分发包,同样**必须用 `nodeagent-win-arm64.zip`** —— x64 包在 ARM 上握手不通(见 B5)
|
||||
|
||||
④ VM 内起被控端(二选一)
|
||||
a. 免安装:双击 run-portable.cmd (不注册计划任务,适合快速验能力)
|
||||
b. 正式装:解压 nodeagent-win-x64.zip → 双击 install.cmd(自提权,会建计划任务)
|
||||
b. 正式装:解压 nodeagent-win-arm64.zip → 双击 install.cmd(自提权,会建计划任务)
|
||||
|
||||
⑤ Mac 端登记设备并连接
|
||||
nodeagent --node vm connect <VM的IP> --key <密钥> --insecure
|
||||
@@ -133,14 +133,35 @@ node scripts/anchor-audit.mjs verify --anchors /tmp/vm-anchor.jsonl --node vm
|
||||
```
|
||||
**判定**:`collect` 能拿到链头、云盘上传成功、`verify` 返回 `ok:true`。
|
||||
|
||||
#### B5. `win-x64` 包在 ARM Windows 上的兼容性(额外结论)
|
||||
#### B5. 分发包在 ARM Windows 上的架构适配(✅ 已验,2026-10-11)
|
||||
|
||||
**结论:ARM Windows 上必须用 `nodeagent-win-arm64.zip`;`win-x64` 包不可用。**
|
||||
|
||||
同机、同密钥、同参数的对照实测:
|
||||
|
||||
| 包 | 内含 `node.exe` | 结果 |
|
||||
|---|---|---|
|
||||
| `nodeagent-win-arm64.zip` | ARM64(PE `0xaa64`) | ✅ TLS 握手成功 → PSK 认证 → 声明 51 项能力(两端一致)→ `info`/`status`/`process.list`/`service.list`/`shell.exec`/`audit verify` 全通;`system.info.arch` 报 `arm64`(原生,非模拟层) |
|
||||
| `nodeagent-win-x64.zip` | x86-64(PE `0x8664`) | ❌ 能启动、能 listen,但 `connect` 报 `E_TIMEOUT` —— **TLS 握手不完成** |
|
||||
|
||||
> 踩坑记录:v2.0.1 的 arm64 资产曾**误装 x64 node**(`pack.mjs` 的运行时缓存文件名不含架构 →
|
||||
> 打 arm64 时复用了 x64 的缓存),两份公开包内 `node.exe` 字节完全相同(同 sha256)。
|
||||
> 该缺陷在 macOS 上 **100% 复现不出**,只有 ARM 目标机才暴露。
|
||||
> 现 `pack.mjs` 解包后断言 PE Machine,CI 亦直接校验最终产物内 `node.exe` 的 PE 头。
|
||||
|
||||
**复验方式**:
|
||||
|
||||
```bash
|
||||
# 把项目打的包解压到 VM,用**包内自带的 x64 Node**起被控端(走 Prism 模拟)
|
||||
# 期望:能起、能被连接、基础能调用
|
||||
# ① VM 内,用包内自带 node 起被控端(务必 arm64 包)
|
||||
C:\na-test\node.exe C:\na-test\agent.mjs
|
||||
# 注意:以 SYSTEM(会话 0)启动时 screen.capture 等图形能力必失败(会话 0 无交互桌面),
|
||||
# 正式安装路径不受影响 —— install.ps1 的计划任务用登录用户 + LogonType Interactive。
|
||||
|
||||
# ② Mac 侧连入
|
||||
nodeagent connect <VM的IP> --port 8765 --key <密钥> --insecure --id vm
|
||||
```
|
||||
**判定**:能跑 → 说明分发包对 ARM Windows 也友好(**值得写进 README 的加分结论**);
|
||||
不能跑或异常 → 记录现象,并在文档里注明"分发包需 ARM64 版本方能原生运行"。
|
||||
|
||||
**判定**:`connect` 成功并列出 51 项能力 = 通。若只见 `E_TIMEOUT`,先查包内 `node.exe` 是不是 ARM64。
|
||||
|
||||
---
|
||||
|
||||
@@ -165,9 +186,9 @@ A 基础验收(verify.mjs)
|
||||
| 架构无关项(B2/B3/B4)在 VM 验过 | `✅ 已验证(VM · Windows 11 ARM)` |
|
||||
| 输入/系统能力(A/B1)在 VM 验过 | `✅ 已验证(VM)`,**若涉及坐标/DPI 则加注「坐标类待真机复验」** |
|
||||
| 杀软交互 / 显示 / 性能 | `⏳ 待真机`(VM 不可替代,**不得**因 VM 通过就标已验) |
|
||||
| x64 包在 ARM 上跑通(B5) | `✅ 兼容性结论:win-x64 包可在 ARM Windows 运行(经 Prism)` |
|
||||
| 分发包架构适配(B5) | `✅ 已验证(VM · Windows 11 ARM):arm64 包原生可用;win-x64 包 TLS 握手不完成、不可用` |
|
||||
|
||||
> **一条纪律**:VM 与真机**架构不同**(真机 i7-12650H = x86_64)。任何"VM 通过"
|
||||
> **一条纪律**:VM 与真机**架构不同**(真机 = x86_64)。任何"VM 通过"
|
||||
> 都不能自动推出"真机通过";涉及**原生二进制、驱动、杀软、显示栈**的部分尤其如此。
|
||||
> 宁可多留一条"待真机复验",也不要让台账出现无法追溯来源的"已验证"。
|
||||
|
||||
@@ -181,14 +202,31 @@ A 基础验收(verify.mjs)
|
||||
| 分辨率/坐标与真机不符 | VM 显示栈不同 | 只做逻辑验证,坐标待真机 |
|
||||
| 性能数字明显偏低 | VM + 可能的 Prism 模拟 | **不要**采信 VM 的性能指标 |
|
||||
| 杀软相关错误复现不了 | VM 里只有 Defender | 需真机(或另行安装第三方杀软) |
|
||||
| x64 Node 启动慢/异常 | 走 Prism 模拟 | 换 ARM64 Node 复核 |
|
||||
| **x64 Node 在 ARM Windows 上"启动慢/协议异常"** | 走 **Prism 模拟层**,TLS 握手下行为异常(能 listen,握手不完成) | 换 **ARM64 Node**(用 `nodeagent-win-arm64.zip`) |
|
||||
| **`screen.capture` 报 `E_EXECUTION_FAILED`** | 被控端跑在 **SYSTEM(会话 0)**,会话 0 **无交互桌面** | 以**登录用户 + `LogonType Interactive`** 启动(见 §8 配方);**非产品缺陷** |
|
||||
| **autologon 对微软账户(MSA)无效** | MSA 凭据由 **CloudAP** 处理,注册表 `DefaultPassword`(Winlogon→LSA)那条路对它不生效 | 建**本地账户**做 autologon(`LogonUser` 对 MSA 恒返回 `1326`) |
|
||||
|
||||
---
|
||||
|
||||
## 7. 实际验证结果(2026-10-11,Parallels 27 + Win11 ARM)
|
||||
## 7. 实测记录(VM)
|
||||
|
||||
> 本节是**实测记录**,不是计划。环境:Parallels 共享网络,VM `10.211.55.9`,
|
||||
> Mac 侧 `bridge100=10.211.55.2`;被控端 `-NodeId win-arm -AllowInput`。
|
||||
| 日期 | 项 | 结论 | 证据 |
|
||||
|---|---|---|---|
|
||||
| 2026-10-11 | **B5 架构适配** | ✅ arm64 包原生可用;❌ win-x64 包不可用 | 同机同参对照:arm64 握手成功 / x64 `E_TIMEOUT` |
|
||||
| 2026-10-11 | 基础能力(A 子集) | ✅ | `system.info`(`arch=arm64`、能力 51 项两端一致)/ `status` / `process.list` / `service.list` / `shell.exec`(退出码 0)/ `audit.list` / `audit verify`(链完整)均正常 |
|
||||
| 2026-10-11 | **图形能力** | ✅ **登录态下全通** | 以登录用户 + `LogonType Interactive` 启动后:`screen.capture` 返回**真实桌面** 1280×720(含中文,渲染正常)/`window.list` 返回窗口列表(前台窗口 PID 与实测一致)/`screen` 枚举显示器/`audit verify` 链完整。**证实会话 0 的失败是身份问题,非产品缺陷** |
|
||||
|
||||
> 本轮**尚未执行**:B1(v1.5 四项输入)/ B2(v23 三防护)/ B3(v26 重启清理)/ B4(审计锚点端到端)。
|
||||
> 另注:宿主虚拟机 CLI 的 `exec` 子命令默认以 **SYSTEM(会话 0)** 身份执行,图形类能力须换登录用户会话方可复验。
|
||||
> **图形能力复验配方(本轮实证可通过)**:见 §8 末节。
|
||||
|
||||
---
|
||||
|
||||
## 8. 实际验证结果(2026-10-11,虚拟化平台 + Win11 ARM)
|
||||
|
||||
> 本节是**实测记录**,不是计划。环境:虚拟化平台的共享网络,VM `192.0.2.9`,
|
||||
> Mac 侧 `bridge100=192.0.2.2`;被控端 `-NodeId win-arm -AllowInput`。
|
||||
> (上列地址为文档保留段示例,非真实环境值。)
|
||||
|
||||
### B5 结论(重要,与预期不同)
|
||||
|
||||
@@ -204,7 +242,7 @@ A 基础验收(verify.mjs)
|
||||
node scripts/pack.mjs --arch arm64 # → release/nodeagent-win-arm64.zip(内含 ARM64 node.exe)
|
||||
node scripts/pack.mjs # 默认仍出 x64 包(向后兼容)
|
||||
```
|
||||
ARM Windows(含 Parallels Win11 ARM)请用 **arm64 包**;用 x64 包会卡在 TLS 握手。
|
||||
ARM Windows(含虚拟化平台上的 Win11 ARM)请用 **arm64 包**;用 x64 包会卡在 TLS 握手。
|
||||
(临时替代:手工把包内 `node.exe` 换成 ARM64 版同样可行,重启后需再换 —— 故推荐用 arm64 包。)
|
||||
|
||||
### 各项结果
|
||||
@@ -229,3 +267,194 @@ ARM Windows(含 Parallels Win11 ARM)请用 **arm64 包**;用 x64 包会卡
|
||||
| 计划任务启动后端口不通 | **node.exe 被手工进程占用**(覆盖失败)+ 8765 被占(任务起来即冲突退出) | 先 `Get-Process node \| Stop-Process -Force` 再解压/启动 |
|
||||
| 自测时 `socket hang up` | **URL 写成 `ws://` 而服务端是 `wss`**(`tls:true`) | 用 `wss://` |
|
||||
| 测 `max_connections` 把自己锁在外面 | 测试值太小 + 被拒连接会被计入握手失败 → 恶性循环 | ① 测试时留余量 ② **给自己加 `auth_ban.whitelist`** |
|
||||
|
||||
---
|
||||
|
||||
## 9. 图形能力复验配方(2026-10-11 实证)
|
||||
|
||||
图形类能力(`screen.capture` / `window.list` / `input.*` / `screen.find`)在 VM 里失败的**唯一原因**
|
||||
是被控端跑在 **SYSTEM(会话 0)**——会话 0 没有交互桌面。要让它们可用,须让被控端跑在
|
||||
**已登录用户的会话**里。完整配方(本轮实测可复现):
|
||||
|
||||
### ① 建立登录会话(关键坑:微软账户不行)
|
||||
|
||||
VM 无人登录时停在 LogonUI。可脚本化的路径只有 **autologon**,但:
|
||||
|
||||
> **微软账户(MSA)不支持传统 autologon。** MSA 凭据由 **CloudAP** 凭据提供程序处理,
|
||||
> 而 autologon 走的是 Winlogon→LSA + 注册表 `DefaultPassword` 那条**老路**。
|
||||
> 实测:对 MSA 账户调 `LogonUser` 四种域形式**全部返回 `1326`(用户名或密码错误)**,
|
||||
> 且 Winlogon 日志里**连一次登录尝试都不记录**(账户解析阶段就被跳过)——
|
||||
> 极易误判为"密码打错了"。
|
||||
|
||||
**配方**:新建一个**本地账户**再 autologon:
|
||||
```powershell
|
||||
$pw = ConvertTo-SecureString '<password>' -AsPlainText -Force
|
||||
New-LocalUser -Name na-test -Password $pw -PasswordNeverExpires -AccountNeverExpires
|
||||
Add-LocalGroupMember -Group Administrators -Member na-test
|
||||
$k = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon'
|
||||
Set-ItemProperty $k AutoAdminLogon '1'
|
||||
Set-ItemProperty $k DefaultUserName 'na-test'
|
||||
Set-ItemProperty $k DefaultDomainName $env:COMPUTERNAME
|
||||
Set-ItemProperty $k DefaultPassword '<password>' # 明文,验完务必清理
|
||||
Restart-Computer -Force
|
||||
```
|
||||
|
||||
### ② 以登录用户身份启动被控端(这一步才是图形能力的关键)
|
||||
|
||||
```powershell
|
||||
$act = New-ScheduledTaskAction -Execute 'C:\na-test\node.exe' -Argument 'C:\na-test\agent.mjs'
|
||||
$pri = New-ScheduledTaskPrincipal -UserId ($env:COMPUTERNAME + '\na-test') -LogonType Interactive -RunLevel Highest
|
||||
Register-ScheduledTask -TaskName 'na-gui' -Action $act -Principal $pri -Force
|
||||
Start-ScheduledTask -TaskName 'na-gui'
|
||||
```
|
||||
|
||||
`LogonType Interactive` = 「仅在用户登录时运行」→ 任务落在**该用户的交互会话**里。
|
||||
|
||||
### ③ 核对(三件事缺一不可)
|
||||
|
||||
| 检查 | 期望 |
|
||||
|---|---|
|
||||
| `Get-Process node \| Select Id,SessionId` | **SessionId = 1**(不是 0) |
|
||||
| `Get-Process explorer \| Select SessionId` | 1(桌面已就绪) |
|
||||
| `Get-LocalUser na-test \| Select LastLogon` | 有值(确实登录过) |
|
||||
|
||||
### ④ 证书指纹会变(这是钉住机制在正常工作)
|
||||
|
||||
换了数据目录(新用户 → 新 `.nodeagent/`)→ agent **重新生成自签证书** →
|
||||
客户端报 `E_CERT_MISMATCH`。用 `nodeagent connect <ip> --forget-cert --insecure` 重钉即可。
|
||||
**注意**:这不代表被中间人,而是"换目录=换身份"的正常结果。
|
||||
|
||||
### ⑤ 验证完的清理(安全)
|
||||
|
||||
autologon 会把密码**明文**写进 `HKLM\...\Winlogon\DefaultPassword`:
|
||||
|
||||
```powershell
|
||||
$k = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon'
|
||||
Remove-ItemProperty $k DefaultPassword -ErrorAction SilentlyContinue
|
||||
Set-ItemProperty $k AutoAdminLogon '0'
|
||||
Unregister-ScheduledTask -TaskName 'na-gui' -Confirm:$false
|
||||
Remove-LocalUser -Name na-test # 临时账户一并删除
|
||||
```
|
||||
|
||||
> 若需**保留**该环境供后续验证(重启后仍自动恢复图形会话),可暂不清理 ——
|
||||
> 但要清楚:明文密码就留在注册表里。
|
||||
|
||||
---
|
||||
|
||||
## 10. 200% 缩放下的坐标口径,与 VM 输入通道的真机真相(2026-10-12)
|
||||
|
||||
全部在 **虚拟化平台 + Win11 ARM64(200% 缩放)** 上实测。
|
||||
§9 讲「怎么让图形能力跑起来」,本节讲「跑起来之后坐标为什么还是错的」以及「被控端自己不动了怎么救」。
|
||||
|
||||
### 10.1 缺陷本体:一个进程里并存两套坐标系
|
||||
|
||||
被控端的 PowerShell 助手进程**默认 DPI 不感知**(Windows 的默认行为):
|
||||
|
||||
| 调用 | 不感知时的结果 | 危害 |
|
||||
|---|---|---|
|
||||
| `Screen.Bounds` / `SystemInformation.VirtualScreen` | **逻辑**尺寸 1280×720 | `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` 注入到**每个坐标脚本的最前面**。
|
||||
|
||||
### 10.2 复验配方(改完全部坐标路径后,这三条必须跑)
|
||||
|
||||
| 断言 | 怎么取 | 修复前 | 修复后应为 |
|
||||
|---|---|---|---|
|
||||
| 屏幕尺寸口径 | `screen.info` | `1280x720` | **`2560x1440`**,且与**裸 Win32** `GetSystemMetrics(0/1)`(感知后)一致 |
|
||||
| 缺省整屏截图 | `screen.capture`(**不传** `region`) | 只有左上 1/4 | 整屏(按 `scale` 缩放后返回) |
|
||||
| 光标落点 | `input.mouse.move(x,y)` 后**用同样感知的助手**读回 `GetCursorPos` | 落到 (2x,2y) | **精确等于** `(x,y)` |
|
||||
| 端到端零换算 | `screen.find` 的物理矩形中心**直接**喂 `input.mouse.click` | 点不中 | 命中(可用主题单选框的 UIA `selected` 翻转验证) |
|
||||
|
||||
> ⚠️ 读回光标时**助手也必须 DPI 感知**,否则读到的是逻辑值 —— 会得到「move 生效了但值差 2 倍」的假象。
|
||||
|
||||
### 10.3 被控端「输入注入整体失效」——VM 环境坑,**不是产品缺陷**
|
||||
|
||||
现象:`input.mouse.*` 与 `input.key.*` 全部无效,光标纹丝不动。
|
||||
|
||||
先把环境位逐条排干净:
|
||||
|
||||
| 检查 | 实测 |
|
||||
|---|---|
|
||||
| 会话 / 窗口站 / 桌面 | `session=1` / `WinSta0` / `Default` ✅ |
|
||||
| `UserInteractive` | `True` ✅ |
|
||||
| `OpenInputDesktop` | 成功 → `Default`;`SetThreadDesktop(它)` 报 `ERROR_BUSY(170)`(本就在该桌面)✅ |
|
||||
| `ClipCursor` | 整屏 `0,0,2560,1440`,无裁剪 ✅ |
|
||||
| 指点设备 | `HID-compliant mouse` + 虚拟化平台的 USB 鼠标同步设备 均 `Status OK` ✅ |
|
||||
| 虚拟化平台的鼠标过滤驱动 | `STATE: 4 RUNNING` ✅ |
|
||||
| **进程来源** | 从 **node 子树** 与 **宿主虚拟机 CLI 的 `enter` 子命令**(Guest Tools 侧)两条**互不相干**的来源跑同一段探针,**结果完全一致** ❌ |
|
||||
|
||||
判决性证据(**纯 Win32 探针,不含任何本产品代码**):
|
||||
|
||||
```text
|
||||
SetCursorPos(1500,1200) = FALSE ← 直改 win32k 指针:硬失败
|
||||
SendInput(绝对) = 1 ← 已插入输入队列
|
||||
SendInput(相对) = 1 ← 已插入输入队列
|
||||
GetCursorPos = 410,1488 → 410,1488 ← 指针纹丝不动
|
||||
GetAsyncKeyState(按键) = 0x0000 ← 连键盘注入也不被系统认可
|
||||
```
|
||||
|
||||
**`SendInput` 报告成功,而键盘与鼠标一视同仁地无反应** —— 不是「指针被同步设备独占」,
|
||||
而是**整条输入注入通道被吞在输入队列之后**。任何应用层代码都绕不过去。
|
||||
|
||||
复位手段实测:
|
||||
|
||||
| 手段 | 结果 |
|
||||
|---|---|
|
||||
| `--smart-mouse-optimize` 三档(`off` / `auto` / `on`) | ❌ 无效 |
|
||||
| 虚拟机「挂起 / 恢复」 | ❌ 无效 —— resume 会**完整还原 win32k 状态**,卡住的输入态当然一起被还原 |
|
||||
| **虚拟机「ACPI 关机 + 启动」(真正冷启动 Windows)** | ✅ **立即恢复** |
|
||||
|
||||
> 结论:VM 输入栈瞬时卡死,**只有冷启动能复位**。
|
||||
> 开工前先让宿主机把 VM 窗口真正当前台窗口交互一次(宿主机侧绝对鼠标集成会把 guest 指针从属过去;
|
||||
> 实测 `--smart-mouse-optimize` 三档都不改变这一点)。
|
||||
|
||||
### 10.4 被控端「活着但不响应」——两条运维事实
|
||||
|
||||
**① 计划任务以 `RunLevel Highest` 拉起时,普通权限 `taskkill` 会「拒绝访问」。**
|
||||
|
||||
```text
|
||||
na-gui user=na-test logon=Interactive runlevel=Highest
|
||||
```
|
||||
|
||||
→ 必须从**更高权限通道**杀:宿主虚拟机 CLI 进入 guest(**不带** `--current-user`)
|
||||
拿到 `nt authority\system` 的 shell,`taskkill /F /PID <pid>` 才会成功。
|
||||
|
||||
另外:旧进程仍占着 8765 时,`schtasks /run /tn na-gui` 会**报「执行成功」但新实例立即退出**
|
||||
(表现为端口仍被旧 PID 占着)→ 必须**先杀后起**。
|
||||
|
||||
**② 客户端异常断开后,被控端会留一条 `CLOSE_WAIT` 并拒收新连接。**
|
||||
|
||||
```text
|
||||
netstat -ano | findstr 8765
|
||||
TCP 192.0.2.9:8765 192.0.2.2:57182 CLOSE_WAIT 9396
|
||||
```
|
||||
|
||||
此时新连接**能完成 TCP 三次握手,但 TLS 握手永不完成**(控制端只见 `E_TIMEOUT`),
|
||||
**不会自愈**,只能重启被控端进程。触发方式:客户端在请求进行中被 `SIGTERM`/`SIGKILL`
|
||||
(即「不做优雅关闭」)。
|
||||
|
||||
> ⚠️ **尚未定论**是产品缺陷还是环境使然 —— 本轮只观察到现象与可复现路径,未取到被控端的卡点证据。
|
||||
> 记此供后续专门排查。若成因是「`console.log` 写满无人读取的管道 → 事件循环阻塞」,
|
||||
> 那对「被控端常驻、控制端随时可能硬断开」这种使用模式是个真问题。
|
||||
|
||||
### 10.5 两条虚拟机 CLI 通道的语义差异(本轮踩出来的)
|
||||
|
||||
> 下表 `<vm-cli>` 泛指所用虚拟化平台提供的 guest 控制命令(具体命令名见该平台文档)。
|
||||
|
||||
| 命令 | 实测行为 |
|
||||
|---|---|
|
||||
| `<vm-cli> exec <VM> -- <cmd>` | **返回 exit=0 但完全空转**:无输出、无副作用(用「guest 反连 Mac 监听端口」判决)→ **不可用** |
|
||||
| `<vm-cli> enter <VM>` | 默认 `nt authority\system`(会话 0,无交互桌面) |
|
||||
| `<vm-cli> enter <VM> --current-user` | 登录用户的**真实交互** shell ✅ |
|
||||
|
||||
另两条:
|
||||
|
||||
- `<vm-cli> enter` **没有执行参数**(加 `--` 报 `Unrecognized option`),它是交互式 shell,
|
||||
命令要**经 stdin 喂**:`printf 'cmd\r\n...\r\nexit\r\n' | <vm-cli> enter ...`
|
||||
- **该 CLI 的参数里带逗号 / `&&` / 重定向会报 `Invalid argument`** —— 复杂逻辑一律先落成 `.cmd` / `.ps1` 再调用。
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
classDiagram
|
||||
class ProxyModule {
|
||||
<<module apps/agent/src/net/proxy.ts>>
|
||||
+normalizeProxyUrl(raw) URL|null
|
||||
+isBypassed(targetUrl, noProxy) boolean
|
||||
+pickEnvProxy(env, targetUrl) EnvPick
|
||||
+parseScutilProxy(text) SysProxy|null
|
||||
+parseRegQueryProxy(text) SysProxy|null
|
||||
+detectSystemProxy(platform, run) Promise~SysProxy|null~
|
||||
+resolveProxy(opts) Promise~ProxyDecision~
|
||||
+httpGet(url, opts) Promise~HttpResponse~
|
||||
+tunnelConnection(proxy, target, signal) Function
|
||||
}
|
||||
|
||||
class SelfUpdateCapability {
|
||||
<<module apps/agent/src/capabilities/selfupdate.ts>>
|
||||
+agentUpdate(args) Promise~unknown~
|
||||
-download(url, timeoutMs) Promise~DownloadResult~
|
||||
-planBackupCleanup(files, keep) string[]
|
||||
}
|
||||
|
||||
class LkgRecord {
|
||||
<<interface apps/agent/src/lkg.ts>>
|
||||
+hash string
|
||||
+hash_full string?
|
||||
+bytes number
|
||||
+script_path string
|
||||
+backup_glob string?
|
||||
+recorded_at number
|
||||
+node_version string
|
||||
+platform string
|
||||
}
|
||||
|
||||
class LkgModule {
|
||||
<<module apps/agent/src/lkg.ts>>
|
||||
+recordLKG() LkgRecord|null
|
||||
+readLKG() LkgRecord|null
|
||||
+lkgStatus() LkgStatus
|
||||
+lkgFilePath() string
|
||||
}
|
||||
|
||||
class RollbackCommand {
|
||||
<<module apps/cli/src/cmd/rollback.ts>>
|
||||
+cmdRollback(opts) Promise~void~
|
||||
+planRollback(info) RollbackPlan
|
||||
+pickBackupByHash(candidates, wantHash) Candidate|null
|
||||
+hashRemoteFile(client, path) Promise~FileHash~
|
||||
}
|
||||
|
||||
class AuditL2Capability {
|
||||
<<module apps/agent/src/capabilities/audit-l2.ts>>
|
||||
+auditL2(args) Promise~unknown~
|
||||
+verifyL2() L2VerifyResult
|
||||
+readL2(limit) L2Entry[]
|
||||
+l2FilePath() string
|
||||
}
|
||||
|
||||
class ManifestSystem {
|
||||
<<module packages/protocol/src/capabilities/manifest/system.ts>>
|
||||
+SYSTEM_CAPABILITIES CapabilityDescriptor[]
|
||||
}
|
||||
|
||||
SelfUpdateCapability --> ProxyModule : 单向依赖
|
||||
SelfUpdateCapability --> LkgModule : 读 LKG(回滚提示)
|
||||
LkgModule --> LkgRecord : 读写
|
||||
RollbackCommand --> LkgModule : 经 system.info.lkg 消费
|
||||
RollbackCommand ..> AuditL2Capability : 无直接依赖(仅同库)
|
||||
ManifestSystem ..> AuditL2Capability : returns_schema 契约真源
|
||||
+490
-19
@@ -5,8 +5,8 @@
|
||||
> 保留原文是为了可回溯决策过程;实现以「实施决策」节与代码为准。
|
||||
|
||||
|
||||
> 版本 v1.0 | 日期 2026-10-07 | 状态:待皇上评审
|
||||
> 定位:为「Mac 控 Windows」补上**人肉可视化操作台**——CLI 是脚本底座、MCP 是 AI 落点、本方案是**皇上自己点按钮**的桌面界面。
|
||||
> 版本 v1.0 | 日期 2026-10-07 | 状态:待用户评审
|
||||
> 定位:为「Mac 控 Windows」补上**人肉可视化操作台**——CLI 是脚本底座、MCP 是 AI 落点、本方案是**用户自己点按钮**的桌面界面。
|
||||
|
||||
---
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
|
||||
| 层 | 选型 | 版本 | 选型理由 |
|
||||
|---|---|---|---|
|
||||
| 桌面壳 | **Tauri 2** | 2.x | 跨端(Mac/Win)+ 性能好 + 体积小;Rust 后端契合皇上「Rust 加速」技术栈 |
|
||||
| 前端框架 | **Vue 3** | 3.5+ | 皇上既有 Vue2/Vue3 经验(reader、FinDataHub),迁移成本最低 |
|
||||
| 桌面壳 | **Tauri 2** | 2.x | 跨端(Mac/Win)+ 性能好 + 体积小;Rust 后端契合用户「Rust 加速」技术栈 |
|
||||
| 前端框架 | **Vue 3** | 3.5+ | 既有 Vue 2 / Vue 3 实战经验,迁移成本最低 |
|
||||
| 语言 | **TypeScript** | 5.x | 与 monorepo 既有 TS 生态对齐,复用协议类型定义 |
|
||||
| 构建 | **Vite** | 6.x | Tauri 官方模板默认,HMR 快 |
|
||||
| 状态管理 | **Pinia** | 2.x | Vue3 官方推荐,模块化 store 天然契合多面板 |
|
||||
@@ -41,7 +41,7 @@
|
||||
| 性能 | 好(Rust 后端) | 中 | 受浏览器沙箱限制 |
|
||||
| 直接复用 Node SDK | ❌(需 Rust 版 client) | ✅(主进程可 import) | ❌(需浏览器版) |
|
||||
|
||||
**取舍**:Tauri 需要新写一份 Rust 版 client(协议本身是 JSON-RPC over WebSocket,语言无关,逻辑对齐已跑通的 client.ts 即可),换取体积、内存、性能上的长期优势——与皇上「性能好 + 跨端」的要求一致。
|
||||
**取舍**:Tauri 需要新写一份 Rust 版 client(协议本身是 JSON-RPC over WebSocket,语言无关,逻辑对齐已跑通的 client.ts 即可),换取体积、内存、性能上的长期优势——与用户「性能好 + 跨端」的要求一致。
|
||||
|
||||
---
|
||||
|
||||
@@ -224,9 +224,18 @@ nodeagent/
|
||||
| 命令闭环成功率 | 成功返回 ÷ 发起 | ≥ 95% |
|
||||
| 内网调用 P95 时延 | 前端点击到结果渲染 | < 3s |
|
||||
| 应用内存占用 | Mac 版空闲常驻 RSS | < 150MB(远低于 Electron) |
|
||||
| 打包体积 | 单端安装包 | Mac `.app` < 30MB / Win 安装包 < 40MB |
|
||||
| 打包体积 | **用户实际下载的分发件** | Mac `.dmg` < 40MB(实测 **33.8MB** ✓)/Win 安装包 < 40MB(实测 ✓ `.msi` **34.2 MiB**、NSIS **19.5~23.2 MiB**) |
|
||||
| 双端编译 | `pnpm build` 一次产出两端 | 100% |
|
||||
|
||||
> **⚠️ 口径调整(2026-10-11,待确认)**:原指标写的是「Mac `.app` < 30MB / Win 安装包 < 40MB」。
|
||||
> 那条是 **Tauri + Rust client** 时代的数字 —— 当时应用内不含 JS 运行时。
|
||||
> 改为 Node sidecar 方案后,应用必须自带 Node 运行时(sidecar 是 `.mjs`,不能假定用户装了 Node),
|
||||
> 故 `.app` 实测 **90.8 MiB**(其中 Node 运行时 86MB)。
|
||||
> **但用户下载的是 `.dmg`,实测 33.78 MiB** —— 仍低于原定 40MB。
|
||||
> 故本表口径改为「分发件体积」。作为对照:本机实测 Electron 运行时本体
|
||||
> (`Electron Framework.framework`)为 **207MB**,即 Tauri + Node 的运行时开销
|
||||
> 仍不到 Electron 的 **一半**(86MB vs 207MB)。
|
||||
|
||||
---
|
||||
|
||||
## 9. 里程碑拆分
|
||||
@@ -314,16 +323,15 @@ cargo run --example probe -- ws://127.0.0.1:8765 <key> /tmp/nodeagent-e2e
|
||||
|
||||
---
|
||||
|
||||
## 11. 待确认项(请皇上评审时一并拍板)
|
||||
## 11. 待确认项(请用户评审时一并拍板)
|
||||
|
||||
| # | 事项 | 臣的建议 |
|
||||
| # | 事项 | 建议 |
|
||||
|---|---|---|
|
||||
| C1 | Windows 端桌面应用的定位 | 被控端本地面板(默认侧重)+ 对称可作控制端 |
|
||||
| C2 | 组件库 | Naive UI(暗色友好、TS 完整);若皇上惯用 Element Plus 可换 |
|
||||
| C2 | 组件库 | Naive UI(暗色友好、TS 完整);若用户惯用 Element Plus 可换 |
|
||||
| C3 | 图表库 | ECharts(资源趋势/磁盘);确认后纳入 M2 |
|
||||
| C4 | 是否同步做图形接管 UI | 建议 v2 再做,首期聚焦闭环 |
|
||||
|
||||
> 仅供参考,本方案为技术规划,不构成任何投资建议。
|
||||
|
||||
---
|
||||
|
||||
@@ -414,16 +422,47 @@ Rust 侧从 resource 目录解析路径后 spawn(开发期直接用系统 node
|
||||
**真跑 Node 子进程**验证 spawn / 请求-响应匹配 / 错误不崩 —— 不必等到人工开窗口才敢确认。
|
||||
`cargo test` 实测:**3/3 通过**(0.49s)。
|
||||
|
||||
### ⚠️ 两处已知待办(本轮未做,均需真机/图形环境)
|
||||
### Node 运行时分发(2026-10-11 完成)
|
||||
|
||||
1. **Node 运行时随包分发**:sidecar 是 `.mjs`,目标机器需有 Node。开发期用系统 node
|
||||
(`resolve_node` 会回退到 PATH);**分发给他人时**需把 Node 运行时作为 resource 打进包。
|
||||
计划:写 `scripts/prepare-console.mjs` 下载对应平台 node 到 `src-tauri/resources/`,
|
||||
并在打包时用 `tauri build --config` 注入 `bundle.resources` 的 node 项
|
||||
(**不能写死在默认 conf 里** —— `tauri-build` 会在编译期校验文件存在性,
|
||||
声明未下载的文件会让 `cargo check/test` 直接失败,本轮已踩并修复)。
|
||||
2. **GUI 端到端人工验证**:开窗口 → 连接被控端 → 操作界面。
|
||||
沙箱无法运行图形应用,故本轮的验证止于 `cargo test`(真跑 sidecar)+ 单测/构建全绿。
|
||||
sidecar 是 `.mjs`(不是可执行文件,故**不满足 Tauri `externalBin` 的要求**),
|
||||
分发给他人时须把 Node 运行时一并打进包。做法沿袭被控端 `scripts/pack.mjs` 的既有路子:
|
||||
**把 Node 运行时当作 Tauri resource**。
|
||||
|
||||
```
|
||||
scripts/prepare-console.mjs ① 从本机 node 复制 → apps/console/src-tauri/runtime/node
|
||||
② macOS:strip -x(108MB→86MB)**并 adhoc 重签**
|
||||
③ 生成 apps/console/src-tauri/tauri.bundle.gen.json
|
||||
{ "bundle": { "resources": {
|
||||
"../sidecar/dist/sidecar.mjs": "sidecar.mjs",
|
||||
"runtime/node": "node" } } }
|
||||
pnpm --filter nodeagent-console bundle
|
||||
= sidecar:build → prepare:node → tauri build --config tauri.bundle.gen.json → verify:bundle
|
||||
```
|
||||
|
||||
**三处必须记住的约束**(都是实测踩出来的):
|
||||
|
||||
1. **绝不写进默认 `tauri.conf.json`** —— `bundle.resources` 是**编译期契约**,
|
||||
`tauri-build` 会校验声明的每个文件必须存在;声明一个「运行期才准备好」的文件会让
|
||||
`cargo check/test` 直接失败(2026-10-10 已踩)。故一律走 `--config` 注入。
|
||||
2. **macOS 上 strip 之后必须 adhoc 重签** —— `strip` 会使 Mach-O 代码签名失效,
|
||||
而 macOS 内核对签名无效的可执行文件**直接 SIGKILL(exit 137)**,无任何有用报错。
|
||||
实测对照:原样复制 ✓|strip 不重签 ✗(137)|strip + `codesign --force --sign -` ✓。
|
||||
3. **`resolve_node` 与 resource 映射必须对齐** —— 映射 `runtime/node → node`
|
||||
使产物落在 `Contents/Resources/node`,正是 `src-tauri/src/sidecar.rs` 里
|
||||
`resolve_node(resource_dir)` 查找的路径。
|
||||
|
||||
**验证(不只看「打包成功」)**:`apps/console/scripts/verify-bundle.mjs` 真 spawn 打包内的
|
||||
node 去跑打包内的 sidecar,并走一次真实 JSON-RPC 握手 —— 源码与单测都正常、
|
||||
只在产物中暴露的问题(如上述 137)靠它拦下。已接入 CI(Linux 与 Windows 双覆盖)。
|
||||
|
||||
**实测体积**:`.app` **90.8 MiB**(6.75 MiB → 90.8 MiB,增量即 Node 运行时 86MB)/
|
||||
`.dmg` **33.78 MiB**(分发件,仍 < 40MB 目标)。
|
||||
|
||||
### ⚠️ 剩余一处已知待办(需图形环境)
|
||||
|
||||
**GUI 端到端人工验证**:开窗口 → 连接被控端 → 操作界面。
|
||||
沙箱无法运行图形应用,故验证止于 `cargo test`(真跑 sidecar)+ `verify:bundle`(真跑产物)
|
||||
+ 应用进程可启动(实测存活、无崩溃输出)。**界面交互仍需在图形环境点一遍。**
|
||||
|
||||
### 一个易踩的构建约束(本轮 CI 失败后查明)
|
||||
|
||||
@@ -432,3 +471,435 @@ Rust 侧从 resource 目录解析路径后 spawn(开发期直接用系统 node
|
||||
- 只声明**构建期必会生成**的产物(如 `sidecar.mjs`,由 beforeBuildCommand 产出)
|
||||
- CI 里在 clippy/test **之前**必须先生成它,否则 build.rs 阶段就失败
|
||||
- 本地复现:删掉 `sidecar/dist/` → `cargo check` 报 "failed to run custom build command" ✓
|
||||
|
||||
---
|
||||
|
||||
## 本机服务托管(2026-10-11):把「命令行那一步」从用户手里拿走
|
||||
|
||||
### 需求原文与逐条落点
|
||||
|
||||
> 「不再打开命令行窗口,运行应用之后,有启动、停止、重启、检查更新等功能,
|
||||
> 有日志功能,可以后台运行和开机自启等等功能」
|
||||
|
||||
| # | 要求 | 落点 | 实现位置 |
|
||||
|---|---|---|---|
|
||||
| 1 | **不再打开命令行窗口** | 三处(见下表) | `agent.rs` / `autostart.rs` / Tauri 自身 |
|
||||
| 2 | 启动 / 停止 / 重启 | 「本机服务」页三按钮 + 托盘菜单 + 侧栏快捷按钮 | `AgentManager` |
|
||||
| 3 | 检查更新 | 「更新」卡片:检查 → 更新 → 打开 Release 页 | `check_update` / `download` / `apply_update` |
|
||||
| 4 | 日志功能 | 「服务日志」页(实时推送 + 过滤 + 暂停 + 导出) | `na-agent-log` 事件 + `agent_logs` |
|
||||
| 5 | 后台运行 | 托盘常驻;关窗 → 最小化到托盘 | `build_tray()` + `on_window_event` |
|
||||
| 6 | 开机自启 | 「运行方式」卡片开关 | `autostart.rs` |
|
||||
|
||||
### 消灭命令行窗口:三条措施缺一不可
|
||||
|
||||
| # | 措施 | 不加会怎样 |
|
||||
|---|---|---|
|
||||
| 1 | 控制台本身是 GUI 子系统程序 | 打开应用先弹一个黑窗 |
|
||||
| 2 | spawn `node.exe` 时带 `CREATE_NO_WINDOW`(`0x0800_0000`) | **每次点「启动」都闪一个黑窗** —— 正是要消灭的现象 |
|
||||
| 3 | 开机自启拉起的是控制台(`--agent-only`),不是裸 `node` | 开机时黑窗一闪而过 |
|
||||
|
||||
> 第 2 条是最容易被漏掉的一条:Rust 的 `std::process::Command` **默认不设**这个标志,
|
||||
> 于是「界面上点一下启动」= 屏幕上闪一个控制台窗口。它不报错、不影响功能,
|
||||
> 所以只有在**真的看着屏幕点**的时候才会发现。
|
||||
|
||||
### 架构:控制台自己就是被控端的「进程管理者」
|
||||
|
||||
```
|
||||
┌──────────────────── apps/console ────────────────────┐
|
||||
│ Vue:本机服务页 / 服务日志页 │
|
||||
│ │ invoke ▲ event │
|
||||
│ ▼ │ na-agent-state │
|
||||
│ Rust:AgentManager ──────────────────────────────┐ │
|
||||
│ │ ① spawn node agent.mjs(CREATE_NO_WINDOW)│ │
|
||||
│ │ ② 800ms 轮询 try_wait(不把 Child 交给 wait()) │
|
||||
│ │ ③ stdout/stderr 双泵 → 环形 4000 行 + seq │ │
|
||||
│ ▼ │ │
|
||||
│ Node 被控端(本机,loopback 8765)◄──── 同一个 agent 复用 ─┘
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**复用同一个被控端实现**:控制台托管的这台本机被控端,与控制端连远端用的是**同一份 `agent.mjs`**,
|
||||
不做「控制台内嵌一个第二套被控端」。这与 §「主控被控一体」的既有决策一致。
|
||||
|
||||
### 四条实测踩出来的关键约束
|
||||
|
||||
| # | 约束 | 为什么(踩坑记录) |
|
||||
|---|---|---|
|
||||
| 1 | **退出监控用轮询 `try_wait`,绝不把 `Child` 交给 `wait()`** | `wait()` 会**取得 `Child` 的所有权**,此后 `stop()` 再也拿不到它去 kill → 点「停止」无效 |
|
||||
| 2 | **日志游标用独立单调 `seq`,不用位置下标** | 环形缓冲淘汰旧行后位置下标会漂移 → 前端增量拉取**静默错位**(重复行 / 丢行,且不报错) |
|
||||
| 3 | **`start()` / `restart()` 的接收者必须是 `self: &Arc<Self>`** | 监控任务要持有自持引用;`&self` 拿不到 `Arc`,编译期 E0599/E0502 |
|
||||
| 4 | **macOS `gui/<uid>` 的 uid 运行时 `id -u` 现取** | 硬编码 `501` 只是首个本地用户的常见值,多用户机不成立 |
|
||||
|
||||
### 播种(seeding):为什么必须有版本比较
|
||||
|
||||
控制台随包带一份 `agent.mjs`,首次运行复制到数据目录。规则:
|
||||
|
||||
```
|
||||
随包版本 > 已安装版本 → 覆盖(用户拿到了更新的控制台,应带上更新的被控端)
|
||||
随包版本 ≤ 已安装版本 → 保留数据目录里的(用户可能已自更新到更新版本)
|
||||
```
|
||||
|
||||
若写成「同版本才跳过」,则**用户自更新过被控端后,下次开控制台会被静默降回旧版** ——
|
||||
无任何提示,能力凭空变少,且极难归因。已用单测钉死「随包版本更低时不得覆盖」。
|
||||
版本比较按**数值逐段**(`2.0.10 > 2.0.9`):字符串比较会把前者判小。
|
||||
|
||||
### 判定「实例是否在跑」为什么不靠单一信号
|
||||
|
||||
控制台同时面对两种被控端:
|
||||
|
||||
| 情况 | 判定方式 | 界面处置 |
|
||||
|---|---|---|
|
||||
| 本控制台托管的实例 | 管理器状态 = `RUNNING` | 可停止 / 重启 |
|
||||
| **手工安装的非托管实例**(用户先跑过 `install.ps1` / `install-macos.sh`) | 管理器状态 = `STOPPED` **但** TCP 探测端口已被占用 | **明确告警**:这不是本控制台启的,无法代它停止;给出两条出路 —— 手工停掉,或用「连接本机」直接复用 |
|
||||
|
||||
只看管理器状态会把第二种情况判成「未运行」,然后用户点「启动」得到一个端口冲突;
|
||||
只看端口探测则会把「别人的服务」误报成本控制台托管的实例。**两个信号交叉判**才能给出正确建议。
|
||||
|
||||
### 契约守卫:钉住三类「不报错的失效」
|
||||
|
||||
界面点按钮没反应、日志不刷新,往往**没有任何异常** —— 因为失效发生在「两边名字不一致」这种
|
||||
编译器和类型系统都看不见的地方。新增 `tests/unit/console-commands.test.mjs`(8 用例)覆盖:
|
||||
|
||||
| 守卫 | 若缺失会怎样 |
|
||||
|---|---|
|
||||
| `invoke("x")` ⇄ Rust `generate_handler!` 双向 | 前端调不存在的命令;或改名后残留死命令 |
|
||||
| `emit("na-*")` ⇄ `listen("na-*")` 双向 | **界面永不刷新,零报错**(最阴的一类) |
|
||||
| `tauri.bundle.gen.json` 资源名 ⇄ Rust `resolve_*` 字面量 | 生产包内找不到 sidecar / node / agent |
|
||||
| sidecar 方法名 ⇄ Rust `.request("x")` | 跨语言调用错名 |
|
||||
| 路由表 ⇄ 实际 view 文件 + 默认路由 | 写了页面进不去;循环依赖导致路由推导成 `any` |
|
||||
| `var(--na-*)` 使用点 ⇄ `cssVars()` 定义 | 拼错变量名**不报错**,颜色静默落回继承值 |
|
||||
| `index.html` 首帧兜底色 ⇄ 调色板 `bg` | 首帧兜底是全应用唯一允许的色值重复处,允许重复就必须有守卫 |
|
||||
|
||||
> 该测试**已注入故障实测会红**:① 改 `agent_start` → `agent_startX`、`na-agent-state` → `na-agent-statee`
|
||||
> 当场 2 条失败;② 把 `var(--na-sunken)` 拼成 `var(--na-sunkenTypo)`(含大写)与 `var(--na-sunkenx)`,
|
||||
> 变量闭合那条均失败;③ 把 `index.html` 的 `#f4f4f5` 改回 `#ffffff`,首帧底色那条失败。
|
||||
> 全部复原后 `#pass = 8 / #fail = 0`,文件逐字节还原。
|
||||
>
|
||||
> 第 ② 条暴露过一次**守卫自身漏报**:正则原写 `[a-z0-9-]+`,遇到含大写的拼错会在中途停下导致
|
||||
> 整个模式不匹配 —— 于是「写错的变量名」反而逃过检查。放宽到 `[^)\s,;]+` 后才真正生效。
|
||||
> 教训:**注入故障不只是验证守卫有没有,还要验证它是否覆盖了目标形态**。
|
||||
|
||||
### 出网代理:让「检查更新」在境内真的能用(2026-10-13)
|
||||
|
||||
「检查更新 / 下载更新包」是控制台**唯一**两条需要出公网(GitHub)的路径。原实现直接用
|
||||
Node 内建 `fetch`,于是境内**必然**失败,且失败原因永远是笼统的 `fetch failed`。三条根因:
|
||||
|
||||
1. `fetch`(undici)**默认不读代理环境变量**(`NODE_USE_ENV_PROXY=1` 能开,但只认环境变量);
|
||||
2. **系统代理读不到** —— Windows 用户配的是 WinINET(第三方代理客户端的「系统代理」),macOS 用户配的是系统设置里的代理,Node 两者都不读;
|
||||
3. `fetch(url,{dispatcher})` 也走不通:`dispatcher` 必须是 undici 的实例,而 Node 未把 undici 暴露成可导入模块。
|
||||
|
||||
因此 `apps/console/sidecar/proxy.mjs` **自持出网**(零新依赖):
|
||||
|
||||
| 目标 | 经代理的做法 |
|
||||
|---|---|
|
||||
| `https://` | 先向代理发 `CONNECT host:443` 建隧道,再在隧道 socket 上叠 TLS;socket 交 `http.request`(**不用 `https.request`**,否则二次握手) |
|
||||
| `http://` | 按 HTTP 代理规范发**绝对形态**请求行 |
|
||||
| 无代理 | 直连(与改动前行为一致) |
|
||||
|
||||
代理来源优先级:`NA_PROXY` → 目标协议对应的环境变量 → **系统代理**(Windows 读注册表 WinINET / macOS 读 `scutil --proxy`)。
|
||||
`NO_PROXY` 命中则直连。只支持 HTTP 代理;遇到 `socks5://` 或**系统代理是 PAC 脚本**时
|
||||
**明确把原因带给用户**(本机 AdGuard 正是 PAC 模式)—— 静默降级是这类缺陷最坏的地方。
|
||||
|
||||
**超时成对定义**(`sidecar.rs`):内层出网 120s / 外层 RPC 150s,且**内层必须小于外层** ——
|
||||
这条关系写成**编译期不变式**,因为一旦破坏,用户只会看到「check_update 超时」这种**丢掉真实原因**的假失败。
|
||||
120s 的依据是真机实测的重定向链耗时分布(2.0 / 10.0 / 39.7 / 68.6 / >109 秒),不是拍脑袋。
|
||||
|
||||
守卫:`tests/unit/sidecar-proxy.test.mjs`(16 用例)= 纯函数解析(夹具取自真机)+ 离线真发请求
|
||||
(直连/绝对形态/CONNECT/**隧道+TLS 阶段 abort 必须立即生效**)+ 真 spawn sidecar 走 JSON-RPC 的端到端
|
||||
+ 「sidecar 源码不得再出现裸 `fetch(`」形态守卫 + 「连接被中途重置不得报成请求超时」的错误映射守卫。
|
||||
全部做过注入故障实测(含还原后逐字节一致)。
|
||||
|
||||
**真机已闭环**(Win11 ARM64 VM,用随包 `node.exe` 直跑 `dist/sidecar.mjs`,两侧 sha256 一致):
|
||||
|
||||
| 组 | 配置 | 结果 |
|
||||
|---|---|---|
|
||||
| 对照 | 未配系统代理、**不给任何环境变量**(直连) | **30877ms 失败**,但报的是真实原因 `socket hang up`,`data` 带 `via=直连`/`timeout_ms` 与可操作提示 |
|
||||
| 实验 | 只设 **WinINET 系统代理**指向转发端点、**仍不给任何环境变量** | **成功 28883ms** → `v2.0.3`、`via_proxy=system`,日志 `(来源 system)`,退出码 0 |
|
||||
|
||||
→ 实验组走通的正是本次新增的**系统代理发现**(`reg query` → `来源 system`);
|
||||
其 28.9s 耗时同时反证旧实现固定 20s 的内部超时对这条链路**必然**失败。
|
||||
复验后 VM 的 `ProxyEnable`/`ProxyServer` 已**原样还原**。
|
||||
|
||||
## Windows 端控制台(2026-10-11)
|
||||
|
||||
### 起因
|
||||
|
||||
控制台此前**只在 macOS 上出过包**(`.app` / `.dmg`)。而它的主要用户场景恰恰是
|
||||
「在 Windows 机器上管控其他机器」——只出 macOS 版等于把主战场让开了。
|
||||
|
||||
排查后发现:代码层面对 Windows 其实是备好的(`cfg!(windows)` 分支、`node.exe` 路径、
|
||||
`creation_flags`、PowerShell 计划任务、`.ico` 图标、`prepare-console.mjs` 的平台自适应都在),
|
||||
**缺的只是「真在 Windows 上构建一次」这一环**。
|
||||
|
||||
> 这里有个容易被忽视的点:macOS 上的 `cargo check` **不会编译 `cfg(windows)` 代码**,
|
||||
> 所以那些 Windows 分支在此之前**从未被编译器看过一眼**。而 `cfg!(windows)` 这种运行时宏
|
||||
> 虽然两边都会编译,却也只是类型检查、从未产出过 Windows 产物。
|
||||
|
||||
### 为什么必须双架构
|
||||
|
||||
被控端发布包出过「arm64 包里装的其实是 x64 `node.exe`」的事故,后果是:
|
||||
在 ARM Windows 上**能启动、能 listen,但 TLS 握手不完成**(客户端只见超时),
|
||||
且 macOS 侧 100% 复现不出。
|
||||
|
||||
控制台同理,而且更甚 —— 它内含 node 运行时,还要用这个 node **托起本机被控端**,
|
||||
即自己就是 TLS 服务端。所以 x64 / arm64 必须各出一套,不能只出默认架构。
|
||||
|
||||
### 为什么用原生 ARM runner,而不是交叉编译
|
||||
|
||||
GitHub 提供 ARM64 Windows runner(`windows-11-arm`,公共仓库免费),
|
||||
本仓库是 public,故 arm64 可以在它上面**原生构建**。
|
||||
|
||||
交叉编译(在 x64 runner 上编 arm64)看似省事,实则要付两笔代价:
|
||||
1. 需要 VS 的 ARM64 工具链,且 Tauri 的 NSIS/MSI bundler 对 arm64 支持面较窄;
|
||||
2. 更关键:`prepare-console.mjs` 的**自检要真执行 `node.exe`**
|
||||
(x64 机器跑不了 arm64 二进制),会逼着引入「跳过自检」这类**削弱验证**的开关。
|
||||
而当产出的是「架构错的运行时」时,自检正是唯一能拦住它的那道闸。
|
||||
|
||||
宁可等原生 runner,也不引入削弱验证的分支。
|
||||
|
||||
### 三条断言是必需项,不是顺手检查
|
||||
|
||||
| 断言 | 为什么不能省 |
|
||||
|---|---|
|
||||
| **构建机架构 ⇄ 目标架构**(`node -p process.arch`) | ARM runner 镜像里确实存在 x64 模拟的组件(社区实测 python 即为 AMD64)。一旦 Node 也是模拟版,`process.arch` 会是 `x64` → 把 x64 的 `node.exe` 打进 arm64 安装包 → **真机上能启动但 TLS 握手不完成,而 CI 全绿** |
|
||||
| **随包 `node.exe` 的 PE Machine**(打包前,查 `src-tauri/runtime/`) | 与 `pack.mjs` 的双架构校验同源:不只看「脚本跑完了」,而是直接读最终要打进包的那个二进制头。中间逻辑再自洽,也可能因上游给了错架构的二进制而产出错包。放在打包前,是为了让失败点更靠近原因 |
|
||||
| **安装包里确实装着三样东西**(打包后,解开安装包查内容) | 见下节。它才是**最终交付物级**的断言 |
|
||||
|
||||
### 首个 run 双双失败:错的不是构建,是断言本身
|
||||
|
||||
`console-windows.yml` 的第一个 run(`38151366534`)两个 job 全红,且**失败点完全一致**。
|
||||
但取日志后发现,**构建链路一路全绿**:
|
||||
|
||||
| 步骤 | x64 job | arm64 job |
|
||||
|---|---|---|
|
||||
| 构建机架构 | ✓ | ✓ `process.arch = arm64`(原生 ARM runner 属实) |
|
||||
| 随包 `node.exe` PE | ✓ `0x8664` | ✓ `0xaa64`(73.5 MB) |
|
||||
| `tauri build` | ✓ `.msi` 34.2 MiB + `-setup.exe` 23.2 MiB | ✓ `-setup.exe` 19.5 MiB |
|
||||
| 产物自举 | ✓ 打包内 node v22.23.3 真跑起打包内 sidecar | ✓ |
|
||||
| **体积断言** | ✗ `34.2 < 50` | ✗ `19.5 < 50` |
|
||||
|
||||
死的是最后一步,而这一步是**守卫自己写错了**。原断言是「安装包体积 > 50MB」,
|
||||
|
||||
> 另断言安装包体积 > 50MB:含 Node 运行时时 x64 约 82MB / arm64 约 72MB,
|
||||
> 体积过小意味着运行时没打进去 —— 那种包能装,但一启动就找不到 node。
|
||||
|
||||
错在把**未压缩的目录形态**(`.app` ≈ 92 MiB)当成了**压缩安装包的体积**。
|
||||
安装包是压缩容器:LZMA 下 `node.exe` 自身就能压到 1/3~1/4,实测 `.msi` 34.2 MiB、
|
||||
NSIS 19.5~23.2 MiB,阈值 50MB 必然误杀。
|
||||
|
||||
> 更根本的问题:**体积阈值属于「中间逻辑自洽」式断言**。包大不等于 `node.exe` 真在里面
|
||||
> (可能打进的是别的大文件),包小也不等于没在里面(压缩率会随 Node 版本漂移)。
|
||||
> 这与「被控端 zip 里塞了错架构的 node」那条教训是同一类 —— 判据必须落在**产物内容**上,
|
||||
> 不能落在**间接指标**上。
|
||||
|
||||
改为**解开安装包、查内容**(`.msi` 走 `msiexec /a` 管理安装、NSIS 走 `/S /D=` 静默安装,
|
||||
两者都是各自格式的核心能力,不引外部依赖),一次钉死三件事:
|
||||
|
||||
| 检查 | 挡住什么 |
|
||||
|---|---|
|
||||
| `node.exe` / `sidecar.mjs` / `agent.mjs` **三样都在** | 缺任一,真机上就是「应用能开、但托不起被控端」 |
|
||||
| `node.exe` 的 **PE Machine = 目标架构** | 「arm64 包里装的是 x64 node.exe」那类**只在真机暴露**的事故 |
|
||||
| `node.exe` 解压后 **> 40MB** | 塞了个占位文件(这里是「体积」的**正确用法**:作为内容已验明后的自明性检查,而不是作为「运行时在不在」的**替代判据**) |
|
||||
|
||||
附带收益:**真装一遍本身就是安装器冒烟** —— 比只读目录更强。
|
||||
另给该步加 `timeout-minutes: 8`,避免静默安装万一卡住把 job 拖满 60 分钟。
|
||||
|
||||
### 第二个 run 仍红:`&` 调 GUI 程序不等待,也不设退出码
|
||||
|
||||
第二个 run(`38152850819`)两个 job **仍同点红**,但已是另一处原因,且同样是**静默失效**。
|
||||
日志里只有一行关键信息:
|
||||
|
||||
```
|
||||
msiexec /a 失败(退出码 ):nodeagent-console_2.0.2_x64_en-US.msi
|
||||
```
|
||||
|
||||
`$LASTEXITCODE` 是**空的**,而整步从开始到抛错只过了 **0.8 秒** —— 根本没等安装器跑完。
|
||||
|
||||
**根因**:`msiexec.exe` 与 NSIS 安装包都是 **GUI 子系统程序**,PowerShell 用 `&` 调用时
|
||||
**既不等待其结束、也不设置 `$LASTEXITCODE`**。于是 `$null -ne 0` 判真,
|
||||
在安装器还没跑完时就当场抛错 —— 报错读起来像「安装失败」,实为「压根没等」。
|
||||
(arm64 那条没有 MSI、直奔 NSIS,失败形态一模一样,正是同一根因的第二个样本。)
|
||||
|
||||
**修法**:
|
||||
- 两处都改 `Start-Process -Wait -PassThru`,读 `$p.ExitCode`(确定性等待),并打印实际退出码;
|
||||
- 参数拼成**单个字符串**传 `-ArgumentList`,避免数组被 PowerShell 自动加引号 ——
|
||||
那会破坏 NSIS `/D=`「必须原样、不加引号、且是最后一个参数」的硬要求;
|
||||
- 新增 `Wait-PayloadRoot`(最多 30 秒收敛):安装器可能「自己先退出、由子进程继续装」,
|
||||
避免竞态误报「找不到 node.exe」;
|
||||
- `msiexec` 退出码 3010(成功但需重启)按成功处理。
|
||||
|
||||
### 第三个 run:双绿,且断言给出了产物实证
|
||||
|
||||
第三个 run(`38154282653`)**两个 job 全绿**,第 14 步的实际输出:
|
||||
|
||||
| 检查对象 | 安装包体积 | 安装器退出码 | 解开后 `node.exe` | PE Machine | 另两样 |
|
||||
|---|---|---|---|---|---|
|
||||
| x64 `.msi` | 34.2 MiB | 0 | **82.9 MB** | **`0x8664`** ✓ | sidecar 232 KB / agent 1060 KB |
|
||||
| x64 `-setup.exe`(NSIS) | 23.2 MiB | 0 | **82.9 MB** | **`0x8664`** ✓ | 同上 |
|
||||
| arm64 `-setup.exe`(NSIS) | 19.5 MiB | 0 | **73.5 MB** | **`0xaa64`** ✓ | 同上 |
|
||||
|
||||
这一步的价值在于:**它证明的正是「19.5MB 的压缩包里真的装着 73.5MB 的 arm64 `node.exe`」** ——
|
||||
这恰是体积断言永远无法区分的那件事(19.5MB 与「没打运行时」的包在体积上无法区分)。
|
||||
另:`/D=` 确实生效(安装位置即 `pkg-extract\nsis`),故 NSIS 的默认安装位置候选列表是备用保险。
|
||||
|
||||
### 产物形态
|
||||
|
||||
| 架构 | runner | 安装包 | 实测体积 |
|
||||
|---|---|---|---|
|
||||
| x64 | `windows-latest` | `.msi`(企业批量部署)+ `-setup.exe`(NSIS,普通用户双击) | **34.2 MiB** / **23.2 MiB** |
|
||||
| arm64 | `windows-11-arm` | `-setup.exe`(NSIS)—— WiX 的 arm64 MSI 支持面较窄,不值得为它引入不确定性 | **19.5 MiB** |
|
||||
|
||||
对照 macOS:`.app` 92.2 MiB(目录形态)/`.dmg` 33.8 MiB(压缩分发件)。
|
||||
**两者不可直接比** —— 这正是上面那条错断言的来源。
|
||||
|
||||
工作流 `console-windows.yml` 与 `windows-e2e.yml` **刻意分开**:
|
||||
后者是「被控端在 Windows 上的行为验证」,跑一遍约 4 分钟、含安装/卸载真机冒烟;
|
||||
前者要装 Rust 工具链、编 Tauri(冷编 10 分钟级)。目标不同、耗时量级不同,
|
||||
混在一起会让「改一行前端也要等 15 分钟」。
|
||||
|
||||
### 仍未做
|
||||
|
||||
- **控制台尚未接入 `scripts/release.mjs`**(该脚本目前只出被控端包)。
|
||||
控制台的安装包目前只作为 CI artifact 产出,未随 Release 发布。
|
||||
- 真机安装冒烟(在真实 Windows 上装一遍、点一遍)仍需图形环境人工执行。
|
||||
|
||||
## 界面观感约定(2026-10-11)
|
||||
|
||||
两轮纯界面调整,方向都是「少即是多」。理由记下来,免得日后被当成「没做完」再补回去。
|
||||
|
||||
### 一、一律不使用图标
|
||||
|
||||
触发原因是一次真实的渲染事故:侧栏的 emoji 图标在非系统字体栈的渲染器里退化成**豆腐块**。
|
||||
试过三法,分别无效或更糟 —— 给图标指定 `Apple Color Emoji`、全局字体栈加 `!important`、
|
||||
乃至 `font-variant-emoji: emoji`(最后一招把 ASCII 数字也拆成了 emoji 呈现)。
|
||||
「哪套字体栈一定带 emoji」跨平台并不成立 —— **与其赌字形,不如不用**。
|
||||
|
||||
去掉的范围比"看得见的图标"宽得多(下面这三类都算):
|
||||
|
||||
| 类别 | 处数 | 关法 |
|
||||
|---|---|---|
|
||||
| 侧栏 emoji | 9 | 路由表 `meta.icon` 字段**整体删除**,`NavMeta` 接口同步收窄 |
|
||||
| `n-empty` 空态**插画** | **16** | `:show-icon="false"` —— 插画比图标更「图形」,散在 9 个页面,**最易漏** |
|
||||
| `n-alert` 类型图标 | 7 | naive-ui 的 `showIcon` **默认就是 `true`**,不显式关它就一直都在 |
|
||||
| `n-popconfirm` 询问图标 | 3 | 同上 |
|
||||
| 文案里的符号 | 1 | 终端页「⚠ 高权限操作,请谨慎」保留纯文字 |
|
||||
|
||||
顺带清掉 `public/{vite,tauri}.svg` 与 `src/assets/vue.svg`:`public/` 下的文件会被**整体复制进产物**,
|
||||
此前等于把 Vite / Tauri 的 logo 一并发了出去(且 `index.html` 的 favicon 正指向它)。
|
||||
`index.html` 的 `<title>` 还是模板默认的「Tauri + Vue + Typescript App」,也一并改齐
|
||||
(原生窗口标题取自 `tauri.conf.json`,故此前只是 dev 态标签页不一致)。
|
||||
|
||||
**刻意未动**:① 托盘图标 —— 菜单栏没图标就点不到,关窗后应用无法寻回,属**功能必需**;
|
||||
② 被控端 CLI 自己打印的文本标记(`⚠️`/`ℹ️`/`✅`)—— 是**程序输出内容**,会原样出现在「服务日志」页里,
|
||||
但不是界面元素;③ 控件的结构性部件(下拉/折叠箭头、输入框清除钮、表格排序箭头)。
|
||||
|
||||
### 二、配色收敛为两套中性灰阶主题
|
||||
|
||||
`简约白`(浅)/`高级灰`(深),侧栏底部一行切换,选择存 `localStorage`。
|
||||
|
||||
| 决定 | 理由 |
|
||||
|---|---|
|
||||
| **主色位改中性灰** | naive-ui 默认主色是**绿色**、原设计的品牌字是**青绿 `#63e2b7`** —— 都是"颜色",与「简单些」相悖。主按钮/开关激活/进度条/勾选框统一吃这一个值 |
|
||||
| 白主题**页底不是纯白**(`#f4f4f5`) | 卡片本身是白的,两者同色就只能靠 1px 分线撑层次,整页发「糊」。压一档浅灰后层次为「页底 < 侧栏 < 卡片」,且仍是白为主 |
|
||||
| 语义色**降饱和**而非去掉 | 这是运维工具,「运行中 / 出错」必须一眼可辨,全部灰化会导致误读。故它们只出现在徽章与告警这类**小面积**上,底色观感仍是「白 + 灰」 |
|
||||
| **色值只定义一份** | `src/theme/index.ts` 的 `Palette` 同时喂 naive-ui 的 `themeOverrides` 与 `--na-*` CSS 变量(`App.vue` 运行期注入)。两处各写一份,换主题时会**一半新一半旧且不报错、类型也抓不到** |
|
||||
| 主题**只存 `localStorage`**,不落 Rust settings | Rust 那套 `Settings` 是给「仅服务模式」(无窗口、常驻托盘)也要生效的偏好用的;主题是纯图形偏好,**没有窗口时毫无意义**。为一件纯前端的事去动 Rust 命令面与契约守卫不划算 |
|
||||
|
||||
清掉散在 **8 个文件**里的 **17 处写死色值**(日志三流着色、代码块/预览底色、节点选中边框、历史悬停…),
|
||||
全部改为 `var(--na-*)`。复扫后自定义样式里的硬编码色为 **0**。
|
||||
|
||||
**首帧防闪**:`index.html` 内联一小段脚本,在 Vue 接管前先把上次主题的底色刷上
|
||||
(否则「高级灰」用户每次启动都会看到一帧刺眼的白)。
|
||||
此处**刻意重复了 2 个底色值**,已在 `index.html` 与 `theme/index.ts` 两处注明「改底色时同步」——
|
||||
因为在它执行之前没有任何模块可依赖。除此之外全应用再无第二份色值。
|
||||
|
||||
### 本轮验证记录
|
||||
|
||||
| 条目 | 结果 |
|
||||
|---|---|
|
||||
| `cargo check --all-targets` | ✅ 0 错 0 警 |
|
||||
| `cargo test --lib` | ✅ **10/10**(含真起 node 子进程:启动/日志/停止/崩溃自愈/重复启动拒绝) |
|
||||
| `cargo clippy --all-targets -- -D warnings` | ✅ 0 警告 |
|
||||
| `npx vue-tsc --noEmit` | ✅ 通过 |
|
||||
| `node tests/unit/console-commands.test.mjs` | ✅ 8/8(三条注入故障均验证会红) |
|
||||
| `pnpm test:unit` | ✅ **282/282**(274 → 282) |
|
||||
| `node scripts/prepare-console.mjs` | ✅ agent.mjs 1059 KB + Node 运行时 86.3 MB = 87.6 MB |
|
||||
| `node apps/console/scripts/verify-bundle.mjs --runtime` | ✅ 打包内 node 真跑起打包内 sidecar(`agent.mjs` 语法解析通过) |
|
||||
|
||||
### ⚠️ 仍需图形环境人工验证的部分
|
||||
|
||||
沙箱内无法运行图形应用,故以下**只做了代码级与产物级验证,未实点**:
|
||||
|
||||
| 待验 | 怎么验 |
|
||||
|---|---|
|
||||
| 托盘图标 / 菜单 / 左键唤出 | 打开打包后的 `.app`,点图标与菜单项 |
|
||||
| 关窗 → 最小化到托盘(不退出) | 点窗口关闭按钮,确认进程仍在、图标仍在 |
|
||||
| 界面点「启动/停止/重启」 | 点三按钮,看状态与日志是否实时变化 |
|
||||
| 开机自启实际生效 | macOS:登出再登入 / 重启;Windows:重启后看计划任务 |
|
||||
| 黑窗是否真的没有 | **全程盯着屏幕操作一遍**(这条只能靠眼睛) |
|
||||
|
||||
---
|
||||
|
||||
## ARM64 真机安装冒烟与界面实点(2026-10-12)
|
||||
|
||||
上一节那张表里的条目,本轮在 **虚拟化平台 + Win11 ARM64** 上逐条做完;
|
||||
过程中还挖出并修掉一个**只在真机上才暴露**的缺陷(见本节末「真机暴露的缺陷」)。
|
||||
|
||||
### 怎么把包送进去
|
||||
|
||||
目标机的 SSH / 445 / 3389 / 5985 全被防火墙挡着,**只有 8765 开着**。
|
||||
于是让**产品自己去搬产品**:`fs.write`(分块 base64)传包 → `system.shell.exec` 静默安装 → `fs.read` 取回结果。
|
||||
这条路子本身也顺带验证了分块传输与 base64 往返的正确性。
|
||||
|
||||
| 项 | 实测 |
|
||||
|---|---|
|
||||
| 传输 | **20,445,580 B,0 块损坏**(两端 sha256 一致) |
|
||||
| 静默安装 | `EXIT=0` |
|
||||
| 包内 `node.exe` | PE `aa64` / subsystem **3 (CONSOLE)** |
|
||||
| 包内 `nodeagent-console.exe` | PE `aa64` / subsystem **2 (GUI)** ← **结构上就不可能弹黑窗** |
|
||||
| 包内 `uninstall.exe` | PE `014c` / subsystem 2 |
|
||||
| 随包 Node 自报 | `v22.23.3` / `arm64`(v2.0.2 的修复确实生效) |
|
||||
| 安装位置 | `C:\Users\<user>\AppData\Local\nodeagent-console`(**按用户安装**,非 Program Files) |
|
||||
|
||||
### 界面实点结果
|
||||
|
||||
| 条目 | 结果 | 判据 |
|
||||
|---|---|---|
|
||||
| 启动 | ✅ `pid=9052 / session=1`,工作集 **30~32 MB**(目标 <150 MB) | 进程表 |
|
||||
| **主题切换** | ✅ 切到「高级灰」→ 选中态翻转 + 整窗深色,且**可逆** | UIA `selected` 翻转(不是看截图猜的)+ 截图 |
|
||||
| **关窗 → 托盘** | ✅ 发 `WM_CLOSE` 后窗口从 `window.list` 消失、**进程仍存活 32 MB** | 窗口列表对比 |
|
||||
| **无黑窗** | ✅ 结构性(PE subsystem=2)+ 视觉确认 | PE 头 + 截图 |
|
||||
| 输入默认禁 | ✅ `input.mouse.move` → `E_CAPABILITY_DISABLED` | 错误名 |
|
||||
| 开机自启 | ✅ 计划任务 `cn_jkooe.nodeagent-console` **不存在**,与 UI 开关=关 一致 | `schtasks` |
|
||||
| 数据目录显示 | ✅ UI 显示 `C:\Users\<user>\.nodeagent`,与真实生效目录一致 | 配置比对 |
|
||||
| 「最近日志」 | ✅ 正确记下「端口 8765 已被占用」并给出降级提示 | 截图 |
|
||||
|
||||
### ⚠️ 本轮新发现(**未修**):控制台没有单实例保护
|
||||
|
||||
复现:关窗到托盘(进程仍在)→ **再次双击启动** → **起出第二个控制台进程**(9052 + 9232 并存)。
|
||||
|
||||
`apps/console/src-tauri/Cargo.toml` 里只有 `tauri-plugin-opener`,**没有单实例插件**。
|
||||
|
||||
后果:两份托盘图标、两套互不知情的状态机,且**都会去抢被控端的 8765**。
|
||||
|
||||
建议:引入 `tauri-plugin-single-instance`,第二次启动只 `show()` + `set_focus()` 已有窗口。
|
||||
|
||||
### 真机暴露的缺陷:200% 缩放下图形能力坐标错位(**本机已修**)
|
||||
|
||||
同一台机器上还实测到一个**只在真机暴露、单测与 CI 全绿**的缺陷:
|
||||
|
||||
被控端的 PowerShell 助手进程**默认不 DPI 感知**,于是在 200% 缩放下同一个进程里并存两套坐标系 ——
|
||||
|
||||
| 调用 | 不感知时的结果 |
|
||||
|---|---|
|
||||
| `Screen.Bounds` / `SystemInformation.VirtualScreen` | 报**逻辑**尺寸 1280×720 |
|
||||
| `CopyFromScreen` | 按**物理**像素裁剪 → `screen.capture` 缺省只截到左上 1/4 |
|
||||
| `SetCursorPos` | 被系统 ×2(喂 100,100 → 光标到 200,200) |
|
||||
| UIA(`screen.find`) | 报**物理**坐标(与进程是否感知无关) |
|
||||
|
||||
净效果:`screen.find` 给出的物理矩形**不能**直接喂给 `input.mouse.*`,
|
||||
必须人工除以 `scale` —— 上一轮就是靠这个临时绕法才点中的主题按钮。
|
||||
|
||||
修法:新增 `apps/agent/src/util/dpi.ts`,把 `SetProcessDPIAware()` 注入到**全部坐标相关脚本的最前面**
|
||||
(5 个文件),并配 8 条单测守卫。详见证见 `CHANGELOG.md` 与 `docs/VM-VERIFICATION.md` §10。
|
||||
|
||||
Loaded 100 of 159 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user