106 Commits
Author SHA1 Message Date
jkooe 79969e9d5d chore(release): v2.0.3 2026-10-13 17:36:54 +08:00
jkooe a1c51d62a0 docs(changelog): 记入本批三类改动,并统一版本叙述为 v2.0.3
- 安全加固批次:18 条实机复现缺陷(含两条 P0 的完整根因与修法、崩溃链共同根因)
- 控制台单实例保护(症状:Windows 端两个被控端 / 旧服务端在跑时控制台起不来)
- 发版脚本漏改 Cargo 清单导致的版本号分叉
- 版本叙述统一:注释里原先散落 v2.0.4,与下一版号不符,全部改为 v2.0.3

⚠️ 本批含被控端变更,发版时 `pnpm release patch`(→ v2.0.3)必须执行。
2026-10-13 17:16:51 +08:00
jkooe bce11514a9 fix(release): 发版脚本漏改 Cargo 清单导致版本号分叉
release.mjs patch --dry-run 实测:只改 8 个 package.json + 1 个 tauri.conf.json,而
apps/console/src-tauri/{Cargo.toml,Cargo.lock} 里**同样存着 2.0.2** —— 发版后必然滞留,
与 tauri.conf.json 悄悄分叉,且**不报错**(Tauri 打包以 tauri.conf.json 为准)。

- 版本写入抽成 scripts/lib/version-write.mjs(纯函数、可测),release.mjs 调用它。
  抽出来的理由:埋在脚本里时单测只能断言「源码里出现过某个字符串」—— 那种守卫注入
  故障都不会变红(铁律:守卫要落在最终产物字节上)。
- Cargo.toml **只在 [package] 段**改(非 package 段里也可能有独占一行的 version = "…");
  Cargo.lock **按 [[package]] 块 + crate 名定位**(全文盲替换会改坏 thiserror 2.0.21
  这类依赖版本)。
- release.mjs 的「脏文件白名单」改为取自**实际改写结果**(changed),不再手写文件名列表 ——
  手写的那份一旦与写入逻辑漂移,要么拦下本该放行的(发版卡死),要么放过不该放行的
  (静默推走改动)。
- 新增 tests/unit/version-write.test.mjs(9 条):在临时目录铺**真实文件**并 git init/git add
  (versionTargets 走 git ls-files,不真进索引就会假绿),跑完读回文件断言;另含一条
  **仓库版本号一致性守卫**,发版漏项会直接变红。
- 注入 4 处故障实测:编排漏 Cargo / Cargo.toml 全文盲替换 / Cargo.lock 按位置盲改 /
  仓库版本分叉 —— 全部变红,还原后全绿。
2026-10-13 17:16:46 +08:00
jkooe 530bb4fca7 fix(console): 加单实例保护,修「Windows 端起了两个被控端」
真机现象两桩:① Windows 上同时跑着两个被控端;② 旧的服务端还在跑时控制台起不来。
根因同一个:控制台没有单实例锁,再启动一次就是第二个进程 → 第二个 AgentManager
→ 抢同一个 8765 端口 → 后起的那个起不来。另有一条陈旧的 na-gui 计划任务指向测试
部署目录,也在抢同一个端口。

- 引入 tauri-plugin-single-instance(**必须注册为第一个插件**)。
- 第二次启动**不是**静默退出,而是「聚焦既有窗口 + 发 na-second-instance 事件」让前端
  刷新状态。这是刻意的:自启动实例是**无窗口**的,若第二次启动静默退出,用户双击图标
  只会看到「什么都不发生」—— 那比缺陷本身更难排查。带自启参数(AGENT_ONLY_FLAG)时跳过显示。
- 端口占用的报错补上「怎么找出占用者」:netstat -ano | findstr :<port> /
  lsof -nP -iTCP:<port> -sTCP:LISTEN,并新增一条单测。
2026-10-13 17:16:41 +08:00
jkooe ae02252a1d fix(agent): 修复 18 条实机复现缺陷(2×P0 / 5×P1 / 10×P2 / 1×P3)
一轮「静态审查 + 真机实测」的完整普查。基线 v2.0.2 / ad90634 上 unit 291 + e2e 30
全绿 —— 也就是说 18 条**全部落在既有覆盖之外**。报告见 bug-report-2026-10-13.html。

P0 两条:
- W-01:window.focus 把 JSON.stringify(title) 插进 PowerShell **双引号**串(PS 里 `\` 不是
  转义符、双引号串还会展开 $(...))→ 命令注入。真机实测最险的是它**静默**:接口返回的是
  「正常业务错误」E_EXECUTION_FAILED: 未找到匹配的窗口,而 payload 已经把文件创建出来了。
  新增 apps/agent/src/util/quote.ts 作 psLit/shLit 单一真源,14 处注入点收敛(window.focus /
  uiaFind / ocrFind / system.ts 计划任务参数 / ps-helper.ts 的 -File 裸拼 …)。
- A-01:ACL 省略 clients 时 aclPolicy.clients.find 抛错,而这段落在 if (hasAcl) 块**之外**
  无条件执行 → 配置少写一个字段,每条 invoke 都崩掉被控端。
  ⚠️ 静态审查当时判它是「fail-open 放行」,实测是崩溃(更严重)。

崩溃链共同根因:ws.on('message') 用 void handleMessage() 且无 .catch,全仓又没有任何
process.on('unhandledRejection') —— Node ≥22 遇未处理 rejection 直接终止进程。两条畸形报文
(hello {protocol:123} / auth {hmac:1})就能在**认证之前**打死被控端(verifyNonce 有 try/catch,
psk 默认路径的 verifyHmac 没有)。index.ts 新增进程级兜底:日志每次打、审计按 10s 限流
(否则畸形报文可高频刷爆 audit.log),且明确不退出进程(可用性优先,取舍写在注释里)。

其余 15 条:
- clients: [] → fail-open 全放行(判据改为「对象是否存在」而非「数组长度」)
- macOS screen.find 的 text 拼 bash → $(...) 实测执行(走 shLit)
- 控制台审计筛选项与协议 enum 漂移,除 invoke 外全 E_PARAM_INVALID
  → 新增 packages/protocol/src/audit-types.ts 作唯一真源,manifest enum 由它派生 + 编译期双向守卫
- monitor.start 的 target 注入、monitor id 路径穿越(读/删)→ psLit + guardPath
- keys{} + client_id:"constructor" 崩 → hasOwnProperty(原型链取值)
- 事件订阅跨控制端可见/可删、event.watch 绕开 fs_roots → 有状态资源落 owner + 统一白名单
- screen.capture 的 region 无上界 → assertRegionSane() 且**提前到枚举显示器之前**(fail fast)
- ipMatchesCidr('8.8.8.8','10.0.0.0/') → true(空掩码被 Number('') === 0 当 /0)
- CLI --timeout 静默失效(strict:false 下未声明选项被当 boolean,值掉进 positionals)
- 拒绝类事件不写审计 → 补 request.rejected / agent.error

回归:unit 321/321(+21)、e2e 33/33(+3 组硬化用例)、rust 11/11。
e2e 新增用例的姿势:每场景**独占端口 + 独立临时 NODEAGENT_HOME**(首版复用端口,
连上了上一块的残留实例,把对照组误判成「实现有缺陷」)。
新增守卫均按铁律注入故障实测:确认会变红、还原后复核一致。
2026-10-13 17:16:36 +08:00
jkooe ad90634a18 fix(test): 兜底守卫的片段排除在 Windows 上失效(跨平台路径分隔符)
CI(Windows E2E,run 38166982975)在「单元测试」步骤红:

  not ok 102 - 兜底:capabilities 下命中坐标信号的新文件必须引用 PS_DPI_AWARE
    - apps\agent\src\capabilities\win-ps\prelude-image.ts(命中 "CopyFromScreen")

而本机 macOS 全绿 —— 又是一例「本地全绿 ≠ Windows 绿」。

根因:`rel = relative(ROOT, full)` 在 Windows 返回 `apps\agent\…`,而排除规则写的是
`rel.includes('capabilities/win-ps/')`(**硬编码 "/")→ 在 Windows 上恒为 false
→ 片段文件被误报成违规。排除本身是**对的**(prelude-* / body-* 由 WIN_HELPER_PRELUDE
在最前面统一注入 DPI 感知,其序位已由第 5 条断言),错的只是判断依赖了平台分隔符。

修法:
- 新增 `toPosix()`:把任意平台路径归一为 posix 形态(附注释说明为何必须显式归一);
- 把「是否排除」收敛成纯函数 `isWinPsFragment(relPath, name)`,**函数内自带归一化** ——
  调用方传什么形态都正确,且可在本地直接断言;
- `walk` 内改用归一化后的 `rel` 并调用该函数。

新增守卫(第 9 条):直接喂 Windows 反斜杠路径断言排除逻辑,把这类差异钉进
「本地可验」的范围。已用 `path.win32.relative()` 在本地**真实复现** CI 场景
(修复前 false → 误报;修复后 true → 正确排除),并注入故障实测该守卫会红。

测试:dpi 守卫 9/9;pnpm test:unit 291/291。
2026-10-12 03:17:44 +08:00
jkooe 02887e265b fix(agent): 图形能力在高 DPI(200% 缩放)下坐标错位
真机(Parallels 27 + Win11 ARM64,主机 200% 缩放、物理 3600×2020)暴露:
被控端 PowerShell 助手进程**默认 DPI 不感知**,同一进程里并存两套坐标系 ——

| 调用 | 不感知时 |
|---|---|
| Screen.Bounds / VirtualScreen | 逻辑 1800×1010 |
| CopyFromScreen | 按物理裁剪 → 缺省只截到屏幕左上 1/4 |
| SetCursorPos | 被系统 ×2 |
| UIA(screen.find) | 物理(与进程是否感知无关) |

净效果:screen.find 给出的物理矩形**不能**直接喂 input.mouse.*,必须人工除以 scale
(上一轮靠这个临时绕法才点中主题按钮 —— 能绕,但属用户侧的隐式约定,必须消灭)。

- 新增 apps/agent/src/util/dpi.ts:PS_DPI_AWARE(SetProcessDPIAware 前置段)。
- 注入全部坐标相关脚本:screen.ts / input.ts / window.ts(WIN_HELPER_PRELUDE,覆盖
  window.list 与 window.focus)/ record.ts;并把它从 win-ps/prelude-ocr.ts 的 try{} 里
  移出 —— 放在 try 内一旦抛错就被 catch 吞掉,助手会静默退回不感知。
- screen.ts 显式改用 $d.Bounds.Width(已是物理),scale 仅作参考。
- 新增守卫 tests/unit/dpi-awareness.test.mjs(8 项):断言**实际下发的脚本文本**,
  而非「源码里出现过常量」—— 首版按源码扫描,注入故障实测不会变红;改为断言
  buildInputPsScript / buildListDisplaysScript / buildCaptureScript / WIN_HELPER_PRELUDE
  的脚本串后,二次注入故障精准变红。

真机终验(零换算):
- screen.info 3600×2020,与裸 GetSystemMetrics 一致;
- input.mouse.move(x,y) 读回物理光标 == (x,y)(4 处抽样全中);
- screen.find 物理矩形中心**直接** click → 主题单选 UIA selected 正确翻转且可逆。

文档:VM-VERIFICATION.md 新增 §10(坐标口径缺陷本体 / 复验配方 / 输入注入失效的逐条
排除与判决证据 / 两条 prlctl 通道的语义差异);console-plan.md 补 ARM64 真机安装冒烟与
界面实点记录(含新发现 P2:控制台无单实例保护)。

⚠️ CHANGELOG 备注:本批**属被控端变更**,与上一节「本轮不升版本号」的结论不同 ——
发版必须升 PATCH(→ 2.0.3),否则 agent.mjs 会谎报一个并不包含本修复的版本。

测试:dpi 守卫 8/8;pnpm test:unit 290/290。
2026-10-12 03:11:34 +08:00
jkooe c20e71bddc docs: 补齐 Windows 端控制台文档(含三次 run 的失败-修复-实证全过程)
README / CHANGELOG / docs/console-plan.md 三处同步:

- README:补 Windows 双架构安装包实测体积、第三条断言(解开安装包查内容)、
  以及实测解出的 node.exe 体积与 PE Machine。
- CHANGELOG:新增「Windows 端控制台安装包」整节,含双架构理由、
  为什么用原生 ARM runner、cfg!(windows) 分支首次被编译器看到、
  以及三次 run 的完整过程(体积断言写错 → `&` 调 GUI 程序不等待 → 双绿)。
- docs/console-plan.md:断言表 2 条 → 3 条;新增三节实测记录;
  产物形态表补实测体积;8.2 指标验收的「Win 安装包 < 40MB(待实测)」补上实测值。

另修复:`## 界面观感约定(2026-10-11)` 标题被上一轮编辑误吞
(正文直接从「两轮纯界面调整」开始),已恢复。

留下两条可迁移教训:
1. 体积阈值属于「中间逻辑自洽」式断言 —— 判据要落在产物内容上;
   且压缩容器与未压缩目录的体积不可直接比。
2. `msiexec.exe` / NSIS 安装包是 GUI 子系统程序,PowerShell `&` 调用
   既不等待也不设 $LASTEXITCODE → 必须 Start-Process -Wait -PassThru。
2026-10-12 00:20:50 +08:00
jkooe e616e80819 fix(ci): 取安装器退出码改用 Start-Process -Wait,& 调 GUI 程序不等待也不设退出码
第二个 run(38152850819)两 job 仍同点红,但已是另一处原因,且同样是
「静默失效」:日志里 `msiexec /a 失败(退出码 )` —— $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」,避免竞态误报。
- 打印安装器实际退出码,便于下次一眼看出是哪一类失败。
2026-10-12 00:05:01 +08:00
jkooe 71f2cd2597 fix(ci): 控制台 Windows 断言改为解开安装包查内容,不再用体积阈值
首个 run(38151366534)两个 job 双双失败,且失败点完全一致:
第 14 步「断言安装包存在且体积合理」。构建链路本身全绿 ——
tauri build(msi 34.2MB / nsis 19.5~23.2MB)、产物自举
(打包内 node v22.23.3 真跑起打包内 sidecar)都成功,
是守卫自己错了。

错在哪:断言写成「安装包体积 > 50MB」,理由是「含 Node 运行时约 80MB」。
这把**未压缩的目录形态**(.app ≈ 92MB)当成了**压缩安装包的体积** ——
LZMA 下 node.exe 自身就能压到 1/3~1/4,阈值必然误杀。
更根本的是:体积阈值属于「中间逻辑自洽」式断言,包大不等于
node.exe 真在里面,且压缩率会随版本漂。

改为解开安装包、查内容(msi 走 msiexec /a 管理安装,nsis 走
/S 静默安装),一次钉死三件事:
  ① node.exe / sidecar.mjs / agent.mjs 三样随包资源必须都在;
  ② node.exe 的 PE Machine 必须等于目标架构(最终产物的架构断言,
     对治「arm64 包里装的是 x64 node.exe」那类只在真机暴露的事故);
  ③ node.exe 解压后 > 40MB,挡住占位文件。
附带收益:真装一遍本身就是安装器冒烟。

另加 timeout-minutes: 8,避免静默安装万一卡住把 job 拖满 60 分钟。
2026-10-11 23:48:29 +08:00
jkooe 1471708f35 ci: 新增 Windows 控制台安装包构建(x64 + arm64 双架构)
在此之前,控制台的 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 必须各出一套。

为什么用原生 ARM runner 而不是交叉编译
------------------------------------
`windows-11-arm` 是 GitHub 的 ARM64 Windows runner(公共仓库免费,本仓库为 public),
在它上面 arm64 是**原生构建**。若改走 x64 机器交叉编译,需要 VS 的 ARM64 工具链,
且 prepare-console.mjs 的自检要**真执行** node.exe(x64 机器跑不了 arm64 二进制),
会逼着引入「跳过自检」这类削弱验证的开关 —— 宁可等原生 runner。

两条断言是必需项,不是顺手检查
----------------------------
1. **构建机架构 ⇄ 目标架构**:ARM runner 镜像里确实存在 x64 模拟的组件
   (社区实测 python 即为 AMD64)。一旦 Node 也是模拟版,process.arch 会是 x64
   → 把 x64 的 node.exe 打进 arm64 安装包 → 真机上能启动、TLS 握手不完成,而 CI 全绿。
2. **随包 node.exe 的 PE Machine**:与 pack.mjs 的双架构校验同源思路 ——
   不只看「脚本跑完了」,而是直接读最终要打进那个包的二进制头。
   中间逻辑再自洽,也可能因上游给了错架构的二进制而产出错包。

另断言安装包体积 > 50MB:含 Node 运行时(约 80MB),体积过小意味着运行时没打进去
—— 那种包能装、但一启动就找不到 node。

产物:x64 出 msi + nsis(企业批量部署 + 普通双击各一),arm64 只出 nsis
(WiX 的 arm64 MSI 支持面较窄,不值得为它引入不确定性)。
2026-10-11 23:25:16 +08:00
jkooe 456e452b30 test(console): 补两条「静默失效」守卫 —— CSS 变量闭合 + 首帧底色一致
主题改动引入了一类新的静默失效:**拼错的 CSS 变量名不报错**。
`var(--na-sunkenTypo)` 类型检查过、构建过、运行也不抛 —— 颜色只是落回继承值,
界面「看着对但就是不对」,与本项目历来吃亏的失效模式同源。故补守卫:

7. 扫 src/** 全部 var(--na-*) 使用点,与 cssVars() 的键集合比对
8. index.html 的首帧兜底色 ⇄ 调色板 bg

第 8 条是因为首帧防闪脚本是全应用**唯一允许的色值重复处**(其执行时点早于任何模块加载,
重复无法消除);既然允许重复,就必须有东西盯着它,否则改调色板忘了同步
→ 深色用户每次启动闪一帧白。

注入故障实测两条都会红:
  var(--na-sunken) → var(--na-sunkenTypo)   第 7 条红
  var(--na-sunken) → var(--na-sunkenx)      第 7 条红
  index.html #f4f4f5 → #ffffff              第 8 条红
复原后 8/8 绿,文件逐字节还原。

第 7 条顺带暴露过一次**守卫自身漏报**:正则原写 [a-z0-9-]+,遇到 `sunkenTypo`
这种含大写的拼错会在 `T` 处停下导致整个模式不匹配 —— 写错的变量名反而逃过检查。
放宽到 [^)\s,;]+ 后才真正生效。
教训:**注入故障不只验证守卫有没有,还要验证它覆盖了目标形态**。

单测 280 → 282。
2026-10-11 14:34:41 +08:00
jkooe 4e6eb32929 style(console): 配色收敛为「简约白 / 高级灰」两套中性灰阶主题
原配色是深色 + 品牌青绿(#63e2b7)+ naive-ui 默认绿主色 —— 颜色种类多。
改为两套**只用中性灰阶**的主题,侧栏底部一行切换,选择存 localStorage:

  简约白  页底 #f4f4f5 / 侧栏 #fafafa / 卡片 #ffffff
  高级灰  页底 #1d1f22 / 侧栏 #181a1d / 卡片 #24262a

几处刻意的选择:

1. 白主题页底**不是纯白**。卡片本身是白的,两者同色就只能靠 1px 分线撑层次,
   整页发「糊」;压一档浅灰后层次为「页底 < 侧栏 < 卡片」,且仍是白为主。
2. 主色位(naive-ui 的 primary)改**中性灰** —— 主按钮、开关激活态、进度条、
   勾选框全吃这一个值。原来它们是绿色/青绿,正是「配色不简单」的主因。
3. 语义色(成功/警告/错误/信息)**降饱和而非去掉**。这是运维工具,
   「运行中 / 出错」必须一眼可辨,全部灰化会导致误读。故它们只出现在徽章与
   告警这类**小面积**上,底色观感仍是「白 + 灰」。
4. **色值只定义一份**:src/theme/index.ts 的 Palette 既生成 naive-ui 的
   themeOverrides,又由 App.vue 运行期注入成 --na-* CSS 变量。
   若「TS 调色板」与「CSS 写死色值」各存一份,换主题时会**一半新一半旧、
   不报错、类型也抓不到** —— 正是本轮要消灭的失效模式。
   为此清掉散在 8 个文件里的 17 处写死色值(日志三流着色、代码块底色、
   节点选中边框、历史悬停…),复扫后自定义样式里硬编码色为 0。
5. 主题**只存 localStorage**,不落 Rust 侧 settings:那套 Settings 是给
   「仅服务模式」(无窗口、常驻托盘)也生效的偏好用的;主题是纯图形偏好,
   没有窗口时毫无意义 —— 为一件纯前端的事去动 Rust 命令面与契约守卫不划算。
6. index.html 内联一小段脚本做**首帧防闪**(否则深色用户每次启动闪一帧白)。
   此处**刻意重复 2 个底色值**,已在 index.html 与 theme/index.ts 两处注明
   「改底色时同步」—— 因为在它执行之前没有任何模块可依赖;
   除此之外全应用再无第二份色值。

验证:vue-tsc 0 错 | 契约守卫 6/6 | 单测 280/280 | .app 重打通过(92.22 MiB)|
两套主题均以真实采集数据回填渲染核对 —— 截图脚本顺带读回 --na-bg 与 body 背景,
确认主题真的切过去了,而不是「看起来像」。
2026-10-11 14:29:50 +08:00
jkooe 591e44721a style(console): 界面一律不使用图标 —— 导航改纯文字,并关闭内置插画/图标
触发原因是一次真实的渲染事故:侧栏那些 emoji 图标在非系统字体栈的渲染器里
退化成**豆腐块**(本机截图对比可见)。试过给图标指定 Apple Color Emoji、
全局字体栈加 !important、乃至 font-variant-emoji —— 分别无效或更糟
(最后一招把 ASCII 数字也拆成了 emoji 呈现)。而「哪套字体栈一定带 emoji」
跨平台并不成立,与其赌字形,不如不用。

  emoji 图标          → 路由表 meta.icon 字段整体移除,侧栏改纯文字
  n-empty 空态插画    → 16 处 :show-icon="false"(空态里的插画比图标更「图形」,
                        分布在 9 个页面,是最容易漏掉的一类)
  n-alert 类型图标    → 7 处(naive-ui 的 showIcon 默认 true,不显式关就在)
  n-popconfirm 询问图标 → 3 处
  夹在文案里的 ⚠      → TerminalView 的「高权限操作,请谨慎」保留纯文字

严重程度不靠图标也能读:n-alert 仍由 type 的底色/边框区分。

顺带清掉随包的模板残留资产:
  public/vite.svg、public/tauri.svg —— public/ 下的文件会被整体复制进产物,
  此前等于把 Vite / Tauri 的 logo 一并发出去了(且 index.html 的 favicon 正指向它);
  src/assets/vue.svg —— 无人引用;
  index.html 的 <title> 还是模板默认的「Tauri + Vue + Typescript App」。
  原生窗口标题取自 tauri.conf.json("nodeagent 控制台"),故此前不影响观感,
  但与 dev 态浏览器标签页不一致,一并改齐。

「不属界面图标、刻意未动」的三类写明在 CHANGELOG 里:
托盘图标(菜单栏没图标就点不到,关窗后应用无法寻回,属功能必需)、
被控端 CLI 自身打印的文本标记(⚠️/ℹ️/✅,是程序输出内容,非界面元素)、
控件的结构性部件(下拉箭头、折叠箭头、输入框清除钮、表格排序箭头)。

验证:vue-tsc 0 错 | 契约守卫 6/6 | 单测 280/280 | 前端产物内已无任何 svg 与 logo 字样 |
macOS .app 重打通过(92.21 MiB,前端在无 public/ 下亦正常构建)。
2026-10-11 13:50:49 +08:00
jkooe 2528f6b709 docs: 归档「控制台去命令行化」—— CHANGELOG / console-plan / README
CHANGELOG:新增 [未发布] 章节,并按「刻意不升版本号」写明理由 ——
本轮新增的全是控制台功能,被控端能力集合未变;而 bundle-agent.mjs 会把根
package.json 的 version 注入 agent.mjs(__AGENT_VERSION__),
一旦为控制台功能升号,被控端就会谎报一个它并未包含的版本。故维持 2.0.2。

console-plan:新增「本机服务托管」章节,含需求逐条落点对照表、
消灭命令行窗口的三条措施、四条实测踩出来的约束、播种防降级的规则、
「不靠单一信号判实例在跑」的交叉判定、以及仍需图形环境人工实点的清单。

README:控制台页面表 7 → 9(补「本机服务」与「服务日志」),
打包命令改为 pnpm --filter nodeagent-console bundle,
体积数字修正 —— 原文写的 .app ≈ 6.8 MiB 是 Node sidecar 方案之前的旧数,
现为 .app 90.8 MiB / .dmg 33.78 MiB,并补「无需预装 Node」「自启与图形能力
要求登录用户会话」两条注意事项。
2026-10-11 11:42:27 +08:00
jkooe ad569d4e6d ci: 控制台资源断言 2→3,并补 @nodeagent/agent 构建前置
控制台随包分发的资源由 2 份增至 3 份(sidecar.mjs / node / agent.mjs):
- rust.yml 的资源项断言同步改为 3,且不再只数个数 ——
  改为断言目标名确实包含 sidecar.mjs 与 agent.mjs、以及 node/node.exe 二者任一。
  只数个数的话,「三份但错了一份」照样过。

顺序依赖(本地验证发现不了的一类):
prepare:node 现在还要 esbuild 打包被控端,故 @nodeagent/agent 的 dist 成为新的
前置产物。本地验证时 dist 早就在,会把这层依赖完全掩盖 —— 故在 steps 里显式写全
构建步骤,并加注释说明为什么顺序不能调。

rust.yml 的 paths 触发器补 scripts/lib/bundle-agent.mjs 与 apps/agent/**:
改这两处同样会让控制台打包结果变化,不触发就等于零覆盖。

windows-e2e.yml 同步:Windows 分支现在也覆盖 agent.mjs 的准备与解析。
2026-10-11 11:42:14 +08:00
jkooe 2eb6a6f8bb feat(console): 本机被控端托管 —— 启停/重启/日志/自更新/托盘/开机自启
目标:双击应用即用,全程不碰命令行。

  要求                落点
  不再打开命令行窗口   agent.rs 的 CREATE_NO_WINDOW + 自启拉控制台而非裸 node
  启动/停止/重启       AgentManager 五态状态机 + 界面按钮 + 托盘菜单
  检查更新             check_update / download / apply_update
  日志功能             stdout/stderr 双泵 + 环形缓冲 + 实时推送 + 过滤/导出
  后台运行             托盘常驻,关窗最小化而非退出
  开机自启             macOS LaunchAgent / Windows 计划任务

新增「本机服务」页(/local,默认首页)与「服务日志」页(/logs)。
默认路由由 /nodes 改为 /local:打开应用先看到本机被控端的启停面板,
而不是先要求用户填一个连接地址。

四条实测踩出来的关键约束(均已在代码里注明原因):

1. 退出监控用轮询 try_wait,绝不把 Child 交给 wait()
   —— wait() 会取得 Child 所有权,此后 stop() 再也拿不到它去 kill。
2. 日志游标用独立单调 seq,不用位置下标
   —— 环形缓冲淘汰旧行后位置下标会漂移,前端增量拉取静默错位(不报错)。
3. start() / restart() 的接收者必须是 self: &Arc<Self>
   —— 监控任务要持自持引用。
4. macOS gui/<uid> 的 uid 运行时 id -u 现取,不硬编码 501。

播种(seeding)防降级:只有「随包版本 > 已安装版本」才覆盖数据目录里的脚本,
且版本比较按数值逐段(2.0.10 > 2.0.9)。若写成「同版本才跳过」,
用户自更新过被控端后下次开控制台会被静默降回旧版。

「实例是否在跑」不靠单一信号:管理器状态与 TCP 端口探测交叉判定,
以区分「本控制台托管的实例」与「手工安装的非托管实例」并给出不同处置。

消灭黑窗有三条措施,第 2 条最易漏:Rust 的 std::process::Command 默认不设
CREATE_NO_WINDOW,于是「界面上点一下启动」= 屏幕上闪一个控制台窗口。

控制台现在随包分发被控端脚本(资源 2 份 → 3 份:sidecar.mjs / node / agent.mjs),
用户装完控制台本机被控端即已就绪。

新增 tests/unit/console-commands.test.mjs(6 用例)钉住三类「不报错的失效」:
invoke ⇄ generate_handler! 双向、emit ⇄ listen 双向(这类失效会让界面永不刷新且零报错)、
bundle 资源名 ⇄ Rust resolve_* 字面量、sidecar 方法名、路由表 ⇄ 实际 view 文件。
已注入故障实测会红(改命令名与事件名 → 当场 2 条失败),非空转守卫。

验证:cargo check 0 错 0 警 / cargo test --lib 10·10 通过(含真起 node 子进程)/
clippy -D warnings 通过 / vue-tsc 通过 / 单测 280·280(274 → 280)/
verify-bundle 真 spawn 打包内 node 跑通打包内 sidecar。

⚠️ 托盘、关窗最小化、开机自启、界面点按钮仍需图形环境人工实点。
2026-10-11 11:41:56 +08:00
jkooe a0be43d76c refactor(pack): 被控端 esbuild 配置抽为单一实现
`agent.mjs` 现在有两个消费方:
  1. scripts/pack.mjs            → Windows 便携包 / 自更新载荷(Release 资产)
  2. scripts/prepare-console.mjs → 桌面控制台的随包资源(控制台要能托起本机被控端)

若各写一份 esbuild 配置就是「两套真相」——改了一处漏另一处时构建过、单测过,
只在真机产物里暴露(本项目已多次吃过这类亏)。故配置只此一份。

bundle-agent.mjs 导出 repoRoot / repoVersion / gitShortCommit / bundleAgent,
沿用原有全部约束:JS API(pnpm 隔离布局下 .bin/esbuild 在 Windows 不存在)、
不注入构建时间(否则每次打包字节不同,指纹比对失真)、
define 用 JSON.stringify 得纯双引号。

本次对 pack.mjs 的输出无语义变化(同一份 options 原样迁移)。
2026-10-11 11:41:38 +08:00
jkooe 9ae1326be2 docs(readme): 版本基线更新到 v2.0.2
v2.0.0(10-10)那条基线已过期。现基线:v2.0.2(2026-10-11,资产修复 ——
v2.0.1 的 arm64 包内实为 x64 node),51 项能力 · 274 单测(本地实跑全绿)。
2026-10-11 10:45:48 +08:00
jkooe 1ab9ed22c7 docs(vm): 图形能力实测归档 + §9 复验配方
- §7 图形能力行:⏳ 待登录态 → ✅ **登录态下全通**(真实桌面截图 1800×1010 / window.list /
  screen / audit verify),并注明「会话 0 的失败是身份问题,非产品缺陷」
- 修正重复的 §7 编号(→ §8),复位一处错位的表格行
- §6 补三条排查项:x64 Node 的 Prism 现象、`screen.capture` 的会话身份、
  **微软账户不支持传统 autologon**(MSA 凭据走 CloudAP,`LogonUser` 恒返回 1326)
- 新增 §9「图形能力复验配方」:本地账户 autologon → 计划任务 Interactive 启动 →
  三件核对 → 证书指纹变更的处置 → 验证完的安全清理
2026-10-11 10:32:48 +08:00
jkooe 5e6231493e fix(pack): Windows 打包条目带目录前缀 —— 与 macOS 产物结构不一致
CI 在 f85acad 上红了:`✗ zip 内未找到条目 node.exe: release/nodeagent-win-x64.zip`
(而本地 macOS 全绿 —— 方向恰好相反)。根因是两平台用了不同打包器:

- macOS/Linux: `cd <dir> && zip -r <out> .`       → 条目是裸名 `node.exe`
- Windows    : `Compress-Archive -Path '<dir>\*'` → 条目带前缀 `<dir>/node.exe`

两处一起修:

1. `scripts/lib/zip-pe-arch.mjs`:条目名匹配改用 **basename**,兼容两种打包方式。
   已用构造的带前缀 zip 实测通过,并注入错架构验证守卫仍会红。
2. `scripts/pack.mjs`:Windows 分支改用 .NET `ZipFile::CreateFromDirectory`
   —— 它打包**目录内容**,条目同样是裸名,从根上消除两平台产物结构差异。
   否则 CI 校验的产物与发布产物**不同构**,校验本身的价值打折;
   且「解压后双击 install.cmd」的说明会因多一层目录而失配。

教训:**「本地全绿」≠「Windows 绿」** —— 平台差异只有真 runner 能暴露(第 N 次实证)。
2026-10-11 10:32:41 +08:00
jkooe f85acad5db chore(release): v2.0.2 2026-10-11 10:18:28 +08:00
jkooe f558e3e7df chore(release): 交付物架构断言 + CHANGELOG 归置 v2.0.2
已公开发布的 nodeagent-win-arm64.zip 内装的其实是 x64 的 node.exe
(两包内 node.exe 字节完全相同,PE Machine 均为 0x8664),该包在 ARM Windows 上
能启动、能 listen,却 TLS 握手不完成 —— 被控端实际不可用,而 macOS 侧 100% 复现不出。

- 新增 scripts/lib/zip-pe-arch.mjs:纯内置 zlib 手解 zip、读 PE 头判架构。
  不依赖 unzip / Expand-Archive(Windows runner 上没有 unzip)。
  含 CLI 自检入口,已注入故障实测会红(arm64 包去要 x64 期望 → 退出码 1)。
- release.mjs:生成资产前调用之,不符即中止发版 —— 拦在 tag/push 之前,
  是「交付物本身」的断言,与 pack.mjs 的「流程内部」断言分两层。
- windows-e2e.yml:把原先内联的 PowerShell 校验实现改为复用同一模块,
  消除两套实现各自漂移的可能。
- CHANGELOG:[未发布] 归入 v2.0.2。
2026-10-11 10:17:30 +08:00
jkooe 87b74fd015 docs(vm): B5 按实测改写 —— arm64 包原生可用 / win-x64 包 TLS 握手不通
原 B5 的假设是「x64 包在 ARM 上也许能跑」,本轮实测证伪:能启动、能 listen,
但 TLS 握手不完成(E_TIMEOUT),被控端实际不可用。补对照数据与复验步骤;
同步更新检测表、前置准备(③④ 改用 arm64 包)、结论标注规则与新增 §7 实测记录。
2026-10-11 09:48:10 +08:00
jkooe b2404291b0 fix(pack): arm64 包内实为 x64 node —— 缓存名不含架构 + 补 PE 架构断言
release.mjs 先打 x64 时会把运行时缓存为 node-runtime.zip(该名不含架构),
随后打 arm64 时误判「已缓存」直接复用 → arm64 包内 node.exe 实为 x86-64
(两份公开包内 node.exe 字节完全相同,sha256 同为 fdddbf45…,PE Machine=0x8664)。
该包在 ARM Windows 上能启动、能 listen,但 TLS 握手不完成(控制端只见 E_TIMEOUT),
且 macOS 侧完全复现不出。

- pack.mjs: 缓存名改为 node-runtime-<arch>.zip(按架构隔离)
- pack.mjs: 解包后断言 node.exe 的 PE Machine 与 --arch 一致(防呆 C,已注入故障实测会红)
- ci: windows-e2e 补「双架构包 PE 架构校验」,直读最终 zip 内 node.exe 的 PE 头
      (此前 CI 只打 x64 且只验 x64,arm64 打包零覆盖 —— 该 bug 必然逃过 CI)
- docs: CHANGELOG 记录 ARM64 真机对照实测

真机实测(ARM64 Windows 虚拟机,同机同参对照):
  arm64 → TLS 握手成功 + PSK + 51 项能力两端一致 + exec/审计全通
  x64   → E_TIMEOUT(复现「能 listen 但 TLS 握手不完成」)
2026-10-11 09:42:32 +08:00
jkooe e816c1fd39 fix(ci): Windows 上补 sidecar:build —— 新加的步骤漏了前置产物
本轮新增的「控制台 Node 运行时(Windows 分支)与自举验证」一步在真实 runner 上失败:
  ✗ 未找到 D:\a\nodeagent\nodeagent\apps\console\sidecar\dist\sidecar.mjs
根因:prepare-console.mjs 的前置检查要求 sidecar 已构建,而该步被排在了构建 sidecar 之前
(rust.yml 里恰好已有「构建 sidecar」在前,所以那边没暴露)。

教训(本地跑过 ≠ CI 跑得过,又一例):本地验证时 sidecar/dist 早已存在,
把「步骤之间的产物依赖」这一层完全掩盖了。**新增 CI 步骤时,应逐条核对
「该步依赖的产物由哪一步生成、顺序是否确实在其后」,而不是在已有产物的本地环境里跑一遍就认为通过。**
2026-10-11 09:00:09 +08:00
jkooe a6be917637 docs: 记录控制台 Node 运行时分发与体积口径调整
- console-plan.md:
  · §8.2 打包体积指标口径由「Mac .app < 30MB」改为「**分发件** Mac .dmg < 40MB」
    —— 原数字是 Tauri + Rust client 时代定的(当时应用内不含 JS 运行时);
    改 sidecar 方案后应用必须自带 Node 运行时,.app 因此到 90.8MB,但用户下载的 dmg 仅 33.8MB。
    已明确标注「口径调整,待确认」。附本机 Electron 运行时本体 207MB 的实测对照。
  · 新增「Node 运行时分发」节:做法、三处实测约束(编译期契约 / strip 须重签 / 映射须对齐
    resolve_node)、验证方式(verify-bundle 真跑产物)、实测体积。
  · 「已知待办」由两条收敛为一条(剩 GUI 端到端人工验证,需图形环境)。
- CHANGELOG.md:新增 [未发布] 节,记录运行时分发功能与两处规避(SIGKILL 137、编译期契约)。
2026-10-11 08:55:45 +08:00
jkooe 8664ec2926 ci: 覆盖 Node 运行时准备与「产物自举」(Linux + Windows 双覆盖)
新增 runtime 准备脚本后,两件事此前零覆盖:脚本本身在 Windows 上的分支、
以及「打包产物能不能真跑」。补上:

- rust.yml:prepare:node 真跑(验证脚本可用 + 产物自检)→ 断言注入片段声明两份资源
  → `verify-bundle --runtime` 真 spawn 运行时跑 sidecar 走一次 JSON-RPC。
  paths 触发加上 scripts/prepare-console.mjs。
  注:Linux 上不走 strip 分支(macOS 专属),故「strip 必须 adhoc 重签」由 macOS 本机实测覆盖。
- windows-e2e.yml:新增一步跑 prepare:node + verify-bundle --runtime —— 该脚本在 Windows
  上走的是「复制 node.exe、无 strip」的另一条分支,此前从未被真实 runner 验证过。
2026-10-11 08:55:38 +08:00
jkooe 22bc732334 feat(console): 控制台随包分发 Node 运行时(目标机器无需预装 Node)
控制台的 sidecar 是 .mjs,此前只能在装了 Node 的机器上跑(resolve_node 会回退系统 node)。
新增 scripts/prepare-console.mjs 把 Node 运行时准备为 Tauri resource,随包分发给最终用户。

- prepare-console.mjs:复制本机 node → apps/console/src-tauri/runtime/,macOS 上 strip -x
  (108MB→86MB)**并 adhoc 重签**,再生成注入片段 tauri.bundle.gen.json;带幂等(同源跳过)
  与**产物自检**(真跑一次 node,失败即删除产物并报错,避免坏产物被静默复用)。
- apps/console/scripts/verify-bundle.mjs:验证「打包产物能否自举」——真 spawn 打包内的 node
  去跑打包内的 sidecar 并走一次真实 JSON-RPC 握手。
- package.json:新增 prepare:node / verify:bundle,bundle = sidecar:build → prepare:node →
  tauri build --config → verify:bundle(一条命令产出可分发件并自证可用)。
- src-tauri/.gitignore:忽略 /runtime/(~86MB)与生成的注入片段(生成物不入库)。

三处实测踩出来的约束(均已写进脚本注释与 console-plan.md):

1. **macOS 上 strip 会使 Mach-O 代码签名失效 → 内核直接 SIGKILL(exit 137)**,且无任何有用报错。
   实测对照:原样复制 ✓ / strip 不重签 ✗(137) / strip + `codesign --force --sign -` ✓。
   故 strip 之后的 adhoc 重签是**必要配套**,不是可选优化。
2. **Node 运行时不得声明进默认 tauri.conf.json** —— bundle.resources 是编译期契约
   (tauri-build 校验声明的文件必须存在),声明「运行期才就绪」的资源会让 cargo check/test
   在 build.rs 阶段直接失败(2026-10-10 已踩)。故一律走 `tauri build --config` 注入。
3. **resource 映射必须与 resolve_node 对齐** —— `runtime/node → node` 使产物落在
   Contents/Resources/node,正是 src-tauri/src/sidecar.rs 里 resolve_node 查找的路径。

实测(macOS arm64):.app **90.8 MiB**(原 6.75 MiB,增量即运行时 86MB)/
.dmg **33.78 MiB**(分发件,仍低于 40MB 目标);应用进程实测可启动且无崩溃输出。
对照:本机实测 Electron 运行时本体(Electron Framework.framework)207MB —— 不到一半。
2026-10-11 08:55:31 +08:00
jkooe 4f4d8913b5 chore(release): v2.0.1 2026-10-11 08:02:19 +08:00
jkooe f4ae20969a chore(release): release.mjs 支持双架构(同时出 x64 + arm64 包)+ CHANGELOG v2.0.1
## release.mjs
- 打包阶段改为循环 `pack.mjs --arch x64|arm64`(原先只出 x64)
- 资产新增 `nodeagent-win-arm64.zip`;`latest.json` 新增 `zip_arm64` 字段
  (**只增不减**,符合 VERSIONING 的兼容铁律;旧控制端忽略即可)
- RELEASE_NOTES 提示按架构选包(x64 包在 ARM Windows 上 TLS 握手不通)
- 顺手修一处改名遗漏:`zipPath` → `zipX64` 后,产物存在性检查仍在用旧名(会 ReferenceError)

## CHANGELOG
补 v2.0.1 段:3 个修复(control.ps1 解析期错误 / 连接保活缺 pong / v26 清理阈值过粗)
+ 2 项新增(`pack --arch`、`verify-b2.mjs`)+ 新增文档 `VM-VERIFICATION.md` + 真机验证状态表
2026-10-11 08:00:52 +08:00
jkooe 7d13a48caa docs(changelog): v2.0.0 的「已知限制」更新为「验证状态已补齐」(2026-10-11 VM 真机验证) 2026-10-11 07:53:42 +08:00
jkooe 40b7db16c1 docs(roadmap): §五/§九 的 7 处历史「未做」标注实际状态(避免误导为待办)
这些行是当时的计划快照,内容其实都已实现(screen.find 属性化、条件组合、方向二/三、ed25519 默认化
以及 v1.5 真机验证)。不改写历史结论,只更新事实以免读者误判为未完成。
2026-10-11 07:53:10 +08:00
jkooe 765ad08ee6 docs(roadmap): §九 台账 —— v1.5/v26 真机验证标记完成(VM Win11 ARM) 2026-10-11 01:13:36 +08:00
jkooe fbcb8f8408 fix(v23): 连接保活补 pong 检测(治僵尸连接占名额)+ B2 三防护真机验证通过
## 真机发现(Parallels Win11 ARM,B2 验证过程中撞到)
心跳只发 `ping()` 而**不检查 pong**,且 ws 库默认不会因「ping 无 pong」断开 ——
客户端进程已死(TCP 未发 FIN)时,连接会一直挂在 `states` 里占 `max_connections` 名额。
叠加「有任务/订阅时清扫保守跳过」的既有策略 → `states.size` 虚高 → **把正常控制端挡在外面**
(本次实测就把自己锁在外面了)。

## 改法
- `attachConnection` 里加 `ws.on('pong')`:**客户端回应也算活跃**(更新 lastActiveAt)
- 心跳里:若某连接静默超过 `idle_timeout_ms * 2` → `ws.terminate()`(标准 ws 保活)
  → 僵尸连接不会再永久占位

## B2 三场景真机验证(全部通过)
- ① **握手封禁**:配 `max_attempts=3` → 3 次错误密钥被拒后,**正确密钥也被拒** ✓
- ② **连接上限**:配 `max_connections=6` → 第 1~6 个成功、**第 7 个被拒** ✓
- ③ **空闲断开**:配 `idle_timeout_ms=20000` → 静默 **40 秒**被断开 ✓
  (符合 20s 阈值 + 30s 清扫周期)
- 新增 `scripts/verify-b2.mjs`(三模式:idle / maxconn / ban),全部走**真实连接**

## 验证过程中另修的两个自测问题(都记进文档)
1. 脚本默认 URL 写成了 `ws://` —— 被控端 `tls:true`,**必须 wss**(否则 socket hang up)
2. 测 `max_connections` 时值太小会**把自己锁在外面**;且被拒连接会被计入握手失败形成恶性循环
   → 结论:测试要留余量,并给自己配 `auth_ban.whitelist`

## 收尾
测试用的 `security` 段已用**备份恢复**(`agent.json.before-b2`);pong 修复已 `deploy` 到 VM
(指纹 abca2b8168f0)。
2026-10-11 01:13:12 +08:00
jkooe ab7a2b147b fix(v26): 自重启任务清理阈值改为「本 agent 启动时刻」(真机实测 1 小时粗阈值仍会累积)
## 真机发现(Parallels Win11 ARM)
restart 两次后仍有 **2 个** selfrestart 任务(旧的 Ready 未被清)。诊断确认:
- 清理**条件**能选中它 ✓(非匹配问题)
- **手工删除**成功 ✓(非权限问题)
→ 算时间戳才明白:那是「1 小时」粗阈值的**必然结果** —— 旧任务创建于 00:44,
  第二次 restart 在 00:56(仅隔 12 分钟),清理那一刻它**仍是 Running**(cmd 还持有上一次的 agent),
  不满足「非 Running 或早于 1 小时」→ 保留。

**但规则本身是缺陷**:频繁 restart(间隔 < 1h)时旧任务照样累积 ——
v26 想治的僵尸只是从「永不清理」变成「1 小时后才清」。

## 改法(语义更精确)
```powershell
# 旧:$cut = (Get-Date).AddHours(-1)                    ← 粗略
# 新:$cut = (Get-Date).AddMilliseconds(-<agent uptime>) ← 「本 agent 的启动时刻」
```
凡在**我启动之前**就运行的任务,必然不是我的宿主(我的宿主刚刚才创建)→ 可安全删除。

## 验证(真机闭环)
1. `nodeagent deploy release/agent.mjs` → 指纹校验 + 备份 + 上传 + 重启 + 校验 ✓
   (顺带把 deploy 机制也验了)
2. `restart` → **任务只剩 1 个**(Running,当前宿主)→ **旧任务被精确清理** ✓✓
3. 单测:断言必须是「agent 启动时刻」阈值,且**禁止回退到 AddHours(-1)**;
   守卫 9/9 + 全量 274 绿;7 包构建通过
2026-10-11 00:50:51 +08:00
jkooe 047cc9efd2 docs(vm): 补 ARM64 包的解决方案(pack.mjs --arch arm64) 2026-10-11 00:32:54 +08:00
jkooe d8f21f4e68 feat(pack): 支持 --arch(x64 / arm64)—— ARM Windows 上 x64 包不可用(真机实证)
## 背景(Parallels Win11 ARM 真机验证的结论)
x64 包(内含 x64 Node)在 ARM Windows 上:**能启动、能 listen 8765,但 TLS 握手 60s 不完成**
(Prism 模拟下协议层异常)→ 协议层完全不可用,且现象具有误导性(端口是通的)。
换同版本 ARM64 Node 后**完全正常**(握手成功、连接、51 项能力)。

## 改动
`scripts/pack.mjs` 新增 `--arch <x64|arm64>`(默认 x64,向后兼容):
- Node 下载 URL、包目录、产物名全部按架构生成
- 产出:`nodeagent-win-arm64.zip`(ARM Windows 用)/ `nodeagent-win-x64.zip`(默认)
- 加了架构校验(非法值直接报错)

## 验证
- `node --check` 语法通过
- 实跑 `node scripts/pack.mjs --arch arm64` → 产出 31.4 MB 包,
  内含 ARM64 `node.exe`(85.6 MB 解压后)+ `agent.mjs`
2026-10-11 00:30:43 +08:00
jkooe 54f7f4c7ab test(vm): Parallels Win11 ARM 真机验证 —— A 组 15/0 + v1.5 四项全过 + 锚点端到端通过
## 环境
Parallels 27 + Win11 ARM,VM 10.211.55.9(共享网络);被控端 `-NodeId win-arm -AllowInput`。

## ⭐ B5 结论(与预期不同,但更有价值)
| 组合 | 结果 |
|---|---|
| `win-x64` 包(内含 x64 Node) | ⚠️ **能启动、能 listen 8765,但 TLS 握手 60s 不完成** → 协议层不可用(Prism 下) |
| **换 ARM64 Node(同版本 v22.20.0)** | ✅ **完全正常**:握手成功、连接、51 项能力 |

× 结论:`nodeagent-win-x64.zip` 不能在 ARM Windows 上实用;ARM 上需 ARM64 Node。

## 验证结果
- **A 组(verify.mjs 17 项)**:✅ 15 通过 / 0 失败 / 1 跳过(29.5s)
- **B1 v1.5 四项**:✅ 全过 —— 字符串热键 / 预设别名(`copy→ctrl+c` 映射正确)/ 长按 /
  后台定向投递(**explorer 命中 138 窗口**、WindowsTerminal 10 窗口)
  → **v1.5.0「未经真机就发版」的债,至此还清**
- **B4 锚点端到端**:✅ collect(62 条) → 上传云盘 → verify 判定「链已增长」+ 精确明细
- B2/B3:⏸ 阻塞(需 restart,而计划任务启动失败 —— 手工跑却完全正常)

## 本轮真机抓到的 2 个真 bug(都已修)
1. `control.ps1:100` 的 `$scheme:` 被 PowerShell 当 drive 引用 → **解析期错误 → 整个脚本不可用**
   (stop/start/status 全废);已修 + 加守卫(扫全部 .ps1 的 `$ident:`)
2. `anchor-audit.mjs` 调用**不存在的** `audit head` 子命令 → 改为 `invoke system.audit.head`
   (这种「能力存在但 CLI 无子命令」的错配,不真跑一次发现不了)

## 文档
`docs/VM-VERIFICATION.md` 补「实际验证结果」节 + 5 条新增排查项(安装/密钥/CLI 语法/计划任务)
2026-10-11 00:18:43 +08:00
jkooe c8aba83589 fix(control): control.ps1 的 $scheme: 被当 drive 引用 → 整个脚本不可用(真机踩到)
## 现象(Parallels Win11 ARM 真机)
`control.ps1 -Action stop/start/status` 一律报:
  所在位置 control.ps1:100 字符: 36
  +  Write-Host "  协议     : $scheme://0.0.0.0:$Port"
  变量引用无效。':' 后面的变量名称字符无效。

## 根因
PowerShell 把 `$scheme:` 解析为 **drive 引用**(如 `env:`),而 `scheme` 不是 drive → 语法错误。
且这是**解析期**错误 → **整个脚本无法运行**(不是只有那一行),stop/start/status 三个子命令全废。
修法:写成 `${scheme}://`(用花括号界定变量名)。

## 改动
- `scripts/control.ps1:100`:`$scheme://` → `${scheme}://`,并加注释说明为什么必须用花括号
  (注释里刻意**不写**危险字面量 —— 否则会被新守卫命中,这点在验证时真踩到了)
- 新增守卫(`tests/unit/security-hardening.test.mjs`):扫全部 `.ps1/.psm1`,
  禁止 `$ident:` 形式(白名单 `env`/`nuget`/`psitem`/`PSScriptRoot`)

## 验证
- 守卫 **9/9 绿**;全量单测 **274 绿**;ps1-encoding 保持通过(BOM 未被破坏)
- **注入故障实测**:把 `${scheme}` 回退成 `$scheme:` → 守卫**立即红**(失败数 1)→ 恢复后绿
  (按项目纪律:没验证过会红的守卫等于没有)
- 全仓重扫同类模式:**仅此 1 处**
2026-10-11 00:05:42 +08:00
jkooe 3b05f5b373 fix(docs): VM 标注插错位置 —— 移到 §九 台账的正确条目之后
上一提交的锚点匹配过宽(匹配到版本台账表里的「ci: 补全 Windows 真机验证」行),
导致标注插在了错误位置。现改为精确锚点「清理策略**真机验证**」,并复核只出现一次。
2026-10-10 23:39:48 +08:00
jkooe 7eb773099f docs(roadmap): 补上「VM 可补验证」标注(上一提交因文本未匹配而未生效) 2026-10-10 23:38:17 +08:00
jkooe ba57d99806 docs: 新增 VM 验证清单(Parallels 27 + Win11 ARM)—— 真机不可用时的替代路径
## 背景
Windows 被控端离线导致 4 项验证卡住(v1.5 四项 / v23 三防护 / v26 清理 / 锚点端到端)。
Parallels Desktop 27 可跑 **Windows 11 ARM** 虚拟机(Apple silicon 只支持 ARM 版 Windows,
x86_64 仅 Pro/Business 的早期预览),可提前跑掉**架构无关**的验证项。

## 清单内容(docs/VM-VERIFICATION.md)
- **能补 / 不能补的矩阵**:协议层与输入层可验;**杀软交互、显示/DPI、性能指标不可替代**
- 前置准备:桥接网络(否则 UDP 广播发现测不了)、优先 ARM64 Node、run-portable/install 二选一
- **逐条命令**(均已对着源码核实参数):
  - A 基础验收:直接复用既有 `scripts/verify.mjs`(17 项 + Markdown 报告)
  - B1 v1.5 四项:`key press "ctrl+shift+esc"` / `copy` / `--hold 800` / `--route post --pid N`
    (对应 args 的 hotkey / preset / hold_ms / route+target_pid 四个分支)
  - B2 v23 三防护:`agent.json` 的 `security.auth_ban` 与 `idle_timeout_ms` + 三场景判定
  - B3 v26 清理:`restart` 后 `schtasks /query`,**重点看"当前宿主任务必须仍在"**(否则即误杀)
  - B4 锚点端到端:`anchor-audit.mjs collect` → 云盘 → `verify`
  - B5 额外结论:**win-x64 包在 ARM Windows 上能否跑**(走 Prism;跑通则写进 README)
- **结论标注规则(强制)**:VM 验过的写「VM 已验」;杀软/显示/性能**不得**因 VM 通过就标已验;
  并注明「VM 与真机架构不同(真机 x86_64),VM 通过不自动推出真机通过」
- **已知 VM 环境差异表**(排查先看):广播发现需桥接、坐标为逻辑验证、性能数字不可采信等

README 文档区与 ROADMAP §九 均已链入。
2026-10-10 23:38:02 +08:00
jkooe 28ce95f045 feat(audit): 审计锚点外部化落地 —— 判定逻辑收敛为 protocol 纯函数 + 控制端工具 + 云盘链路
## 为什么(一句话)
锚点的安全性完全来自「它在攻击者碰不到的地方」。与被审计日志同机的锚点等于没锚:
被控端一旦被 root,重写日志后顺手改掉锚点即可。

## 本次改动

### ① 判定逻辑收敛(消除「两份真相」)
把 `compareWithAnchors` 的三条判定抽到 **`packages/protocol/src/audit-anchor.ts`** 的
**纯函数 `judgeAnchors(head, anchors)`**(无 IO,不知路径):
- agent 端 `compareWithAnchors` 改为「读文件 + 调 judgeAnchors」
- 控制端脚本同样调用它
→ 同一份日志不可能出现「被控端说一致、控制端说被改」的分歧
- 附带 `parseAnchorsFromJsonl`(坏行跳过,一行坏数据不毁整份锚点)
- agent 侧类型改为 re-export,既有导入路径不变(**9 个原有锚点单测全绿,行为零变化**)

### ② 控制端工具 `scripts/anchor-audit.mjs`
- `collect`:拉链头 → 生成一行锚点 JSONL(并打印 `ANCHOR_JSON=` 便于 automation 取用)
- `verify`:读锚点文件(可从云盘下载)+ 拉链头 → 判定,`ok:false` 时**退出码 2** + 醒目告警
- 走 CLI 而非自己实现连接(复用其多设备配置/证书钉住/daemon,避免又一份实现)
- 支持 `--head-file`:**离线也能验证判定链路**(本轮即用它做了 fixture 验证)

### ③ 云盘链路
- 建 `…/Project/NodeAgent/anchors/`(与全量备份分开目录)
- 每日 automation 更新为「拉链头 → 上传云盘(一天一个带日期的文件)→ 简报」,
  并规定每月首日额外做一次「下载历史锚点 → 链外比对」

## 验证
- **纯函数重构零行为变化**:原有 audit-anchor 单测 9/9 全绿;全仓 273 单测全绿;7 包构建
- **工具离线验证**(fixture):一致 → 「与锚点完全一致」退出码 0;
  篡改 → 「链头哈希与锚点不一致」退出码 2;三个错误分支均给出明确提示
- 云盘:`anchors/` 目录创建成功(1/1 步),目录内容已复核
- ⚠️ 端到端(真拉链头 + 真上传)**待被控端上线**;脚本的 CLI 分支已做错误处理验证
2026-10-10 19:14:09 +08:00
jkooe 2fe717cb62 fix(ci): 修 clippy 的两个错误(含一个真实并发问题)+ 本地补齐 lint 验证
## CI 失败的真实原因(上一轮我误判为 resources,实为 clippy)
`-D warnings` 下两个错误:
1. **`clippy::await_holding_lock`(sidecar.rs,真问题)**:
   `if let Some(child) = self.child.lock().unwrap().take() { child.kill().await }`
   —— MutexGuard 存活到 if 块结束,**跨 await 持有 std Mutex**:不仅触发 lint,
   真会阻塞 executor。已改为「先取出 child(锁随即释放),再 await kill」。
2. `clippy::doc_lazy_continuation`(lib.rs 文档注释):列表与后续段落之间缺空行。

## 我的验证方式错在哪(已修正)
上一轮我本地只跑 `cargo test`,**没跑 clippy** —— 而 CI 是 `clippy -D warnings` + `test`
两道闸。本地与 CI **标准不一致**,于是"本地绿、CI 红"。
本轮起:**本地按 CI 的完整命令集验证**(clippy --all-targets -- -D warnings → cargo test)。

## 验证
- `cargo clippy --all-targets -- -D warnings` → **零警告**
- `cargo test` → **3/3 通过**
2026-10-10 15:33:11 +08:00
jkooe 9bba53d5c0 fix(ci): 修 Rust Shell 工作流 —— resources 只声明构建期必产出的文件
CI 失败根因(本地已复现确认):`tauri-build`(build.rs) 会在**编译期校验**
`tauri.conf.json` 的 `bundle.resources` 声明文件是否存在。
我之前声明了两项:`sidecar.mjs`(CI 未生成)+ `resources/node`(Node 运行时未下载)
→ build.rs 直接失败("failed to run custom build command")。

修复:
- `tauri.conf.json` 的 resources **只留 sidecar.mjs**,并写明为何不能声明未就绪的文件
- `rust.yml` 在 clippy/test **之前**加「构建 sidecar」步骤(`pnpm --filter nodeagent-console sidecar:build`)
- console-plan.md:⑤ 不再把 node 记为已完成(未验证的不写成做完),
  补「已知待办」(Node 运行时随包分发 + GUI 端到端人工验证)与「构建约束」说明

本地复现验证:删掉 `sidecar/dist/` → `cargo check` 复现同一错误;恢复 → 通过 ✓
2026-10-10 15:25:12 +08:00
jkooe 53bf5a0634 feat(console)!: Rust 壳改用 Node sidecar(选项 D 完成)—— 退役 crates/nodeagent-client
## 动机(消除「两套协议实现」)
原设计让 Rust 壳自己实现 client,与 TS 版(@nodeagent/client)并存 → 改一处漏一处时
构建过、单测过,只在真机暴露。现 Rust 壳只做**进程管理 + 消息转发**,协议实现只剩 TS 一份。

## ④ Rust 壳改造
- 新增 `src-tauri/src/sidecar.rs`(~290 行):Sidecar 管理器 —— spawn node、
  行分隔 JSON-RPC 的请求/响应匹配(oneshot + 超时兜底)、通知回调(state/log/agent_event)、
  `resolve_node`/`resolve_sidecar` 路径解析(env → Tauri resource → 开发期系统 node)
- 重写 `lib.rs` 为薄转发层:`ensure_sidecar` 懒启动;**5 个 command 签名与 3 个 event 名
  (na-state/na-log/na-event)一律不变 → 前端零改动**;`get_capabilities`/`get_state`
  读本地缓存以**保持同步签名**;`RunEvent::Exit` 时优雅 shutdown(kill_on_drop 兜底不留孤儿)
- `Cargo.toml`:移除 nodeagent-client 依赖、加 tokio;**版本 0.1.0 → 2.0.0**
  (原先与 tauri.conf.json 的 2.0.0 不一致,属版本号统一范围)

## ⑤ 打包配置
- `tauri.conf.json`:`bundle.resources` 声明 sidecar.mjs 与 node;
  `beforeBuildCommand` 串上 `sidecar:build`(否则 resource 缺失)

## ⑥ 退役
- 删除整个 `crates/`(12 文件,含 nodeagent-client 与其 probe 示例)
- `rust.yml` 由「Rust client 单测」改为「**Rust 壳单测(真跑 sidecar)**」:
  装 Tauri 系统依赖 + Node + pnpm,跑 clippy `-D warnings` 与 `cargo test`
- `package.json`:`test:rust` 改指 console 壳;移除 `probe`
  (其「对真机逐项核对字段」的用途已由 CLI `nodeagent invoke` 覆盖)
- 引用清理:README(数字与协议客户端说明)、console 前端两处注释、
  console-plan(开头加「原方案已被取代」提示 + 补改造详情)、ROADMAP 历史描述加退役批注

## 验证
- **`cargo test` 3/3 通过**(真跑 Node sidecar 子进程:spawn/请求-响应/错误不崩)——
  这是关键:sidecar 管理器**不依赖 Tauri GUI**,故核心逻辑可在 CI 真实验证,不必等人工开窗口
- `cargo check` 零警告;clippy 标准与 CI 一致
- 273 单测 / 7 包构建 全绿
- ⚠️ **仍需人工验证**:Tauri GUI 端到端(开窗口 → 连被控端 → 点界面),沙箱跑不了图形应用
2026-10-10 15:16:24 +08:00
jkooe 096193a8ec feat(console): 采纳 sidecar 方案(选项 D 第一步)—— Node sidecar 实现 + 8/8 真机 e2e
## 为什么
原设计让 Rust 壳自己实现一份 client(crates/nodeagent-client),代价是**两套协议实现静默漂移**
(改一处漏一处时构建/单测都不报错,只在真机暴露)。sidecar 让 Rust 壳退化为
「进程管理 + 消息转发」,协议实现只剩 TS 一份,从根上消除漂移。

## 本次交付
1. `apps/console/sidecar/main.mjs`(190 行):把 `@nodeagent/client` 暴露成
   **JSON-RPC 2.0 over stdio**(行分隔)。方法 connect/disconnect/invoke/state;
   onStateChange/onLog/onEvent 三个回调 → `event` 通知(kind=state/log/agent_event)
2. `apps/console/sidecar/build.mjs`:esbuild **JS API** 打成单文件(避开 pnpm `.bin` 的
   ENOENT 坑),并注入 `createRequire` shim —— 否则 `ws` 的动态 require 会报
   "Dynamic require of events is not supported"
3. `apps/console/package.json`:加 `@nodeagent/client`/`protocol` 依赖 + sidecar:dev/build 脚本
4. `docs/console-plan.md`:补「实施决策」节(决策理由、协议定义、进度表、打包方向)

## 两条协议纪律(写进注释与文档)
- **stdout 只写协议消息**,日志一律 stderr(混入非 JSON 行会让 Rust 侧解析失败)
- 任何异常都转成响应,**绝不因单个请求崩进程**

## 错误形态归一(实测中修正)
被控端对未知能力/ACL 拒绝/超时是回 JSON-RPC error → `client.invoke()` 抛异常。
若原样上抛前端要处理两种形态,故归一为 `{status:'failed',error:{name,message,data?}}`;
而「未连接/缺参数」仍走协议错误(语义不同)。

## 验证(真实链路,非 mock)
本地 agent → sidecar **8/8 全绿**:connect 回传 51 项能力与 agent_version ✓ ·
invoke system.info ✓ · state 反映已连接 ✓ · 事件通知已推送 ✓ ·
非法能力返回 failed 且不崩 ✓ · disconnect ✓ · **stdout 无日志污染** ✓
打包产物拷到仓库外**独立运行通过**(227KB 单文件自足)。

## 待办(下一批)
Rust 壳改造(command 签名不变 → 前端零改动)· tauri.conf.json 的 resource 配置 +
Node 运行时打包 · 退役 crates/nodeagent-client
2026-10-10 15:11:39 +08:00
jkooe e23788d717 refactor(versioning): 统一版本号 —— semver 为唯一版本号,开发批次代号并入其归属版本
## 问题(皇上指出)
项目里两套编号并行,无法判断哪个是"当前版本":
- **发布版本**(semver):v1.1.0 ~ v1.6.0、v2.0.0
- **开发批次代号**:v1 ~ v26(README 路线表 / 代码注释 / 文档)
时间线证据:v1.1.0 首发(09-29)时批次已推进到 v12.4 —— **二者从来不是一对一**(26 批次只发过 10 版)。

## 规则(新增 docs/VERSIONING.md §0 版本号唯一性规范)
- **唯一版本号 = semver `vX.Y.Z`**,两个来源必须永远一致:git tag 与 package.json(构建期注入 agent)
- **批次代号不是版本号**,不再单独出现;需要区分同版内多批改动时用**功能名**
- 附「批次 → 归属发布版本」映射表(按 tag 时间线与功能内容推定,并注明以 CHANGELOG 为准)

## 统一动作
1. **批次 v23 ~ v26 → v2.0.0**(22 文件 / 53 处)—— 它们确实属于 v2.0.0
2. **臣的批次号 v1.7 / v1.8 → v2.0.0**(27 文件)—— 未单独发版
3. **简写补全三段式**:v1.5 → v1.5.0、v1.6 → v1.6.0
4. **README 路线表**:`**v1**` → `**批次 1**`;表头加「批次 ≠ 版本号」说明并链到 §0;
   补齐批次 16~29 与 **v2.0.0 发布行**;删掉过时的「待办」行(提到的分支早已删除)
5. `docs/VERSIONING.md` 里**故意保留** 3 处批次号 —— 那是规范说明本身

## 保留项(真实发布版本,不动)
v1.1.0 / v1.2.1 / v1.3.0 / v1.4.0~v1.4.3 / v1.5.0 / v1.6.0 / v2.0.0(10 个 tag)

## 验证
- 版本号一致性:package.json = tag = 8 个 workspace = tauri.conf.json = **2.0.0** ✓
- v1.7/v1.8/v23~v26 全仓残留 **3 处**(均在 VERSIONING.md 规范说明中,属预期)
- 7 包构建 · 单测 273 · e2e 30 · ps1-encoding 24 全绿
2026-10-10 14:27:12 +08:00
jkooe 5b69d6cec9 refactor: 项目结构与命名规范化(重写前的最后一次常规提交)
## 改名
`scripts/run-temp.cmd` → `scripts/run-portable.cmd`
理由:它是**正式功能**(免安装、免管理员的便携启动方案),"temp" 易被误认成临时文件。
程序内文案同步:TEMPORARY launcher → PORTABLE launcher;
打包产物 README 的"【临时试用,不装也行】" → "【免安装试用,不装也行】"。

## 移动
`PRD.md` → `docs/PRD.md`
理由:文档统一放 docs/(README/CHANGELOG 按惯例留在根)。

## 同步的引用(共 11 处)
- scripts/pack.mjs:产物文件名校验 + 生成 README 的 3 处指引文案
- scripts/run-portable.cmd:自身用法注释 6 处
- README.md:目录树 + 文档链接
- docs/DEVELOPMENT.md:配套文档链接(`../PRD.md` → `PRD.md`,现已同目录)+ 目录树

## 验证
- 全仓 `run-temp` 残留 **0**;`./PRD.md`/`../PRD.md` 旧路径残留 **0**
- 7 包构建通过;单测 273 全绿;ps1-encoding 24 全绿
- `pack.mjs` 实跑通过,`run-portable.cmd` 已正确进入 Windows 产物
- `.cmd` 保持 ASCII + CRLF(94 行);PRD.md 无内部相对链接需调整
2026-10-10 13:39:42 +08:00
jkooe 11127b1a89 chore(privacy): 杀软实名与网卡名示例泛化(安全报告第十三轮 P2 两项)
## 甲1 杀软实名泛化(6 行)
`主流杀软 / 杀软 / 360` → 「主流杀毒软件 / 杀软」:
- docs/REMOTE-LIMITS.md:标题 + 正文 + 表格两处(保留"会拦什么"的实操信息,去掉具体厂牌)
- scripts/release.mjs:278:Release 正文模板里的信任区提示
- apps/cli/src/cmd/ops.ts:398:部署失败排查提示

## 甲2 网卡名示例泛化(13 行)
「Ethernet」→ `Ethernet`(示例)/泛化为"中文网卡名"(讲原理时):
- docs/REMOTE-LIMITS.md 网络适配器表格、ROADMAP 该项标记为已完成
- scripts/rescue-network.ps1 参数说明、automation.ts 的 interface 描述
- apps/agent/src/capabilities/network.ts 注释:改为"脚本里的非 ASCII 字符(如中文网卡名)"
  —— **保留"为什么"的教训,去掉具体实况**
- tests/unit/net-scripts.test.mjs:8 处测试数据与正则断言同步改为 Ethernet

## 验证
工作区残留「主流杀软/杀软/Ethernet」= **0 处**;单测 273 全绿;7 包构建通过。
2026-10-10 12:26:41 +08:00
jkooe d9e2103be7 docs(roadmap): 同步 v2.0.0 已发布状态
- 头部基线:v2.0.0(2026-10-10 / a515eb2),单测 273,能力 51,CI 双工作流全绿
- §九 台账补「发版 v2.0.0」行 + 「v1.5 四项与 v26 清理策略真机验证仍待补」的显式标记
2026-10-10 12:18:28 +08:00
jkooe a515eb2e63 chore(release): v2.0.0 2026-10-10 12:14:02 +08:00
jkooe 3fcfc7d12d chore: CHANGELOG 定版 v2.0.0 + 忽略 .workbuddy/(本地 agent 数据)
- CHANGELOG:「未发布」→ v2.0.0(2026-10-10),补兼容性声明(完全向后兼容)
  与**已知限制**(发布时被控端离线 → v1.5 的四项与 v26 清理策略尚未真机验证)
- .gitignore:忽略 .workbuddy/(memory/automations 属本机状态,不应入库;
  此前未被忽略,会干扰 release.mjs 的干净工作区检查)
2026-10-10 12:12:47 +08:00
jkooe 865862ee7c refactor: 拆 mcp/index.ts(1139 → 530 行),工具清单独立成 tools/defs.ts
## 拆法
`TOOLS` 数组(610 行,占原文件过半)抽到 `apps/mcp/src/tools/defs.ts` 并 export。

保留在 index.ts 的:
- 连接与调用辅助(ensureClient / call / forwardEventToHost)
- toArgs(入参特化)· TOOL_TO_CAPABILITY · resolveToolCall(分发 switch)
- MCP server 定义与 main

**分工**:defs.ts 只管「暴露什么」,index.ts 管「怎么转发」。

## 零漂移的证据
抽出的最大风险是**顺序漂移或漏搬** —— 两者都不会让构建失败,只会静默改变客户端
看到的工具表。故用「工具名序列 sha256」做证据:
  拆分前 41 个工具 / sha=69e8533c42c3…
  拆分后 41 个工具 / sha=69e8533c42c3…  ✅ 序列与顺序完全一致

## 新增回归守卫 tests/unit/mcp-tools.test.mjs(4 例)
- 工具数 41 且名称唯一
- **顺序未漂移**(序列 sha 比对;有意调整须同步更新基线并说明)
- 每个工具都有 name/description/inputSchema 三件套
- inputSchema 普遍声明 additionalProperties: false

## 验证
单测 269 → 273 全绿;e2e 30 全绿;7 包构建通过。
2026-10-10 07:50:13 +08:00
jkooe 690f38207c refactor: 拆 window.ts(1057 → 647 行),PS 预加载段按关注点分离
## 拆法(保守:只换文件组织,运行时逐字节不变)
抽出 5 段到 apps/agent/src/capabilities/win-ps/:
- prelude-win32.ts(30 行)Win32 窗口枚举与矩形读取
- body-list.ts(42 行)窗口枚举 JS/PS 体
- prelude-uia.ts(117 行)UIA 控件树 + Get-UiaProps/Test-UiaWhere/Find-UiaByText
- prelude-ocr.ts(37 行)OCR 引擎预加载
- prelude-image.ts(226 行)图像模板匹配(ZNCC 粗到精)

window.ts 保留:工具函数(runPS/runPSRaw/extractJson/warmup)、
WIN_HELPER_PRELUDE 拼接(顺序不变)、windowList / windowFocus / screenFind。

## 为什么这样拆
1. **不拆常驻助手**:window.list / window.focus / screen.find 共用同一个常驻 PS 进程,
   预加载段缺一段就会被 esbuild tree-shake,直到运行时才报"找不到类型"(真机踩过)。
   故拼接表达式与四段顺序**原样保留**在 window.ts。
2. **用 sha256 做零行为变化的强证据**:拆分前 dump WIN_HELPER_PRELUDE 的
   len=15232 / sha=88059028b342…,拆分后**逐字节一致** ✅
3. 保留原有回归测试 tests/unit/win-prelude.test.mjs(8 例)作为长期守卫。

## 验证
- prelude sha256 与拆分前一致(逐字节)
- win-prelude 8/8、单测 269 全绿、e2e 30 全绿、7 包构建通过
2026-10-10 07:47:31 +08:00
jkooe 2e6356c7f4 docs: REMOTE-LIMITS 的「观测窗口时点性」缓解措施已落地(monitor/log/audit)
该文原写「可让被控端持续采样并落盘……具备实现基础」—— 现已实现:
- monitor.* 定时采样落盘 + 摘要回看(断几次 / 最长断多久 / 值域)
- log.query 在被控端侧过滤日志(不必整份拉回)
- system.audit.anchor 定期锚定链头(防整链重写)

同时补一句仍未解决的:应用层内部状态(当前在等哪个请求)依旧只能从日志反推 ——
monitor 采的是外部可观测量,读不到进程内部语义。
2026-10-10 07:38:46 +08:00
jkooe 01e711be77 docs: 新增 CHANGELOG(项目此前没有变更日志)
自 v1.6.0 起累计 33 个未发版提交,按 Keep a Changelog 结构整理:
新增(语义可断言 / 状态可回看 / 安全加固三批)→ 修复(五处只有真机能暴露的严重缺陷 +
其他)→ 安全隐私 → 文档。并补记 v1.6.0 / v1.5.0 历史条目(含该版未经真机验证即发布、
2026-10-10 00:40:36 +08:00
jkooe 2cb0ed148e chore(privacy): 源码与测试里的真机常量脱敏(机器名 / 真机内网 IP)
路线图 §七 序 7「源码真机常量注入化」的脱敏部分。

## 改了什么
- apps/agent/src/capabilities/network.ts:注释里的真机机器名 → 泛化为「机器名+纯数字」
  (保留"为什么不能拼 USERDOMAIN/USERNAME"这个教训本身,去掉具体机器名)
- scripts/rescue-network.ps1:.EXAMPLE 里的真机内网地址 → 文档示例段 192.168.1.100;
  网卡名示例由中文默认名改为 'Ethernet'(既避免像真机、又更通用)
- packages/protocol/src/acl.ts:注释里的真机 Mac 地址 → 192.168.1.100
- tests/unit×4:测试数据里的真机 IP 统一换成 192.168.1.x 示例段

## 过程中的两个教训(都记在代码注释里了)
1. **正则转义形态漏替换**:断言里写的是 `192\.168\.0\.88`(转义点),第一轮只替换了
   裸形态 `192.168.0.`,导致脚本值变了、断言没变 → 3 个测试红。
2. **机械替换会破坏「跨网段」这类语义测试**:acl 的 `/24 网段匹配` 用例里有一条
   本意是「**不同网段不匹配**」,替换后两侧变成同段 → 断言必红。已改回不同网段
   并在注释里标注原因,防止下次再被批量替换破坏。

## 验证
单测 269 全绿(复跑);e2e 30 全绿。
2026-10-10 00:39:28 +08:00
jkooe 74d878a1c5 docs: DEVELOPMENT 能力表补全至 51 项;RETROSPECTIVE 加「短板已全部解决」批注
DEVELOPMENT.md:能力表此前停在 19 项(v5 时期)—— 补齐 20~51(32 行),
按功能分组并标注引入批次与风险图例;表末声明「权威定义以 manifest 为准,能力数由单测守卫」。

RETROSPECTIVE.md:保留 2026-09-28 的原判断(历史价值),
在末尾追加「短板已全部解决」对照表(自持/语义/状态/安全残余 → 各自解决于哪个版本),
并补一条本轮验证出的经验:真机验证不可替代(8 个 bug 只有真机能暴露)。
2026-10-10 00:35:43 +08:00
jkooe dfd3b2349e docs: README/ROADMAP 同步至 v26(能力 51 / MCP 41 / 单测 269 / Rust 9)
README:
- 能力总览补三行(GUI 等待 / 状态采样 / 审计锚定)
- 数字行 43→51 项能力、38→41 个 MCP 工具、215→269 单测、11→9 Rust 单测(实测)
- gui.await 章节补「属性谓词与组合」(where 四属性表 + any_of 示例 + 引擎限制说明)
- 新增两章:状态采样(log.query + monitor)、审计链外部锚定

ROADMAP:
- 头部基线刷新(269 单测 / 51 能力 / 未发版提示)
- §九 台账补 10-09~10-10 的语义 / 状态 / 安全三方向收尾(含 SECURITY §5 清单全部完成)
- 新增「真机验证抓到的 8 个真 bug」表,便于后续复盘
2026-10-10 00:29:39 +08:00
jkooe 32e0dd7bd5 fix(v26): 自重启任务清理加时间余量(治真机累积的 Running 僵尸)
## 问题
重启任务的 cmd 会**一直持有新 agent 进程**,所以任务状态长期是 Running。
原清理逻辑只清「非 Running」的(怕误杀当前宿主)—— 代价是真机累积:
2026-10-09 实测 D:\\Nodeagent 上残留 3 个 nodeagent-selfrestart-* 任务(全 Ready/Running)。

## 修法(保守优先)
清理条件改为「**非 Running** 或 **LastRunTime 早于 1 小时**」:
- 1 小时余量远大于一次重启的耗时(正常 <1 分钟),所以"当前宿主"绝不会被误判 ——
  它刚刚才运行过,LastRunTime 就是几分钟前
- 清旧 Running 僵尸是安全的:它们的 cmd/node 早已退出,只剩注册表条目

用 includes 断言锁死这三条(AddHours(-1) / LastRunTime -lt $cut / Get-ScheduledTaskInfo),
并禁止回退到"只清非 Running"的老写法。

## 验证
单测 267 → **269** 全绿(新增 2 例源码断言);e2e 30 全绿;7 包构建通过。
⚠️ **该项在 agent 内,且涉及进程清理 —— 必须真机验证后才算可交付**。
当前被控端整机离线(2026-10-10 00:15 ping 100% 丢包),故标注为待验证,未发版。
2026-10-10 00:19:43 +08:00
jkooe 68546e63e8 docs+fix(v25): 审计锚定的语义说明(锚定自身也写审计 → 紧随比对应显示「已增长」)+ SECURITY §4.8
真机验证时发现一个语义瑕疵:刚锚定就比对,永远显示链已增长 ——
原因是锚定调用自身也被 server 层记入审计(统一记录所有 invoke)。
功能正确(不误报篡改),但反直觉,故:
- compareWithAnchors 的 verdict 补上解释
- manifest 的 audit.anchor 描述补上说明
- SECURITY.md 新增 §4.8(含两个关键设计:四元组锚点防轮转误判、追加式防刷新抹痕),
  §5「还没做的」清单至此**全部完成**
2026-10-10 00:12:02 +08:00
jkooe bec07fc277 feat(v25): 审计链外部锚定 —— 链头可读 + 锚点落盘 + 锚点比对(方向三收尾)
## 解决的问题
审计链的哈希只能发现**链内**篡改(改一条要重算其后全部)。但「有 root 的攻击者」
可以**整链重写**:从零重建一份自洽的假日志,verifyAudit 照样通过。
外部锚定是唯一能戳破它的手段:把链头记到**链外**(另一台机器/网盘),
攻击者改不了已经落到别处的历史记录。

## 两个新能力(能力数 49 → 51)
- system.audit.head(low):返回 {entries, head_hash, head_ts, rotated_segments, file_bytes};
  compare=true 时附带与最近锚点的比对结论
- system.audit.anchor(medium):把链头**追加**写锚点文件(默认 <数据目录>/audit-anchors.jsonl,
  指定 path 时仍受 fs_roots 白名单约束)

## 一个关键设计点(最容易写错的地方)
锚点**不能只记 head_hash**:日志轮转会丢弃最旧段使条目数下降,只记哈希会把轮转误判成篡改。
故记四元组 {entries, head_hash, head_ts, rotated_segments},比对时先按轮转对齐再判尾部。
三条判定:① 条目数减少且无轮转 → 疑似整链重写;② 条目数与段数都没变但哈希不同 → 该段被重写;
③ 条目数增长/轮转 → 锚点仍在链上,ok(附 detail 供人工核对)。

## 追加式而非覆盖式
锚点文件只追加:若可覆盖,攻击者重写链后再"刷新"锚点即可抹掉痕迹。

## 验证
- 单测 258 → **267**(新增 audit-anchor 9 例,含「整链重写」与「哈希不一致」两个关键检测、
  追加式不被覆盖、空链返回 null、无锚点时的提示语气);e2e 30 全绿;7 包构建通过
- CLI:nodeagent audit head [--compare] / audit anchor [--note ...]
- ⚠️ 真机验证随后(需部署到被控端)
2026-10-10 00:07:58 +08:00
jkooe 8b97fc6106 feat(v24): 零信任优先安装 —— 拿到控制端公钥就走 ed25519,否则明确告警
## 为什么不是直接改 auth_mode 默认值
ed25519 的**私钥必须在控制端**,被控端安装时拿不到公钥。硬改默认值会让新装的控制端
连不上(还得手工改配置回退)—— 比「有条件就走零信任 + 无条件时明确告警」更糟。
所以这里的"默认"是**优先顺序**,不是死值。

## 做法
install.ps1 新增 -ClientAclFile(默认探测脚本同目录 client-acl.json,内容即
`nodeagent keygen` 打印的那一条 acl.clients JSON):
- 有且含 client_id+pubkey → auth_mode=ed25519,写 acl{default_effect=deny, clients=[…]},
  绿色确认 + 打印 --auth-mode ed25519 连接示例
- 无 / 缺字段 → 保持 psk,但输出**醒目黄色告警**:说明 client_id 可自报的固有风险
  + 三步切换指引(keygen → 存 client-acl.json → 重跑 install.cmd)
install.cmd 无需改动(install.ps1 自行探测)。

## 验证(真机,不是推理)
- **PowerShell 5.1 语法校验**:PARSE_OK,BOM 保留(EF BB BF)
- **等效逻辑三 CASE**:
  A 有文件 → auth_mode=ed25519 + default_effect=deny + clients 完整(allow/deny/note 保留)
  B 无文件 → auth_mode=psk 且**不写 acl 字段**
  C 缺 pubkey → WARN 并回退 psk(不写半截 ACL)
- 编码守卫:ps1-encoding 24 例全过(BOM + 无 U+FFFD)
- 新增 tests/unit/install-zerotrust.test.mjs(8 例源码断言:参数、探测路径、
  useZeroTrust 条件化、deny 兜底、字段校验、告警文案、连接示例、ConvertTo-Json -Depth 6)

## 文档
docs/SECURITY.md 新增 §4.7 并更新 §5 清单 —— 把 v23 的三项(握手封禁/会话上限/
安装期密钥)与本次 ed25519 默认化一并标为已完成,只留「审计链外部锚定」待做。

## 数据
单测 250 → **258** 全绿;7 包构建通过。
2026-10-09 23:53:28 +08:00
jkooe 7e63963e4f fix(v23): 自重启在非管理员身份下的降级 + deploy 备份不再堆积
## ① system.agent.restart / deploy 重启:加权限降级(真机坐实)
原实现硬用 `New-ScheduledTaskPrincipal -RunLevel Highest`(前提是"agent 以管理员运行,
普通权限 taskkill 杀不掉它")。但真机 2026-10-09 坐实:**agent 以普通用户运行时
(is_admin=False),非管理员注册 Highest 任务会被系统拒绝** → deploy/restart 一律报
"注册重启任务失败 exit=1",只有文件更新成功、进程不重启(部署"部分成功"的假象)。

修:两级降级 —— 先试 Highest(管理员场景保持原语义),注册后校验;
失败则不指定 -Principal(默认继承当前权限级别 Limited),同权限下 taskkill 自己完全够用。
已在被控端用 schtasks /RL LIMITED 现场验证过该路径可行。

## ② deploy 备份改为固定名,并清理历史堆积
此前每次部署写 `${target}.bak-<时间戳>`,真机 D:\Nodeagent 下堆了 15+ 个(单文件 1MB)。
部署回滚只需要「上一个可用版本」→ 改为固定名 `${target}.bak`(覆盖式)。
并在上传后 best-effort 清理老格式 `.bak-*`(平台分流:Windows 用 PS,类 Unix 用 rm;
清理失败不影响部署结果,只提示)。

## 验证
单测 250 全绿;e2e 30 全绿;7 包构建通过。
⚠️ ① 在 agent 内,需部署新包才能真机验证;② 在本地 CLI,立即可见。
2026-10-09 23:44:35 +08:00
jkooe 28d590a4e5 fix(v1.8): where 注入改为 PS 单引号字面量(真机连修两轮才通)
上一版(982eb9b)把 JSON 字面量改成「JSON.stringify 两次」,生成
  $whereJson = "{\\"enabled\\":true}"
而 **PowerShell 5.1 不认反斜杠转义**(PS 用反引号),依然 ParseError。
真机复验:部署+重启后带 where 仍一律"窗口操作失败"。

正解(已在被控端现场验证):**PS 单引号字符串字面量**,JSON 内的单引号翻倍为''
  $whereJson = '{"enabled":true}'
再 ConvertFrom-Json 摊平成哈希表(Test-UiaWhere 依赖 .Keys,PSCustomObject 没有)。

## 真机复验(部署 → 自愈重启 → 五场景)
① 带 text 的 where → 命中 1 条 where_applied=True enabled=True
② 反向对照 enabled=false → 0 条  ← 证明 where 真在过滤
③ where-only → 命中 2 条(此前报"text 不能为空")
④ where value 对 null 属性 → 0 条  ← 不猜的设计成立
⑤ text 与 where 全无 → 业务层拒绝

## 两轮踩坑记录(已写进代码注释防再犯)
✗ JS 的 JSON.stringify 生成的对象字面量 → PS 不认
✗ JS 的 JSON 转义 (\\") → PS 5.1 不认反斜杠转义
✓ PS 单引号字面量 + 内部单引号翻倍

教训:**跨语言拼代码时,别用源语言的转义规则猜目标语言** ——
本轮两次都是"在 JS 里想 PS 的转义",正确做法是到真机上把候选写法直接跑一遍。
2026-10-09 23:34:39 +08:00
jkooe 982eb9b1fa fix(v1.8): 真机验证抓到的两个 where 相关真 bug(macOS 上永远测不出)
皇上问"能不能连上 Windows 真机",连上后顺带做积压的真机验证,当场暴露两处:

## ① screen.find 的 where-only 被自己的入口校验拦下
v1.7 声称"control 条件可只用 where 不传 text",但只改了 gui.await 侧,
screen.find 入口仍要求 text 非空 → 直连与 await 的 where-only 全部报
"text 不能为空"。修:text 与 where 至少给一个;macOS 侧明确说明
"无 UIA 等价物,where 不可用,请给 text"(而非笼统的参数错误)。

## ② where 的 PowerShell 注入语法错误(更关键)
原实现直接把 JSON 字面量注入 PS:$where = {"enabled":true}
—— PowerShell 不认这种语法(哈希表要 @{enabled=$true}),ParseError 让
**整份助手脚本**失败。真机现象:带 where 的调用一律"窗口操作失败",
不带 where 的完全正常。
修:改为 JSON 字符串 + ConvertFrom-Json 摊平成哈希表(Test-UiaWhere 依赖 .Keys,
PSCustomObject 没有 Keys)。

## ③ 补上此前"只在注释里承诺"的 note
manifest 写明"OCR/image 引擎忽略 where 并带回 note",代码里没实现。
现补 note + where_applied 字段(UIA 时 true,降级时 false 并说明)。
不补的话,调用方会以为"按属性过滤过了"而误判结果。

## 真机已确认可用的部分(无需修复)
- UIA 属性读取 ✓:QQ音乐精简窗的 Text 控件读到 enabled=true,
  value/selected/toggle 均为 null —— **"拿不到就留 null、绝不猜"的设计在真实
  第三方应用上验证成立**(Qt/Electron 类控件给不出这些属性)。
- monitor/log 能力已在被控端就绪(v1.8 构建已部署):monitor.list 返回 ok。

## 验证
单测 250 全绿(新增 where-only 放行断言);e2e 30 全绿;7 包构建通过。
⚠️ 两个修复都只在 Windows UIA 路径生效,**必须部署到被控端才能真机复验**。
2026-10-09 23:24:40 +08:00
jkooe 933863529a docs: 包内 README 密钥说明与实际一致
README 原写「PSK.txt 里是预共享密钥」,但 PSK.txt 自 045e3b0 起已不再随包
分发(该 zip 会作为公开 Release 资产,打进密钥等于公开)。说明与事实不符,
首次上手者会找不到这个文件。

改为按实际取值顺序描述:① 本目录已有 PSK.txt 则用之
② 已有 %USERPROFILE%\.nodeagent\agent.json 则复用其中 key
③ 都没有则自动生成随机密钥;并指明密钥会打印在屏幕上、
可用 run-temp.cmd status 查看。
2026-10-09 23:15:56 +08:00
jkooe 4cb8ad566b feat: run-temp.cmd —— 免安装、免管理员的临时启动方案
问题:Windows 端 agent 离线时,control.cmd 依赖已安装的计划任务
(Start-ScheduledTask),未安装就完全起不来;而 install.cmd 需要管理员
权限并写防火墙规则,临时试用太重。

run-temp.cmd 直接 node.exe agent.mjs —— agent 本身零配置,
首次运行会自动生成 %USERPROFILE%\.nodeagent\agent.json(随机 PSK +
自签证书)并开始监听,故无需任何前置步骤。

用法:
  run-temp.cmd            前台启动(Ctrl+C 停)
  run-temp.cmd background 最小化后台启动
  run-temp.cmd status     查看进程 / 监听 / 配置与密钥
  run-temp.cmd stop       结束

实现要点:
- agent **不解析命令行参数**(argv 出现 0 次),端口只来自 agent.json
  → 脚本不再传 --port(传了也会被忽略,属误导),脚本内 PORT 仅用于状态检查
- CMD 批处理规避:start "title" 后必须套 cmd /c ""..."" 才不吞标题;
  子程序用 goto 而非 call(call :LABEL 会在末尾 exit /b 时连带退出)
- 进程检测用 Win32_Process 查 CommandLine,而非 Get-Process.Path
  (后者在非管理员下拿不到 Path 属性)

pack.mjs 同步:
- 把 run-temp.cmd 纳入文件清单
- 新增 CRLF 守卫(此前只查非 ASCII)→ LF-only 的 .cmd 在部分
  Windows 环境会解析异常
- 两个守卫均已注入故障实测会红(LF-only 94 处 / 非 ASCII 偏移 1677)
- 包内 README 增补临时试用说明
2026-10-09 23:12:14 +08:00
jkooe 7c6a68268b feat(v1.8): 状态采样 —— log.query 被控端侧过滤 + monitor 定时采样落盘与回看(方向二)
## 背景:复盘 §四 那条判断仍成立
代理不通、杀软拦截、端口时开时闭都是**间歇性**的,而 system.status 只能看当下快照;
event.watch 解决「有事件时通知」,没解决「**持续记录并回看**」。

> 顺带纠正路线图一处过时判断:"fs.watch 未做" 不成立 —— event.watch 的 kind=file
> 就是 fs.watch 实现(递归 + glob + 推送/拉取双出口)。本轮不重复造。

## ① log.query(新增能力,低危只读)
在被控端侧过滤,只回匹配行 —— 日志常几万行,整份拉回既慢又占带宽。
- 流式逐行读(readline),扫到 limit 即停,不全量载入
- pattern(正则)/ level(含 WARNING→WARN 归一)/ since(按行首时间戳)/ offset / limit / tail
- 单行 >4KB 截断后再匹配;单次扫描上限 200 万行防失控
- 路径复用 fs.guardPath —— **不新开安全面**(fs_roots 非空即受限)

## ② monitor.*(四项新能力,低危)
start / report / stop / list / delete。四类采样源:
- port(TCP 连通性 + 延迟)/ process(存活 + PID)/ command(退出码 + 首行)/ metric(CPU·内存)
- 样本落 <数据目录>/monitors/<id>.jsonl(一行一针),**摘要直接给结论**:
  「断了几次 + 最长连续中断样本数」「CPU/内存 min·avg·max」「成功失败数与退出码集合」
- 并发上限 8;单文件 10 万行封顶轮转;timer.unref() 不阻止 agent 退出

## 真机验证(本地,15 例全过)
port 采样造一次真实断线(关端口 1.6s 再开)→ report 给出 up=9/down=4/最长中断 4 针/延迟 avg 1.31ms;
metric 给 CPU 16.9% 内存 50.24%;落盘 13 样本可回看;stop 后 report 仍可查;
log.query 按级别/pattern+tail 过滤准确;fs_roots 白名单外的 /etc/hosts 被拒;
非法 source/target/id 字符/重复 id/不存在 id 均被拒。

## 修复一个真 bug(测试抓到)
parseLeadingTs 的正则把 ISO 的  时区标记吃掉了 —— UTC 时间被当本地时间解析,
**整体偏 8 小时**。跨时区日志判断"何时断的"会错。已保留 Z/±hh:mm 后缀。

## CLI / MCP
- nodeagent monitor start|report|stop|list|delete ...;nodeagent log --path ... --level ERROR
- MCP:na_monitor(action 分发)+ na_log

## 数据
能力数 43 → **49**;单测 231 → **247** 全绿;e2e 30 全绿;7 包构建通过。
2026-10-09 23:08:03 +08:00
jkooe 2e14f1f451 fix(v1.7): 修 any_of 路径的类型收窄(TS 跨分支无法推断 condition 已校验)
CI 构建抓出:await.ts 里 buildInnerArgs(condition, ...) 收到 Condition|undefined。
走到该行时已过入口校验、且未进 any_of 分支,condition 必合法,但 TS 跨分支推不出 —
— 显式断言为 single。本地同样报错(上次漏看了 Done 数只有 3 而非 7)。
2026-10-08 01:11:13 +08:00
jkooe 8c41414e04 feat(v1.7): 语义属性化 —— screen.find 读 UIA 属性 + where 过滤,gui.await 支持属性条件与 any_of 组合
## 背景(路线图方向一第二步:从「能找到」到「可断言」)
原链路是「点 → 截图 → 目测 → 再点」。gui.await 解决了「等得到出现/消失」,
但等不到「**等它变成某状态**」—— 按钮出现了却还是灰的,点了没反应。
根因是 screen.find 只回坐标,不回控件状态。

## ① screen.find 返回 UIA 属性(v1.7)
在原有 name/control_type/automation_id/坐标 之外补四个只读属性:
- enabled(是否可用)、value(ValuePattern 当前值)、selected(SelectionItemPattern)、
  toggle(TogglePattern 的 On/Off)
- 每项单独 try/catch:**拿不到就留 null**,绝不猜 —— Electron/游戏 UI 常给不出值,
  由上层判「未知」而非「false」

## ② where 属性谓词(仅 UIA 生效)
{"enabled":true} 只回可用控件;{"value":"*已完成*"} 按值模糊匹配。
- 白名单四键(enabled/selected/value/toggle),其余键直接报错
- OCR / 图像模板引擎忽略该参数并**带回 note**,避免调用方误以为按属性过滤过了

## ③ gui.await 的属性条件与 any_of 组合
- control 条件可只用 where 不传 text ——「等一个 enabled 的按钮」比「等一个叫某名字的按钮」
  更贴近真实意图;但两者全无仍报错
- any_of: [ {...}, {...} ] 任一命中即算,命中时回报具体是哪个子条件(hit 字段)
  例:[{window:安装完成},{control:错误}] =「装成功或报错,先出现的算」
- 设计取舍:**不做通用布尔表达式**。「弹窗出现且其中按钮 enabled」用
  any_of(弹窗) + where(按钮 enabled) 两步即可表达;通用 and/or/not 会把 schema
  与求值都复杂化,收益不成比例。确需两个都成立时上层连调两次更直白。

## ④ CLI / MCP 同步
- CLI:nodeagent await ... --where '{"enabled":true}' --any_of '[{...}]'(JSON 字符串,非法 JSON 即报错)
- MCP:na_await 透传 where/any_of,schema 同步

## 验证
- 单测 **227 → 231**(新增 4 例:where/any_of 过 schema、where 白名单、
  screen.find 的 where 类型校验、any_of 的 OR 语义);e2e **30** 全绿
- **本地真机 6 场景全过**:any_of 命中并回报子条件 / absent 等到全消失 /
  where 在报错环境正常超时 / 非法 where 键被拒 / any_of 项缺 condition 被拒 / 空数组被拒
- ⚠️ 待 Windows 真机:UIA 属性的真实读取(enabled 随播放状态变化)与 where 过滤;
  macOS 侧走 OCR,where 自动忽略并带 note(已按此设计)
2026-10-08 01:04:50 +08:00
jkooe 921ff89edf feat(v23): 连接层三项防护 + 安装期密钥改 stdin(堵住最后一条密钥暴露面)
## ① 安装期密钥不再走命令行(路线图方向三,最后一条暴露面)
此前 install.cmd 用 -Key 传参,**同机其他用户可从 Win32_Process.CommandLine
明文读到**。现改为管道喂 stdin:
- install.ps1 新增 -KeyFromStdin:先判 [Console]::IsInputRedirected 再读
  (没管道时不阻塞)、Trim 去 CRLF、hex 16-128 校验;优先级 -Key > stdin > 复用既有 > 新生成
- install.cmd:echo %PSK%| powershell ... -KeyFromStdin(管道符前不留空格,
  否则空格会被 echo 进密钥 —— 老坑)

## ② 握手失败封禁(此前只计数,攻击者可无限重试)
- 同来源连续失败达 max_attempts(默认 5)→ 拉黑 ban_ms × 2^(N-max),指数退避封顶 24h;
  封禁期过计数保留,继续翻倍
- 5 个失败路径统一记账(挑战过期/nonce 不匹配/未注册/Ed25519 失败/PSK 失败);
  **认证成功即清零**(不误伤手滑的控制端)
- 白名单来源永不封禁;归一化剥离 ::ffff: 前缀(否则同来源被当两个键)
- 在连接入口挡(TLS 之后、业务握手之前),错误响应带 retry_after_ms
- 封禁状态放 core(handleAuth 在内),AgentCore 暴露 banRemainingMs/states

## ③ 连接上限 + 空闲断开
- max_connections 默认 8(超限 close 1013);idle_timeout_ms 默认 30 分钟
- 空闲清扫**保守**:只要还有任务在跑或还有事件订阅就不断 —— 不把挂着长任务
  等结果的控制端踢下线(新增 runningTaskCount / watchCount 两个判据)
- states 由 WeakMap 改 Map(需要 size 与遍历),close 时手动 delete 防泄漏
- 新增审计类型 session.idle_close(console 的 AuditType 同步,契约测试守住)

## 验证
- 单测 **227 全绿**(新增 security-hardening 6 例:退避公式翻倍/封顶、IP 归一化、
  白名单、hex 校验、配置形状);e2e **30** 全绿
- **本地真机三场景**:正确密钥可连 → 连续错误被拒 → 封禁期连正确密钥也被拒 ✓
- ⚠️ 待 Windows 真机:install.cmd 双击首装(改了安装入口,必须验管道喂钥)
2026-10-07 21:23:37 +08:00
jkooe 9665edf35b docs(roadmap): 据实刷新(v1.6.0 已发 + CI 全绿 + 6 个 Windows bug 已修 + 新增铁律) 2026-10-07 20:33:03 +08:00
jkooe 277c5f1ba5 feat(v1.6.1): gui.await 补齐单测与 CLI/MCP 入口;源码注释脱敏;gitignore 补密钥规则
## ① gui.await 补齐(路线图 §1 收尾清单第 2、3 项)
此前该能力只有实现、**单测 0 覆盖、CLI/MCP 0 入口**:
- 新增 tests/unit/gui-await.test.mjs(6 例):能力登记与风险等级、四类条件 ×
  两态 schema、timeout 上限兜底、isHit 对五种返回形态的判定契约、
  CLI/MCP 入口存在性
- CLI:`nodeagent await --condition <window|control|process|file> [--text/--title/
  --process/--path] [--state absent] [--timeout ms]`,回显 satisfied/耗时/轮询次数/末次异常
- MCP:新增 na_await 工具(定义 + 分发),描述含典型编排示例

## ② 源码注释脱敏(路线图序 0.5 / §4.0)
input.ts 与 input-script.ts 里 4 处真实播放器名 / 游戏名泛化为
「主流媒体播放器」「全屏独占应用」「部分播放器会创建数十个辅助窗口」——
不再画像「哪台机器装了什么、常在什么场景下远程控它」。grep 复核零残留。

## ③ .gitignore 补密钥规则(路线图 §6.2)
此前只挡 PSK.txt,挡不住 keys/(ed25519 私钥)与 *.key / *.pem(TLS 私钥)
—— 一旦误 add 就是私钥入公开库。*.pub 显式放行。

## 验证
单测 215 → **221**(+6 全绿);e2e **30** 全绿;7 包构建通过;
CLI await 实测到连接层(被控端当前离线,超时属预期)。
2026-10-07 20:31:36 +08:00
jkooe 24b518c663 chore(gitignore): 补密钥材料规则 keys/ *.key *.pem(路线图 §6.2)
.ed25519 私钥存 <数据目录>/keys/ed25519.json、TLS 证书私钥在 certs/ 下,
此前 .gitignore 只挡了 PSK.txt,挡不住这两个路径 —— 一旦误 add 就是私钥入公开库。
*.pub 显式放行(公钥无敏感)。
2026-10-07 20:22:10 +08:00
jkooe d065242413 docs/ci: README 数字校正 + checkout 升 v5
- README:统计口径已过期(43 能力 · **32** MCP 工具 · **114** 单测 · **26** E2E),
  校正为实测值:43 能力 · 38 MCP 工具 · 215 单测 · 30 E2E · 11 Rust 单测。
  另补「桌面控制台」小节(架构图、页面表、构建命令、平台门控说明)与构建步骤说明
  —— 新组件入库后不同步 README,读者会以为仓库里没有它。
- CI:actions/checkout@v4 → v5,消除 runner 上的 Node.js 20 弃用告警。
  仅升被标记的这一个;setup-node / pnpm-action 未被标记,不做未验证的连带升级。
2026-10-07 10:18:52 +08:00
jkooe e7da258d17 docs: 入库控制台方案与路线图
- docs/console-plan.md:大前端方案(选型取舍、7 页面/7 store、command 表、事件名、
  验收标准 A1-A7、指标、里程碑 M1-M4)。§9.1 记录了 M3 的契约探针对账结果 ——
  9 处静默空列的漂移明细、平台门控设计、以及三段契约闭环的构成。
- docs/ROADMAP.md:能力路线图(含在建项与经验条目)。

两份文档此前因「在建工程」策略暂不入库,随本轮正式入库一并转正。
2026-10-07 10:14:51 +08:00
jkooe 0a5fa715d4 ci/test/chore: Rust CI + e2e 契约守卫 + 发版闸门加固
【新增 .github/workflows/rust.yml】crates/ 此前**零 CI 覆盖**(唯一工作流只在
windows-latest 跑 pnpm 那套,不碰 Rust)。新工作流走 Linux 快车道,只跑该 crate:
- clippy --all-targets -- -D warnings(告警即失败;该 crate 既定标准是零告警)
- cargo test(9 单测 + 2 E2E)
- **工具链锁死 1.98.1** 而不跟 stable 漂 —— 否则 lint 集合随工具链漂移,
  「本地全绿」与「CI 绿」会分叉(本仓库已因同类原因吃过 8 轮 Windows CI 的苦头)。
本步当场抓到一处真实 lint:discovery.rs 的 sort_by(|a,b| b.f.cmp(&a.f)) → sort_by_key(Reverse(..))。

【tests/e2e/run.mjs】新增 testContractShapes:对**真实被控端**调 7 项只读能力,
断言返回字段 ⊆ manifest returns_schema(含数组首元素)。与单测侧的 console-contract
合起来构成闭环:
    agent 实现 ──e2e──▶ 协议契约 ──unit──▶ 前端类型
只调无副作用能力(不写文件/不装软件/不动输入),避免 E2E 产生难回收的变更。
**已注入故障实测会红**:给 system.info 塞 injected_drift_field → 精确报出契约外字段。

【scripts/release.mjs】发版闸门补第二层。原闸门只查 git ls-files --others(未跟踪文件),
但紧接着的 `git add -A` 会提交**已修改的跟踪文件**,而它们不在原闸门视野里 —— 真例:
`pnpm install` 把某应用依赖写进跟踪中的根 pnpm-lock.yaml(+1401 行),发版时静默推走
且闸门不响。现补「脏文件白名单」:除脚本自己改的文件外,任何脏跟踪文件一律中止发版。
另:Tauri 的版本号不在 package.json 而另存于 src-tauri/tauri.conf.json(且它才是打进
.app 的版本),原先漏更新 → 现一并纳入版本号写入,避免「包里是旧版本号」。

【package.json】补 test:rust 与 probe 脚本(probe 需 crates/ 在库内,现已满足)。
2026-10-07 10:14:29 +08:00
jkooe dac8a0a2a7 feat(console): 入库 Tauri 2 桌面控制台(主控/被控一体)
apps/console —— 跨端(macOS/Windows)桌面控制台,复用 ops 侧的 Node agent 作本地被控端,
不做第二套 agent。技术栈:Tauri 2(Rust 壳 + 系统 WebView,包体小)+
Vue 3 + TS + Vite + Pinia + Naive UI(暗色)+ Vue Router(hash)。

- command 层(src-tauri/src/lib.rs):connect / disconnect / invoke_capability /
  get_capabilities / get_state;client 事件经 Emitter 桥接为 na-log / na-state / na-event 推前端。
  握手后**先 await 成功再落 state**(不持锁 await)。
- 7 页面:设备管理 / 状态总览 / 终端 / 进程与服务 / 软件 / 文件 / 审计。
- 实测:macOS .app 6.75 MiB(方案指标 <30MB;Electron 同级约 100MB+),
  直接执行二进制进程存活、无崩溃 —— `codesign --verify --deep --strict` 那条
  "code has no resources but signature indicates they must be present" 是 adhoc
  链接签名的固有噪声,实测无害(勿被误导去「修签名」)。

⚠️ 本轮用 examples/probe.rs 对真实被控端核对字段契约,抓出 9 处**静默空列**
(不报错、类型系统也抓不到,界面只显示 "-"),全部按契约修掉:
- 进程表读 r.cpu / r.memory → 真源 cpu_pct / memory_bytes(CPU、内存两列恒为 "-")
- 服务表读 r.status → 真源 state
- 磁盘读 d.mount||d.name / d.used → 真源只有 drive + total/free,
  **没有 used**(须自算 total-free)
- 网卡读 {name,rx_bytes,tx_bytes} → 真源 {adapter,ip,up}
- 软件表读 a.id → app.list 结果**没有 id**,应为 publisher
- 审计 broken_at 当数字渲染 → 真源是 {file,line,reason} 对象
另清掉幻影字段 SystemInfo.node(契约里只有 node_path)。

配套两点设计:
- **平台门控**:连接后探测 system.info.os → isWindows/platformReady;app.* 与
  system.service.list 仅 Windows 被控端可用,故软件页与服务 Tab **事前禁用并说明原因**,
  而不是发出注定失败的请求再解释错误。
- **结构化错误**:协议真源 InvokeResult.error 只有 {name,message,data?}(**没有 code**),
  故分支一律按 name;前端 CapabilityFailure 暴露 errorName,并提供
  isUnsupportedPlatform / isAclDenied / isRateLimited。未改协议(改动会扩散 4 端)。

tests/unit/console-contract.test.mjs(20 例):把「前端声明 ⊆ 契约」与「契约 ⊆ 前端声明」
双向钉死,另校验平台门控清单与 agent 侧 requireWindows 集合一致、AuditType 覆盖
apps/agent/src/audit.ts 全部取值。**已注入漂移实测会红**(把 cpu_pct 改回 cpu →
正/反向各挂 1 项),非空转守卫。
2026-10-07 10:14:12 +08:00
jkooe 8ab8f8ae80 feat(crates): 入库 Rust 控制端客户端 nodeagent-client
复刻 @nodeagent/client 的协议行为,供桌面控制台(Tauri 壳)经 Cargo path 依赖:
- 模块:error / types / ulid / crypto / tls / discovery / client
- 握手:hello → challenge → auth → auth_ok;psk(HMAC-SHA256) 与 ed25519 两种模式
- 证书指纹 sha256(DER) 钉住(E_CERT_MISMATCH);ULID(Crockford Base32)
- 断线指数退避重连 1s→30s(±15% 抖动)

Rust async Send 三层陷阱(本轮主要成本,解法已写入 client.rs 注释):
1. futures_util::lock::Mutex 无手写 Sync 实现 → 含它的类型整体 !Sync
2. tokio::sync::MutexGuard 非 Send
3. establish → spawn(read_loop) → on_disconnected → establish 构成**类型级递归**,
   编译器无法证明 Send。最终采用**监督者模式**:单个 supervise 任务串行
   「建立+握手 → 等读循环 Notify 断开 → 退避重连」,读循环只 notify 不回调
   establish,从而打破递归;发送半用专用写任务 + mpsc 解耦(同步 send,不跨 await 持锁)。

测试:9 单测(crypto/ulid/Send+Sync 断言)+ 2 E2E(psk 握手→invoke 全链路、连不上端口
返回 NODE_OFFLINE),`cargo clippy --all-targets -- -D warnings` 零告警。
另附 examples/connect.rs(连真实被控端)与 examples/probe.rs(字段契约探针,
输出顶层键+数组首元素键,并按 system.info.os 区分「平台不支持」与「真失败」)。

.gitignore:crates/ 转正后必须显式忽略 /crates/target/ —— 此前是靠整个 /crates/
被忽略才没让 4GB 级构建产物进库,取消那条保护后会裸奔。
2026-10-07 10:13:41 +08:00
jkooe 9efd290061 fix(pack): 打 zip 改为平台分流(Windows 无 Info-ZIP),顺带修 ls/du
Windows CI 连续暴露的第三类问题:
- zip -q -r ... -x PSK.txt → Windows runner 没有 Info-ZIP,命令不存在
- 同文件里还有 ls -la 与 du -sh,在 Windows 上同样不可用

修法:
- zip:Windows 用 PowerShell 的 Compress-Archive(先删 PSK.txt 再压,因为它没有 -x 排除项);
  macOS 继续用 zip
- 列目录改用 Node readdirSync+statSync;体积用 statSync 计算(不再依赖 du)
macOS 路径复跑通过(zip 31M),195 单测 / 29 e2e 绿。
2026-10-07 02:41:18 +08:00
jkooe 4140b8e32c fix(pack): 改用 esbuild 的 JS API,不再 spawn node_modules/.bin/esbuild
Windows CI 的产物包步骤暴露:pnpm 的隔离布局下那个 .bin shim 在 Windows 上不存在 →
  spawnSync D:\a\nodeagent\node_modules\.bin\esbuild ENOENT
(macOS 上一直正常,所以此前从未发现。)
改用 await import('esbuild') + esbuild.build({...}) 后只依赖 require 解析,跨平台一致。
本地复跑 pack:win 通过(bundle 1010.8kb / zip 32M),195 单测 / 29 e2e 复跑全绿。
2026-10-07 02:35:23 +08:00
jkooe 6b2b3828a5 fix(ci): 产物包检查改为反向断言 —— 包内**不得**含 PSK.txt(原来是断言应该有)
045e3b0 起 PSK.txt 已不再打进公开 zip(现生成给本机,打进公开资产=泄露密钥),
但 CI 的必含清单里还列着 PSK.txt → 每轮必红,且方向完全反了。
现在:必含 7 项 entries;并反向断言不含 PSK.txt / *.key / *.pem / certs ——
把「公开资产不带钥匙」这条性质变成 CI 守护项,而不是相反的束缚。
2026-10-07 02:27:58 +08:00
jkooe b809aaa92e fix(install): 双击入口的密钥生成路径是坏的 —— 改为交给 install.ps1 处理
**Windows CI 的双击链路冒烟抓到的真 bug**(第四轮 CI 暴露):
install.cmd 在「没有 PSK.txt」时自己用 cmd 生成密钥:
  for /f ... in ('powershell ... -Command "... ($b ^| ForEach-Object ...) ..."')
经 cmd 的 for /f 投递时需要 ^| 转义管道,而 ^ 未被消费、原样漏进 PowerShell →
  Unexpected token '^' in expression or statement
**必炸**:PSK.txt 已不在分发 zip 里(045e3b0),所以「首次双击安装」恰好走到这条路径。

修复:cmd 不再自己生成密钥 —— install.ps1 本来就有完整逻辑
(先复用既有 agent.json 的 key,没有再现场生成,且 PowerShell 引号安全)。
install.cmd 按有无 PSK 决定是否传 -Key。

顺带:本文件的 CRLF 曾被工具写坏,已恢复并复核(ASCII 0 非 ASCII、107 CRLF、无裸 LF);
编码守卫单测 22 项通过,全量 195 单测 / 29 e2e 绿。
2026-10-07 02:23:20 +08:00
jkooe 9ee75cf694 ci: 清理步骤加 continue-on-error(前置失败不应连带掩盖真实结论) 2026-10-07 02:15:05 +08:00
jkooe cd278ffda8 fix(ci): 5.1 执行的内联脚本一律 ASCII-only(同一个 BOM 坑,臣在 CI 步骤里原样复现)
第二轮 CI 暴露:『install.ps1 安装链路』失败,报 string is missing the terminator。
根因不是被测脚本(BOM 守卫同轮通过),而是**步骤自身的 run: 块里有中文** ——
GitHub 把 run 块写成**无 BOM** 的临时 .ps1,5.1 按 GBK 读 → 中文乱码 → 引号边界崩。
这与 2026-10-04 install.ps1 无 BOM 导致的事故是同一机制,臣在 CI 里原样踩了一遍。

修正:
- 所有 shell: powershell(= 5.1)/ shell: cmd 步骤的内联脚本改为 ASCII-only
  (中文只留在步骤名,那是元数据不参与执行)
- BOM 守卫的 throw 消息也一并英文化(它上一轮是侥幸没过炸)
- 清理步骤加 continue-on-error,避免前置失败连带掩盖真实结论

保留:shell: pwsh 步骤的英文/中文不受影响(7+ 认 UTF-8 无 BOM)。
2026-10-07 02:14:27 +08:00
jkooe 817ec3645b fix(ci): BOM 守卫步骤改用 shell: powershell(cmd 套 -Command 多行引号会全乱)
第一轮 CI 暴露:该步骤自身写法错,不是脚本错。shell: cmd + powershell -Command "..."
带多行/内层引号,经 cmd 转义后变成:
  Missing closing '}' / '$errs' is not recognized / -and was unexpected
全是指 step 自身,与被测 .ps1 无关。

修正:直接用 shell: powershell(runner 上的 powershell 就是 5.1,正是要测的目标)+ run: | 块,
与旁边已通过的 install 冒烟步骤同一形态。
2026-10-07 02:04:43 +08:00
jkooe 25f889c6d8 fix(test): ps1-encoding 守卫改用 fileURLToPath(Windows 上 URL.pathname 会多一根前导斜杠)
**这是 Windows CI 一上线就抓到的真 bug** —— 新加的 CI 单测步骤当场回本。

根因:测试用 new URL('../../', import.meta.url).pathname 取仓库根。macOS 上得到
/Users/... 可用;Windows 上得到 /D:/a/nodeagent/...(盘符前多一根斜杠),
readdirSync 直接 ENOENT → 整个文件 22 个用例全灭。
本地(macOS)永远测不出来,只有 Windows runner 会炸。

修法:改用 fileURLToPath(new URL(...)) —— Node 官方的跨平台转换。
全仓已无同类残留。本地 195 单测 / 29 e2e 复跑全绿。
2026-10-07 02:00:10 +08:00
jkooe af1cd39fdf ci: 补全 Windows 真机验证(单测步骤 + 5.1 BOM 守卫 + install.cmd 双击链路冒烟)
三处改动都源自真机事故复盘:
1. **单测步骤**(并入 ci/workflow-unit-tests 分支 4d73d6e 的实质贡献 —— 该分支基于
   09-29 老 main,直接 merge 会回退上万行,故只取其提交内容手工并入)
2. **PowerShell 5.1 解析守卫**:install.ps1 无 BOM 时 5.1 按 GBK 解析 → 中文注释
   破坏字符串边界 → 整份脚本 ParseError(2026-10-04 真机踩过)。此前 CI 冒烟用
   pwsh(7+ 认无 BOM UTF-8)**所以 CI 绿、真机炸** —— 现在这道守卫只问一件事:
   5.1 能否读懂,而这就是真机的核心约束
3. **install.cmd / control.cmd 双击链路冒烟**:这是用户唯一的上手入口,
   编码或语法坏了只有真机 cmd.exe 能测出来

(workflow 作用域已于 2026-10-07 由皇上授权后推送)
2026-10-07 01:55:14 +08:00
jkooe 5e08a2768b chore(release): v1.6.0 2026-10-07 01:44:21 +08:00
jkooe 79a0c15214 fix(release)+chore: 发版前增设未跟踪文件安全闸;gitignore 保护在建工程
① 安全闸:发版前若存在未跟踪文件即中止并列明,绝不盲目 add。
   成因:2026-10-07 的 add -A 与 commit -am(-a 同样会带入全部已跟踪修改)
   两次把无关文件带入发版提交并推到公开仓库。
② .gitignore 加入 /crates/、docs/console-plan.md、docs/ROADMAP.md ——
   皇上在建的 Rust 客户端与控制台方案(自述「待皇上评审」)暂不入库,防再次误提交。
2026-10-07 01:42:44 +08:00
jkooe 5571cf1aea chore: 取消跟踪误提交的在建工程(保持工作区文件不动)
上一个提交(v1.6)用了 git add -A,把工作区里**不属于本任务的用户在建文件**
一起带入并推送:crates/(Rust 客户端脚手架)、docs/console-plan.md、
docs/ROADMAP.md,以及 docs/RETROSPECTIVE.md 的本地改动。

本提交将它们移出 git 跟踪(磁盘文件全部保留、回到未跟踪状态),
使公开仓库只包含 v1.6 的预期变更。如需从历史中彻底抹去,
可另行 filter-repo 重写(公开仓库,需皇上决策)。
2026-10-07 01:22:58 +08:00
jkooe 2eb33587fe feat(v1.6): 新增 gui.await 等待条件能力(复盘短板之「语义」)
把 GUI 操作从「按一下→睡几秒→截图碰运气」变成**可断言**:四类条件
window / control / process / file,各复用 window.list / screen.find /
process.list / fs.stat(零新平台代码,平台行为与既有能力一致,只读低危)。
- present/absent 两种等待;超时正确返回 satisfied:false + note
- 轮询期单次异常**不视为失败**(启动中程序常短暂报错),超时才带回 last_error
- 参数非法分两层拒:schema 层(协议错误)与业务层(E_PARAM_INVALID)

本地真实验证(macOS,7 项全过):present 命中(1208ms/7 次轮询)、absent 命中、
超时返回、非法参数 2+1 拒绝、process 命中、底层报错不中断轮询(last_error 带回
辅助功能权限提示)、缺省参数可用。

能力数 42 → 43(数量守卫测试同步更新);README 补快捷键与 gui.await 用法章节。
2026-10-07 01:21:41 +08:00
jkooe 7037c7ff94 chore(release): v1.5.0 2026-10-07 01:00:25 +08:00
jkooe 689bb8c98d feat(v1.5): 快捷键控制范围扩展 —— 字符串热键 / 预设别名 / 长按 / 后台定向投递
## 背景
原 `input.key.press` 只接受数组和弦(keys/sequence),按键表也不全:
AI/MCP 调用要拼 ["ctrl","shift","esc"],且 **win+d、ctrl++、win+. 这类
"修饰键 + OEM 符号键"的组合根本打不出来**(表里没有符号键)。

## 四类扩展
① **字符串热键**:`hotkey: "ctrl+shift+esc"`、`hotkeys: ["ctrl+c","ctrl+v"]`
   —— 分隔符支持 `+ - 空格`,大小写不敏感,别名归一(command/cmd→win、del→delete…)
② **预设别名**:约 50 个语义名(copy / show_desktop / task_manager / screenshot /
   volume_mute_toggle…),AI 调用免记键位;`preset` 与 `presets`(序列)均支持
③ **长按** `hold_ms`(≤5s):按下→保持→释放,插在"按完"与"释放"之间
④ **后台定向投递** `route=post` + `target_pid`:向目标进程**全部顶层窗口**
   PostMessage WM_KEYDOWN/UP —— 目标窗口被遮挡/最小化时可用
   (媒体键的 WM_APPCOMMAND 机制早已验证,此为其等价物)
   文档明示局限:游戏/部分输入型程序不响应后台键消息

## 按键表补齐(单测覆盖断言防漂移)
- 左右侧修饰键 lctrl/rctrl/lshift/rshift/lalt/ralt/lwin/rwin(游戏常区分左右)
- 小键盘 numpad0-9 + 四则运算,**与数字键分开**(Excel 靠 NumLock 区分)
- OEM 符号键 oem_1..oem_8 / oem_minus/plus/comma/period(解锁 win+d、ctrl++、win+.)
- 浏览器收藏夹/主页、系统键(sleep/help/select/execute/clear/separator/oem_clear)
- IME 键(kana/hangul/kanji/hanja/convert/nonconvert/accept/modechange)
- 补齐 pause/apps(重构时一度漏掉,被覆盖断言测试立刻抓到)

## 可测性重构
脚本生成抽成**纯函数** `apps/agent/src/capabilities/input-script.ts`
(VK_MAP / MEDIA_COMMANDS / toVk / build{Media,Foreground,Post}Body),
行内生成逻辑全部改为调用 —— 于是**按下顺序/逆序释放/hold 插入位置/post 路由**
这些最容易写错的部分,现在由 14 个单测逐行断言,真机只需验证最终效果。

## 参数校验
protocol `expandChords()` 统一展开六种输入形式,**混用即报错**(消除"以谁为准"歧义);
同一和弦重复修饰键、超 4 键、空输入均拒绝。渲染返回值新增 presets/via/channel/sent_windows。

## CLI / MCP
- CLI:`nodeagent key press "ctrl+shift+esc"` / `key press copy` / 老形式 `key press ctrl c`
  新增 `--hold`、`--route`;回显预设展开与后台投递命中窗口数
- MCP `na_key`:同步支持 hotkey/hotkeys/preset/presets/hold_ms/route/target_pid

## 验证
- 单测 **179 → 195**:hotkeys 解析与别名 17 例、**脚本生成逐行断言 14 例**、
  按键覆盖断言(HOTKEY_KEYS ⊆ VK_MAP)、新参数 schema 校验 2 例
- e2e **29** 全绿
- ⚠️ **真机未验证**:Windows 被控端当前离线(连接超时)。待上线后需验:
  `key press "win+d"`(切桌面再切回)、`key press copy` 配剪贴板、
  `route=post` 定向一个最小化窗口。验证通过后再发版。
2026-10-07 00:59:40 +08:00
jkooe 9d32592ed1 feat(v1.2): input.key.press 支持媒体键(WM_APPCOMMAND 通道 + 定向投递)
远程控 QQ 音乐时发现:媒体键全部查不到,window.focus 越不过游戏全屏,
而软件热键在精简模式下未注册 —— 只能绕道 WM_APPCOMMAND 手写脚本。
现把这条通道正式纳入能力,避免每次重造轮子。

1. VK_MAP 补全(input.ts)
   media_play_pause(0xB3) / media_stop(0xB2) / media_next(0xB0) / media_prev(0xB1)
   media_rewind(0xB7) / media_fast_forward(0xB4) / volume_mute(0xAD) /
   volume_down(0xAE) / volume_up(0xAF) / 浏览器键 / F13-F24

2. 新增 WM_APPCOMMAND 通道(关键,非仅补 VK)
   媒体键不再走 SendInput,改为识别「整条序列都是媒体键」后走
   PostMessage(WM_APPCOMMAND)。原因:
   - SendInput 打进媒体键后,只有当前媒体会话焦点的窗口才响应
   - APPCOMMAND 可定向投递到指定窗口/进程,绕开该不确定性
   - 窗口最小化或被全屏游戏遮挡时依然有效(实测 QQ 音乐被 DNF 全屏压住仍可控)

3. 新增 target_pid 参数 + CLI --pid
   播放器常创建 30+ 辅助顶层窗口(QQ 音乐实测 36 个),只投主窗口常常无效
   → 指定 PID 后向该进程全部顶层窗口投递。
   返回值加 via:'appcommand' 与 target_pid,便于确认走的是哪条通道。

4. 音量键刻意不进 APPCOMMAND 表
   APPCOMMAND 的 lParam 按位传递(低 16 位 command、位 12-15 设备类型),
   裸值会撞车(APPCOMMAND_VOLUME_DOWN 与 APPCOMMAND_PAUSE 都是 1),
   纳入表中会导致静音键被误当暂停。故音量键走真·VK 注入,由系统自行翻译。

5. screenshot --scale 报错信息改善(gui.ts)
   协议限定 scale ∈ (0,1],原先非法值直发只回「参数校验失败」,看不出是哪项
   → CLI 前置校验,明确提示「--scale 应在 0 到 1 之间…收到: 3」。

6. window.focus 边界写入 manifest
   明确无法越过全屏独占应用:此时返回 focused:true 但视觉仍遮挡、点不到,
   属预期而非失败;指引改用媒体键 / shell.exec / 请用户切出全屏。

真机验证(192.168.1.100):单测 162 全绿;媒体键广播与定向投递均生效;
volume_up 20%→22%、volume_down×2 →18%;非法键名仍明确报错。
双端已部署,指纹 5b68faa20a3e,v1.4.3。
2026-10-05 15:06:24 +08:00
jkooe 3ff8e86fe8 chore(release): v1.4.3 2026-10-05 11:35:53 +08:00
jkooe 7689a1f80c fix(v22): 构建改为**确定性** —— 去掉注入的构建时间,否则「指纹比对是否最新」永远失真
## 现象(真机执行 `deploy --check`)
明明被控端就跑着 v1.4.2、本地也是 v1.4.2,却报「版本不一致」:
    本地 : 47326da097c7  (976 KB)
    远端 : 6499b3f7ad3c  (node v22.20.0)

## 根因
打包时注入了 `__BUILD_TIME__`,于是**同一份代码每次打包的字节都不同** →
内容哈希无法充当「构建身份」,`--check` / 部署后校验只能靠"这次上传的文件"比对,
跨批次就必然误报。

## 修复
- `pack.mjs` 不再注入构建时间(版本与 commit 仍是确定性的)
- `system.info.build.built_at` 改用**入口文件的 mtime**(mtime 不在文件内容里,
  因此不破坏字节确定性,同时仍能反映构建时刻)

## 验证
- 连打两次包(间隔 2s),指纹完全相同:`b13ce7d3ba74d0e0b293649f` ✓
- 真机更新:指纹 b13ce7d3ba74、42 项能力、证书钉住一致 ✓
- **再次执行 `--check` → 「✓ 已是最新,无需更新」**(此前同版本却报不一致)✓

## 意义
产物变为**可复现**(reproducible):同一 commit 打出同一份字节,
sha256 可以放心用作分发与更新的身份标识 —— 这对一条「下载即执行代码」的更新通道很关键。
2026-10-05 11:27:28 +08:00
jkooe 6c650244bf chore(release): v1.4.2 2026-10-05 02:51:31 +08:00
jkooe a56d67c012 fix(v22): 自更新在 Windows 上必须**就地覆写**而非 rename(真机暴露的平台差异)
## 现象(真机 192.168.1.100 上拉取式更新)
`✗ E_EXECUTION_FAILED: 替换脚本失败` —— 下载与哈希校验都过了,挂在最后一步「替换」。

## 根因
Windows **不允许重命名/替换正被运行进程打开的文件**(EPERM/EBUSY),而 node.exe
正持有 agent.mjs。POSIX 可以 unlink 打开中的文件,所以本地(macOS)测不出来。
这也解释了为什么推送式 `deploy` 一直没问题:它走**就地覆写**,而覆写打开中的文件
在 Windows 上是允许的(只有删除/重命名被禁)。

## 修复
- 改为 `writeFileSync(scriptPath, buf)` 就地覆写(先备份 + 已校验的暂存内容)
- **写后校验**:重算入口文件 sha256,与刚校验过的下载比对;不一致立即从备份回滚
- 清理暂存文件;错误信息带上 backup_path / staged_path 便于人工兜底

## 顺带的真机结论(都已在 192.168.1.100 上验证)
- **被控端访问不了 github.com**(`fetch failed`)→ 国内网络现实;改用局域网/自建镜像即可
  (本轮用 `http://192.168.1.100:8081/...` 拉取成功 ✓)→ 故 URL 参数化是必需的,不是花活
- **拉取式更新在 Windows 上可用**(内容确实变化:构建哈希 4833b0b5 → 216328d5 ✓,
  重启后复核通过 ✓)
- **Windows 音频能力真机跑通**(Core Audio COM):🔇 静音 · 音量 77%,可读回真实状态
- **证书指纹钉住**:普通 agent 更新不影响钉住(证书未变 → 仍一致 ✓);只有证书重建才触发
  `E_CERT_MISMATCH` —— 符合设计意图
2026-10-05 02:50:48 +08:00
jkooe 4106ada876 chore(release): v1.4.1 2026-10-05 02:46:37 +08:00
jkooe f88738d36d fix(v22): 修正构建期注入的版本号带多余引号(真机暴露)
## 现象
真机 `nodeagent info` 显示 `被控端版本 : v"1.4.0" @ "e841fbb"` —— 值里带了引号。

## 根因
esbuild 的 `--define:K=V` 中 **V 是按 JS 表达式解析的**。原先写成
`--define:__AGENT_VERSION__='"1.4.0"'`(单引号包裹),单引号被当成**字符串内容**,
于是运行时值变成 `"1.4.0"`(含引号)。产物内联可见:
`return false ? "dev" : '"e841fbb"';`

## 修复
改用 `JSON.stringify(v)`(纯双引号)→ 产物内联为 `return false ? "dev" : "1.4.0";` ✓
真机复核:`被控端版本 : v1.4.0 @ 50ac4b9  构建 05abbbc79abf` ✓

## 附带真机确认(v1.4.0 部署)
- 推送式部署 + **指纹校验通过**(能力 42 项、PID 16664)
- **证书指纹 TOFU 钉住生效**,且普通 agent 更新**不影响**钉住(证书未变 → 仍一致 ✓)——
  这正是设计意图:只有证书重建才会触发 E_CERT_MISMATCH
- **Windows 音频能力真机跑通**(Core Audio COM 路径):`✓ 已应用:🔇 静音 · 音量 77%`,
  且能**读回真实状态**(确定性设置而非切换)—— 这补上了此前只在 macOS 验证的空缺
2026-10-05 02:45:51 +08:00
jkooe 50ac4b9646 chore(release): v1.4.0 2026-10-05 02:42:56 +08:00
jkooe e841fbb187 feat(v22): 第二批安全加固 —— 每客户端独立 PSK + 发现广播最小化
接着 v21 的两件 P0,补齐第二批清单里的两项(对应原建议 ③/④ 的一半)。

## ① 每客户端一把 PSK(`agent.json` 的 `keys{}`)—— 让 ACL 的身份维度真正有效
**问题**:psk 模式下 `client_id` 由客户端自报(server.ts 原注释自认),
一把共享 key 意味着**持有者可冒充任意 client_id**,ACL 的身份维度形同虚设。

**做法**:按 `client_id` 分配独立钥匙;未登记者回落到共享 `key`(旧部署行为不变)。
比 ed25519 更轻量(不用管公钥),同样是「一把一个身份」。

**实测**(五场景全对):
| 场景 | 结果 |
|---|---|
| alice 用自己的 key | ✅ |
| **alice 的 key 冒用 bob 身份** | ❌ E_AUTH_FAILED ← 核心安全性质 |
| bob 用自己的 key | ✅ |
| 未登记 carol 用共享 key | ✅(向后兼容) |
| 已登记 alice 用共享 key | ❌ 拒绝(专属优先,不被共享 key 绕过) |

配套:`nodeagent keygen --psk --id mac_02` 生成专属 key 并打印可直接粘贴的 agent.json 片段;
鉴权日志带 `key=scoped|shared-fallback|shared`,审计区分「专属钥匙失败」与「回落共享失败」。

## ② 发现广播最小化(`discovery.detail` + `discovery.secret`)—— 收情报泄漏
**问题**:UDP 广播未认证,默认明文告知 IP/端口/认证方式/键鼠开关,等于给攻击者一份目标清单。

**做法**:
- `detail: "minimal"` → 顶层只剩 `service / version / node_id / ts`,
  真实信息收进 `detail{}`(**实测顶层已无 host/port/auth_mode/input_enabled**)
- 配 `secret` → `detail` 带 HMAC 签名(`node_id|ts|JSON(detail)`),
  控制端 `discover --discover-secret <同一密钥>` 验签 → 列表标 **🔒 已认证**;
  未配/验签失败标 **⚠️ 明文**(明示「仅作参考、不可当凭据」)
- 配了 secret 自动启用 minimal;未配置时启动横幅**醒目告警**提示收敛

**实测**三视角:正确密钥 🔒 / 无密钥 ⚠️ / 错密钥 ⚠️(验签失败正确降级)✓

## 验证
- 单测 159 → **162**(新增签名往返一致性:错密钥/篡改详情/改时间戳均须验签失败)
- e2e **29** 全绿;本轮改动为纯加法(新增可选字段),未破坏既有行为
- 文档 `SECURITY.md` 新增 §4.5 / §4.6,并把「发现最小化」从待办划掉

## 剩余待办(SECURITY.md §5)
握手失败封禁 / 审计链签名与外部锚定 / 会话上限与空闲断开 / 安装期密钥改用文件传递 /
ed25519 仍非默认(v22 的 `keys{}` 已是更轻量的等价物)
2026-10-05 02:42:12 +08:00
169 changed files with 25796 additions and 1334 deletions

No files matched your search

+319
View File
@@ -0,0 +1,319 @@
name: Console Windows
# 控制台(apps/console)的 Windows 安装包构建。
#
# 为什么单独一个工作流、而不塞进 windows-e2e
# ----------------------------------------
# windows-e2e 是「被控端在 Windows 上的行为验证」,跑一遍约 4 分钟、含安装/卸载真机冒烟;
# 而控制台打包要装 Rust 工具链、编 Tauri(首次冷编 10 分钟级)。两者目标不同、
# 耗时量级不同,混在一起会让「改一行前端也要等 15 分钟」。
#
# ⚠️ 在此之前,控制台的 Windows 侧只被验证到 prepare:node 与自举(见 windows-e2e),
# **从未真跑过 tauri build**。而 Rust 里那些 `cfg!(windows)` 分支
# (taskkill 收进程树、PowerShell 计划任务、node.exe 路径)在 macOS 上
# 只被类型检查、从未被编译成 Windows 目标,更没有产物级验证 —— 故本工作流是它们的第一道真闸。
#
# 架构(为什么必须两套)
# --------------------
# 被控端发布包曾出过「arm64 包里装的是 x64 node.exe」的事故,后果是
# **在 ARM Windows 上能 listen、但 TLS 握手不完成**,且 macOS 侧 100% 复现不出。
# 控制台同理:它内含 node 运行时,且要用它**托起本机被控端**(即 TLS 服务端)。
# 故 x64 与 arm64 必须各出一套,且都要能装到对应架构的 Windows 上。
#
# 这里用 GitHub 的 ARM64 Windows runner(`windows-11-arm`,公共仓库免费)
# **原生构建**,而不是在 x64 runner 上交叉编译 —— 交叉编译需要 VS 的 ARM64 工具链,
# 且 `prepare-console.mjs` 的自检要真执行 node.exe(x64 机器跑不了 arm64 二进制),
# 会逼着引入「跳过自检」这类削弱验证的开关。原生构建则自检照跑、无特殊分支。
on:
push:
branches: [main]
paths:
- 'apps/console/**'
- 'apps/agent/**'
- 'packages/**'
- 'scripts/prepare-console.mjs'
- 'scripts/lib/bundle-agent.mjs'
- 'package.json'
- 'pnpm-lock.yaml'
- '.github/workflows/console-windows.yml'
pull_request:
branches: [main]
paths:
- 'apps/console/**'
- '.github/workflows/console-windows.yml'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
name: 控制台安装包 · ${{ matrix.arch }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 60
strategy:
# 一条架构挂了不要拖停另一条 —— 否则「arm64 失败」会掩盖「x64 到底成没成」
fail-fast: false
matrix:
include:
- arch: x64
runner: windows-latest
# x64 给两种安装形态:msi(企业批量部署)+ nsis(普通用户双击)
bundles: msi,nsis
pe: '0x8664'
- arch: arm64
runner: windows-11-arm
# arm64 只出 nsis:WiX 的 arm64 MSI 支持面较窄,不值得为它引入不确定性
bundles: nsis
pe: '0xaa64'
steps:
- name: 检出代码
uses: actions/checkout@v5
- name: 安装 pnpm
uses: pnpm/action-setup@v4
with:
version: 12
- name: 安装 Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: 安装 Rust 工具链
uses: dtolnay/rust-toolchain@stable
- name: 缓存 Rust 构建产物
uses: Swatinem/rust-cache@v2
with:
# 只缓存 src-tauri(控制台壳),不牵动仓库根的 Rust 工作区
workspaces: apps/console/src-tauri
- name: 安装依赖
run: pnpm install --frozen-lockfile
# ⚠️ 这条断言是必需项,不是「顺手检查」。
# ARM runner 的镜像里确实存在 x64 模拟的组件(社区实测 python 即为 AMD64),
# 一旦 Node 也是模拟版,process.arch 会是 x64 → prepare-console 会把 x64 的 node.exe
# 打进 arm64 安装包 → **装到真机上能启动、TLS 握手不完成**,且本机 CI 全绿。
# 故先断言「我到底在什么架构上编」,再往下走。
- name: 断言构建机架构与目标一致
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$arch = node -p "process.arch"
Write-Host "node process.arch = $arch"
Write-Host "runner = ${{ matrix.runner }}"
if ($arch -ne '${{ matrix.arch }}') {
throw "架构不符:期望 ${{ matrix.arch }},实际 $arch —— 打进包的 node.exe 会是错的架构"
}
- name: 构建 workspace 包(含被控端)
# agent 的 dist 是 prepare-console 打包被控端的前置产物,顺序不能调
run: pnpm --filter "./packages/*" --filter "@nodeagent/agent" build
# prepare-console 的前置检查要求 sidecar.mjs 已存在;它自己也会调 esbuild 打 agent.mjs
- name: 构建 sidecar 并准备随包资源(node + agent.mjs)
working-directory: apps/console
run: |
pnpm sidecar:build
pnpm prepare:node
# ⚠️ 交付物级断言(与 pack.mjs 的双架构校验同源思路):
# 不只看「脚本跑完了」,而是直接读最终要打进包的那个 node.exe 的 PE 头。
# 中间逻辑再自洽,也可能因为上游给了错架构的二进制而产出错包。
- name: 断言随包 node.exe 的 PE 架构
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$node = 'apps/console/src-tauri/runtime/node.exe'
if (-not (Test-Path $node)) { throw "缺少随包 Node 运行时:$node" }
$bytes = [IO.File]::ReadAllBytes((Resolve-Path $node))
$peOff = [BitConverter]::ToUInt32($bytes, 0x3C)
$machine = [BitConverter]::ToUInt16($bytes, $peOff + 4)
$actual = '0x{0:x4}' -f $machine
Write-Host "node.exe PE Machine = $actual(体积 $([math]::Round($bytes.Length/1MB,1)) MB)"
if ($actual -ne '${{ matrix.pe }}') {
throw "随包 node.exe 架构错误:期望 ${{ matrix.pe }},实际 $actual —— 严禁发布该包"
}
- name: 打包安装包(${{ matrix.bundles }})
working-directory: apps/console
# beforeBuildCommand(vite build)由 tauri 自己触发;资源靠 --config 注入,
# 默认 conf 里绝不出现 runtime 路径(bundle.resources 是编译期契约,声明了不存在的文件 build.rs 会直接失败)
run: pnpm tauri build --config src-tauri/tauri.bundle.gen.json --bundles ${{ matrix.bundles }}
- name: 验证产物自举
# 真 spawn 产物内的 node 去跑产物内的 sidecar,走一次 JSON-RPC
# (这在 macOS 上抓出过 strip 破坏签名导致 SIGKILL 的问题,Windows 侧同样值得钉住)
run: node apps/console/scripts/verify-bundle.mjs --runtime
# ⚠️ 断言的是**最终交付物**,不是中间产物。
#
# 这一版是本步的第二次实现。首版写成「安装包体积 > 50MB」,理由是
# 「含 Node 运行时(约 80MB),故不可能是个小文件」—— 这个推理把
# **未压缩的目录形态**(.app ≈ 92MB)当成了**压缩安装包的体积**。
# 2026-10-11 首个 run 两个 job 双双死在这一行:实测 msi 34.2MB、
# nsis 19.5~23.2MB(LZMA 下 node.exe 自身就能压到 1/3~1/4),阈值必然误杀。
#
# 更根本的问题是:**体积阈值属于「中间逻辑自洽」式断言** —— 包大
# 不等于 node.exe 真在里面,包小也不等于没在里面(压缩率会随版本漂)。
# 故改为**解开包、查内容**,一次钉死三件事:
# ① 三个随包资源(node.exe / sidecar.mjs / agent.mjs)必须都在 ——
# 缺任一,装到真机上就是「应用能开、但托不起被控端」;
# ② node.exe 的 PE Machine 必须等于目标架构 —— 最终产物的架构断言,
# 直接对治「arm64 包里装的是 x64 node.exe」那类只在真机暴露的事故;
# ③ node.exe 解压后体积必须 > 40MB —— 挡住「塞了个占位文件」。
# 附带收益:真装一遍本身就是安装器冒烟(比只读目录更强)。
- name: 断言安装包内容(随包 node.exe 齐全且架构正确)
shell: pwsh
# 静默安装理论上会自行结束;给个上限,免得万一卡住把 job 拖满 60 分钟
timeout-minutes: 8
run: |
$ErrorActionPreference = 'Stop'
$expect = '${{ matrix.pe }}'
$bundle = 'apps/console/src-tauri/target/release/bundle'
function Get-PeMachine([string]$path) {
$fs = [System.IO.File]::OpenRead($path)
try {
$br = [System.IO.BinaryReader]::new($fs)
$fs.Position = 0x3C
$peOff = $br.ReadInt32()
$fs.Position = $peOff
if ($br.ReadUInt32() -ne 0x00004550) { throw "不是 PE 文件:$path" }
return '0x{0:x4}' -f $br.ReadUInt16()
} finally { $fs.Dispose() }
}
# 在候选目录里找出真正装了 node.exe 的那个(安装位置随安装形态而异)
function Find-PayloadRoot([string[]]$candidates) {
foreach ($c in $candidates) {
if (-not (Test-Path $c)) { continue }
$hit = Get-ChildItem -Path $c -Recurse -Filter 'node.exe' -ErrorAction SilentlyContinue |
Select-Object -First 1
if ($hit) { return $c }
}
return $null
}
# 安装器可能「自己先退出、再由子进程继续装」,故给它一段收敛时间
function Wait-PayloadRoot([string[]]$candidates, [int]$seconds = 30) {
$deadline = (Get-Date).AddSeconds($seconds)
do {
$r = Find-PayloadRoot $candidates
if ($r) { return $r }
Start-Sleep -Seconds 1
} while ((Get-Date) -lt $deadline)
return $null
}
# 解开后的目录里必须三样齐全,且 node.exe 架构要对
function Assert-Payload([string]$root, [string]$label) {
$node = Get-ChildItem -Path $root -Recurse -Filter 'node.exe' -ErrorAction SilentlyContinue |
Select-Object -First 1
$side = Get-ChildItem -Path $root -Recurse -Filter 'sidecar.mjs' -ErrorAction SilentlyContinue |
Select-Object -First 1
$agent = Get-ChildItem -Path $root -Recurse -Filter 'agent.mjs' -ErrorAction SilentlyContinue |
Select-Object -First 1
$listing = (Get-ChildItem -Path $root -Recurse -File -ErrorAction SilentlyContinue |
Select-Object -First 40 -ExpandProperty FullName) -join "`n"
if (-not $side) { throw "$label 内未找到 sidecar.mjs`n实际内容:`n$listing" }
if (-not $agent) {
throw "$label 内未找到 agent.mjs —— 被控端未随包,装上后启动不了本机被控端`n实际内容:`n$listing"
}
if (-not $node) {
throw "$label 内未找到 node.exe —— 随包 Node 运行时未打入,装上后一启动就找不到 node`n实际内容:`n$listing"
}
$mb = [math]::Round($node.Length / 1MB, 1)
$machine = Get-PeMachine $node.FullName
Write-Host " node.exe $mb MB PE Machine=$machine"
Write-Host " sidecar.mjs $([math]::Round($side.Length / 1KB)) KB"
Write-Host " agent.mjs $([math]::Round($agent.Length / 1KB)) KB"
if ($mb -lt 40) { throw "$label 内 node.exe 仅 $mb MB —— 体积异常,疑似占位文件" }
if ($machine -ne $expect) {
throw "$label 内 node.exe 架构错误:期望 $expect,实际 $machine —— 严禁发布该包"
}
}
$msis = @(Get-ChildItem -Path "$bundle/msi/*.msi" -ErrorAction SilentlyContinue)
$nsis = @(Get-ChildItem -Path "$bundle/nsis/*.exe" -ErrorAction SilentlyContinue)
if (($msis.Count + $nsis.Count) -eq 0) { throw "未产出任何安装包($bundle 下为空)" }
$checked = 0
foreach ($m in $msis) {
Write-Host "── MSI:$($m.Name) $([math]::Round($m.Length / 1MB, 1)) MB"
# 管理安装(/a)= 只解包不注册,正合此处用途
$dest = Join-Path $PWD 'pkg-extract/msi'
Remove-Item -Recurse -Force $dest -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $dest | Out-Null
$log = Join-Path $PWD 'msi-extract.log'
# ⚠️ 必须用 Start-Process -Wait -PassThru 取退出码,**不能**用 `& msiexec.exe`:
# msiexec 是 GUI 子系统程序,脚本里 `&` 调它**既不等待、也不设 `$LASTEXITCODE`**
# (实测:整个步骤 0.8 秒就跑到下一行,`$LASTEXITCODE` 为空 → `$null -ne 0` 判真 → 当场抛错)。
# 参数拼成**单个字符串**传递,避免 -ArgumentList 数组被 PowerShell 自动加引号而破坏 /D= 语义。
$argLine = "/a `"$($m.FullName)`" /qn TARGETDIR=`"$dest`" /l*v `"$log`""
$p = Start-Process -FilePath 'msiexec.exe' -ArgumentList $argLine -Wait -PassThru
Write-Host " msiexec 退出码 = $($p.ExitCode)"
# 3010 = 成功但需重启,不是失败
if ($p.ExitCode -ne 0 -and $p.ExitCode -ne 3010) {
$tail = (Get-Content $log -Tail 30 -ErrorAction SilentlyContinue) -join "`n"
throw "msiexec /a 失败(退出码 $($p.ExitCode)):$($m.Name)`n$tail"
}
$root = Wait-PayloadRoot @($dest)
if (-not $root) { throw "MSI 管理安装后未在 $dest 下找到 node.exe" }
Assert-Payload $root $m.Name
$checked++
}
foreach ($n in $nsis) {
Write-Host "── NSIS:$($n.Name) $([math]::Round($n.Length / 1MB, 1)) MB"
# /S 静默、/D 指定目录:两者都由 NSIS **运行时自身**在 .onInit 之前解析
# (核心参数,不依赖 Tauri 模板),故对任何 NSIS 安装包都成立。
# /D= 必须是最后一个参数且**不加引号**,故这里用仓库目录下的相对路径
# (不含空格),而不是通常带空格的用户目录。
$dest = Join-Path $PWD 'pkg-extract/nsis'
Remove-Item -Recurse -Force $dest -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $dest | Out-Null
# 同 MSI:安装器也是 GUI 子系统程序,必须 Start-Process -Wait 才拿得到退出码。
# 单个字符串传参 → `/D=` 保持原样不加引号(NSIS 的硬要求)。
$p = Start-Process -FilePath $n.FullName -ArgumentList "/S /D=$dest" -Wait -PassThru
Write-Host " 安装器退出码 = $($p.ExitCode)"
if ($p.ExitCode -ne 0 -and $p.ExitCode -ne 3010) {
throw "NSIS 静默安装失败(退出码 $($p.ExitCode)):$($n.Name)"
}
# /D= 理论上一律生效,但若模板在 .onInit 里改写了 $INSTDIR 就未必;
# 故把默认安装位置一并列为候选,避免「装成功了却因为找不到目录而误报」。
$root = Wait-PayloadRoot @(
$dest,
(Join-Path $env:LOCALAPPDATA 'nodeagent-console'),
(Join-Path $env:LOCALAPPDATA 'Programs/nodeagent-console'),
(Join-Path $env:ProgramFiles 'nodeagent-console')
)
if (-not $root) {
throw "NSIS 静默安装后未找到随包 node.exe —— 已查:$dest 与默认安装位置"
}
Write-Host " 安装位置:$root"
Assert-Payload $root $n.Name
$checked++
}
Write-Host ""
Write-Host "✓ 已核对 $checked 个安装包:node.exe / sidecar.mjs / agent.mjs 齐全,node.exe 架构 = $expect"
- name: 上传安装包
uses: actions/upload-artifact@v4
with:
name: nodeagent-console-windows-${{ matrix.arch }}
path: |
apps/console/src-tauri/target/release/bundle/msi/*.msi
apps/console/src-tauri/target/release/bundle/nsis/*.exe
# 一条架构没产出就报错,避免「jobs 全绿但产物不全」
if-no-files-found: error
retention-days: 30
+120
View File
@@ -0,0 +1,120 @@
name: Rust Shell
# 2026-10:原 crates/nodeagent-client(Rust 版协议客户端)已退役 ——
# 协议实现改由 Node sidecar 承担(apps/console/sidecar/),避免两套实现静默漂移。
# 本工作流随之改为验证 **console 的 Rust 壳**(sidecar 管理 + command 转发)。
#
# 它的单测是**真跑** Node sidecar 子进程的(见 apps/console/src-tauri/src/sidecar.rs),
# 不依赖 Tauri GUI,因此能在 Linux 快车道上跑。
on:
push:
branches: [main]
paths:
- 'apps/console/src-tauri/**'
- 'apps/console/sidecar/**'
- 'scripts/prepare-console.mjs'
- 'scripts/lib/bundle-agent.mjs'
- 'apps/agent/**'
- '.github/workflows/rust.yml'
pull_request:
paths:
- 'apps/console/src-tauri/**'
- 'apps/console/sidecar/**'
- 'scripts/prepare-console.mjs'
- 'scripts/lib/bundle-agent.mjs'
- 'apps/agent/**'
- '.github/workflows/rust.yml'
workflow_dispatch:
env:
CARGO_TERM_COLOR: always
jobs:
test:
name: Rust 壳单测(真跑 sidecar)
runs-on: ubuntu-latest
steps:
- name: 检出代码
uses: actions/checkout@v5
# 版本**锁死**而不跟 stable 漂 —— 否则「本地全绿」与「CI 绿」会因 lint 集合随工具链
# 变化而分叉(本仓库已因同类原因吃过 8 轮 Windows CI 的苦头)。
- name: 安装 Rust 工具链(含 clippy)
uses: dtolnay/rust-toolchain@master
with:
toolchain: stable
components: clippy
# Tauri 壳在 Linux 上编译需要这些系统库(webkit2gtk 等)。sidecar 单测本身
# 不需要 GUI,但整个 crate 要能编过。
- name: 安装 Tauri 系统依赖
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev \
librsvg2-dev patchelf libgtk-3-dev
- name: 安装 Node(sidecar 单测要真跑它)
uses: actions/setup-node@v4
with:
node-version: '22'
- name: 安装 pnpm 与依赖(sidecar 需 @nodeagent/client)
uses: pnpm/action-setup@v4
with:
version: 9
- run: pnpm install --frozen-lockfile
# ⚠️ 顺序有依赖:prepare:node 现在**还要 esbuild 打包被控端**(控制台随包分发
# agent.mjs,用于托起本机被控端)。故 @nodeagent/agent 的 dist 必须先构建出来,
# 否则 prepare 阶段会以「未找到被控端构建产物」失败。
# (本项目已因「新步骤漏排前置产物」在 Windows CI 上红过一次,此处显式写全。)
- name: 构建 workspace 包(sidecar 与 agent 的 dist 都是前置产物)
run: pnpm --filter "./packages/*" --filter "@nodeagent/agent" build
# ⚠️ tauri-build 在**编译期**校验 tauri.conf.json 里 resources 的文件存在性,
# 故必须先产出 sidecar.mjs,否则 clippy/test 会在 build.rs 阶段就失败。
- name: 构建 sidecar(resources 引用的产物)
run: pnpm --filter nodeagent-console sidecar:build
# 随包分发的 Node 运行时 + 被控端 agent.mjs 由该脚本准备;它同时产出
# 「构建期注入片段」。在此真跑一遍:既验证脚本本身可用(含两份产物自检:
# node 必须真能执行、agent.mjs 必须含被控端标识),也验证注入片段声明就绪。
# 注意 Linux 上不会走 strip 分支(那是 macOS 专属,见脚本内注释),
# 故「strip 后必须 adhoc 重签」这条由 macOS 本机实测覆盖,CI 不重复验证。
- name: 准备 Node 运行时 + 被控端(验证 prepare-console.mjs)
run: pnpm --filter nodeagent-console prepare:node
- name: 注入片段应声明三份资源(node + sidecar + agent)
run: |
node -e '
const g = require("./apps/console/src-tauri/tauri.bundle.gen.json");
const r = g.bundle.resources;
const keys = Object.keys(r);
if (keys.length !== 3) { console.error("资源项应为 3,实际", keys); process.exit(1); }
const vals = Object.values(r);
for (const need of ["sidecar.mjs", "agent.mjs"]) {
if (!vals.includes(need)) { console.error("缺少资源", need, vals); process.exit(1); }
}
if (!vals.includes("node") && !vals.includes("node.exe")) {
console.error("缺少 node 运行时", vals); process.exit(1);
}
console.log("✓", JSON.stringify(r));
'
# 更强的一道:不是「文件在不在」,而是「产物能不能自举」——
# 真 spawn 打包内的 node 跑 sidecar 并走一次 JSON-RPC 握手,
# 并用打包内的 node 解析一次 agent.mjs(截断/损坏会在此暴露)。
# 这类问题只在产物中暴露(源码与单测都正常)。
- name: 验证运行时 + sidecar + agent 可自举
run: node apps/console/scripts/verify-bundle.mjs --runtime
- name: 缓存 cargo 构建产物
uses: Swatinem/rust-cache@v2
with:
workspaces: apps/console/src-tauri
- name: Clippy(告警即失败)
run: cargo clippy --manifest-path apps/console/src-tauri/Cargo.toml --all-targets -- -D warnings
- name: 单测(真跑 Node sidecar 进程)
run: cargo test --manifest-path apps/console/src-tauri/Cargo.toml
+109 -9
View File
@@ -27,7 +27,7 @@ jobs:
steps:
- name: 检出代码
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: 安装 pnpm
uses: pnpm/action-setup@v4
@@ -49,31 +49,129 @@ jobs:
- name: 类型检查
run: pnpm typecheck
# 控制台「随包分发的 Node 运行时 + 被控端 agent.mjs」在 Windows 上的分支
# 此前零覆盖(准备脚本本机只在 macOS 上跑过)。此处真跑:复制 node.exe(无 strip
# 分支)→ 自检可执行 → 再验证它与 sidecar 能否走通一次 JSON-RPC 自举,
# 并用同一个 node 解析 agent.mjs。
# ⚠️ 必须先 sidecar:build —— prepare-console.mjs 的前置检查要求 sidecar.mjs 存在
# (首次推送时漏了这步,CI 在 Windows 上当场报「未找到 sidecar/dist/sidecar.mjs」)。
# agent.mjs 的前置是 `pnpm -r build`(上面的「构建」步骤已覆盖)。
- name: 控制台 Node 运行时 + 被控端(Windows 分支)与自举验证
run: |
pnpm --filter nodeagent-console sidecar:build
pnpm --filter nodeagent-console prepare:node
node apps/console/scripts/verify-bundle.mjs --runtime
- name: 单元测试 — 协议纯函数 / 清单守护 / 脚本生成
# 来自 ci/workflow-unit-tests 分支(4d73d6e)的实质贡献;该分支基于老 main,
# 直接 merge 会回退上万行,故只取其提交内容手工并入。
run: node --test "tests/unit/**/*.test.mjs"
- name: E2E — 协议与跨平台能力
run: node tests/e2e/run.mjs
- name: E2E — Windows 专属能力(服务 / 软件 / winget)
run: node tests/e2e/windows.mjs
# ⚠️ 这一步刻意用 **powershell(Windows PowerShell 5.1)** 而非 pwsh。
# 5.1 读无 BOM 的 .ps1 会按 ANSI/GBK 解析,中文注释会破坏字符串边界 →
# 整份脚本 ParseError(真机 2026-10-04 踩过:install.ps1 报「","后面缺少表达式」)。
# 之前的冒烟全用 pwsh(7+ 认无 BOM UTF-8),所以 CI 绿、真机炸。
# 这里只做「解析 + 首行 help」检查:5.1 能否读懂,就是真机的核心约束。
# ⚠️ 必须用 shell: powershell(runner 上的 powershell 即 Windows PowerShell 5.1)。
# 不要用 shell: cmd 再套 powershell -Command "..." —— 多行带引号经 cmd 转义会全乱
# (实测:Missing closing '}' / '$errs' is not recognized,是步骤自身坏,不是脚本坏)。
- name: 冒烟 — 脚本可被 Windows PowerShell 5.1 解析(BOM 守卫)
shell: powershell
run: |
$ErrorActionPreference = 'Stop'
foreach ($f in @('scripts\install.ps1', 'scripts\uninstall.ps1', 'scripts\rescue-network.ps1')) {
$errs = $null
[void][System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path $f).Path, [ref]$null, [ref]$errs)
if ($errs -and $errs.Count -gt 0) { throw ('5.1 parse FAILED: ' + $f + ' -> ' + $errs[0].Message) }
Write-Host ('OK 5.1 parse: ' + $f)
}
- name: 冒烟 — install.ps1 安装链路
shell: pwsh
shell: powershell
run: |
$ErrorActionPreference = "Stop"
./scripts/install.ps1 -Port 18769 -NodeId ci_win
$task = Get-ScheduledTask -TaskName "nodeagent" -ErrorAction Stop
Write-Host "计划任务状态: $($task.State)"
Write-Host "Scheduled task state: $($task.State)"
$cfg = Get-Content "$env:USERPROFILE\.nodeagent\agent.json" -Raw | ConvertFrom-Json
if ($cfg.port -ne 18769) { throw "端口配置错误: $($cfg.port)" }
if (-not $cfg.key) { throw "预共享密钥缺失" }
if (-not $cfg.tls) { throw "TLS 默认应开启" }
if ($cfg.port -ne 18769) { throw "port mismatch: $($cfg.port)" }
if (-not $cfg.key) { throw "PSK missing in agent.json" }
if (-not $cfg.tls) { throw "TLS should be on by default" }
$rule = Get-NetFirewallRule -DisplayName "nodeagent (TCP 18769)" -ErrorAction Stop
if (-not $rule) { throw "防火墙规则未创建" }
if (-not $rule) { throw "Firewall rule" }
Write-Host "OK — 计划任务 / 配置 / 防火墙均已就位"
Write-Host "OK scheduled task / config / firewallall in place"
# 一键入口冒烟:真跑 install.cmd / control.cmd。
# 目的不是「再装一遍」,而是证明双击路径可用 —— 这些脚本是用户唯一的
# 上手入口,语法或编码一旦坏了,只有真机 cmd.exe 才测得出来。
# 端口用 18770 避开前一步的 18770 之外的安装,便于分辨是哪一步装的。
- name: 冒烟 — install.cmd 双击链路(含自提权与 PSK 读取)
shell: cmd
run: call scripts\install.cmd 18770
env:
CI: "true"
- name: 冒烟 — control.cmd 状态查询
shell: cmd
run: scripts\control.cmd 18770 < nul
env:
CI: "true"
- name: 冒烟 — 产物包结构与编码
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
pnpm pack:win
$zip = "release/nodeagent-win-x64.zip"
if (-not (Test-Path $zip)) { throw "zip not produced: $zip" }
Add-Type -AssemblyName System.IO.Compression.FileSystem
$z = [IO.Compression.ZipFile]::OpenRead((Resolve-Path $zip))
try {
$names = $z.Entries.Name
# 必须随包分发
foreach ($need in @('install.cmd','control.cmd','install.ps1','control.ps1','README.txt','agent.mjs','node.exe')) {
if ($names -notcontains $need) { throw "missing in package: $need" }
}
# 必须**不**在包内:PSK.txt 是现生成给本机的,打进公开资产等于泄露密钥
# (eaca9e0 起已从 zip 剔除;此处反向断言守住该性质)
foreach ($forbidden in @('PSK.txt','*.key','*.pem','certs')) {
if ($names -contains $forbidden) { throw "package must NOT contain: $forbidden" }
}
if ($names -match 'PSK\.txt') { throw "package leaks PSK.txt" }
foreach ($e in $z.Entries) {
$s = $e.Open(); $buf = New-Object byte[] 3
[void]$s.Read($buf, 0, 3); $s.Close()
$hasBom = ($buf[0] -eq 0xEF -and $buf[1] -eq 0xBB -and $buf[2] -eq 0xBF)
if ($e.Name -like '*.ps1' -and -not $hasBom) { throw "$($e.Name) lost its BOM in package" }
if ($e.Name -like '*.cmd' -and $hasBom) { throw "$($e.Name) must not have a BOM" }
}
Write-Host "OK - package complete; .ps1 has BOM / .cmd has none; no key shipped"
} finally { $z.Dispose() }
- name: 冒烟 — 双架构包(node.exe PE 架构校验)
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
# 真机教训(Parallels 27 + Win11 ARM):曾出现 arm64 包里装的其实是 x64 的 node.exe
# —— 两份包的 node.exe 字节完全相同。x64 node 在 ARM Windows 上**能 listen,
# 但 TLS 握手不完成**(客户端只见超时)→ 被控端实际不可用,且 macOS 侧 100% 测不出。
# 根因是 pack.mjs 的运行时缓存文件名不含架构。这里直接验**最终产物**的 PE 架构,
# 比只验脚本内部逻辑更靠近交付物(缓存串包 / 手工塞错文件都会被这一步拦下)。
# 校验实现与发版脚本共用(scripts/lib/zip-pe-arch.mjs)—— 避免两套实现各自漂移。
foreach ($arch in @('x64','arm64')) {
node scripts/pack.mjs --arch $arch
}
foreach ($arch in @('x64','arm64')) {
node scripts/lib/zip-pe-arch.mjs "release/nodeagent-win-$arch.zip" $arch
if ($LASTEXITCODE -ne 0) { throw "node.exe 架构校验失败($arch)—— 严禁发布该包" }
}
- name: 冒烟 — 被控端监听
shell: pwsh
run: |
@@ -108,7 +206,9 @@ jobs:
- name: 清理
if: always()
shell: pwsh
# 清理失败不掩盖真实结论(前置挂了会连带这里报错)
continue-on-error: true
shell: powershell
run: |
Get-Process node -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
./scripts/uninstall.ps1 -Port 18769 -Purge
+14
View File
@@ -40,3 +40,17 @@ release/
# 重装备份数据(含设备软件清单,不入库)
../win-backup/
win-backup/
# Rust 构建产物
# ⚠️ 2026-10-07:crates/ 此前整体被忽略,target/ 是「顺带」没进库的;crates/ 正式入库后
# 必须显式忽略,否则 4GB 级的 cargo target 会被 add -A 卷进提交。
/crates/target/
# 密钥材料(路线图 §6.2):被控端数据目录 keys/、ed25519/TLS 私钥
keys/
*.key
*.pem
!*.pub
# 本地 agent 工作数据(memory / automations 等,属本机状态,不入库)
.workbuddy/
+563
View File
@@ -0,0 +1,563 @@
# 变更日志
本文件记录 nodeagent 的显著变更。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),
版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
---
## [未发布]
> 主题:**控制台「去命令行化」** —— 双击应用即用,启停/重启/更新/日志/托盘常驻/开机自启全部在界面里完成。
> 目标原文:「不再打开命令行窗口,运行应用之后,有启动、停止、重启、检查更新等功能,
> 有日志功能,可以后台运行和开机自启等等功能」。
>
> 末段另含两轮纯界面调整:**一律不使用图标**(emoji 在部分渲染器里退化成豆腐块)、
> **配色收敛为「简约白 / 高级灰」两套中性灰阶主题**。
### 新增 —— 本机被控端托管(`apps/console/src-tauri/src/agent.rs`,约 560 行)
- **新增「本机服务」页(`/local`,默认首页)与「服务日志」页(`/logs`)**。
默认路由由 `/nodes` 改为 `/local`:打开应用先看到「本机被控端」的启停面板,
而不是先要求用户填一个连接地址 —— 这是「运行应用之后就有启动/停止/重启」的落点。
- **进程生命周期管理**:`AgentManager`(`STOPPED / STARTING / RUNNING / STOPPING / CRASHED` 五态)。
启动、停止、重启均走界面按钮;`stop()` 在 Windows 走 `taskkill /PID <pid> /T /F`(连子进程树一起收),
POSIX 走 `child.kill()`,随后**轮询确认端口已释放**才算停干净。
**重复启动被拒绝**(不会起出两个进程),**异常退出自动重启**(上限 5 次,超限停在 `CRASHED` 并保留原因)。
- **日志功能**:同时捕获被控端 `stdout`/`stderr` 两路,环形缓冲 4000 行。
每行带一个**单调递增的 `seq`** —— 这是刻意设计:环形缓冲淘汰旧行后,若用「位置下标」做游标,
前端的增量拉取会静默错位;独立 `AtomicU64` 发号可跨淘汰保持连续。
界面侧:实时事件推送、关键字过滤、三流(stdout/stderr/system)复选框、自动滚动、
暂停接收、重新拉取、滚到底部、导出文本、清空。
- **界面化自更新**:`check_update` / `download` / `apply_update` 三个能力。
- `check_update` 读 GitHub Release 的**稳定别名** `latest.json`(而非 API),**不占用 API 配额**、不受限流影响。
- `download` 的 `--sha256` 是**必填**(不校验哈希等于开放远程代码执行),校验不通过**不落盘**,写后再复核一次。
- `apply_update` 的替换顺序:下载到 `runtime/agent.mjs.staged-<pid>` → 复用已有被控端停服 →
备份现有脚本 → **原子替换** → 写 `agent.seed.json` 版本 → 按需回启。
任一步失败都停在替换之前,不会留下半截产物。
- **开机自启**(`autostart.rs`):macOS 写 LaunchAgent `~/Library/LaunchAgents/cn.jkooe.nodeagent-console.plist`
(`RunAtLoad`;**刻意不用 `KeepAlive`**,否则会与控制台的崩溃自愈形成两层守护互相打架);
Windows 用计划任务(**登录用户 + `Interactive` + `Highest`**)。
一个易错点:macOS 的 `bootstrap` 目标 `gui/<uid>` 里的 uid **不能硬编码 501**(那只是首个本地用户的常见值),
改为运行时 `id -u` 现取。
- **后台常驻(托盘)**:托盘图标 + 右键菜单(显示主窗口 / 启动 / 停止 / 重启 / 退出);
左键单击图标唤出窗口。窗口 `CloseRequested` → `prevent_close()` + `hide()`,
**关窗不退出**,与「后台运行」的要求一致;真正退出走托盘菜单。
- **`--agent-only` 仅服务模式**:开机自启拉起的是**控制台自身**(GUI 子系统程序,不带控制台窗口),
进入该模式后不显示窗口、只把被控端托起来;而不是去直接拉起裸 `node`。
### 新增 —— 消灭命令行窗口(三条措施,缺一不可)
| # | 措施 | 位置 | 不加会怎样 |
|---|---|---|---|
| 1 | 控制台是 GUI 子系统程序,自身不弹控制台 | Tauri 默认 | 打开应用先弹黑窗 |
| 2 | spawn `node.exe` 时加 `CREATE_NO_WINDOW`(`0x0800_0000`) | `agent.rs` 的 `creation_flags` | **每次启动被控端都闪一个黑窗** —— 正是皇上要求消灭的现象 |
| 3 | 开机自启拉起控制台而非裸 `node` | `autostart.rs` + `--agent-only` | 开机时黑窗一闪而过 |
### 新增 —— 契约守卫(`tests/unit/console-commands.test.mjs`,8 用例)
把「改了名字两边不同步、但不报错」的三类**静默失效**钉死,避免「界面点了没反应、控制台无异常」:
| # | 守卫 | 抓什么 |
|---|---|---|
| 1 | 前端每个 `invoke("x")` 都已在 Rust `generate_handler!` 注册 | 前端调了个不存在的命令 → 运行时才 `not found` |
| 2 | Rust 注册的命令都被前端使用 | 死命令堆积(改名后残留的旧命令) |
| 3 | `emit("na-*")` ⇄ `listen("na-*")` 双向一致 | 事件名不匹配 → **界面永远不刷新,且无任何报错** |
| 4 | `tauri.bundle.gen.json` 资源目标名 ⇄ Rust `resolve_*` 字面量 | 映射改名 → 生产包内找不到 sidecar/node/agent |
| 5 | sidecar 方法名 ⇄ Rust `.request("x")` | 跨语言调用错名 |
| 6 | 新页面已注册路由 + 默认路由指向 `/local` + 懒加载的 view 文件真实存在 | 「写了页面但进不去」/循环依赖导致的路由表推导失败 |
| 7 | 自定义样式用到的 `var(--na-*)` 都由 `cssVars()` 定义 | 拼错变量名**不报错**,颜色只是静默落回继承值 —— 类型检查、构建、运行全都不吭声 |
| 8 | `index.html` 的首帧兜底色 ⇄ 调色板的 `bg` | 首帧底色是全应用**唯一允许的色值重复处**;允许重复就必须有守卫 |
> **已注入故障实测会红**:① 把 `agent_start` 改成 `agent_startX`、`na-agent-state` 改成 `na-agent-statee`
> → 当场 2 条失败;② 把某处 `var(--na-sunken)` 拼成 `var(--na-sunkenTypo)`(含大写)与 `var(--na-sunkenx)`
> → 第 7 条均失败;③ 把 `index.html` 的 `#f4f4f5` 改回 `#ffffff` → 第 8 条失败。
> 全部复原后 `#pass = 8 / #fail = 0`,文件逐字节还原。未验证过会红的守卫等于没有守卫。
>
> 第 7 条的正则刻意放宽到 `[^)\s,;]+` 而非 `[a-z0-9-]+` —— 后者遇到含大写的拼错会在中途停下导致
> **整个模式不匹配**,于是「写错的变量名」反而逃过检查。这是注入故障时才发现的守卫自身漏报。
### 变更 —— 打包与单一实现
- **被控端 esbuild 配置抽为 `scripts/lib/bundle-agent.mjs`**(`repoRoot` / `repoVersion` / `gitShortCommit` / `bundleAgent`),
`scripts/pack.mjs`(被控端发布包)与 `scripts/prepare-console.mjs`(控制台资源)**共用同一份**。
此前 `pack.mjs` 里内联了一份配置,控制台若再内联一份就是**两套真相**(改一处漏一处,只在产物里暴露)。
该模块刻意**不注入构建时间** —— 否则每次打包字节都不同,产物指纹比对会失真。
- **控制台随包分发被控端脚本**:资源由 2 份(`sidecar.mjs` / `node`)增至 **3 份**(+ `agent.mjs`)。
带来的效果:用户装完控制台,**本机被控端已经就绪**,不需要另外手工安装一次才能点「启动」。
- **播种防降级**:`<数据目录>/runtime/agent.seed.json` 记录版本,
只有「**随包版本 > 已安装版本**」才覆盖数据目录里的脚本。
若写成「同版本才跳过」,则用户被控端自更新到更新版本后,下次开控制台会被**静默降回旧版**。
版本比较按**数值逐段**(`2.0.10 > 2.0.9`),字符串比较会误判。
- `verify-bundle.mjs` 增加 `agent.mjs` 存在性检查 + 用打包内的 node 跑 `--check agent.mjs`。
### 变更 —— CI
- `.github/workflows/rust.yml`:资源数断言 2 → **3**;`paths` 增加 `scripts/lib/bundle-agent.mjs` 与 `apps/agent/**`;
构建步骤补 `@nodeagent/agent`(`prepare:node` 现在依赖它的 dist,
**新步骤必须逐条核对前置产物由哪一步生成、顺序是否在其后** —— 本地验证时产物早就在,会把这层依赖完全掩盖)。
- `.github/workflows/windows-e2e.yml`:步骤名与注释同步为含 `agent.mjs` 的说明。
### 新增 —— Windows 端控制台安装包(`.github/workflows/console-windows.yml`)
控制台此前**只在 macOS 上出过包**(`.app` / `.dmg`),而它的主战场恰恰是
「在 Windows 上管控其他机器」。本轮补上 Windows 构建:**双架构矩阵** ——
`windows-latest` 出 x64 `.msi` + NSIS `-setup.exe`,`windows-11-arm` 出 arm64 NSIS `-setup.exe`。
- **为什么必须双架构**:与 v2.0.2 那次事故同源 —— 包里装着错架构的 `node.exe` 时,
ARM Windows 上「能启动、能 listen,但 TLS 握手不完成」,而 macOS 侧 100% 复现不出。
控制台更甚:它内含 node 运行时,还要用这个 node **托起本机被控端**,即自己就是 TLS 服务端。
- **为什么用原生 ARM runner 而非交叉编译**:`prepare-console.mjs` 的自检要**真执行 `node.exe`**,
x64 机器跑不了 arm64 二进制;交叉编译会逼着引入「跳过自检」这类**削弱验证**的开关 ——
而产出错架构运行时的时候,自检正是唯一能拦住它的那道闸。
- **`cfg!(windows)` 分支第一次被编译器看到**:macOS 上的 `cargo check` **不会编译 `cfg(windows)` 代码**,
故 `taskkill` 收进程树、PowerShell 计划任务、`node.exe` 路径等分支此前从未产出过 Windows 产物。
- **实测产物**:x64 `.msi` **34.2 MiB** / NSIS **23.2 MiB**;arm64 NSIS **19.5 MiB**(均低于 40MB 目标)。
`if-no-files-found: error` + `fail-fast: false`:一条架构没产出就报错,同时一条挂了不掩盖另一条的结果。
- **尚未接入 `scripts/release.mjs`**:安装包目前只作为 CI artifact 产出,未随 Release 发布。
> **首个 run 两个 job 全红,但错的不是构建,是断言。**(run `38151366534`)
> 两个 job 的失败点完全一致,而日志显示构建链路**一路全绿**:构建机架构(arm64 runner 上
> `process.arch = arm64`,原生属实)、随包 `node.exe` 的 PE Machine(`0x8664` / `0xaa64`)、
> `tauri build`、产物自举(打包内 node v22.23.3 真跑起打包内 sidecar)四步全 success。
> 死掉的是本步自己写的守卫 —— 原断言「安装包体积 > 50MB」,理由是「含 Node 运行时约 80MB」,
> 这**把未压缩的目录形态(`.app` ≈ 92 MiB)当成了压缩安装包的体积**:
> LZMA 下 `node.exe` 自身能压到 1/3~1/4,实测 `.msi` 34.2 MiB、NSIS 19.5~23.2 MiB,阈值必然误杀。
>
> 更根本的问题是:**体积阈值属于「中间逻辑自洽」式断言**。包大不等于 `node.exe` 真在里面
> (可能打进的是别的大文件),包小也不等于没在里面(压缩率会随 Node 版本漂移)。
> 判据必须落在**产物内容**上,不能落在**间接指标**上。
>
> 改为**解开安装包查内容**(`.msi` 走 `msiexec /a` 管理安装、NSIS 走 `/S /D=` 静默安装,
> 两者都是各自格式的核心能力,不引任何外部依赖),一次钉死三件事:
> ① `node.exe` / `sidecar.mjs` / `agent.mjs` **三样随包资源必须都在**(缺任一,真机上就是
> 「应用能开、但托不起被控端」);② `node.exe` 的 **PE Machine = 目标架构**(最终交付物级架构断言);
> ③ `node.exe` 解压后 **> 40MB**(体积在这是**正确用法**:内容已验明后的自明性检查,
> 而不是「运行时在不在」的**替代判据**)。附带收益:**真装一遍本身就是安装器冒烟**。
> 另给该步加 `timeout-minutes: 8`,避免静默安装万一卡住把 job 拖满 60 分钟。
>
> **第二个 run(`38152850819`)两 job 仍同点红,但已是另一处原因,且同样是静默失效**:
> 日志里 `msiexec /a 失败(退出码 )` —— `$LASTEXITCODE` 是**空的**,而整步从开始到抛错
> 只过了 **0.8 秒**。根因是 `msiexec.exe` 与 NSIS 安装包都是 **GUI 子系统程序**,
> PowerShell 用 `&` 调用时**既不等待其结束、也不设置 `$LASTEXITCODE`**
> (`$null -ne 0` 判真 → 安装器还没跑完就抛错,报错读起来像「安装失败」,实为「压根没等」);
> arm64 那条没有 MSI、直奔 NSIS,失败形态一模一样,正是同一根因的第二个样本。
> 修法:改 `Start-Process -Wait -PassThru` 读 `.ExitCode`;参数拼成**单个字符串**传,
> 避免数组被自动加引号而破坏 NSIS `/D=` 的「原样、不加引号、且居末」硬要求;
> 加 `Wait-PayloadRoot`(30 秒收敛)防安装器「自己先退出、子进程继续装」的竞态误报。
>
> **第三个 run(`38154282653`)双绿**,断言给出产物实证 —— x64 `.msi`/`-setup.exe` 内均解出
> **`node.exe` 82.9 MB / PE `0x8664`**、arm64 `-setup.exe` 内解出 **`node.exe` 73.5 MB / PE `0xaa64`**,
> 三样随包资源齐全(sidecar 232 KB、agent 1060 KB)。
> 这一步证明的恰是体积断言永远无法区分的那件事:**19.5MB 的压缩包里真的装着 73.5MB 的 arm64 运行时**。
### 变更 —— 界面一律不使用图标
- **去掉全部 emoji 图标**:路由表 `meta.icon` 字段整体移除,侧栏导航由「图标 + 标题」改为**纯文字**。
触发原因是一次真实的渲染事故:emoji 图标在非系统字体栈的渲染器里退化成**豆腐块**(截图对比可见),
而「哪套字体栈一定带 emoji」跨平台并不成立 —— 与其赌字形,不如不用。
- **`n-alert` / `n-empty` / `n-popconfirm` 的内置图形一并关闭**:这三个组件的 `showIcon` 默认均为 `true`,
故 16 处 `n-empty` 的空态插画、7 处 `n-alert` 的类型图标、3 处 `n-popconfirm` 的询问图标全部消失,
只剩文字与配色(严重程度仍由 `type` 的底色/边框区分,信息不丢)。
`n-empty` 是**空态里的插画**(比图标更「图形」),在 9 个页面都有,是最容易漏掉的一类。
- **夹在文案里的符号也去掉**:`TerminalView` 的「⚠ 高权限操作,请谨慎」保留纯文字。
- **清理随包的模板残留资产**:删掉 `public/vite.svg`、`public/tauri.svg`、`src/assets/vue.svg`
与 `index.html` 的 favicon 声明。`public/` 下的文件会被整体复制进产物,
此前等于把 Vite / Tauri 的 logo 一起发了出去;`index.html` 的标题也还是模板默认值
(原生窗口标题取自 `tauri.conf.json`,不受影响),本次一并改齐。
> **边界(以下不算「界面图标」,未动)**:① 托盘图标 —— 菜单栏没有图标就点不到,
> 关窗后应用将无法寻回,属**功能必需**;② 被控端 CLI 自身打印的文本标记(`⚠️`/`ℹ️`/`✅`,
> 见 `apps/agent/src/index.ts`、`packages/client/src/keys.ts`、`apps/agent/src/metrics.ts`)——
> 它们是**程序输出内容**,会原样出现在「服务日志」页里,但不是界面元素;
> ③ 控件的结构性部件(下拉箭头、折叠箭头、输入框清除钮、表格排序箭头)。
### 变更 —— 配色收敛为两套中性灰阶主题
- **两套可切换主题:`简约白`(浅)与 `高级灰`(深)**,侧栏底部一行切换,选择存 `localStorage`。
两套都**只用中性灰阶**:去掉原先的青色品牌字(`#63e2b7`)与 naive-ui 默认的绿色主色,
主按钮 / 开关激活态 / 进度条 / 勾选框统一改吃「主色位 = 中性灰」这一个值。
- 白主题的**页底刻意不是 `#ffffff`**(`#f4f4f5`):卡片本身是白的,两者同色就只能靠 1px 分线撑层次,
整页发「糊」;压一档浅灰后层次为「页底 < 侧栏 < 卡片」,且仍是白为主。
- **语义色不是去掉,而是降饱和**(灰绿 / 灰琥珀 / 灰红 / 灰蓝)。
这是运维工具,`运行中 / 出错` 必须一眼可辨,全部灰化会导致误读;故它们只出现在徽章与告警这类小面积上,
底色观感仍是「白 + 灰」。严重程度在关闭图标后本就更依赖 `type` 的底色/边框。
- **色值只有一份定义**(`src/theme/index.ts` 的 `Palette`):既生成 naive-ui 的 `themeOverrides`,
又由 `App.vue` 在运行期注入成 `--na-*` CSS 变量供自定义样式使用。
原因是若「TS 调色板」与「CSS 写死的色值」各存一份,换主题时会**一半新一半旧且不报错、类型也抓不到**
—— 这正是本轮要消灭的失效模式。
- **清掉 17 处写死的色值**(散在 8 个文件:日志三流着色、代码块/预览底色、节点选中边框、历史悬停等),
全部改为 `var(--na-*)`。复扫后自定义样式里的硬编码色为 **0**。
- **主题偏好只存 `localStorage`,不落 Rust 侧 settings**:那是给「仅服务模式」(无窗口、常驻托盘)
也要生效的偏好用的,而主题是纯图形偏好 —— 没有窗口时毫无意义。为一件纯前端的事去动 Rust 命令面与契约守卫不划算。
- **首帧防闪**:`index.html` 内联一段极小脚本,在 Vue 接管前先把上次主题的底色刷上,
否则「高级灰」用户每次启动都会看到一帧刺眼的白。此处**刻意重复了 2 个底色值**(真值在 `theme/index.ts`),
因为在此之前没有任何模块可依赖;已在两处都注明「改底色时同步」。
### 说明 —— 本轮**刻意不升版本号**
本轮新增的全部是**控制台**功能,**被控端能力集合未变**(未增删任何 `system.*` / `fs.*` / `app.*` 能力)。
而 `scripts/lib/bundle-agent.mjs` 会把**根 `package.json` 的 version 注入 `agent.mjs`**(`__AGENT_VERSION__`):
一旦为控制台功能去升版本号,被控端就会**谎报**一个它并未包含的版本 —— 属版本纪律所禁止。
故版本号维持 **2.0.2**,本批改动记入本节,待真正含被控端变更时由 `pnpm release minor` 一并升号。
### 修复 —— 图形能力在 200% 缩放下坐标错位(真机实证,**属被控端变更**)
> ⚠️ 与上一节结论**不同**:本节改的是**被控端**(`apps/agent/src/**`)。
> 按上一节自己立的规矩,发版时**必须**升版本号(PATCH,→ 2.0.3),
> 否则 `agent.mjs` 会谎报一个并不包含本修复的版本。
真机(Parallels 27 + Win11 ARM64,**200% 缩放**)暴露的缺陷:
被控端的 PowerShell 助手进程**默认 DPI 不感知**,于是**同一个进程里并存两套坐标系**:
| 调用 | 不感知时的结果 | 实际危害 |
|---|---|---|
| `Screen.Bounds` / `SystemInformation.VirtualScreen` | **逻辑**尺寸 1800×1010 | `screen.info` 报错尺寸;`screen.capture` 缺省 region 一并错 |
| `CopyFromScreen` | 按**物理**像素裁剪 | `screen.capture` 缺省**只截到左上 1/4** |
| `SetCursorPos` | 被系统 **×2** | `input.mouse.*` 喂 100,100 → 光标落到 200,200 |
| UIA(`screen.find`) | **物理**坐标(与进程是否感知无关) | 与上面三项**口径不一致** |
净效果:`screen.find` 给出的物理矩形**不能**直接喂给 `input.mouse.*`,必须人工除以 `scale`
(上一轮就是靠这个临时绕法才点中的主题按钮 —— **能绕,但属用户侧的隐式约定,必须消灭**)。
- **新增 `apps/agent/src/util/dpi.ts`**:`PS_DPI_AWARE` 常量,把 `SetProcessDPIAware()` 提到脚本首行,
并附完整真机实证表与「不要删」警告。
- **注入到全部坐标相关脚本**:`capabilities/screen.ts`(`listDisplays` / `captureWindows`)、
`capabilities/input.ts`(`buildInputPsScript` + 点击后读回光标那一次)、
`capabilities/window.ts`(`WIN_HELPER_PRELUDE` —— `window.list` / `window.focus` 都走它)、
`capabilities/record.ts`(全屏探测)。
- **从 OCR 前奏的 `try{}` 里移除** `SetProcessDPIAware`(`win-ps/prelude-ocr.ts`):
放在 `try` 里一旦抛错就被 catch 吞掉 → 助手静默退回不感知。
- **新增 8 条守卫(`tests/unit/dpi-awareness.test.mjs`)**,关键设计是**断言「实际下发的脚本文本」
而非「源码里出现过常量」** —— 首版用源码扫描,注入故障实测**不会变红**(同文件别处也引用了该常量);
改为断言 `buildInputPsScript()` / `buildListDisplaysScript()` / `buildCaptureScript()` / `WIN_HELPER_PRELUDE`
的脚本串后,二次注入故障**精准变红**。另含一条「`::SetProcessDPIAware()` 恰好出现 1 次」防重复注入
(正则用 `::` 区分**调用**与 P/Invoke **声明**,否则把声明也算一次 → 误报 2 次)。
- **修复后真机复验(三项全过,零换算)**:
| 断言 | 结果 |
|---|---|
| `screen.info` | 报 **3600×2020**,与裸 `GetSystemMetrics`(感知后)一致 ✅ |
| `input.mouse.move(x,y)` → 读回光标(物理) | 四次抽样**精确等于** (x,y) ✅ |
| `screen.find` 物理矩形 → `input.mouse.click` **直接点击** | 移动后光标 = 物理中心;主题单选框 `selected` 翻转且**可逆** ✅ |
### 修复 —— 安全加固批次:18 条实机复现缺陷(**属被控端变更**)
> 来源:一轮「静态审查 + 真机实测」的完整缺陷普查(报告 `bug-report-2026-10-13.html`)。
> 基线 v2.0.2 / `ad90634` 上 `test:unit` **291/291** + `test:e2e` **30/30** **全绿** ——
> 也就是说 **18 条全部落在既有覆盖之外**。分级 **2×P0 / 5×P1 / 10×P2 / 1×P3**。
#### P0-1 命令注入(`window.focus` 的 `title`)—— 而且它是**静默**的
原实现把 `JSON.stringify(title)` 直接插进 PowerShell 的**双引号**串。PS 里 `\` 不是转义符,
双引号串还会做变量插值与 `$(...)` 展开 —— 于是 `title` 里的 `"; <命令>; "` 被当代码执行。
真机实测里最险的一点:**接口返回的是「正常业务错误」**(`E_EXECUTION_FAILED: 未找到匹配的窗口`),
而 payload 已经把文件创建出来了 —— 调用方从返回值里完全看不出异常。
- 新增 `apps/agent/src/util/quote.ts`:`psLit` / `shLit` 作**单一真源**
(PS 用 `''` 转义、sh 用 `'\''`),另加 `assertSingleQuotedLiteral()` 与裸参前提校验
`isSafeBareWindowsPathArg()`(用于 `-File <path>` 这种不能用引号的位置)。
- 全仓注入点收敛到 `psLit`/`shLit`:`window.focus` / `uiaFind` / `ocrFind` /
`system.ts` 的计划任务参数 / `ps-helper.ts` 的 `-File` 裸拼 等 **14 处**;
原有的「双引号」断言同步**翻转**为单引号 —— 旧断言的方向本就是错的(CI 断言会过期且方向可能反)。
- 新增 `tests/unit/quote.test.mjs`:既有行为断言,也有**源码形态守卫**
(禁止 `${JSON.stringify(` 拼进 PS 模板、禁止再手写 `replace(/'/g,"''")`)。
形态守卫带 `stripComments()` —— 否则注释里**讲解漏洞**的文字会被当成犯案。
#### P0-2 ACL 省略 `clients` → 每条 invoke 崩掉被控端
`{"acl":{"default_effect":"deny"}}`(没写 `clients`)会让 `aclPolicy.clients.find(...)` 抛错,
而这段**落在 `if (hasAcl)` 块之外**、无条件执行 —— 配置少写一个字段,整个被控端即不可用。
⚠️ 值得记一笔:**静态审查当时判它是「fail-open 放行」,实测是崩溃**(后果更严重)。
静态审查必须配实测 —— 这是本轮最直接的一条教训。
#### 崩溃链的共同根因:没有 `unhandledRejection` 兜底
`ws.on('message')` 用 `void handleMessage()` 起 Promise 且**没有 `.catch`**,
而全仓没有任何 `process.on('unhandledRejection')` —— Node ≥22 遇到未处理的 rejection **直接终止进程**。
`verifyNonce` 有 try/catch,而 psk 默认路径走的 `verifyHmac` 没有。于是两条畸形报文就够了:
`hello {protocol:123}` 与 `auth {hmac:1}` 都能在**认证之前**把被控端打死。
- `apps/agent/src/index.ts` 新增进程级兜底:日志每次都打、**审计按 10s 限流**
(否则畸形报文可高频刷爆 `audit.log`)。
- 明确**不**退出进程:源头已有校验与 `.catch`,可用性优先(取舍写在注释里)。
#### 其余 15 条
| 级别 | 缺陷 | 修法 |
|---|---|---|
| P1 | `clients: []` → **fail-open 全放行** | 判据改为「对象是否存在」而非「数组长度」 |
| P1 | 畸形 hello / 畸形 auth → 崩且**免认证** | 控制面报文类型校验 + 上述进程级兜底 |
| P1 | macOS `screen.find` 的 text 拼 bash → `$(...)` 实测执行 | 走 `shLit` |
| P1 | 控制台审计筛选项与协议 enum 漂移(除 `invoke` 外全 `E_PARAM_INVALID`) | 新增 `packages/protocol/src/audit-types.ts` 作**唯一真源**,manifest enum 由它派生,并加编译期双向守卫 |
| P2 | `monitor.start` 的 target 注入;monitor id 路径穿越(读/删) | 走 `psLit` + 统一过 `guardPath` |
| P2 | `keys{}` + `client_id:"constructor"` 崩 | 改 `Object.prototype.hasOwnProperty.call`(原型链取值) |
| P2 | 事件订阅跨控制端可见/可删;`event.watch` 绕开 `fs_roots` | 有状态资源落 owner + 统一路径白名单 |
| P2 | `screen.capture` 的 region 无上界 | `assertRegionSane()`,并**提前到枚举显示器之前**(fail fast) |
| P2 | `ipMatchesCidr('8.8.8.8','10.0.0.0/')` → `true`(空掩码被 `Number('') === 0` 当成 `/0`) | 掩码解析加严,补 `/0` 与 `/32` 对照组用例 |
| P3 | CLI `--timeout` 静默失效 | `parseArgs` 补声明(`strict:false` 下未声明选项被当 boolean,值掉进 `positionals`,双重危害) |
| P3 | 拒绝类事件不写审计 | 补 `request.rejected` / `agent.error` 两类事件 |
#### 回归
`test:unit` **312/312**(+21)、`test:e2e` **33/33**(+3 组硬化用例)、`test:rust` **11/11**。
e2e 新增的 ACL / 畸形报文用例有个必须记住的姿势:**每个场景独占端口 + 独立临时 `NODEAGENT_HOME`** ——
首版复用了同一端口,结果连上了上一块的残留实例,把对照组误判成「实现有缺陷」。
### 新增 —— 控制台单实例保护(症状:Windows 端起了两个被控端)
真机观察到两桩现象:① Windows 上同时跑着**两个被控端**;② **旧的服务端还在运行时,控制台起不来**。
根因是同一个:控制台**没有单实例锁**,再启动一次就是第二个进程 → 第二个 `AgentManager`
→ 抢同一个 8765 端口 → 后起的那个起不来。另有一条陈旧的 `na-gui` 计划任务指向
测试部署目录,也在抢同一个端口。
- 引入 `tauri-plugin-single-instance`(**必须注册为第一个插件**)。
- 第二次启动**不是**静默退出,而是「聚焦既有窗口 + 发 `na-second-instance` 事件」让前端刷新状态。
这一点是刻意为之:自启动的实例是**无窗口**的,若第二次启动静默退出,用户双击图标只会看到
「什么都不发生」—— 那比缺陷本身更难排查。带自启参数(`AGENT_ONLY_FLAG`)时跳过显示。
- 端口占用的报错补上「怎么找出占用者」:`netstat -ano | findstr :<port>` /
`lsof -nP -iTCP:<port> -sTCP:LISTEN`,并新增一条单测。
### 修复 —— 发版脚本漏改 Cargo 清单(版本号会分叉)
`release.mjs patch --dry-run` 实测:只改 **8 个 `package.json` + 1 个 `tauri.conf.json`**,
而 `apps/console/src-tauri/{Cargo.toml,Cargo.lock}` 里**同样存着 2.0.2** —— 发版后必然滞留,
与 `tauri.conf.json` 悄悄分叉,且**不报错**(Tauri 打包以 `tauri.conf.json` 为准)。
- 版本写入抽成 `scripts/lib/version-write.mjs`(纯函数、可测),`release.mjs` 调用它。
- `Cargo.toml` **只在 `[package]` 段**改(非 package 段里也可能有独占一行的 `version = "…"`);
`Cargo.lock` **按 `[[package]]` 块 + crate 名定位**(全文盲替换会改坏 `thiserror 2.0.21` 这类依赖版本)。
- `release.mjs` 的「脏文件白名单」改为取自**实际改写结果**(`changed`),不再手写文件名列表 ——
手写的那份一旦与写入逻辑漂移,要么拦下本该放行的(发版卡死),要么放过不该放行的(静默推走改动)。
- 新增 `tests/unit/version-write.test.mjs`(9 条):在临时目录铺**真实文件**并 `git init` / `git add`
(`versionTargets` 走 `git ls-files`,不真进索引就会假绿),跑完**读回文件**断言;
另含一条**仓库版本号一致性守卫**,发版漏项会直接变红。
- 注入 4 处故障实测:编排漏 Cargo / `Cargo.toml` 全文盲替换 / `Cargo.lock` 按位置盲改 /
仓库版本分叉 —— **全部变红**,还原后全绿。
> ⚠️ 本批**含被控端变更**(`apps/agent/src/**` 十余个文件),按版本纪律,发版时
> `pnpm release patch`(→ **v2.0.3**)**必须**执行,否则 `agent.mjs` 会谎报一个并不包含这些修复的版本。
---
## [v2.0.2] —— 2026-10-11
> 一轮真机验证(Parallels 27 + Win11 ARM)抓出的**发布事故**修复批次。
> 已公开发布的 `nodeagent-win-arm64.zip` 内装的其实是 **x64 的 `node.exe`** ——
> 该包在 ARM Windows 上能启动、能 listen,却 **TLS 握手不完成**(控制端只见 `E_TIMEOUT`),
> 实际不可用。本版重出资产,并把「交付物架构」直接钉进发版脚本与 CI。
### 新增
- **控制台自带 Node 运行时,目标机器无需预装 Node**(`pnpm --filter nodeagent-console bundle`)
—— 控制台的 sidecar 是 `.mjs`,此前只能在装了 Node 的机器上运行(`resolve_node` 回退系统 node)。
现由 `scripts/prepare-console.mjs` 把 Node 运行时准备为 Tauri resource 随包分发。
实测:`.app` **90.8 MiB**(含运行时 86MB)/`.dmg` **33.78 MiB**(分发件,仍低于 40MB 目标)。
作为对照,本机实测 Electron 运行时本体(`Electron Framework.framework`)为 207MB ——
即 Tauri + Node 的运行时开销不到 Electron 的一半。
- **`apps/console/scripts/verify-bundle.mjs`** —— 验证「打包产物能否自举」:真 spawn 打包内的 node
去跑打包内的 sidecar,并走一次真实 JSON-RPC 握手。源码与单测都正常、只在产物中暴露的问题靠它拦下。
### 修复 / 规避(两处实测踩出来的约束)
- **macOS 上 `strip` 会使 Mach-O 代码签名失效 → 内核直接 `SIGKILL`(exit 137)**,
且没有任何有用报错(不是警告、不是异常,就是静默无法执行)。
实测对照:原样复制 ✓ / strip 不重签 ✗(137) / strip + `codesign --force --sign -` ✓。
故准备脚本在 strip 之后**必须** adhoc 重签(省 20% 体积值得,但这步不能少)。
- **Node 运行时不得声明进默认 `tauri.conf.json`** —— `bundle.resources` 是**编译期契约**
(`tauri-build` 校验声明的文件必须存在),声明「运行期才就绪」的资源会让 `cargo check/test`
在 build.rs 阶段直接失败。一律走 `tauri build --config` 注入。
### 修复 —— 被控端打包(ARM64)
- **`nodeagent-win-arm64.zip` 里装的其实是 x64 的 `node.exe`**。
`pack.mjs` 下载 Node 运行时的缓存文件名是固定的 `node-runtime.zip`(**不含架构**):
`release.mjs` 先打 x64 时把它缓存下来,随后打 arm64 时误判「已缓存」直接复用
→ **两份公开包内的 `node.exe` 字节完全相同**(sha256 同为 `fdddbf45…`,PE Machine = `0x8664`)。
后果极隐蔽:该包在 ARM Windows 上**能启动、能 listen**,但 **TLS 握手不完成**
(控制端只见 `E_TIMEOUT`)—— 被控端实际不可用,而 macOS 侧 100% 复现不出来。
修法:① 缓存文件名带上架构(`node-runtime-<arch>.zip`);
② 解包后**断言 `node.exe` 的 PE Machine 与 `--arch` 一致**(缓存串包 / 手工塞错文件当场红)。
- **CI 补「双架构包 PE 架构校验」**(`.github/workflows/windows-e2e.yml`)——
此前 CI 只跑 `pnpm pack:win`(默认 x64)且只校验 x64 包,**arm64 打包零覆盖**,
故该 bug 必然逃过 CI。现两条架构都打,并直接读最终 zip 内 `node.exe` 的 PE 头比对。
- **发版脚本补「交付物架构断言」**(`scripts/lib/zip-pe-arch.mjs`)——
`release.mjs` 生成资产前,直接解出**最终 zip 内**的 `node.exe`、读 PE 头比对架构,
不符即**中止发版**。与 `pack.mjs` 的断言刻意分两层:后者是打包**流程内部**的自洽,
这里钉的是**交付物本身**的性质。纯内置 `zlib` 实现,不依赖 `unzip` / `Expand-Archive`
(Windows runner 上没有 `unzip`),另留 CLI 自检入口:
`node scripts/lib/zip-pe-arch.mjs <zip路径> <x64|arm64>`(已注入故障实测会红)。
### 真机实测(ARM64 Windows 虚拟机)
- **arm64 包**:TLS 握手成功 → PSK 认证 → 声明 51 项能力(两端一致)→
`system.info` / `status` / `process.list` / `service.list` / `shell.exec` /
`audit verify` 全部正常,`arch` 报告为 `arm64`(原生,非模拟层)。
- **x64 包(对照,同机同参)**:`connect` 报 `E_TIMEOUT` —— **复现**了「能 listen 但握手不完成」。
→ 证实「按架构出包」不是理论洁癖,而是 ARM Windows 上的硬需求。
- 已知边界:被控端若以 **SYSTEM(会话 0)** 手动启动,`screen.capture` 等图形能力会失败
(会话 0 无交互桌面)。正式安装路径不受影响 —— `install.ps1` 的计划任务 principal
用的是登录用户 + `LogonType Interactive`,注释已明写「图形能力可用」。
---
## [v2.0.1] —— 2026-10-11
> 一轮**真机验证**(Parallels 27 + Win11 ARM)带来的修复批次:在补齐 6 项待验证项的过程中,
> **抓到并修掉 5 个真问题** —— 全部只有真机能发现(macOS 上 100% 测不出)。
### 修复
- **`control.ps1` 的 `$scheme:` 被当 drive 引用** —— PowerShell **解析期错误 → 整个脚本不可用**
(stop / start / status 全废,且只报一行语法错、极易误判为别的问题)。改用 `${scheme}`;
并新增守卫扫全仓 `.ps1` 的 `$ident:` 形式(含注入故障验证)。
- **连接保活缺 pong 检测** —— 心跳只 `ping()` 而不检查 pong,且 ws 库默认不会因「ping 无 pong」断开;
客户端进程已死(TCP 未发 FIN)时,连接会**永久占用 `max_connections` 名额**,
叠加「有任务/订阅时清扫保守跳过」的策略 → **会把正常控制端挡在外面**(真机实测把自己锁住了)。
现对静默超过 `idle_timeout_ms * 2` 的连接 `terminate()`,并让 pong 也算「活跃」。
- **v26 自重启任务清理阈值过粗** —— 原用「早于 1 小时」,频繁 restart(间隔 < 1h)时旧任务照样累积;
改为「**早于本 agent 启动时刻**」——语义精确:凡在**我启动之前**运行的任务,必然不是我的宿主。
### 新增
- **`scripts/pack.mjs --arch <x64|arm64>`** —— Windows 包分架构。**x64 包在 ARM Windows 上协议层不可用**
(能启动、能 listen 8765,但 **TLS 握手不完成** —— Prism 模拟下异常,现象极具误导性);
ARM 机器须用 arm64 包。`release.mjs` 随之**同时产出两个架构**,`latest.json` 新增 `zip_arm64` 字段
(**只增不减**,旧控制端忽略即可)。
- **`scripts/verify-b2.mjs`** —— v23 连接层三防护的真机验证脚本(`idle` / `maxconn` / `ban` 三模式),
全部走真实连接而非 mock。
- **`docs/VM-VERIFICATION.md`** —— 无 Windows 真机时用 Parallels Win11 ARM 补验证的完整清单
(含实测结果、能补/不能补矩阵、8 条排查项)。
### 真机验证状态(本版实测)
| 项 | 结果 |
|---|---|
| A 组(`verify.mjs` 17 项) | 15 通过 / 0 失败 / 1 跳过 |
| v1.5 四项(字符串热键/预设别名/长按/后台投递) | 全过(`route=post` 实测命中 138 窗口) |
| v23 三防护 | 三场景全过(封禁 / 上限 / 空闲断开) |
| v26 自重启清理 | 验证并改进(阈值改为 agent 启动时刻) |
| 锚点端到端 | 全过(含云盘链外留存) |
## [v2.0.0] —— 2026-10-10
> 自 `v1.6.0` 起累计 37 个提交。**能力数 43 → 51**,单测 195 → 273,端到端 30。
> 之所以直接跳 v2.0.0:两个新方向(语义可断言 / 状态可回看)+ 安全批次四连 + 五处
> 只有真机能暴露的严重缺陷修复,量级远超一次 MINOR。
>
> **兼容性**:完全向后兼容 —— 未改任何既有能力的参数名或语义,未新增必填参数,
> 返回结构只增不减(遵循 `docs/VERSIONING.md` 的四条铁律)。
>
> **验证状态**:本版发布时被控端整机离线,`v1.5.0` 的预设别名/长按/后台投递/热键序列
> 与 `v2.0.0` 的自重启任务清理策略**当时未经真机验证**(后者已在本地单测锁定判定条件)。
>
> ✅ **2026-10-11 已补齐**:在 Parallels 27 + Win11 ARM 虚拟机上完成真机验证 ——
> A 组 15 通过/0 失败、v1.5 四项全过、v23 三防护三场景全过、v26 清理策略**验证并改进**
> (阈值由「1 小时」改为「本 agent 启动时刻」)、锚点端到端跑通(含云盘链外留存)。
> 并**顺带发现并修复 5 个真问题**(含 `control.ps1` 解析期错误、连接保活缺 pong 检测等)。
> 详见 `docs/VM-VERIFICATION.md`。
### 新增:语义可断言(v1.6.0 / v2.0.0)
- **`gui.await`** —— 等条件成立再返回。四类条件(`window` / `control` / `process` / `file`)
× 两种状态(`present` / `absent`),复用既有能力,零新增平台探测代码。
超时是**正常返回**(`satisfied:false` + `note`),不抛异常;轮询期单次异常不视为失败。
- **`screen.find` 返回 UIA 属性** —— `enabled` / `value` / `selected` / `toggle`。
每项单独 try/catch,**拿不到就留 `null` 绝不猜**(Electron、游戏 UI 常给不出值)。
- **`where` 属性谓词** —— 如 `{"enabled":true}`「等一个可用的按钮」,
比「等一个叫某名字的按钮」更贴近真实意图。仅 UIA 引擎生效,
OCR/图像引擎会忽略并**带回 `note`**,不让调用方误以为过滤过了。
- **`any_of` 组合** —— 任一命中即算,命中时回报是哪个子条件。
例:`[{window:"安装完成"},{control:"错误"}]` = 「装成功或报错,先出现的算」。
- CLI:`nodeagent await`;MCP:`na_await`。
### 新增:状态可回看(v2.0.0)
- **`log.query`** —— 日志**在被控端侧过滤**后只回匹配行(`pattern` / `level` / `since` /
`offset` / `limit` / `tail`)。流式逐行读,几万行日志也不整份拉回。
- **`monitor.start` / `report` / `stop` / `list` / `delete`** —— 定时采样并落盘。
四类源:`port`(连通性 + 延迟)/ `process`(存活 + PID)/ `command`(退出码 + 首行)/
`metric`(CPU·内存)。**摘要直接给结论**:断了几次、最长断多久、值域。
- CLI:`nodeagent monitor` / `log`;MCP:`na_monitor` / `na_log`。
### 新增:安全加固(v2.0.0)
- **连接层三防护**:
- 握手失败封禁 —— 连续失败达阈值 → 指数退避(封顶 24h),成功即清零,支持白名单豁免
- 连接上限(默认 8)+ 空闲断开(默认 30 分钟;有任务或订阅在跑时保守不踢)
- 状态从 `WeakMap` 改 `Map`(需要 size 与遍历),关闭时手动清理
- **安装期密钥改 stdin** —— `install.cmd` 用管道喂 `-KeyFromStdin`,密钥不再出现在命令行
(同机其他用户无法从进程命令行读到)。
- **零信任优先安装** —— 侦测到 `client-acl.json`(含控制端公钥)即走 `ed25519` 并写入
`acl.default_effect="deny"`;否则保持 `psk` 但输出**醒目安全告警**(说明 `client_id`
自报的固有风险 + 三步切换指引)。
- **审计链外部锚定** —— `system.audit.head`(链头可读)+ `system.audit.anchor`(锚点追加落盘)。
锚点记四元组 `{entries, head_hash, head_ts, rotated_segments}`:只记哈希会把**日志轮转
误判成篡改**。锚点文件只追加 —— 可覆盖的话,攻击者重写链后再刷新锚点即可抹痕。
### 修复(五处只有真机能暴露的严重缺陷)
- **`install.cmd` 双击首装即炸** —— `for /f` 调 PowerShell 时 `^|` 转义漏进 PS,导致
ParseError。**真实用户第一次安装必然踩到**(PSK.txt 已不在分发包内)。
- **`pack.mjs` 三连** —— `spawn node_modules/.bin/esbuild` 在 pnpm 布局下 ENOENT;
以及 `zip` / `ls -la` / `du -sh` 在 Windows 上都不存在。已改为 esbuild JS API +
平台分流(Windows 用 `Compress-Archive`)+ Node 原生 API。
- **`where` 的 PowerShell 注入语法错** —— 连修两轮:JSON 字面量 ✗ → `\"` 反斜杠转义 ✗
(PS 5.1 不认)→ **PS 单引号字面量** ✓。症状是「带 where 一律窗口操作失败,不带则正常」。
- **`screen.find` 的 where-only 被自己入口校验拦下** —— 只改了一半(`gui.await` 侧放开、
`screen.find` 侧没动)。
- **`parseLeadingTs` 吃掉 ISO 的 `Z` 时区标记** —— UTC 被当本地时间解析,**整体偏 8 小时**,
跨时区日志判断「何时断的」会错(单测抓到)。
### 修复(其他)
- **自重启在非管理员身份下失败** —— `-RunLevel Highest` 被系统拒绝(v7 起的既有缺陷)。
现改为两级降级:先试 Highest,失败则不指定 Principal(继承当前权限级别)。
修复后 `deploy` 首次能一次走通完整闭环。
- **`deploy` 备份无限堆积** —— `.bak-<时间戳>` → 固定名 `.bak` + 清理历史(真机 17 → 2 个文件)。
- **自重启任务 Running 僵尸** —— 清理条件加「`LastRunTime` 早于 1 小时」余量
(不能简单清所有 Running:那会连当前 agent 的宿主一起杀)。
- Windows CI 四连:`URL.pathname` 跨平台路径、`shell: cmd` 套多行 PowerShell、
5.1 执行的内联脚本含中文、产物包断言方向反了(`PSK.txt` 从「必含」改「不得含」)。
### 安全 / 隐私
- 源码与测试里的真机常量脱敏(机器名、内网地址)—— 保留「为什么」的教训,去掉具体实况。
- 能力调用新增审计类型 `session.idle_close`。
### 文档
- `README` 能力总览补三行、数字同步至 51 项能力 / 41 个 MCP 工具 / 269 单测。
- 新增章节:`gui.await` 属性谓词与组合、状态采样、审计链外部锚定。
- `docs/SECURITY.md` 新增 §4.7(零信任优先安装)/ §4.8(审计锚定),
§5「还没做的」清单**至此全部完成**。
- `docs/DEVELOPMENT.md` 能力表补全(此前停在 19 项)。
- `docs/RETROSPECTIVE.md` 加「短板已全部解决」对照表(保留原判断不改写)。
- `docs/ROADMAP.md` 台账刷新,并新增「真机验证抓到的 8 个真 bug」表。
---
## [v1.6.0] —— 2026-10-07
### 新增
- `gui.await` 等待条件能力(第 43 项)。
### 说明
- 本版**未经真机验证**即发布(当时被控端离线);相关验证已于 2026-10-09 ~ 10-10 补齐。
## [v1.5.0] —— 2026-10-07
### 新增
- `input.key.press` 参数面扩展:字符串热键(`hotkey: "ctrl+shift+esc"`)、约 50 个预设别名、
长按 `hold_ms`、后台定向投递 `route=post` + `target_pid`。
- 按键表补齐:左右侧修饰键、小键盘(与数字键分开)、OEM 符号键(解锁 `win+d` / `ctrl++` /
`win+.`)、IME 键、浏览器键、系统键。
### 说明
- 本版同样**未经真机验证**即发布;2026-10-10 已验证字符串热键与单键投递,其余项待被控端在线后补齐。
+252 -34
View File
@@ -27,11 +27,148 @@ nodeagent 把一台 Windows 机器的能力**标准化成一组可授权、可
| **屏幕** | `screen.info` · `screen.capture` · `screen.record` · `screen.find` | 显示器 / 截屏 / **录屏为帧序列** / **元素定位三引擎**(UIA 控件树 → OCR 文字 → **图像模板**) |
| **窗口** | `window.list` · `window.focus` | 枚举可见窗口(精确矩形)/ 置前聚焦(**Windows + macOS**) |
| **文件** | `fs.list` · `fs.stat` · `fs.read` · `fs.write` | 列目录 / 元信息 / 分块读 / 原子写 |
| **输入** | `input.mouse.move` · `input.mouse.click` · `input.mouse.scroll` · `input.mouse.drag` · `input.key.type` · `input.key.press` | 键鼠控制(🔒 **默认禁用**),含**拖拽**与**按键序列/连按** |
| **输入** | `input.mouse.move` · `input.mouse.click` · `input.mouse.scroll` · `input.mouse.drag` · `input.key.type` · `input.key.press` | 键鼠控制(🔒 **默认禁用**),含**拖拽**、**字符串热键 / 预设 / 长按 / 后台定向投递** |
| **剪贴板** | `clip.get` · `clip.set` | 读写文本**或图片**(PNG Base64) |
| **事件订阅** | `event.watch` · `event.unwatch` · `event.list` · `event.poll` | 文件变动 / 进程启停 / 端口开闭,**主动推送**(无需轮询) |
| **GUI 等待** | `gui.await` | 等条件成立再返回(窗口/元素/进程/文件 × 出现/消失);支持**属性谓词 `where`** 与 **`any_of` 组合** |
| **状态采样** | `log.query` · `monitor.start` · `monitor.report` · `monitor.stop` · `monitor.list` · `monitor.delete` | 日志**在被控端侧过滤**(不上传整份)/ 定时采样落盘 + **摘要回看**(断几次、最长断多久、CPU/内存值域) |
| **审计锚定** | `system.audit.head` · `system.audit.anchor` | 链头可读 + 锚点**追加**落盘,让「有 root 者整链重写」可被发现 |
**36 项能力** · **32 个 MCP 工具** · **114 项单元测试** · **26 项端到端用例**(CI 在真实 Windows 上验证)
## 快捷键(hotkey)控制 —— v1.5.0
`input.key.press` 支持 **4 种输入形式**(一次只用一种):
```bash
# ① 字符串热键(最常用;分隔符支持 + - 空格,大小写不敏感)
nodeagent --node win key press "ctrl+shift+esc"
nodeagent --node win key press "win+d" # 显示桌面
nodeagent --node win key press "alt+tab" # 切换应用
# ② 热键序列
nodeagent --node win key press "ctrl+c" # 老形式也兼容:key press ctrl c
nodeagent --node win invoke input.key.press --args '{"hotkeys":["ctrl+c","ctrl+v"]}'
# ③ 预设语义名(约 50 个,免记键位)
nodeagent --node win key press copy # = ctrl+c
nodeagent --node win key press task_manager # = ctrl+shift+esc
nodeagent --node win key press volume_mute_toggle # 媒体键(走 WM_APPCOMMAND)
# ④ 老数组形式
nodeagent --node win key press ctrl c
```
**按键覆盖**:字母/数字 · F1–F24 · 左右侧修饰键(`lalt`/`rctrl`/`lwin`…)·
小键盘(`numpad7`、`numpad_add`…,**与数字键分开**——Excel 靠 NumLock 区分)·
OEM 符号键(`oem_plus`/`oem_comma`/`oem_period`… → 支持 `win+d`、`ctrl++`、`win+.`)·
媒体键 · 浏览器键 · IME 键(`convert`/`kana`…)· 系统键(`sleep`/`help`…)
**进阶参数**:
| 参数 | 说明 |
|---|---|
| `hold_ms` | 长按:按下后保持 N 毫秒再释放(≤5000) |
| `route=post` + `target_pid` | **后台定向投递**:向目标进程全部顶层窗口 PostMessage(目标窗口被遮挡/最小化时用)。⚠️ 游戏与部分输入型程序不响应后台键消息 |
| `target_pid`(默认路由) | 媒体键定向(播放器常建 30+ 辅助窗口,指定 PID 投给该进程全部顶层窗口) |
**预设速查**(完整表见 `packages/protocol/src/hotkeys.ts` 的 `HOTKEY_PRESETS`):
| 类别 | 预设名 |
|---|---|
| 编辑 | `copy` `cut` `paste` `paste_plain` `undo` `redo` `select_all` |
| 文件 | `save` `save_as` `new` `open` `close_tab` `print` |
| 窗口 | `switch_app` `task_view` `task_manager` `show_desktop` `minimize_all` `maximize` `snap_left` `snap_right` `rename` `properties` |
| 系统 | `lock_screen` `run` `explorer` `search` `settings` `clipboard_history` `emoji_picker` `screenshot` |
| 虚拟桌面 | `new_desktop` `desktop_left` `desktop_right` |
| 标签/视图 | `new_tab` `next_tab` `reopen_tab` `zoom_in` `zoom_out` `fullscreen` |
| 媒体 | `music_play_pause` `music_next` `music_prev` `volume_add` `volume_mute_toggle` |
> MCP 侧同名工具为 `na_key`(`action: "press"`),支持同一套参数。
## 等待条件 gui.await —— v1.6.0
GUI 操作从「按一下→睡几秒→截图碰运气」变成**可断言**流程:
```bash
# 等界面元素出现(最多 30s)
nodeagent --node win invoke gui.await --args '{"condition":"control","text":"完成","timeout_ms":30000}'
# 等它消失
nodeagent --node win invoke gui.await --args '{"condition":"control","text":"安装中","state":"absent"}'
# 等窗口 / 进程 / 文件
nodeagent --node win invoke gui.await --args '{"condition":"window","title":"安装程序","timeout_ms":10000}'
nodeagent --node win invoke gui.await --args '{"condition":"process","process":"setup.exe"}'
nodeagent --node win invoke gui.await --args '{"condition":"file","path":"C:\\log.txt"}'
```
四类条件各自复用 `window.list` / `screen.find` / `process.list` / `fs.stat`,
平台行为(Windows UIA/OCR、macOS Vision OCR)与那些能力完全一致,**只读低危**。
轮询期单次异常不视为失败(启动中的程序常短暂报错),超时未命中才带回 `last_error`。
**典型闭环**:`await control(text:"立即安装")` → `mouse.click(x, y)` → `await control(text:"完成", timeout_ms:60000)` → `click` → `await absent(text:"安装中")`。
### 属性谓词与组合(v2.0.0)
`screen.find` 现在返回 UIA 属性,`gui.await` 可据此等**状态**而不只是「出现」:
```bash
# 等一个「可用」的按钮(不指定文字)
nodeagent --node win await --condition control --where '{"enabled":true}'
# 等值里含「已」的元素
nodeagent --node win await --condition control --where '{"value":"*已*"}' --timeout 15000
# 组合:装成功 或 报错,先出现的算
nodeagent --node win await --any_of '[{"condition":"window","title":"安装完成"},{"condition":"control","text":"错误"}]'
```
| 属性 | 来源 | 拿不到时 |
|---|---|---|
| `enabled` | `IsEnabled` | — |
| `value` | `ValuePattern` | `null`(**不猜**) |
| `selected` | `SelectionItemPattern` | `null` |
| `toggle` | `TogglePattern` | `null` |
> 属性只在 **UIA 引擎**下可读(Windows)。走 OCR/图像模板时 `where` 被忽略并**带回 `note`**,
> 不会让调用方误以为「按属性过滤过了」。macOS 无 UIA 等价物,`where` 不可用(会明确报错提示用 `text`)。
---
## 状态采样:日志过滤与定时采样 —— v2.0.0
治**间歇性**问题(代理不通、杀软拦截、端口时开时闭)—— `system.status` 只能看当下快照,
而 `event.watch` 只解决「有事件时通知」,没有「**持续记录并回看**」。
```bash
# 日志在被控端侧过滤,只回匹配行(几万行日志也不整份拉回)
nodeagent --node win log --path "C:\app\app.log" --level ERROR --limit 20
nodeagent --node win log --path "C:\app\app.log" --pattern "timeout|refused" --tail
# 连续采样 5 分钟,事后回看「断过几次、最长断多久」
nodeagent --node win invoke monitor.start --args '{"source":"port","target":"127.0.0.1:8765","interval_ms":2000,"id":"probe"}'
nodeagent --node win monitor report probe
nodeagent --node win monitor stop probe
```
采样源四类:`port`(连通性 + 延迟)/ `process`(存活 + PID)/ `command`(退出码 + 首行输出)/
`metric`(CPU·内存)。样本落 `<数据目录>/monitors/<id>.jsonl`,**摘要直接给结论**。
---
## 审计链外部锚定 —— v2.0.0
链内哈希只能发现「改一条」。**有 root 的攻击者可以整链重写** —— 从零重建一份自洽的假日志,
`audit.verify` 照样通过。外部锚定是唯一能戳破它的手段:把链头记到**链外**。
```bash
nodeagent --node win audit head --compare # 看链头 + 与最近锚点比对
nodeagent --node win audit anchor --note "每日例行" # 追加一个锚点
```
**锚点记四元组** `{entries, head_hash, head_ts, rotated_segments}`,不能只记哈希 ——
日志轮转会丢弃最旧段使条目数下降,只比哈希会把**轮转误判成篡改**。
锚点文件**只追加**(可覆盖的话,攻击者重写链后再刷新锚点即可抹痕)。
> 真正发挥价值的前提:把锚点文件指向**另一台机器 / 网盘同步目录**。
> 本机文件被整盘重写时,锚点也随之消失 —— 那就等于没锚。
**51 项能力** · **41 个 MCP 工具** · **269 项单元测试** · **30 项端到端用例** · **3 项 Rust 壳单测**(真跑 Node sidecar)(CI 在真实 Windows / Linux runner 上验证)
> 关键里程碑:**GUI 语义**(`window.list` + `screen.find`,UIA 找不到自动降级 OCR)
> 让 AI 从「看得到画面但读不懂界面」变成「按名字取坐标点下去」。
@@ -42,9 +179,15 @@ nodeagent 把一台 Windows 机器的能力**标准化成一组可授权、可
```bash
pnpm install
pnpm build
pnpm build # 全部 workspace(protocol/client/agent/cli/mcp/hub + 桌面控制台前端)
pnpm test:unit # 单元测试(协议纯函数 / 清单守护 / 编码脚本守卫 / 契约守卫)
pnpm test:e2e # 端到端(起真实被控端跑全链路)
pnpm test:rust # Rust 控制端客户端(需 cargo)
```
> 桌面控制台是 Tauri 应用,**打包**需另进目录:`cd apps/console && pnpm tauri build`。
> 仅前端产物则 `pnpm build` 已覆盖。
### 1. Windows 侧(被控端)
**方式 A:安装包(推荐,免装 Node.js)**
@@ -168,11 +311,17 @@ nodeagent pull "C:\big.iso" --out ./big.iso # 下载(大文件自动
## 架构
```
┌────────────────────┐ ┌────────────────────┐
│ 控制端 (macOS) │ ◄──── JSON-RPC 2.0 ────► │ 被控端 (Windows) │
│ CLI · MCP Server │ over WebSocket │ Agent(开机自启) │
└────────────────────┘ └────────────────────┘
│ │
┌──────────────────────────────────────────┐
│ 控制端 (macOS / Windows) │
│ ① 桌面控制台(Tauri 2) ② CLI ③ MCP │
└──────────────────────────────────────────┘
│
JSON-RPC 2.0 over WebSocket
│
┌──────────────────────────────────────────┐
│ 被控端 Agent(Windows 常驻 / 开机自启) │
│ 同时也是「本地被控端」,供本机控制台直连 │
└──────────────────────────────────────────┘
└──────────── 三种链路可选 ──────────────────────┘
① 局域网直连 ② UDP 广播自动发现 ③ Hub 中转(跨网段/公网)
```
@@ -185,6 +334,66 @@ nodeagent pull "C:\big.iso" --out ./big.iso # 下载(大文件自动
| 审计 | JSONL 落盘 + 轮转 + 参数脱敏 + 查询能力 |
| 传输 | TLS(自签证书可用 `--insecure` 跳过校验,鉴权另有保障) |
### 桌面控制台(Tauri 2)
`apps/console` —— 跨端图形控制台,**主控与被控一体**:本机也可作为被控端,
由控制台经 loopback 直连(复用同一个 Agent,不做第二套被控端实现)。
技术栈 Tauri 2(Rust 壳 + 系统 WebView)+ Vue 3 + TypeScript + Pinia + Naive UI。
协议客户端由 **Node sidecar**(`apps/console/sidecar/`)承担(复用 `@nodeagent/client`,
Tauri 侧经 Cargo path 依赖),**不引入第二套协议实现**。
**打开即用、全程不碰命令行**:应用自带 Node 运行时与被控端脚本,
默认页面就是「本机服务」——点按钮即可启动 / 停止 / 重启本机被控端,
并支持检查更新、查看日志、后台常驻(托盘)、开机自启。
**界面**:导航为**纯文字**(不使用图标);配色为两套中性灰阶主题 ——
「简约白」与「高级灰」,侧栏底部一键切换。
| 页面 | 能力 |
|---|---|
| **本机服务**(默认) | 本机被控端启停/重启、连接本机、节点 ID 与密钥、开机自启、后台运行、异常自动重启、检查并应用更新 |
| **服务日志** | 被控端 stdout/stderr 实时推送、关键字过滤、暂停接收、导出、清空 |
| 设备管理 | 连接/断开、保存多台设备、能力清单与握手元信息、实时日志 |
| 状态总览 | CPU / 内存 / 磁盘 / 网卡,3s 轮询 |
| 终端 | `system.shell.exec`(可设超时)、历史回放 |
| 进程与服务 | 进程表(CPU/内存/已运行)+ Windows 服务表 |
| 软件 | `app.list` / `app.install`(winget 静默安装,超时放大到 10 分钟) |
| 文件 | 浏览 / 预览 / 写入(走分块能力,天然支持大文件) |
| 审计 | `system.audit.list` 筛选 + `system.audit.verify` 链完整性校验 + 导出 JSONL |
```bash
pnpm -r build # 含 console 前端
cd apps/console && pnpm tauri dev # 开发态(起 Vite + Tauri 窗口)
pnpm --filter nodeagent-console bundle # 打包(含 sidecar + Node 运行时 + 被控端脚本)
# macOS 实测:.app 92.2 MiB / .dmg 33.78 MiB(分发件,其中 Node 运行时 86MB)
```
Windows 版由 CI 产出(`.github/workflows/console-windows.yml`),**双架构各一套**:
| 架构 | runner | 安装包 | 实测体积 |
|---|---|---|---|
| x64 | `windows-latest` | `.msi`(企业批量部署)+ `-setup.exe`(NSIS,双击安装) | 34.2 MiB / 23.2 MiB |
| arm64 | `windows-11-arm` | `-setup.exe`(NSIS) | 19.5 MiB |
> 为什么必须两套:控制台内含 Node 运行时,且要用它**托起本机被控端**(即自己就是 TLS 服务端)。
> 若在 ARM Windows 上装到 x64 的运行时,会**能启动、能监听,但 TLS 握手不完成** ——
> 客户端只见超时,且开发机上 100% 复现不出。CI 里为此加了三道断言:
> 「构建机架构 ⇄ 目标架构」、「随包 `node.exe` 的 PE Machine」、
> 以及**解开安装包查内容**(三样随包资源齐全 + 包内 `node.exe` 的 PE Machine 对得上 ——
> 前两条查的是打包**前**的中间产物,这一条查的才是**最终交付物**;
> 实测解出 x64 `node.exe` 82.9 MB / `0x8664`、arm64 73.5 MB / `0xaa64`)。
> 控制台安装包目前**只作为 CI artifact 产出**,尚未接入 `scripts/release.mjs` 随 Release 发布。
⚠️ 平台差异:`app.*` 与 `system.service.list` **仅在被控端为 Windows 时可用**,
非 Windows 被控端会返回 `E_UNSUPPORTED_PLATFORM`。控制台连接后会探测
`system.info.os` 并**事前禁用**这些入口,而不是发出注定失败的请求。
⚠️ 控制台**已自带** Node 运行时与被控端脚本,目标机器无需预装 Node.js,也无需先手工安装被控端;
但**开机自启与被控端的图形能力**要求运行在**登录用户会话**中(Windows 计划任务用
`Interactive` + 登录用户;SYSTEM 会话 0 没有交互桌面,截图/窗口类能力会失败)。
## 安全模型
三层防护,逐层收窄权限:
@@ -427,47 +636,56 @@ nodeagent/
├── scripts/ # install.cmd / control.cmd(一键入口)/ install.ps1 / control.ps1 / verify.mjs
├── tests/e2e/ # 端到端测试
├── docs/ # 开发文档
├── PRD.md # 产品需求文档
├── docs/ # 项目文档(含 PRD)
└── README.md
```
## 路线图
| 阶段 | 目标 | 状态 |
> 下表「**批次**」是**开发阶段的内部代号**(一批相关改动的集合),**不是版本号** ——
> 项目的唯一版本号是 semver(见 `git tag` / `package.json`)。批次与发布版本的对应关系
> 及书写规范见 [`docs/VERSIONING.md`](docs/VERSIONING.md) §0。
| 阶段(开发批次) | 目标 | 状态 |
|---|---|---|
| **v1** | 命令级接管 + 装软件 + 查状态 + 基本鉴权 + CLI + MCP | ✅ |
| **v2** | 图形接管(`screen.*` 截屏 + `input.*` 键鼠)+ 输入控制开关 | ✅ |
| **v3** | 零信任:Ed25519 身份认证 + 能力级 ACL | ✅ |
| **v3+** | 审计日志(留痕 + 查询)+ 调用限速 | ✅ |
| **v4** | 无感体验:局域网自动发现 + 断线自动重连 | ✅ |
| **v5** | 多设备管理 + 文件传输(分块 / 大文件 / 原子写) | ✅ |
| **v6** | Hub 中转:跨网段 / 公网接入(被控端主动外连,穿 NAT) | ✅ |
| **v7** | **自持**:受控重启(配置变更后让自身生效,不再丢连接) | ✅ |
| **v8** | **GUI 语义**:`window.list` / `window.focus` / `screen.find`(UIA 按名取坐标) | ✅ |
| **v9** | `screen.find` **OCR 兜底**(自绘 UI 也能定位)+ MCP 工具同步 | ✅ |
| **v10** | **长任务异步化**(`system.task.*`)+ 剪贴板 + CLI 常驻连接池 daemon | ✅ |
| **v11** | **安全加固**:私钥入系统密钥库(Keychain/DPAPI)+ 审计链防篡改 + ACL 细化(IP/时段/按能力限速) | ✅ |
| **v11+** | **操作面补完**:鼠标拖拽 / 录屏 / 剪贴板图片 / 多设备并发 / 一键升级 | ✅ |
| **v12** | **事件订阅**(file/process/net 主动推送)+ **GUI 宏**(步骤序列回放)+ **Hub 并发多控制端** + 设备分组 | ✅ |
| **v12.1** | **Hub 侧按控制端授权**(多令牌 + 按设备白/黑名单 + 注册准入) | ✅ |
| **v12.2** | GUI **等待/重试语义**(`wait_ms`)—— 界面加载/动画不再假失败 | ✅ |
| **v12.3** | **macOS 端 GUI 对齐**:AppleScript 窗口枚举/聚焦 + Vision OCR 元素定位(含 Retina 坐标换算) | ✅ |
| **v12.4** | **事件直达 MCP 宿主**(实时推送 + 拉取双通路) | ✅ |
| **v13** | **成功指标聚合**(PRD 2.2 四项)+ 依真实数据修正口径(时延分层) | ✅ |
| **v14** | **PowerShell 常驻助手** —— GUI 能力提速 4~25 倍(预加载 C#/UIA,免每次现编译) | ✅ |
| **v15** | `screen.find` **图像模板匹配**(纯图标/无文字控件盲区)+ 一堆真机根因修复 | ✅ |
| **待办** | CI 单元测试步骤已就绪(分支 `ci/workflow-unit-tests`,**需 `gh auth refresh -s workflow` 授权后推送**);GUI 图像锚点匹配(自绘 UI 更稳) | 🚧 |
| **批次 1** | 命令级接管 + 装软件 + 查状态 + 基本鉴权 + CLI + MCP | ✅ |
| **批次 2** | 图形接管(`screen.*` 截屏 + `input.*` 键鼠)+ 输入控制开关 | ✅ |
| **批次 3** | 零信任:Ed25519 身份认证 + 能力级 ACL | ✅ |
| **批次 3+** | 审计日志(留痕 + 查询)+ 调用限速 | ✅ |
| **批次 4** | 无感体验:局域网自动发现 + 断线自动重连 | ✅ |
| **批次 5** | 多设备管理 + 文件传输(分块 / 大文件 / 原子写) | ✅ |
| **批次 6** | Hub 中转:跨网段 / 公网接入(被控端主动外连,穿 NAT) | ✅ |
| **批次 7** | **自持**:受控重启(配置变更后让自身生效,不再丢连接) | ✅ |
| **批次 8** | **GUI 语义**:`window.list` / `window.focus` / `screen.find`(UIA 按名取坐标) | ✅ |
| **批次 9** | `screen.find` **OCR 兜底**(自绘 UI 也能定位)+ MCP 工具同步 | ✅ |
| **批次 10** | **长任务异步化**(`system.task.*`)+ 剪贴板 + CLI 常驻连接池 daemon | ✅ |
| **批次 11** | **安全加固**:私钥入系统密钥库(Keychain/DPAPI)+ 审计链防篡改 + ACL 细化(IP/时段/按能力限速) | ✅ |
| **批次 11+** | **操作面补完**:鼠标拖拽 / 录屏 / 剪贴板图片 / 多设备并发 / 一键升级 | ✅ |
| **批次 12** | **事件订阅**(file/process/net 主动推送)+ **GUI 宏**(步骤序列回放)+ **Hub 并发多控制端** + 设备分组 | ✅ |
| **批次 12.1** | **Hub 侧按控制端授权**(多令牌 + 按设备白/黑名单 + 注册准入) | ✅ |
| **批次 12.2** | GUI **等待/重试语义**(`wait_ms`)—— 界面加载/动画不再假失败 | ✅ |
| **批次 12.3** | **macOS 端 GUI 对齐**:AppleScript 窗口枚举/聚焦 + Vision OCR 元素定位(含 Retina 坐标换算) | ✅ |
| **批次 12.4** | **事件直达 MCP 宿主**(实时推送 + 拉取双通路) | ✅ |
| **批次 13** | **成功指标聚合**(PRD 2.2 四项)+ 依真实数据修正口径(时延分层) | ✅ |
| **批次 14** | **PowerShell 常驻助手** —— GUI 能力提速 4~25 倍(预加载 C#/UIA,免每次现编译) | ✅ |
| **批次 15** | `screen.find` **图像模板匹配**(纯图标/无文字控件盲区)+ 一堆真机根因修复 | ✅ |
| **批次 16 ~ 26** | 网络两阶段提交 · 音频控制 · 构建指纹 · 自更新 · 证书钉住 · 每客户端 PSK · 连接层三防护 · 零信任安装 · 审计锚定 | ✅ |
| **批次 27 ~ 29** | **语义可断言**(`gui.await` + UIA 属性 + `where` 谓词 + `any_of`)· **状态可回看**(`log.query` + `monitor.*`) | ✅ |
| **发布** | **v2.0.2**(2026-10-11):51 项能力 · 274 单测 · Windows/Rust CI 全绿 —— 本版为**资产修复**(v2.0.1 的 `arm64` 包内实为 x64 node,详见 CHANGELOG) | ✅ 已发布 |
## 文档
- [`docs/SECURITY.md`](docs/SECURITY.md) —— **安全模型与加固指南**(钥匙=root、证书指纹钉住、网段白名单、应急处置)
- [`docs/VERSIONING.md`](docs/VERSIONING.md) —— **版本与兼容性契约**(升级顺序、什么算破坏性、发版流程、更新方式)
- [`docs/REMOTE-LIMITS.md`](docs/REMOTE-LIMITS.md) —— 远程能力边界与真实故障案例(含杀软误拦)
- [`docs/VM-VERIFICATION.md`](docs/VM-VERIFICATION.md) —— **无 Windows 真机时,用 Parallels 的 Win11 ARM 虚拟机补验证**(含逐条命令与结论标注规则)
- [`CHANGELOG.md`](CHANGELOG.md) —— **变更日志**(未发布版本的内容汇总 / 历史版本)
- [`docs/ROADMAP.md`](docs/ROADMAP.md) —— **当前进度盘点与下一步路线**(2026-10-07 基线:待办收尾 / 语义可断言 / 状态采样 / 安全残余)
- [产品需求文档(PRD)](./PRD.md)
- [产品需求文档(PRD)](./docs/PRD.md)
- [开发文档(DEVELOPMENT)](./docs/DEVELOPMENT.md) —— 协议细节、能力 schema、安全模型、各端实现指南
- [**跨机接管的边界与局限**](./docs/REMOTE-LIMITS.md) —— 哪些问题能远程修、哪些必须人工,附真实故障诊断案例
- [**实战复盘:短板与改进优先级**](./docs/RETROSPECTIVE.md) —— 基于真机使用的短板分析(GUI 语义 / 编码 / 自愈 / 可观测性)
- [**实战复盘:短板与改进优先级**](./docs/RETROSPECTIVE.md) —— 基于真机使用的短板分析(GUI 语义 / 编码 / 自愈 / 可观测性)。**写于 2026-09-28(19 项能力时),多数 P0/P1 已解决 —— 最新状态见 [ROADMAP](./docs/ROADMAP.md) §3 对照表**
## 关键决策
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/agent",
"version": "1.3.0",
"version": "2.0.3",
"private": true,
"type": "module",
"main": "./dist/index.js",
+157 -1
View File
@@ -2,6 +2,13 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSy
import { createHash } from 'node:crypto';
import { join } from 'node:path';
import { agentDir } from './config.js';
import {
judgeAnchors,
parseAnchorsFromJsonl,
AUDIT_TYPES,
type AnchorComparison,
type AnchorRecord,
} from '@nodeagent/protocol';
export type AuditType =
| 'agent.start'
@@ -17,7 +24,39 @@ export type AuditType =
/** v21:来源不在 allow_from 被拒 */
| 'net.deny'
/** v19:agent 自更新(拉取式) */
| 'agent.update';
| 'agent.update'
/** v2.0.0:连接空闲被主动断开 */
| 'session.idle_close'
/**
* v2.0.3:**被拒的请求**统一留痕。
*
* 为什么单独一个类型:原来「畸形报文 / 未知方法 / 未认证调用 / 参数非法 / 能力不存在」
* 这类拒绝**完全不留痕**(U-01)—— 被扫描、被爆破、被误用时事后无从复盘。
* 归到 `invoke` 会污染正常调用统计,归到 `acl.denied` 又不准确(很多拒绝与 ACL 无关)。
*/
| 'request.rejected'
/**
* v2.0.3:**进程级未捕获错误 / 未处理 Promise 拒绝**。
*
* 为什么单独一个类型:这类错误是「最后一次兜底拦截」(`index.ts` 的
* `unhandledRejection` / `uncaughtException`)。既然选择了**不退出进程**,
* 就必须留下证据 —— 归到 `request.rejected` 会让「被扫描」与「我们自己有 bug」
* 混在同一类里,事后无法区分。
*/
| 'agent.error';
/**
* 编译期守卫(v2.0.3,修 C-01):本文件这份联合类型必须与 `@nodeagent/protocol` 的
* `AUDIT_TYPES` **双向一致**。
*
* 少了 → 「写进日志但协议不认」(控制台筛不到,用户以为没发生);
* 多了 → 「协议认但从不写」(下拉框里是个永远为空的选项)。
* 两种漂移都**不报错**,所以必须让 `tsc` 来说话。真正的真源在
* `packages/protocol/src/audit-types.ts`,此处保留逐条注释只为记录「哪个版本引入的」。
*/
type AssertExact<A, B> = [A] extends [B] ? ([B] extends [A] ? true : never) : never;
const _auditTypeInSyncWithProtocol: AssertExact<AuditType, (typeof AUDIT_TYPES)[number]> = true;
void _auditTypeInSyncWithProtocol;
/** 一条审计记录(JSONL 单行)。 */
export interface AuditEntry {
@@ -332,3 +371,120 @@ export function readAudit(query: AuditQuery = {}): { entries: AuditEntry[]; tota
collected.sort((a, b) => a.ts - b.ts);
return { entries: collected.slice(-limit), total, file: p };
}
// ---------------- v2.0.0 审计链外部锚定 ----------------
//
// ## 为什么需要
// 审计链的哈希使**链内**篡改可被发现(改一条就要重算其后全部)。但「有 root 的攻击者」
// 可以**整链重写**:从零重建一份自洽的假日志,链校验照样通过。
// 外部锚定就是打破这一点:定期把**链头哈希 + 条目数**记到链外的独立位置
// (另一台机器 / 网盘 / U 盘)。事后比对时,若「当前链在某锚点之后的部分对不上」,
// 说明这段被重写过 —— 攻击者无法回到过去改掉已经落到别处的锚点。
//
// ## 记什么(关键:不能只记哈希)
// 轮转会丢弃最旧的段,所以条目数会**下降**。只记 head_hash 会导致轮转后被误判为篡改。
// 故锚点记 {entries, head_hash, head_ts, rotated_segments} 四元组,
// 比对时先按"轮转了几段"对齐,再判断尾部是否一致。
export interface AuditHead {
/** 当前链可见条目数(含轮转段) */
entries: number;
/** 链头哈希(末条的 hash)—— 这是要外部留存的核心值 */
head_hash: string | null;
/** 末条时间戳 */
head_ts: number | null;
/** 存在的轮转段数(.log.1 … .log.20) */
rotated_segments: number;
file: string;
file_bytes: number;
computed_at: number;
}
/** 计算当前审计链的头部信息(只读)。 */
export function computeAuditHead(): AuditHead {
const p = auditFilePath();
let rotated = 0;
for (let i = 1; i <= 20; i += 1) if (existsSync(`${p}.${i}`)) rotated += 1;
const entries = readAuditAll();
const last = entries.length > 0 ? entries[entries.length - 1]! : null;
let fileBytes = 0;
try {
fileBytes = statSync(p).size;
} catch {
/* 文件可能尚不存在(还没写过审计) */
}
return {
entries: entries.length,
head_hash: last?.hash ?? null,
head_ts: last?.ts ?? null,
rotated_segments: rotated,
file: p,
file_bytes: fileBytes,
computed_at: Date.now(),
};
}
// 锚点记录与比对结果的定义**移到了 protocol**(唯一实现,控制端脚本也用它)——
// 见 packages/protocol/src/audit-anchor.ts 顶部的说明。此处 re-export 以保持既有导入路径可用。
export type { AnchorRecord, AnchorComparison } from '@nodeagent/protocol';
/** 锚点文件路径(默认与审计同目录)。 */
export function anchorFilePath(custom?: string): string {
return custom && custom.trim().length > 0 ? custom.trim() : join(agentDir(), 'audit-anchors.jsonl');
}
/** 把当前链头**追加**到锚点文件(追加式:历史锚点不可被后续覆盖)。 */
export function anchorAudit(opts: { path?: string; note?: string } = {}): {
file: string;
record: AnchorRecord;
total_lines: number;
} {
const head = computeAuditHead();
const file = anchorFilePath(opts.path);
const rec: AnchorRecord = {
ts: Date.now(),
entries: head.entries,
head_hash: head.head_hash,
head_ts: head.head_ts,
rotated_segments: head.rotated_segments,
...(opts.note ? { note: opts.note } : {}),
};
appendFileSync(file, `${JSON.stringify(rec)}\n`, 'utf8');
let total = 0;
try {
total = readFileSync(file, 'utf8').split('\n').filter((l) => l.trim().length > 0).length;
} catch {
total = 1;
}
return { file, record: rec, total_lines: total };
}
/**
* 与最近一条锚点比对,判断当前链是否与锚定时刻自洽。
*
* 判定逻辑(三条):
* - 条目数应 **≥** 锚点记录(只增不减;减少只能由轮转解释,轮转会让 rotated_segments 变大)
* - 若条目数与轮转段数都没变 → 链头哈希必须**完全一致**
* - 若已轮转(段数变大)→ 只校验"当前 entries ≥ 锚点 entries",并提示需人工核对
*/
export function compareWithAnchors(customPath?: string): AnchorComparison {
const file = anchorFilePath(customPath);
if (!existsSync(file)) {
return { ok: true, anchors_checked: 0, verdict: '尚无锚点:请先调用 system.audit.anchor 建立外部锚点' };
}
let anchors: AnchorRecord[] | null = null;
try {
anchors = parseAnchorsFromJsonl(readFileSync(file, 'utf8'));
} catch {
return { ok: false, anchors_checked: 0, verdict: '锚点文件损坏,无法比对' };
}
if (!anchors) {
return { ok: true, anchors_checked: 0, verdict: '锚点文件为空' };
}
// 判定逻辑在 protocol(judgeAnchors)—— 与「控制端对云盘锚点」的比对共用同一份实现,
// 避免两处规则漂移(那会导致同一份日志两端结论不同)。
return judgeAnchors(computeAuditHead(), anchors);
}
+4 -5
View File
@@ -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;
+4 -1
View File
@@ -1,6 +1,7 @@
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { IS_WINDOWS, execCommand } from '../util/exec.js';
import { runPowerShellSmart } from '../util/ps-helper.js';
import { shLit } from '../util/quote.js';
type Args = Record<string, unknown>;
@@ -132,7 +133,9 @@ async function runWindows(body: string, timeoutMs = 30_000): Promise<string> {
/** macOS:osascript 原生音量控制(静音用 `output muted`,音量用 `output volume`)。 */
async function runMac(script: string): Promise<string> {
const r = await execCommand({ command: `osascript -e ${JSON.stringify(script)}`, timeoutMs: 15_000 });
// ⚠️ AppleScript 源码里含用户可控文本 → 必须走 shell 单引号字面量。
// 曾用 JSON.stringify(双引号)→ bash 会在双引号内展开 `$(...)` 与反引号(注入面)。
const r = await execCommand({ command: `osascript -e ${shLit(script)}`, timeoutMs: 15_000 });
if (r.exit_code !== 0) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '音频控制失败(osascript)', {
detail: (r.stderr || r.stdout).slice(0, 300),
+271
View File
@@ -0,0 +1,271 @@
/**
* gui.await —— 等待条件成立(v1.6.0)。
*
* ## 为什么需要它(复盘三大短板之「语义」)
* 原来的 GUI 操作是**盲试**:按一下、截个图、看看有没有变化,不行再试。
* 问题不是"找不到元素"(screen.find 已解决),而是**操作之后无法断言界面到了预期状态**。
* 有了本能力,"点了安装 → 等待『完成』按钮出现" 从"睡 3 秒碰运气"变成**可验证的等待**:
*
* await control(text:"完成", timeout:30s) → click(found) → await absent(text:"安装中")
*
* ## 实现要点
* 不写任何新的平台探测代码 —— 四个条件全部**复用既有能力**(window.list /
* screen.find / process.list / fs.stat),因此 Windows/macOS 行为与它们完全一致。
* 轮询期间异常一律视为"尚未命中"并记录 last_error,避免单次抖动整句失败;
* 但超时未命中时会把它带回,方便判断是"真的没有"还是"每次都在报错"。
*/
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { windowList } from './window.js';
import { screenFind } from './window.js';
import { processList } from './system.js';
import { fsStat } from './fs.js';
type Args = Record<string, unknown>;
type Condition = 'window' | 'control' | 'process' | 'file';
const CONDITIONS: readonly Condition[] = ['window', 'control', 'process', 'file'];
/** 判断一次底层调用是否"命中"(兼容各能力不同的返回形态)。 */
function isHit(result: unknown): boolean {
if (result === null || result === undefined) return false;
if (typeof result === 'boolean') return result;
const r = result as Record<string, unknown>;
if (r['found'] === true) return true;
if (r['exists'] === true) return true;
for (const key of ['windows', 'processes', 'matches', 'elements']) {
const arr = r[key];
if (Array.isArray(arr) && arr.length > 0) return true;
}
if (Array.isArray(result) && result.length > 0) return true;
return false;
}
/** 按条件组装底层调用的参数(只透传该条件支持的字段)。 */
function buildInnerArgs(condition: Condition, args: Args): Args {
const num = (k: string): number | undefined => {
const v = args[k];
return typeof v === 'number' && Number.isFinite(v) ? v : undefined;
};
switch (condition) {
case 'window': {
const inner: Args = {};
const title = (args['title'] as string | undefined) ?? (args['pattern'] as string | undefined);
if (title) inner['title_pattern'] = title;
const limit = num('limit');
if (limit !== undefined) inner['limit'] = limit;
return inner;
}
case 'control': {
// 透传 screen.find 的全部相关参数(method=image 时也一样走轮询)
const inner: Args = {};
for (const k of ['text', 'window', 'control_type', 'method', 'template', 'limit', 'timeout_ms']) {
if (args[k] !== undefined) inner[k] = args[k];
}
// v2.0.0:属性谓词(仅 UIA 引擎生效)。有 where 时允许不传 text ——
// 「等一个 enabled 的按钮」比「等一个叫某名字的按钮」更贴近真实意图。
const where = args['where'];
if (where !== undefined) {
if (typeof where !== 'object' || where === null || Array.isArray(where)) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'where 必须是对象,如 {"enabled":true}');
}
const allowed = ['enabled', 'selected', 'value', 'toggle'];
const bad = Object.keys(where as Record<string, unknown>).filter((k) => !allowed.includes(k));
if (bad.length > 0) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `where 只支持 ${allowed.join('/')}`, { got: bad });
}
inner['where'] = where;
}
if (inner['text'] === undefined && where === undefined) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'control 条件需要 text 或 where 之一');
}
return inner;
}
case 'process': {
const inner: Args = {};
const name = args['process'] as string | undefined;
if (name) inner['filter'] = { name_pattern: name };
const limit = num('limit');
if (limit !== undefined) inner['limit'] = limit;
return inner;
}
case 'file': {
const p = args['path'] as string | undefined;
if (!p) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'condition=file 需要 path', {
hint: '如 {"condition":"file","path":"C:\\\\Windows\\\\Temp\\\\setup.log"}',
});
}
return { path: p };
}
}
}
/** 条件 → 底层能力(本进程内直接调用,省去 RPC 往返开销)。 */
function innerProbe(condition: Condition): (args: Args) => Promise<unknown> {
switch (condition) {
case 'window':
return (a) => windowList(a);
case 'control':
return (a) => screenFind(a);
case 'process':
return (a) => processList(a);
case 'file':
return (a) => fsStat(a);
}
}
/**
* v2.0.0 组合条件:`any_of: [ {...}, {...} ]`,任一命中原句即算命中。
*
* 为什么只做 any_of 不做 and/or/not generalize:真实场景里"弹窗出现 **且**
* 其中某按钮 enabled"用 any_of(弹窗) + where(按钮 enabled) 两步就能表达,
* 而通用布尔表达式会把 schema 与求值都复杂化,收益不成比例。
* 若真需要"两个都必须成立",上层连调两次 await 更直白。
*/
function buildAnyOf(args: Args): Array<{ condition: Condition; inner: Args; label: string }> {
const raw = args['any_of'];
if (!Array.isArray(raw) || raw.length === 0) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'any_of 需为非空数组,每项为一个条件对象');
}
if (raw.length > 8) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'any_of 最多 8 个条件', { got: raw.length });
}
return raw.map((item, i) => {
const obj = item as Record<string, unknown>;
const condition = obj['condition'] as Condition | undefined;
if (!condition || !CONDITIONS.includes(condition)) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `any_of[${i}].condition 不支持: ${String(condition)}`, {
allowed: CONDITIONS,
});
}
// 每项复用主条件的构造逻辑(同等校验),顶层键名直接透传
const inner = buildInnerArgs(condition, obj);
const label =
condition === 'control' ? String(obj['text'] ?? JSON.stringify(obj['where'] ?? ''))
: condition === 'window' ? String(obj['title'] ?? obj['pattern'] ?? '')
: condition === 'process' ? String(obj['process'] ?? '')
: String(obj['path'] ?? '');
return { condition, inner, label };
});
}
export async function guiAwait(args: Args): Promise<unknown> {
// v2.0.0:有 any_of 时**不要求**顶层 condition(语义就是"这组条件任一命中"),
// 校验顺序也因此调整为 any_of 先行 —— 否则会先撞上"condition 缺失"的报错。
const condition = args['condition'] as Condition | undefined;
if (!args['any_of'] && (!condition || !CONDITIONS.includes(condition))) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `不支持的 condition: ${String(condition)}`, {
allowed: CONDITIONS,
hint: 'window=等窗口出现/消失;control=等界面元素;process=等进程;file=等文件',
});
}
const state = (args['state'] as string | undefined) ?? 'present';
if (state !== 'present' && state !== 'absent') {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `不支持的 state: ${state}`, {
allowed: ['present', 'absent'],
});
}
const timeoutMs = Math.max(0, Math.min(60_000, (args['timeout_ms'] as number | undefined) ?? 5000));
const intervalMs = Math.max(50, Math.min(5000, (args['interval_ms'] as number | undefined) ?? 400));
const startedAt = Date.now();
let attempts = 0;
let lastHit = false;
let lastError: string | null = null;
// v2.0.0:组合条件走单独路径(任一命中即算),失败语义与单条完全一致
const anyOf = args['any_of'] !== undefined ? buildAnyOf(args) : null;
if (anyOf) {
for (;;) {
attempts += 1;
let hitLabel: string | null = null;
lastError = null;
for (const c of anyOf) {
try {
const r = await innerProbe(c.condition)(c.inner);
if (isHit(r)) { hitLabel = c.label; break; }
} catch (err) {
lastError = err instanceof Error ? err.message : String(err);
}
}
const satisfied = (state === 'present' ? hitLabel !== null : hitLabel === null) as boolean;
if (satisfied) {
return {
satisfied: true,
condition: 'any_of',
state,
elapsed_ms: Date.now() - startedAt,
attempts,
last_seen: state,
hit: hitLabel ?? undefined,
note: hitLabel ? `命中:${hitLabel}` : '所有子条件均已消失',
};
}
if (Date.now() - startedAt >= timeoutMs) {
return {
satisfied: false,
condition: 'any_of',
state,
elapsed_ms: Date.now() - startedAt,
attempts,
last_seen: lastHit ? 'present' : 'absent',
last_error: lastError ?? undefined,
note: `等待 ${timeoutMs}ms,${anyOf.length} 个条件均未${state === 'present' ? '命中' : '消失'}`,
};
}
await new Promise((r) => setTimeout(r, intervalMs));
}
}
// 能走到这里说明没走 any_of 分支,而进入函数时的校验已保证 condition 合法;
// TS 无法跨分支收窄,这里显式断言一次(buildInnerArgs / innerProbe 内部还会各自校验)。
const single = condition as Condition;
const innerArgs = buildInnerArgs(single, args);
const probe = innerProbe(single);
for (;;) {
attempts += 1;
try {
const result = await probe(innerArgs);
lastHit = isHit(result);
lastError = null;
} catch (err) {
// 轮询期异常不代表条件不成立(可能正忙于启动)——记下再试
lastError = err instanceof Error ? err.message : String(err);
lastHit = false;
}
const satisfied = state === 'present' ? lastHit : !lastHit;
if (satisfied) {
return {
satisfied: true,
condition,
state,
elapsed_ms: Date.now() - startedAt,
attempts,
last_seen: state,
note:
state === 'absent'
? '条件已消失(连续一次未命中即视为消失;如需更稳可加大 interval_ms)'
: undefined,
};
}
if (Date.now() - startedAt >= timeoutMs) {
return {
satisfied: false,
condition,
state,
elapsed_ms: Date.now() - startedAt,
attempts,
last_seen: lastHit ? 'present' : 'absent',
last_error: lastError ?? undefined,
note:
state === 'present'
? `等待 ${timeoutMs}ms 仍未出现${lastError ? `,期间持续报错:${lastError.slice(0, 160)}` : ''}`
: `等待 ${timeoutMs}ms 仍未消失${lastError ? `,期间持续报错:${lastError.slice(0, 160)}` : ''}`,
};
}
await new Promise((r) => setTimeout(r, intervalMs));
}
}
+14 -3
View File
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { IS_WINDOWS, execCommand } from '../util/exec.js';
import { shLit } from '../util/quote.js';
import { agentDir } from '../config.js';
type Args = Record<string, unknown>;
@@ -317,7 +318,15 @@ async function ensureOcrBin(): Promise<string> {
export async function macScreenFind(args: Args): Promise<unknown> {
assertDarwin('screen.find');
const text = args['text'] as string;
if (!text) throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'text 不能为空');
if (!text) {
// macOS 走 Vision OCR,**没有控件属性可读** —— where-only 无法满足,
// 明确说清原因,别让调用方以为是参数写错。
throw new CapabilityError(
ErrorCodes.UNSUPPORTED_PLATFORM,
'macOS 无 UIA 等价物:where 属性过滤不可用,请提供 text(OCR 按文字查找)',
{ platform: 'darwin', engine: 'ocr' },
);
}
const limit = (args['limit'] as number | undefined) ?? 20;
const waitMs = Math.max(0, Math.min(30_000, (args['wait_ms'] as number | undefined) ?? 0));
const intervalMs = Math.max(100, Math.min(2000, (args['interval_ms'] as number | undefined) ?? 400));
@@ -336,7 +345,7 @@ export async function macScreenFind(args: Args): Promise<unknown> {
try {
for (;;) {
const cap = await execCommand({
command: `screencapture -x -t png -R ${captureRegion.x},${captureRegion.y},${captureRegion.width},${captureRegion.height} ${JSON.stringify(shot)}`,
command: `screencapture -x -t png -R ${captureRegion.x},${captureRegion.y},${captureRegion.width},${captureRegion.height} ${shLit(shot)}`,
timeoutMs: 30_000,
});
if (cap.exit_code !== 0) {
@@ -351,8 +360,10 @@ export async function macScreenFind(args: Args): Promise<unknown> {
const scale =
img && captureRegion.width > 0 ? img.width / captureRegion.width : 1;
// ⚠️ `text` 来自控制端,`bin`/`shot` 是内部路径 —— 一律 shLit 包成 shell 单引号字面量。
// 曾用 JSON.stringify(双引号)→ `$(touch /tmp/p)` 会被 bash 执行(P1 D-01,实测复现)。
const r = await execCommand({
command: `${JSON.stringify(bin)} ${JSON.stringify(shot)} ${JSON.stringify(text)}`,
command: `${shLit(bin)} ${shLit(shot)} ${shLit(text)}`,
timeoutMs: 60_000,
maxOutputBytes: 4 * 1024 * 1024,
});
+57 -8
View File
@@ -1,4 +1,4 @@
import { createHash } from 'node:crypto';
import { createHash, randomBytes } from 'node:crypto';
import {
appendFileSync,
closeSync,
@@ -7,6 +7,7 @@ import {
openSync,
readSync,
readdirSync,
realpathSync,
renameSync,
statSync,
writeFileSync,
@@ -26,24 +27,70 @@ const HASH_LIMIT_BYTES = 8 * 1024 * 1024;
let allowedRoots: string[] = [];
export function setFsRoots(roots: string[] | undefined): void {
allowedRoots = (roots ?? []).map((r) => resolve(r));
// 白名单根也做一次 realpath 归一:否则「配置里写的是软链」会与后面 realpath 后的路径对不上
allowedRoots = (roots ?? []).map((r) => realPathBestEffort(resolve(r)));
}
function guardPath(input: string): string {
/**
* 尽力取真实路径(解析符号链接)。
*
* - 路径存在 → 直接 `realpathSync`
* - 不存在(写入场景很常见)→ 向上找到**最近的已存在祖先**做 realpath,再把剩余段拼回去
*
* 这样即便目标文件本身还不存在,也能正确判断「它最终会落在哪里」,
* 从而拦住「用软链接把访问点引到白名单之外」这一类绕过。
*/
function realPathBestEffort(abs: string): string {
try {
return realpathSync.native(abs);
} catch {
/* 继续向上找 */
}
const parts: string[] = [];
let cur = abs;
for (;;) {
const parent = dirname(cur);
if (parent === cur) return abs; // 到根了仍不存在 → 原样返回
parts.unshift(cur.slice(parent.length + (parent.endsWith(sep) ? 0 : 1)));
try {
const base = realpathSync.native(parent);
return parts.length > 0 ? join(base, ...parts) : base;
} catch {
cur = parent;
}
}
}
/**
* 路径白名单守卫(v5)。
*
* ⚠️ 两道防线,缺一不可:
* 1. **解析符号链接后的真实路径**再做包含判断 —— 只比较字面路径时,
* `白名单/a_软链 → /etc` 这样的路径能直接穿出去(绕过 `fs_roots`)。
* 2. 前缀比较必须带上分隔符(`/root` 不得匹配 `/rootX`),且大小写按平台归一
* (Windows 文件系统不区分大小写)。
*/
export function guardPath(input: string): string {
if (typeof input !== 'string' || input.length === 0) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'path 不能为空');
}
const abs = resolve(input);
if (allowedRoots.length === 0) return abs;
const ok = allowedRoots.some((root) => {
if (abs === root) return true;
const real = realPathBestEffort(abs);
const norm = (p: string): string => (process.platform === 'win32' ? p.toLowerCase() : p);
const target = norm(real);
const ok = allowedRoots.some((rootRaw) => {
const root = norm(rootRaw);
if (target === root) return true;
const prefix = root.endsWith(sep) ? root : root + sep;
return abs.startsWith(prefix);
return target.startsWith(prefix);
});
if (!ok) {
throw new CapabilityError(ErrorCodes.ACL_DENIED, `路径不在允许范围内: ${abs}`, {
allowed_roots: allowedRoots,
resolved: real,
});
}
return abs;
@@ -207,8 +254,10 @@ export async function fsWrite(args: Args): Promise<unknown> {
if (append) {
appendFileSync(p, buf);
} else {
// 原子写:先写临时文件再重命名,避免中途失败留下半截文件
const tmp = `${p}.nodeagent-tmp-${process.pid}`;
// 原子写:先写临时文件再重命名,避免中途失败留下半截文件。
// ⚠️ 临时名必须**唯一**:原来只用 PID,同一进程内并发写同一路径(或两个会话同时写)
// 会互相踩对方的 tmp(先完成的 rename 走后,后一个 renameSync 会 ENOENT 失败)。
const tmp = `${p}.nodeagent-tmp-${process.pid}-${randomBytes(4).toString('hex')}`;
writeFileSync(tmp, buf);
renameSync(tmp, p);
}
+24 -1
View File
@@ -1,6 +1,6 @@
import { CapabilityNames } from '@nodeagent/protocol';
import type { AgentConfig } from '../config.js';
import { systemInfo, systemStatus, processList, serviceList, shellExec, auditList, auditVerify, metricsReport, agentRestart } from './system.js';
import { systemInfo, systemStatus, processList, serviceList, shellExec, auditList, auditVerify, auditHead, auditAnchor, metricsReport, agentRestart } from './system.js';
import { appList, appInstall } from './app.js';
import { screenInfo, screenCapture } from './screen.js';
import { windowList, windowFocus, screenFind } from './window.js';
@@ -13,10 +13,21 @@ import { audioGet, audioSet } from './audio.js';
import { agentUpdate } from './selfupdate.js';
import { mouseMove, mouseClick, mouseScroll, mouseDrag, keyType, keyPress, setInputPolicy } from './input.js';
import { fsList, fsStat, fsRead, fsWrite, setFsRoots } from './fs.js';
import { guiAwait } from './await.js';
import { logQuery, monitorStart, monitorReport, monitorStop, monitorList, monitorDelete } from './monitor.js';
export interface HandlerContext {
/** 连接标识(事件订阅归属 / 断开清理) */
owner: string;
/**
* 调用方身份(`client_id`)。用于**跨控制端隔离**:
* 事件订阅的 list / unwatch / poll 只暴露给「创建它的那个 client_id」。
*
* 为什么按 client_id 而不是连接:同一次工作的多个连接(如 MCP 每次调用新建连接、
* CLI 的 watch 与 poll 分属两个进程)必须能互相看到自己的订阅,否则语义就断了。
* psk 模式下 client_id 由客户端自报(配置级约束);ed25519 + 每客户端独立 PSK 下身份才被真正绑定。
*/
clientId?: string;
/** 向该连接推送事件 */
emit: (evt: unknown) => void;
}
@@ -52,10 +63,22 @@ export function createCapabilityRegistry(cfg: AgentConfig): Record<string, Capab
[CapabilityNames.MouseDrag]: mouseDrag,
[CapabilityNames.KeyType]: keyType,
[CapabilityNames.KeyPress]: keyPress,
// v1.6.0 等待条件(把盲试变为可断言)
[CapabilityNames.GuiAwait]: guiAwait,
// v2.0.0 状态采样(方向二)
[CapabilityNames.LogQuery]: logQuery,
[CapabilityNames.MonitorStart]: monitorStart,
[CapabilityNames.MonitorReport]: monitorReport,
[CapabilityNames.MonitorStop]: monitorStop,
[CapabilityNames.MonitorList]: monitorList,
[CapabilityNames.MonitorDelete]: monitorDelete,
// v3+ 审计
[CapabilityNames.AuditList]: auditList,
// v11 审计防篡改
[CapabilityNames.AuditVerify]: auditVerify,
// v2.0.0 审计链外部锚定(防整链重写)
[CapabilityNames.AuditHead]: auditHead,
[CapabilityNames.AuditAnchor]: auditAnchor,
// v13 成功指标
[CapabilityNames.Metrics]: metricsReport,
// v5 文件传输
+200
View File
@@ -0,0 +1,200 @@
/**
* 输入注入的 **PowerShell 脚本生成**(纯函数,无副作用 —— 便于单测)。
*
* 独立成模块的原因:脚本生成逻辑(顺序、逆序释放、hold 插入、PostMessage 路由)
* 以往只能靠真机验证;而它恰好是最容易写错(一行顺序错 → 组合键失效)的部分。
* 现在由单测逐行断言,真机只需验证"最终是否生效"。
*/
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
/** 键名 → 虚拟键码(字母/数字走 ASCII 映射,其余查表白名单)。 */
export const VK_MAP: Record<string, number> = {
ctrl: 0x11, control: 0x11, shift: 0x10, alt: 0x12, win: 0x5b, meta: 0x5b,
enter: 0x0d, return: 0x0d, tab: 0x09, esc: 0x1b, escape: 0x1b, space: 0x20,
backspace: 0x08, delete: 0x2e, del: 0x2e, insert: 0x2d, ins: 0x2d,
up: 0x26, down: 0x28, left: 0x25, right: 0x27,
home: 0x24, end: 0x23, pageup: 0x21, pagedown: 0x22,
capslock: 0x14, printscreen: 0x2c,
// 系统/编辑键补充
pause: 0x13, break: 0x13,
apps: 0x5d, menu: 0x5d,
f1: 0x70, f2: 0x71, f3: 0x72, f4: 0x73, f5: 0x74, f6: 0x75,
f7: 0x76, f8: 0x77, f9: 0x78, f10: 0x79, f11: 0x7a, f12: 0x7b,
f13: 0x7c, f14: 0x7d, f15: 0x7e, f16: 0x7f, f17: 0x80, f18: 0x81,
f19: 0x82, f20: 0x83, f21: 0x84, f22: 0x85, f23: 0x86, f24: 0x87,
// 媒体键(0xA6-0xB7 区段)—— 2026-10-05 补
//
// 为何必要:远程控媒体播放器(音乐 / 视频 / 浏览器内视频)时,
// 若软件热键不可用(精简模式常不注册全局热键),系统媒体键是唯一
// 与「窗口是否可见/被遮挡」无关的通道 —— 窗口最小化、被游戏全屏盖住时
// 依然有效。实测:精简模式的播放器被全屏游戏遮挡时,媒体键仍能控制。
media_play: 0xb3, media_pause: 0xb3, media_play_pause: 0xb3, play_pause: 0xb3,
media_stop: 0xb2, stop: 0xb2,
media_prev: 0xb1, media_previous: 0xb1, prev_track: 0xb1, prev: 0xb1,
media_next: 0xb0, next_track: 0xb0, next: 0xb0,
media_fast_forward: 0xb4, fast_forward: 0xb4,
media_rewind: 0xb7, rewind: 0xb7,
media_eject: 0xb2,
volume_mute: 0xad, mute: 0xad,
volume_down: 0xae,
volume_up: 0xaf,
browser_back: 0xa6, browser_forward: 0xa7, browser_refresh: 0xa8, browser_stop: 0xa9, browser_search: 0xaa,
launch_mail: 0xb4, launch_media: 0xb5, launch_app1: 0xb6, launch_app2: 0xb7,
browser_favorites: 0xab, browser_home: 0xac,
// ---- 左右侧修饰键(v1.5.0)----
// 游戏/部分应用区分左右(如游戏中把 LAlt 设为跑步、RAlt 设为瞄准),故不能只给一个 alt。
lctrl: 0xa2, rctrl: 0xa3, lshift: 0xa0, rshift: 0xa1,
lalt: 0xa4, ralt: 0xa5, lwin: 0x5b, rwin: 0x5c,
super: 0x5b, cmd: 0x5b, left_cmd: 0x5b, right_cmd: 0x5c,
// ---- 小键盘(v1.5.0)----
// numpadN 与数字 N 分开:Excel 等程序靠 NumLock 状态区分二者行为,合成一个会坏掉快捷键。
numpad0: 0x60, numpad1: 0x61, numpad2: 0x62, numpad3: 0x63, numpad4: 0x64,
numpad5: 0x65, numpad6: 0x66, numpad7: 0x67, numpad8: 0x68, numpad9: 0x69,
numpad_multiply: 0x6a, numpad_add: 0x6b, numpad_subtract: 0x6d, numpad_decimal: 0x6e, numpad_divide: 0x6f,
numlock: 0x90, scrolllock: 0x91,
numpad_enter: 0x0d,
// ---- OEM 符号键(v1.5.0,美式布局 VK 码)----
// 必备理由:大量系统/应用快捷键就是"修饰键 + 符号键"(win+d 显示桌面、win+, 速览、
// ctrl++ 放大、ctrl+- 缩小、win+. 表情面板),此前只能靠字母数字,覆盖不到。
oem_1: 0xba, // : ;
oem_2: 0xbf, // / ?
oem_3: 0xc0, // ` ~
oem_4: 0xdb, // [ {
oem_5: 0xdc, // \ |
oem_6: 0xdd, // ] }
oem_7: 0xde, // ' "
oem_8: 0xdf,
oem_minus: 0xbd, // - _
oem_plus: 0xbb, // + =
oem_comma: 0xbc, // , <
oem_period: 0xbe,// . >
// ---- 系统/IME 杂项(v1.5.0)----
sleep: 0x5f, help: 0x2f, select: 0x29, execute: 0x2b, clear: 0x0c,
separator: 0x6c, oem_clear: 0xfe,
kana: 0x15, hangul: 0x15, kanji: 0x19, hanja: 0x19,
convert: 0x1c, nonconvert: 0x1d, accept: 0x1e, modechange: 0x1f,
};
/** 键名 → 虚拟键码。 */
export function toVk(key: string): number {
const k = key.trim().toLowerCase();
if (VK_MAP[k] !== undefined) return VK_MAP[k]!;
if (/^[a-z]$/.test(k)) return k.toUpperCase().charCodeAt(0);
if (/^[0-9]$/.test(k)) return k.charCodeAt(0);
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `不支持的按键: ${key}`, {
supported: [...Object.keys(VK_MAP), 'a-z', '0-9'],
});
}
/**
* 媒体键名 → APPCOMMAND 取值。
*
* 取值见 Windows `APPCOMMAND_*` 常量。整条序列都是媒体键时改走 WM_APPCOMMAND
* 投递(而非 SendInput 注入 VK)—— 前者能定向到指定进程且不受窗口遮挡影响。
*
* ⚠️ 音量键(volume_mute / volume_up / volume_down)**不在此表**:
* WM_APPCOMMAND 的 lParam 低 16 位按 device 段解释,VOLUME_DOWN 与 PAUSE 的裸值相同(都是 1),
* 放进表里会与 PAUSE 撞车。音量键走 VK 注入(0xAD-0xAF)更可靠 —— 系统会自行映射为
* APPCOMMAND_VOLUME_*,故此处刻意不收录音量键,避免 device-bit 歧义。
*/
export const MEDIA_COMMANDS: Record<string, number> = {
media_play: 14, play: 14,
media_pause: 1, pause: 1,
media_play_pause: 12, play_pause: 12,
media_stop: 15, stop: 15,
media_prev: 6, media_previous: 6, prev_track: 6, prev: 6,
media_next: 7, next_track: 7, next: 7,
media_fast_forward: 3, fast_forward: 3,
media_rewind: 4, rewind: 4,
media_close: 10, close: 10,
};
export interface PressBodyOptions {
/** 序列中相邻和弦之间的间隔毫秒 */
gapMs: number;
/** 按下后保持毫秒数(长按) */
holdMs: number;
/** route=post 时的目标进程 PID */
targetPid: number;
/** 投放路由 */
route: 'foreground' | 'post';
}
/** 判断整条序列是否都是媒体键。 */
export function isAllMedia(chords: string[][]): boolean {
return (
chords.length > 0 &&
chords.every((ch) => ch.length === 1 && MEDIA_COMMANDS[ch[0]!.trim().toLowerCase()] !== undefined)
);
}
/**
* 生成媒体键(WM_APPCOMMAND)投放脚本。
* targetPid=0 → HWND_BROADCAST 广播;否则定向该进程全部顶层窗口。
*/
export function buildMediaBody(chords: string[][], opts: PressBodyOptions): string[] {
const body: string[] = [];
const pid = Math.max(0, Math.trunc(opts.targetPid));
for (let i = 0; i < chords.length; i += 1) {
const cmd = MEDIA_COMMANDS[chords[i]![0]!.trim().toLowerCase()]!;
body.push(`[NAInput]::MediaCommand(${cmd}, ${pid})`);
if (i < chords.length - 1 && opts.gapMs > 0) {
body.push(`Start-Sleep -Milliseconds ${opts.gapMs}`);
}
}
return body;
}
/**
* 生成前台注入(SendInput Vk)脚本。
*
* 顺序至关重要:按下按给定顺序、**释放逆序** —— 否则 Windows 组合键状态机会淆
* (例如 ctrl+c 若先释放 ctrl,绝大多数程序收不到"带 ctrl 的 c")。
*/
export function buildForegroundBody(chords: string[][], opts: PressBodyOptions): string[] {
const body: string[] = [];
for (let i = 0; i < chords.length; i += 1) {
const chord = chords[i]!;
if (chord.length === 0) continue;
const vks = chord.map(toVk);
for (const vk of vks) body.push(`[NAInput]::Vk(${vk}, $false)`);
if (opts.holdMs > 0) body.push(`Start-Sleep -Milliseconds ${opts.holdMs}`);
// 逆序释放,保证组合键正确
for (const vk of [...vks].reverse()) body.push(`[NAInput]::Vk(${vk}, $true)`);
if (i < chords.length - 1 && opts.gapMs > 0) {
body.push(`Start-Sleep -Milliseconds ${opts.gapMs}`);
}
}
return body;
}
/**
* 生成后台定向投递(PostMessage)脚本。
*
* 与前台注入同样遵守"按下顺序 / 释放逆序";每和弦投递给目标进程全部顶层窗口。
* C# 侧 PostChord(vks, targetPid, up):up=false → WM_KEYDOWN,up=true → WM_KEYUP。
*/
export function buildPostBody(chords: string[][], opts: PressBodyOptions): string[] {
const body: string[] = [];
const pid = Math.max(1, Math.trunc(opts.targetPid));
for (let i = 0; i < chords.length; i += 1) {
const vks = chords[i]!.map(toVk);
if (vks.length === 0) continue;
body.push(`[NAInput]::PostChord(@(${vks.join(', ')}), ${pid}, $false)`);
if (opts.holdMs > 0) body.push(`Start-Sleep -Milliseconds ${opts.holdMs}`);
// 逆序释放
const rel = [...vks].reverse().join(', ');
body.push(`[NAInput]::PostChord(@(${rel}), ${pid}, $true)`);
if (i < chords.length - 1 && opts.gapMs > 0) {
body.push(`Start-Sleep -Milliseconds ${opts.gapMs}`);
}
}
return body;
}
+203 -58
View File
@@ -1,5 +1,12 @@
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { CapabilityError, ErrorCodes, expandChords } from '@nodeagent/protocol';
import {
isAllMedia,
buildMediaBody,
buildForegroundBody,
buildPostBody,
} from './input-script.js';
import { IS_WINDOWS, execCommand } from '../util/exec.js';
import { PS_DPI_AWARE } from '../util/dpi.js';
type Args = Record<string, unknown>;
@@ -128,22 +135,116 @@ public static class NAInput {
var u = new INPUT[1]; u[0].type = 0; u[0].U.mi.dwFlags = upFlag;
SendInput(1, u, SIZE);
}
/// <summary>
/// 媒体控制(2026-10-05 补):经 WM_APPCOMMAND 广播到目标窗口。
///
/// 为何不只靠 VK 注入:媒体键用 SendInput 打进去后,**并非所有播放器都接收**
/// —— 只有当前获得媒体会话焦点的窗口才会响应。而 WM_APPCOMMAND 可直接
/// 投递给指定 hwnd,绕开「谁是当前媒体会话焦点」这层不确定性。
///
/// 用途:远程控制主流媒体播放器(音乐 / 视频)或浏览器视频,且**窗口最小化
/// 或被全屏独占应用遮挡时依然有效**(实测:精简模式的播放器被全屏游戏压住时该通道可用)。
/// </summary>
[DllImport("user32.dll", CharSet = CharSet.Auto)]
private static extern bool PostMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
[DllImport("user32.dll")]
private static extern bool EnumWindows(EnumProc cb, IntPtr p);
private delegate bool EnumProc(IntPtr h, IntPtr p);
[DllImport("user32.dll")]
private static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
/// <summary>
/// 向目标窗口(或其全部顶层窗口)发媒体控制命令。
/// appCommand 取值:14=PLAY 15=STOP 12=PLAY_PAUSE 6=PREV 7=NEXT
/// 3=FFWD 4=REWIND 10=CLOSE 0=PLAY 1=PAUSE
/// targetPid 非 0 时,自动找到该进程的所有顶层窗口并逐个投递
/// (部分播放器会创建数十个辅助窗口,只投主窗口常常无效)。
/// </summary>
public static int MediaCommand(int appCommand, int targetPid) {
int sent = 0;
if (targetPid == 0) {
// HWND_BROADCAST:广播给所有顶层窗口
if (PostMessage((IntPtr)0xFFFF, 0x0319, IntPtr.Zero, (IntPtr)appCommand)) sent++;
return sent;
}
var handles = new System.Collections.Generic.List<IntPtr>();
EnumWindows(delegate(IntPtr h, IntPtr p) {
uint wpid;
GetWindowThreadProcessId(h, out wpid);
if (wpid == (uint)targetPid) handles.Add(h);
return true;
}, IntPtr.Zero);
foreach (var h in handles) {
if (PostMessage(h, 0x0319, IntPtr.Zero, (IntPtr)appCommand)) sent++;
}
return sent;
}
/// <summary>
/// v1.5.0:向目标进程的**全部顶层窗口** PostMessage 键盘消息(后端定向快捷键投放)。
///
/// 场景:目标窗口不在前台(被遮挡 / 最小化 / 聊天窗口在后台),又要给它发快捷键。
/// WM_KEYDOWN=0x0100,WM_KEYUP=0x0101;修饰键按顺序投递,调用方负责释放顺序。
///
/// ⚠️ 已知局限(务必告知调用方):
/// - 部分程序不处理**后台**键盘消息(游戏、部分浏览器与输入型应用直接忽略);
/// - PostMessage 不带 scancode,依赖 lParam 解码按键的程序可能取不到键。
/// 该类目标请改用默认 route=foreground(前台注入,需目标可见)。
/// </summary>
public static int PostChord(ushort[] vks, int targetPid, bool up) {
int sent = 0;
if (vks == null || vks.Length == 0) return 0;
var handles = new System.Collections.Generic.List<IntPtr>();
EnumWindows(delegate(IntPtr h, IntPtr p) {
uint wpid;
GetWindowThreadProcessId(h, out wpid);
if (wpid == (uint)targetPid) handles.Add(h);
return true;
}, IntPtr.Zero);
uint msg = up ? 0x0101u : 0x0100u;
foreach (var h in handles) {
foreach (var vk in vks) {
if (PostMessage(h, msg, (IntPtr)vk, IntPtr.Zero)) sent++;
}
}
return sent;
}
}`;
/** 组装:加类型定义 + 具体操作。 */
function psScript(body: string[]): string {
return [`$ErrorActionPreference = 'Stop'`, `$code = @'`, CS_CODE, `'@`, `Add-Type -TypeDefinition $code`, ...body].join(
'\n',
);
/**
* 组装:加 DPI 感知 + 类型定义 + 具体操作。
*
* 导出**仅供单测断言**(`tests/unit/dpi-awareness.test.mjs` 直接检查这里下发的
* PowerShell 是否带上了 DPI 感知、且位于坐标调用之前)——真机 2026-10-12 的
* 「点不准」缺陷就是这里漏了 DPI 感知,而源码扫描式守卫抓不到。
*/
export function buildInputPsScript(body: string[]): string {
return [
`$ErrorActionPreference = 'Stop'`,
// ⚠️ 必须最先执行,且**不可删**:不设 DPI 感知时 `SetCursorPos` / `mouse_event` 的坐标
// 会被系统按缩放比虚拟化(200% 下 ×2),而 `screen.find`(UIA)返回的是**物理**坐标 ——
// 「找得到、点不准」,且不报任何错。真机 2026-10-12 实证:
// 请求 (100,100) 实际落到 (200,200),请求 (552,1489) 实际落到 (1104,2019)。
PS_DPI_AWARE,
`$code = @'`,
CS_CODE,
`'@`,
`Add-Type -TypeDefinition $code`,
...body,
].join('\n');
}
async function runPs(body: string[], timeoutMs = 30_000): Promise<void> {
const r = await execCommand({ command: psScript(body), timeoutMs });
async function runPs(body: string[], timeoutMs = 30_000): Promise<string> {
const r = await execCommand({ command: buildInputPsScript(body), timeoutMs });
if (r.exit_code !== 0) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '输入注入失败', {
detail: (r.stderr || r.stdout).slice(0, 400),
});
}
return r.stdout ?? '';
}
// ---------------- 鼠标 ----------------
@@ -201,9 +302,11 @@ export async function mouseClick(args: Args): Promise<unknown> {
}
await runPs(body);
// 返回实际点击位置
// 返回实际点击位置。
// ⚠️ 读回光标位置同样要 DPI 感知,否则这里读到的是**逻辑**值(200% 下是真实值的一半),
// 于是「主叫方传物理坐标 → 返回被腰斩的坐标」,看起来像点错位置。真机 2026-10-12 排查。
const pos = await execCommand({
command: `Add-Type -AssemblyName System.Windows.Forms; $p = [System.Windows.Forms.Cursor]::Position; "{0},{1}" -f $p.X, $p.Y`,
command: `${PS_DPI_AWARE}; Add-Type -AssemblyName System.Windows.Forms; $p = [System.Windows.Forms.Cursor]::Position; "{0},{1}" -f $p.X, $p.Y`,
timeoutMs: 20_000,
});
const [cx, cy] = pos.stdout.trim().split(',').map(Number);
@@ -256,29 +359,6 @@ export async function mouseDrag(args: Args): Promise<unknown> {
return { dragged: true, from: { x: x1, y: y1 }, to: { x: x2, y: y2 }, button, steps };
}
// ---------------- 键盘 ----------------
const VK_MAP: Record<string, number> = {
ctrl: 0x11, control: 0x11, shift: 0x10, alt: 0x12, win: 0x5b, meta: 0x5b,
enter: 0x0d, return: 0x0d, tab: 0x09, esc: 0x1b, escape: 0x1b, space: 0x20,
backspace: 0x08, delete: 0x2e, del: 0x2e, insert: 0x2d, ins: 0x2d,
up: 0x26, down: 0x28, left: 0x25, right: 0x27,
home: 0x24, end: 0x23, pageup: 0x21, pagedown: 0x22,
capslock: 0x14, printscreen: 0x2c,
f1: 0x70, f2: 0x71, f3: 0x72, f4: 0x73, f5: 0x74, f6: 0x75,
f7: 0x76, f8: 0x77, f9: 0x78, f10: 0x79, f11: 0x7a, f12: 0x7b,
};
/** 键名 → 虚拟键码(字母/数字走 ASCII 映射,其余查表白名单)。 */
function toVk(key: string): number {
const k = key.trim().toLowerCase();
if (VK_MAP[k] !== undefined) return VK_MAP[k]!;
if (/^[a-z]$/.test(k)) return k.toUpperCase().charCodeAt(0);
if (/^[0-9]$/.test(k)) return k.charCodeAt(0);
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `不支持的按键: ${key}`, {
supported: [...Object.keys(VK_MAP), 'a-z', '0-9'],
});
}
export async function keyType(args: Args): Promise<unknown> {
assertAllowed('input.key.type');
requireWindows('input.key.type');
@@ -303,35 +383,96 @@ export async function keyPress(args: Args): Promise<unknown> {
assertAllowed('input.key.press');
requireWindows('input.key.press');
// v10.2:支持三种语义
// 1) keys —— 单个和弦(如 ["ctrl","c"])
// 2) repeat —— 和弦重复 N 次(解决「连按上键 3 次」这类需求)
// 3) sequence —— 任意和弦序列(如 [["ctrl","c"], ["ctrl","v"]])
const sequence = args['sequence'] as string[][] | undefined;
const repeat = Math.max(1, Math.min(50, (args['repeat'] as number | undefined) ?? 1));
const keys = args['keys'] as string[] | undefined;
// v1.5.0:入参统一经 protocol 的 expandChords 展开 —— 字符串热键 / 预设名 / 老数组形式
// 全部收敛成「归一化和弦序列」。混用多种形式会直接报错,避免"以谁为准"的歧义。
let expanded;
try {
expanded = expandChords({
hotkey: args['hotkey'] as string | undefined,
hotkeys: args['hotkeys'] as string[] | undefined,
preset: args['preset'] as string | undefined,
presets: args['presets'] as string[] | undefined,
keys: args['keys'] as string[] | undefined,
sequence: args['sequence'] as string[][] | undefined,
repeat: args['repeat'] as number | undefined,
});
} catch (err) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `快捷键参数错误: ${err instanceof Error ? err.message : String(err)}`);
}
const chords = expanded.chords;
const presets = expanded.presets;
const chords: string[][] = sequence?.length
? sequence
: keys?.length
? Array.from({ length: repeat }, () => keys)
: [];
if (chords.length === 0) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, '需提供 keys(可配 repeat)或 sequence');
const gap = Math.max(0, Math.min(2000, (args['interval_ms'] as number) ?? 40));
// v1.5.0:长按(按下到释放的保持时长),用于"长按音量/持续按住方向键"等场景
const holdMs = Math.max(0, Math.min(5000, Math.round((args['hold_ms'] as number | undefined) ?? 0)));
// v1.5.0:投放路由。foreground = SendInput 注入当前焦点(默认,旧行为);
// post = 向目标进程全部顶层窗口 PostMessage(窗口在后台时可用,但兼容性差)
const route = (args['route'] as string | undefined) ?? 'foreground';
if (route !== 'foreground' && route !== 'post') {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `不支持的 route: ${route}`, { allowed: ['foreground', 'post'] });
}
const pid = (args['target_pid'] as number) ?? 0;
if (route === 'post' && pid <= 0) {
throw new CapabilityError(
ErrorCodes.PARAM_INVALID,
'route=post 需要提供 target_pid(≥1)—— 后台投递必须知道投给哪个进程',
{ hint: '用 window.list 取目标进程 PID;无目标时请用默认 route=foreground' },
);
}
const body: string[] = [];
const gap = Math.max(0, Math.min(2000, (args['interval_ms'] as number | undefined) ?? 40));
for (let i = 0; i < chords.length; i += 1) {
const chord = chords[i]!;
if (chord.length === 0) continue;
const vks = chord.map(toVk);
for (const vk of vks) body.push(`[NAInput]::Vk(${vk}, $false)`);
// 逆序释放,保证组合键正确
for (const vk of [...vks].reverse()) body.push(`[NAInput]::Vk(${vk}, $true)`);
if (i < chords.length - 1 && gap > 0) body.push(`Start-Sleep -Milliseconds ${gap}`);
// 媒体键走 WM_APPCOMMAND 通道(2026-10-05 补)
//
// 仅当**整条序列都是媒体键**时启用。若混在普通按键里(如 ["ctrl","media_play_pause"])
// 则回退到 VK 注入 —— 那种组合场景本就罕见,SendInput 足够。
const allMedia = isAllMedia(chords);
if (allMedia) {
// target_pid 非 0 时定向投递到该进程的全部顶层窗口(部分播放器会建数十个辅助窗口,
// 只投主窗口常常无效 —— 实测踩过);否则广播。
const mediaBody = buildMediaBody(chords, { gapMs: gap, holdMs, targetPid: pid, route });
await runPs(mediaBody);
return {
pressed: true,
keys: chords.length === 1 ? chords[0] : undefined,
chords,
times: chords.length,
presets,
via: expanded.via,
channel: 'appcommand',
target_pid: pid || null,
};
}
await runPs(body);
// v1.5.0:后端定向投递(route=post)—— 目标窗口不在前台时使用
if (route === 'post') {
let sent = 0;
const out = await runPs(buildPostBody(chords, { gapMs: gap, holdMs, targetPid: pid, route }));
// 累计投递成功的窗口数(C# 侧每次 PostMessage 成功计数)
let total = 0;
for (const line of out.split('\n')) {
const m = /(\d+)/.exec(line.trim());
if (m) total += Number(m[1]);
}
sent = total;
return {
pressed: true,
keys: chords.length === 1 ? chords[0] : undefined,
chords,
times: chords.length,
presets,
via: expanded.via,
channel: 'postmessage',
route,
target_pid: pid,
sent_windows: sent,
note:
sent === 0
? '未投递到任何窗口(进程可能已退出或没有任何顶层窗口)'
: '后台投递兼容性有限:游戏/部分浏览器与输入型程序不响应后台键盘消息,' +
'无效时改用 route=foreground 并确保目标窗口可见',
};
}
await runPs(buildForegroundBody(chords, { gapMs: gap, holdMs, targetPid: pid, route }));
// 同时回传 keys(单和弦时的回显)与 chords(完整序列)。
// 修正 2026-10-04:此前只回 chords,控制端 CLI 读 keys.join() 直接崩溃。
return {
@@ -339,5 +480,9 @@ export async function keyPress(args: Args): Promise<unknown> {
keys: chords.length === 1 ? chords[0] : undefined,
chords,
times: chords.length,
presets,
via: expanded.via,
hold_ms: holdMs || undefined,
route,
};
}
+151
View File
@@ -0,0 +1,151 @@
/**
* log.query(v2.0.0,方向二「状态」第一块)。
*
* ## 为什么必须有
* 治间歇性问题(代理不通、杀软拦截、端口时开时闭)要看**历史**,而被控端日志常在
* 几万行以上。`fs.read` 虽有 8MB 上限,但"整份拉回再筛"既慢又占带宽 ——
* 过滤必须发生在 **Windows 侧**。
*
* ## 实现要点
* - 流式逐行读(readline),**不全量载入内存**;扫到 limit 条即停
* - 三级过滤:`pattern`(正则,大小写不敏感)/ `level`(ERROR|WARN|INFO|DEBUG,
* 命中行内含 `[LEVEL]` 或 `LEVEL:` 即算)/ `since`(Unix ms,按行首时间戳或
* 文件 mtime 粗筛)
* - 路径仍走 `fs.guardPath` 的白名单(fs_roots 非空即受限)—— 不新开安全面
* - 单行超长保护:超过 4KB 的行截断后再匹配,避免一行巨型 JSON 拖垮内存
*/
import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';
import { statSync } from 'node:fs';
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { guardPath } from './fs.js';
type Args = Record<string, unknown>;
const MAX_LINE = 4096;
const MAX_SCAN = 2_000_000; // 单次查询最多扫 200 万行,防失控文件拖死 agent
export interface LogLine {
/** 行号(从 1 开始)—— 回看时便于对照原文件 */
n: number;
text: string;
}
export interface LogQueryResult {
path: string;
matched: LogLine[];
/** 实际扫描过的行数(用于判断"是扫完了还是被 limit 截住") */
scanned: number;
/** 是否因达到 MAX_SCAN 提前结束 */
truncated: boolean;
note?: string;
}
/** 从行里推断日志级别(找不到返回 null,不猜)。 */
function detectLevel(line: string): string | null {
const m = /\[(ERROR|WARN|WARNING|INFO|DEBUG|TRACE)\]|\b(ERROR|WARN|INFO|DEBUG|TRACE):/i.exec(line);
if (!m) return null;
const raw = (m[1] ?? m[2] ?? '').toUpperCase();
return raw === 'WARNING' ? 'WARN' : raw;
}
export async function logQuery(args: Args): Promise<LogQueryResult> {
const p = guardPath(args['path'] as string);
const limit = Math.max(1, Math.min(5000, (args['limit'] as number | undefined) ?? 100));
const offset = Math.max(0, (args['offset'] as number | undefined) ?? 0);
const patternStr = args['pattern'] as string | undefined;
const level = (args['level'] as string | undefined)?.toUpperCase();
const since = args['since'] as number | undefined;
const tail = (args['tail'] as boolean | undefined) ?? false;
let re: RegExp | null = null;
if (patternStr) {
try {
re = new RegExp(patternStr, 'i');
} catch (err) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `pattern 不是合法正则: ${patternStr}`, {
detail: err instanceof Error ? err.message : String(err),
});
}
}
if (level && !['ERROR', 'WARN', 'INFO', 'DEBUG', 'TRACE'].includes(level)) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `不支持的 level: ${level}`, {
allowed: ['ERROR', 'WARN', 'INFO', 'DEBUG', 'TRACE'],
});
}
let st;
try {
st = statSync(p);
} catch {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `文件不存在或不可读: ${p}`);
}
if (!st.isFile()) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `不是普通文件: ${p}`);
}
// since 粗筛:文件在 since 之前就没改过,必然没有更新的行 —— 省一次全扫
if (since !== undefined && st.mtimeMs < since) {
return { path: p, matched: [], scanned: 0, truncated: false, note: '文件 mtime 早于 since,未扫描' };
}
// tail 模式:保留最后 limit 条命中的环形缓冲(边扫边丢旧的)
const ring: LogLine[] = [];
let matched = 0;
let scanned = 0;
let truncated = false;
const rl = createInterface({
input: createReadStream(p, { encoding: 'utf8' }),
crlfDelay: Number.POSITIVE_INFINITY,
});
for await (const rawLine of rl) {
scanned += 1;
if (scanned > MAX_SCAN) {
truncated = true;
break;
}
let line = rawLine;
if (line.length > MAX_LINE) line = `${line.slice(0, MAX_LINE)}…[截断]`;
if (re && !re.test(line)) continue;
if (level && detectLevel(line) !== level) continue;
// since 精确筛:只在行首能解出时间戳时才按时间比(解不出就放行,交由调用方判断)
if (since !== undefined) {
const ts = parseLeadingTs(line);
if (ts !== null && ts < since) continue;
}
matched += 1;
if (tail) {
ring.push({ n: scanned, text: line });
if (ring.length > limit) ring.shift();
} else {
if (matched > offset && matched <= offset + limit) ring.push({ n: scanned, text: line });
if (matched > offset + limit) break; // 已取够,提前收工
}
}
return {
path: p,
matched: ring,
scanned,
truncated,
...(truncated ? { note: `扫描超过 ${MAX_SCAN} 行上限,结果可能不完整` } : {}),
};
}
/**
* 行首时间戳 → Unix ms(支持 ISO 与常见 `[2026-10-07 12:34:56]` 形态;解不出返回 null)。
*
* ⚠️ 必须**连同时区后缀一起解析**:只截取到秒会丢掉 `Z` / `+08:00`,
* 让 UTC 时间被当本地时间读 —— 实测会整体偏 8 小时(真机日志跨时区时判断"何时断的"就错了)。
*/
export function parseLeadingTs(line: string): number | null {
const m = /^\[?(\d{4}[-/]\d{2}[-/]\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:?\d{2})?)/.exec(line);
if (!m) return null;
// 无时区信息时按本地时间解析(与 `[2026-10-07 10:00:12]` 这类本地日志的语义一致);
// 带 Z / ±hh:mm 时 Date.parse 自行按该时区处理 —— 关键是**别把后缀截掉**。
const iso = m[1]!.replace(/\//g, '-').replace(' ', 'T');
const t = Date.parse(iso);
return Number.isFinite(t) ? t : null;
}
+411
View File
@@ -0,0 +1,411 @@
/**
* monitor.*(v2.0.0,方向二「状态」第二块)—— 定时采样落盘与回看。
*
* ## 为什么需要(复盘 §四)
* 代理不通、杀软拦截、端口时开时闭都是**间歇性**的,而 `system.status` 只能看当下快照。
* `event.watch` 解决「有事件时通知」,没解决「**持续记录,事后能查**」。
*
* ## 设计
* - 四类采样源:`port`(连通性 + 延迟)/ `process`(存活 + PID 列表)/
* `command`(退出码 + 首行输出)/ `metric`(CPU/内存)
* - 落盘 `<数据目录>/monitors/<id>.jsonl`(一行一样本,追加写 + 立即 flush 语义)
* - `monitor.report` 读回并给摘要:min/max/均值、断线次数、最长中断 —— 直接回答
* 「5 分钟里断过几次、最长断了多久」
* - 并发上限 8 个;单文件 10 万行封顶(防写满磁盘)
* - 采样循环 `unref()`,不阻止 agent 退出;`monitor.stop` 或进程退出即停
*/
import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { connect } from 'node:net';
import { exec, execFile } from 'node:child_process';
import { join, resolve as resolvePath, sep } from 'node:path';
import { homedir, totalmem, freemem, cpus } from 'node:os';
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { logQuery, type LogLine } from './log.js';
type Args = Record<string, unknown>;
export type MonitorSource = 'port' | 'process' | 'command' | 'metric';
interface Sample {
ts: number;
/** 各源自定义字段 */
[k: string]: unknown;
}
interface MonitorRecord {
id: string;
source: MonitorSource;
target: string;
intervalMs: number;
createdAt: number;
samples: number;
file: string;
timer: ReturnType<typeof setInterval>;
/** 首次采样失败时记下,避免每次 tick 都刷错误 */
lastError: string | null;
}
const monitors = new Map<string, MonitorRecord>();
const MAX_MONITORS = 8;
const MAX_SAMPLES_PER_FILE = 100_000;
function dataDir(): string {
return process.env['NODEAGENT_HOME'] ?? join(homedir(), '.nodeagent');
}
function monitorDir(): string {
const d = join(dataDir(), 'monitors');
if (!existsSync(d)) mkdirSync(d, { recursive: true });
return d;
}
/**
* 校验监控 id:**唯一入口**,四个命令(start/report/stop/delete)都必须先过它。
*
* ⚠️ M-01/M-02(真机实测):`monitor.report` / `monitor.delete` 曾经**不校验 id**
* 就直接 `join(monitorDir(), id + '.jsonl')` → `id:"../escape-target"` 可读/删
* 数据目录外的任意 `.jsonl` 文件(路径穿越)。
* 这里除了字符白名单,还做一次**解析后前缀比对**(`..` 与符号链接都拦得住)。
*/
function safeMonitorId(raw: unknown): string {
const id = typeof raw === 'string' ? raw.trim() : '';
if (!id) throw new CapabilityError(ErrorCodes.PARAM_INVALID, '需要 id');
if (!/^[\w.-]{1,64}$/.test(id) || id.includes('..')) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'id 只允许字母数字与 . _ -(≤64 字符,不含 ..)', {
got: id,
});
}
return id;
}
/** 监控样本文件路径 —— id 已在 safeMonitorId 校验过,这里再兜一次「必须在 monitors 目录内」。 */
function monitorFile(id: string): string {
const dir = resolvePath(monitorDir());
const file = resolvePath(dir, `${id}.jsonl`);
if (!file.startsWith(dir + sep)) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'id 解析后越出 monitors 目录', { id });
}
return file;
}
// ---------------- 采样器 ----------------
/** port:TCP 连通性 + 握手耗时(跨平台,无额外依赖)。 */
function samplePort(host: string, port: number, timeoutMs: number): Promise<Sample> {
return new Promise((resolve) => {
const started = Date.now();
const sock = connect({ host, port });
const done = (up: boolean, err?: string): void => {
sock.destroy();
resolve({ ts: Date.now(), up, ms: Date.now() - started, ...(err ? { error: err } : {}) });
};
sock.setTimeout(timeoutMs);
sock.once('connect', () => done(true));
sock.once('timeout', () => done(false, 'timeout'));
sock.once('error', (e) => done(false, String(e.message ?? e)));
});
}
/** process:按名判断存活(Windows tasklist / POSIX ps,与 events.ts 同策略)。 */
function sampleProcess(name: string): Promise<Sample> {
return new Promise((resolve) => {
// ⚠️ D-02(真机实测):原来把 `name` 拼进 `tasklist /FI "IMAGENAME eq ${name}"` 与
// `ps … | grep -i "${name}"`,payload `zzz"; touch /tmp/m…; #` 会被 shell 执行。
// 正解:**不经过 shell** —— Windows 用 execFile 传参数数组,POSIX 直接把 ps 输出
// 交给 JS 过滤(不再 grep)。
const collect = (stdout: string, err: Error | null): void => {
const lines = stdout
.split('\n')
.map((l) => l.trim())
.filter((l) => l.length > 0 && !/^INFO:/i.test(l));
const pids = lines
.map((l) => {
const m = /^(\d+)/.exec(l);
return m ? Number(m[1]) : null;
})
.filter((x): x is number => x !== null);
resolve({
ts: Date.now(),
alive: pids.length > 0,
pids,
...(err && pids.length === 0 ? { error: String(err.message) } : {}),
});
};
if (process.platform === 'win32') {
// 无 shell:name 作为单个参数传入,`"` `;` `&` 等都不再具特殊含义
execFile('tasklist', ['/FI', `IMAGENAME eq ${name}`, '/NH'], { timeout: 8000 }, (err, stdout) =>
collect(stdout ?? '', err),
);
return;
}
execFile('ps', ['-axo', 'pid=,comm='], { timeout: 8000 }, (err, stdout) => {
const needle = name.toLowerCase();
const filtered = (stdout ?? '')
.split('\n')
.filter((l) => l.toLowerCase().includes(needle))
.join('\n');
collect(filtered, err);
});
});
}
/** command:退出码 + 输出首行(用于"跑个探针命令"这类自定义采样)。 */
function sampleCommand(command: string, timeoutMs: number): Promise<Sample> {
return new Promise((resolve) => {
exec(command, { timeout: timeoutMs }, (err, stdout, stderr) => {
const first = (stdout || stderr || '').split('\n')[0]?.slice(0, 500) ?? '';
resolve({
ts: Date.now(),
exit_code: err ? (typeof err.code === 'number' ? err.code : 1) : 0,
first_line: first,
});
});
});
}
/** metric:CPU(两次采样差分)+ 内存。 */
async function sampleMetric(): Promise<Sample> {
const a = cpus();
await new Promise((r) => setTimeout(r, 200));
const b = cpus();
const idle = (c: typeof a): number =>
c.reduce((s, x) => s + x.times.idle + x.times.irq, 0) / c.length;
const total = (c: typeof a): number =>
c.reduce((s, x) => s + Object.values(x.times).reduce((p, q) => p + q, 0), 0) / c.length;
const idleDiff = idle(b) - idle(a);
const totalDiff = total(b) - total(a);
const cpu = totalDiff > 0 ? Math.round((1 - idleDiff / totalDiff) * 10000) / 100 : 0;
const mem = totalmem();
const free = freemem();
return { ts: Date.now(), cpu_pct: cpu, mem_pct: Math.round(((mem - free) / mem) * 10000) / 100 };
}
async function takeSample(rec: MonitorRecord): Promise<Sample> {
switch (rec.source) {
case 'port': {
const [host, portStr] = rec.target.split(':');
return samplePort(host || '127.0.0.1', Number(portStr), Math.min(rec.intervalMs, 5000));
}
case 'process':
return sampleProcess(rec.target);
case 'command':
return sampleCommand(rec.target, Math.min(rec.intervalMs * 2, 20_000));
case 'metric':
return sampleMetric();
}
}
// ---------------- 能力实现 ----------------
export async function monitorStart(args: Args): Promise<unknown> {
const source = args['source'] as MonitorSource | undefined;
if (!source || !['port', 'process', 'command', 'metric'].includes(source)) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `不支持的 source: ${String(source)}`, {
allowed: ['port', 'process', 'command', 'metric'],
hint: 'port 需 target="host:port";process 需 target=进程名;command 需 target=命令;metric 不需要 target',
});
}
const intervalMs = Math.max(500, Math.min(600_000, (args['interval_ms'] as number | undefined) ?? 2000));
const target = (args['target'] as string | undefined) ?? '';
if (source !== 'metric' && !target) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `source=${source} 需要 target`);
}
if (source === 'port' && !/^.+:\d+$/.test(target)) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'port 采样的 target 应为 "host:port"', { got: target });
}
if (monitors.size >= MAX_MONITORS) {
throw new CapabilityError(ErrorCodes.RATE_LIMITED, `并发监控上限 ${MAX_MONITORS} 个`, {
running: [...monitors.keys()],
});
}
const id =
(args['id'] as string | undefined)?.trim() ||
`m_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
safeMonitorId(id); // 与 report/delete 同一套校验(防路径穿越 + 统一口径)
if (monitors.has(id)) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `监控 id 已存在: ${id}`, { hint: '换个 id,或先 monitor.stop' });
}
const file = monitorFile(id);
const rec: MonitorRecord = {
id, source, target, intervalMs, createdAt: Date.now(), samples: 0, file,
timer: setInterval(() => { void tick(rec); }, intervalMs),
lastError: null,
};
monitors.set(id, rec);
// 立即采第一针(调用方往往想马上知道"现在通不通"),不额外等一个 interval
void tick(rec);
return { id, source, target, interval_ms: intervalMs, file, started_at: rec.createdAt };
}
async function tick(rec: MonitorRecord): Promise<void> {
try {
const s = await takeSample(rec);
rec.lastError = null;
appendFileSync(rec.file, `${JSON.stringify(s)}\n`, 'utf8');
rec.samples += 1;
// 行数封顶:超过就从头截断(保留后半),防止磁盘写满
if (rec.samples > MAX_SAMPLES_PER_FILE) {
const lines = readFileSync(rec.file, 'utf8').trim().split('\n');
writeFileSync(rec.file, `${lines.slice(-MAX_SAMPLES_PER_FILE).join('\n')}\n`, 'utf8');
rec.samples = MAX_SAMPLES_PER_FILE;
}
} catch (err) {
rec.lastError = err instanceof Error ? err.message : String(err);
// 采样失败也记一行("查不到"本身也是信息),但不无限刷盘
if (rec.samples < MAX_SAMPLES_PER_FILE) {
appendFileSync(rec.file, `${JSON.stringify({ ts: Date.now(), error: rec.lastError })}\n`, 'utf8');
rec.samples += 1;
}
}
}
export async function monitorReport(args: Args): Promise<unknown> {
const id = safeMonitorId(args['id']);
const rec = monitors.get(id);
const file = monitorFile(id);
if (!rec && !existsSync(file)) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `没有这个监控: ${id}`, { running: [...monitors.keys()] });
}
const since = args['since'] as number | undefined;
const limit = Math.max(1, Math.min(20_000, (args['limit'] as number | undefined) ?? 5000));
const samples: Sample[] = [];
let truncated = false;
if (existsSync(file)) {
const lines = readFileSync(file, 'utf8').split('\n');
for (const line of lines) {
if (!line.trim()) continue;
try {
const s = JSON.parse(line) as Sample;
if (since !== undefined && s.ts < since) continue;
samples.push(s);
} catch {
/* 半行(写入中断)跳过 */
}
}
if (samples.length > limit) {
samples.splice(0, samples.length - limit);
truncated = true;
}
}
return {
id,
running: rec !== undefined,
source: rec?.source ?? 'unknown',
target: rec?.target ?? '',
interval_ms: rec?.intervalMs,
file,
samples: samples.length,
truncated,
last_error: rec?.lastError ?? undefined,
summary: summarize(samples, rec?.source ?? 'port'),
series: samples,
};
}
/** 摘要:把样本压成"人话"—— 断几次、最长多久、值域。 */
function summarize(samples: Sample[], source: MonitorSource): Record<string, unknown> {
if (samples.length === 0) return { n: 0 };
const base: Record<string, unknown> = { n: samples.length };
const ts = samples.map((s) => s.ts);
base.window_ms = Math.max(...ts) - Math.min(...ts);
if (source === 'port') {
const ups = samples.filter((s) => s.up === true);
base.up_count = ups.length;
base.down_count = samples.length - ups.length;
const lat = samples.map((s) => Number(s.ms ?? 0)).filter((x) => Number.isFinite(x));
if (lat.length) {
base.ms_min = Math.min(...lat);
base.ms_max = Math.max(...lat);
base.ms_avg = Math.round((lat.reduce((a, b) => a + b, 0) / lat.length) * 100) / 100;
}
// 最长连续中断
let run = 0;
let worst = 0;
for (const s of samples) {
if (s.up === true) { run = 0; } else { run += 1; worst = Math.max(worst, run); }
}
base.longest_outage_samples = worst;
} else if (source === 'process') {
base.alive_count = samples.filter((s) => s.alive === true).length;
base.dead_count = samples.filter((s) => s.alive !== true).length;
let run = 0;
let worst = 0;
for (const s of samples) {
if (s.alive === true) { run = 0; } else { run += 1; worst = Math.max(worst, run); }
}
base.longest_dead_samples = worst;
} else if (source === 'command') {
const codes = samples.map((s) => Number(s.exit_code ?? -1));
base.ok_count = codes.filter((c) => c === 0).length;
base.fail_count = codes.filter((c) => c !== 0).length;
base.exit_codes_seen = [...new Set(codes)];
} else {
const cpu = samples.map((s) => Number(s.cpu_pct ?? 0));
const mem = samples.map((s) => Number(s.mem_pct ?? 0));
if (cpu.length) {
base.cpu_min = Math.min(...cpu); base.cpu_max = Math.max(...cpu);
base.cpu_avg = Math.round((cpu.reduce((a, b) => a + b, 0) / cpu.length) * 100) / 100;
}
if (mem.length) {
base.mem_min = Math.min(...mem); base.mem_max = Math.max(...mem);
base.mem_avg = Math.round((mem.reduce((a, b) => a + b, 0) / mem.length) * 100) / 100;
}
}
return base;
}
export async function monitorStop(args: Args): Promise<unknown> {
const id = safeMonitorId(args['id']);
const rec = monitors.get(id);
if (!rec) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `没有正在运行的监控: ${id}`, { running: [...monitors.keys()] });
}
clearInterval(rec.timer);
monitors.delete(id);
const report = (await monitorReport({ id })) as { samples: number; summary: Record<string, unknown> };
return { id, stopped: true, samples: report.samples, summary: report.summary, file: rec.file };
}
export async function monitorList(_args: Args): Promise<unknown> {
const running = [...monitors.values()].map((r) => ({
id: r.id, source: r.source, target: r.target, interval_ms: r.intervalMs,
samples: r.samples, started_at: r.createdAt, file: r.file, last_error: r.lastError ?? undefined,
}));
const dir = monitorDir();
let files: string[] = [];
try {
files = (await import('node:fs')).readdirSync(dir).filter((f) => f.endsWith('.jsonl'));
} catch {
/* 目录不存在即无历史 */
}
return {
running,
running_count: running.length,
history_files: files.map((f) => f.replace(/\.jsonl$/, '')),
};
}
/** 供 monitor.delete:删除历史文件(不影响运行中的实例)。 */
export async function monitorDelete(args: Args): Promise<unknown> {
const id = safeMonitorId(args['id']);
if (monitors.has(id)) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, '该监控仍在运行,请先 stop', { hint: `monitor.stop {"id":"${id}"}` });
}
const file = monitorFile(id);
if (!existsSync(file)) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `没有历史文件: ${id}`);
}
rmSync(file, { force: true });
return { id, deleted: true, file };
}
// log.query 与 monitor 同属"状态"方向,放在同一能力文件里导出
export { logQuery };
export type { LogLine };
+24 -23
View File
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFile
import { join } from 'node:path';
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { IS_WINDOWS, execCommand } from '../util/exec.js';
import { psLit as psLitShared, shLit } from '../util/quote.js';
import { agentDir } from '../config.js';
import { audit } from '../audit.js';
import { extractPsError } from '../util/ps-helper.js';
@@ -59,7 +60,7 @@ async function runPsFile(script: string, timeoutMs: number, tag = 'ps'): Promise
const path = join(dir, `${tag}-${Date.now()}.ps1`);
writeFileSync(path, withBom(script), 'utf8');
return execCommand({
command: `powershell -NoProfile -ExecutionPolicy Bypass -File ${JSON.stringify(path)}`,
command: `powershell -NoProfile -ExecutionPolicy Bypass -File ${psLit(path)}`,
timeoutMs,
});
}
@@ -68,7 +69,7 @@ async function runPsFile(script: string, timeoutMs: number, tag = 'ps'): Promise
* 生成的 .ps1 **必须带 UTF-8 BOM**。
*
* ⚠️ 真机踩过:PowerShell 5.1 读**无 BOM** 的 .ps1 会按系统 ANSI(GBK) 解析,
* 脚本里的中文(如网卡名「Ethernet」)会吃掉后面的闭合引号 →
* 脚本里的非 ASCII 字符(如中文网卡名)会吃掉后面的闭合引号 →
* 报「The string is missing the terminator」→ 计划任务退出码 1 且不留日志。
* 所有落盘的 PowerShell 脚本一律经此函数。
*/
@@ -83,17 +84,17 @@ export function withBom(content: string): string {
* 曾经用 JSON.stringify(双引号)或手动把 \ 翻倍当转义,结果生成 `C:\\Users\\...`
* 这种非法路径 → Out-File 失败 → 计划任务退出码 1 且不留日志(真机踩过)。
* 需要字面量时一律走本函数;需要插值时用双引号但避免反斜杠。
*
* 注:实现已收敛到 `util/quote.ts`(单一真源),此处保留同名导出以兼容既有引用与测试。
*/
export function psLit(v: string): string {
return `'${v.replace(/'/g, "''")}'`;
}
export const psLit = psLitShared;
/**
* 生成「注册一次性计划任务」的 PowerShell 脚本(唯一实现,两处复用)。
*
* ⚠️ 两个真机教训都固化在这里:
* 1) **账号名必须用 WindowsIdentity 取**,不要用 "$env:USERDOMAIN\$env:USERNAME" 拼 ——
* 在 TS 模板串里 `\$` 会被当转义吃掉反斜杠,拼出「机器名<user>」这种不可解析的账号,
* 在 TS 模板串里 `\$` 会被当转义吃掉反斜杠,拼出「机器名+纯数字」这种不可解析的账号,
* Register-ScheduledTask 报 HRESULT 0x80070534「No mapping between account names...」。
* 2) 复用同一实现:此前回滚/延时两处各写一份,改了一处漏了另一处(真机踩过)。
*/
@@ -173,7 +174,7 @@ export interface IfaceSnapshot {
async function snapshotIface(iface: string): Promise<IfaceSnapshot> {
const script = `
$ErrorActionPreference='Continue'
$a = ${JSON.stringify(iface)}
$a = ${psLit(iface)}
$o = [ordered]@{ alias = $a; dhcp = $false; ipv4 = @(); gateway = @(); dns = @() }
$ipif = Get-NetIPInterface -InterfaceAlias $a -AddressFamily IPv4 -ErrorAction SilentlyContinue
if ($ipif) { $o.dhcp = ($ipif.Dhcp -eq 'Enabled') }
@@ -210,7 +211,7 @@ $o | ConvertTo-Json -Compress -Depth 4
/** 由结构化快照生成**原生 cmdlet** 回滚脚本(不用 netsh set —— 它在 Win11 上会挂起)。 */
export function buildRollbackScript(snap: IfaceSnapshot, backupTxt: string, logPath: string): string {
const a = JSON.stringify(snap.alias);
const a = psLit(snap.alias);
const L = psLit(logPath);
const lines = [
`# nodeagent 自动回滚(生成于 ${new Date().toISOString()})`,
@@ -229,23 +230,23 @@ export function buildRollbackScript(snap: IfaceSnapshot, backupTxt: string, logP
// 同样「先加后清 + 先清默认路由」——否则回滚本身也会踩 already exists(真机事故根因)
for (const addr of snap.ipv4) {
lines.push(
` New-NetIPAddress -InterfaceAlias ${a} -IPAddress ${JSON.stringify(addr.ip)} -PrefixLength ${addr.prefix} -ErrorAction SilentlyContinue | Out-Null`,
` New-NetIPAddress -InterfaceAlias ${a} -IPAddress ${psLit(addr.ip)} -PrefixLength ${addr.prefix} -ErrorAction SilentlyContinue | Out-Null`,
);
}
lines.push(` Get-NetRoute -InterfaceAlias ${a} -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue | Remove-NetRoute -Confirm:$false -ErrorAction SilentlyContinue`);
const keep = snap.ipv4.map((x) => JSON.stringify(x.ip)).join(',');
const keep = snap.ipv4.map((x) => psLit(x.ip)).join(',');
lines.push(` $keep = @(${keep})`);
lines.push(` Get-NetIPAddress -InterfaceAlias ${a} -AddressFamily IPv4 -ErrorAction SilentlyContinue |`);
lines.push(` Where-Object { $keep -notcontains $_.IPAddress -and $_.PrefixOrigin -ne 'WellKnown' } |`);
lines.push(` ForEach-Object { Remove-NetIPAddress -IPAddress $_.IPAddress -InterfaceAlias ${a} -Confirm:$false -ErrorAction SilentlyContinue }`);
if (snap.gateway[0]) {
lines.push(
` New-NetRoute -InterfaceAlias ${a} -DestinationPrefix '0.0.0.0/0' -NextHop ${JSON.stringify(snap.gateway[0])} -ErrorAction SilentlyContinue | Out-Null`,
` New-NetRoute -InterfaceAlias ${a} -DestinationPrefix '0.0.0.0/0' -NextHop ${psLit(snap.gateway[0])} -ErrorAction SilentlyContinue | Out-Null`,
);
}
if (snap.dns.length > 0) {
lines.push(
` Set-DnsClientServerAddress -InterfaceAlias ${a} -ServerAddresses ${snap.dns.map((d) => JSON.stringify(d)).join(',')} -ErrorAction SilentlyContinue`,
` Set-DnsClientServerAddress -InterfaceAlias ${a} -ServerAddresses ${snap.dns.map((d) => psLit(d)).join(',')} -ErrorAction SilentlyContinue`,
);
}
lines.push(` W 'restored: static'`);
@@ -260,14 +261,14 @@ export function buildRollbackScript(snap: IfaceSnapshot, backupTxt: string, logP
async function readIfaceWindows(iface: string): Promise<Record<string, unknown>> {
const script = `
$ErrorActionPreference='Continue'
$c = Get-NetIPConfiguration -InterfaceAlias ${JSON.stringify(iface)} -ErrorAction SilentlyContinue
$c = Get-NetIPConfiguration -InterfaceAlias ${psLit(iface)} -ErrorAction SilentlyContinue
if (-not $c) { Write-Output '{}'; return }
$o = [ordered]@{ interface = ${JSON.stringify(iface)} }
$o = [ordered]@{ interface = ${psLit(iface)} }
$o.ipv4 = @($c.IPv4Address | ForEach-Object { $_.IPAddress })
$o.prefix = @($c.IPv4Address | ForEach-Object { $_.PrefixLength })
$o.gateway = @($c.IPv4DefaultGateway | ForEach-Object { $_.NextHop })
$o.dns = @($c.DNSServer | Where-Object { $_.AddressFamily -eq 2 } | ForEach-Object { $_.ServerAddresses } | Select-Object -First 4)
$dhcp = Get-NetIPInterface -InterfaceAlias ${JSON.stringify(iface)} -AddressFamily IPv4 -ErrorAction SilentlyContinue
$dhcp = Get-NetIPInterface -InterfaceAlias ${psLit(iface)} -AddressFamily IPv4 -ErrorAction SilentlyContinue
$o.dhcp = ($dhcp.Dhcp -eq 'Enabled')
$o | ConvertTo-Json -Compress
`;
@@ -436,7 +437,7 @@ export async function netApply(args: Args): Promise<unknown> {
* netsh 仅保留用于「读取」与人工恢复用的 dump 备份(对操作者友好)。
*/
export function buildApplyCommand(mode: string, iface: string, args: Args, snap: IfaceSnapshot): string {
const a = JSON.stringify(iface);
const a = psLit(iface);
if (mode === 'command') {
const cmd = args['command'] as string | undefined;
if (!cmd) throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'mode=command 需要 command 参数');
@@ -459,7 +460,7 @@ export function buildApplyCommand(mode: string, iface: string, args: Args, snap:
}
const prefix = maskToPrefix(mask);
const dns = (args['dns'] as string[] | undefined) ?? [];
const ipLit = JSON.stringify(ip);
const ipLit = psLit(ip);
const lines = [
`$ErrorActionPreference='Continue'`,
// 关掉 DHCP(否则设静态地址会与 DHCP 租约打架)
@@ -477,12 +478,12 @@ export function buildApplyCommand(mode: string, iface: string, args: Args, snap:
];
if (gateway) {
lines.push(
`New-NetRoute -InterfaceAlias ${a} -DestinationPrefix '0.0.0.0/0' -NextHop ${JSON.stringify(gateway)} -ErrorAction SilentlyContinue | Out-Null`,
`New-NetRoute -InterfaceAlias ${a} -DestinationPrefix '0.0.0.0/0' -NextHop ${psLit(gateway)} -ErrorAction SilentlyContinue | Out-Null`,
);
}
if (dns.length > 0) {
lines.push(
`Set-DnsClientServerAddress -InterfaceAlias ${a} -ServerAddresses ${dns.map((d) => JSON.stringify(d)).join(',')} -ErrorAction SilentlyContinue`,
`Set-DnsClientServerAddress -InterfaceAlias ${a} -ServerAddresses ${dns.map((d) => psLit(d)).join(',')} -ErrorAction SilentlyContinue`,
);
}
lines.push(`Write-Output 'static applied'`);
@@ -495,10 +496,10 @@ export function buildApplyCommand(mode: string, iface: string, args: Args, snap:
* 而调用方还以为变更成功了(真机事故:机器掉到 APIPA 彻底失联)。
*/
export function buildSelfCheck(iface: string, expectIp: string | null, rollbackScriptPath: string): string {
const a = JSON.stringify(iface);
const rb = JSON.stringify(rollbackScriptPath);
const a = psLit(iface);
const rb = psLit(rollbackScriptPath);
const cond = expectIp
? `$ok = @(Get-NetIPAddress -InterfaceAlias ${a} -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -eq ${JSON.stringify(expectIp)} }).Count -gt 0`
? `$ok = @(Get-NetIPAddress -InterfaceAlias ${a} -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -eq ${psLit(expectIp)} }).Count -gt 0`
: `$ok = @(Get-NetIPAddress -InterfaceAlias ${a} -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.PrefixOrigin -ne 'WellKnown' }).Count -gt 0`;
return [
``,
@@ -551,7 +552,7 @@ async function posixCommandApply(
'utf8',
);
const launched = await execCommand({
command: `chmod +x ${JSON.stringify(rollbackPath)} && setsid nohup sh ${JSON.stringify(rollbackPath)} > ${JSON.stringify(join(dir, `rollback-${ts}.log`))} 2>&1 & echo started`,
command: `chmod +x ${shLit(rollbackPath)} && setsid nohup sh ${shLit(rollbackPath)} > ${shLit(join(dir, `rollback-${ts}.log`))} 2>&1 & echo started`,
timeoutMs: 15_000,
});
+4 -2
View File
@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { IS_WINDOWS, execCommand } from '../util/exec.js';
import { PS_DPI_AWARE } from '../util/dpi.js';
type Args = Record<string, unknown>;
@@ -109,9 +110,10 @@ export async function screenRecord(args: Args): Promise<unknown> {
let height = region?.height ?? 0;
if (!region) {
// ⚠️ 这里必须先设 DPI 感知,`VirtualScreen` 才是**物理**尺寸(见 util/dpi.ts)。
// 统一走 PS_DPI_AWARE,避免各能力各写一份 P/Invoke 壳而漂移。
const probe = await execCommand({
command:
'Add-Type -AssemblyName System.Windows.Forms; Add-Type -TypeDefinition \'using System.Runtime.InteropServices; public class NADPIR { [DllImport("user32.dll")] public static extern bool SetProcessDPIAware(); }\'; [void][NADPIR]::SetProcessDPIAware(); $vs = [System.Windows.Forms.SystemInformation]::VirtualScreen; Write-Output ("{0},{1},{2},{3}" -f $vs.X, $vs.Y, $vs.Width, $vs.Height)',
command: `${PS_DPI_AWARE}; Add-Type -AssemblyName System.Windows.Forms; $vs = [System.Windows.Forms.SystemInformation]::VirtualScreen; Write-Output ("{0},{1},{2},{3}" -f $vs.X, $vs.Y, $vs.Width, $vs.Height)`,
timeoutMs: 30_000,
});
const nums = probe.stdout.trim().split(',').map(Number);
+84 -24
View File
@@ -1,5 +1,7 @@
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { IS_WINDOWS, execCommand } from '../util/exec.js';
import { shLit } from '../util/quote.js';
import { PS_DPI_AWARE } from '../util/dpi.js';
type Args = Record<string, unknown>;
@@ -25,22 +27,37 @@ const MAX_CAPTURE_BYTES = 40 * 1024 * 1024;
// ---------------- screen.info ----------------
/**
* listDisplays 下发给 PowerShell 的脚本。
*
* 导出**仅供单测断言**(检查 DPI 感知确实注入且位于坐标 API 之前)——
* 真机 2026-10-12 的「缺省整屏只截到左上 1/4」就是这里漏了 DPI 感知,
* 源码扫描式守卫抓不到(文件里别处也引用了 PS_DPI_AWARE)。
*/
export function buildListDisplaysScript(): string {
return [
// ⚠️ 必须最先执行:不设 DPI 感知时,`Screen.Bounds` 返回的是**逻辑**尺寸,
// 而 `CopyFromScreen` 又按**物理**像素裁剪 → 缺省整屏截图只截到左上 1/4
//(200% 缩放下 3600×2020 的桌面报成 1800×1010)。真机 2026-10-12 实证。
PS_DPI_AWARE,
'Add-Type -AssemblyName System.Windows.Forms',
'$s = [System.Windows.Forms.Screen]::AllScreens',
// 读取 DPI 缩放(HKCU AppliedDPI;读不到则按 100% 处理)。
// 注意:设了 DPI 感知后 Bounds 已是**物理**像素,scale 仅作参考信息保留。
'$dpi = (Get-ItemProperty -Path "HKCU:\\Control Panel\\Desktop\\WindowMetrics" -Name AppliedDPI -ErrorAction SilentlyContinue).AppliedDPI',
'$scale = if ($dpi) { [math]::Round($dpi / 96.0, 2) } else { 1.0 }',
'$out = @()',
'for ($i = 0; $i -lt $s.Count; $i++) {',
' $d = $s[$i]',
' $out += [pscustomobject]@{ id = $i; name = $d.DeviceName; width = $d.Bounds.Width; height = $d.Bounds.Height; is_primary = $d.Primary; scale = $scale; x = $d.Bounds.X; y = $d.Bounds.Y }',
'}',
'$out | ConvertTo-Json -Compress',
].join('\n');
}
async function listDisplays(): Promise<Display[]> {
if (IS_WINDOWS) {
const script = [
'Add-Type -AssemblyName System.Windows.Forms',
'$s = [System.Windows.Forms.Screen]::AllScreens',
// 读取 DPI 缩放(HKCU AppliedDPI;读不到则按 100% 处理)
'$dpi = (Get-ItemProperty -Path "HKCU:\\Control Panel\\Desktop\\WindowMetrics" -Name AppliedDPI -ErrorAction SilentlyContinue).AppliedDPI',
'$scale = if ($dpi) { [math]::Round($dpi / 96.0, 2) } else { 1.0 }',
'$out = @()',
'for ($i = 0; $i -lt $s.Count; $i++) {',
' $d = $s[$i]',
' $out += [pscustomobject]@{ id = $i; name = $d.DeviceName; width = $d.Bounds.Width; height = $d.Bounds.Height; is_primary = $d.Primary; scale = $scale; x = $d.Bounds.X; y = $d.Bounds.Y }',
'}',
'$out | ConvertTo-Json -Compress',
].join('\n');
const r = await execCommand({ command: script, timeoutMs: 30_000 });
const r = await execCommand({ command: buildListDisplaysScript(), timeoutMs: 30_000 });
if (r.exit_code !== 0) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '读取显示器信息失败', { detail: r.stderr.slice(0, 500) });
}
@@ -106,27 +123,61 @@ export async function screenCapture(args: Args): Promise<unknown> {
region: args['region'] as CaptureOpts['region'],
};
/**
* ⚠️ S-01(v2.0.3):region 的合法性检查必须放在**枚举显示器之前**。
*
* 两道闸的由来:被控端会照 region 直接 `New-Object Bitmap $w,$h`(32bpp ≈ 4×w×h 字节),
* 一个 100000×100000 的请求足以吃光内存(≈40GB)→ 进程被杀。
* 收敛为「单边 ≤ 16384(常见位图/纹理上限)+ 总像素 ≤ 32M(≈128MB)」。
*
* 放在枚举之前有两个理由:① 校验不依赖显示器信息,没理由先付出枚举成本;
* ② 真要挡住的就是这种请求 —— 让它在任何副作用之前就失败(fail fast)。
*/
if (opts.region) assertRegionSane(opts.region);
const displays = await listDisplays();
const display = displays[opts.display_id] ?? displays[0];
if (!display) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, `显示器 ${opts.display_id} 不存在`);
}
// region 缺省 = 整个目标屏;带上多屏偏移
// region 缺省 = 整个目标屏;带上多屏偏移。
// 缺省值也要过同一道闸:显示器自报异常尺寸时同样不能放行。
const region = opts.region ?? { x: display.x, y: display.y, width: display.width, height: display.height };
if (region.width < 1 || region.height < 1) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'region 宽高必须 ≥ 1', { region });
}
assertRegionSane(region);
if (IS_WINDOWS) return captureWindows(opts, region);
return capturePosix(opts, region);
}
async function captureWindows(
opts: CaptureOpts,
/** region 上界校验(S-01)。缺省值与显式传入走**同一份**判据,避免两处漂移。 */
function assertRegionSane(region: NonNullable<CaptureOpts['region']>): void {
if (region.width < 1 || region.height < 1) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'region 宽高必须 ≥ 1', { region });
}
const MAX_EDGE = 16384;
const MAX_PIXELS = 33_554_432;
if (region.width > MAX_EDGE || region.height > MAX_EDGE || region.width * region.height > MAX_PIXELS) {
throw new CapabilityError(
ErrorCodes.PARAM_INVALID,
`region 过大:单边须 ≤ ${MAX_EDGE} 且总像素 ≤ ${MAX_PIXELS}(当前 ${region.width}×${region.height})`,
{ region, max_edge: MAX_EDGE, max_pixels: MAX_PIXELS },
);
}
if (![region.x, region.y, region.width, region.height].every((n) => Number.isFinite(n))) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'region 必须都是有限数字', { region });
}
}
/** captureWindows 下发给 PowerShell 的脚本(导出**仅供单测断言**,同 buildListDisplaysScript)。 */
export function buildCaptureScript(
region: { x: number; y: number; width: number; height: number },
): Promise<CaptureResult> {
const script = [
opts: { scale: number; format: string; quality: number },
): string {
return [
// ⚠️ 与 listDisplays 同理:region 是物理像素,进程也必须是 DPI 感知,
// 否则「region 语义(物理)」与「进程坐标观(逻辑)」两套口径并存。
PS_DPI_AWARE,
"$ErrorActionPreference = 'Stop'",
'Add-Type -AssemblyName System.Drawing',
`$x = ${region.x}; $y = ${region.y}; $w = ${region.width}; $h = ${region.height}`,
@@ -157,8 +208,17 @@ async function captureWindows(
'$ms.Dispose(); $bmp.Dispose()',
'Write-Output ("{0}|{1}|{2}" -f [Convert]::ToBase64String($bytes), $bmpW, $bmpH)',
].join('\n');
}
const r = await execCommand({ command: script, timeoutMs: 60_000, maxOutputBytes: MAX_CAPTURE_BYTES });
async function captureWindows(
opts: CaptureOpts,
region: { x: number; y: number; width: number; height: number },
): Promise<CaptureResult> {
const r = await execCommand({
command: buildCaptureScript(region, opts),
timeoutMs: 60_000,
maxOutputBytes: MAX_CAPTURE_BYTES,
});
if (r.exit_code !== 0) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '截屏失败', { detail: r.stderr.slice(0, 500) });
}
@@ -197,7 +257,7 @@ async function capturePosix(
// macOS 用 screencapture;区域用 -R x,y,w,h
const cmd =
`screencapture -x -t ${ext} -R ${region.x},${region.y},${region.width},${region.height} ${JSON.stringify(file)}`;
`screencapture -x -t ${ext} -R ${region.x},${region.y},${region.width},${region.height} ${shLit(file)}`;
const r = await execCommand({ command: cmd, timeoutMs: 30_000 });
if (r.exit_code !== 0) {
throw new CapabilityError(ErrorCodes.UNSUPPORTED_PLATFORM, '当前平台截屏不可用(需要 macOS 屏幕录制权限)', {
+25 -2
View File
@@ -167,15 +167,38 @@ export async function agentUpdate(args: Args): Promise<unknown> {
detail: err instanceof Error ? err.message : String(err),
});
}
// ⚠️ 必须**就地覆写**,不能用 rename 覆盖 —— Windows 不允许重命名/替换「正被运行进程
// 打开的文件」(EPERM/EBUSY),而 node.exe 正持有 agent.mjs。真机踩过:rename 必失败。
// 覆写打开中的文件是允许的(只有删除/重命名被禁),这也正是推送式 deploy 能工作的原因。
try {
renameSync(staged, scriptPath);
writeFileSync(scriptPath, buf);
} catch (err) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '替换脚本失败', {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '写入脚本失败(已保留备份,可回滚)', {
detail: err instanceof Error ? err.message : String(err),
backup_path: backup,
staged_path: staged,
});
}
// 写后校验:内容必须与刚校验过的下载一致,否则立即回滚(防止截断/半写)
const written = createHash('sha256').update(readFileSync(scriptPath)).digest('hex');
if (written !== got) {
try {
copyFileSync(backup, scriptPath); // 回滚
} catch {
/* 回滚失败时靠 backup_path 人工兜底 */
}
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '写入后校验不一致,已自动回滚', {
expected: got.slice(0, 12),
actual: written.slice(0, 12),
backup_path: backup,
});
}
try {
unlinkSync(staged); // 暂存文件已并入入口文件,及时清理
} catch {
/* 清理失败不影响正确性 */
}
audit({
type: 'agent.update',
+60 -10
View File
@@ -1,4 +1,5 @@
import { createHash } from 'node:crypto';
import { guardPath } from './fs.js';
import { readFileSync, statSync } from 'node:fs';
import { spawn } from 'node:child_process';
import os from 'node:os';
@@ -6,10 +7,11 @@ import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { IS_WINDOWS, execCommand } from '../util/exec.js';
import { currentCertFingerprint } from '../certs.js';
import { loadAgentConfig } from '../config.js';
import { readAudit, readAuditAll, verifyAudit } from '../audit.js';
import { readAudit, readAuditAll, verifyAudit, computeAuditHead, anchorAudit, compareWithAnchors } from '../audit.js';
import { computeMetrics } from '../metrics.js';
import { allPsShellStats } from '../util/ps-helper.js';
import { startTask } from './task.js';
import { psLit } from '../util/quote.js';
type Args = Record<string, unknown>;
@@ -55,7 +57,6 @@ async function isAdmin(): Promise<boolean> {
// 故一律用 typeof 探测,绝不能直接引用(否则 dev 启动就 ReferenceError)。
declare const __AGENT_VERSION__: string | undefined;
declare const __BUILD_COMMIT__: string | undefined;
declare const __BUILD_TIME__: string | undefined;
/** v21:读取当前生效的来源白名单(用于 system.info 上报)。 */
function configAllowFrom(): string[] | null {
@@ -67,10 +68,9 @@ function configAllowFrom(): string[] | null {
}
}
function injected(name: 'version' | 'commit' | 'time'): string {
function injected(name: 'version' | 'commit'): string {
if (name === 'version') return typeof __AGENT_VERSION__ === 'undefined' ? 'dev' : __AGENT_VERSION__;
if (name === 'commit') return typeof __BUILD_COMMIT__ === 'undefined' ? 'dev' : __BUILD_COMMIT__;
return typeof __BUILD_TIME__ === 'undefined' ? 'dev' : __BUILD_TIME__;
return typeof __BUILD_COMMIT__ === 'undefined' ? 'dev' : __BUILD_COMMIT__;
}
const STARTED_AT = Date.now();
@@ -118,7 +118,9 @@ export function buildInfo(): {
// v20:语义化版本 + 提交 + 构建时间(来自构建期注入)
version: injected('version'),
commit: injected('commit'),
built_at: injected('time'),
// 构建时间取入口文件的 mtime(而非构建期注入)—— 注入会让每次打包字节不同,
// 破坏「指纹 = 构建身份」的确定性(v22 真机踩过)
built_at: buildCache.mtime_ms ? new Date(buildCache.mtime_ms).toISOString() : 'unknown',
// v21:TLS 证书指纹(控制端据此钉住被控端身份)
cert_sha256: currentCertFingerprint(),
};
@@ -526,15 +528,38 @@ async function scheduleWindowsRestart(p: RestartParams): Promise<unknown> {
const TASK = `nodeagent-selfrestart-${Date.now()}`;
const ps = [
`$ErrorActionPreference='SilentlyContinue'`,
// 清理历史 selfrestart 任务(保留 Running 的 —— 那正是当前 agent 的宿主)
`Get-ScheduledTask -TaskName 'nodeagent-selfrestart*' | Where-Object { $_.State -ne 'Running' } | Unregister-ScheduledTask -Confirm:$false`,
`$a = New-ScheduledTaskAction -Execute 'cmd.exe' -Argument '${cmdLine.replace(/'/g, "''")}'`,
// 清理历史 selfrestart 任务。
//
// ⚠️ 为什么不能简单清所有非 Running 的:重启任务的 cmd 会**一直持有新 agent 进程**,
// 所以任务长期处于 Running;若把 Running 的全清掉,会连当前 agent 的宿主一起杀
// (= 自杀,表现为"restart 成功但 agent 消失")。原实现只清非 Running,
// 代价是真机累积了 3 个 Running 僵尸(2026-10-09 实测)。
//
// 现策略:非 Running **或** LastRunTime **早于"本 agent 启动时刻"** → 清。
//
// ⚠️ 2026-10-11 真机(Parallels Win11 ARM)修正:原先用「早于 1 小时」作阈值,
// 实测发现**频繁 restart(间隔 < 1h)时旧任务照样累积**(v26 想治的僵尸只是从
// "永不清理"变成"1 小时后才清")。根因是阈值选得太粗:
// 阈值应表达「**这个任务是不是我(当前 agent)的宿主**」——
// 凡在**我启动之前**就运行的任务,必然不是我的宿主(我的宿主是刚刚才创建的),
// 因此可以安全删除。用 uptime 换算出的启动时刻正是这个边界。
`$agentStart = (Get-Date).AddMilliseconds(-${Math.max(0, Math.round(process.uptime() * 1000))})`,
`$cut = $agentStart`,
`Get-ScheduledTask -TaskName 'nodeagent-selfrestart*' -ErrorAction SilentlyContinue | Where-Object { if ($_.State -ne 'Running') { return $true }; $ti = ($_ | Get-ScheduledTaskInfo -ErrorAction SilentlyContinue); if ($ti -and $ti.LastRunTime -and $ti.LastRunTime -lt $cut) { return $true }; return $false } | Unregister-ScheduledTask -Confirm:$false -ErrorAction SilentlyContinue`,
// ⚠️ 不要手写 `replace(/'/g,"''")` —— 那是「又发明一份 psLit」,两份实现必然漂移。
// PS 里 `\` 不是转义符、双引号串还会插值,故 cmdLine 唯一安全的落法就是单引号字面量。
`$a = New-ScheduledTaskAction -Execute 'cmd.exe' -Argument ${psLit(cmdLine)}`,
// 不限时(默认 72h 后强制结束),保证 node 能长期运行
`$s = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Seconds 0)`,
// ⚠️ 必须用最高权限:agent 通常以管理员运行,普通权限的 taskkill 杀不掉它,
// ① 先试「最高权限」:agent 通常以管理员运行,普通权限的 taskkill 杀不掉它,
// 否则会出现「新进程起来了但端口被占(EADDRINUSE)」。
`$p = New-ScheduledTaskPrincipal -UserId $env:USERNAME -RunLevel Highest -LogonType Interactive`,
`Register-ScheduledTask -TaskName '${TASK}' -Action $a -Settings $s -Principal $p -Force | Out-Null`,
// ② 降级:**非管理员身份下 Highest 会被系统拒绝**(真机 2026-10-09 坐实:
// agent 以普通用户运行时,deploy/restart 一律报"注册重启任务失败 exit=1")。
// 此时不指定 -Principal —— 默认继承当前用户权限级别(Limited),
// 注册能成功,且同权限下 taskkill 自己完全够用。
`if ((Get-ScheduledTask -TaskName '${TASK}' -ErrorAction SilentlyContinue) -eq $null) { Register-ScheduledTask -TaskName '${TASK}' -Action $a -Settings $s -Force | Out-Null }`,
// 校验注册真的成功(SilentlyContinue 会吞错,必须显式确认)
`if ((Get-ScheduledTask -TaskName '${TASK}' -ErrorAction SilentlyContinue) -eq $null) { Write-Output 'register-failed'; exit 1 }`,
`Start-ScheduledTask -TaskName '${TASK}'`,
@@ -600,6 +625,31 @@ export async function auditVerify(_args: Args): Promise<unknown> {
return verifyAudit();
}
/**
* v2.0.0:返回审计链的链头(只读)。
*
* 用途:控制端定期拉取并存到**链外**(Mac 本地 / 另一台机器)—— 这是"外部锚定"的一半。
* compare=true 时附带与最近一条锚点的比对结论(另一半)。
*/
export async function auditHead(args: Args): Promise<unknown> {
const head = computeAuditHead();
if (args['compare'] !== true) return head;
return { ...head, comparison: compareWithAnchors(args['anchor_path'] as string | undefined) };
}
/**
* v2.0.0:把当前链头**追加**写到锚点文件(默认 <数据目录>/audit-anchors.jsonl)。
*
* 为什么是追加而不是覆盖:历史锚点一旦写成就不可被后续覆盖,否则攻击者重写链后
* 再"刷新"锚点即可抹掉痕迹。追加式让每次锚定都留痕。
* path 若指定,仍受 fs_roots 白名单约束(不新开写入面)。
*/
export async function auditAnchor(args: Args): Promise<unknown> {
const raw = args['path'] as string | undefined;
const path = raw ? guardPath(raw) : undefined;
return anchorAudit({ ...(path ? { path } : {}), ...(args['note'] ? { note: String(args['note']) } : {}) });
}
/** v13:成功指标聚合(对齐 PRD 2.2)—— 数据源为审计日志,无需额外埋点。 */
export async function metricsReport(args: Args): Promise<unknown> {
const since = args['since'] as number | undefined;
+7
View File
@@ -132,6 +132,13 @@ function getTask(id: string): TaskRecord {
// ---------------- system.task.list ----------------
/** v2.0.0:正在跑的任务数(连接层"有空闲断开但不动长任务"的判据)。 */
export function runningTaskCount(): number {
let n = 0;
for (const t of tasks.values()) if (t.state === 'running') n += 1;
return n;
}
export async function taskList(_args: Args): Promise<unknown> {
return {
tasks: [...tasks.values()].map((t) => ({
@@ -0,0 +1,42 @@
/**
* 枚举可见顶层窗口 → JSON(**不含 prelude**,供常驻助手与一次性路径复用)
*
* 2026-10 从 window.ts 抽出(结构拆分,**内容逐字节不变**)。
* ⚠️ WIN_HELPER_PRELUDE 是本段与另外几段的拼接 —— 改动会直接影响常驻 PS 助手。
* 拆分以 tests/unit/win-prelude.test.mjs + prelude sha256 比对双重把关。
*/
/**
* 枚举可见顶层窗口 → JSON(**不含 prelude**,供常驻助手与一次性路径复用)。
* 注意:本段会被送进常驻进程执行,**严禁出现 exit**(会杀掉助手进程)。
*/
export const LIST_BODY = `
$fg = [NAWin32]::GetForegroundWindow()
$list = New-Object System.Collections.ArrayList
$cb = [NAWin32+EnumProc]{
param($h, $l)
if (-not [NAWin32]::IsWindowVisible($h)) { return $true }
$sb = New-Object System.Text.StringBuilder 512
[void][NAWin32]::GetWindowTextW($h, $sb, 512)
$title = $sb.ToString()
if ([string]::IsNullOrWhiteSpace($title)) { return $true }
$r = New-Object NAWin32+RECT
if (-not [NAWin32]::GetWindowRect($h, [ref]$r)) { return $true }
$w = $r.Right - $r.Left; $ht = $r.Bottom - $r.Top
if ($w -le 0 -or $ht -le 0) { return $true }
# ⚠️ 变量名不能叫 $pid —— 它是 PowerShell 的自动变量(当前进程 ID),赋值会被忽略,
# 导致所有窗口都报成 powershell 进程(真机踩过)。
$procId = 0
[void][NAWin32]::GetWindowThreadProcessId($h, [ref]$procId)
$pn = ''
try { $pn = (Get-Process -Id $procId -ErrorAction Stop).ProcessName } catch {}
[void]$list.Add([pscustomobject]@{
hwnd = ('0x{0:X}' -f $h.ToInt64()); title = $title; process = $pn; pid = $procId
x = $r.Left; y = $r.Top; width = $w; height = $ht
is_foreground = ($h -eq $fg); is_minimized = [NAWin32]::IsIconic($h)
})
return $true
}
[void][NAWin32]::EnumWindows($cb, [IntPtr]::Zero)
$list | ConvertTo-Json -Compress
`;
@@ -0,0 +1,226 @@
/**
* 图像模板匹配预加载段(ZNCC 粗到精)
*
* 2026-10 从 window.ts 抽出(结构拆分,**内容逐字节不变**)。
* ⚠️ WIN_HELPER_PRELUDE 是本段与另外几段的拼接 —— 改动会直接影响常驻 PS 助手。
* 拆分以 tests/unit/win-prelude.test.mjs + prelude sha256 比对双重把关。
*/
/**
* 图像模板匹配预加载段(v15)。
*
* 动机:UIA 靠控件树、OCR 靠文字 —— **纯图标/无文字的控件**两者都抓不到(审查报告 §5.2 指出的盲区)。
* 做法:把模板图与屏幕区域都转灰度,做**零均值归一化互相关(ZNCC)**;
* 全分辨率逐像素太慢(3440×1440 屏 × 64×64 模板 ≈ 190 亿次),故**粗到精**:
* ① 1/4 分辨率全图扫描(约 8000 万次)取候选
* ② 对候选在全分辨率 ±10px 邻域精修
* 编译一次常驻(随助手预加载),运行时纯 native 循环。
* 亮度和对比度无关(ZNCC 归一化),故对主题/亮度变化不敏感。
*/
export const IMAGE_MATCH_PRELUDE = `
Add-Type -AssemblyName System.Drawing
# ⚠️ Add-Type 的老坑:-AssemblyName 只把程序集加载进 PowerShell 会话,
# 编译 C# 时还必须显式 -ReferencedAssemblies,否则报「命名空间 System.Drawing
# 中不存在 Imaging」。真机踩过:编译失败会让整个助手起不来,并伴随误导性的
# 「找不到类型 System.Windows.Forms.SystemInformation」二次错误。
Add-Type @"
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Imaging;
using System.Runtime.InteropServices;
public class NAImageMatch {
public class Hit { public int x; public int y; public int w; public int h; public double score; }
private static byte[] Gray(Bitmap src, out int w, out int h) {
w = src.Width; h = src.Height;
var dst = new byte[w * h];
var rect = new Rectangle(0, 0, w, h);
Bitmap bmp = src.PixelFormat == PixelFormat.Format24bppRgb ? src : src.Clone(rect, PixelFormat.Format24bppRgb);
var data = bmp.LockBits(rect, ImageLockMode.ReadOnly, PixelFormat.Format24bppRgb);
try {
int stride = data.Stride;
var buf = new byte[stride * h];
Marshal.Copy(data.Scan0, buf, 0, buf.Length);
for (int y = 0; y < h; y++) {
int row = y * stride;
for (int x = 0; x < w; x++) {
int i = row + x * 3;
// BGR -> luma (integer approximation of ITU-R BT.601)
dst[y * w + x] = (byte)((buf[i + 2] * 77 + buf[i + 1] * 150 + buf[i] * 29) >> 8);
}
}
} finally { bmp.UnlockBits(data); if (bmp != src) bmp.Dispose(); }
return dst;
}
// Area-average downscale (coarse search layer)
private static byte[] Downscale(byte[] src, int w, int h, int f, out int ow, out int oh) {
ow = w / f; oh = h / f;
var dst = new byte[ow * oh];
for (int y = 0; y < oh; y++) {
for (int x = 0; x < ow; x++) {
int sum = 0;
for (int dy = 0; dy < f; dy++) {
int row = (y * f + dy) * w;
for (int dx = 0; dx < f; dx++) sum += src[row + x * f + dx];
}
dst[y * ow + x] = (byte)(sum / (f * f));
}
}
return dst;
}
// Zero-mean normalized cross correlation; 1.0 = identical
private static double Zncc(byte[] S, int sw, int sx, int sy, byte[] T, int tw, int th) {
int n = tw * th;
double sumT = 0;
for (int i = 0; i < n; i++) sumT += T[i];
double meanT = sumT / n;
double sumS = 0;
for (int y = 0; y < th; y++) {
int row = (sy + y) * sw + sx;
for (int x = 0; x < tw; x++) sumS += S[row + x];
}
double meanS = sumS / n;
double num = 0, dS = 0, dT = 0;
for (int y = 0; y < th; y++) {
int row = (sy + y) * sw + sx;
int trow = y * tw;
for (int x = 0; x < tw; x++) {
double a = S[row + x] - meanS;
double b = T[trow + x] - meanT;
num += a * b; dS += a * a; dT += b * b;
}
}
double den = Math.Sqrt(dS * dT);
if (den < 1e-9) return 0;
return num / den;
}
/**
// (comment removed: ASCII only)
// (comment removed: ASCII only)
*/
public static string Match(string screenPath, string templatePath, int maxResults, double threshold, int searchX, int searchY, int searchW, int searchH) {
var hits = new List<Hit>();
using (var screenBmp = new Bitmap(screenPath))
using (var tmplBmp = new Bitmap(templatePath)) {
int sw, sh, tw, th;
var S0 = Gray(screenBmp, out sw, out sh);
var T0 = Gray(tmplBmp, out tw, out th);
if (tw >= sw || th >= sh) return "[]";
// Guard: a flat template carries no gradient information, so correlation is
// undefined (ZNCC denominator ~ 0). Report it explicitly instead of silently
// returning no hits - a uniform crop usually means the wrong region was picked.
//
// NOTE: build all JSON/string literals with the char constant Q.
// Writing a backslash-quote inside a TS template literal collapses it to a bare
// quote and breaks the C# compile - this trap bit this file twice.
const char Q = '"';
var ci0 = System.Globalization.CultureInfo.InvariantCulture;
double tSum = 0, tSum2 = 0;
for (int i = 0; i < T0.Length; i++) { tSum += T0[i]; tSum2 += (double)T0[i] * T0[i]; }
double tMean = tSum / T0.Length;
double tVar = (tSum2 / T0.Length) - (tMean * tMean);
if (tVar < 4.0) {
return "[" + "{" + Q + "__error" + Q + ":" + Q + "template_has_no_contrast" + Q
+ "," + Q + "variance" + Q + ":" + tVar.ToString("F2", ci0) + "}" + "]";
}
// Optional search window: crop a sub-image, then add the offset back to hit coords
int ox = 0, oy = 0;
if (searchW > 0 && searchH > 0) {
ox = Math.Max(0, Math.Min(searchX, sw - 1));
oy = Math.Max(0, Math.Min(searchY, sh - 1));
int w = Math.Min(searchW, sw - ox), h = Math.Min(searchH, sh - oy);
var sub = new byte[w * h];
for (int y = 0; y < h; y++) Array.Copy(S0, (oy + y) * sw + ox, sub, y * w, w);
S0 = sub; sw = w; sh = h;
}
const int F = 4;
int sw4, sh4, tw4, th4;
var S4 = Downscale(S0, sw, sh, F, out sw4, out sh4);
var T4 = Downscale(T0, tw, th, F, out tw4, out th4);
// (1) coarse scan: collect candidates.
// NOTE: the pre-filter must be MUCH looser than the final threshold.
// Downsampling lowers the correlation of the true position (typically 0.7-0.85
// for a 0.9+ full-res match), so a tight pre-filter discards the right answer
// before refinement ever runs (this exact bug made every match return empty).
var cands = new List<Hit>();
if (tw4 > 0 && th4 > 0 && tw4 <= sw4 && th4 <= sh4) {
double preFilter = Math.Max(0.30, threshold - 0.30);
double bestCoarse = -1; int bx4 = 0, by4 = 0;
for (int y = 0; y <= sh4 - th4; y++) {
for (int x = 0; x <= sw4 - tw4; x++) {
double sc = Zncc(S4, sw4, x, y, T4, tw4, th4);
if (sc > bestCoarse) { bestCoarse = sc; bx4 = x; by4 = y; }
if (sc >= preFilter) cands.Add(new Hit { x = x * F, y = y * F, w = tw, h = th, score = sc });
}
}
// Belt and braces: always keep the coarse best, even if it missed the pre-filter
if (bestCoarse > 0) {
bool seen = false;
foreach (var c in cands) { if (Math.Abs(c.x - bx4 * F) < F && Math.Abs(c.y - by4 * F) < F) { seen = true; break; } }
if (!seen) cands.Add(new Hit { x = bx4 * F, y = by4 * F, w = tw, h = th, score = bestCoarse });
}
}
cands.Sort((a, b) => b.score.CompareTo(a.score));
if (cands.Count > 40) cands.RemoveRange(40, cands.Count - 40);
// (2) refine at full resolution within a +/-F*2 neighborhood
foreach (var c in cands) {
double best = -1; int bx = c.x, by = c.y;
for (int dy = -F * 2; dy <= F * 2; dy++) {
for (int dx = -F * 2; dx <= F * 2; dx++) {
int x = c.x + dx, y = c.y + dy;
if (x < 0 || y < 0 || x + tw > sw || y + th > sh) continue;
double sc = Zncc(S0, sw, x, y, T0, tw, th);
if (sc > best) { best = sc; bx = x; by = y; }
}
}
if (best >= threshold) hits.Add(new Hit { x = bx + ox, y = by + oy, w = tw, h = th, score = best });
}
// (3) de-dup: non-maximum suppression (keep best within 8px)
hits.Sort((a, b) => b.score.CompareTo(a.score));
var final = new List<Hit>();
foreach (var h in hits) {
bool dup = false;
foreach (var f in final) {
if (Math.Abs(f.x - h.x) < 8 && Math.Abs(f.y - h.y) < 8) { dup = true; break; }
}
if (!dup) final.Add(h);
if (final.Count >= maxResults) break;
}
var parts = new List<string>();
foreach (var h in final) {
// Build JSON by concatenation; reuse the shared Q / ci0 declared above.
var ci = ci0;
parts.Add("{" + Q + "x" + Q + ":" + h.x.ToString(ci) + "," + Q + "y" + Q + ":" + h.y.ToString(ci)
+ "," + Q + "width" + Q + ":" + h.w.ToString(ci) + "," + Q + "height" + Q + ":" + h.h.ToString(ci)
+ "," + Q + "score" + Q + ":" + h.score.ToString("F4", ci) + "}");
}
return "[" + String.Join(",", parts.ToArray()) + "]";
}
}
// Capture a screen region to a file (avoids extra process round-trips)
public static string Capture(string outPath, int x, int y, int w, int h) {
using (var bmp = new Bitmap(w, h))
using (var g = Graphics.FromImage(bmp)) {
g.CopyFromScreen(x, y, 0, 0, new Size(w, h));
bmp.Save(outPath, ImageFormat.Png);
}
return outPath;
}
}
"@ -ReferencedAssemblies System.Drawing
`;
@@ -0,0 +1,39 @@
/**
* OCR 引擎预加载段:截图 / DPI / WinRT 类型与 Await 辅助函数
*
* 2026-10 从 window.ts 抽出(结构拆分,**内容逐字节不变**)。
* ⚠️ WIN_HELPER_PRELUDE 是本段与另外几段的拼接 —— 改动会直接影响常驻 PS 助手。
* 拆分以 tests/unit/win-prelude.test.mjs + prelude sha256 比对双重把关。
*/
/**
* OCR 引擎预加载段(v14):截图 / WinRT 类型与 Await 辅助函数。
* 注意:WinRT 类型加载用 try/catch 包住 —— 个别环境缺组件时只应让 OCR 退化,
* 不该让整个助手(连带 window.list/focus/UIA)起不来。
*
* ⚠️ DPI 感知(`SetProcessDPIAware`)**已从本段移出**,改由 WIN_HELPER_PRELUDE 最前面的
* `PS_DPI_AWARE` 无条件执行:放在这个 try 里会被 catch 顺带吞掉,一旦吞掉
* 助手就退回 DPI 不感知,`GetWindowRect` 与 UIA / 截图坐标口径不一致(真机 2026-10-12 排查)。
*/
export const OCR_PRELUDE = `
Add-Type -AssemblyName System.Drawing
Add-Type -AssemblyName System.Windows.Forms
try {
Add-Type -AssemblyName System.Runtime.WindowsRuntime
$null = [Windows.Media.Ocr.OcrEngine, Windows.Foundation, ContentType=WindowsRuntime]
$null = [Windows.Graphics.Imaging.BitmapDecoder, Windows.Foundation, ContentType=WindowsRuntime]
$null = [Windows.Storage.Streams.InMemoryRandomAccessStream, Windows.Foundation, ContentType=WindowsRuntime]
$null = [Windows.Storage.Streams.DataWriter, Windows.Foundation, ContentType=WindowsRuntime]
$null = [Windows.Graphics.Imaging.SoftwareBitmap, Windows.Foundation, ContentType=WindowsRuntime]
$script:asTaskGeneric = ([System.WindowsRuntimeSystemExtensions].GetMethods() | Where-Object { $_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation\`1' })[0]
function global:Await($WinRtTask, $ResultType) {
$asTask = $script:asTaskGeneric.MakeGenericMethod($ResultType)
$netTask = $asTask.Invoke($null, @($WinRtTask))
$netTask.Wait(-1) | Out-Null
$netTask.Result
}
$script:ocrReady = $true
} catch {
$script:ocrReady = $false
}
`;
@@ -0,0 +1,117 @@
/**
* UIA 控件树预加载段(含 Where 谓词与属性读取)
*
* 2026-10 从 window.ts 抽出(结构拆分,**内容逐字节不变**)。
* ⚠️ WIN_HELPER_PRELUDE 是本段与另外几段的拼接 —— 改动会直接影响常驻 PS 助手。
* 拆分以 tests/unit/win-prelude.test.mjs + prelude sha256 比对双重把关。
*/
/**
* v14:常驻助手预加载段 —— 这些内容**只在助手启动时执行一次**。
* 过去每次调用都重新编译 C# / 加载程序集,真机实测占单次耗时的绝大部分
* (window.list 1267ms / window.focus 1631ms,其中「起进程+编译」是固定成本)。
*/
export const UIA_ASSEMBLY_PRELUDE = `
Add-Type -AssemblyName UIAutomationClient
Add-Type -AssemblyName UIAutomationTypes
Add-Type -AssemblyName WindowsBase
function Get-WinByTitle([string]$re) {
$root = [System.Windows.Automation.AutomationElement]::RootElement
$cond = New-Object System.Windows.Automation.PropertyCondition(
[System.Windows.Automation.AutomationElement]::ControlTypeProperty,
[System.Windows.Automation.ControlType]::Window)
$wins = $root.FindAll([System.Windows.Automation.TreeScope]::Children, $cond)
foreach ($w in $wins) {
try {
$t = $w.Current.Name
if ($t -and $t -match $re) { return $w }
} catch {}
}
return $null
}
# v14:按子串在给定 scope 内遍历查找(FindAll + 子串过滤)。
# UIA 的 PropertyCondition 只能精确匹配,故必须遍历后过滤;遍历规模设上限防超大 UI 树卡死。
# v2.0.0:读取元素的 UIA 属性(v2.0.0 语义属性化)。
# 为什么逐个 try:ValuePattern / SelectionItemPattern / TogglePattern 并非所有控件
# 都支持(不支持时 GetSupportedPattern 返回 false 或抛异常),而且**部分应用
# (Electron / 游戏 UI)根本给不出值** —— 拿不到就留空,由上层判"未知"而非"false"。
function Get-UiaProps($e) {
$enabled = $true
try { $enabled = [bool]$e.Current.IsEnabled } catch {}
$value = $null
try {
$vp = $e.GetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern)
if ($vp -ne $null) { $value = $vp.Current.Value }
} catch {}
$selected = $null
try {
$sp = $e.GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern)
if ($sp -ne $null) { $selected = [bool]$sp.Current.IsSelected }
} catch {}
$toggle = $null
try {
$tp = $e.GetCurrentPattern([System.Windows.Automation.TogglePattern]::Pattern)
if ($tp -ne $null) { $toggle = "$($tp.Current.ToggleState)" }
} catch {}
return @{ enabled = $enabled; value = $value; selected = $selected; toggle = $toggle }
}
function Test-UiaWhere($props, $where) {
if ($where -eq $null) { return $true }
foreach ($k in $where.Keys) {
$want = $where[$k]
switch ($k) {
'enabled' { if ($props.enabled -ne [bool]$want) { return $false } }
'selected' { if ($null -eq $props.selected -or $props.selected -ne [bool]$want) { return $false } }
'value' { if ($props.value -eq $null -or $props.value -notlike [string]$want) { return $false } }
default { } # 未知键忽略(Node 侧已白名单校验)
'toggle' { if ($props.toggle -eq $null -or $props.toggle -notlike [string]$want) { return $false } }
}
}
return $true
}
function Find-UiaByText($scope, [string]$text, [int]$limit, [string]$ct, $where) {
$cond = [System.Windows.Automation.Condition]::TrueCondition
if ($ct -ne '') {
$ctObj = $null
try { $ctObj = [System.Windows.Automation.ControlType]::$ct } catch {}
if ($ctObj -ne $null) {
$cond = New-Object System.Windows.Automation.PropertyCondition(
[System.Windows.Automation.AutomitionElement]::ControlTypeProperty, $ctObj)
}
}
$found = $scope.FindAll([System.Windows.Automation.TreeScope]::Descendants, $cond)
$out = New-Object System.Collections.ArrayList
$scanned = 0
foreach ($e in $found) {
if ($out.Count -ge $limit) { break }
$scanned++
if ($scanned -gt 20000) { break }
try {
$nm = $e.Current.Name
if ([string]::IsNullOrEmpty($nm)) { continue }
if ($text -ne '' -and $nm.IndexOf($text, [System.StringComparison]::OrdinalIgnoreCase) -lt 0) { continue }
$r = $e.Current.BoundingRectangle
if ($r.Width -le 0 -or $r.Height -le 0) { continue }
$props = Get-UiaProps $e
if (-not (Test-UiaWhere $props $where)) { continue }
[void]$out.Add([pscustomobject]@{
name = $nm
control_type = ($e.Current.ControlType.ProgrammaticName -replace 'ControlType\\.','')
automation_id = $e.Current.AutomationId
x = [int]($r.Left + $r.Width / 2)
y = [int]($r.Top + $r.Height / 2)
left = [int]$r.Left; top = [int]$r.Top
width = [int]$r.Width; height = [int]$r.Height
enabled = $props.enabled
value = $props.value
selected = $props.selected
toggle = $props.toggle
})
} catch {}
}
return ,$out
}
`;
@@ -0,0 +1,30 @@
/**
* Win32 窗口枚举与矩形读取(供 window.list / window.focus 复用)
*
* 2026-10 从 window.ts 抽出(结构拆分,**内容逐字节不变**)。
* ⚠️ WIN_HELPER_PRELUDE 是本段与另外几段的拼接 —— 改动会直接影响常驻 PS 助手。
* 拆分以 tests/unit/win-prelude.test.mjs + prelude sha256 比对双重把关。
*/
export const WIN32_PRELUDE = `
Add-Type @"
using System;
using System.Text;
using System.Runtime.InteropServices;
public class NAWin32 {
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left, Top, Right, Bottom; }
public delegate bool EnumProc(IntPtr hWnd, IntPtr lParam);
[DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr p);
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);
[DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetWindowTextW(IntPtr h, StringBuilder s, int n);
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
[DllImport("user32.dll")] public static extern void mouse_event(uint f, int dx, int dy, uint d, UIntPtr e);
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd);
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
}
"@
`;
+87 -385
View File
@@ -1,11 +1,19 @@
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { IS_WINDOWS, execCommand } from '../util/exec.js';
import { runPowerShellSmart } from '../util/ps-helper.js';
import { psLit } from '../util/quote.js';
import { agentDir } from '../config.js';
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { macScreenFind, macWindowFocus, macWindowList } from './darwin.js';
import { PS_DPI_AWARE } from '../util/dpi.js';
import { WIN32_PRELUDE } from './win-ps/prelude-win32.js';
import { LIST_BODY } from './win-ps/body-list.js';
import { UIA_ASSEMBLY_PRELUDE } from './win-ps/prelude-uia.js';
import { OCR_PRELUDE } from './win-ps/prelude-ocr.js';
import { IMAGE_MATCH_PRELUDE } from './win-ps/prelude-image.js';
type Args = Record<string, unknown>;
function toArray<T>(v: T | T[] | null | undefined): T[] {
@@ -80,381 +88,11 @@ function extractJson<T>(raw: string): T | null {
}
/** Win32 窗口枚举与矩形读取(供 window.list / window.focus 复用)。 */
const WIN32_PRELUDE = `
Add-Type @"
using System;
using System.Text;
using System.Runtime.InteropServices;
public class NAWin32 {
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left, Top, Right, Bottom; }
public delegate bool EnumProc(IntPtr hWnd, IntPtr lParam);
[DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr p);
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);
[DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetWindowTextW(IntPtr h, StringBuilder s, int n);
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
[DllImport("user32.dll")] public static extern void mouse_event(uint f, int dx, int dy, uint d, UIntPtr e);
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd);
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
}
"@
`;
/**
* 枚举可见顶层窗口 → JSON(**不含 prelude**,供常驻助手与一次性路径复用)。
* 注意:本段会被送进常驻进程执行,**严禁出现 exit**(会杀掉助手进程)。
*/
const LIST_BODY = `
$fg = [NAWin32]::GetForegroundWindow()
$list = New-Object System.Collections.ArrayList
$cb = [NAWin32+EnumProc]{
param($h, $l)
if (-not [NAWin32]::IsWindowVisible($h)) { return $true }
$sb = New-Object System.Text.StringBuilder 512
[void][NAWin32]::GetWindowTextW($h, $sb, 512)
$title = $sb.ToString()
if ([string]::IsNullOrWhiteSpace($title)) { return $true }
$r = New-Object NAWin32+RECT
if (-not [NAWin32]::GetWindowRect($h, [ref]$r)) { return $true }
$w = $r.Right - $r.Left; $ht = $r.Bottom - $r.Top
if ($w -le 0 -or $ht -le 0) { return $true }
# ⚠️ 变量名不能叫 $pid —— 它是 PowerShell 的自动变量(当前进程 ID),赋值会被忽略,
# 导致所有窗口都报成 powershell 进程(真机踩过)。
$procId = 0
[void][NAWin32]::GetWindowThreadProcessId($h, [ref]$procId)
$pn = ''
try { $pn = (Get-Process -Id $procId -ErrorAction Stop).ProcessName } catch {}
[void]$list.Add([pscustomobject]@{
hwnd = ('0x{0:X}' -f $h.ToInt64()); title = $title; process = $pn; pid = $procId
x = $r.Left; y = $r.Top; width = $w; height = $ht
is_foreground = ($h -eq $fg); is_minimized = [NAWin32]::IsIconic($h)
})
return $true
}
[void][NAWin32]::EnumWindows($cb, [IntPtr]::Zero)
$list | ConvertTo-Json -Compress
`;
/**
* v14:常驻助手预加载段 —— 这些内容**只在助手启动时执行一次**。
* 过去每次调用都重新编译 C# / 加载程序集,真机实测占单次耗时的绝大部分
* (window.list 1267ms / window.focus 1631ms,其中「起进程+编译」是固定成本)。
*/
const UIA_ASSEMBLY_PRELUDE = `
Add-Type -AssemblyName UIAutomationClient
Add-Type -AssemblyName UIAutomationTypes
Add-Type -AssemblyName WindowsBase
function Get-WinByTitle([string]$re) {
$root = [System.Windows.Automation.AutomationElement]::RootElement
$cond = New-Object System.Windows.Automation.PropertyCondition(
[System.Windows.Automation.AutomationElement]::ControlTypeProperty,
[System.Windows.Automation.ControlType]::Window)
$wins = $root.FindAll([System.Windows.Automation.TreeScope]::Children, $cond)
foreach ($w in $wins) {
try {
$t = $w.Current.Name
if ($t -and $t -match $re) { return $w }
} catch {}
}
return $null
}
# v14:按子串在给定 scope 内遍历查找(FindAll + 子串过滤)。
# UIA 的 PropertyCondition 只能精确匹配,故必须遍历后过滤;遍历规模设上限防超大 UI 树卡死。
function Find-UiaByText($scope, [string]$text, [int]$limit, [string]$ct) {
$cond = [System.Windows.Automation.Condition]::TrueCondition
if ($ct -ne '') {
$ctObj = $null
try { $ctObj = [System.Windows.Automation.ControlType]::$ct } catch {}
if ($ctObj -ne $null) {
$cond = New-Object System.Windows.Automation.PropertyCondition(
[System.Windows.Automation.AutomationElement]::ControlTypeProperty, $ctObj)
}
}
$found = $scope.FindAll([System.Windows.Automation.TreeScope]::Descendants, $cond)
$out = New-Object System.Collections.ArrayList
$scanned = 0
foreach ($e in $found) {
if ($out.Count -ge $limit) { break }
$scanned++
if ($scanned -gt 20000) { break }
try {
$nm = $e.Current.Name
if ([string]::IsNullOrEmpty($nm)) { continue }
if ($nm.IndexOf($text, [System.StringComparison]::OrdinalIgnoreCase) -lt 0) { continue }
$r = $e.Current.BoundingRectangle
if ($r.Width -le 0 -or $r.Height -le 0) { continue }
[void]$out.Add([pscustomobject]@{
name = $nm
control_type = ($e.Current.ControlType.ProgrammaticName -replace 'ControlType\\.','')
automation_id = $e.Current.AutomationId
x = [int]($r.Left + $r.Width / 2)
y = [int]($r.Top + $r.Height / 2)
left = [int]$r.Left; top = [int]$r.Top
width = [int]$r.Width; height = [int]$r.Height
})
} catch {}
}
return ,$out
}
`;
/** 助手统一预加载段(window.list / window.focus / screen.find 共用同一个进程)。 */
/**
* OCR 引擎预加载段(v14):截图 / DPI / WinRT 类型与 Await 辅助函数。
* 注意:WinRT 类型加载用 try/catch 包住 —— 个别环境缺组件时只应让 OCR 退化,
* 不该让整个助手(连带 window.list/focus/UIA)起不来。
*/
const OCR_PRELUDE = `
Add-Type -AssemblyName System.Drawing
Add-Type -AssemblyName System.Windows.Forms
try {
Add-Type -AssemblyName System.Runtime.WindowsRuntime
Add-Type -TypeDefinition 'using System.Runtime.InteropServices; public class NADPI { [DllImport("user32.dll")] public static extern bool SetProcessDPIAware(); }'
[void][NADPI]::SetProcessDPIAware()
$null = [Windows.Media.Ocr.OcrEngine, Windows.Foundation, ContentType=WindowsRuntime]
$null = [Windows.Graphics.Imaging.BitmapDecoder, Windows.Foundation, ContentType=WindowsRuntime]
$null = [Windows.Storage.Streams.InMemoryRandomAccessStream, Windows.Foundation, ContentType=WindowsRuntime]
$null = [Windows.Storage.Streams.DataWriter, Windows.Foundation, ContentType=WindowsRuntime]
$null = [Windows.Graphics.Imaging.SoftwareBitmap, Windows.Foundation, ContentType=WindowsRuntime]
$script:asTaskGeneric = ([System.WindowsRuntimeSystemExtensions].GetMethods() | Where-Object { $_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation\`1' })[0]
function global:Await($WinRtTask, $ResultType) {
$asTask = $script:asTaskGeneric.MakeGenericMethod($ResultType)
$netTask = $asTask.Invoke($null, @($WinRtTask))
$netTask.Wait(-1) | Out-Null
$netTask.Result
}
$script:ocrReady = $true
} catch {
$script:ocrReady = $false
}
`;
/**
* 图像模板匹配预加载段(v15)。
*
* 动机:UIA 靠控件树、OCR 靠文字 —— **纯图标/无文字的控件**两者都抓不到(审查报告 §5.2 指出的盲区)。
* 做法:把模板图与屏幕区域都转灰度,做**零均值归一化互相关(ZNCC)**;
* 全分辨率逐像素太慢(3440×1440 屏 × 64×64 模板 ≈ 190 亿次),故**粗到精**:
* ① 1/4 分辨率全图扫描(约 8000 万次)取候选
* ② 对候选在全分辨率 ±10px 邻域精修
* 编译一次常驻(随助手预加载),运行时纯 native 循环。
* 亮度和对比度无关(ZNCC 归一化),故对主题/亮度变化不敏感。
*/
const IMAGE_MATCH_PRELUDE = `
Add-Type -AssemblyName System.Drawing
# ⚠️ Add-Type 的老坑:-AssemblyName 只把程序集加载进 PowerShell 会话,
# 编译 C# 时还必须显式 -ReferencedAssemblies,否则报「命名空间 System.Drawing
# 中不存在 Imaging」。真机踩过:编译失败会让整个助手起不来,并伴随误导性的
# 「找不到类型 System.Windows.Forms.SystemInformation」二次错误。
Add-Type @"
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Imaging;
using System.Runtime.InteropServices;
public class NAImageMatch {
public class Hit { public int x; public int y; public int w; public int h; public double score; }
private static byte[] Gray(Bitmap src, out int w, out int h) {
w = src.Width; h = src.Height;
var dst = new byte[w * h];
var rect = new Rectangle(0, 0, w, h);
Bitmap bmp = src.PixelFormat == PixelFormat.Format24bppRgb ? src : src.Clone(rect, PixelFormat.Format24bppRgb);
var data = bmp.LockBits(rect, ImageLockMode.ReadOnly, PixelFormat.Format24bppRgb);
try {
int stride = data.Stride;
var buf = new byte[stride * h];
Marshal.Copy(data.Scan0, buf, 0, buf.Length);
for (int y = 0; y < h; y++) {
int row = y * stride;
for (int x = 0; x < w; x++) {
int i = row + x * 3;
// BGR -> luma (integer approximation of ITU-R BT.601)
dst[y * w + x] = (byte)((buf[i + 2] * 77 + buf[i + 1] * 150 + buf[i] * 29) >> 8);
}
}
} finally { bmp.UnlockBits(data); if (bmp != src) bmp.Dispose(); }
return dst;
}
// Area-average downscale (coarse search layer)
private static byte[] Downscale(byte[] src, int w, int h, int f, out int ow, out int oh) {
ow = w / f; oh = h / f;
var dst = new byte[ow * oh];
for (int y = 0; y < oh; y++) {
for (int x = 0; x < ow; x++) {
int sum = 0;
for (int dy = 0; dy < f; dy++) {
int row = (y * f + dy) * w;
for (int dx = 0; dx < f; dx++) sum += src[row + x * f + dx];
}
dst[y * ow + x] = (byte)(sum / (f * f));
}
}
return dst;
}
// Zero-mean normalized cross correlation; 1.0 = identical
private static double Zncc(byte[] S, int sw, int sx, int sy, byte[] T, int tw, int th) {
int n = tw * th;
double sumT = 0;
for (int i = 0; i < n; i++) sumT += T[i];
double meanT = sumT / n;
double sumS = 0;
for (int y = 0; y < th; y++) {
int row = (sy + y) * sw + sx;
for (int x = 0; x < tw; x++) sumS += S[row + x];
}
double meanS = sumS / n;
double num = 0, dS = 0, dT = 0;
for (int y = 0; y < th; y++) {
int row = (sy + y) * sw + sx;
int trow = y * tw;
for (int x = 0; x < tw; x++) {
double a = S[row + x] - meanS;
double b = T[trow + x] - meanT;
num += a * b; dS += a * a; dT += b * b;
}
}
double den = Math.Sqrt(dS * dT);
if (den < 1e-9) return 0;
return num / den;
}
/**
// (comment removed: ASCII only)
// (comment removed: ASCII only)
*/
public static string Match(string screenPath, string templatePath, int maxResults, double threshold, int searchX, int searchY, int searchW, int searchH) {
var hits = new List<Hit>();
using (var screenBmp = new Bitmap(screenPath))
using (var tmplBmp = new Bitmap(templatePath)) {
int sw, sh, tw, th;
var S0 = Gray(screenBmp, out sw, out sh);
var T0 = Gray(tmplBmp, out tw, out th);
if (tw >= sw || th >= sh) return "[]";
// Guard: a flat template carries no gradient information, so correlation is
// undefined (ZNCC denominator ~ 0). Report it explicitly instead of silently
// returning no hits - a uniform crop usually means the wrong region was picked.
//
// NOTE: build all JSON/string literals with the char constant Q.
// Writing a backslash-quote inside a TS template literal collapses it to a bare
// quote and breaks the C# compile - this trap bit this file twice.
const char Q = '"';
var ci0 = System.Globalization.CultureInfo.InvariantCulture;
double tSum = 0, tSum2 = 0;
for (int i = 0; i < T0.Length; i++) { tSum += T0[i]; tSum2 += (double)T0[i] * T0[i]; }
double tMean = tSum / T0.Length;
double tVar = (tSum2 / T0.Length) - (tMean * tMean);
if (tVar < 4.0) {
return "[" + "{" + Q + "__error" + Q + ":" + Q + "template_has_no_contrast" + Q
+ "," + Q + "variance" + Q + ":" + tVar.ToString("F2", ci0) + "}" + "]";
}
// Optional search window: crop a sub-image, then add the offset back to hit coords
int ox = 0, oy = 0;
if (searchW > 0 && searchH > 0) {
ox = Math.Max(0, Math.Min(searchX, sw - 1));
oy = Math.Max(0, Math.Min(searchY, sh - 1));
int w = Math.Min(searchW, sw - ox), h = Math.Min(searchH, sh - oy);
var sub = new byte[w * h];
for (int y = 0; y < h; y++) Array.Copy(S0, (oy + y) * sw + ox, sub, y * w, w);
S0 = sub; sw = w; sh = h;
}
const int F = 4;
int sw4, sh4, tw4, th4;
var S4 = Downscale(S0, sw, sh, F, out sw4, out sh4);
var T4 = Downscale(T0, tw, th, F, out tw4, out th4);
// (1) coarse scan: collect candidates.
// NOTE: the pre-filter must be MUCH looser than the final threshold.
// Downsampling lowers the correlation of the true position (typically 0.7-0.85
// for a 0.9+ full-res match), so a tight pre-filter discards the right answer
// before refinement ever runs (this exact bug made every match return empty).
var cands = new List<Hit>();
if (tw4 > 0 && th4 > 0 && tw4 <= sw4 && th4 <= sh4) {
double preFilter = Math.Max(0.30, threshold - 0.30);
double bestCoarse = -1; int bx4 = 0, by4 = 0;
for (int y = 0; y <= sh4 - th4; y++) {
for (int x = 0; x <= sw4 - tw4; x++) {
double sc = Zncc(S4, sw4, x, y, T4, tw4, th4);
if (sc > bestCoarse) { bestCoarse = sc; bx4 = x; by4 = y; }
if (sc >= preFilter) cands.Add(new Hit { x = x * F, y = y * F, w = tw, h = th, score = sc });
}
}
// Belt and braces: always keep the coarse best, even if it missed the pre-filter
if (bestCoarse > 0) {
bool seen = false;
foreach (var c in cands) { if (Math.Abs(c.x - bx4 * F) < F && Math.Abs(c.y - by4 * F) < F) { seen = true; break; } }
if (!seen) cands.Add(new Hit { x = bx4 * F, y = by4 * F, w = tw, h = th, score = bestCoarse });
}
}
cands.Sort((a, b) => b.score.CompareTo(a.score));
if (cands.Count > 40) cands.RemoveRange(40, cands.Count - 40);
// (2) refine at full resolution within a +/-F*2 neighborhood
foreach (var c in cands) {
double best = -1; int bx = c.x, by = c.y;
for (int dy = -F * 2; dy <= F * 2; dy++) {
for (int dx = -F * 2; dx <= F * 2; dx++) {
int x = c.x + dx, y = c.y + dy;
if (x < 0 || y < 0 || x + tw > sw || y + th > sh) continue;
double sc = Zncc(S0, sw, x, y, T0, tw, th);
if (sc > best) { best = sc; bx = x; by = y; }
}
}
if (best >= threshold) hits.Add(new Hit { x = bx + ox, y = by + oy, w = tw, h = th, score = best });
}
// (3) de-dup: non-maximum suppression (keep best within 8px)
hits.Sort((a, b) => b.score.CompareTo(a.score));
var final = new List<Hit>();
foreach (var h in hits) {
bool dup = false;
foreach (var f in final) {
if (Math.Abs(f.x - h.x) < 8 && Math.Abs(f.y - h.y) < 8) { dup = true; break; }
}
if (!dup) final.Add(h);
if (final.Count >= maxResults) break;
}
var parts = new List<string>();
foreach (var h in final) {
// Build JSON by concatenation; reuse the shared Q / ci0 declared above.
var ci = ci0;
parts.Add("{" + Q + "x" + Q + ":" + h.x.ToString(ci) + "," + Q + "y" + Q + ":" + h.y.ToString(ci)
+ "," + Q + "width" + Q + ":" + h.w.ToString(ci) + "," + Q + "height" + Q + ":" + h.h.ToString(ci)
+ "," + Q + "score" + Q + ":" + h.score.ToString("F4", ci) + "}");
}
return "[" + String.Join(",", parts.ToArray()) + "]";
}
}
// Capture a screen region to a file (avoids extra process round-trips)
public static string Capture(string outPath, int x, int y, int w, int h) {
using (var bmp = new Bitmap(w, h))
using (var g = Graphics.FromImage(bmp)) {
g.CopyFromScreen(x, y, 0, 0, new Size(w, h));
bmp.Save(outPath, ImageFormat.Png);
}
return outPath;
}
}
"@ -ReferencedAssemblies System.Drawing
`;
/**
* 助手统一预加载段(window.list / window.focus / screen.find 的 UIA/OCR/图像三引擎共用)。
* ⚠️ 必须定义在四段之后(块级作用域),且**四段缺一不可** ——
@@ -483,7 +121,17 @@ export function warmupWindowHelper(): void {
}, 1500).unref?.();
}
export const WIN_HELPER_PRELUDE = `${WIN32_PRELUDE}
/**
* 常驻助手的预加载段。
*
* ⚠️ `PS_DPI_AWARE` **必须排在最前**,且不能放进 `OCR_PRELUDE` 的 try/catch 里 ——
* 那段 catch 是为了「缺 WinRT 组件时只让 OCR 退化」,但它同时也会把
* `SetProcessDPIAware` 一起吞掉;一旦吞掉,助手退回 DPI 不感知,
* `GetWindowRect`(window.list)就与 UIA / 截图的口径不一致了。
* DPI 感知是**坐标口径前提**,不是可退化项。
*/
export const WIN_HELPER_PRELUDE = `${PS_DPI_AWARE}
${WIN32_PRELUDE}
${UIA_ASSEMBLY_PRELUDE}
${OCR_PRELUDE}
${IMAGE_MATCH_PRELUDE}`;
@@ -570,14 +218,18 @@ export async function windowFocus(args: Args): Promise<unknown> {
/**
* 聚焦脚本主体(不含 prelude)。⚠️ 严禁 exit —— 会杀掉常驻助手进程;
* 未找到时改为输出 `{"found":false}` 并 return。
*
* ⚠️⚠️ `title` / `hwnd` 来自控制端,**必须**用 `psLit` 包成 PS 单引号字面量。
* 曾用 `JSON.stringify` → 双引号串 + `\"` 被 PS 解析成「字面反斜杠 + 结束引号」
* → 任意命令执行(P0 W-01,真机实测「返回正常业务错误但命令已执行」)。
*/
function focusBody(title: string | undefined, hwnd: string | undefined): string {
return `
$h = [IntPtr]::Zero
$want = ${JSON.stringify(hwnd ?? '')}
$want = ${psLit(hwnd ?? '')}
if ($want -ne '') { $h = [IntPtr][Convert]::ToInt64($want, 16) }
else {
$re = ${JSON.stringify(title ?? '')}
$re = ${psLit(title ?? '')}
$fg = [NAWin32]::GetForegroundWindow()
$found = [IntPtr]::Zero
$cb = [NAWin32+EnumProc]{
@@ -664,11 +316,26 @@ export async function screenFind(args: Args): Promise<unknown> {
}
const text = (args['text'] as string | undefined) ?? '';
const wantImage = (args['method'] as string | undefined) === 'image';
if (!wantImage && (typeof text !== 'string' || text.length === 0)) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'text 不能为空(method=image 时用 template 代替)');
// v2.0.0 修正:`where` 可单独使用(不传 text)——「找一个 enabled 的按钮」比
// 「找一个叫某名字的按钮」更贴近真实意图。此前只在 gui.await 侧放开了,
// screen.find 自身的入口校验没同步,导致直连调用(以及 await 的 where-only)
// 被这里的"text 不能为空"拦下(真机 2026-10-09 验证时暴露)。
const hasWhere = (() => {
const w = args['where'];
return typeof w === 'object' && w !== null && !Array.isArray(w) && Object.keys(w as object).length > 0;
})();
if (!wantImage && text.length === 0 && !hasWhere) {
throw new CapabilityError(
ErrorCodes.PARAM_INVALID,
'text 与 where 至少给一个(method=image 时用 template)',
{ hint: 'where 仅 UIA 引擎生效,如 {"enabled":true}' },
);
}
const windowTitle = args['window'] as string | undefined;
const controlType = args['control_type'] as string | undefined;
// v2.0.0:属性谓词。仅 UIA 引擎生效;OCR / 图像模板忽略(无属性可读),
// 此时在返回里带 note 说明,避免调用方误以为"按属性过滤过了"。
const whereArg = args['where'] as Record<string, unknown> | undefined;
const limit = (args['limit'] as number | undefined) ?? 20;
const method = (args['method'] as string | undefined) ?? 'auto'; // auto | uia | ocr
// v12.2:等待语义 —— 界面常有动画/加载延迟,一次性查找极易假失败。
@@ -686,17 +353,39 @@ export async function screenFind(args: Args): Promise<unknown> {
// ---------- UIA 引擎 ----------
const uiaFind = async (): Promise<Record<string, unknown>[]> => {
const ct = controlType ?? '';
// ⚠️ 全部来自控制端,一律 psLit(详见文件顶部 windowFocus 处的 P0 说明)
const body = `
$text = ${JSON.stringify(text)}
$text = ${psLit(text)}
$limit = ${Number(limit)}
$ct = ${JSON.stringify(controlType ?? '')}
$winTitle = ${JSON.stringify(windowTitle ?? '')}
$ct = ${psLit(controlType ?? '')}
$winTitle = ${psLit(windowTitle ?? '')}
# v2.0.0 属性过滤:where 只含 enabled/selected/value/toggle 四类键(白名单式)。
#
# ⚠️⚠️ 这里踩过**两次**同一个坑,真机 2026-10-09 连修两轮才通:
# ✗ 第一版:$where = {"enabled":true} → PS 不认 JSON 字面量,ParseError
# ✗ 第二版:$whereJson = "{\"enabled\":true}" → PS 5.1 **不认反斜杠转义**
# (PS 用反引号),依然 ParseError,整份助手脚本失败
# (症状:带 where 一律"窗口操作失败",不带 where 完全正常)
# ✓ 第三版:**PS 单引号字符串字面量**,JSON 内的单引号翻倍成 ''(本行)
# 并在真机现场验证过:$j = '{"enabled":true}' → ConvertFrom-Json 摊平成哈希表 → OK。
# 摊平是因为 Test-UiaWhere 依赖 .Keys 遍历,而 PSCustomObject 没有 Keys。
$whereJson = ${psLit(whereArg ? JSON.stringify(whereArg) : '')}
$where = $null
if ($whereJson -ne '') {
$where = @{}
try {
($whereJson | ConvertFrom-Json).PSObject.Properties | ForEach-Object { $where[$_.Name] = $_.Value }
} catch {
$where = $null # JSON 不合法就当没传(业务层已在 Node 侧校验过键名)
}
if ($where -ne $null -and $where.Count -eq 0) { $where = $null }
}
$out = New-Object System.Collections.ArrayList
if ($winTitle -ne '') {
# 指定窗口:直接在该窗口子树内找
$w = Get-WinByTitle $winTitle
if ($w -eq $null) { Write-Output '[]'; return } # 严禁 exit:会杀掉常驻助手
$out = Find-UiaByText $w $text $limit $ct
$out = Find-UiaByText $w $text $limit $ct $where
} else {
# v14 优化:先在前台窗口子树内找(典型只几百个元素,快一个数量级),
# 找不到再退回全桌面遍历(原来每次都在全桌面上跑,真机实测 3.1~3.7s)。
@@ -704,9 +393,9 @@ if ($winTitle -ne '') {
try {
$fgEl = [System.Windows.Automation.AutomationElement]::FromHandle([NAWin32]::GetForegroundWindow())
} catch {}
if ($fgEl -ne $null) { $out = Find-UiaByText $fgEl $text $limit $ct }
if ($fgEl -ne $null) { $out = Find-UiaByText $fgEl $text $limit $ct $where }
if ($out.Count -eq 0) {
$out = Find-UiaByText ([System.Windows.Automation.AutomationElement]::RootElement) $text $limit $ct
$out = Find-UiaByText ([System.Windows.Automation.AutomationElement]::RootElement) $text $limit $ct $where
}
}
# ── 输出 ──(遍历与子串过滤已收敛到预加载的 Find-UiaByText,避免逻辑重复)
@@ -730,7 +419,8 @@ if ($out.Count -eq 0) { Write-Output '[]' } else { $out | ConvertTo-Json -Compre
const shot = join(tmpDir, `match-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.png`);
// PowerShell 单引号字面量:不插值(避免路径里的 $ 被展开)、不处理反斜杠转义。
// 不能用 JSON.stringify —— 它会产出双引号串,而 PS 双引号会做变量插值且把 \ 当字面两个反斜杠。
const q = (v: string): string => `'${v.replace(/'/g, "''")}'`;
// (统一走 util/quote.ts 的 psLit,避免各文件各写一份实现而漏改)
const q = psLit;
// 截图区域:显式 region 优先,否则整块虚拟屏
const capX = regionArg ? regionArg.x : 'VSX';
@@ -801,7 +491,7 @@ $xv = ${regionArg ? regionArg.x : -1}
$yv = ${regionArg ? regionArg.y : -1}
$wv = ${regionArg ? regionArg.width : -1}
$hv = ${regionArg ? regionArg.height : -1}
$winRe = ${JSON.stringify(regionArg ? '' : windowTitle ?? '')}
$winRe = ${psLit(regionArg ? '' : windowTitle ?? '')}
$x = 0; $y = 0
$vs = [System.Windows.Forms.SystemInformation]::VirtualScreen
$w = $vs.Width; $h = $vs.Height
@@ -861,11 +551,11 @@ $engine = [Windows.Media.Ocr.OcrEngine]::TryCreateFromUserProfileLanguages()
if ($null -eq $engine) { Write-Output '[]'; return }
$result = Await ($engine.RecognizeAsync($soft)) ([Windows.Media.Ocr.OcrResult])
$text = ${JSON.stringify(text)}
$text = ${psLit(text)}
$limit = ${Number(limit)}
# Windows OCR 会在中文字符间插入空格(如「我的加速」识别为「我 的 加 速」),
# 因此匹配一律在「去除空白后」的文本上进行(两边同样处理)。
$textPlain = ${JSON.stringify(text)}.Replace(' ','')
$textPlain = (${psLit(text)}).Replace(' ','')
if ($textPlain -eq '') { $textPlain = ($text -replace '\\s','') }
$out = New-Object System.Collections.ArrayList
foreach ($line in $result.Lines) {
@@ -954,10 +644,22 @@ $out | ConvertTo-Json -Compress
await new Promise((r) => setTimeout(r, intervalMs));
last = await attempt();
}
// v2.0.0:where 只在 UIA 引擎下有属性可读 —— 若最终走的是 OCR/image,
// 必须**明确告知**,否则调用方会以为"按属性过滤过了"而误判结果。
const whereIgnored = hasWhere && last.engine !== 'uia';
return {
matches: last.matches,
engine: last.engine,
waited_ms: Date.now() - startedAt,
...(waitMs > 0 ? { wait_ms: waitMs } : {}),
...(whereIgnored
? {
note:
`where 已忽略:实际引擎为 ${last.engine}(无控件属性可读)。` +
'属性过滤仅在 UIA 生效;text 过滤仍然有效。',
where_applied: false,
}
: {}),
...(hasWhere && last.engine === 'uia' ? { where_applied: true } : {}),
};
}
+34 -1
View File
@@ -8,8 +8,17 @@ export interface AgentConfig {
host: string;
port: number;
tls: boolean;
/** v1 简化:预共享密钥存于 600 权限文件;v2 升级 DPAPI */
/** v1 简化:预共享密钥存于 600 权限文件;v2 升级 DPAPI。**所有调用方共用**,仅适合信任网络 */
key: string;
/**
* v22:**每客户端一把**预共享密钥(`client_id` → key)。
*
* 为什么需要它:psk 模式下 `client_id` 由客户端自报,历史上「一把共享 key」等于
* **持有者可冒充任意 client_id**,ACL 的身份维度形同虚设。
* 改为每个 client_id 一把钥匙后,「知道 key A 就只能以 A 的身份进来」——
* 身份才真正被绑定。未登记的 client_id 回落到上面的 `key`(旧部署行为不变)。
*/
keys?: Record<string, string>;
log_level: 'debug' | 'info' | 'warn';
/**
* v2:是否允许输入控制(鼠标 / 键盘)。
@@ -32,6 +41,19 @@ export interface AgentConfig {
* "allow_from": ["192.168.0.0/16", "10.0.0.0/8"]
*/
allow_from?: string[];
/**
* v2.0.0 连接层防护(三项,默认值见 server.ts)。
* 单项缺省即启用默认策略;显式设 0 可关闭对应项。
*/
security?: {
/** 握手失败封禁:同来源连续失败 max_attempts 次起,ban = ban_ms × 2^(N-max),封顶 24h */
auth_ban?: { max_attempts?: number; ban_ms?: number; whitelist?: string[] };
/** 最大并发连接数(默认 8) */
max_connections?: number;
/** 空闲断开(默认 30 分钟;0 = 关闭) */
idle_timeout_ms?: number;
};
/** v3+:审计日志配置 */
audit?: {
/** 是否启用(默认 true) */
@@ -53,6 +75,17 @@ export interface AgentConfig {
broadcast?: string;
/** 广播间隔毫秒(默认 5000) */
interval_ms?: number;
/**
* v22:广播详情级别。
* - `full`(默认,向后兼容):携带地址/端口/认证方式/键鼠开关等全部信息
* - `minimal`:只广播「存在 + node_id + 时间」,详情放进 `detail` 字段;
* 配了 `secret` 时详情会被 **HMAC 认证**(控制端持钥才看得到、且能验证真伪)
* ⚠️ 广播是**未认证**的:任何人收 UDP 都能看到内容。minimal 仍会暴露
* 「这里有一台可被接管的机器」,但不再泄露地址、端口与认证方式。
*/
detail?: 'full' | 'minimal';
/** v22:详情认证密钥(配合 detail=minimal 使用;配了就会自动启用 minimal) */
secret?: string;
};
/**
* v5:文件访问白名单(根目录列表)。
+69 -19
View File
@@ -1,5 +1,5 @@
import { createSocket, type Socket } from 'node:dgram';
import { DEFAULT_DISCOVERY_PORT } from '@nodeagent/protocol';
import { DEFAULT_DISCOVERY_PORT, computeHmac } from '@nodeagent/protocol';
import { currentCertFingerprint } from './certs.js';
export { DEFAULT_DISCOVERY_PORT };
@@ -10,20 +10,41 @@ export interface BeaconPayload {
service: 'nodeagent';
version: string;
node_id: string;
/**
* 以下字段在 `full` 模式下直给在顶层;在 `minimal` 模式下收在 `detail` 里
* (顶层只留「存在性 + node_id + 时间」)。故为可选。
*/
/** 被控端可达地址(由被控端自行判定) */
host: string;
port: number;
tls: boolean;
auth_mode: string;
host?: string;
port?: number;
tls?: boolean;
auth_mode?: string;
/** 需要输入控制开关的状态,便于控制端提示 */
input_enabled: boolean;
input_enabled?: boolean;
/**
* v21:TLS 证书指纹(sha256 hex)。
* ⚠️ 这是**未认证**广播,可被伪造 —— 只用于「看起来像不像」,不能作为身份凭据;
* 真正的身份确认发生在握手后(auth_ok 里带指纹,且控制端会严格比对)。
*/
cert_sha256?: string | null;
platform: string;
platform?: string;
/**
* v22:`true` 表示这是**最小化**广播 —— 顶层只有存在性信息,
* 真实地址/端口/认证方式等在 `detail` 里(有 secret 时带 `sig` 认证)。
*/
minimal?: boolean;
/** v22:最小化广播里的详情(可能带 `sig`,由持有 secret 的控制端验证) */
detail?: {
host: string;
port: number;
tls: boolean;
auth_mode: string;
input_enabled: boolean;
cert_sha256?: string | null;
platform: string;
};
/** v22:详情签名(HMAC-SHA256 over `node_id|ts|JSON(detail)`),由持有 secret 的控制端验证 */
sig?: string;
ts: number;
}
@@ -34,8 +55,14 @@ export interface BeaconOptions {
/** 广播地址;局域网内通常为 255.255.255.255 */
broadcast: string;
intervalMs: number;
/** 报文中除 ts 外的固定字段 */
base: Omit<BeaconPayload, 'service' | 'version' | 'ts'>;
/** 报文中除 ts 外的固定字段(full 模式直给顶层的那组) */
base: Required<
Pick<BeaconPayload, 'node_id' | 'host' | 'port' | 'tls' | 'auth_mode' | 'input_enabled' | 'platform'>
>;
/** v22:详情级别;配了 secret 也会自动启用 minimal */
detail?: 'full' | 'minimal';
/** v22:详情认证密钥(HMAC-SHA256 over node_id|ts|detail) */
secret?: string;
}
export interface Beacon {
@@ -63,16 +90,39 @@ export function startBeacon(opts: BeaconOptions, onError?: (msg: string) => void
const send = (): void => {
if (!ready) return;
const buf = Buffer.from(
JSON.stringify({
service: SERVICE_TAG,
version: PROTOCOL_VERSION,
cert_sha256: currentCertFingerprint(),
...opts.base,
ts: Date.now(),
} satisfies BeaconPayload),
'utf8',
);
const ts = Date.now();
// v22 详情分级:minimal 模式只广播「存在性」,详情移入 detail{} 并用 secret 做 HMAC。
// 未配 secret 时 detail 仍然明文(此时 sig 缺省,控制端会标为「未认证」)。
const full = {
host: opts.base.host,
port: opts.base.port,
tls: opts.base.tls,
auth_mode: opts.base.auth_mode,
input_enabled: opts.base.input_enabled,
cert_sha256: currentCertFingerprint(),
platform: opts.base.platform,
};
const minimal = opts.detail === 'minimal' || !!opts.secret;
const payload: BeaconPayload = minimal
? {
service: SERVICE_TAG,
version: PROTOCOL_VERSION,
node_id: opts.base.node_id,
minimal: true,
detail: full,
sig: opts.secret
? computeHmac(opts.secret, `${opts.base.node_id}|${ts}|${JSON.stringify(full)}`)
: undefined,
ts,
}
: {
service: SERVICE_TAG,
version: PROTOCOL_VERSION,
cert_sha256: currentCertFingerprint(),
...opts.base,
ts,
};
const buf = Buffer.from(JSON.stringify(payload), 'utf8');
try {
socket.send(buf, 0, buf.length, opts.port, opts.broadcast, (err) => {
if (err) onError?.(`广播失败: ${err.message}`);
+59 -7
View File
@@ -1,6 +1,8 @@
import { watch, type FSWatcher } from 'node:fs';
import { exec } from 'node:child_process';
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
// ⚠️ E-03:file watcher 必须与 fs.* 用**同一套**路径白名单守卫(否则 fs_roots 形同虚设)
import { guardPath } from './capabilities/fs.js';
type Args = Record<string, unknown>;
@@ -30,6 +32,8 @@ interface WatchRecord {
id: string;
kind: 'file' | 'process' | 'net';
owner: string; // 连接标识,断开时清理
/** 创建者的 client_id —— 跨控制端隔离的依据(E-01/E-02) */
clientId: string;
description: string;
createdAt: number;
count: number;
@@ -186,10 +190,23 @@ function startNetWatch(rec: WatchRecord, intervalMs: number): () => void {
export interface WatchContext {
/** 连接标识(用于断开时清理) */
owner: string;
/** 调用方 client_id(跨控制端隔离;缺省视为匿名,彼此可见) */
clientId?: string;
/** 该连接的推送出口 */
emit: (evt: WatchEvent) => void;
}
/**
* 该 watcher 是否属于 caller。
*
* 兼容性:`clientId` 为空(匿名 / 老客户端)时**不做隔离** —— 但只要有 client_id,
* 隔离就必须生效:否则 B 控制端能看到甚至 `event.unwatch` 掉 A 的订阅(E-01/E-02 实测)。
*/
function ownedBy(rec: WatchRecord, callerId: string): boolean {
if (!callerId) return true;
return !rec.clientId || rec.clientId === callerId;
}
export async function eventWatch(args: Args, ctx: WatchContext): Promise<unknown> {
const kind = args['kind'] as 'file' | 'process' | 'net';
if (!['file', 'process', 'net'].includes(kind)) {
@@ -202,6 +219,7 @@ export async function eventWatch(args: Args, ctx: WatchContext): Promise<unknown
id,
kind,
owner: ctx.owner,
clientId: ctx.clientId ?? '',
description: '',
createdAt: Date.now(),
count: 0,
@@ -210,8 +228,12 @@ export async function eventWatch(args: Args, ctx: WatchContext): Promise<unknown
};
if (kind === 'file') {
const path = args['path'] as string | undefined;
if (!path) throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'file 类型需要 path');
const raw = args['path'] as string | undefined;
if (!raw) throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'file 类型需要 path');
// ⚠️ E-03(实测复现):file watcher **必须**过 `guardPath`。
// 原来 `fs.read` 会被 `fs_roots` 正确拦下,而 `event.watch` 用同一路径却畅通无阻
// —— 白名单形同虚设(可通过事件流观察白名单外的目录变动)。
const path = guardPath(raw);
const recursive = args['recursive'] === true;
rec.dispose = startFileWatch(rec, path, recursive, pattern);
rec.description = `文件变动 ${path}${recursive ? '(递归)' : ''}${pattern ? ` pattern=${pattern}` : ''}`;
@@ -227,18 +249,27 @@ export async function eventWatch(args: Args, ctx: WatchContext): Promise<unknown
return { watch_id: id, kind, description: rec.description };
}
export async function eventUnwatch(args: Args): Promise<unknown> {
/** 取消订阅。**只有创建者(同 client_id)能取消** —— 否则任意控制端可掐掉别人的监控。 */
export async function eventUnwatch(args: Args, ctx?: WatchContext): Promise<unknown> {
const id = args['watch_id'] as string;
const rec = watches.get(id);
if (!rec) return { removed: false };
const callerId = ctx?.clientId ?? '';
if (!ownedBy(rec, callerId)) {
// 明确回报,而不是假装成功(也便于审计追查越权尝试)
return { removed: false, reason: `订阅 ${id} 属于其他控制端,无权取消` };
}
rec.dispose();
watches.delete(id);
return { removed: true };
}
export async function eventList(_args: Args): Promise<unknown> {
/** 列出订阅:**只列自己的**(含匿名创建的)。 */
export async function eventList(_args: Args, ctx?: WatchContext): Promise<unknown> {
const callerId = ctx?.clientId ?? '';
const mine = [...watches.values()].filter((w) => ownedBy(w, callerId));
return {
watches: [...watches.values()].map((w) => ({
watches: mine.map((w) => ({
watch_id: w.id,
kind: w.kind,
description: w.description,
@@ -249,18 +280,39 @@ export async function eventList(_args: Args): Promise<unknown> {
};
}
export async function eventPoll(args: Args): Promise<unknown> {
/** 拉取事件:只回「属于本 client_id 的订阅」产生的事件。 */
export async function eventPoll(args: Args, ctx?: WatchContext): Promise<unknown> {
const limit = Math.max(1, Math.min(500, (args['limit'] as number | undefined) ?? 50));
const since = (args['since'] as number | undefined) ?? 0;
const watchId = args['watch_id'] as string | undefined;
const callerId = ctx?.clientId ?? '';
// watch_id 显式指定时先验归属,避免「猜 id 就能读别人事件」
if (watchId) {
const rec = watches.get(watchId);
if (rec && !ownedBy(rec, callerId)) {
throw new CapabilityError(ErrorCodes.ACL_DENIED, `订阅 ${watchId} 属于其他控制端`, { watch_id: watchId });
}
}
let list = buffer.filter((e) => e.seq > since);
if (watchId) list = list.filter((e) => e.watch_id === watchId);
if (watchId) {
list = list.filter((e) => e.watch_id === watchId);
} else if (callerId) {
const allowed = new Set(
[...watches.values()].filter((w) => ownedBy(w, callerId)).map((w) => w.id),
);
list = list.filter((e) => allowed.has(e.watch_id));
}
const sliced = list.slice(0, limit);
const nextCursor = sliced.length > 0 ? sliced[sliced.length - 1]!.seq : since;
return { events: sliced, next_cursor: nextCursor, dropped };
}
/** 连接断开时清理该连接创建的所有订阅(避免 watcher 泄漏)。 */
/** v2.0.0:当前事件订阅数(空闲断开的豁免判据之一)。 */
export function watchCount(): number {
return watches.size;
}
export function disposeWatchesByOwner(owner: string): number {
let n = 0;
for (const [id, rec] of watches) {
+73
View File
@@ -18,6 +18,63 @@ function listLocalIps(): string[] {
return out;
}
// ---------------------------------------------------------------------------
// v2.0.3:进程级最后一道防线(崩溃链兜底)
// ---------------------------------------------------------------------------
//
// ## 为什么需要
// 真机缺陷链的共同根因:`ws.on('message')` 里用 `void handleMessage(...)` 启动异步处理,
// 未挂 `.catch`;而全仓**没有任何** `unhandledRejection` 处理器 → Node ≥22 的默认策略是
// **直接终止进程**。于是一条畸形报文就能把被控端打死(外部只见 `code=1` / 连接断开),
// 且因为 `verifyHmac`(psk 默认路径)没有 try/catch,连**未认证**的报文都能触发。
//
// 根治在源头(`server.ts` 已改 `.catch(...)` + 逐处报文类型校验),这里补的是
// 「将来某处再漏一个 await」时的兜底 —— 有证据地活着,胜过静默地死。
//
// ## 为什么**不**退出进程
// 被控端是常驻服务,「可用性」优先于「出错即死」。若这里改成 `process.exit(1)`,
// 就退化回「一条畸形报文打死服务」—— 正是本次要修的东西。
// 代价(可能带着不一致的状态继续跑)由两点对冲:
// ① 源头已有报文校验与 `.catch`;② 这里每次都写审计 + 日志,问题不会被掩盖。
//
// ## 为什么要限流审计
// 畸形报文可以高频重复(扫描/爆破)→ 无限写 `agent.error` 会把审计日志冲爆、把
// 正常记录挤掉。故日志每次都打(stdout 由控制台的环形缓冲兜底),但**审计按时间窗限流**。
let procErrCount = 0;
let lastProcErrAuditAt = 0;
/** 两条 `agent.error` 审计之间的最小间隔 */
const PROC_ERR_AUDIT_INTERVAL_MS = 10_000;
function reportProcessError(kind: string, detail: string): void {
procErrCount += 1;
console.error(
`[${new Date().toISOString()}] [ERROR] ${kind}(已拦截,进程继续运行;累计 ${procErrCount} 次):${detail}`,
);
const now = Date.now();
if (now - lastProcErrAuditAt < PROC_ERR_AUDIT_INTERVAL_MS) return;
lastProcErrAuditAt = now;
audit({
type: 'agent.error',
status: 'failed',
reason: `${kind}: ${detail}(累计 ${procErrCount} 次,审计按 ${PROC_ERR_AUDIT_INTERVAL_MS / 1000}s 限流)`,
});
}
process.on('unhandledRejection', (reason: unknown) => {
reportProcessError(
'未处理的 Promise 拒绝',
reason instanceof Error ? `${reason.name}: ${reason.message}` : String(reason),
);
});
process.on('uncaughtException', (err: unknown) => {
reportProcessError(
'未捕获异常',
err instanceof Error ? `${err.name}: ${err.message}` : String(err),
);
});
async function main(): Promise<void> {
const { config, isNew } = loadAgentConfig();
initAudit({
@@ -61,6 +118,9 @@ async function main(): Promise<void> {
port: config.discovery?.port ?? DEFAULT_DISCOVERY_PORT,
broadcast: config.discovery?.broadcast ?? '255.255.255.255',
intervalMs: config.discovery?.interval_ms ?? 5000,
// v22:详情分级 + 认证(配了 secret 自动启用 minimal)
detail: config.discovery?.detail ?? 'full',
secret: config.discovery?.secret,
base: {
node_id: config.node_id,
host: ips[0] ?? '127.0.0.1',
@@ -88,6 +148,19 @@ async function main(): Promise<void> {
console.log(` 能力 : ${CAPABILITY_MANIFEST.length} 项`);
console.log(` 认证模式 : ${auditMode}${auditMode === 'ed25519' ? `(已登记 ${config.acl?.clients.length ?? 0} 个调用方)` : ''}`);
console.log(` 输入控制 : ${config.allow_input === true ? '已开启' : '已禁用(默认)'}`);
// v22:明文广播会暴露「地址/端口/认证方式/键鼠是否开启」,未收敛时必须提醒
if (config.discovery?.enabled !== false && !useHub) {
const minimal = config.discovery?.detail === 'minimal' || !!config.discovery?.secret;
if (!minimal) {
console.log(
' ⚠️ 发现广播为「明文全量」:同网段可见 IP/端口/认证方式/键鼠开关。' +
'建议在 agent.json 设 discovery.detail="minimal"(可再配 secret 加密详情)',
);
} else if (!config.discovery?.secret) {
console.log(' ℹ️ 发现广播已最小化,但 detail 未配 secret —— 详情仍是明文(可加 secret 让其带签名)');
}
}
const discoOn = config.discovery?.enabled !== false;
console.log(
` 局域网发现 : ${discoOn ? `已开启(UDP ${config.discovery?.port ?? DEFAULT_DISCOVERY_PORT} 心跳广播)` : '已关闭'}`,
+280 -32
View File
@@ -30,7 +30,8 @@ import {
import { buildInfo } from './capabilities/system.js';
import { createCapabilityRegistry } from './capabilities/index.js';
import { audit, describeArgs } from './audit.js';
import { disposeWatchesByOwner } from './events.js';
import { disposeWatchesByOwner, watchCount } from './events.js';
import { runningTaskCount } from './capabilities/task.js';
import type { AgentConfig } from './config.js';
import type { TlsMaterial } from './certs.js';
@@ -47,6 +48,8 @@ interface ConnState {
remote: string;
/** v12:连接标识,用于清理该连接创建的事件订阅 */
connId: string;
/** v2.0.0:末次活跃时间(任何入站消息),空闲断开的依据 */
lastActiveAt: number;
}
export interface AgentServer {
@@ -61,15 +64,37 @@ type LogLevel = 'debug' | 'info' | 'warn';
export interface AgentCore {
/** 在给定连接上提供被控端服务(连接已建立)。 */
attach(ws: WebSocket, remote: string): void;
/** v2.0.0:该来源封禁剩余毫秒(0 = 不挡)。连接入口在业务握手前调用。 */
banRemainingMs(remote: string): number;
/** v2.0.0:连接表(并发上限 / 空闲清扫判据;key 为 WebSocket)。 */
states: Map<WebSocket, ConnState>;
}
export function createAgentCore(cfg: AgentConfig): AgentCore {
const capabilityRegistry = createCapabilityRegistry(cfg);
/** v3:本次实例采用的认证模式(psk 向后兼容 / ed25519 零信任) */
const authMode: 'psk' | 'ed25519' = cfg.auth_mode === 'ed25519' ? 'ed25519' : 'psk';
const aclPolicy = cfg.acl ?? { default_effect: 'deny' as const, clients: [] };
/** 只要配置了 ACL 就启用能力级授权(无论 psk 还是 ed25519)。 */
const hasAcl = Boolean(cfg.acl?.clients?.length);
/**
* ACL 策略归一化。
*
* ⚠️ A-01(真机实测,P0):`clients` 必须在**这里**补齐成数组。
* 原实现只写 `cfg.acl ?? {…, clients: []}` —— 当用户配置 `{"acl":{"default_effect":"deny"}}`
* (很自然的写法,省略空的 clients)时,`cfg.acl` 非空 → `??` 不兜底 →
* `aclPolicy.clients` 为 `undefined` → 后面无条件执行 `.find()` → **每次 invoke 都崩掉被控端进程**。
*/
const aclPolicy = {
default_effect: cfg.acl?.default_effect === 'allow' ? ('allow' as const) : ('deny' as const),
clients: Array.isArray(cfg.acl?.clients) ? cfg.acl.clients : [],
};
/**
* 只要**配置了 ACL 对象**就启用能力级授权(无论 psk 还是 ed25519)。
*
* ⚠️ A-02(真机实测,P1):判据必须是「对象是否存在」,**不能**用 `clients.length`。
* 原实现 `Boolean(cfg.acl?.clients?.length)` 在 `{"acl":{"default_effect":"deny","clients":[]}}`
* 时得到 `false` → 整段 ACL 校验被跳过 → **fail-open 全放行**(配置写着默认拒绝却全通)。
* 空 clients 的正确语义是「没有任何调用方被授权」,即拒绝一切(由 default_effect 决定)。
*/
const hasAcl = Boolean(cfg.acl);
/** 按调用方的滑动窗口限速(每分钟)。 */
const rateBuckets = new Map<string, number[]>();
@@ -93,7 +118,9 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
}
};
const states = new WeakMap<WebSocket, ConnState>();
// v2.0.0:用 Map 而非 WeakMap —— 连接层需要 size(并发上限)与遍历(空闲清扫)。
// 代价是必须手动清理:close 时 delete,否则断开的连接会一直留在表里。
const states = new Map<WebSocket, ConnState>();
const send = (ws: WebSocket, msg: unknown): void => {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
@@ -105,14 +132,37 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
function handleHello(ws: WebSocket, state: ConnState, req: RpcRequest): void {
const params = req.params as HelloParams | undefined;
const proto = params?.protocol ?? '';
// ⚠️ B-01(真机实测,P1):`protocol` 必须先做**类型**校验再 `.split()`。
// 原实现 `params?.protocol ?? ''` 对 `protocol: 123` 得到数字 → `proto.split` 抛
// TypeError → 未捕获的 rejection → Node ≥22 直接终止被控端进程(**免认证即可打崩**)。
const rawProto = params?.protocol;
if (typeof rawProto !== 'string' || rawProto === '') {
log('warn', `握手失败:protocol 非法(${typeof rawProto})`);
audit({
type: 'request.rejected',
remote: state.remote,
status: 'denied',
reason: `hello.protocol 类型非法: ${typeof rawProto}`,
});
sendError(ws, req.id, ErrorCodes.PARAM_INVALID, 'hello 的 protocol 必须是形如 "1.0" 的字符串', {
got: typeof rawProto,
});
return;
}
const proto = rawProto;
if (proto.split('.')[0] !== PROTOCOL_VERSION.split('.')[0]) {
audit({
type: 'request.rejected',
remote: state.remote,
status: 'denied',
reason: `协议版本不兼容: 客户端 ${proto}`,
});
sendError(ws, req.id, ErrorCodes.PROTOCOL_MISMATCH, `协议版本不兼容: 客户端 ${proto},服务端 ${PROTOCOL_VERSION}`);
return;
}
state.nonce = generateNonce();
state.nonceExpiresAt = Date.now() + NONCE_TTL_MS;
state.clientId = params?.client_id ?? null;
state.clientId = typeof params?.client_id === 'string' ? params.client_id : null;
// 新一轮握手开始:清除上一轮的认证态与授权
// (Hub 模式下同一连接会被不同控制端先后复用,必须重置,否则存在越权风险)
state.authenticated = false;
@@ -122,33 +172,45 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
function handleAuth(ws: WebSocket, state: ConnState, req: RpcRequest): void {
const params = req.params as AuthParams | undefined;
const failOut = (msg: string, hint?: string): void => {
const ban = recordAuthFailure(state.remote);
audit({ type: 'auth.failure', client_id: params?.client_id ?? '?', remote: state.remote, reason: msg });
sendError(ws, req.id, ErrorCodes.AUTH_FAILED, msg, {
...(hint ? { hint } : {}),
...(ban.banned ? { retry_after_ms: ban.waitMs } : {}),
});
// 稍作延迟再关闭,确保错误响应先送达控制端
setTimeout(() => ws.close(), 100);
};
if (!state.nonce || Date.now() > state.nonceExpiresAt) {
sendError(ws, req.id, ErrorCodes.AUTH_FAILED, '挑战已过期,请重新握手');
log('warn', '握手失败:挑战已过期');
failOut('挑战已过期,请重新握手');
return;
}
// ⚠️ B-02(真机实测,P1):nonce / hmac / signature 都要先做类型校验。
// `params?.nonce !== state.nonce` 对数字、对象等都只会走「不匹配」分支 —— 这步安全;
// 真正致命的是下一段:把 `hmac:1` 交给 `verifyHmac` → `Buffer.from(1, 'hex')` 抛
// `ERR_INVALID_ARG_TYPE` → 未捕获 rejection → 进程终止(免认证即可打崩)。
if (params?.nonce !== state.nonce) {
sendError(ws, req.id, ErrorCodes.AUTH_FAILED, 'nonce 不匹配');
log('warn', '握手失败:nonce 不匹配');
failOut('nonce 不匹配', '请用 hello 返回的 nonce 计算签名');
return;
}
const clientId = params.client_id ?? '?';
const clientId = typeof params.client_id === 'string' ? params.client_id : '?';
let authorized: string[] | null = null;
let keyKindUsed = 'ed25519';
if (authMode === 'ed25519') {
const client = aclPolicy.clients.find((c) => c.client_id === clientId);
if (!client?.pubkey) {
log('warn', `鉴权失败(调用方未注册)client_id=${clientId}`);
audit({ type: 'auth.failure', client_id: clientId, remote: state.remote, reason: '调用方未在 ACL 中注册' });
sendError(ws, req.id, ErrorCodes.AUTH_FAILED, `调用方未在 ACL 中注册: ${clientId}`, {
hint: '在被控端 acl.clients 中加入该 client_id 与公钥',
});
setTimeout(() => ws.close(), 100);
failOut(`调用方未在 ACL 中注册: ${clientId}`, '在被控端 acl.clients 中加入该 client_id 与公钥');
return;
}
if (!verifyNonce(client.pubkey, state.nonce, params.signature ?? '')) {
if (!verifyNonce(client.pubkey, state.nonce, typeof params.signature === 'string' ? params.signature : '')) {
log('warn', `鉴权失败(Ed25519 验签不通过)client_id=${clientId}`);
audit({ type: 'auth.failure', client_id: clientId, remote: state.remote, reason: 'Ed25519 验签失败' });
sendError(ws, req.id, ErrorCodes.AUTH_FAILED, 'Ed25519 签名校验失败');
setTimeout(() => ws.close(), 100);
failOut('Ed25519 签名校验失败', '确认用的是该 client_id 对应的私钥');
return;
}
authorized = authorizedCapabilities(
@@ -156,23 +218,45 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
clientId,
CAPABILITY_MANIFEST.map((c) => c.name),
);
} else if (!verifyHmac(cfg.key, state.nonce, params.hmac ?? '')) {
log('warn', `鉴权失败 client_id=${clientId}`);
audit({ type: 'auth.failure', client_id: clientId, remote: state.remote, reason: '预共享密钥校验失败' });
sendError(ws, req.id, ErrorCodes.AUTH_FAILED, '预共享密钥校验失败');
// 稍作延迟再关闭,确保错误响应先送达控制端
setTimeout(() => ws.close(), 100);
return;
} else {
// v22:优先用「该 client_id 专属的钥匙」,未登记才回落到共享 key。
// 这样「知道 key A」就只能以 A 的身份进来 —— ACL 的身份维度在 psk 模式下才有意义。
//
// ⚠️ B-03(真机实测,P1):必须用 **own-property** 判断,不能直接 `cfg.keys?.[clientId]`。
// `client_id:"constructor"` 会沿原型链取到 `Object.prototype.constructor`(一个函数)
// 并被当作 HMAC 密钥 → `Buffer.from(function…)` 抛错 → 进程终止。
const hasScopedKey = Boolean(cfg.keys && Object.prototype.hasOwnProperty.call(cfg.keys, clientId));
const scopedKey = hasScopedKey ? cfg.keys![clientId] : undefined;
const keyForClient = scopedKey ?? cfg.key;
const keyKind = scopedKey ? 'scoped' : cfg.keys ? 'shared-fallback' : 'shared';
// ⚠️ B-02:hmac 同样要做类型校验 + verifyHmac 自身不得把异常泄漏到顶层
const hmac = typeof params.hmac === 'string' ? params.hmac : '';
let hmacOk = false;
try {
hmacOk = Boolean(keyForClient) && hmac !== '' && verifyHmac(keyForClient as string, state.nonce, hmac);
} catch (err) {
log('warn', `鉴权失败(HMAC 计算异常):${err instanceof Error ? err.message : String(err)}`);
hmacOk = false;
}
if (!hmacOk) {
log('warn', `鉴权失败 client_id=${clientId} key=${keyKind}`);
failOut('预共享密钥校验失败', scopedKey
? '该 client_id 有专属密钥,需用那把 key'
: '用错了 key,或该 client_id 应登记到 keys{}');
return;
}
keyKindUsed = keyKind;
}
state.nonce = null; // 一次性,用后即废
state.nonceExpiresAt = 0;
recordAuthSuccess(state.remote); // v2.0.0:握手成功即清零失败计数(避免误伤手滑的控制端)
state.authenticated = true;
state.clientId = clientId;
state.authorized = authorized;
log(
'info',
`鉴权通过 client_id=${clientId} mode=${authMode}` +
`鉴权通过 client_id=${clientId} mode=${authMode} key=${keyKindUsed}` +
(authorized ? `,授权 ${authorized.length}/${CAPABILITY_MANIFEST.length} 项能力` : ''),
);
audit({
@@ -195,11 +279,17 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
async function handleInvoke(ws: WebSocket, state: ConnState, req: RpcRequest): Promise<void> {
if (!state.authenticated) {
audit({
type: 'request.rejected',
remote: state.remote,
status: 'denied',
reason: '未完成认证即调用能力',
});
sendError(ws, req.id, ErrorCodes.AUTH_REQUIRED, '未完成认证,请先握手');
return;
}
const params = req.params as InvokeParams | undefined;
const name = params?.capability ?? '';
const name = typeof params?.capability === 'string' ? params.capability : '';
// 能力级 ACL:只要被控端配置了 ACL 即执行(deny 优先 → allow → 默认拒绝)。
// 注:psk 模式下 client_id 由客户端自报,此为「配置级约束」;对抗恶意调用方仍需 ed25519 绑定身份。
@@ -227,6 +317,7 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
}
// v3+:按调用方滑动窗口限速(ACL 的 rate_limits 按能力覆盖,未命中回退 max_calls_per_min)
// 注:aclPolicy.clients 已在 createAgentCore 里归一成数组(A-01),这里取不到就是 undefined。
const clientRule = aclPolicy.clients.find((c) => c.client_id === state.clientId);
// v11:来源 IP 白/黑名单 + 生效时段(仅在已配置该 client 规则时生效)
@@ -290,6 +381,14 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
const cap = findCapability(name);
const handler = capabilityRegistry[name];
if (!cap || !handler) {
audit({
type: 'request.rejected',
client_id: state.clientId ?? undefined,
remote: state.remote,
capability: name,
status: 'denied',
reason: `不支持的能力: ${name}`,
});
sendError(ws, req.id, ErrorCodes.CAPABILITY_NOT_FOUND, `不支持的能力: ${name}`, { capability: name });
return;
}
@@ -297,6 +396,15 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
const args = (params?.args ?? {}) as Record<string, unknown>;
const errors = validate(args, cap.params_schema);
if (errors.length > 0) {
// ⚠️ U-01:拒绝必须留痕(探测行为若不留审计,事后无法复盘被谁、用什么参数试过)
audit({
type: 'request.rejected',
client_id: state.clientId ?? undefined,
remote: state.remote,
capability: name,
status: 'denied',
reason: `参数校验失败: ${errors.slice(0, 3).join('; ')}`,
});
sendError(ws, req.id, ErrorCodes.PARAM_INVALID, '参数校验失败', { errors });
return;
}
@@ -308,6 +416,7 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
// v12:把连接上下文交给能力处理器(事件订阅需要「谁订阅的」与「往哪推」)
const data = await handler(finalArgs, {
owner: state.connId,
clientId: state.clientId ?? '',
emit: (evt: unknown) => send(ws, { jsonrpc: '2.0', method: 'event', params: evt }),
});
const duration = Date.now() - started;
@@ -354,10 +463,18 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
try {
req = JSON.parse(text) as RpcRequest;
} catch {
audit({ type: 'request.rejected', remote: states.get(ws)?.remote, status: 'denied', reason: '无法解析 JSON' });
sendError(ws, null, ErrorCodes.PARSE_ERROR, '无法解析 JSON');
return;
}
if (req?.jsonrpc !== '2.0' || typeof req.id !== 'string' || typeof req.method !== 'string') {
// 信封非法:可能是扫描器/协议错误,也留一条痕(原实现完全静默)
audit({
type: 'request.rejected',
remote: states.get(ws)?.remote,
status: 'denied',
reason: `请求信封无效(jsonrpc=${typeof req?.jsonrpc} id=${typeof req?.id} method=${typeof req?.method})`,
});
sendError(ws, typeof req?.id === 'string' ? req.id : null, ErrorCodes.INVALID_REQUEST, '请求信封无效');
return;
}
@@ -376,6 +493,12 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
return;
case Methods.Capabilities:
if (!state.authenticated) {
audit({
type: 'request.rejected',
remote: state.remote,
status: 'denied',
reason: '未认证即拉取能力清单',
});
sendError(ws, req.id, ErrorCodes.AUTH_REQUIRED, '未完成认证');
return;
}
@@ -385,10 +508,60 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
sendResult(ws, req.id, { pong: true, ts: Date.now() });
return;
default:
audit({
type: 'request.rejected',
client_id: state.clientId ?? undefined,
remote: state.remote,
status: 'denied',
reason: `未知方法: ${req.method}`,
});
sendError(ws, req.id, ErrorCodes.METHOD_NOT_FOUND, `未知方法: ${req.method}`);
}
}
// ---------- v2.0.0 连接层防护:握手失败封禁 ----------
// 状态放在 core:handleAuth 在此函数内,且 server 侧连接入口也要查它。
interface BanState { fails: number; bannedUntil: number }
const authBan = new Map<string, BanState>();
const banMax = Math.max(1, cfg.security?.auth_ban?.max_attempts ?? 5);
const banBaseMs = Math.max(1000, cfg.security?.auth_ban?.ban_ms ?? 600_000);
const banWhitelist = (cfg.security?.auth_ban?.whitelist ?? []).map((x) => x.trim()).filter(Boolean);
/** 归一化来源 IP:剥离 IPv4-mapped 前缀(::ffff:1.2.3.4 → 1.2.3.4),否则同一来源会被当成两个键。 */
const normIp = (addr: string): string => addr.replace(/^::ffff:/i, '');
/** 该来源还有多久解封(0 = 不在封禁期)。白名单来源永不封禁。 */
function banRemainingMs(remote: string): number {
const ip = normIp(remote);
if (banWhitelist.includes(ip)) return 0;
const st = authBan.get(ip);
if (!st) return 0;
const left = st.bannedUntil - Date.now();
return left > 0 ? left : 0; // 已过期则不挡(计数保留,继续指数退避)
}
/** 记录一次握手失败;返回本次是否**新触发**封禁(好让错误响应带上 retry_after_ms)。 */
function recordAuthFailure(remote: string): { banned: boolean; waitMs: number } {
const ip = normIp(remote);
if (banWhitelist.includes(ip)) return { banned: false, waitMs: 0 };
const prev = authBan.get(ip) ?? { fails: 0, bannedUntil: 0 };
const fails = prev.fails + 1;
if (fails < banMax) {
authBan.set(ip, { fails, bannedUntil: 0 });
return { banned: false, waitMs: 0 };
}
// 第 N 次失败起:ban = base × 2^(N - max),封顶 24h
const power = Math.min(10, fails - banMax);
const waitMs = Math.min(86_400_000, banBaseMs * 2 ** power);
authBan.set(ip, { fails, bannedUntil: Date.now() + waitMs });
return { banned: true, waitMs };
}
function recordAuthSuccess(remote: string): void {
const ip = normIp(remote);
if (authBan.delete(ip)) log('info', `握手失败计数已清零(来源 ${ip} 本次认证成功)`);
}
function attachConnection(ws: WebSocket, remote: string): void {
log('info', `新连接: ${remote}`);
const connId = `c_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
@@ -400,20 +573,50 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
authorized: null,
remote,
connId,
lastActiveAt: Date.now(),
});
// v2.0.0 修正:**客户端对 ping 的 pong 回应也算活跃**。
// 之前只更新 message 的活跃时间 → 僵尸连接(TCP 未断但客户端已死)不会被判空闲,
// 会被永久算进 states.size → 并发上限虚高 → **可能把正常控制端挡在外面**。
ws.on('pong', () => {
const st = states.get(ws);
if (st) st.lastActiveAt = Date.now();
});
ws.on('message', (raw: RawData) => {
void handleMessage(ws, raw.toString());
// v2.0.0:任何入站消息都算活跃(空闲断开以"完全静默"为准,不是"没有调用能力")
const st = states.get(ws);
if (st) st.lastActiveAt = Date.now();
// ⚠️ 崩溃链根因(P0/P1 多条共用):原来这里是 `void handleMessage(...)` ——
// Promise 一旦 reject 就成为 unhandled rejection,Node ≥22 默认**终止进程**。
// 「畸形 hello」「畸形 auth」之所以能把被控端打崩,靠的就是这条路径。
// 这里显式兜底:任何处理异常只记日志 + 回一个错误响应,绝不冒泡到进程级。
void handleMessage(ws, raw.toString()).catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
log('warn', `处理消息时发生未预期异常(已拦截,不影响进程):${msg}`);
try {
sendError(ws, null, ErrorCodes.EXECUTION_FAILED, '服务端处理消息时发生未预期异常', { detail: msg });
} catch {
/* 连接可能已关闭 */
}
});
});
ws.on('close', () => {
log('info', `连接关闭: ${remote}`);
// v12:连接断开即释放其事件订阅,避免 watcher 泄漏
const n = disposeWatchesByOwner(connId);
if (n > 0) log('debug', `已清理 ${n} 个事件订阅(连接 ${connId})`);
states.delete(ws); // v2.0.0:Map 需手动清理(WeakMap 时代靠 GC)
});
ws.on('error', (err: Error) => log('warn', `连接错误: ${err.message}`));
}
return { attach: attachConnection };
return {
attach: attachConnection,
/** v2.0.0:连接入口用 —— 该来源是否在封禁期(返回剩余毫秒,0=不挡)。 */
banRemainingMs,
/** v2.0.0:连接表(空闲清扫与并发上限判据)。 */
states,
};
}
/** 监听模式:被控端在本地起 HTTP(S) + WebSocket 服务。 */
@@ -433,6 +636,11 @@ export function createAgentServer(cfg: AgentConfig, tls: TlsMaterial | null): Pr
: createHttpServer();
const wss = new WebSocketServer({ server: httpServer });
// v23②:连接数上限(判定放在连接入口,见下方 wss.on('connection'))
const maxConnections = Math.max(1, cfg.security?.max_connections ?? 8);
// v23③:空闲断开阈值(0 = 关闭)
const idleTimeoutMs = Math.max(0, cfg.security?.idle_timeout_ms ?? 1_800_000);
// v21 第一批加固:来源网段白名单 —— **在业务握手之前**就断开。
// 放在这里而不是等认证失败,是因为拒绝得更早、开销更低(不必做 TLS/挑战应答)。
wss.on('connection', (ws, req) => {
@@ -443,13 +651,53 @@ export function createAgentServer(cfg: AgentConfig, tls: TlsMaterial | null): Pr
ws.close(1008, 'source not allowed');
return;
}
// v23①:封禁中的来源直接拒(在 TLS 之后、业务握手之前)
const bannedLeft = core.banRemainingMs(remote);
if (bannedLeft > 0) {
log('warn', `连接被拒绝:来源处于封禁期(remote=${remote},剩余 ${Math.ceil(bannedLeft / 1000)}s)`);
audit({ type: 'net.deny', remote, reason: `auth banned, ${bannedLeft}ms left` });
ws.close(1008, `too many failed attempts, retry in ${Math.ceil(bannedLeft / 1000)}s`);
return;
}
// v23②:连接数上限
if (core.states.size >= maxConnections) {
log('warn', `连接被拒绝:已达并发上限 ${maxConnections}(current=${wss.clients.size})`);
audit({ type: 'net.deny', remote, reason: `max connections reached: ${maxConnections}` });
ws.close(1013, 'too many connections');
return;
}
core.attach(ws, remote);
});
// WebSocket 层保活:30s ping,探测死连接
// WebSocket 层保活:30s ping,探测死连接;v23③ 同时做空闲连接清扫
const heartbeat = setInterval(() => {
for (const ws of wss.clients) {
if (ws.readyState === WebSocket.OPEN) ws.ping();
if (ws.readyState !== WebSocket.OPEN) continue;
// v2.0.0 修正(真机踩到):**pong 缺失必须主动 terminate**。
// ws 库默认不会因「ping 无 pong」而断开 —— 客户端进程已死(TCP 未发 FIN)时,
// 连接会一直挂在 states 里,占用 max_connections 名额。
// 判据用与空闲清扫同一个 lastActiveAt(pong 已在上方更新它)。
const st = core.states.get(ws);
if (st && idleTimeoutMs > 0 && Date.now() - st.lastActiveAt > idleTimeoutMs * 2) {
log('warn', `连接无响应(ping 未回 pong 且静默超 ${Math.round((Date.now() - st.lastActiveAt) / 1000)}s)→ 强制断开 ${st.connId}`);
audit({ type: 'session.idle_close', client_id: st.clientId ?? undefined, remote: st.remote, reason: 'unresponsive (no pong)' });
ws.terminate();
continue;
}
ws.ping();
}
if (idleTimeoutMs <= 0) return;
// 保守策略:只要还有任务在跑 / 还有事件订阅,就不断任何连接 ——
// 宁可少断,也不能把"挂着长任务等结果"的控制端踢下线。
if (runningTaskCount() > 0 || watchCount() > 0) return;
const now = Date.now();
for (const [ws, st] of core.states) {
if (ws.readyState !== WebSocket.OPEN) continue;
const idleFor = now - st.lastActiveAt;
if (idleFor < idleTimeoutMs) continue;
log('info', `空闲断开:连接 ${st.connId}(${st.remote})已静默 ${Math.round(idleFor / 1000)}s`);
audit({ type: 'session.idle_close', client_id: st.clientId ?? undefined, remote: st.remote, reason: `idle ${idleFor}ms` });
ws.close(1000, 'idle timeout');
}
}, 30_000);
heartbeat.unref();
+42
View File
@@ -0,0 +1,42 @@
/**
* Windows **DPI 感知** PowerShell 前置段(物理像素坐标的唯一真源)。
*
* ## 为什么必须有
*
* PowerShell 默认是「**DPI 不感知**」进程。在高 DPI(如 200% 缩放)下,这种进程里
* **同时存在两套坐标系**,而且互不提醒:
*
* | 调用 | 该进程看到的 |
* |---|---|
* | `[System.Windows.Forms.Screen]::Bounds` | **逻辑**尺寸(物理 3600×2020 → 报 1800×1010) |
* | `[System.Windows.Forms.SystemInformation]::VirtualScreen` | **逻辑**(同上) |
* | `CopyFromScreen(0,0,1800,1010)` | **物理**裁剪 → 只截到屏幕左上 1/4 |
* | `SetCursorPos(552,1489)` | 落点实际在物理 (1104, 2978)(被系统 ×2) |
* | UIA(`screen.find`) | **物理**(与进程 DPI 感知无关) |
*
* 于是最要命的一条链路会静默断掉:`screen.find` 返回物理坐标 → 直接喂给
* `input.mouse.click` → **点到两倍远的地方**(还可能被屏幕边界截断)。
* 「找得到、点不准」,而且不报任何错。
*
* ## 用法
*
* 在 **任何** `System.Windows.Forms` / 截屏 / 光标调用**之前**执行本段;
* 同一进程内只需一次(`SetProcessDPIAware` 幂等)。新起的 PowerShell 每次都要带上。
*
* ## 真机实证(2026-10-12)
*
* Parallels Win11 ARM 虚拟机,主机缩放 200%,物理 3600×2020:
* - 缺省 `screen.capture` 只拿到 1800×1010(= 屏幕左上 1/4),且 `screen.info` 报 1800×1010
* - `input.mouse.move 100,100` → DPI 感知读回 **(200,200)**
* - `input.mouse.move 552,1489` → 实际落点 **(1104, 2019)**(截断到屏幕底部)
* - 补上本段后:`screen.capture` 拿到完整 3600×2020;`screen.find` 的物理坐标
* 可直接用于 `input.mouse.click`(同一坐标一次点中)
*
* ⚠️ 不要删。这不是「锦上添花」的兼容代码,而是坐标口径统一的**前提**。
* 守卫见 `tests/unit/dpi-awareness.test.mjs`。
*/
export const PS_DPI_AWARE = [
// Add-Type 现编译一个小 P/Invoke 壳;类名固定,每进程只应出现一次。
`Add-Type -TypeDefinition 'using System.Runtime.InteropServices; public class NADpiAware { [DllImport("user32.dll")] public static extern bool SetProcessDPIAware(); }'`,
`[void][NADpiAware]::SetProcessDPIAware()`,
].join('\n');
+12 -1
View File
@@ -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) {
+69
View File
@@ -0,0 +1,69 @@
/**
* 跨平台「安全字面量」工具 —— 把不可信字符串安全地嵌进 shell / PowerShell 脚本。
*
* ## 为什么必须有这个文件
*
* 本项目真机踩到过 **P0 级静默 RCE**:把 `JSON.stringify(title)` 直接插进 PowerShell
* 双引号串,payload `x"; New-Item …; #` 就执行了任意命令 —— 而接口还返回「正常的业务错误」,
* 调用方完全看不出异常(见 `window.ts` 的历史注释与 bug 报告 W-01)。
*
* 两条铁律(违反任一条都会重新引入漏洞):
*
* 1. **PowerShell 里 `\` 不是转义符**(那是 C/JS 的规则)。JSON 产出的 `\"` 会被 PS 解析成
* 「字面反斜杠 + 字符串结束引号」,后面的内容就成了新语句。
* 2. **PS 双引号串会做变量插值与子表达式展开**(`$x`、`$(...)`、反引号),
* 而**单引号串什么都不展开** —— 这是唯一正确的选择。
*
* 因此:**任何**嵌入 PowerShell 的不可信值都必须走 `psLit()`;
* 嵌入 bash/sh 的走 `shLit()`;嵌进命令行参数数组的(`spawn(file, args)`)根本不需要引用,
* 那是首选方案,不要拼字符串。
*/
/** PowerShell 单引号字面量:内部单引号翻倍,其余字符(含 `$`、反引号、反斜杠)全部原样。 */
export function psLit(v: string): string {
return `'${v.replace(/'/g, "''")}'`;
}
/** PowerShell 数组字面量:`@('a','b')`。空数组产出 `@()`。 */
export function psLitArray(values: readonly string[]): string {
return `@(${values.map(psLit).join(',')})`;
}
/**
* POSIX shell 单引号字面量:把 `'` 拆成 `'\''`(闭合 → 转义单引号 → 重开)。
* 单引号内不会有变量展开、命令替换或反斜杠转义。
*/
export function shLit(v: string): string {
return `'${v.replace(/'/g, `'\\''`)}'`;
}
/**
* 校验单引号字面量的形状(供调用侧自检):首尾是 `'`,且内部**没有落单的单引号**。
*
* 原理:正确转义后内部每个 `'` 都成对(`''`),把 `''` 全部消去后不应再残留任何 `'`。
* 只做形状检查,不替代真机验证。
*/
export function assertSingleQuotedLiteral(lit: string): boolean {
if (!lit.startsWith("'") || !lit.endsWith("'")) return false;
return !lit.slice(1, -1).replace(/''/g, '').includes("'");
}
/**
* Windows 原生命令行参数的「裸传」前提校验。
*
* 有些位置**不能用** `psLit()`:例如 `powershell.exe -File <path>` —— 这一段的引号由
* `powershell.exe` 自己的命令行解析器处理,塞 PS 单引号字面量进去不保证被剥掉。
* 这种位置只能把路径**裸着**拼进命令行,于是必须保证它**本身含不了任何会改变语义的字符**。
*
* 真例(v2.0.3 审查发现,与 W-01 同类):`util/ps-helper.ts` 把 `mkdtempSync` 产出的临时脚本
* 路径直接拼进 `-File`。那条路径由本进程生成、正常含不了引号,但**这是隐含前提**——
* 一旦 `TMP`/`TEMP` 被设成怪路径(或用户目录名含特殊字符),前提就破了,而且是静默的。
* 故把前提**显式化**:不满足就当场报错,而不是把可疑字符串拼进命令行。
*/
export function isSafeBareWindowsPathArg(v: string): boolean {
if (v === '' || v.includes('\0')) return false;
// 引号/反引号/$/&/|/</>/(/)/%/^/!/换行:任何一个都能改变 cmd.exe 或 PS 的解析
if (/["'`$&|<>()%^!\r\n\t]/.test(v)) return false;
// 结尾是单个反斜杠会在双引号包裹时吃掉闭引号(cmd 的经典怪癖)
return !/\\$/.test(v);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/cli",
"version": "1.3.0",
"version": "2.0.3",
"private": true,
"type": "module",
"main": "./dist/index.js",
+43 -5
View File
@@ -25,8 +25,9 @@ import {
} from '@nodeagent/client';
import {
CapabilityNames,
matchPattern,
DEFAULT_DISCOVERY_PORT,
generateSharedKey,
matchPattern,
type CapabilityDescriptor,
type InvokeResult,
} from '@nodeagent/protocol';
@@ -56,7 +57,9 @@ export async function cmdConnect(host: string, opts: Options): Promise<void> {
const profile: NodeProfile = {
host,
port,
tls: !process.argv.includes('--no-tls'),
// 原来这里直接扫 `process.argv.includes('--no-tls')` —— 绕过了解析器,
// 于是「帮助里承诺的选项」与「解析器认识的选项」是两套(L-01 同类)。
tls: !opts.noTls,
insecure: opts.insecure,
key: opts.key ?? '',
auth_mode: authMode,
@@ -176,6 +179,31 @@ export async function cmdRemove(name: string | undefined, opts: Options): Promis
export async function cmdKeygen(opts: Options): Promise<void> {
const clientId = opts.id ?? 'mac_01';
// v22:`--psk` 生成**每客户端专属**的预共享密钥(配合被控端 agent.json 的 keys{})。
// 与 ed25519 的区别:这是「对称」方案 —— 实现简单、不用管公钥,但同样是**一把一个身份**
// (知道 A 的 key 就冒充不了 B),适合不方便管理公钥的场景。
if (opts.psk === true) {
const psk = generateSharedKey();
console.log('✓ 已生成该 client_id 的专属预共享密钥(psk 模式)');
console.log(` client_id : ${clientId}`);
console.log(` 密钥 : ${psk}`);
console.log('\n请把下面这段加入被控端 agent.json(可与其它 client_id 并列):\n');
console.log(
JSON.stringify(
{ keys: { [clientId]: psk } },
null,
2,
),
);
console.log('\n然后控制端这样连(key 即上面的密钥):');
console.log(` nodeagent connect <被控端IP> --port 8765 --key ${psk} --id ${clientId} --insecure`);
console.log(
'\n提示:登记了 keys{} 之后,未登记的 client_id 会回落到共享 key(若被控端仍配有 key)。',
);
return;
}
const keys = createKeys(clientId);
console.log('✓ 已生成 Ed25519 密钥对');
console.log(` client_id : ${keys.client_id}`);
@@ -206,7 +234,7 @@ export async function cmdDiscover(opts: Options): Promise<void> {
let nodes: Awaited<ReturnType<typeof discoverOnce>>;
try {
nodes = await discoverOnce(waitMs, { port });
nodes = await discoverOnce(waitMs, { port, secret: opts.discoverSecret });
} catch (err) {
fail(`监听失败:${err instanceof Error ? err.message : String(err)}`);
}
@@ -222,11 +250,21 @@ export async function cmdDiscover(opts: Options): Promise<void> {
if (opts.json) return printJson(nodes);
console.log(`发现 ${nodes.length} 台被控端:\n`);
console.log(` ${'节点 ID'.padEnd(16)} ${'地址'.padEnd(20)} ${'协议'.padEnd(6)} ${'认证'.padEnd(8)} 平台`);
console.log(
` ${'节点 ID'.padEnd(16)} ${'地址'.padEnd(20)} ${'协议'.padEnd(6)} ${'认证'.padEnd(8)} ${'来源'.padEnd(10)} 平台`,
);
for (const n of nodes) {
const ctl = n.input_enabled ? ' [输入控制已开]' : '';
// v22:广播本身是未认证的,只有验签通过的才标 🔒
const trust = n.authenticated ? '🔒 已认证' : '⚠️ 明文';
console.log(
` ${n.node_id.padEnd(16)} ${`${n.host}:${n.port}`.padEnd(20)} ${(n.tls ? 'wss' : 'ws').padEnd(6)} ${n.auth_mode.padEnd(8)} ${n.platform}${ctl}`,
` ${n.node_id.padEnd(16)} ${`${n.host}:${n.port}`.padEnd(20)} ${(n.tls ? 'wss' : 'ws').padEnd(6)} ${n.auth_mode.padEnd(8)} ${trust.padEnd(10)} ${n.platform}${ctl}`,
);
}
if (nodes.some((n) => !n.authenticated) && !opts.discoverSecret) {
console.log(
'\n ℹ️ 标「⚠️ 明文」的条目:广播可被同网段任意伪造,其地址/认证方式仅作参考。' +
'\n 要可信发现,请给被控端 discovery.secret,并在此用 --discover-secret <同一密钥>。',
);
}
const first = nodes[0]!;
+111 -6
View File
@@ -25,6 +25,7 @@ import {
} from '@nodeagent/client';
import {
CapabilityNames,
HOTKEY_PRESETS,
matchPattern,
DEFAULT_DISCOVERY_PORT,
type CapabilityDescriptor,
@@ -61,7 +62,15 @@ export async function cmdScreen(opts: Options): Promise<void> {
export async function cmdScreenshot(opts: Options): Promise<void> {
const args: Record<string, unknown> = {};
if (opts.format) args['format'] = opts.format;
if (opts.scale) args['scale'] = Number(opts.scale);
if (opts.scale) {
const s = Number(opts.scale);
// 协议限定 scale ∈ (0, 1](缩小)。原先直接把非法值发出去,
// 被控端只回一句「参数校验失败」,看不出是哪一项 —— 2026-10-05 实测踩过。
if (!Number.isFinite(s) || s <= 0 || s > 1) {
fail(`--scale 应在 0 到 1 之间(1=原尺寸,0.5=半尺寸),收到: ${opts.scale}`);
}
args['scale'] = s;
}
if (opts.region) {
const nums = opts.region.split(',').map(Number);
if (nums.length !== 4 || nums.some((n) => !Number.isFinite(n))) fail('--region 格式应为 x,y,width,height');
@@ -163,19 +172,57 @@ export async function cmdKey(action: string | undefined, positionals: string[],
return;
}
case 'press': {
if (positionals.length === 0) fail('用法: nodeagent key press <键1> [键2] ...(如 ctrl c)');
// v1.5.0:三种用法(一次一种)
// nodeagent key press "ctrl+shift+esc" 字符串热键
// nodeagent key press copy 预设名
// nodeagent key press ctrl c 老形式(空格分隔的各键)
if (positionals.length === 0) fail('用法: nodeagent key press "<热键>" | <预设名> | <键1> [键2] ...(如 "ctrl+shift+esc" / copy / ctrl c)');
const args: Record<string, unknown> = {};
let positionalsEcho = positionals;
if (positionals.length === 1) {
const one = positionals[0]!;
// 含分隔符 → 字符串热键;不含但命中预设表 → 预设;都不中 → 单键(也当热键交给被控端解析)
const spec = HOTKEY_PRESETS[one.trim().toLowerCase()];
if (spec) {
args['preset'] = one.trim().toLowerCase();
} else {
args['hotkey'] = one;
}
} else {
args['keys'] = positionals;
}
// 老形式说明:位置参数以空格分隔 → 兼容"ctrl c"(无 + 号)
if (opts.interval) args['interval_ms'] = Number(opts.interval);
// v1.5.0 新增可选参数
if (opts.hold) args['hold_ms'] = Number(opts.hold);
if (opts.route) args['route'] = opts.route;
// 媒体键可定向到指定进程(播放器常建 30+ 辅助窗口,只投主窗口往往无效)
if (opts.pid) args['target_pid'] = Number(opts.pid);
await withClient((c) =>
callAndPrint(c, CapabilityNames.KeyPress, { keys: positionals }, opts.json, (d) => {
callAndPrint(c, CapabilityNames.KeyPress, args, opts.json, (d) => {
// 被控端返回 { pressed, chords, times },**没有 keys 字段**
// (真机 2026-10-04 实测:原先直接读 r.keys.join() 会抛
// "Cannot read properties of undefined (reading 'join')")
// 故优先用请求参数回显,缺失时再退回 chords 展开。
const r = d as { keys?: string[]; chords?: string[][]; times?: number };
const r = d as {
keys?: string[]; chords?: string[][]; times?: number; via?: string;
presets?: string[]; channel?: string; route?: string; sent_windows?: number;
target_pid?: number | null; note?: string;
};
const shown = (r.chords ?? []).map((ch) => ch.join('+')).join(' → ');
const label = r.keys?.length
? r.keys.join('+')
: (r.chords ?? []).map((ch) => ch.join('+')).join(' → ') || positionals.join('+');
: shown || positionalsEcho.join('+');
const times = r.times && r.times > 1 ? `(重复 ${r.times} 次)` : '';
console.log(`✓ 已按下 ${label}${times}`);
const presetEcho = r.presets?.length ? `(预设 ${r.presets.join('→')} = ${shown})` : '';
const via =
r.channel === 'appcommand'
? `(媒体通道${r.target_pid ? ` → pid ${r.target_pid}` : ' · 广播'})`
: r.channel === 'postmessage'
? `(后台投递 → pid ${r.target_pid},命中 ${r.sent_windows ?? 0} 窗口)`
: '';
console.log(`✓ 已按下 ${label}${times}${presetEcho}${via}`);
if (r.note) console.log(` ℹ️ ${r.note}`);
}),
);
return;
@@ -185,6 +232,64 @@ export async function cmdKey(action: string | undefined, positionals: string[],
}
}
// ---------- v1.6.0 等待条件(gui.await) ----------
/**
* 等待一个条件成立再返回(只读低危)。
*
* nodeagent await --condition control --text "完成" --timeout 30000
* nodeagent await --condition window --title "安装程序"
* nodeagent await --condition process --process setup.exe
* nodeagent await --condition file --path "C:\\log.txt" --state absent
*
* 四类条件各自复用 window.list / screen.find / process.list / fs.stat,
* 平台行为与那些能力一致。超时是**正常返回**(satisfied:false + note),不抛异常。
*/
export async function cmdAwait(opts: Options): Promise<void> {
const condition = opts.condition as string | undefined;
if (!condition) {
fail('用法: nodeagent await --condition <window|control|process|file> --timeout <ms> [--text/--title/--process/--path] [--state absent]');
}
const args: Record<string, unknown> = { condition };
// v2.0.0:where / any_of 是 JSON 字符串(属性谓词与组合条件不便用命令行开关表达)
for (const k of ['where', 'any_of']) {
const raw = (opts as unknown as Record<string, string | undefined>)[k];
if (raw !== undefined) {
try {
args[k] = JSON.parse(raw);
} catch {
fail(`--${k} 不是合法 JSON: ${raw.slice(0, 60)}`);
}
}
}
if (opts.text !== undefined) args['text'] = opts.text;
if (opts.title !== undefined) args['title'] = opts.title;
if (opts.process !== undefined) args['process'] = opts.process;
if (opts.path !== undefined) args['path'] = opts.path;
if (opts.state !== undefined) args['state'] = opts.state;
if (opts.timeoutMs !== undefined) args['timeout_ms'] = Number(opts.timeoutMs);
if (opts.interval !== undefined) args['interval_ms'] = Number(opts.interval);
await withClient((c) =>
callAndPrint(c, CapabilityNames.GuiAwait, args, opts.json, (d) => {
const r = d as {
satisfied: boolean; condition: string; state: string;
elapsed_ms: number; attempts: number; last_seen: string; last_error?: string; note?: string;
};
const mark = r.satisfied ? '✓' : '✗';
const label = r.condition === 'control' ? String(args['text'] ?? '')
: r.condition === 'window' ? String(args['title'] ?? '')
: r.condition === 'process' ? String(args['process'] ?? '')
: String(args['path'] ?? '');
console.log(
`${mark} ${r.state === 'absent' ? '已消失' : '已出现'}: ${r.condition} ${label}` +
`(${r.elapsed_ms}ms,${r.attempts} 次轮询,末次=${r.last_seen})`,
);
if (r.note) console.log(` ℹ️ ${r.note}`);
if (r.last_error) console.log(` ⚠️ 期间末次异常:${r.last_error.slice(0, 120)}`);
}),
);
}
// ---------- v3+ 审计 ----------
/** v11:审计链完整性校验。 */
+134 -2
View File
@@ -273,7 +273,10 @@ export async function cmdDeploy(localFile: string, opts: Options): Promise<void>
const uploadInfo = await withClient(async (c) => {
// 备份走协议自带的 fs 能力(而非 shell 的 Copy-Item)——
// Copy-Item 是 Windows 专用,在 macOS 被控端上会静默失败(真机踩过:以为备份了,其实没有)。
const backup = `${target}.bak-${Date.now()}`;
// 备份用**固定名**(覆盖式):此前用 `.bak-<时间戳>`,每部署一次就多一个文件 ——
// 真机 2026-10-09 发现 D:\Nodeagent 下堆了 15+ 个 bak(单文件 1MB,纯浪费)。
// 部署回滚只需要「上一个可用版本」,一份就够,故改为覆盖写。
const backup = `${target}.bak`;
let backupPath = '';
try {
const st = await c.invoke<{ size?: number; exists?: boolean }>(CapabilityNames.FsStat, { path: target });
@@ -329,6 +332,23 @@ export async function cmdDeploy(localFile: string, opts: Options): Promise<void>
offset += part.length;
}
console.log(`✓ 已上传 ${(buf.length / 1024).toFixed(0)} KB`);
// best-effort 清理历史的时间戳备份(老格式 `.bak-<ts>`)。
// 平台分流:Windows 用 PS,类 Unix 用 rm。清不掉不影响部署,只提示。
try {
const isWin = String((info as { os?: string }).os ?? '').toLowerCase().startsWith('win');
const cleanup = isWin
? `Get-ChildItem '${target}.bak-*' -EA SilentlyContinue | Remove-Item -Force -EA SilentlyContinue; (Get-ChildItem '${target}.bak-*' -EA SilentlyContinue | Measure-Object).Count`
: `rm -f '${target}.bak-'* 2>/dev/null; ls '${target}.bak-'* 2>/dev/null | wc -l`;
const r = await c.invoke<{ stdout?: string }>(CapabilityNames.ShellExec, { command: cleanup, timeout_ms: 15_000 });
const left = Number((r.data?.stdout ?? '').trim());
if (r.status === 'ok' && Number.isFinite(left)) {
if (left === 0) console.log('✓ 已清理历史时间戳备份(老格式)');
else console.log(`⚠️ 仍有 ${left} 个历史备份未清理(可手动删除)`);
}
} catch {
/* 清理是附加项,失败不影响部署结果 */
}
return { backupPath };
});
@@ -375,7 +395,7 @@ export async function cmdDeploy(localFile: string, opts: Options): Promise<void>
console.error(` 回滚方式:把备份复制回目标即可 ——`);
console.error(` nodeagent exec "Copy-Item '${uploadInfo.backupPath}' '${target}' -Force"`);
}
console.error(' 排查:被控端杀软(主流杀软/杀软)可能处置了被覆盖的 agent 文件');
console.error(' 排查:被控端杀软可能处置了被覆盖的 agent 文件');
process.exitCode = 1;
}
}
@@ -620,3 +640,115 @@ export async function cmdClip(opts: Options): Promise<void> {
}),
);
}
// ---------- v2.0.0 状态采样(monitor / log) ----------
/**
* 状态采样与回看。
*
* nodeagent monitor start --source port --target 127.0.0.1:8765 --interval 2000
* nodeagent monitor report <id>
* nodeagent monitor stop <id>
* nodeagent monitor list
* nodeagent monitor delete <id>
*/
export async function cmdMonitor(action: string | undefined, positional: string[], opts: Options): Promise<void> {
switch (action) {
case 'start': {
const source = opts.source;
if (!source) fail('用法: nodeagent monitor start --source <port|process|command|metric> [--target X] [--interval ms] [--id name]');
const args: Record<string, unknown> = { source };
if (opts.target !== undefined) args['target'] = opts.target;
if (opts.interval !== undefined) args['interval_ms'] = Number(opts.interval);
if (positional[0]) args['id'] = positional[0];
await withClient((c) =>
callAndPrint(c, CapabilityNames.MonitorStart, args, opts.json, (d) => {
const r = d as { id: string; source: string; target: string; interval_ms: number; file: string };
console.log(`✓ 已启动监控 ${r.id}(${r.source}${r.target ? ' → ' + r.target : ''},每 ${r.interval_ms}ms 一针)`);
console.log(` 样本文件:${r.file}`);
console.log(` 回看:nodeagent monitor report ${r.id}`);
}),
);
return;
}
case 'report': {
const id = positional[0];
if (!id) fail('用法: nodeagent monitor report <id> [--limit n]');
const args: Record<string, unknown> = { id };
if (opts.limit !== undefined) args['limit'] = Number(opts.limit);
await withClient((c) =>
callAndPrint(c, CapabilityNames.MonitorReport, args, opts.json, (d) => {
const r = d as {
running: boolean; source: string; target: string; samples: number;
summary: Record<string, number>;
};
console.log(`${r.running ? '● 运行中' : '○ 已停止'} ${r.source}${r.target ? ' → ' + r.target : ''},共 ${r.samples} 个样本`);
// 摘要直接说人话(这是本能力的主要价值)
const sm = r.summary;
if (sm.down_count !== undefined) {
console.log(` 连通:${sm.up_count} 通 / ${sm.down_count} 断;最长连续中断 ${sm.longest_outage_samples} 针` +
(sm.ms_avg !== undefined ? `;延迟 min/avg/max = ${sm.ms_min}/${sm.ms_avg}/${sm.ms_max}ms` : ''));
}
if (sm.dead_count !== undefined) console.log(` 存活:${sm.alive_count} 在 / ${sm.dead_count} 不在;最长连续缺失 ${sm.longest_dead_samples} 针`);
if (sm.fail_count !== undefined) console.log(` 命令:${sm.ok_count} 成功 / ${sm.fail_count} 失败;退出码 ${JSON.stringify(sm.exit_codes_seen)}`);
if (sm.cpu_avg !== undefined) console.log(` CPU min/avg/max = ${sm.cpu_min}/${sm.cpu_avg}/${sm.cpu_max}%;内存 avg ${sm.mem_avg}%`);
if (sm.window_ms !== undefined) console.log(` 时间窗:${(sm.window_ms / 1000).toFixed(1)}s`);
}),
);
return;
}
case 'stop': {
const id = positional[0];
if (!id) fail('用法: nodeagent monitor stop <id>');
await withClient((c) =>
callAndPrint(c, CapabilityNames.MonitorStop, { id }, opts.json, (d) => {
const r = d as { id: string; samples: number; file: string };
console.log(`✓ 已停止 ${r.id}(共 ${r.samples} 个样本);样本留在 ${r.file}`);
}),
);
return;
}
case 'list': {
await withClient((c) =>
callAndPrint(c, CapabilityNames.MonitorList, {}, opts.json, (d) => {
const r = d as { running: Array<{ id: string; source: string; target: string; samples: number }>; running_count: number; history_files: string[] };
console.log(`运行中 ${r.running_count} 个:`);
for (const m of r.running) console.log(` ● ${m.id}(${m.source}${m.target ? ' → ' + m.target : ''},${m.samples} 样本)`);
if (r.history_files.length) console.log(`历史文件:${r.history_files.join(', ')}`);
}),
);
return;
}
case 'delete': {
const id = positional[0];
if (!id) fail('用法: nodeagent monitor delete <id>(运行中需先 stop)');
await withClient((c) =>
callAndPrint(c, CapabilityNames.MonitorDelete, { id }, opts.json, () => console.log(`✓ 已删除 ${id} 的历史样本`)),
);
return;
}
default:
fail('用法: nodeagent monitor <start|report|stop|list|delete> ...');
}
}
/** 在被控端侧过滤日志(v2.0.0):日志常几万行,过滤必须发生在被控端。 */
export async function cmdLog(opts: Options): Promise<void> {
const logPath = opts.path;
if (!logPath) fail('用法: nodeagent log --path <文件> [--pattern 正则] [--level ERROR] [--limit n] [--tail]');
const args: Record<string, unknown> = { path: logPath };
if (opts.pattern !== undefined) args['pattern'] = opts.pattern;
if (opts.level !== undefined) args['level'] = opts.level;
if (opts.since !== undefined) args['since'] = Number(opts.since);
if (opts.limit !== undefined) args['limit'] = Number(opts.limit);
if (opts.offset !== undefined) args['offset'] = Number(opts.offset);
if (opts.tail === true) args['tail'] = true;
await withClient((c) =>
callAndPrint(c, CapabilityNames.LogQuery, args, opts.json, (d) => {
const r = d as { matched: Array<{ n: number; text: string }>; scanned: number; truncated: boolean; note?: string };
console.log(`✓ 扫描 ${r.scanned} 行,命中 ${r.matched.length} 条${r.truncated ? '(结果被截断)' : ''}`);
for (const m of r.matched) console.log(` ${String(m.n).padStart(6)} ${m.text.slice(0, 160)}`);
if (r.note) console.log(` ℹ️ ${r.note}`);
}),
);
}
+48
View File
@@ -183,6 +183,54 @@ export async function cmdRestart(opts: Options): Promise<void> {
);
}
// ---------- v2.0.0 审计链外部锚定 ----------
/**
* 打印审计链头(可选与最近锚点比对)。
*
* nodeagent audit head [--compare]
*/
export async function cmdAuditHead(opts: Options): Promise<void> {
const args: Record<string, unknown> = {};
if (opts.compare === true) args['compare'] = true;
if (opts.anchorPath !== undefined) args['anchor_path'] = opts.anchorPath;
await withClient((c) =>
callAndPrint(c, CapabilityNames.AuditHead, args, opts.json, (data) => {
const d = data as {
entries: number; head_hash: string | null; head_ts: number | null;
rotated_segments: number; file_bytes: number;
comparison?: { ok: boolean; anchors_checked: number; verdict: string };
};
console.log(`审计链头:${d.entries} 条,轮转 ${d.rotated_segments} 段,${(d.file_bytes / 1024).toFixed(0)} KB`);
console.log(` head_hash : ${d.head_hash ?? '(空链)'}`);
console.log(` head_ts : ${d.head_ts ? new Date(d.head_ts).toLocaleString() : '-'}`);
console.log(' 💡 把 head_hash 连同 entries 存到链外(另一台机器/网盘)—— 这是外部锚定');
if (d.comparison) {
const k = d.comparison.ok ? '✓' : '✗';
console.log(` ${k} 锚点比对(${d.comparison.anchors_checked} 条锚点):${d.comparison.verdict}`);
}
}),
);
}
/**
* 追加一个锚点到锚点文件。
*
* nodeagent audit anchor [--anchor-path <文件>] [--note "每日例行"]
*/
export async function cmdAuditAnchor(opts: Options): Promise<void> {
const args: Record<string, unknown> = {};
if (opts.anchorPath !== undefined) args['path'] = opts.anchorPath;
if (opts.note !== undefined) args['note'] = opts.note;
await withClient((c) =>
callAndPrint(c, CapabilityNames.AuditAnchor, args, opts.json, (data) => {
const d = data as { file: string; total_lines: number; record: { entries: number; head_hash: string | null } };
console.log(`✓ 已锚定:${d.record.entries} 条,链头 ${String(d.record.head_hash).slice(0, 16)}…`);
console.log(` 锚点文件:${d.file}(累计 ${d.total_lines} 条锚点)`);
}),
);
}
export async function cmdAuditVerify(opts: Options): Promise<void> {
await withClient((c) =>
callAndPrint(c, CapabilityNames.AuditVerify, {}, opts.json, (data) => {
+10
View File
@@ -48,6 +48,13 @@ export const HELP = `nodeagent —— 跨机 AI 接管框架(控制端 CLI)
nodeagent macro validate <file.json> | macro init [文件]
nodeagent events [--kind file|process|net] [--path <路径>] [--pattern G] [--seconds 15]
订阅事件并实时接收推送(无 --kind 则列出订阅)(v12)
nodeagent monitor start --source <port|process|command|metric> [--target X] [--interval ms] [--id 名]
启动定时采样并落盘 (v2.0.0):port 验连通性/process 验存活/
command 取退出码/metric 取 CPU·内存
nodeagent monitor report <id> 回看样本 + 摘要(断几次、最长断多久、值域)(v2.0.0)
nodeagent monitor stop|list|delete <id>
nodeagent log --path <文件> [--pattern 正则] [--level ERROR] [--limit n] [--tail]
在被控端侧过滤日志,只回匹配行(日志几万行也不拖网)(v2.0.0)
nodeagent record [--duration 5000] [--fps 2] [--scale 0.5] [--region x,y,w,h]
录屏为帧序列(有 ffmpeg 则封装 mp4)(v11)
nodeagent update --url <地址> --sha256 <哈希> [--dry-run]
@@ -72,6 +79,9 @@ export const HELP = `nodeagent —— 跨机 AI 接管框架(控制端 CLI)
nodeagent mouse scroll <delta>
nodeagent key type "<文本>" [--interval 10]
nodeagent key press <键1> [键2] ... (组合键,如 ctrl c)
nodeagent key press media_play_pause --pid 1234 (媒体键,v1.2:走 WM_APPCOMMAND,
--pid 定向到该进程的全部顶层窗口;省略则广播。
窗口被遮挡/最小化/游戏全屏时依然有效)
通用选项:
--node <设备名> 本次命令临时指定目标设备(不改变 current)
+27 -3
View File
@@ -34,13 +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, cmdAuditVerify, cmdExec, cmdInfo, cmdInstall, cmdInvoke, cmdList,
cmdApps, cmdAudio, cmdAudit, cmdAuditAnchor, cmdAuditHead, cmdAuditVerify, cmdExec, cmdInfo, cmdInstall, cmdInvoke, cmdList,
cmdMetrics, cmdPs, cmdRestart, cmdServices, cmdStatus, cmdUpdate,
} from './cmd/system.js';
import {
cmdBg, cmdClip, cmdDeploy, cmdEvents, cmdFanout, cmdMacro, cmdRecord, cmdTask, cmdTasks,
cmdBg, cmdClip, cmdDeploy, cmdEvents, cmdFanout, cmdLog, cmdMacro, cmdMonitor, cmdRecord, cmdTask, cmdTasks,
} from './cmd/ops.js';
import { cmdKey, cmdMouse, cmdScreen, cmdScreenshot } from './cmd/gui.js';
import { cmdAwait, cmdKey, cmdMouse, cmdScreen, cmdScreenshot } from './cmd/gui.js';
import { cmdCat, cmdLs, cmdPull, cmdPush, cmdStat } from './cmd/fs.js';
import { cmdNet } from './cmd/net.js';
import { DAEMON_SOCK, fail, getClientConfig, setNodeOverride } from './core.js';
@@ -60,6 +60,16 @@ async function main(): Promise<void> {
return;
}
// ⚠️ `--help` 是**选项**、不是位置参数。
// parseArgs 开了 `strict: false`,`--help` / `-h` 会被收进 `values`(作 boolean),
// `positionals` 里压根没有它 —— 原来靠「`positionals[0] === undefined` 时打印帮助」
// **偶然**蒙对,一旦前面多一个位置参数就会掉进「未知命令」。
// L-01 总闸守卫(tests/unit/cli-options.test.mjs)扫出这类「承诺了却没接上」的选项。
if (opts.help) {
console.log(HELP);
return;
}
switch (command) {
case 'connect': {
const host = positionals[0];
@@ -187,6 +197,14 @@ async function main(): Promise<void> {
case 'events':
await cmdEvents(opts);
return;
case 'monitor': {
const [action, ...rest2] = positionals;
await cmdMonitor(action, rest2, opts);
return;
}
case 'log':
await cmdLog(opts);
return;
case 'macro': {
const sub = positionals[0];
await cmdMacro(sub, positionals[1], opts);
@@ -268,6 +286,8 @@ async function main(): Promise<void> {
return;
case 'audit':
if (positionals[0] === 'verify') await cmdAuditVerify(opts);
else if (positionals[0] === 'head') await cmdAuditHead(opts);
else if (positionals[0] === 'anchor') await cmdAuditAnchor(opts);
else await cmdAudit(opts);
return;
case 'discover':
@@ -304,6 +324,10 @@ async function main(): Promise<void> {
await cmdKey(action, rest2, opts);
return;
}
case 'await': {
await cmdAwait(opts);
return;
}
case undefined:
case 'help':
case '--help':
+55 -3
View File
@@ -38,6 +38,11 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
const { values, positionals } = parseArgs({
args: rest,
options: {
// ⚠️ 这两个是 L-01 同类问题的「漏网之鱼」,由 cli-options 守卫扫出(v2.0.3):
// `--help` 原来靠 positionals[0]===undefined 偶然命中;`--no-tls` 则绕过解析器
// 直接在 cmd/conn.ts 里扫 `process.argv`。两者都是「能跑,但没接进解析器」。
help: { type: 'boolean', default: false },
'no-tls': { type: 'boolean', default: false },
json: { type: 'boolean', default: false },
insecure: { type: 'boolean', default: false },
port: { type: 'string' },
@@ -54,6 +59,24 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
button: { type: 'string' },
duration: { type: 'string' },
interval: { type: 'string' },
tail: { type: 'boolean' },
compare: { type: 'boolean' },
'anchor-path': { type: 'string' },
note: { type: 'string' },
level: { type: 'string' },
target: { type: 'string' },
source: { type: 'string' },
where: { type: 'string' },
any_of: { type: 'string' },
title: { type: 'string' },
text: { type: 'string' },
process: { type: 'string' },
state: { type: 'string' },
condition: { type: 'string' },
// 媒体键定向投递的目标进程 PID(v1.2)
pid: { type: 'string' },
hold: { type: 'string' },
route: { type: 'string' },
'auth-mode': { type: 'string' },
since: { type: 'string' },
type: { type: 'string' },
@@ -62,7 +85,6 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
'discovery-port': { type: 'string' },
node: { type: 'string' },
name: { type: 'string' },
note: { type: 'string' },
recursive: { type: 'boolean', default: false },
pattern: { type: 'string' },
'create-dirs': { type: 'boolean', default: false },
@@ -72,6 +94,12 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
delay: { type: 'string' },
// v10 后台任务 / 剪贴板
'timeout-ms': { type: 'string' },
// ⚠️ L-01(v2.0.3 修):`--timeout` 曾经**没在这里声明**。因为 `strict: false`,
// 未声明选项会被当成 boolean 塞进 `values.timeout`(`true`),而它的值
// `30000` 则掉进 `positionals` —— 于是 `--timeout` 静默失效,
// 并且**污染位置参数**(可能把子命令/主机名解析错)。
// 帮助文本与 `cmd/gui.ts` 的 await 命令用的都是 `--timeout`,故必须显式声明。
timeout: { type: 'string' },
kill: { type: 'boolean', default: false },
offset: { type: 'string' },
set: { type: 'string' },
@@ -94,6 +122,8 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
url: { type: 'string' },
sha256: { type: 'string' },
'dry-run': { type: 'boolean', default: false },
psk: { type: 'boolean', default: false },
'discover-secret': { type: 'string' },
'forget-cert': { type: 'boolean', default: false },
check: { type: 'boolean', default: false },
force: { type: 'boolean', default: false },
@@ -107,6 +137,8 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
});
return {
opts: {
help: Boolean(values['help']),
noTls: Boolean(values['no-tls']),
json: Boolean(values['json']),
insecure: Boolean(values['insecure']),
port: values['port'] as string | undefined,
@@ -121,6 +153,23 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
button: values['button'] as string | undefined,
duration: values['duration'] as string | undefined,
interval: values['interval'] as string | undefined,
tail: values['tail'] as boolean | undefined,
compare: values['compare'] as boolean | undefined,
anchorPath: values['anchor-path'] as string | undefined,
note: values['note'] as string | undefined,
level: values['level'] as string | undefined,
target: values['target'] as string | undefined,
source: values['source'] as string | undefined,
where: values['where'] as string | undefined,
any_of: values['any_of'] as string | undefined,
condition: values['condition'] as string | undefined,
state: values['state'] as string | undefined,
process: values['process'] as string | undefined,
text: values['text'] as string | undefined,
title: values['title'] as string | undefined,
pid: values['pid'] as string | undefined,
hold: values['hold'] as string | undefined,
route: values['route'] as string | undefined,
authMode: values['auth-mode'] as string | undefined,
since: values['since'] as string | undefined,
type: values['type'] as string | undefined,
@@ -129,7 +178,9 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
discoveryPort: values['discovery-port'] as string | undefined,
node: values['node'] as string | undefined,
delay: values['delay'] as string | undefined,
timeoutMs: values['timeout-ms'] as string | undefined,
// 两个写法都接受:`--timeout` 是帮助文本/`await` 命令里的正式写法,
// `--timeout-ms` 是早期在 `bg` 命令里引入的别名。二者同时给时以 `--timeout` 为准。
timeoutMs: (values['timeout'] ?? values['timeout-ms']) as string | undefined,
kill: Boolean(values['kill']),
offset: values['offset'] as string | undefined,
set: values['set'] as string | undefined,
@@ -152,6 +203,8 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
url: values['url'] as string | undefined,
sha256: values['sha256'] as string | undefined,
dryRun: Boolean(values['dry-run']),
psk: Boolean(values['psk']),
discoverSecret: values['discover-secret'] as string | undefined,
forgetCert: Boolean(values['forget-cert']),
check: Boolean(values['check']),
force: Boolean(values['force']),
@@ -160,7 +213,6 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
seconds: values['seconds'] as string | undefined,
intervalMs: values['interval-ms'] as string | undefined,
name: values['name'] as string | undefined,
note: values['note'] as string | undefined,
recursive: Boolean(values['recursive']),
pattern: values['pattern'] as string | undefined,
createDirs: Boolean(values['create-dirs']),
+37 -5
View File
@@ -1,6 +1,10 @@
// CLI 共享类型与常量(从 index.ts 拆出)
/** 已解析的常用选项。 */
export interface Options {
/** 打印帮助后退出(`--help` / `-h`)。选项而非位置参数 */
help: boolean;
/** 关闭 TLS(`--no-tls`)。原来是直接扫 process.argv,现归位到解析器 */
noTls: boolean;
json: boolean;
insecure: boolean;
port?: string;
@@ -16,10 +20,38 @@ export interface Options {
button?: string;
duration?: string;
interval?: string;
/** 媒体键定向投递的目标进程 PID(v1.2 keyPress) */
/** 通用选项:文本 / 标题 */
text?: string;
title?: string;
/** v2.0.0 审计锚定 */
compare?: boolean;
anchorPath?: string;
note?: string;
/** v2.0.0 状态采样 */
source?: string;
target?: string;
pattern?: string;
level?: string;
since?: string;
offset?: string;
tail?: boolean;
/** v2.0.0 gui.await 扩展 */
where?: string;
any_of?: string;
/** v1.6.0 gui.await */
condition?: string;
state?: string;
path?: string;
process?: string;
/** v1.5.0 快捷键扩展:长按毫秒 */
hold?: string;
/** v1.5.0 快捷键扩展:投递路由 foreground|post */
route?: string;
pid?: string;
/** v3:认证模式 psk | ed25519 */
authMode?: string;
/** v3+ 审计查询 */
since?: string;
type?: string;
clientId?: string;
/** v4 发现 */
@@ -28,10 +60,8 @@ export interface Options {
/** v5 多设备 */
node?: string;
name?: string;
note?: string;
/** v5 文件 */
recursive?: boolean;
pattern?: string;
createDirs?: boolean;
/** v6 Hub */
hubToken?: string;
@@ -41,7 +71,6 @@ export interface Options {
/** v10 后台任务 / 剪贴板 */
timeoutMs?: string;
kill?: boolean;
offset?: string;
set?: string;
/** v11 剪贴板图片 */
imageFile?: string;
@@ -50,11 +79,14 @@ export interface Options {
/** v11 多设备并发 */
nodes?: string;
/** v11 部署目标路径 */
path?: string;
/** v12 事件订阅 */
kind?: string;
/** v12 宏变量 */
vars?: string[];
/** v22 发现广播认证密钥 */
discoverSecret?: string;
/** v22 每客户端 PSK */
psk?: boolean;
/** v21 证书指纹 */
forgetCert?: boolean;
/** v19 拉取式自更新 */
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+7
View File
@@ -0,0 +1,7 @@
# Tauri + Vue + TypeScript
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
## Recommended IDE Setup
- [VS Code](https://code.visualstudio.com/) + [Vue - Official](https://marketplace.visualstudio.com/items?itemName=Vue.volar) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
+28
View File
@@ -0,0 +1,28 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>nodeagent 控制台</title>
<script>
// 首帧防闪:Vue 接管之前浏览器已经会画一次背景,若不先刷上——
// 「高级灰」用户每次启动都会看到一帧刺眼的白。
//
// 此处只重复**两个**底色值(真值在 src/theme/index.ts),因为在此之前
// 没有任何模块可依赖;其余所有颜色仍由 App.vue 运行期注入的 --na-* 负责。
// 改调色板底色时记得同步这两行。
try {
var isGray = localStorage.getItem("nodeagent.console.theme") === "gray";
document.documentElement.style.background = isGray ? "#1d1f22" : "#f4f4f5";
document.documentElement.style.colorScheme = isGray ? "dark" : "light";
} catch (e) {
/* localStorage 不可用(隐私模式)时走默认底色即可 */
}
</script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+35
View File
@@ -0,0 +1,35 @@
{
"name": "nodeagent-console",
"private": true,
"version": "2.0.3",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"tauri": "tauri",
"sidecar:dev": "node sidecar/main.mjs",
"sidecar:build": "node sidecar/build.mjs",
"prepare:node": "node ../../scripts/prepare-console.mjs",
"verify:bundle": "node scripts/verify-bundle.mjs",
"bundle": "pnpm sidecar:build && pnpm --filter @nodeagent/agent build && pnpm prepare:node && tauri build --config src-tauri/tauri.bundle.gen.json && pnpm verify:bundle"
},
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2",
"echarts": "^6.1.0",
"naive-ui": "^2.45.3",
"pinia": "^4.0.3",
"vue": "^3.5.13",
"vue-router": "^5.3.1",
"@nodeagent/client": "workspace:*",
"@nodeagent/protocol": "workspace:*"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@vitejs/plugin-vue": "^6.0.7",
"typescript": "~6.0.3",
"vite": "^8.0.16",
"vue-tsc": "^3.3.5"
}
}
+154
View File
@@ -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);
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env node
/**
* 把 sidecar 打成**单文件**(供 Tauri 打包进应用)。
*
* 用法:node apps/console/sidecar/build.mjs [--out <dir>]
*
* 为什么用 esbuild 的 **JS API** 而不是 spawn `node_modules/.bin/esbuild`:
* pnpm 的 `.bin` 布局下 spawn 该路径会 ENOENT(scripts/pack.mjs 真机踩过)。
* 用 API 则从模块解析走,与包管理器布局无关。
*
* 依赖处理:`@nodeagent/client` 与 `@nodeagent/protocol` 是 workspace 包,
* **一并 bundle 进去**(sidecar 要作为独立文件被 Tauri 拉起,不能依赖仓库 node_modules)。
* `ws` 亦被打入(它是纯 JS)。Node 内置模块(readline/process)保持 external。
*/
import { build } from 'esbuild';
import { mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const argv = process.argv.slice(2);
const outIdx = argv.indexOf('--out');
const outFile = join(outIdx >= 0 ? argv[outIdx + 1] : join(here, 'dist'), 'sidecar.mjs');
mkdirSync(dirname(outFile), { recursive: true });
const result = await build({
entryPoints: [join(here, 'main.mjs')],
outfile: outFile,
bundle: true,
platform: 'node',
format: 'esm',
target: 'node20',
// Node 内置模块保持外部引用;其余(含 ws / workspace 包)全部内联
external: ['node:*'],
// ⚠️ CJS 依赖(如 ws)里有**动态 require**(`require('events')`),esbuild 打成 ESM 后
// 无法转换 → 运行时报 "Dynamic require of X is not supported"。注入 createRequire shim
// 是标准解法:让 bundle 内的 `require(...)` 走真实的 CJS 解析。
banner: {
js:
'// nodeagent console sidecar(由 apps/console/sidecar/build.mjs 生成,请勿手工编辑)\n' +
"import { createRequire as __naCreateRequire } from 'node:module';\n" +
'const require = __naCreateRequire(import.meta.url);',
},
legalComments: 'none',
minify: false, // 保持可读:出问题时能直接看堆栈
metafile: true,
});
const bytes = Object.values(result.metafile.outputs)[0]?.bytes ?? 0;
console.log(`✓ sidecar 已打包: ${outFile} ${(bytes / 1024).toFixed(0)} KB`);
+340
View File
@@ -0,0 +1,340 @@
#!/usr/bin/env node
/**
* console sidecar —— 把 `@nodeagent/client`(TS 客户端)暴露成 **JSON-RPC 2.0 over stdio**。
*
* ## 为什么需要它(选项 D:Tauri + Node sidecar)
* console 的 Rust 壳若自己实现一份 client,就出现「Rust client 与 TS client 两套协议实现」——
* 二者会静默漂移(改了一处漏另一处,且构建、单测都不报错)。sidecar 让 Rust 壳退化为
* **进程管理者 + 消息转发者**,协议实现只剩 TS 一份。
*
* ## 协议(行分隔 JSON,每条消息一行)
* 请求(Rust → 本进程):
* {"jsonrpc":"2.0","id":1,"method":"connect","params":{url,key,client_id,insecure,...}}
* {"jsonrpc":"2.0","id":2,"method":"disconnect"}
* {"jsonrpc":"2.0","id":3,"method":"invoke","params":{capability,args,timeout_ms}}
* {"jsonrpc":"2.0","id":4,"method":"state"}
* {"jsonrpc":"2.0","id":5,"method":"check_update","params":{console_version,url?}}
* {"jsonrpc":"2.0","id":6,"method":"download","params":{url,sha256,dest}}
* 响应(本进程 → Rust):{"jsonrpc":"2.0","id":N,"result":{...}} 或 {"...","error":{code,message,data}}
* 通知(无 id,本进程主动推):
* {"jsonrpc":"2.0","method":"event","params":{"kind":"state","state":"connected"}}
* {"jsonrpc":"2.0","method":"event","params":{"kind":"log","message":"..."}}
* {"jsonrpc":"2.0","method":"event","params":{"kind":"agent_event","event":{...}}}
*
* ## 纪律
* - **stdout 只写协议消息**(一行一条);日志一律走 stderr —— 混入任何非 JSON 行都会让 Rust 侧解析失败。
* - 任何异常都转成 error 响应,**绝不因单个请求崩掉进程**(Rust 侧会当成"连接断了")。
*/
import { createInterface } from 'node:readline';
import { createHash } from 'node:crypto';
import { mkdir, readFile, writeFile, unlink } from 'node:fs/promises';
import { dirname } from 'node:path';
import { NodeAgentClient } from '@nodeagent/client';
/** @type {NodeAgentClient | null} */
let client = null;
/** 最近一次连接参数(重连/状态查询用)。 */
let lastOptions = null;
// ---------------- 输出(唯一写 stdout 的地方) ----------------
function send(msg) {
process.stdout.write(JSON.stringify(msg) + '\n');
}
function ok(id, result) {
send({ jsonrpc: '2.0', id, result: result ?? null });
}
function fail(id, code, message, data) {
send({ jsonrpc: '2.0', id, error: { code, message, ...(data !== undefined ? { data } : {}) } });
}
function notify(kind, payload) {
send({ jsonrpc: '2.0', method: 'event', params: { kind, ...payload } });
}
/** 日志走 stderr(stdout 归协议专用)。 */
function log(level, message) {
process.stderr.write(`[sidecar] ${level} ${message}\n`);
}
// ---------------- 状态快照 ----------------
function stateSnapshot() {
if (!client) return { connected: false };
return {
connected: true,
capabilities: client.listCapabilities(),
authorized: client.listAuthorized(),
agent_version: client.getAgentVersion() ?? null,
agent_build: client.getAgentBuild() ?? null,
peer_cert_fp: client.getPeerCertFingerprint(),
client_id: lastOptions?.clientId ?? null,
auth_mode: lastOptions?.authMode ?? 'psk',
};
}
// ---------------- 方法实现 ----------------
/**
* 比较两个 `x.y.z` 版本号:a > b 返回正、相等返回 0、a < b 返回负。
*
* 按**数值**逐段比较,不做字符串比较 —— 否则 `"2.0.10" < "2.0.9"`(字符串序),
* 到两位数的补丁号时会误判「无更新」。
*/
function cmpVersion(a, b) {
const pa = String(a).split('.').map((x) => parseInt(x, 10) || 0);
const pb = String(b).split('.').map((x) => parseInt(x, 10) || 0);
for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
if (d !== 0) return d > 0 ? 1 : -1;
}
return 0;
}
const methods = {
/** 建立连接并完成握手。返回与旧 Rust client 的 ConnectInfo 同构的元信息。 */
async connect(params) {
if (!params?.url || !params?.key) {
throw Object.assign(new Error('connect 需要 url 与 key'), { code: -32602 });
}
// 已有连接先干净关闭,避免两条 ws 并存
if (client) {
try {
client.close();
} catch {
/* 关不掉也无妨,下面会覆盖 */
}
}
lastOptions = {
url: String(params.url),
clientId: String(params.client_id ?? 'console'),
authMode: params.auth_mode === 'ed25519' ? 'ed25519' : 'psk',
};
client = new NodeAgentClient({
url: lastOptions.url,
key: String(params.key),
clientId: lastOptions.clientId,
insecure: params.insecure !== false,
...(params.cert_sha256 ? { certSha256: String(params.cert_sha256) } : {}),
...(params.auth_mode === 'ed25519' ? { authMode: 'ed25519' } : {}),
...(params.private_key ? { privateKey: String(params.private_key) } : {}),
...(params.hub ? { hub: params.hub } : {}),
autoReconnect: params.auto_reconnect !== false,
// 三个回调 → 三条通知(Rust 侧再桥接成 Tauri event)
onStateChange: (state) => notify('state', { state }),
onLog: (message) => notify('log', { message }),
onEvent: (event) => notify('agent_event', { event }),
});
await client.connect();
log('info', `已连接 ${lastOptions.url}(${lastOptions.clientId})`);
return stateSnapshot();
},
disconnect() {
if (client) {
try {
client.close();
} catch (err) {
log('warn', `关闭连接时异常:${String(err)}`);
}
client = null;
}
return { connected: false };
},
async invoke(params) {
// 「调用方式不对」(未连接 / 缺参数)→ 协议错误,Rust 侧能看到明确的 code
if (!client) throw Object.assign(new Error('尚未连接(先调 connect)'), { code: -32005 });
if (!params?.capability) {
throw Object.assign(new Error('invoke 需要 capability'), { code: -32602 });
}
try {
// 正常路径:被控端返回 InvokeResult(status: ok|failed)
return await client.invoke(
String(params.capability),
(params.args ?? {}),
typeof params.timeout_ms === 'number' ? params.timeout_ms : undefined,
);
} catch (err) {
// ⚠️ 被控端对**未知能力 / ACL 拒绝 / 超时**是回 JSON-RPC error,client 会**抛异常**。
// 若原样往上扔,前端就得处理两种形态(result.status 与 error),调用面不统一。
// 故归一成 InvokeResult 的 failed 形态 —— 前端只判 status 即可。
return {
status: 'failed',
error: {
name: err?.rpcName ?? err?.name ?? 'E_INVOKE_FAILED',
message: err instanceof Error ? err.message : String(err),
...(err?.data !== undefined ? { data: err.data } : {}),
},
};
}
},
/**
* 检查更新:拉项目最新 Release 的 `latest.json`,与本应用版本比对。
*
* ## 为什么放在 sidecar 而不是 Rust 壳
* 本壳的既定分工是「**进程管理 + 消息转发**,协议与 IO 逻辑集中在 Node 一侧」
* (见文件头注释)。更新检查要做 HTTPS + JSON + 后续大概率还要下载文件校验哈希,
* 在 Rust 侧等于再引一套 HTTP 栈;而 Node 有内置 `fetch`,一行到位。**单一实现**优先。
*
* `latest.json` 由发版脚本产出(含 version / commit / agent.url / agent.sha256),
* 走 `releases/latest/download/` 这个**稳定别名**,无需调 GitHub API、不占速率配额。
*/
async check_update(params) {
const consoleVersion = String(params?.console_version ?? '0.0.0');
const url =
params?.url ??
'https://github.com/jkooe/nodeagent/releases/latest/download/latest.json';
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), 20_000);
let meta;
try {
const res = await fetch(url, { signal: ac.signal, redirect: 'follow' });
if (!res.ok) {
throw new Error(`拉取 latest.json 失败:HTTP ${res.status} ${res.statusText}`);
}
meta = await res.json();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
throw Object.assign(new Error(`检查更新失败:${msg}(地址 ${url})`), {
data: { url, hint: '若本机网络无法直连 GitHub,可在被控端配置里改用镜像地址' },
});
} finally {
clearTimeout(timer);
}
const latest = String(meta?.version ?? '');
if (!latest) {
throw new Error('latest.json 缺少 version 字段(发版产物异常)');
}
const agent = meta?.agent ?? {};
return {
console_version: consoleVersion,
latest_version: latest,
has_update: cmpVersion(latest, consoleVersion) > 0,
published_at: meta?.built_at ?? null,
commit: meta?.commit ?? null,
capabilities: meta?.capabilities ?? null,
agent: {
url: agent.url ?? null,
sha256: agent.sha256 ?? null,
bytes: agent.bytes ?? null,
},
release_url: `https://github.com/jkooe/nodeagent/releases/tag/v${latest}`,
source: url,
checked_at: Date.now(),
};
},
/**
* 下载文件并做 sha256 校验(校验不通过**不落盘**,避免半成品污染数据目录)。
* 供「更新本机被控端」使用。
*/
async download(params) {
const url = String(params?.url ?? '');
const expect = String(params?.sha256 ?? '').toLowerCase();
const dest = String(params?.dest ?? '');
if (!url || !dest) {
throw Object.assign(new Error('download 需要 url 与 dest'), { code: -32602 });
}
if (!/^[0-9a-f]{12,64}$/.test(expect)) {
throw Object.assign(
new Error('download 需要 sha256(12~64 位十六进制)—— 不校验哈希等于接受任意内容'),
{ code: -32602 },
);
}
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), 300_000);
let buf;
try {
const res = await fetch(url, { signal: ac.signal, redirect: 'follow' });
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
buf = Buffer.from(await res.arrayBuffer());
} catch (err) {
throw new Error(`下载失败:${err instanceof Error ? err.message : String(err)}`);
} finally {
clearTimeout(timer);
}
const got = createHash('sha256').update(buf).digest('hex');
if (got !== expect && !got.startsWith(expect)) {
throw Object.assign(new Error('哈希校验失败,已丢弃(未写入任何文件)'), {
data: { expected: expect.slice(0, 12), actual: got.slice(0, 12), bytes: buf.length },
});
}
await mkdir(dirname(dest), { recursive: true });
await writeFile(dest, buf);
// 写后复核:防止截断/半写(磁盘满等)
const written = createHash('sha256')
.update(await readFile(dest))
.digest('hex');
if (written !== got) {
await unlink(dest).catch(() => {});
throw new Error('写入后校验不一致,已删除(请检查磁盘空间)');
}
return { path: dest, sha256: got, bytes: buf.length };
},
state: () => stateSnapshot(),
};
// ---------------- 主循环 ----------------
const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
rl.on('line', (line) => {
const text = line.trim();
if (!text) return;
let req;
try {
req = JSON.parse(text);
} catch {
fail(null, -32700, 'JSON 解析失败');
return;
}
const { id = null, method, params } = req ?? {};
const fn = methods[method];
if (typeof fn !== 'function') {
fail(id, -32601, `未知方法: ${String(method)}`);
return;
}
// 统一包裹:任何异常都变成 error 响应,绝不冒泡崩进程
Promise.resolve()
.then(() => fn(params ?? {}))
.then((result) => ok(id, result))
.catch((err) => {
const code = typeof err?.code === 'number' ? err.code : -32603;
const msg = err instanceof Error ? err.message : String(err);
log('error', `${String(method)} 失败:${msg}`);
fail(id, code, msg, err?.data);
});
});
rl.on('close', () => {
// Rust 侧关闭 stdin(进程退出/窗口关闭)→ 干净收尾后退出
methods.disconnect();
log('info', 'stdin 关闭,sidecar 退出');
process.exit(0);
});
process.on('uncaughtException', (err) => {
log('error', `未捕获异常:${err?.stack ?? String(err)}`);
});
process.on('unhandledRejection', (err) => {
log('error', `未处理的 rejection:${String(err)}`);
});
log('info', `sidecar 就绪(pid=${process.pid},node=${process.version})`);
+14
View File
@@ -0,0 +1,14 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas
# 随包分发的 Node 运行时(由 scripts/prepare-console.mjs 生成,~87MB;不入库)
# 体积原因 + 它是从本机 node 复制/剥离而来,不是源代码。
/runtime/
# 构建期注入片段(生成物,引用 runtime/ 下的 node;不入库,见 prepare-console.mjs 注释)
/tauri.bundle.gen.json
+4854
View File
File diff suppressed because it is too large. Load diff
+41
View File
@@ -0,0 +1,41 @@
[package]
name = "nodeagent-console"
version = "2.0.3"
description = "nodeagent 桌面控制台(主控 + 被控一体,含本机服务托管)"
authors = ["jkooe"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "nodeagent_console_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
# `tray-icon`:托盘常驻(后台运行 / 开机自启的载体)必需。
# 不给这个 feature,`TrayIconBuilder` 根本不存在(编译期就找不到符号)。
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-opener = "2"
# ⚠️ 单实例保护(v2.0.3 修):缺了它,「开机自启的隐藏实例 + 用户手开的实例」会并存,
# 各自托管一个被控端 → 同机两个被控端抢同一个 8765。详见 src/lib.rs 顶部「单实例」一节。
tauri-plugin-single-instance = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# ⚠️ 2026-10:不再依赖 crates/nodeagent-client —— 协议实现改由 Node sidecar 承担
# (见 src/sidecar.rs 的说明:避免 Rust/TS 两套协议实现静默漂移)
tokio = { version = "1", features = ["process", "io-util", "sync", "time", "macros", "rt-multi-thread"] }
# Read the optimization guideline for more details: https://tauri.app/concept/size/#cargo-configuration
[profile.release]
codegen-units = 1
lto = true
opt-level = 3
panic = "abort"
strip = true
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
@@ -0,0 +1,10 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"opener:default"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 567 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 777 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1020 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 848 B

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

+856
View File
@@ -0,0 +1,856 @@
//! 本机被控端(agent)进程管理器 —— 让控制台能「启动 / 停止 / 重启」本机被控端,
//! 用户全程**不必碰命令行**。
//!
//! ## 关键设计(每条都是踩出来的)
//!
//! 1. **脚本必须在数据目录里跑,不能在 app bundle 里跑。**
//! macOS 的 `.app` 是签名保护的只读资源 —— 自更新写不进去;且开机自启需要**稳定路径**
//! (用户挪动 / 重装 / 删除 app 不该带走被控端)。故首次运行时把内置的 `agent.mjs`
//! **播种**到 `<数据目录>/runtime/agent.mjs`,此后一律跑这一份。
//!
//! 2. **绝不覆盖「已自更新」的副本。**
//! 用 `<数据目录>/runtime/agent.seed.json` 记「播种时的应用版本」;只有**应用版本变了**
//! 才重新播种。否则控制台每次启动都会把被控端(可能已自更新到更新版)**降级**回随包版本 ——
//! 这类「悄悄降级」不会报任何错,只是功能莫名回退。
//!
//! 3. **Windows 必须 `CREATE_NO_WINDOW`。**
//! 否则每次 spawn `node.exe` 都会弹一个黑色控制台窗口。本项目的目标之一正是
//! 「不再打开命令行窗口」,故这一步不可省。
//!
//! 4. **退出监控用轮询 `try_wait`,不把 `Child` 移出去。**
//! 若把 `Child` 交给 `wait()` 的 task,`stop()` 就再也拿不到它去 kill 了。
//! 轮询 800ms 代价可忽略,换来 stop 随时可用。
//!
//! ## 可测试性
//! 本模块**不依赖 Tauri GUI** —— 单测直接拉起真实的 node 子进程,验证
//! 「启动 → 捕获日志 → 停止 → 状态收敛」全链路。
use serde::Serialize;
use serde_json::{json, Value};
use std::collections::VecDeque;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::{Child, Command};
/// 日志环形缓冲上限(行)。够回看一次启动/一次故障,又不至于吃内存。
const LOG_CAP: usize = 4000;
/// 被控端状态机取值(前后端共用字面量)。
pub mod state {
pub const STOPPED: &str = "stopped";
pub const STARTING: &str = "starting";
pub const RUNNING: &str = "running";
pub const STOPPING: &str = "stopping";
pub const CRASHED: &str = "crashed";
}
/// 一行日志。
#[derive(Debug, Clone, Serialize)]
pub struct LogLine {
/// 全局单调递增序号(环形缓冲淘汰旧行后仍然递增 —— 前端靠它做增量拉取)
pub seq: u64,
/// 毫秒时间戳(UTC)
pub ts: u64,
/// `stdout` / `stderr` / `system`(本管理器自己产生的说明行)
pub stream: String,
pub line: String,
}
impl LogLine {
fn new(seq: u64, stream: &str, line: impl Into<String>) -> Self {
Self {
seq,
ts: now_ms(),
stream: stream.to_string(),
line: line.into(),
}
}
}
/// 管理器向外推送的事件。
#[derive(Debug, Clone)]
pub enum AgentEvent {
Log(LogLine),
/// 状态变化(取值见 `state::*`)
State(String),
}
pub type AgentEmit = Arc<dyn Fn(AgentEvent) + Send + Sync>;
fn now_ms() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0)
}
/// 数据目录:优先 `NODEAGENT_HOME`(跨平台统一),否则 `~/.nodeagent`。
///
/// ⚠️ Windows 认 `USERPROFILE`、不认 `HOME` —— 与被控端 `apps/agent/src/config.ts`
/// 的 `agentDir()` 必须保持一致,否则「控制台显示的数据目录」与「被控端实际用的」
/// 会是两个地方。
pub fn agent_data_dir() -> PathBuf {
if let Ok(p) = std::env::var("NODEAGENT_HOME") {
if !p.trim().is_empty() {
return PathBuf::from(p);
}
}
let home = std::env::var_os("USERPROFILE")
.or_else(|| std::env::var_os("HOME"))
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
home.join(".nodeagent")
}
/// 被控端入口脚本(数据目录里的那一份)。
pub fn agent_script_path(data_dir: &Path) -> PathBuf {
data_dir.join("runtime").join("agent.mjs")
}
/// 播种标记文件。
fn seed_marker_path(data_dir: &Path) -> PathBuf {
data_dir.join("runtime").join("agent.seed.json")
}
/// 比较 `x.y.z` 版本号(数值逐段比,不做字符串比 —— 否则 `2.0.10 < 2.0.9`)。
pub fn cmp_version(a: &str, b: &str) -> i32 {
let pa: Vec<u32> = a.split('.').map(|x| x.parse().unwrap_or(0)).collect();
let pb: Vec<u32> = b.split('.').map(|x| x.parse().unwrap_or(0)).collect();
for i in 0..pa.len().max(pb.len()) {
let d = pa.get(i).copied().unwrap_or(0) as i64 - pb.get(i).copied().unwrap_or(0) as i64;
if d != 0 {
return if d > 0 { 1 } else { -1 };
}
}
0
}
/// 读出「数据目录里当前脚本所对应的版本」(无标记则视为 `0.0.0`)。
pub fn installed_agent_version(data_dir: &Path) -> Option<String> {
let text = std::fs::read_to_string(seed_marker_path(data_dir)).ok()?;
let v: Value = serde_json::from_str(&text).ok()?;
v.get("version").and_then(Value::as_str).map(str::to_string)
}
/// 记录「数据目录里当前脚本的版本」。
///
/// 播种与被控端自更新(**含控制台发起的更新**)都要写它 —— 这是防止
/// 「控制台启动时把已更新的被控端降级回随包版本」的唯一依据。
pub fn mark_installed_version(data_dir: &Path, version: &str, source: &str) -> Result<(), String> {
let marker = seed_marker_path(data_dir);
if let Some(dir) = marker.parent() {
std::fs::create_dir_all(dir).map_err(|e| format!("创建 runtime 目录失败:{e}"))?;
}
std::fs::write(
&marker,
format!(
"{{\n \"version\": \"{}\",\n \"source\": \"{}\",\n \"at\": {}\n}}\n",
version,
source,
now_ms()
),
)
.map_err(|e| format!("写入播种标记失败:{e}"))
}
/// 定位随包(或开发期)的内置 `agent.mjs`。
///
/// 优先级:环境变量 `NODEAGENT_CONSOLE_AGENT` → 随包 resource 的 `agent.mjs` →
/// 开发期的 `apps/agent/dist/index.js`。
pub fn resolve_bundled_agent(resource_dir: Option<&Path>) -> Result<PathBuf, String> {
if let Ok(p) = std::env::var("NODEAGENT_CONSOLE_AGENT") {
if !p.is_empty() && Path::new(&p).exists() {
return Ok(PathBuf::from(p));
}
}
if let Some(dir) = resource_dir {
let bundled = dir.join("agent.mjs");
if bundled.exists() {
return Ok(bundled);
}
}
// 开发期:src-tauri → apps/agent/dist/index.js
let dev = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../agent/dist/index.js");
if dev.exists() {
return Ok(dev);
}
Err("找不到内置 agent.mjs(先跑 `pnpm -r build`,或用 NODEAGENT_CONSOLE_AGENT 指定)".to_string())
}
/// 把内置被控端**播种**到数据目录。
///
/// **只在「数据目录里的脚本版本 < 随包版本」时才覆盖** —— 这条规则防的是两类静默故障:
/// - 控制台每次启动都覆盖 → 把**已自更新**的被控端**降级**回随包版本(功能莫名回退,不报错);
/// - 应用被降级安装后,反过来把更新的被控端覆盖掉。
///
/// 返回实际使用的脚本路径。
pub fn seed_agent_script(
bundled: &Path,
data_dir: &Path,
app_version: &str,
) -> Result<PathBuf, String> {
let target = agent_script_path(data_dir);
if target.exists() {
let installed = installed_agent_version(data_dir).unwrap_or_else(|| "0.0.0".to_string());
if cmp_version(app_version, &installed) <= 0 {
// 数据目录里的版本不低于随包版本(可能是自更新来的)→ 不动它
return Ok(target);
}
}
std::fs::create_dir_all(target.parent().unwrap_or(data_dir))
.map_err(|e| format!("创建 runtime 目录失败:{e}"))?;
std::fs::copy(bundled, &target).map_err(|e| {
format!(
"播种被控端脚本失败({} → {}):{e}",
bundled.display(),
target.display()
)
})?;
// 保留可执行位(POSIX;Windows 无此概念)
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755));
}
mark_installed_version(data_dir, app_version, "bundled")?;
Ok(target)
}
/// TCP 探测端口是否已被监听(判断「是否已有实例在跑」)。
///
/// 只做一次 TCP 连接,不跑协议 —— 快、无副作用,足够回答「这个端口有没有人在听」。
pub fn port_in_use(port: u16) -> bool {
use std::net::{SocketAddr, TcpStream};
let addr: SocketAddr = ([127, 0, 0, 1], port).into();
TcpStream::connect_timeout(&addr, Duration::from_millis(400)).is_ok()
}
/// 读被控端配置(`<数据目录>/agent.json`)里与控制台有关的部分。
///
/// 只读、不创建 —— 配置由被控端自己在首次启动时生成(含随机密钥)。
pub fn read_agent_config(data_dir: &Path) -> Value {
let p = data_dir.join("agent.json");
let (node_id, port, key, auth_mode, tls, exists) = match std::fs::read_to_string(&p) {
Ok(text) => {
// 剥离 BOM:Windows 上由 PowerShell 写出的 JSON 可能带 BOM
let parsed: Value = serde_json::from_str(text.trim_start_matches('\u{feff}')).unwrap_or(json!({}));
(
parsed.get("node_id").and_then(Value::as_str).unwrap_or("").to_string(),
parsed.get("port").and_then(Value::as_u64).unwrap_or(8765),
parsed.get("key").and_then(Value::as_str).unwrap_or("").to_string(),
parsed.get("auth_mode").and_then(Value::as_str).unwrap_or("psk").to_string(),
parsed.get("tls").and_then(Value::as_bool).unwrap_or(true),
true,
)
}
Err(_) => (String::new(), 8765, String::new(), "psk".to_string(), true, false),
};
json!({
"data_dir": data_dir.to_string_lossy(),
"config_path": p.to_string_lossy(),
"config_exists": exists,
"node_id": node_id,
"port": port,
"tls": tls,
"auth_mode": auth_mode,
// 密钥回传给本机 UI 属预期(用户要用它连接自己的机器);
// 它本来就在用户本机的 600 文件里,不构成额外暴露面。
"key": key,
"port_in_use": port_in_use(port as u16),
"url": format!("ws://127.0.0.1:{port}"),
})
}
struct Inner {
state: String,
child: Option<Child>,
pid: Option<u32>,
started_at: Option<u64>,
restarts: u32,
last_exit: Option<String>,
}
/// 本机被控端管理器。
pub struct AgentManager {
inner: tokio::sync::Mutex<Inner>,
/// 环形日志缓冲。用 `Arc<Mutex<..>>` 是为了让日志泵任务能**同时**入缓冲与推事件,
/// 而不必持有整个管理器(否则自引用,Arc 循环)。
logs: Arc<Mutex<VecDeque<LogLine>>>,
seq: Arc<AtomicU64>,
emit: AgentEmit,
/// 脚本与 node 路径(start 时注入,便于 status 展示)
paths: Mutex<Option<(PathBuf, PathBuf, PathBuf)>>, // (node, script, data_dir)
}
impl AgentManager {
pub fn new(emit: AgentEmit) -> Arc<Self> {
Arc::new(Self {
inner: tokio::sync::Mutex::new(Inner {
state: state::STOPPED.to_string(),
child: None,
pid: None,
started_at: None,
restarts: 0,
last_exit: None,
}),
logs: Arc::new(Mutex::new(VecDeque::with_capacity(LOG_CAP))),
seq: Arc::new(AtomicU64::new(1)),
emit,
paths: Mutex::new(None),
})
}
/// 追加一行日志:入环形缓冲 + 推事件。
pub fn push_log(&self, stream: &str, text: impl Into<String>) {
let entry = LogLine::new(self.next_seq(), stream, text);
{
let mut logs = self.logs.lock().unwrap();
if logs.len() >= LOG_CAP {
logs.pop_front();
}
logs.push_back(entry.clone());
}
(self.emit)(AgentEvent::Log(entry));
}
fn next_seq(&self) -> u64 {
self.seq.fetch_add(1, Ordering::SeqCst)
}
async fn set_state(&self, s: &str) {
{
let mut inner = self.inner.lock().await;
if inner.state == s {
return;
}
inner.state = s.to_string();
}
(self.emit)(AgentEvent::State(s.to_string()));
}
/// 当前状态快照(不探测端口,纯读内存 + 已记录的路径)。
pub async fn snapshot(&self) -> Value {
let inner = self.inner.lock().await;
let paths = self.paths.lock().unwrap().clone();
let (node, script, data_dir) = match paths {
Some((n, s, d)) => (
n.to_string_lossy().to_string(),
s.to_string_lossy().to_string(),
d.to_string_lossy().to_string(),
),
None => (String::new(), String::new(), agent_data_dir().to_string_lossy().to_string()),
};
json!({
"state": inner.state,
"pid": inner.pid,
"started_at": inner.started_at,
"uptime_ms": inner.started_at.map(|t| now_ms().saturating_sub(t)),
"restarts": inner.restarts,
"last_exit": inner.last_exit,
"node": node,
"script": script,
"data_dir": data_dir,
})
}
/// 取日志(`since` 为上次拿到的最大 `seq`,0 表示取全部缓冲)。
pub fn logs_since(&self, since: u64) -> Value {
let logs = self.logs.lock().unwrap();
let total = self.seq.load(Ordering::SeqCst).saturating_sub(1);
let lines: Vec<Value> = logs
.iter()
.filter(|l| l.seq > since)
.map(|l| json!({ "seq": l.seq, "ts": l.ts, "stream": l.stream, "line": l.line }))
.collect();
json!({ "lines": lines, "total": total })
}
pub fn clear_logs(&self) {
self.logs.lock().unwrap().clear();
}
/// 启动被控端。
///
/// 接收者写成 `self: &Arc<Self>`(而非 `&self`)是**必需**的:启动后要挂一个
/// **自持引用**的监控任务(崩溃自动重启要能再次调用 `start`),而 `&self` 拿不到 `Arc`。
///
/// `auto_restart`:进程意外退出时是否自动拉起(「后台运行」的语义要求)。
pub async fn start(
self: &Arc<Self>,
node: &Path,
script: &Path,
data_dir: &Path,
auto_restart: bool,
) -> Result<Value, String> {
{
let inner = self.inner.lock().await;
if inner.child.is_some() {
return Err("被控端已在运行".to_string());
}
}
// 端口占用检查:既有的(非本控制台托管的)实例在跑时,给出可执行的说明,
// 而不是让用户看到一个 `EADDRINUSE` 堆栈。
//
// ⚠️ 这里是症状 B(「应用端无法启动」)的**现场**:真机上曾有陈旧计划任务拉起的
// 被控端占着 8765,控制台自己的被控端起不来。端口检查本身是对的,
// 但只给一句「已被占用」用户无从下手 —— 故补上「怎么找出占用者」。
let cfg = read_agent_config(data_dir);
let port = cfg.get("port").and_then(Value::as_u64).unwrap_or(8765) as u16;
if port_in_use(port) {
let hint = if cfg!(windows) {
format!(
"netstat -ano | findstr :{port} 找到 PID,再 tasklist /FI \"PID eq <PID>\" 看是谁"
)
} else {
format!("lsof -nP -iTCP:{port} -sTCP:LISTEN")
};
return Err(format!(
"端口 {port} 已被占用 —— 已有另一个被控端在监听(手工安装的、计划任务拉起的,\
或另一个控制台实例)。\n\
排查占用者:{hint}\n\
处理:先停掉它;或把 {} 里的 port 改成别的端口后重启本机服务。",
data_dir.join("agent.json").display()
));
}
*self.paths.lock().unwrap() = Some((node.to_path_buf(), script.to_path_buf(), data_dir.to_path_buf()));
self.set_state(state::STARTING).await;
self.push_log(
"system",
format!(
"启动被控端:{} {}(数据目录 {})",
node.display(),
script.display(),
data_dir.display()
),
);
let mut cmd = Command::new(node);
cmd.arg(script)
.current_dir(data_dir)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(false); // 由本管理器显式管理生命周期,不留孤儿也不误杀
// 让被控端与控制台用**同一个**数据目录(不设则各按各自规则解析)
cmd.env("NODEAGENT_HOME", data_dir);
// ⚠️ 消灭黑窗:Windows 上不加这个标志,每 spawn 一次 node 就弹一个控制台窗口
#[cfg(windows)]
{
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
cmd.creation_flags(CREATE_NO_WINDOW);
}
let mut child = cmd
.spawn()
.map_err(|e| format!("启动被控端失败({}):{e}", node.display()))?;
let pid = child.id();
let stdout = child.stdout.take();
let stderr = child.stderr.take();
{
let mut inner = self.inner.lock().await;
inner.child = Some(child);
inner.pid = pid;
inner.started_at = Some(now_ms());
}
self.set_state(state::RUNNING).await;
// 日志泵:既入环形缓冲(供前端「拉取历史」),也推事件(供前端「实时滚动」)
if let Some(out) = stdout {
spawn_line_pump(out, "stdout", Arc::clone(&self.emit), Arc::clone(&self.logs), Arc::clone(&self.seq));
}
if let Some(err) = stderr {
spawn_line_pump(err, "stderr", Arc::clone(&self.emit), Arc::clone(&self.logs), Arc::clone(&self.seq));
}
// 退出监控 + 自动重启
AgentManager::spawn_monitor(
self,
node.to_path_buf(),
script.to_path_buf(),
data_dir.to_path_buf(),
auto_restart,
);
Ok(json!({ "pid": pid, "state": state::RUNNING }))
}
/// 停止被控端(幂等)。
pub async fn stop(&self) -> Result<Value, String> {
self.set_state(state::STOPPING).await;
let child = {
let mut inner = self.inner.lock().await;
inner.child.take()
};
let Some(mut child) = child else {
self.set_state(state::STOPPED).await;
return Ok(json!({ "state": state::STOPPED, "note": "本来就未运行" }));
};
let pid = child.id();
self.push_log("system", format!("停止被控端(pid {})…", pid.map(|p| p.to_string()).unwrap_or_else(|| "?".into())));
// Windows:用 taskkill /T 连子进程树一起收;POSIX:直接 kill 本进程。
let killed = if cfg!(windows) {
match pid {
Some(p) => {
let out = Command::new("taskkill")
.args(["/PID", &p.to_string(), "/T", "/F"])
.output()
.await;
match out {
Ok(o) => o.status.success(),
Err(_) => child.kill().await.is_ok(),
}
}
None => child.kill().await.is_ok(),
}
} else {
child.kill().await.is_ok()
};
let _ = child.wait().await;
{
let mut inner = self.inner.lock().await;
inner.pid = None;
inner.started_at = None;
inner.last_exit = Some(if killed { "stopped by console".into() } else { "stop failed".into() });
}
self.set_state(state::STOPPED).await;
self.push_log("system", "被控端已停止");
// 端口释放确认(如被控端刚触发了「受控重启」,可能留下继承者)
let data_dir = self
.paths
.lock()
.unwrap()
.as_ref()
.map(|(_, _, d)| d.clone())
.unwrap_or_else(agent_data_dir);
let port = read_agent_config(&data_dir).get("port").and_then(Value::as_u64).unwrap_or(8765) as u16;
for _ in 0..12 {
if !port_in_use(port) {
break;
}
tokio::time::sleep(Duration::from_millis(400)).await;
}
let still = port_in_use(port);
if still {
self.push_log(
"system",
format!("⚠️ 端口 {port} 仍未释放 —— 可能存在继承进程(如受控重启产生的后继),请检查"),
);
}
Ok(json!({ "state": state::STOPPED, "port_released": !still }))
}
/// 重启 = 停止 + 启动。
pub async fn restart(
self: &Arc<Self>,
node: &Path,
script: &Path,
data_dir: &Path,
auto_restart: bool,
) -> Result<Value, String> {
self.push_log("system", "重启被控端…");
let _ = self.stop().await;
tokio::time::sleep(Duration::from_millis(600)).await;
self.start(node, script, data_dir, auto_restart).await
}
/// 退出监控:轮询 `try_wait`(**不把 Child 移出去**,否则 stop() 就杀不到它了)。
fn spawn_monitor(
me: &Arc<Self>,
node: PathBuf,
script: PathBuf,
data_dir: PathBuf,
auto_restart: bool,
) {
let me = Arc::clone(me);
tauri::async_runtime::spawn(async move {
loop {
tokio::time::sleep(Duration::from_millis(800)).await;
let mut inner = me.inner.lock().await;
let Some(child) = inner.child.as_mut() else {
return; // 已被 stop() 取走 → 结束监控
};
match child.try_wait() {
Ok(Some(status)) => {
let code = status.code();
inner.child = None;
inner.pid = None;
inner.started_at = None;
inner.last_exit = Some(format!("exit code {:?}", code));
drop(inner);
me.push_log("system", format!("被控端已退出(exit code {:?})", code));
me.set_state(state::CRASHED).await;
if !auto_restart {
return;
}
// 自动重启:退避 2s,超 5 次则放弃并留在 crashed(避免无限重启风暴)
let n = {
let mut inner = me.inner.lock().await;
inner.restarts += 1;
inner.restarts
};
if n > 5 {
me.push_log("system", "自动重启已达上限(5 次),停止拉起。请查看上方日志定位原因。");
return;
}
me.push_log("system", format!("将在 2 秒后自动重启(第 {n} 次)…"));
tokio::time::sleep(Duration::from_secs(2)).await;
let _ = me.stop().await;
if let Err(e) = me.start(&node, &script, &data_dir, true).await {
me.push_log("system", format!("自动重启失败:{e}"));
me.set_state(state::CRASHED).await;
return;
}
// start() 已重新挂上监控,本任务结束
return;
}
Ok(None) => { /* 仍在运行 */ }
Err(e) => {
me.push_log("system", format!("监控被控端失败:{e}"));
return;
}
}
}
});
}
}
/// 逐行读一个管道:入环形缓冲 + 推日志事件。
fn spawn_line_pump<R>(
reader: R,
stream: &'static str,
emit: AgentEmit,
logs: Arc<Mutex<VecDeque<LogLine>>>,
seq: Arc<AtomicU64>,
) where
R: tokio::io::AsyncRead + Unpin + Send + 'static,
{
tauri::async_runtime::spawn(async move {
let mut lines = BufReader::new(reader).lines();
while let Ok(Some(text)) = lines.next_line().await {
let entry = LogLine::new(seq.fetch_add(1, Ordering::SeqCst), stream, text);
{
let mut buf = logs.lock().unwrap();
if buf.len() >= LOG_CAP {
buf.pop_front();
}
buf.push_back(entry.clone());
}
emit(AgentEvent::Log(entry));
}
});
}
// ---------------------------------------------------------------------------
// 单测:真跑 node 子进程
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn node_bin() -> PathBuf {
crate::sidecar::resolve_node(None).expect("需要 node 才能跑被控端管理单测")
}
/// 造一个「打印两行、然后常驻」的假被控端。
fn fake_agent(dir: &Path) -> PathBuf {
let p = dir.join("fake-agent.mjs");
std::fs::write(
&p,
"console.log('READY-LINE');\nconsole.error('ERR-LINE');\nsetInterval(()=>{}, 1000);\n",
)
.unwrap();
p
}
fn tmp_dir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("na-agent-test-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[tokio::test]
async fn 启动应捕获日志且状态为running() {
let dir = tmp_dir("start");
let script = fake_agent(&dir);
let m = AgentManager::new(Arc::new(|_| {}));
m.start(&node_bin(), &script, &dir, false).await.expect("应能启动");
assert_eq!(m.snapshot().await["state"], json!(state::RUNNING));
// 等日志泵读到两行
let mut collected = String::new();
for _ in 0..40 {
tokio::time::sleep(Duration::from_millis(100)).await;
let logs = m.logs_since(0);
collected = logs["lines"]
.as_array()
.unwrap()
.iter()
.map(|l| l["line"].as_str().unwrap_or("").to_string())
.collect::<Vec<_>>()
.join("\n");
if collected.contains("READY-LINE") && collected.contains("ERR-LINE") {
break;
}
}
assert!(collected.contains("READY-LINE"), "应捕获 stdout,实际:{collected}");
assert!(collected.contains("ERR-LINE"), "应捕获 stderr,实际:{collected}");
let r = m.stop().await.expect("应能停止");
assert_eq!(r["state"], json!(state::STOPPED));
assert_eq!(m.snapshot().await["state"], json!(state::STOPPED));
}
#[tokio::test]
async fn 停止后应可再次启动() {
let dir = tmp_dir("restart");
let script = fake_agent(&dir);
let m = AgentManager::new(Arc::new(|_| {}));
m.start(&node_bin(), &script, &dir, false).await.unwrap();
m.stop().await.unwrap();
m.start(&node_bin(), &script, &dir, false).await.expect("停止后应能重新启动");
assert_eq!(m.snapshot().await["state"], json!(state::RUNNING));
m.stop().await.unwrap();
}
#[tokio::test]
async fn 重复启动应被拒绝而不是起两个进程() {
let dir = tmp_dir("dup");
let script = fake_agent(&dir);
let m = AgentManager::new(Arc::new(|_| {}));
m.start(&node_bin(), &script, &dir, false).await.unwrap();
let err = m.start(&node_bin(), &script, &dir, false).await;
assert!(err.is_err(), "已在运行时再次启动应被拒绝");
assert!(err.unwrap_err().contains("已在运行"), "错误信息应说明原因");
m.stop().await.unwrap();
}
#[tokio::test]
async fn 端口被外部占用时应给出可执行的错误说明() {
let dir = tmp_dir("port-busy");
let script = fake_agent(&dir);
// 占住一个空闲端口,扮演「手工安装的 / 计划任务拉起的另一个被控端」
// (真机症状 B:陈旧计划任务 na-gui 占着 8765,控制台自己的被控端起不来)
let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).expect("应能绑一个空闲端口");
let port = listener.local_addr().unwrap().port();
std::fs::write(
dir.join("agent.json"),
format!("{{\"node_id\":\"t\",\"port\":{port},\"key\":\"k\",\"tls\":true}}"),
)
.unwrap();
let m = AgentManager::new(Arc::new(|_| {}));
let err = m
.start(&node_bin(), &script, &dir, false)
.await
.expect_err("端口被占时应拒绝启动");
assert!(err.contains("已被占用"), "应说明端口被占用,实际:{err}");
assert!(err.contains(&port.to_string()), "错误里应带上具体端口号,实际:{err}");
// 关键:必须给出「怎么找出占用者」的可执行线索,否则用户无从下手
assert!(
err.contains("lsof") || err.contains("netstat"),
"应给出排查占用者的命令,实际:{err}"
);
// 端口检查失败不该改动状态、更不该留下进程
assert_eq!(m.snapshot().await["state"], json!(state::STOPPED));
drop(listener);
}
#[tokio::test]
async fn 进程意外退出应转为crashed() {
let dir = tmp_dir("crash");
let p = dir.join("crash-agent.mjs");
std::fs::write(&p, "console.log('BYE');\nprocess.exit(3);\n").unwrap();
let m = AgentManager::new(Arc::new(|_| {}));
m.start(&node_bin(), &p, &dir, false).await.unwrap();
let mut seen = String::new();
for _ in 0..40 {
tokio::time::sleep(Duration::from_millis(100)).await;
seen = m.snapshot().await["state"].as_str().unwrap_or("").to_string();
if seen == state::CRASHED {
break;
}
}
assert_eq!(seen, state::CRASHED, "进程退出后状态应为 crashed");
assert!(
m.snapshot().await["last_exit"].as_str().unwrap_or("").contains("3"),
"应记录退出码"
);
}
#[test]
fn 播种应只在应用版本变化时覆盖() {
let dir = tmp_dir("seed");
let bundled = dir.join("bundled.mjs");
std::fs::write(&bundled, "// v1\n").unwrap();
let data = dir.join("data");
std::fs::create_dir_all(&data).unwrap();
let t = seed_agent_script(&bundled, &data, "2.0.0").unwrap();
assert!(t.exists());
assert_eq!(std::fs::read_to_string(&t).unwrap(), "// v1\n");
// 模拟「被控端自更新」改了副本
std::fs::write(&t, "// self-updated v9\n").unwrap();
// 同版本再播种 → 不应覆盖
seed_agent_script(&bundled, &data, "2.0.0").unwrap();
assert_eq!(
std::fs::read_to_string(&t).unwrap(),
"// self-updated v9\n",
"同版本播种不得覆盖已自更新的副本(否则是静默降级)"
);
// 应用升级 → 应重新播种
std::fs::write(&bundled, "// v2\n").unwrap();
seed_agent_script(&bundled, &data, "2.1.0").unwrap();
assert_eq!(std::fs::read_to_string(&t).unwrap(), "// v2\n", "应用版本变化后应重新播种");
}
#[test]
fn 播种不得把更新过的被控端降级() {
let dir = tmp_dir("seed-down");
let bundled = dir.join("bundled.mjs");
std::fs::write(&bundled, "// bundled 2.0.3\n").unwrap();
let data = dir.join("data");
std::fs::create_dir_all(&data).unwrap();
// 模拟:被控端已通过控制台更新到 2.1.0
std::fs::create_dir_all(data.join("runtime")).unwrap();
std::fs::write(agent_script_path(&data), "// console-updated 2.1.0\n").unwrap();
mark_installed_version(&data, "2.1.0", "console").unwrap();
seed_agent_script(&bundled, &data, "2.0.3").unwrap();
assert_eq!(
std::fs::read_to_string(agent_script_path(&data)).unwrap(),
"// console-updated 2.1.0\n",
"随包版本更低时**不得**覆盖数据目录里更新的被控端(那是静默降级)"
);
}
#[test]
fn 版本比较应按数值而非字符串() {
assert_eq!(cmp_version("2.0.10", "2.0.9"), 1, "2.0.10 应大于 2.0.9");
assert_eq!(cmp_version("2.0.2", "2.0.2"), 0);
assert_eq!(cmp_version("2.1.0", "2.0.99"), 1);
assert_eq!(cmp_version("2.0", "2.0.1"), -1, "缺段按 0 处理");
}
}
+252
View File
@@ -0,0 +1,252 @@
//! 开机自启管理 —— 「随系统启动」开关的落地实现。
//!
//! ## 设计:自启的是**控制台的仅服务模式**,不是裸 node
//!
//! 两个候选方案,各有硬伤:
//! - **直接自启 `node agent.mjs`**:Windows 上会弹一个黑色控制台窗口(schtasks 无法指定
//! `CREATE_NO_WINDOW`),且进程无人托管 —— 崩了不会拉起、也无法从界面停止。
//! - **自启本控制台(`--agent-only`)**:本应用是 GUI 子系统程序,**不会有控制台窗口**;
//! 且它本来就负责「托管被控端」(启动/停止/自动重启/日志),语义完全对齐。
//!
//! 故选后者。`--agent-only` 模式下应用不显示主窗口,直接进托盘并拉起被控端。
//!
//! ## 平台差异
//! | 平台 | 机制 | 位置 |
//! |---|---|---|
//! | macOS | LaunchAgent(用户级,无需提权) | `~/Library/LaunchAgents/<label>.plist` |
//! | Windows | 计划任务(登录时触发,Interactive 登录类型) | 任务名 `<label>` |
//!
//! Windows 用**登录用户 + Interactive** 而非 SYSTEM:被控端的图形能力(截图 / 键鼠 / 窗口)
//! 必须跑在交互式会话里,SYSTEM(会话 0)没有桌面 —— 这一点在真机验证时已实证。
use serde_json::{json, Value};
use std::path::{Path, PathBuf};
use std::process::Command;
/// 自启项标识(macOS 的 launchd label / Windows 的计划任务名)。
const LABEL: &str = "cn.jkooe.nodeagent-console";
/// 仅服务模式命令行参数(无窗口、直接进托盘并拉起被控端)。
pub const AGENT_ONLY_FLAG: &str = "--agent-only";
/// 当前可执行文件路径。
fn app_exe() -> Result<PathBuf, String> {
std::env::current_exe().map_err(|e| format!("取当前程序路径失败:{e}"))
}
/// macOS 上 LaunchAgent plist 的位置。
fn macos_plist_path() -> Result<PathBuf, String> {
let home = std::env::var_os("HOME")
.map(PathBuf::from)
.ok_or("拿不到 HOME")?;
Ok(home.join("Library").join("LaunchAgents").join(format!("{LABEL}.plist")))
}
/// 生成 macOS LaunchAgent plist。
///
/// `RunAtLoad` 保证登录即拉起;**不用** `KeepAlive` —— 让「退出」按钮能真正退出,
/// 用户想恢复就重新登录或再开应用。进程级别的崩溃自愈由控制台的 `AgentManager` 负责,
/// 不该在这里再叠一层(两层自愈会互相打架)。
fn macos_plist(exe: &Path) -> String {
let exe = xml_escape(&exe.to_string_lossy());
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!-- 由 nodeagent 控制台生成 —— 请勿手工编辑(界面上的「开机自启」开关会覆写本文件) -->
<plist version="1.0">
<dict>
<key>Label</key>
<string>{LABEL}</string>
<key>ProgramArguments</key>
<array>
<string>{exe}</string>
<string>{AGENT_ONLY_FLAG}</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>ProcessType</key>
<string>Interactive</string>
</dict>
</plist>
"#
)
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
/// 当前用户的 uid(macOS 的 `launchctl bootstrap gui/<uid>` 需要它)。
///
/// 用 `id -u` 现取,**不硬编码 501** —— 501 只是首个本地用户的常见值,
/// 多用户机器上并不成立。
fn current_uid() -> Option<String> {
let out = Command::new("id").arg("-u").output().ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
if s.is_empty() {
None
} else {
Some(s)
}
}
/// macOS:加载 / 卸载 LaunchAgent。
///
/// 新版 macOS 推荐 `launchctl bootstrap gui/<uid>`,旧版用 `load -w`;两条都试,
/// 任一路径成功即算成功(不同 macOS 版本上可用的那条不一样)。
fn macos_launchctl(plist: &Path, enable: bool) -> Result<String, String> {
let mut notes: Vec<String> = Vec::new();
let uid = current_uid();
let domain = uid.map(|u| format!("gui/{u}"));
let attempts: Vec<Vec<String>> = if enable {
let mut v = vec![vec!["load".into(), "-w".into(), plist.to_string_lossy().to_string()]];
if let Some(d) = &domain {
v.push(vec!["bootstrap".into(), d.clone(), plist.to_string_lossy().to_string()]);
}
v
} else {
let mut v = vec![vec!["unload".into(), "-w".into(), plist.to_string_lossy().to_string()]];
if let Some(d) = &domain {
v.push(vec!["bootout".into(), format!("{d}/{LABEL}")]);
}
v
};
for args in attempts {
match Command::new("launchctl").args(&args).output() {
Ok(o) if o.status.success() => return Ok(format!("launchctl {} 成功", args[0])),
Ok(o) => notes.push(format!(
"launchctl {} 失败:{}",
args[0],
String::from_utf8_lossy(&o.stderr).trim()
)),
Err(e) => notes.push(format!("launchctl {} 无法执行:{e}", args[0])),
}
}
if enable {
Err(notes.join(";"))
} else {
// 卸载失败不致命:plist 已删,下次登录不会再起
Ok(format!("(卸载命令未全部成功,但配置已移除){}", notes.join(";")))
}
}
/// Windows:注册 / 注销计划任务。
///
/// 用 PowerShell 的 `New-ScheduledTask*` + `Register-ScheduledTask` 而非 `schtasks /TR` ——
/// 后者要拼一层引号转义(路径含空格时尤其容易出错),前者用参数对象,无转义问题。
fn windows_schtasks(exe: &Path, enable: bool) -> Result<String, String> {
if enable {
let script = format!(
r#"$ErrorActionPreference='Stop'
$exe = '{exe}'
$action = New-ScheduledTaskAction -Execute $exe -Argument '{AGENT_ONLY_FLAG}'
$trigger = New-ScheduledTaskTrigger -AtLogOn
$user = "$env:USERDOMAIN\$env:USERNAME"
$principal = New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit ([TimeSpan]::Zero)
Register-ScheduledTask -TaskName '{LABEL}' -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null
Write-Output ('OK ' + $user)"#,
exe = exe.display().to_string().replace('\'', "''"),
);
run_powershell(&script)
} else {
let script = format!(
r#"$ErrorActionPreference='Stop'
Unregister-ScheduledTask -TaskName '{LABEL}' -Confirm:$false
Write-Output 'OK removed'"#
);
run_powershell(&script)
}
}
fn run_powershell(script: &str) -> Result<String, String> {
let out = Command::new("powershell")
.args(["-NoProfile", "-NonInteractive", "-Command", script])
.output()
.map_err(|e| format!("无法执行 PowerShell:{e}"))?;
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
if out.status.success() {
Ok(stdout)
} else {
Err(if stderr.is_empty() { stdout } else { stderr })
}
}
/// 自启是否已启用。
pub fn is_enabled() -> bool {
if cfg!(target_os = "macos") {
macos_plist_path().map(|p| p.exists()).unwrap_or(false)
} else if cfg!(windows) {
Command::new("schtasks")
.args(["/Query", "/TN", LABEL])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
} else {
false
}
}
/// 读取自启状态的完整快照(供界面展示)。
pub fn status() -> Value {
let exe = app_exe();
let supported = cfg!(any(target_os = "macos", windows));
let where_ = if cfg!(target_os = "macos") {
macos_plist_path().map(|p| p.to_string_lossy().to_string()).unwrap_or_default()
} else if cfg!(windows) {
format!("计划任务 {LABEL}")
} else {
String::new()
};
json!({
"enabled": is_enabled(),
"supported": supported,
"location": where_,
"app_exe": exe.map(|p| p.to_string_lossy().to_string()).unwrap_or_default(),
"mode": AGENT_ONLY_FLAG,
"note": if supported {
"开机自启将以「仅服务模式」拉起本控制台:不显示窗口、直接进托盘并托管本机被控端。"
} else {
"当前平台暂不支持——请在系统设置里自行添加登录项。"
},
})
}
/// 开启 / 关闭开机自启。
pub fn set(enabled: bool) -> Result<Value, String> {
if !cfg!(any(target_os = "macos", windows)) {
return Err("当前平台暂不支持自动配置开机自启".to_string());
}
let exe = app_exe()?;
let detail = if cfg!(target_os = "macos") {
let plist = macos_plist_path()?;
if enabled {
if let Some(dir) = plist.parent() {
std::fs::create_dir_all(dir).map_err(|e| format!("创建 LaunchAgents 目录失败:{e}"))?;
}
std::fs::write(&plist, macos_plist(&exe)).map_err(|e| format!("写入 plist 失败:{e}"))?;
macos_launchctl(&plist, true)?
} else {
if plist.exists() {
std::fs::remove_file(&plist).map_err(|e| format!("删除 plist 失败:{e}"))?;
}
macos_launchctl(&plist, false)?
}
} else {
windows_schtasks(&exe, enabled)?
};
let mut snap = status();
snap["detail"] = json!(detail);
Ok(snap)
}
+769
View File
@@ -0,0 +1,769 @@
//! nodeagent 桌面控制台(主控 + 被控一体)——Tauri 后端。
//!
//! ## 结构(2026-10 起:sidecar 方案)
//! 本层只做**进程管理 + 消息转发**,真正的协议实现只有 TS 一份(`@nodeagent/client`),
//! 由 `sidecar.rs` 拉起的 Node 进程承载。原先「Rust 版 client 与 TS 版并存」的漂移风险就此消除。
//!
//! `agent.rs` 是同一思路的延伸:本机**被控端**也由本层托管(启动 / 停止 / 重启 / 日志 /
//! 崩溃自愈),用户在界面上点按钮即可,全程不必碰命令行。
//!
//! ## command 面
//! 连接类(签名与语义从未变过):
//! - `connect` / `disconnect` / `invoke_capability` / `get_capabilities` / `get_state`
//!
//! 本机服务类(2026-10-11 新增):
//! - `agent_status` / `agent_start` / `agent_stop` / `agent_restart`
//! - `agent_logs` / `agent_logs_clear`
//! - `autostart_status` / `autostart_set`
//! - `check_update` / `apply_update`
//! - `settings_get` / `settings_set` / `system_info`
//!
//! ## 事件面
//! - 连接:`na-state` / `na-log` / `na-event`
//! - 本机服务:`na-agent-state` / `na-agent-log`
//!
//! ## 启动模式
//! - 默认:显示主窗口
//! - `--agent-only`:**不显示窗口**,直接进托盘并托管本机被控端(开机自启用它,
//! 这样登录自启也不会有任何窗口弹出来)
//!
//! ## 单实例(v2.0.3)
//! 见下方「单实例」一节 —— 真机缺陷:无保护时同机会跑起两个被控端。
mod agent;
mod autostart;
mod settings;
mod sidecar;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use serde_json::{json, Value};
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
use tauri::{AppHandle, Emitter, Manager, State};
use agent::{AgentEvent, AgentManager};
use sidecar::{Sidecar, SidecarEvent};
/// 全局状态。
///
/// `connect` 成功后缓存「能力清单 / 版本 / 连接态」——这样 `get_capabilities`、
/// `get_state` 能保持**同步**(无需每次往返 sidecar),签名与改造前一致。
struct AppState {
sidecar: tokio::sync::Mutex<Option<Arc<Sidecar>>>,
capabilities: Mutex<Value>,
agent_version: Mutex<Option<String>>,
conn_state: Mutex<String>,
/// 本机被控端管理器(在 `setup` 里建,因为需要 AppHandle 来桥接事件)
agent: Mutex<Option<Arc<AgentManager>>>,
settings: Mutex<settings::Settings>,
/// 控制台自身配置目录(设置存这里)
config_dir: PathBuf,
/// 控制台版本(由 `package_info` 注入,用于更新比对;不在代码里写死)
app_version: String,
}
impl AppState {
fn agent(&self) -> Result<Arc<AgentManager>, String> {
self.agent
.lock()
.unwrap()
.as_ref()
.map(Arc::clone)
.ok_or_else(|| "被控端管理器尚未初始化".to_string())
}
fn settings(&self) -> settings::Settings {
self.settings.lock().unwrap().clone()
}
}
/// 连接成功后回传给前端的元信息(字段名与改造前一致)。
#[derive(serde::Serialize)]
struct ConnectInfo {
capabilities: Value,
agent_version: Option<String>,
authorized: Option<Value>,
peer_cert_fp: Option<String>,
auth_mode: String,
client_id: String,
}
/// 本机被控端的三条路径。
struct AgentPaths {
node: PathBuf,
script: PathBuf,
data_dir: PathBuf,
}
/// 解析(并在需要时播种)本机被控端所需路径。
fn resolve_agent_paths(app: &AppHandle, state: &AppState) -> Result<AgentPaths, String> {
let resource_dir = app.path().resource_dir().ok();
let node = sidecar::resolve_node(resource_dir.as_deref())?;
let bundled = agent::resolve_bundled_agent(resource_dir.as_deref())?;
let data_dir = agent::agent_data_dir();
let script = agent::seed_agent_script(&bundled, &data_dir, &state.app_version)?;
Ok(AgentPaths { node, script, data_dir })
}
/// 懒启动 sidecar(首次调用时拉起,之后复用同一进程)。
async fn ensure_sidecar(app: &AppHandle, state: &AppState) -> Result<Arc<Sidecar>, String> {
let mut guard = state.sidecar.lock().await;
if let Some(sc) = guard.as_ref() {
return Ok(Arc::clone(sc));
}
// 打包后 node 与 sidecar.mjs 随 resource 分发;开发期用系统 node + 仓库内脚本
let resource_dir = app.path().resource_dir().ok();
let node = sidecar::resolve_node(resource_dir.as_deref())?;
let script = sidecar::resolve_sidecar(resource_dir.as_deref())?;
// 事件桥接:sidecar 通知 → Tauri event(名字与改造前一致)
let app_events = app.clone();
let on_event = Arc::new(move |evt: SidecarEvent| match evt {
SidecarEvent::State(s) => {
let _ = app_events.emit("na-state", s);
}
SidecarEvent::Log(msg) => {
let _ = app_events.emit("na-log", msg);
}
SidecarEvent::Event(params) => {
let _ = app_events.emit("na-event", params);
}
SidecarEvent::Other(raw) => {
let _ = app_events.emit("na-log", format!("[sidecar] 未知通知: {raw}"));
}
});
let sc = Sidecar::spawn(&node, &script, on_event)?;
*state.conn_state.lock().unwrap() = "未连接".to_string();
*guard = Some(Arc::clone(&sc));
Ok(sc)
}
// ---------------------------------------------------------------------------
// 连接类 command(签名与语义保持不变)
// ---------------------------------------------------------------------------
/// 建立到被控端的连接并完成握手(默认 psk 模式、接受自签证书、开启自动重连)。
#[tauri::command]
async fn connect(
app: AppHandle,
state: State<'_, AppState>,
url: String,
key: String,
client_id: Option<String>,
insecure: Option<bool>,
) -> Result<ConnectInfo, String> {
let sc = ensure_sidecar(&app, &state).await?;
let client_id = client_id.unwrap_or_else(|| "console".to_string());
let snap = sc
.request(
"connect",
json!({
"url": url,
"key": key,
"client_id": client_id,
"insecure": insecure.unwrap_or(true),
}),
30,
)
.await?;
let caps = snap.get("capabilities").cloned().unwrap_or_else(|| json!([]));
let ver = snap.get("agent_version").and_then(Value::as_str).map(str::to_string);
*state.capabilities.lock().unwrap() = caps.clone();
*state.agent_version.lock().unwrap() = ver.clone();
*state.conn_state.lock().unwrap() = "connected".to_string();
Ok(ConnectInfo {
capabilities: caps,
agent_version: ver,
authorized: snap.get("authorized").cloned(),
peer_cert_fp: snap.get("peer_cert_fp").and_then(Value::as_str).map(str::to_string),
auth_mode: snap
.get("auth_mode")
.and_then(Value::as_str)
.unwrap_or("psk")
.to_string(),
client_id,
})
}
/// 断开连接(停止自动重连)。
#[tauri::command]
async fn disconnect(state: State<'_, AppState>) -> Result<(), String> {
let sc = { state.sidecar.lock().await.as_ref().map(Arc::clone) };
if let Some(sc) = sc {
sc.request("disconnect", json!({}), 10).await?;
}
*state.conn_state.lock().unwrap() = "未连接".to_string();
*state.capabilities.lock().unwrap() = json!([]);
Ok(())
}
/// 调用一项被控端能力。`timeout_ms` 可选:长任务(装软件 / 长命令)需显式放大。
#[tauri::command]
async fn invoke_capability(
state: State<'_, AppState>,
capability: String,
args: Option<Value>,
timeout_ms: Option<u64>,
) -> Result<Value, String> {
let sc = {
state
.sidecar
.lock()
.await
.as_ref()
.map(Arc::clone)
.ok_or_else(|| "未连接".to_string())?
};
let mut params = json!({ "capability": capability, "args": args.unwrap_or_else(|| json!({})) });
if let Some(ms) = timeout_ms.filter(|m| *m > 0) {
params["timeout_ms"] = json!(ms);
}
// sidecar 侧已把「未知能力 / ACL 拒绝 / 超时」归一成 {status:"failed",error:{...}},
// 故这里拿到的是稳定的 InvokeResult 形态(前端只需判 status)。
sc.request("invoke", params, timeout_ms.map(|m| (m / 1000) + 10).unwrap_or(70)).await
}
/// 当前连接的能力清单(读缓存,保持同步签名)。
#[tauri::command]
fn get_capabilities(state: State<'_, AppState>) -> Result<Value, String> {
Ok(state.capabilities.lock().unwrap().clone())
}
/// 当前连接状态字符串(connected / reconnecting / closed / 未连接)。
#[tauri::command]
fn get_state(state: State<'_, AppState>) -> String {
state.conn_state.lock().unwrap().clone()
}
// ---------------------------------------------------------------------------
// 本机服务(被控端)command
// ---------------------------------------------------------------------------
/// 本机服务总览:进程状态 + 配置 + 自启 + 设置 + 版本。
#[tauri::command]
async fn agent_status(app: AppHandle, state: State<'_, AppState>) -> Result<Value, String> {
// 管理器可能尚未建(极早的调用);此时给一个纯配置视图,而不是报错
let mgr = state.agent.lock().unwrap().clone();
let snap = match &mgr {
Some(m) => m.snapshot().await,
None => json!({ "state": agent::state::STOPPED }),
};
// 数据目录以「实际使用的」为准;管理器还没跑过就用默认值
let data_dir = snap
.get("data_dir")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(PathBuf::from)
.unwrap_or_else(agent::agent_data_dir);
let cfg = agent::read_agent_config(&data_dir);
let resource_dir = app.path().resource_dir().ok();
Ok(json!({
"manager": snap,
"config": cfg,
"autostart": autostart::status(),
"settings": settings::to_json(&state.settings()),
"app_version": state.app_version,
"config_dir": state.config_dir.to_string_lossy(),
"installed_agent_version": agent::installed_agent_version(&data_dir),
"bundled_agent": agent::resolve_bundled_agent(resource_dir.as_deref())
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default(),
}))
}
/// 启动本机被控端。`auto_restart` 缺省时取设置里的值。
#[tauri::command]
async fn agent_start(
app: AppHandle,
state: State<'_, AppState>,
auto_restart: Option<bool>,
) -> Result<Value, String> {
let paths = resolve_agent_paths(&app, &state)?;
let ar = auto_restart.unwrap_or_else(|| state.settings().auto_restart);
state
.agent()?
.start(&paths.node, &paths.script, &paths.data_dir, ar)
.await
}
/// 停止本机被控端(幂等)。
#[tauri::command]
async fn agent_stop(state: State<'_, AppState>) -> Result<Value, String> {
state.agent()?.stop().await
}
/// 重启本机被控端。
#[tauri::command]
async fn agent_restart(
app: AppHandle,
state: State<'_, AppState>,
auto_restart: Option<bool>,
) -> Result<Value, String> {
let paths = resolve_agent_paths(&app, &state)?;
let ar = auto_restart.unwrap_or_else(|| state.settings().auto_restart);
state
.agent()?
.restart(&paths.node, &paths.script, &paths.data_dir, ar)
.await
}
/// 取本机服务日志(增量:传上次拿到的最大 `seq`)。
#[tauri::command]
fn agent_logs(state: State<'_, AppState>, since: Option<u64>) -> Result<Value, String> {
Ok(state.agent()?.logs_since(since.unwrap_or(0)))
}
/// 清空日志缓冲。
#[tauri::command]
fn agent_logs_clear(state: State<'_, AppState>) -> Result<(), String> {
state.agent()?.clear_logs();
Ok(())
}
/// 开机自启状态。
#[tauri::command]
fn autostart_status() -> Value {
autostart::status()
}
/// 开关开机自启。
#[tauri::command]
fn autostart_set(enabled: bool) -> Result<Value, String> {
autostart::set(enabled)
}
// ---------------------------------------------------------------------------
// 更新
// ---------------------------------------------------------------------------
/// 检查更新:比对最新 Release 与本控制台版本,并附上本机被控端已安装版本。
#[tauri::command]
async fn check_update(app: AppHandle, state: State<'_, AppState>) -> Result<Value, String> {
let sc = ensure_sidecar(&app, &state).await?;
let mut snap = sc
.request(
"check_update",
json!({ "console_version": state.app_version }),
45,
)
.await?;
// 本机被控端的版本(数据目录里那份),供界面区分「应用要更新」与「被控端要更新」
let data_dir = agent::agent_data_dir();
let installed_version = agent::installed_agent_version(&data_dir).unwrap_or_default();
let latest = snap
.get("latest_version")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let agent_has_update =
!installed_version.is_empty() && agent::cmp_version(&latest, &installed_version) > 0;
snap["installed_agent_version"] = json!(installed_version);
snap["agent_has_update"] = json!(agent_has_update);
Ok(snap)
}
/// 应用更新:下载 → 校验 → 替换本机被控端脚本 → 重启。
#[tauri::command]
async fn apply_update(
app: AppHandle,
state: State<'_, AppState>,
url: String,
sha256: String,
version: String,
) -> Result<Value, String> {
let sc = ensure_sidecar(&app, &state).await?;
let paths = resolve_agent_paths(&app, &state)?;
let mgr = state.agent()?;
// ① 下载到暂存文件(**校验通过才落盘**;不直接覆盖在跑的脚本)
let staged = paths
.data_dir
.join("runtime")
.join(format!("agent.mjs.staged-{}", std::process::id()));
let dl = sc
.request(
"download",
json!({
"url": url,
"sha256": sha256,
"dest": staged.to_string_lossy(),
}),
320,
)
.await?;
// ② 停被控端(Windows 上正被 node 打开的文件不能重命名/替换)
let was_running = mgr.snapshot().await["state"] == json!(agent::state::RUNNING);
if was_running {
mgr.stop().await?;
}
// ③ 原子替换:先改名旧文件做备份,再落新文件
let backup = paths.script.with_extension(format!("mjs.bak-{}", now_secs()));
let replace = (|| -> Result<(), String> {
if paths.script.exists() {
std::fs::rename(&paths.script, &backup)
.map_err(|e| format!("备份现有脚本失败:{e}"))?;
}
std::fs::rename(&staged, &paths.script).map_err(|e| {
// 失败则回滚
let _ = std::fs::rename(&backup, &paths.script);
format!("替换脚本失败(已回滚):{e}")
})
})();
if let Err(e) = replace {
let _ = std::fs::remove_file(&staged);
return Err(e);
}
// ④ 记版本:这是「控制台启动时不要把已更新的被控端降级」的依据
agent::mark_installed_version(&paths.data_dir, &version, "console")?;
mgr.push_log(
"system",
format!("已更新本机被控端到 v{version}(备份:{})", backup.display()),
);
// ⑤ 按需重新拉起
let mut restarted = false;
if was_running || state.settings().auto_start_agent {
match mgr
.start(&paths.node, &paths.script, &paths.data_dir, state.settings().auto_restart)
.await
{
Ok(_) => restarted = true,
Err(e) => mgr.push_log("system", format!("更新后启动失败:{e}")),
}
}
Ok(json!({
"updated": true,
"version": version,
"bytes": dl.get("bytes").cloned().unwrap_or(json!(0)),
"sha256": dl.get("sha256").cloned().unwrap_or(json!("")),
"backup": backup.to_string_lossy(),
"restarted": restarted,
}))
}
fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
// ---------------------------------------------------------------------------
// 设置与系统信息
// ---------------------------------------------------------------------------
/// 读设置。
#[tauri::command]
fn settings_get(state: State<'_, AppState>) -> Value {
settings::to_json(&state.settings())
}
/// 合并式写设置(只传要改的字段)。
#[tauri::command]
fn settings_set(state: State<'_, AppState>, patch: Value) -> Result<Value, String> {
let next = settings::merge(&state.config_dir, &patch)?;
*state.settings.lock().unwrap() = next.clone();
Ok(settings::to_json(&next))
}
/// 控制台自身信息(版本、数据目录、平台),供「关于」与故障上报使用。
#[tauri::command]
fn system_info(app: AppHandle, state: State<'_, AppState>) -> Value {
let resource_dir = app.path().resource_dir().ok();
// 先取 node 路径,再把 resource_dir 消费成字符串 —— 顺序反了会「借用已移动的值」
let node = sidecar::resolve_node(resource_dir.as_deref())
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
json!({
"app_version": state.app_version,
"config_dir": state.config_dir.to_string_lossy(),
"agent_data_dir": agent::agent_data_dir().to_string_lossy(),
"resource_dir": resource_dir.map(|p| p.to_string_lossy().to_string()).unwrap_or_default(),
"platform": std::env::consts::OS,
"arch": std::env::consts::ARCH,
"node": node,
"agent_only": std::env::args().any(|a| a == autostart::AGENT_ONLY_FLAG),
})
}
// ---------------------------------------------------------------------------
// 托盘与启动模式
// ---------------------------------------------------------------------------
/// 显示主窗口(托盘菜单与第二次启动都用它)。
fn show_main(app: &AppHandle) {
if let Some(w) = app.get_webview_window("main") {
let _ = w.show();
let _ = w.unminimize();
let _ = w.set_focus();
}
}
/// 在后台拉起被控端(忽略错误 —— 失败详情在日志里)。
fn spawn_start_agent(app: &AppHandle, auto_restart: bool) {
let app2 = app.clone();
tauri::async_runtime::spawn(async move {
let state = app2.state::<AppState>();
let mgr = match state.agent() {
Ok(m) => m,
Err(e) => {
eprintln!("[console] 被控端管理器不可用:{e}");
return;
}
};
if mgr.snapshot().await["state"] == json!(agent::state::RUNNING) {
return;
}
match resolve_agent_paths(&app2, &state) {
Ok(p) => {
if let Err(e) = mgr.start(&p.node, &p.script, &p.data_dir, auto_restart).await {
mgr.push_log("system", format!("自动启动失败:{e}"));
}
}
Err(e) => mgr.push_log("system", format!("自动启动失败(路径解析):{e}")),
}
});
}
fn build_tray(app: &AppHandle) -> tauri::Result<()> {
let show = MenuItem::with_id(app, "show", "显示主窗口", true, None::<&str>)?;
let start = MenuItem::with_id(app, "agent_start", "启动本机被控端", true, None::<&str>)?;
let stop = MenuItem::with_id(app, "agent_stop", "停止本机被控端", true, None::<&str>)?;
let restart = MenuItem::with_id(app, "agent_restart", "重启本机被控端", true, None::<&str>)?;
let sep1 = PredefinedMenuItem::separator(app)?;
let sep2 = PredefinedMenuItem::separator(app)?;
let quit = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show, &sep1, &start, &stop, &restart, &sep2, &quit])?;
let mut builder = TrayIconBuilder::with_id("na-tray")
.menu(&menu)
.show_menu_on_left_click(false)
.tooltip("nodeagent 控制台")
.on_menu_event(|app, event| {
let id = event.id().as_ref().to_string();
match id.as_str() {
"show" => show_main(app),
"agent_start" => {
let ar = app.state::<AppState>().settings().auto_restart;
spawn_start_agent(app, ar);
}
"agent_stop" => {
let app2 = app.clone();
tauri::async_runtime::spawn(async move {
let state = app2.state::<AppState>();
if let Ok(mgr) = state.agent() {
let _ = mgr.stop().await;
}
});
}
"agent_restart" => {
let app2 = app.clone();
tauri::async_runtime::spawn(async move {
let state = app2.state::<AppState>();
if let Ok(mgr) = state.agent() {
if let Ok(p) = resolve_agent_paths(&app2, &state) {
let ar = state.settings().auto_restart;
let _ = mgr.restart(&p.node, &p.script, &p.data_dir, ar).await;
}
}
});
}
"quit" => app.exit(0),
_ => {}
}
})
.on_tray_icon_event(|tray, event| {
// 左键单击托盘图标 → 唤出主窗口(符合习惯)
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
show_main(tray.app_handle());
}
});
if let Some(icon) = app.default_window_icon() {
builder = builder.icon(icon.clone());
}
builder.build(app)?;
Ok(())
}
// ---------------------------------------------------------------------------
// 单实例
// ---------------------------------------------------------------------------
//
// ## 为什么必须有(真机缺陷,2026-10-13)
// 控制台此前**完全没有单实例保护**。开机自启会拉起一个 `--agent-only` 实例(无窗口、驻托盘),
// 用户再手动双击图标 → **第二个进程**。而「本机被控端」是**进程内状态**
// (`AgentManager` 各持自己的 `child: Option<Child>` / `pid`),两个进程互相看不见 →
// 同一台机器上跑起**两个被控端**,抢同一个 8765 端口。
//
// 后果按先后顺序分两种,正是用户报的两个症状:
// - **A(两个被控端)**:两边都抢到了不同时机 → 同机并存两个被控端进程;
// - **B(后开的起不来)**:先占住端口的那一侧赢了,后启动的一侧在 `AgentManager::start`
// 的端口检查处失败,界面表现为「应用端无法启动」(本机服务起不来)。
//
// ## 为什么不能简单「已有实例就退出」
// 自启实例是**无窗口**的(`--agent-only`)。若第二次启动只是静默退出,用户双击图标会
// **什么都不发生** —— 比缺陷本身更难排查。正确语义是:
// 第二次启动 → 把「用户想看到界面」这个意图**交给已有实例**(让它显示并聚焦窗口)→ 新进程再退出。
//
// `tauri-plugin-single-instance` 正好是这个语义:回调在**已有实例**里执行,新进程由插件负责收尾退出。
// 它必须注册为**第一个**插件,才能在其他插件与本项目的 `setup`(会拉起被控端)之前完成抢占判定。
/// 第二次启动时执行(在**已有实例**的进程里)。
fn on_second_instance(app: &AppHandle, argv: Vec<String>) {
// 第二次启动若自己就带 `--agent-only`(例如自启被重复触发),**不要弹窗** ——
// 那正是「仅服务模式」要避免的行为。只提示已有实例,不做别的。
if argv.iter().any(|a| a == autostart::AGENT_ONLY_FLAG) {
return;
}
// 已有实例可能是「自启的隐藏实例」或「窗口已关到托盘」→ 两种都要能唤出来。
show_main(app);
// 顺带通知前端刷一次状态(第二次启动往往发生在「用户发现没反应、想确认一下」的时刻)。
// 前端没监听也无害。
let _ = app.emit("na-second-instance", json!({ "argv": argv }));
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let agent_only = std::env::args().any(|a| a == autostart::AGENT_ONLY_FLAG);
tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| {
on_second_instance(app, argv);
}))
.plugin(tauri_plugin_opener::init())
.setup(move |app| {
let handle = app.handle().clone();
let config_dir = handle
.path()
.app_config_dir()
.unwrap_or_else(|_| PathBuf::from("."));
let app_version = handle.package_info().version.to_string();
let loaded = settings::load(&config_dir);
// 事件桥接:被控端管理器 → Tauri event
let app_events = handle.clone();
let emit: agent::AgentEmit = Arc::new(move |evt: AgentEvent| match evt {
AgentEvent::Log(l) => {
let _ = app_events.emit("na-agent-log", l);
}
AgentEvent::State(s) => {
let _ = app_events.emit("na-agent-state", s);
}
});
let manager = AgentManager::new(emit);
manager.push_log(
"system",
format!(
"控制台 v{app_version} 就绪({} / {})",
std::env::consts::OS,
std::env::consts::ARCH
),
);
app.manage(AppState {
sidecar: tokio::sync::Mutex::new(None),
capabilities: Mutex::new(json!([])),
agent_version: Mutex::new(None),
conn_state: Mutex::new("未连接".to_string()),
agent: Mutex::new(Some(Arc::clone(&manager))),
settings: Mutex::new(loaded.clone()),
config_dir,
app_version,
});
build_tray(&handle)?;
// 仅服务模式:不显示窗口(开机自启时不该弹任何窗口出来)
if agent_only {
if let Some(w) = handle.get_webview_window("main") {
let _ = w.hide();
}
manager.push_log("system", "以「仅服务模式」启动(无窗口,常驻托盘)");
}
// 自动拉起被控端(默认开)——「运行应用之后就有服务」的期望
if loaded.auto_start_agent {
spawn_start_agent(&handle, loaded.auto_restart);
}
Ok(())
})
.on_window_event(|window, event| {
// 关窗 → 最小化到托盘(可在设置里关掉,改为直接退出)
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
let app = window.app_handle();
let state = app.state::<AppState>();
let close_to_tray = state.settings().close_to_tray;
if close_to_tray {
api.prevent_close();
let _ = window.hide();
if let Ok(mgr) = state.agent() {
mgr.push_log("system", "窗口已隐藏到托盘(被控端继续运行;从托盘菜单可退出)");
}
}
}
})
.invoke_handler(tauri::generate_handler![
// 连接
connect,
disconnect,
invoke_capability,
get_capabilities,
get_state,
// 本机服务
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,
])
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app, event| {
// app 退出时优雅收尾:先让 sidecar 主动 disconnect,再杀进程
//(Sidecar 设了 kill_on_drop,即使这里失败也不会留孤儿进程)
if let tauri::RunEvent::Exit = event {
let state = app.state::<AppState>();
let sc = tauri::async_runtime::block_on(async {
state.sidecar.lock().await.as_ref().map(Arc::clone)
});
if let Some(sc) = sc {
tauri::async_runtime::block_on(async {
sc.shutdown().await;
});
}
}
});
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
nodeagent_console_lib::run()
}
+78
View File
@@ -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!({}))
}
+315
View File
@@ -0,0 +1,315 @@
//! Node sidecar 管理器 —— 启动 `apps/console/sidecar/main.mjs` 并做 JSON-RPC 转发。
//!
//! ## 为什么是 sidecar(而不是 Rust 版 client)
//! 原设计让本壳自己实现一份 client,与 TS 版(`@nodeagent/client`)**两套协议实现并存** ——
//! 改了一处漏另一处时,构建过、单测过,只在真机暴露。sidecar 让本壳只做
//! **进程管理 + 消息转发**,协议实现只剩 TS 一份。
//!
//! ## 协议(行分隔 JSON,见 apps/console/sidecar/main.mjs 顶部注释)
//! 出(本壳 → sidecar):`{"jsonrpc":"2.0","id":N,"method":...,"params":{...}}`
//! 入(sidecar → 本壳):
//! - 响应:`{"jsonrpc":"2.0","id":N,"result"|"error":...}`
//! - 通知:`{"jsonrpc":"2.0","method":"event","params":{"kind":...,...}}`
//!
//! ## 可测试性(本项目纪律:不能只 `cargo check`)
//! 本模块的 `spawn` / `request` / 事件回调**都能在 `cargo test` 里真跑** ——
//! 单测直接拉起真实的 node 进程,不依赖 Tauri GUI。GUI 那层才需要人工验证。
use serde_json::{json, Value};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, ChildStdin, Command};
use tokio::sync::oneshot;
/// sidecar 主动推上来的事件(与 `main.mjs` 的 `params.kind` 一一对应)。
#[derive(Debug, Clone)]
pub enum SidecarEvent {
/// 连接状态变化:connected / reconnecting / closed
State(String),
/// 日志一行
Log(String),
/// 被控端主动推送的事件(v12 `event.*`)
Event(Value),
/// sidecar 上来的未知通知(保留原始形态,便于排查)
Other(Value),
}
type Pending = Arc<Mutex<HashMap<u64, oneshot::Sender<Result<Value, String>>>>>;
/// 一个已启动的 sidecar 进程。
pub struct Sidecar {
child: Mutex<Option<Child>>,
stdin: tokio::sync::Mutex<ChildStdin>,
pending: Pending,
next_id: AtomicU64,
}
impl Sidecar {
/// 启动 sidecar 并开始读取其 stdout。
///
/// `on_event`:sidecar 的通知回调(本壳里用于桥接成 Tauri event)。
pub fn spawn(
node: &Path,
script: &Path,
on_event: Arc<dyn Fn(SidecarEvent) + Send + Sync>,
) -> Result<Arc<Self>, String> {
let mut child = Command::new(node)
.arg(script)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true) // app 退出时不留孤儿进程
.spawn()
.map_err(|e| format!("启动 sidecar 失败({} {}):{e}", node.display(), script.display()))?;
let stdin = child.stdin.take().ok_or("sidecar stdin 不可用")?;
let stdout = child.stdout.take().ok_or("sidecar stdout 不可用")?;
let stderr = child.stderr.take().ok_or("sidecar stderr 不可用")?;
let pending: Pending = Arc::new(Mutex::new(HashMap::new()));
// stdout:逐行读,分派「响应」与「通知」
{
let pending = Arc::clone(&pending);
tokio::spawn(async move {
let mut lines = BufReader::new(stdout).lines();
while let Ok(Some(line)) = lines.next_line().await {
let text = line.trim();
if text.is_empty() {
continue;
}
let Ok(msg) = serde_json::from_str::<Value>(text) else {
// 混入非 JSON 行说明协议被破坏 —— 记日志但不崩
eprintln!("[sidecar] 非 JSON 输出:{}", &text[..text.len().min(200)]);
continue;
};
// 响应:有 id
if let Some(id) = msg.get("id").and_then(Value::as_u64) {
if let Some(tx) = pending.lock().unwrap().remove(&id) {
let out = if let Some(err) = msg.get("error") {
Err(err
.get("message")
.and_then(Value::as_str)
.unwrap_or("sidecar 返回错误")
.to_string())
} else {
Ok(msg.get("result").cloned().unwrap_or(Value::Null))
};
let _ = tx.send(out);
}
continue;
}
// 通知:method == "event"
if msg.get("method").and_then(Value::as_str) == Some("event") {
let p = msg.get("params").cloned().unwrap_or(Value::Null);
let kind = p.get("kind").and_then(Value::as_str).unwrap_or("");
let evt = match kind {
"state" => SidecarEvent::State(
p.get("state").and_then(Value::as_str).unwrap_or("closed").to_string(),
),
"log" => SidecarEvent::Log(
p.get("message").and_then(Value::as_str).unwrap_or("").to_string(),
),
"agent_event" => {
SidecarEvent::Event(p.get("event").cloned().unwrap_or(Value::Null))
}
_ => SidecarEvent::Other(p),
};
on_event(evt);
}
}
});
}
// stderr:sidecar 的日志 → 本壳 stderr(保持 stdout 干净)
tokio::spawn(async move {
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
eprintln!("{}", line);
}
});
Ok(Arc::new(Self {
child: Mutex::new(Some(child)),
stdin: tokio::sync::Mutex::new(stdin),
pending,
next_id: AtomicU64::new(1),
}))
}
/// 发一个请求并等响应(`timeout_secs` 兜底,避免 sidecar 卡住时本壳一起挂)。
pub async fn request(&self, method: &str, params: Value, timeout_secs: u64) -> Result<Value, String> {
let id = self.next_id.fetch_add(1, Ordering::SeqCst);
let (tx, rx) = oneshot::channel();
self.pending.lock().unwrap().insert(id, tx);
let msg = json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params });
{
let mut stdin = self.stdin.lock().await;
stdin
.write_all(format!("{msg}\n").as_bytes())
.await
.map_err(|e| format!("写入 sidecar 失败:{e}"))?;
stdin.flush().await.map_err(|e| format!("flush sidecar 失败:{e}"))?;
}
match tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), rx).await {
Ok(Ok(r)) => r,
Ok(Err(_)) => Err("sidecar 通道已关闭".to_string()),
Err(_) => {
self.pending.lock().unwrap().remove(&id);
Err(format!("{method} 超时({timeout_secs}s)"))
}
}
}
/// 关掉 sidecar(app 退出或显式 disconnect 时调用)。
pub async fn shutdown(&self) {
let _ = self.request("disconnect", json!({}), 5).await;
// ⚠️ 必须**先把 child 取出来、让锁离开作用域**,再 await kill()。
// 若写成 `if let Some(child) = self.child.lock().unwrap().take() { child.kill().await }`,
// MutexGuard 会存活到 if 块结束 → 跨 await 持有 std Mutex(clippy::await_holding_lock,
// 且真会阻塞 executor)。
let child = self.child.lock().unwrap().take();
if let Some(mut child) = child {
let _ = child.kill().await;
}
}
}
// ---------------------------------------------------------------------------
// 路径解析:开发期用系统 node + 仓库内脚本;打包后从 Tauri resource 目录取
// ---------------------------------------------------------------------------
/// 解析 node 可执行文件。
///
/// 优先级:环境变量 `NODEAGENT_CONSOLE_NODE` → 随包 resource 的 `node` → 系统 `node`。
pub fn resolve_node(resource_dir: Option<&Path>) -> Result<PathBuf, String> {
if let Ok(p) = std::env::var("NODEAGENT_CONSOLE_NODE") {
if !p.is_empty() {
return Ok(PathBuf::from(p));
}
}
if let Some(dir) = resource_dir {
let bundled = dir.join(if cfg!(windows) { "node.exe" } else { "node" });
if bundled.exists() {
return Ok(bundled);
}
}
// 系统 node(开发期)
for cand in ["node", "node.exe"] {
if which(cand).is_some() {
return Ok(PathBuf::from(cand));
}
}
Err("找不到 node 可执行文件(可用 NODEAGENT_CONSOLE_NODE 指定)".to_string())
}
/// 极简 which(避免为一个函数引依赖)。
fn which(cmd: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path) {
let full = dir.join(cmd);
if full.is_file() {
return Some(full);
}
}
None
}
/// 解析 sidecar 脚本。
///
/// 优先级:环境变量 `NODEAGENT_CONSOLE_SIDECAR` → 随包 resource 的 `sidecar.mjs` →
/// 开发期的 `apps/console/sidecar/dist/sidecar.mjs`(相对本 crate)。
pub fn resolve_sidecar(resource_dir: Option<&Path>) -> Result<PathBuf, String> {
if let Ok(p) = std::env::var("NODEAGENT_CONSOLE_SIDECAR") {
if !p.is_empty() && Path::new(&p).exists() {
return Ok(PathBuf::from(p));
}
}
if let Some(dir) = resource_dir {
let bundled = dir.join("sidecar.mjs");
if bundled.exists() {
return Ok(bundled);
}
}
// 开发期:src-tauri → apps/console/sidecar/dist/sidecar.mjs
let dev = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../sidecar/dist/sidecar.mjs");
if dev.exists() {
return Ok(dev);
}
Err("找不到 sidecar.mjs(先跑 `pnpm --filter nodeagent-console sidecar:build`,\
或用 NODEAGENT_CONSOLE_SIDECAR 指定)"
.to_string())
}
// ---------------------------------------------------------------------------
// 单测:真跑 sidecar 进程(不依赖 Tauri GUI)
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::AtomicUsize;
/// 开发期的 sidecar 源码(不依赖打包产物,pnpm 的 node_modules 已能解析其依赖)。
fn dev_sidecar_src() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../sidecar/main.mjs")
}
fn node_bin() -> PathBuf {
resolve_node(None).expect("需要 node 才能跑 sidecar 单测")
}
#[tokio::test]
async fn 启动后_state_请求应返回未连接() {
let seen: Arc<AtomicUsize> = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&seen);
let sc = Sidecar::spawn(
&node_bin(),
&dev_sidecar_src(),
Arc::new(move |_e| {
counter.fetch_add(1, Ordering::SeqCst);
}),
)
.expect("sidecar 应能启动");
let r = sc.request("state", json!({}), 15).await.expect("state 应成功");
assert_eq!(r["connected"], json!(false), "未连接时 connected 应为 false");
sc.shutdown().await;
}
#[tokio::test]
async fn 未知方法应返回错误而不是崩溃() {
let sc = Sidecar::spawn(&node_bin(), &dev_sidecar_src(), Arc::new(|_| {}))
.expect("sidecar 应能启动");
let err = sc.request("no_such_method", json!({}), 15).await;
assert!(err.is_err(), "未知方法应返回错误");
// 关键:进程没崩,后续请求仍可用
let ok = sc.request("state", json!({}), 15).await.expect("sidecar 不应因错误退出");
assert_eq!(ok["connected"], json!(false));
sc.shutdown().await;
}
#[tokio::test]
async fn 未连接时_invoke_应返回明确错误() {
let sc = Sidecar::spawn(&node_bin(), &dev_sidecar_src(), Arc::new(|_| {}))
.expect("sidecar 应能启动");
let err = sc.request("invoke", json!({"capability": "system.info"}), 15).await;
match err {
Err(msg) => assert!(msg.contains("尚未连接"), "错误信息应提示未连接,实际:{msg}"),
Ok(v) => panic!("未连接时 invoke 应失败,实际返回:{v}"),
}
sc.shutdown().await;
}
}
+38
View File
@@ -0,0 +1,38 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "nodeagent-console",
"version": "2.0.3",
"identifier": "cn.jkooe.nodeagent-console",
"build": {
"beforeDevCommand": "pnpm dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "pnpm build && pnpm sidecar:build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "nodeagent 控制台",
"width": 1100,
"height": 720
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"resources": {
"../sidecar/dist/sidecar.mjs": "sidecar.mjs"
}
}
}
+44
View File
@@ -0,0 +1,44 @@
<script setup lang="ts">
import { watchEffect } from "vue";
import { NConfigProvider, NMessageProvider, NDialogProvider, zhCN, dateZhCN } from "naive-ui";
import AppShell from "./components/AppShell.vue";
import { cssVars } from "./theme";
import { useThemeStore } from "./stores/theme";
const theme = useThemeStore();
/**
* 把调色板写成 `--na-*` CSS 变量挂到根元素。
*
* 色值只在 `theme/index.ts` 定义一份,此处在运行期注入给自定义样式用 ——
* 免得「TS 调色板」与「CSS 硬编码」各写一份后静默漂移(换主题时一半新一半旧,
* 不报错、类型也抓不到)。
*
* 同时设 `color-scheme`:不设的话滚动条、`<input>` 等原生控件会一直是浅色,
* 在「高级灰」下白得刺眼。
*/
watchEffect(() => {
const { palette, name } = theme.preset;
const root = document.documentElement;
for (const [k, v] of Object.entries(cssVars(palette))) {
root.style.setProperty(k, v);
}
root.style.colorScheme = palette.scheme;
root.dataset.theme = name;
});
</script>
<template>
<n-config-provider
:theme="theme.preset.base"
:theme-overrides="theme.preset.overrides"
:locale="zhCN"
:date-locale="dateZhCN"
>
<n-message-provider>
<n-dialog-provider>
<app-shell />
</n-dialog-provider>
</n-message-provider>
</n-config-provider>
</template>
+169
View File
@@ -0,0 +1,169 @@
/** Tauri command 调用封装(前端 → Rust 壳 → Node sidecar → 被控端)。 */
import { invoke } from "@tauri-apps/api/core";
import type {
AgentActionResult,
AgentLogPage,
AgentStatus,
ApplyUpdateResult,
AutostartStatus,
CapabilityDescriptor,
ConnectInfo,
ConsoleSettings,
ConsoleSystemInfo,
InvokeResult,
UpdateInfo,
} from "../types";
/** 建立连接并握手。 */
export function connect(url: string, key: string, clientId = "console", insecure = true) {
return invoke<ConnectInfo>("connect", { url, key, clientId, insecure });
}
/** 断开连接(停自动重连)。 */
export function disconnect() {
return invoke<void>("disconnect");
}
/** 泛型能力调用(`timeoutMs` 用于长任务,如装软件 / 长命令)。 */
export function call<T = unknown>(
capability: string,
args: Record<string, unknown> = {},
timeoutMs?: number,
) {
return invoke<InvokeResult<T>>("invoke_capability", { capability, args, timeoutMs });
}
/** 当前连接的能力清单。 */
export function getCapabilities() {
return invoke<CapabilityDescriptor[]>("get_capabilities");
}
/** 当前连接状态字符串。 */
export function getState() {
return invoke<string>("get_state");
}
/**
* 能力调用失败(业务层失败,非传输层失败)。
*
* 协议真源 `InvokeResult.error` 只有 `{ name, message, data? }` —— **不含 code**,
* 所以分支判断一律走 `name`(如 `E_UNSUPPORTED_PLATFORM`)。
*/
export class CapabilityFailure extends Error {
readonly capability: string;
readonly errorName: string;
readonly data?: unknown;
constructor(capability: string, errorName: string, message: string, data?: unknown) {
super(message);
this.name = "CapabilityFailure";
this.capability = capability;
this.errorName = errorName;
this.data = data;
}
}
/** 被控端平台不支持该能力(如 macOS 被控端上的 app.* / system.service.list)。 */
export function isUnsupportedPlatform(e: unknown): boolean {
return e instanceof CapabilityFailure && e.errorName === "E_UNSUPPORTED_PLATFORM";
}
/** 能力被 ACL 拒绝。 */
export function isAclDenied(e: unknown): boolean {
return e instanceof CapabilityFailure && e.errorName === "E_ACL_DENIED";
}
/** 能力被调用限速拦截。 */
export function isRateLimited(e: unknown): boolean {
return e instanceof CapabilityFailure && e.errorName === "E_RATE_LIMITED";
}
/**
* 调用并要求成功:失败时抛出 `CapabilityFailure`(携带被控端 error.name)。
* 便于页面里 `await ok("fs.list", { path })` 直接拿数据。
*/
export async function ok<T = unknown>(
capability: string,
args: Record<string, unknown> = {},
timeoutMs?: number,
): Promise<T> {
const res = await call<T>(capability, args, timeoutMs);
if (res.status !== "ok") {
throw new CapabilityFailure(
capability,
res.error?.name ?? "E_UNKNOWN",
res.error?.message ?? `${capability} 执行失败`,
res.error?.data,
);
}
return res.data as T;
}
// ---------------------------------------------------------------------------
// 本机服务(本机被控端)—— 全部走 Tauri command,不经被控端协议
// ---------------------------------------------------------------------------
/** 本机服务总览:进程状态 + 配置 + 自启 + 设置 + 版本。 */
export function agentStatus() {
return invoke<AgentStatus>("agent_status");
}
/** 启动本机被控端。`autoRestart` 省略时取设置里的值。 */
export function agentStart(autoRestart?: boolean) {
return invoke<AgentActionResult>("agent_start", { autoRestart });
}
/** 停止本机被控端(幂等)。 */
export function agentStop() {
return invoke<AgentActionResult>("agent_stop");
}
/** 重启本机被控端。 */
export function agentRestart(autoRestart?: boolean) {
return invoke<AgentActionResult>("agent_restart", { autoRestart });
}
/** 增量取日志(传上次拿到的最大 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");
}
+204
View File
@@ -0,0 +1,204 @@
<script setup lang="ts">
import { computed, onMounted } from "vue";
import { RouterView, useRoute, useRouter } from "vue-router";
import {
NLayout,
NLayoutSider,
NLayoutHeader,
NLayoutContent,
NMenu,
NTag,
NButton,
NSpace,
NText,
NTooltip,
NRadioGroup,
NRadioButton,
type MenuOption,
} from "naive-ui";
import { navRoutes, type NavMeta } from "../router";
import { useConnStore } from "../stores/conn";
import { useAgentStore } from "../stores/agent";
import { useThemeStore } from "../stores/theme";
import { THEME_LIST } from "../theme";
const route = useRoute();
const router = useRouter();
const conn = useConnStore();
const agent = useAgentStore();
const theme = useThemeStore();
const themeName = computed({
get: () => theme.name,
set: (v) => theme.set(v),
});
onMounted(() => {
conn.bind();
agent.bind();
});
// 纯文字导航:应用内不使用图标(emoji 图标在部分渲染器里会退化成豆腐块)。
const menuOptions: MenuOption[] = navRoutes.map((r) => ({
key: r.name as string,
label: (r.meta as NavMeta).title,
}));
const activeKey = computed(() => (route.name as string) ?? "local");
function onMenu(key: string) {
router.push({ name: key });
}
const stateType = computed(() => {
if (conn.state === "connected") return "success" as const;
if (conn.state === "reconnecting") return "warning" as const;
return "error" as const;
});
const stateLabel = computed(() => {
if (conn.state === "connected") return "已连接";
if (conn.state === "reconnecting") return "重连中";
return "未连接";
});
/** 从侧栏快速启停本机服务(不必切页面)。 */
async function quickToggle() {
if (agent.running) await agent.stop();
else await agent.start();
}
</script>
<template>
<n-layout has-sider style="height: 100vh">
<n-layout-sider bordered :width="200" :native-scrollbar="false">
<div class="brand">
<div class="brand-title">nodeagent</div>
<div class="brand-sub">桌面控制台</div>
</div>
<n-menu :options="menuOptions" :value="activeKey" @update:value="onMenu" />
<div class="sider-foot">
<div class="foot-label">主题</div>
<n-radio-group v-model:value="themeName" size="small" class="theme-switch">
<n-radio-button v-for="t in THEME_LIST" :key="t.name" :value="t.name">
{{ t.label }}
</n-radio-button>
</n-radio-group>
<div class="foot-sep"></div>
<div class="foot-row">
<n-text depth="3" style="font-size: 12px">本机被控端</n-text>
<n-tag :type="agent.meta.type" size="tiny" round>{{ agent.meta.label }}</n-tag>
</div>
<n-button
size="tiny"
block
:loading="agent.busy"
:disabled="agent.busy || agent.externalInstance"
@click="quickToggle"
>
{{ agent.running ? "停止服务" : "启动服务" }}
</n-button>
</div>
</n-layout-sider>
<n-layout>
<n-layout-header bordered class="topbar">
<n-space align="center">
<n-tag :type="stateType" size="small" round>{{ stateLabel }}</n-tag>
<n-text depth="3" style="font-size: 12px">{{ conn.url }}</n-text>
<n-text v-if="conn.info" depth="3" style="font-size: 12px">
· agent {{ conn.info.agent_version ?? "?" }} · {{ conn.info.capabilities.length }} 项能力
</n-text>
</n-space>
<n-space align="center">
<n-tooltip v-if="agent.sysInfo?.agent_only" trigger="hover">
<template #trigger>
<n-tag size="small" type="info" round>仅服务模式</n-tag>
</template>
本控制台以「仅服务模式」启动(无窗口,常驻托盘)—— 通常来自开机自启
</n-tooltip>
<n-button size="small" :disabled="!conn.info" @click="conn.disconnect()">断开</n-button>
</n-space>
</n-layout-header>
<n-layout-content :native-scrollbar="false" content-style="padding: 20px;">
<router-view />
</n-layout-content>
</n-layout>
</n-layout>
</template>
<style>
/* 配色只走 `--na-*` 变量(值在 src/theme/index.ts 单点定义,App.vue 运行期注入),
此处不留任何写死的色值 —— 否则换主题时会一半新一半旧,且不报错。
`color-scheme` 也由 App.vue 按主题设,这里不重复声明。 */
html,
body {
background: var(--na-bg);
}
body {
margin: 0;
}
.brand {
padding: 18px 20px 10px;
}
.brand-title {
font-size: 18px;
font-weight: 600;
color: var(--na-brand);
letter-spacing: 0.5px;
}
.brand-sub {
font-size: 12px;
color: var(--na-text-3);
margin-top: 2px;
}
.topbar {
height: 48px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
}
/* 侧栏做成纵向弹性布局,才能把底部快捷块压到底 */
:deep(.n-layout-sider-scroll-container) {
display: flex;
flex-direction: column;
}
.sider-foot {
margin-top: auto;
padding: 12px 14px 16px;
border-top: 1px solid var(--na-border);
}
.foot-label {
font-size: 11px;
color: var(--na-text-3);
margin-bottom: 6px;
}
.foot-sep {
height: 1px;
background: var(--na-border);
margin: 12px 0;
}
/* 两个主题按钮各占一半宽(侧栏只有 200px,默认内边距会挤出去) */
.theme-switch {
display: flex;
width: 100%;
}
.theme-switch :deep(.n-radio-button) {
flex: 1;
}
.theme-switch :deep(.n-radio-button__label) {
display: flex;
justify-content: center;
font-size: 12px;
padding: 0 2px;
}
.foot-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
</style>
+9
View File
@@ -0,0 +1,9 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import router from "./router";
import App from "./App.vue";
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.mount("#app");
+76
View File
@@ -0,0 +1,76 @@
/** 路由表。hash history 便于 Tauri 打包后稳定工作。 */
import { createRouter, createWebHashHistory } from "vue-router";
// 刻意不设 icon 字段:应用内一律不使用图标,导航以纯文字呈现。
// (原因见 CHANGELOG [未发布]:emoji 图标在非系统字体栈的渲染器里会退化成豆腐块,
// 而跨平台「哪套字体栈一定带 emoji」并不成立 —— 与其赌字形,不如不用。)
export interface NavMeta {
title: string;
}
const routes = [
// 默认落在「本机服务」—— 这是「打开应用就要能启动/停止/重启」的落点,
// 而不是先让用户去填一个连接地址。
{ 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: "设备管理" } satisfies NavMeta,
},
{
path: "/overview",
name: "overview",
component: () => import("../views/OverviewView.vue"),
meta: { title: "状态总览" } satisfies NavMeta,
},
{
path: "/terminal",
name: "terminal",
component: () => import("../views/TerminalView.vue"),
meta: { title: "命令执行" } satisfies NavMeta,
},
{
path: "/processes",
name: "processes",
component: () => import("../views/ProcessesView.vue"),
meta: { title: "进程与服务" } satisfies NavMeta,
},
{
path: "/software",
name: "software",
component: () => import("../views/SoftwareView.vue"),
meta: { title: "软件管理" } satisfies NavMeta,
},
{
path: "/files",
name: "files",
component: () => import("../views/FilesView.vue"),
meta: { title: "文件管理" } satisfies NavMeta,
},
{
path: "/audit",
name: "audit",
component: () => import("../views/AuditView.vue"),
meta: { title: "审计日志" } satisfies NavMeta,
},
];
export const navRoutes = routes.filter((r) => "name" in r);
export default createRouter({
history: createWebHashHistory(),
routes,
});
+243
View File
@@ -0,0 +1,243 @@
/**
* 本机服务 store —— 托管**本机**被控端(启动 / 停止 / 重启 / 自启 / 更新 / 设置)。
*
* 与 `conn` store 的分工:`conn` 管「连到某台被控端」(远程控制),
* 本 store 管「本机这台被控端本身的进程与配置」。二者都连本机时是同一个实例,
* 但关注点不同 —— 本 store 不依赖连接是否成功(进程没起也能启动它)。
*/
import { defineStore } from "pinia";
import { computed, ref } from "vue";
import { listen } from "@tauri-apps/api/event";
import * as api from "../api";
import { useConnStore } from "./conn";
import type {
AgentLogLine,
AgentState,
AgentStatus,
ApplyUpdateResult,
ConsoleSettings,
ConsoleSystemInfo,
UpdateInfo,
} from "../types";
/** 状态 → 展示文案与配色。 */
export const STATE_META: Record<AgentState, { label: string; type: "success" | "warning" | "error" | "default" }> = {
running: { label: "运行中", type: "success" },
starting: { label: "启动中", type: "warning" },
stopping: { label: "停止中", type: "warning" },
stopped: { label: "已停止", type: "default" },
crashed: { label: "已异常退出", type: "error" },
};
export const useAgentStore = defineStore("agent", () => {
const status = ref<AgentStatus | null>(null);
const sysInfo = ref<ConsoleSystemInfo | null>(null);
const update = ref<UpdateInfo | null>(null);
const busy = ref(false);
const error = ref<string | null>(null);
const bound = ref(false);
const process = computed(() => status.value?.manager ?? null);
const state = computed<AgentState>(() => process.value?.state ?? "stopped");
const meta = computed(() => STATE_META[state.value] ?? STATE_META.stopped);
const settings = computed<ConsoleSettings>(
() => status.value?.settings ?? { auto_start_agent: true, auto_restart: true, close_to_tray: true, insecure: true },
);
const running = computed(() => state.value === "running");
/** 端口被占但本管理器没在跑 → 已有「非托管」的实例 */
const externalInstance = computed(
() => !running.value && (status.value?.config.port_in_use ?? false),
);
/** 包一层:统一置 busy / 收集错误,避免每个动作都写一遍 try-finally。 */
async function run<T>(fn: () => Promise<T>): Promise<T | null> {
busy.value = true;
error.value = null;
try {
const r = await fn();
await refresh();
return r;
} catch (e) {
error.value = e instanceof Error ? e.message : String(e);
return null;
} finally {
busy.value = false;
}
}
/** 刷新总览(状态 + 配置 + 自启 + 设置)。 */
async function refresh() {
try {
status.value = await api.agentStatus();
error.value = null;
} catch (e) {
error.value = e instanceof Error ? e.message : String(e);
}
return status.value;
}
async function loadSystemInfo() {
sysInfo.value = await api.systemInfo();
return sysInfo.value;
}
/** 绑定后端事件(应用启动时调用一次):进程状态变化由推送驱动,不轮询。 */
async function bind() {
if (bound.value) return;
bound.value = true;
await listen<string>("na-agent-state", () => {
void refresh();
});
// v2.0.3 单实例:用户再次双击图标时,由**已存在实例**接管(把窗口唤出来)。
// 顺手刷新一次 —— 触发它的场景往往正是「点了没反应,想看看怎么回事」。
await listen<{ argv?: string[] }>("na-second-instance", () => {
void refresh();
});
await refresh();
void loadSystemInfo();
}
const start = () => run(() => api.agentStart());
const stop = () => run(() => api.agentStop());
const restart = () => run(() => api.agentRestart());
const clearLogs = () => api.agentLogsClear();
async function setAutostart(enabled: boolean) {
return run(() => api.autostartSet(enabled));
}
async function patchSettings(patch: Partial<ConsoleSettings>) {
return run(() => api.settingsSet(patch));
}
/** 检查更新。 */
async function checkUpdate() {
busy.value = true;
error.value = null;
try {
update.value = await api.checkUpdate();
return update.value;
} catch (e) {
error.value = e instanceof Error ? e.message : String(e);
return null;
} finally {
busy.value = false;
}
}
/** 应用更新(仅更新本机被控端;控制台自身需重新下载安装包)。 */
async function applyUpdate(): Promise<ApplyUpdateResult | null> {
const u = update.value;
const url = u?.agent?.url;
const sha = u?.agent?.sha256;
if (!u || !url || !sha) {
error.value = "最新版本信息不完整(缺少下载地址或哈希),无法更新";
return null;
}
const r = await run(() => api.applyUpdate(url, sha, u.latest_version));
if (r) await checkUpdate();
return r;
}
/** 一键连接本机被控端(用配置里的地址与密钥)。 */
async function connectLocal() {
const cfg = status.value?.config;
if (!cfg) return null;
const conn = useConnStore();
return conn.connect(cfg.url, cfg.key);
}
return {
status,
sysInfo,
update,
busy,
error,
process,
state,
meta,
settings,
running,
externalInstance,
bind,
refresh,
loadSystemInfo,
start,
stop,
restart,
clearLogs,
setAutostart,
patchSettings,
checkUpdate,
applyUpdate,
connectLocal,
};
});
/**
* 日志 store —— 本机服务日志的实时流 + 增量拉取。
*
* 设计要点:**事件推 + 序号去重**。若只靠事件,页面重新挂载后会丢掉历史;
* 若只靠轮询,则看不到实时的那一行。故两条路并用,用 `seq` 去重。
*/
export const useLogsStore = defineStore("logs", () => {
const lines = ref<AgentLogLine[]>([]);
const maxSeq = ref(0);
const paused = ref(false);
const bound = ref(false);
/** 内存里保留的最大行数(与 Rust 侧环形缓冲上限对齐,避免界面无限膨胀) */
const CAP = 4000;
function append(rows: AgentLogLine[]) {
const fresh = rows.filter((r) => r.seq > maxSeq.value);
if (fresh.length === 0) return;
lines.value.push(...fresh);
maxSeq.value = Math.max(maxSeq.value, ...fresh.map((r) => r.seq));
if (lines.value.length > CAP) lines.value.splice(0, lines.value.length - CAP);
}
/** 拉取历史(首次进页面 / 清空后重建)。 */
async function load() {
const page = await api.agentLogs(0);
lines.value = [];
maxSeq.value = 0;
append(page.lines);
return page;
}
/** 绑定实时日志事件。 */
async function bind() {
if (bound.value) return;
bound.value = true;
await listen<AgentLogLine>("na-agent-log", (e) => {
if (paused.value) return;
append([e.payload]);
});
await load();
}
async function clear() {
await api.agentLogsClear();
lines.value = [];
maxSeq.value = 0;
}
/** 导出为文本(在浏览器侧生成,不额外调后端)。 */
function toText(filtered?: AgentLogLine[]): string {
const src = filtered ?? lines.value;
return src
.map((l) => `${new Date(l.ts).toISOString()} [${l.stream}] ${l.line}`)
.join("\n");
}
function downloadText(text: string, filename: string) {
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = filename;
a.click();
URL.revokeObjectURL(a.href);
}
return { lines, maxSeq, paused, bind, load, clear, append, toText, downloadText };
});
+170
View File
@@ -0,0 +1,170 @@
/** 连接状态 store:连接/断开、能力清单、元信息、日志与被控端事件。 */
import { defineStore } from "pinia";
import { computed, ref } from "vue";
import { listen } from "@tauri-apps/api/event";
import * as api from "../api";
import type { ConnectInfo, InvokeResult, SystemInfo } from "../types";
export interface AgentEvent {
ts: number;
payload: unknown;
}
/**
* 仅在被控端为 Windows 时可用(其余平台返回 E_UNSUPPORTED_PLATFORM)。
* 前端据此**事前门控**,而不是发出注定失败的请求再解释错误。
*/
export const WINDOWS_ONLY_CAPS = ["app.list", "app.install", "system.service.list"] as const;
export const useConnStore = defineStore("conn", () => {
const state = ref<string>("closed");
const url = ref(localStorage.getItem("na.url") || "ws://127.0.0.1:8765");
const key = ref(localStorage.getItem("na.key") || "");
const info = ref<ConnectInfo | null>(null);
const logs = ref<string[]>([]);
const events = ref<AgentEvent[]>([]);
const connecting = ref(false);
const bound = ref(false);
/** 被控端平台:'Windows' | 'macOS' | 'Linux' | ... (连接后自动探测) */
const nodeOs = ref<string | null>(null);
const connected = computed(() => state.value === "connected" && !!info.value);
const capabilities = computed(() => info.value?.capabilities ?? []);
/** 被控端能力名集合,便于按名判存在性。 */
const capabilityNames = computed(() => new Set(capabilities.value.map((c) => c.name)));
const isWindows = computed(() => nodeOs.value === "Windows");
/** 平台门控能力当前是否可用(未探测到平台时按「乐观可用」处理)。 */
const platformReady = computed(() => nodeOs.value === null || isWindows.value);
function pushLog(msg: string) {
logs.value.push(`[${new Date().toLocaleTimeString()}] ${msg}`);
if (logs.value.length > 500) logs.value.shift();
}
/** 绑定后端事件(应用启动时调用一次)。 */
async function bind() {
if (bound.value) return;
bound.value = true;
await listen<string>("na-log", (e) => pushLog(e.payload));
await listen<string>("na-state", (e) => {
state.value = e.payload;
pushLog(`状态 → ${e.payload}`);
});
await listen<unknown>("na-event", (e) => {
events.value.unshift({ ts: Date.now(), payload: e.payload });
if (events.value.length > 200) events.value.pop();
pushLog(`被控端事件:${JSON.stringify(e.payload)}`);
});
state.value = await api.getState();
}
/** 探测被控端平台(`system.info.os`)——决定 Windows 专属能力是否可用。 */
async function loadPlatform() {
try {
const i = await api.ok<SystemInfo>("system.info", { fields: ["os"] });
nodeOs.value = i.os ?? null;
pushLog(`被控端平台:${nodeOs.value ?? "未知"}`);
return nodeOs.value;
} catch (e) {
pushLog(`平台探测失败:${e}`);
return null;
}
}
async function connect(u = url.value, k = key.value) {
connecting.value = true;
url.value = u;
key.value = k;
pushLog(`正在连接 ${u} ...`);
try {
const i = await api.connect(u, k, "console");
info.value = i;
localStorage.setItem("na.url", u);
localStorage.setItem("na.key", k);
pushLog(`握手成功:${i.capabilities.length} 项能力,agent ${i.agent_version ?? "?"}`);
// 握手后立刻探测平台,供各页面事前门控。
await loadPlatform();
return i;
} catch (e) {
pushLog(`连接失败:${e}`);
throw e;
} finally {
connecting.value = false;
}
}
async function disconnect() {
await api.disconnect();
info.value = null;
nodeOs.value = null;
pushLog("已断开");
}
/** 泛型调用;连接断开时抛错。 */
async function call<T = unknown>(
capability: string,
args: Record<string, unknown> = {},
timeoutMs?: number,
): Promise<InvokeResult<T>> {
if (!connected.value) throw new api.CapabilityFailure(capability, "E_NODE_OFFLINE", "未连接");
const t0 = performance.now();
const res = await api.call<T>(capability, args, timeoutMs);
const ms = Math.round(performance.now() - t0);
if (res.status === "ok") {
pushLog(`${capability} → ok (${ms}ms)`);
} else {
pushLog(`${capability} → 失败:[${res.error?.name}] ${res.error?.message ?? "未知错误"}`);
}
return res;
}
/**
* 调用并要求成功,直接返回 data。
* 失败抛 `CapabilityFailure`(携带 `errorName`),便于页面按错误名分支
* ——如 `isUnsupportedPlatform(e)` 判平台不支持。
*/
async function ok<T = unknown>(
capability: string,
args: Record<string, unknown> = {},
timeoutMs?: number,
): Promise<T> {
const res = await call<T>(capability, args, timeoutMs);
if (res.status !== "ok") {
throw new api.CapabilityFailure(
capability,
res.error?.name ?? "E_UNKNOWN",
res.error?.message ?? `${capability} 执行失败`,
res.error?.data,
);
}
return res.data as T;
}
function clearLogs() {
logs.value = [];
}
return {
state,
url,
key,
info,
logs,
events,
connecting,
connected,
capabilities,
capabilityNames,
nodeOs,
isWindows,
platformReady,
bind,
loadPlatform,
connect,
disconnect,
call,
ok,
pushLog,
clearLogs,
};
});
+50
View File
@@ -0,0 +1,50 @@
/** 设备列表 store:本地保存的被控端配置(名称 / 地址 / PSK),CRUD + 持久化。 */
import { defineStore } from "pinia";
import { computed, ref } from "vue";
export interface NodeProfile {
id: string;
name: string;
url: string;
key: string;
}
export const useNodeStore = defineStore("node", () => {
const nodes = ref<NodeProfile[]>(JSON.parse(localStorage.getItem("na.nodes") || "[]"));
const currentId = ref<string | null>(localStorage.getItem("na.current"));
const current = computed(() => nodes.value.find((n) => n.id === currentId.value) ?? null);
function persist() {
localStorage.setItem("na.nodes", JSON.stringify(nodes.value));
localStorage.setItem("na.current", currentId.value ?? "");
}
function add(profile: Omit<NodeProfile, "id">): NodeProfile {
const node: NodeProfile = { id: crypto.randomUUID(), ...profile };
nodes.value.push(node);
persist();
return node;
}
function update(id: string, patch: Partial<NodeProfile>) {
const idx = nodes.value.findIndex((n) => n.id === id);
if (idx >= 0) {
nodes.value[idx] = { ...nodes.value[idx], ...patch };
persist();
}
}
function remove(id: string) {
nodes.value = nodes.value.filter((n) => n.id !== id);
if (currentId.value === id) currentId.value = null;
persist();
}
function setCurrent(id: string | null) {
currentId.value = id;
persist();
}
return { nodes, currentId, current, add, update, remove, setCurrent };
});
+53
View File
@@ -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 };
});
+478
View File
@@ -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";
}
+380
View File
@@ -0,0 +1,380 @@
/** 协议类型镜像(对齐 `@nodeagent/protocol`,亦即 sidecar 与 Tauri command 的序列化形状)。 */
/** 能力描述(被控端声明的能力清单条目)。 */
export interface CapabilityDescriptor {
name: string;
version: string;
description: string;
risk: "low" | "medium" | "high" | string;
params_schema: JsonSchema;
returns_schema?: JsonSchema;
}
/** JSON Schema 子集。 */
export interface JsonSchema {
type?: string;
properties?: Record<string, JsonSchema>;
required?: string[];
additionalProperties?: boolean;
enum?: unknown[];
default?: unknown;
minimum?: number;
maximum?: number;
minLength?: number;
maxLength?: number;
pattern?: string;
items?: JsonSchema;
minItems?: number;
maxItems?: number;
description?: string;
}
/** connect command 返回的连接元信息。 */
export interface ConnectInfo {
capabilities: CapabilityDescriptor[];
agent_version: string | null;
authorized: string[] | null;
peer_cert_fp: string | null;
auth_mode: string;
client_id: string;
}
/** 能力执行失败的错误信息。 */
export interface InvokeError {
name: string;
message: string;
data?: unknown;
}
/** 能力执行结果(业务层)。 */
export interface InvokeResult<T = unknown> {
status: "ok" | "failed";
data?: T;
error?: InvokeError;
}
/** 连接状态。 */
export type ConnState = "connected" | "reconnecting" | "closed";
// ---------- 各能力的返回结构 ----------
/** 被控端构建信息(v18 构建指纹 + 运行状态)。 */
export interface BuildInfo {
version?: string;
hash?: string;
commit?: string;
built_at?: string;
/** 运行中 agent 脚本的 sha256(v21 证书/脚本交叉核对) */
cert_sha256?: string;
bytes?: number;
mtime_ms?: number;
node?: string;
started_at?: number;
uptime_ms?: number;
}
/** PowerShell 常驻助手状态(v15)。 */
export interface PsHelperInfo {
spawned?: boolean;
hits?: number;
failures?: number;
consecutiveFailures?: number;
avg_ms?: number;
[k: string]: unknown;
}
/** v21 来源网段访问控制。 */
export interface NetworkInfo {
/** null = **未配置 = 放行全部**(应尽快收敛) */
allow_from?: string[] | null;
}
export interface SystemInfo {
hostname?: string;
/** 被控端平台:'Windows' | 'macOS' | 'Linux' —— **平台门控能力的判断依据** */
os?: string;
os_version?: string;
arch?: string;
cpu_model?: string;
cpu_cores?: number;
memory_total?: number;
uptime_sec?: number;
is_admin?: boolean;
pid?: number;
agent_home?: string;
/** 被控端入口脚本路径,供一键升级定位 */
agent_script?: string;
/** 被控端 Node 可执行文件路径(字段名是 `node_path`,没有独立的 `node`) */
node_path?: string;
ps_helper?: PsHelperInfo;
network?: NetworkInfo;
build?: BuildInfo;
}
/**
* 磁盘(对齐 manifest system.status.disks 契约)。
* 注意:**没有 `used` 字段**,已用量需自行算 `total - free`。
*/
export interface DiskInfo {
drive?: string;
total?: number;
free?: number;
used_pct?: number;
}
/** 网络适配器(对齐 manifest system.status.net 契约)。 */
export interface NetInfo {
adapter?: string;
ip?: string;
up?: boolean;
}
export interface SystemStatus {
cpu_pct?: number;
memory_used?: number;
memory_total?: number;
memory_pct?: number;
disks?: DiskInfo[];
net?: NetInfo[];
}
/** 进程项(对齐 manifest system.process.list 契约)。 */
export interface ProcessEntry {
pid: number;
name: string;
/** CPU 占用百分比(非 `cpu`) */
cpu_pct?: number;
/** 常驻内存字节数(非 `memory`) */
memory_bytes?: number;
started_at?: number;
}
/** 服务项(对齐 manifest system.service.list 契约)。 */
export interface ServiceEntry {
name: string;
display_name?: string;
/** 运行状态字段名是 `state`,不是 `status` */
state?: string;
start_type?: string;
}
export interface FsEntry {
name: string;
path: string;
type: "file" | "dir" | "other";
size: number;
mtime: number;
}
export interface FsListResult {
entries: FsEntry[];
total: number;
truncated: boolean;
}
export interface FsReadResult {
data: string;
encoding: string;
offset: number;
bytes: number;
total_bytes: number;
eof: boolean;
sha256?: string;
}
/** 已装软件项(对齐 manifest app.list 契约)。 */
export interface AppEntry {
name: string;
version?: string;
publisher?: string;
/** 'registry' | 'uwp' | 'winget' 等来源标记 */
source?: string;
}
/** 审计事件类别(对齐 apps/agent/src/audit.ts 的 `AuditType`)。 */
export type AuditType =
| "agent.start"
| "agent.stop"
| "auth.success"
| "auth.failure"
| "invoke"
| "acl.denied"
| "capability.disabled"
| "rate.limited"
| "net.change"
| "net.deny"
| "agent.update"
| "session.idle_close"
// v2.0.3:被拒请求统一留痕(U-01)+ 进程级兜底错误
| "request.rejected"
| "agent.error";
export interface AuditEntry {
ts?: number;
type?: AuditType | string;
client_id?: string;
remote?: string;
capability?: string;
status?: "ok" | "failed" | "denied";
duration_ms?: number;
error?: string | null;
/** 参数摘要(sha256 前缀)——默认只记摘要,不记内容 */
args_digest?: string;
/** 参数预览(仅 `log_args:true` 时记录,键含敏感词自动脱敏) */
args_preview?: string;
reason?: string;
/** v11 防篡改:上一条哈希(首条为 "genesis") */
prev?: string;
/** v11 防篡改:本条哈希 */
hash?: string;
}
export interface AuditListResult {
entries: AuditEntry[];
total: number;
file?: string;
}
/** 审计链首个异常位置(`ok:false` 时存在)。 */
export interface AuditBrokenAt {
file?: string;
line?: number;
reason?: string;
}
export interface AuditVerifyResult {
ok: boolean;
checked: number;
/** 无链字段的历史条目数(跳过校验) */
legacy?: number;
/** 是**对象**而非数字 */
broken_at?: AuditBrokenAt | null;
}
// ---------------------------------------------------------------------------
// 本机服务(本机被控端托管)—— 契约来源:src-tauri/src/agent.rs
// ---------------------------------------------------------------------------
/** 被控端进程状态机取值(与 Rust `agent::state` 一一对应)。 */
export type AgentState = "stopped" | "starting" | "running" | "stopping" | "crashed";
/** 本机被控端进程快照。 */
export interface AgentProcess {
state: AgentState;
pid?: number | null;
started_at?: number | null;
uptime_ms?: number | null;
/** 自动重启已尝试次数 */
restarts?: number;
last_exit?: string | null;
/** 实际使用的 node 可执行文件 */
node?: string;
/** 实际运行的那份 agent.mjs(在数据目录里,不在 app 包内) */
script?: string;
data_dir?: string;
}
/** 被控端配置(读自 `<数据目录>/agent.json`)。 */
export interface AgentConfigInfo {
data_dir: string;
config_path: string;
config_exists: boolean;
node_id: string;
port: number;
tls: boolean;
auth_mode: "psk" | "ed25519" | string;
/** PSK 明文(本机展示用;它本就在用户自己的 600 权限文件里) */
key: string;
/** 端口是否已被监听(可能是一个非本控制台托管的实例) */
port_in_use: boolean;
url: string;
}
export interface AutostartStatus {
enabled: boolean;
supported: boolean;
/** 落地位置(plist 路径 / 计划任务名) */
location: string;
app_exe: string;
/** 自启时传给应用的模式参数 */
mode: string;
note: string;
/** 本次操作详情(开关后返回) */
detail?: string;
}
export interface ConsoleSettings {
auto_start_agent: boolean;
auto_restart: boolean;
close_to_tray: boolean;
insecure: boolean;
}
export interface AgentStatus {
manager: AgentProcess;
config: AgentConfigInfo;
autostart: AutostartStatus;
settings: ConsoleSettings;
app_version: string;
config_dir: string;
/** 数据目录里那份脚本的版本(被控端自更新或控制台更新后写入) */
installed_agent_version?: string | null;
/** 随包内置的 agent.mjs 路径 */
bundled_agent?: string;
}
export interface AgentActionResult {
state?: AgentState;
pid?: number | null;
note?: string;
port_released?: boolean;
}
export interface AgentLogLine {
seq: number;
ts: number;
/** stdout / stderr / system */
stream: string;
line: string;
}
export interface AgentLogPage {
lines: AgentLogLine[];
total: number;
}
export interface UpdateInfo {
console_version: string;
latest_version: string;
has_update: boolean;
agent_has_update?: boolean;
installed_agent_version?: string;
published_at?: string | null;
commit?: string | null;
capabilities?: number | null;
agent: { url?: string | null; sha256?: string | null; bytes?: number | null };
release_url: string;
source: string;
checked_at: number;
}
export interface ApplyUpdateResult {
updated: boolean;
version: string;
bytes: number;
sha256: string;
backup: string;
restarted: boolean;
}
export interface ConsoleSystemInfo {
app_version: string;
config_dir: string;
agent_data_dir: string;
resource_dir: string;
platform: string;
arch: string;
node: string;
/** 是否以「仅服务模式」启动(开机自启时为 true) */
agent_only: boolean;
}
+33
View File
@@ -0,0 +1,33 @@
/** 通用格式化工具。 */
export function formatBytes(n?: number | null): string {
if (n == null || Number.isNaN(n)) return "-";
const units = ["B", "KB", "MB", "GB", "TB", "PB"];
let v = n;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
}
return `${v.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
}
export function formatDuration(sec?: number | null): string {
if (sec == null || Number.isNaN(sec)) return "-";
const d = Math.floor(sec / 86400);
const h = Math.floor((sec % 86400) / 3600);
const m = Math.floor((sec % 3600) / 60);
if (d) return `${d} 天 ${h} 小时`;
if (h) return `${h} 小时 ${m} 分`;
return `${m} 分`;
}
export function formatTime(ms?: number | null): string {
if (!ms) return "-";
return new Date(ms).toLocaleString("zh-CN", { hour12: false });
}
export function formatPct(v?: number | null): string {
if (v == null || Number.isNaN(v)) return "-";
return `${v.toFixed(1)}%`;
}
+187
View File
@@ -0,0 +1,187 @@
<script setup lang="ts">
import { h, onMounted, ref } from "vue";
import {
NCard,
NDataTable,
NButton,
NSpace,
NInputNumber,
NSelect,
NText,
NTag,
NEmpty,
NAlert,
useMessage,
type DataTableColumns,
} from "naive-ui";
import { useConnStore } from "../stores/conn";
import type { AuditEntry, AuditListResult, AuditVerifyResult } from "../types";
import { formatTime } from "../utils/format";
const conn = useConnStore();
const msg = useMessage();
const entries = ref<AuditEntry[]>([]);
const total = ref(0);
const limit = ref(100);
const typeFilter = ref<string | null>(null);
const loading = ref(false);
const verify = ref<AuditVerifyResult | null>(null);
/**
* 审计筛选项(取值必须是 `@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", 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> = [
{ title: "时间", key: "ts", width: 180, render: (r) => formatTime(r.ts) },
{
title: "类型",
key: "type",
width: 150,
render: (r) => {
const t = r.type ?? "-";
const kind = t.includes("denied") || t.includes("failure") || t.includes("deny") ? "error" : "default";
return h(NTag, { size: "small", type: kind }, { default: () => t });
},
},
{ title: "调用方", key: "client_id", width: 110 },
{ title: "来源", key: "remote", width: 120 },
{ title: "能力", key: "capability", ellipsis: { tooltip: true } },
{
title: "状态",
key: "status",
width: 90,
render: (r) => {
if (!r.status) return "-";
const kind = r.status === "ok" ? "success" : "error";
return h(NTag, { size: "small", type: kind }, { default: () => r.status });
},
},
{ title: "耗时", key: "duration_ms", width: 90, render: (r) => (r.duration_ms != null ? `${r.duration_ms}ms` : "-") },
{
title: "参数摘要",
key: "args_digest",
width: 170,
ellipsis: { tooltip: true },
render: (r) => r.args_digest ?? "-",
},
{
title: "说明",
key: "reason",
ellipsis: { tooltip: true },
render: (r) => r.reason || r.error || "-",
},
];
async function load() {
loading.value = true;
try {
const args: Record<string, unknown> = { limit: limit.value };
if (typeFilter.value) args.type = typeFilter.value;
const r = await conn.ok<AuditListResult>("system.audit.list", args);
entries.value = r.entries ?? [];
total.value = r.total ?? entries.value.length;
} catch (e) {
msg.error(`加载审计失败:${e}`);
} finally {
loading.value = false;
}
}
async function runVerify() {
try {
verify.value = await conn.ok<AuditVerifyResult>("system.audit.verify", {});
} catch (e) {
msg.error(`校验失败:${e}`);
}
}
async function exportJsonl() {
const text = entries.value.map((e) => JSON.stringify(e)).join("\n");
try {
await navigator.clipboard.writeText(text);
msg.success(`已复制 ${entries.value.length} 条到剪贴板`);
} catch {
msg.warning("剪贴板不可用");
}
}
onMounted(() => {
if (conn.connected) load();
});
</script>
<template>
<div v-if="!conn.connected">
<n-empty :show-icon="false" description="请先在「设备管理」页连接被控端" style="margin-top: 80px" />
</div>
<div v-else class="wrap">
<n-card size="small">
<template #header>
<n-space align="center">
<span>审计日志</span>
<n-text depth="3" style="font-size: 12px">共 {{ total }} 条</n-text>
</n-space>
</template>
<template #header-extra>
<n-space align="center">
<n-select
v-model:value="typeFilter"
:options="typeOptions"
size="small"
style="width: 190px"
@update:value="load"
/>
<n-input-number v-model:value="limit" :min="10" :max="2000" :step="50" size="small" style="width: 110px" />
<n-button size="small" :loading="loading" @click="load">刷新</n-button>
<n-button size="small" @click="runVerify">完整性校验</n-button>
<n-button size="small" @click="exportJsonl">导出</n-button>
</n-space>
</template>
<n-alert
v-if="verify"
:type="verify.ok ? 'success' : 'error'"
style="margin-bottom: 12px"
>
完整性校验:{{ verify.ok ? "通过" : "发现问题" }} · 已校验 {{ verify.checked }} 条
<template v-if="verify.legacy"> · 跳过 {{ verify.legacy }} 条历史条目</template>
<template v-if="verify.broken_at">
· 首个断点:{{ verify.broken_at.file }} 第 {{ verify.broken_at.line }} 行
<template v-if="verify.broken_at.reason">({{ verify.broken_at.reason }})</template>
</template>
</n-alert>
<n-empty :show-icon="false" v-if="!entries.length" description="暂无审计记录" size="small" />
<n-data-table v-else :columns="cols" :data="entries" :bordered="false" size="small" :max-height="560" />
</n-card>
</div>
</template>
<style scoped>
.wrap {
max-width: 1200px;
}
</style>
+240
View File
@@ -0,0 +1,240 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import {
NCard,
NInput,
NButton,
NSpace,
NText,
NEmpty,
NDataTable,
NModal,
NTag,
useMessage,
useDialog,
type DataTableColumns,
} from "naive-ui";
import { useConnStore } from "../stores/conn";
import type { FsEntry, FsListResult, FsReadResult, SystemInfo } from "../types";
import { formatBytes, formatTime } from "../utils/format";
const conn = useConnStore();
const msg = useMessage();
const dialog = useDialog();
const cwd = ref("");
const entries = ref<FsEntry[]>([]);
const loading = ref(false);
const truncated = ref(false);
const previewOpen = ref(false);
const previewName = ref("");
const previewText = ref("");
const previewMeta = ref("");
const writeOpen = ref(false);
const wName = ref("");
const wContent = ref("");
const wAppend = ref(false);
const cols: DataTableColumns<FsEntry> = [
{
title: "名称",
key: "name",
render: (r) => r.name + (r.type === "dir" ? "/" : ""),
},
{ title: "类型", key: "type", width: 80 },
{ title: "大小", key: "size", width: 110, render: (r) => (r.type === "file" ? formatBytes(r.size) : "-") },
{ title: "修改时间", key: "mtime", width: 180, render: (r) => formatTime(r.mtime) },
];
async function list(path: string) {
if (!path) return;
loading.value = true;
try {
const r = await conn.ok<FsListResult>("fs.list", { path });
entries.value = (r.entries ?? []).slice().sort((a, b) => {
if (a.type === "dir" && b.type !== "dir") return -1;
if (a.type !== "dir" && b.type === "dir") return 1;
return a.name.localeCompare(b.name);
});
truncated.value = !!r.truncated;
cwd.value = path;
} catch (e) {
msg.error(`列目录失败:${e}`);
} finally {
loading.value = false;
}
}
function parentOf(p: string): string {
const s = p.replace(/[\\/]+$/, "");
const idx = Math.max(s.lastIndexOf("/"), s.lastIndexOf("\\"));
if (idx < 0) return s;
if (idx === 0) return s.slice(0, 1); // POSIX 根
const r = s.slice(0, idx);
return /^[A-Za-z]:$/.test(r) ? r + "\\" : r; // Windows 盘符
}
function onRowClick(row: FsEntry) {
if (row.type === "dir") list(row.path);
else preview(row);
}
async function preview(row: FsEntry) {
try {
const r = await conn.ok<FsReadResult>("fs.read", {
path: row.path,
encoding: "utf8",
max_bytes: 262144,
});
previewName.value = row.name;
previewText.value = (r.data ?? "").replace(/\u0000/g, "");
previewMeta.value = `${formatBytes(r.total_bytes)}${r.eof ? "" : " · 已截断预览"}${r.sha256 ? ` · sha256 ${r.sha256.slice(0, 12)}…` : ""}`;
previewOpen.value = true;
} catch (e) {
msg.error(`读取失败:${e}`);
}
}
function openWrite() {
wName.value = "";
wContent.value = "";
wAppend.value = false;
writeOpen.value = true;
}
function doWrite() {
if (!wName.value) return;
const sep = cwd.value.includes("\\") ? "\\" : "/";
const path = cwd.value.replace(/[\\/]+$/, "") + sep + wName.value;
dialog.warning({
title: "确认写入被控端",
content: `目标:${path}\n模式:${wAppend.value ? "追加" : "覆盖"}\n字节:${wContent.value.length}`,
positiveText: "写入",
negativeText: "取消",
onPositiveClick: () => writeTo(path),
});
}
async function writeTo(path: string) {
try {
await conn.ok("fs.write", { path, data: wContent.value, append: wAppend.value, create_dirs: true });
msg.success(`已写入 ${path}`);
writeOpen.value = false;
list(cwd.value);
} catch (e) {
msg.error(`写入失败:${e}`);
}
}
async function ensureStart() {
try {
const info = await conn.ok<SystemInfo>("system.info", { fields: ["agent_home", "os"] });
let start = info.agent_home ?? "";
if (!start) start = info.os === "Windows" ? "C:\\" : "/";
list(start);
} catch {
list(conn.info ? "/" : "");
}
}
onMounted(() => {
if (conn.connected) ensureStart();
});
</script>
<template>
<div v-if="!conn.connected">
<n-empty :show-icon="false" description="请先在「设备管理」页连接被控端" style="margin-top: 80px" />
</div>
<div v-else class="wrap">
<n-card size="small">
<n-space align="center" :wrap="false">
<n-input
v-model:value="cwd"
size="small"
placeholder="输入路径,回车列目录(如 C:\ 或 /Users)"
style="flex: 1"
@keyup.enter="list(cwd)"
/>
<n-button size="small" @click="list(parentOf(cwd))">上级</n-button>
<n-button size="small" :loading="loading" @click="list(cwd)">刷新</n-button>
<n-button size="small" type="primary" @click="openWrite">新建文件</n-button>
</n-space>
<div class="path">
<n-text depth="3" style="font-size: 12px">
当前:{{ cwd || "-" }} · {{ entries.length }} 项
<n-tag v-if="truncated" size="tiny" type="warning" style="margin-left: 6px">结果被截断</n-tag>
</n-text>
</div>
</n-card>
<n-card size="small" style="margin-top: 16px">
<n-empty :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="520"
:row-props="(row: FsEntry) => ({ style: 'cursor:pointer', onClick: () => onRowClick(row) })"
/>
</n-card>
</div>
<!-- 预览 -->
<n-modal v-model:show="previewOpen" preset="card" :title="previewName" style="width: 820px; max-width: 90vw">
<n-text depth="3" style="font-size: 12px">{{ previewMeta }}</n-text>
<pre class="preview">{{ previewText || "(空文件)" }}</pre>
</n-modal>
<!-- 新建/写入 -->
<n-modal v-model:show="writeOpen" preset="card" title="写入文件" style="width: 640px; max-width: 90vw">
<n-space vertical>
<n-input v-model:value="wName" placeholder="文件名(写入当前目录)" size="small" />
<n-input
v-model:value="wContent"
type="textarea"
:autosize="{ minRows: 6, maxRows: 16 }"
placeholder="文件内容(utf8)"
/>
<n-space align="center">
<n-tag
size="small"
:type="wAppend ? 'warning' : 'default'"
checkable
:checked="wAppend"
@update:checked="(v: boolean) => (wAppend = v)"
>
追加模式
</n-tag>
<n-button size="small" type="primary" @click="doWrite">写入</n-button>
</n-space>
</n-space>
</n-modal>
</template>
<style scoped>
.wrap {
max-width: 1100px;
}
.path {
margin-top: 10px;
}
.preview {
margin: 12px 0 0;
padding: 12px;
border-radius: 8px;
background: var(--na-sunken);
font-family: ui-monospace, Menlo, monospace;
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-all;
max-height: 60vh;
overflow: auto;
}
</style>
+423
View File
@@ -0,0 +1,423 @@
<script setup lang="ts">
/**
* 本机服务 —— 控制台托管的**本机被控端**(默认首页)。
*
* 目标:用户**完全不碰命令行** —— 打开应用即可启动 / 停止 / 重启被控端、
* 开关开机自启、查日志、检查更新。
*/
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import {
NAlert,
NButton,
NCard,
NDivider,
NGrid,
NGridItem,
NPopconfirm,
NSpace,
NSpin,
NSwitch,
NTag,
NText,
NTooltip,
useMessage,
} from "naive-ui";
import { useAgentStore, useLogsStore } from "../stores/agent";
import { formatDuration } from "../utils/format";
const agent = useAgentStore();
const logs = useLogsStore();
const msg = useMessage();
const router = useRouter();
const revealKey = ref(false);
const copied = ref<string | null>(null);
const cfg = computed(() => agent.status?.config ?? null);
const proc = computed(() => agent.process);
const busy = computed(() => agent.busy);
/** 最近 6 行日志(完整日志在「服务日志」页)。 */
const recent = computed(() => logs.lines.slice(-6));
async function copy(text: string, what: string) {
try {
await navigator.clipboard.writeText(text);
copied.value = what;
msg.success(`已复制${what}`);
setTimeout(() => (copied.value = null), 1500);
} catch {
msg.error("复制失败(系统剪贴板不可用,可手工选中复制)");
}
}
async function doAction(fn: () => Promise<unknown>, name: string) {
const r = await fn();
if (agent.error) msg.error(`${name}失败:${agent.error}`);
else if (r) msg.success(`${name}完成`);
}
async function doAutostart(v: boolean) {
const r = await agent.setAutostart(v);
if (agent.error) msg.error(`设置开机自启失败:${agent.error}`);
else msg.success(v ? `已开启开机自启(${agent.status?.autostart.location})` : "已关闭开机自启");
return r;
}
async function doSetting(key: "auto_restart" | "close_to_tray" | "auto_start_agent", v: boolean) {
await agent.patchSettings({ [key]: v } as never);
if (agent.error) msg.error(`保存设置失败:${agent.error}`);
}
async function doCheckUpdate() {
const u = await agent.checkUpdate();
if (agent.error) msg.error(`检查更新失败:${agent.error}`);
else if (u) msg.success(u.has_update ? `有新版本 v${u.latest_version}` : "已是最新版本");
}
async function doApplyUpdate() {
const r = await agent.applyUpdate();
if (agent.error) msg.error(`更新失败:${agent.error}`);
else if (r) msg.success(`已更新到 v${r.version}(${r.restarted ? "并已重启" : "未自动启动"})`);
}
async function doConnectLocal() {
try {
await agent.connectLocal();
msg.success("已连接本机被控端");
} catch (e) {
msg.error(`连接失败:${e}`);
}
}
onMounted(async () => {
await agent.bind();
await logs.bind();
});
function fmtTime(ts?: number | null) {
return ts ? new Date(ts).toLocaleString() : "-";
}
/** 打开 Release 页面(模板里不能直接用 `window`,故走 setup 作用域的函数)。 */
function openRelease() {
const url = agent.update?.release_url;
if (url) window.open(url, "_blank");
}
</script>
<template>
<n-spin :show="busy && !agent.status">
<div class="wrap">
<n-alert v-if="agent.error" type="error" style="margin-bottom: 14px">
{{ agent.error }}
</n-alert>
<!-- 非托管实例提示:端口被占但不是本控制台起的 -->
<n-alert
v-if="agent.externalInstance"
type="warning"
style="margin-bottom: 14px"
title="检测到已有一个被控端在运行(非本控制台托管)"
>
端口 {{ cfg?.port }} 已被监听。它可能是手工安装的(`install.cmd` / 计划任务)。
本控制台无法停止它;如要用本控制台管理,请先停掉它,或直接点「连接本机」复用它。
</n-alert>
<n-grid :cols="4" :x-gap="16" :y-gap="16" item-responsive responsive="screen">
<!-- ① 服务状态与操作 -->
<n-grid-item span="4 s:4 m:2">
<n-card title="本机被控端" size="small">
<n-space align="center" style="margin-bottom: 12px">
<n-tag :type="agent.meta.type" size="medium" round>{{ agent.meta.label }}</n-tag>
<n-text v-if="proc?.pid" depth="3" style="font-size: 12px">PID {{ proc.pid }}</n-text>
<n-text v-if="proc?.uptime_ms" depth="3" style="font-size: 12px">
· 已运行 {{ formatDuration(Math.round((proc.uptime_ms ?? 0) / 1000)) }}
</n-text>
<n-text v-if="proc?.restarts" depth="3" style="font-size: 12px">
· 自动重启 {{ proc.restarts }} 次
</n-text>
</n-space>
<n-space style="margin-bottom: 14px">
<n-button
type="primary"
:loading="busy"
:disabled="agent.running || agent.externalInstance"
@click="doAction(() => agent.start(), '启动')"
>
启动
</n-button>
<n-button :loading="busy" :disabled="!agent.running" @click="doAction(() => agent.stop(), '停止')">
停止
</n-button>
<n-button :loading="busy" :disabled="!agent.running" @click="doAction(() => agent.restart(), '重启')">
重启
</n-button>
<n-button :disabled="!cfg" quaternary @click="doConnectLocal">连接本机</n-button>
</n-space>
<n-alert v-if="proc?.last_exit" type="default" style="margin-bottom: 12px">
上次退出:{{ proc.last_exit }}
</n-alert>
<div class="kv"><span>节点 ID</span><b>{{ cfg?.node_id || "-" }}</b></div>
<div class="kv"><span>监听地址</span><b>{{ cfg?.url || "-" }}</b></div>
<div class="kv">
<span>预共享密钥</span>
<b class="mono">
{{ revealKey ? cfg?.key || "-" : "•".repeat(Math.min(cfg?.key?.length ?? 0, 16)) || "-" }}
<n-button text size="tiny" style="margin-left: 6px" @click="revealKey = !revealKey">
{{ revealKey ? "隐藏" : "显示" }}
</n-button>
<n-button
text
size="tiny"
style="margin-left: 6px"
:disabled="!cfg?.key"
@click="copy(cfg?.key ?? '', '密钥')"
>
复制
</n-button>
</b>
</div>
<div class="kv"><span>认证模式</span><b>{{ cfg?.auth_mode || "-" }}</b></div>
<div class="kv">
<span>数据目录</span>
<b class="mono ellipsis" :title="cfg?.data_dir">{{ cfg?.data_dir || "-" }}</b>
</div>
<div class="kv">
<span>运行脚本</span>
<b class="mono ellipsis" :title="proc?.script">{{ proc?.script || "-" }}</b>
</div>
<div class="kv">
<span>被控端版本</span>
<b>{{ agent.status?.installed_agent_version || "(未播种)" }}</b>
</div>
</n-card>
</n-grid-item>
<!-- ② 行为设置 -->
<n-grid-item span="4 s:4 m:2">
<n-card title="运行方式" size="small">
<div class="switch-row">
<div>
<div>开机自启</div>
<div class="hint">
{{ agent.status?.autostart.note }}
</div>
</div>
<n-switch
:value="agent.status?.autostart.enabled ?? false"
:disabled="!agent.status?.autostart.supported || busy"
:loading="busy"
@update:value="doAutostart"
/>
</div>
<n-divider style="margin: 12px 0" />
<div class="switch-row">
<div>
<div>后台运行(关闭窗口时最小化到托盘)</div>
<div class="hint">关闭后,点窗口的关闭按钮将直接退出应用并停止被控端</div>
</div>
<n-switch
:value="agent.settings.close_to_tray"
:disabled="busy"
@update:value="(v: boolean) => doSetting('close_to_tray', v)"
/>
</div>
<n-divider style="margin: 12px 0" />
<div class="switch-row">
<div>
<div>启动应用时自动拉起被控端</div>
<div class="hint">关闭后需手工点「启动」</div>
</div>
<n-switch
:value="agent.settings.auto_start_agent"
:disabled="busy"
@update:value="(v: boolean) => doSetting('auto_start_agent', v)"
/>
</div>
<n-divider style="margin: 12px 0" />
<div class="switch-row">
<div>
<div>被控端异常退出时自动重启</div>
<div class="hint">连续失败 5 次后停止重试并保留现场日志</div>
</div>
<n-switch
:value="agent.settings.auto_restart"
:disabled="busy"
@update:value="(v: boolean) => doSetting('auto_restart', v)"
/>
</div>
<n-divider style="margin: 12px 0" />
<div class="kv"><span>自启落地位置</span><b class="mono ellipsis">{{ agent.status?.autostart.location || "-" }}</b></div>
<div class="kv"><span>自启方式</span><b class="mono">仅服务模式(无窗口)</b></div>
</n-card>
</n-grid-item>
<!-- ③ 更新 -->
<n-grid-item span="4 s:4 m:2">
<n-card title="更新" size="small">
<div class="kv"><span>控制台版本</span><b>v{{ agent.status?.app_version || "-" }}</b></div>
<div class="kv">
<span>被控端版本</span>
<b>{{ agent.status?.installed_agent_version || "(未播种)" }}</b>
</div>
<div class="kv">
<span>最新版本</span>
<b>
{{ agent.update ? `v${agent.update.latest_version}` : "(未检查)" }}
<n-tag
v-if="agent.update?.has_update"
type="warning"
size="tiny"
style="margin-left: 6px"
>
有更新
</n-tag>
<n-tag
v-else-if="agent.update"
type="success"
size="tiny"
style="margin-left: 6px"
>
已是最新
</n-tag>
</b>
</div>
<div v-if="agent.update?.published_at" class="kv">
<span>发布时间</span><b>{{ fmtTime(Date.parse(agent.update.published_at)) }}</b>
</div>
<n-space style="margin-top: 12px">
<n-button size="small" :loading="busy" @click="doCheckUpdate">检查更新</n-button>
<n-popconfirm :show-icon="false" @positive-click="doApplyUpdate">
<template #trigger>
<n-button
size="small"
type="primary"
:disabled="!agent.update?.agent_has_update || busy"
>
更新被控端
</n-button>
</template>
将下载最新被控端并替换本机正在使用的那份(自动备份旧文件),随后重启服务。确定?
</n-popconfirm>
</n-space>
<div class="hint" style="margin-top: 10px">
「更新被控端」只替换本机被控端脚本(架构无关的 JS)。控制台自身(桌面应用)请从
<n-button
text
size="tiny"
:disabled="!agent.update"
@click="openRelease"
>
Release 页面
</n-button>
下载安装包。
</div>
</n-card>
</n-grid-item>
<!-- ④ 日志预览 -->
<n-grid-item span="4 s:4 m:2">
<n-card title="最近日志" size="small">
<template #header-extra>
<n-space>
<n-button text size="tiny" @click="logs.load()">刷新</n-button>
<n-button text size="tiny" @click="router.push({ name: 'logs' })">查看全部</n-button>
</n-space>
</template>
<div v-if="recent.length" class="logs">
<div v-for="l in recent" :key="l.seq" class="log-line" :class="l.stream">
<span class="ts">{{ new Date(l.ts).toLocaleTimeString() }}</span>
<span class="txt">{{ l.line }}</span>
</div>
</div>
<n-text v-else depth="3" style="font-size: 13px">暂无日志</n-text>
</n-card>
</n-grid-item>
</n-grid>
<n-tooltip trigger="hover">
<template #trigger>
<n-text depth="3" style="font-size: 12px; display: block; margin-top: 18px">
提示:关闭窗口后控制台会缩到托盘继续运行 —— 从托盘菜单可显示窗口或退出。
</n-text>
</template>
若不想后台常驻,可在「运行方式」里关闭「后台运行」
</n-tooltip>
</div>
</n-spin>
</template>
<style scoped>
.wrap {
max-width: 1100px;
}
.kv {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 13px;
padding: 4px 0;
}
.kv > span {
opacity: 0.6;
flex: 0 0 auto;
}
.kv > b {
font-weight: 500;
text-align: right;
min-width: 0;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
}
.ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-block;
max-width: 100%;
}
.switch-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
font-size: 13px;
}
.hint {
font-size: 12px;
opacity: 0.55;
margin-top: 3px;
line-height: 1.5;
}
.logs {
max-height: 190px;
overflow: auto;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
}
.log-line {
display: flex;
gap: 8px;
padding: 1px 0;
white-space: pre-wrap;
word-break: break-all;
}
.log-line .ts {
opacity: 0.45;
flex: 0 0 auto;
}
.log-line.stderr .txt {
color: var(--na-err);
}
.log-line.system .txt {
color: var(--na-info);
}
</style>
+172
View File
@@ -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>
+235
View File
@@ -0,0 +1,235 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import {
NCard,
NInput,
NButton,
NSpace,
NTag,
NEmpty,
NText,
NInputGroup,
NCollapse,
NCollapseItem,
NPopconfirm,
useMessage,
} from "naive-ui";
import { useConnStore } from "../stores/conn";
import { useNodeStore } from "../stores/node";
const conn = useConnStore();
const nodeStore = useNodeStore();
const msg = useMessage();
const newName = ref("");
const newUrl = ref("ws://127.0.0.1:8765");
const newKey = ref("");
async function doConnect() {
try {
await conn.connect();
msg.success("连接成功");
} catch (e) {
msg.error(`连接失败:${e}`);
}
}
async function connectTo(id: string) {
const n = nodeStore.nodes.find((x) => x.id === id);
if (!n) return;
nodeStore.setCurrent(id);
try {
await conn.connect(n.url, n.key);
msg.success(`已连接 ${n.name}`);
} catch (e) {
msg.error(`连接失败:${e}`);
}
}
function addNode() {
if (!newName.value || !newUrl.value) {
msg.warning("请填写名称与地址");
return;
}
nodeStore.add({ name: newName.value, url: newUrl.value, key: newKey.value });
newName.value = "";
newKey.value = "";
msg.success("已保存设备");
}
/** 能力按前缀分组,便于核对。 */
const capGroups = computed(() => {
const groups: Record<string, string[]> = {};
for (const c of conn.capabilities) {
const prefix = c.name.split(".")[0] ?? "other";
(groups[prefix] ??= []).push(c.name);
}
return groups;
});
</script>
<template>
<div class="grid">
<n-card title="连接被控端" size="small">
<n-space vertical>
<n-input-group>
<n-input v-model:value="conn.url" placeholder="ws://127.0.0.1:8765" style="flex: 3" />
<n-input
v-model:value="conn.key"
type="password"
show-password-on="click"
placeholder="PSK 预共享密钥"
style="flex: 2"
/>
<n-button type="primary" :loading="conn.connecting" @click="doConnect">连接</n-button>
</n-input-group>
<div v-if="conn.info" class="meta">
<n-tag type="success" size="small" round>已连接</n-tag>
<n-text depth="3">agent {{ conn.info.agent_version ?? "?" }}</n-text>
<n-text depth="3">
授权 {{ conn.info.authorized ? `${conn.info.authorized.length} 项` : "全部" }}
</n-text>
<n-text depth="3" class="mono">
指纹 {{ (conn.info.peer_cert_fp ?? "-").slice(0, 16) }}…
</n-text>
</div>
<n-empty :show-icon="false" v-else description="尚未连接" size="small" />
</n-space>
</n-card>
<n-card title="已保存设备" size="small">
<template #header-extra>
<n-text depth="3" style="font-size: 12px">{{ nodeStore.nodes.length }} 台</n-text>
</template>
<div v-if="nodeStore.nodes.length" class="nodes">
<div
v-for="n in nodeStore.nodes"
:key="n.id"
class="node"
:class="{ active: nodeStore.currentId === n.id }"
>
<div class="node-main">
<div class="node-name">{{ n.name }}</div>
<n-text depth="3" class="mono" style="font-size: 12px">{{ n.url }}</n-text>
</div>
<n-space>
<n-button size="tiny" type="primary" @click="connectTo(n.id)">连接</n-button>
<n-popconfirm :show-icon="false" @positive-click="nodeStore.remove(n.id)">
<template #trigger>
<n-button size="tiny" quaternary>删除</n-button>
</template>
确认删除该设备?
</n-popconfirm>
</n-space>
</div>
</div>
<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" />
<n-input v-model:value="newUrl" placeholder="地址" size="small" style="flex: 2" />
<n-input v-model:value="newKey" placeholder="PSK" size="small" style="flex: 1" />
<n-button size="small" @click="addNode">保存</n-button>
</div>
</n-card>
<n-card title="能力清单" size="small">
<template #header-extra>
<n-text depth="3" style="font-size: 12px">{{ conn.capabilities.length }} 项</n-text>
</template>
<n-collapse v-if="conn.capabilities.length" :default-expanded-names="Object.keys(capGroups)">
<n-collapse-item
v-for="(caps, prefix) in capGroups"
:key="prefix"
:name="prefix"
:title="`${prefix}.* (${caps.length})`"
>
<div class="cap-list">
<code v-for="c in caps" :key="c">{{ c }}</code>
</div>
</n-collapse-item>
</n-collapse>
<n-empty :show-icon="false" v-else description="连接后展示" size="small" />
</n-card>
<n-card title="日志" size="small">
<template #header-extra>
<n-button size="tiny" quaternary @click="conn.clearLogs()">清空</n-button>
</template>
<div class="logs">
<div v-for="(l, i) in conn.logs.slice().reverse()" :key="i">{{ l }}</div>
<div v-if="conn.logs.length === 0" class="logs-empty">暂无日志</div>
</div>
</n-card>
</div>
</template>
<style scoped>
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
align-items: start;
}
.grid > :nth-child(3),
.grid > :nth-child(4) {
grid-column: span 2;
}
.meta {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
font-size: 13px;
}
.mono {
font-family: ui-monospace, Menlo, monospace;
}
.nodes {
display: flex;
flex-direction: column;
gap: 8px;
}
.node {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border: 1px solid var(--na-border);
border-radius: 8px;
}
.node.active {
border-color: var(--na-accent);
}
.node-name {
font-size: 14px;
margin-bottom: 2px;
}
.add-row {
display: flex;
gap: 8px;
margin-top: 12px;
}
.cap-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.cap-list code {
font-size: 12px;
padding: 2px 8px;
border-radius: 4px;
background: var(--na-sunken);
}
.logs {
font-family: ui-monospace, Menlo, monospace;
font-size: 12px;
line-height: 1.7;
opacity: 0.85;
max-height: 220px;
overflow: auto;
}
.logs-empty {
opacity: 0.4;
}
</style>
+200
View File
@@ -0,0 +1,200 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from "vue";
import {
NCard,
NGrid,
NGridItem,
NProgress,
NStatistic,
NButton,
NSpace,
NEmpty,
NText,
NSwitch,
NTag,
useMessage,
} from "naive-ui";
import { useConnStore } from "../stores/conn";
import type { DiskInfo, SystemInfo, SystemStatus } from "../types";
import { formatBytes, formatDuration } from "../utils/format";
const conn = useConnStore();
const msg = useMessage();
const info = ref<SystemInfo | null>(null);
const status = ref<SystemStatus | null>(null);
const auto = ref(true);
let timer: number | undefined;
/** 契约只给 total/free,已用量需自算。 */
function usedOf(d: DiskInfo): number | undefined {
if (d.total == null || d.free == null) return undefined;
return Math.max(0, d.total - d.free);
}
async function refresh() {
if (!conn.connected) return;
try {
const [i, s] = await Promise.all([
conn.ok<SystemInfo>("system.info"),
conn.ok<SystemStatus>("system.status"),
]);
info.value = i;
status.value = s;
} catch (e) {
msg.error(`刷新失败:${e}`);
}
}
function toggleAuto(v: boolean) {
auto.value = v;
if (v) start(); else stop();
}
function start() {
stop();
timer = window.setInterval(refresh, 3000);
}
function stop() {
if (timer) window.clearInterval(timer);
timer = undefined;
}
onMounted(() => {
if (conn.connected) refresh();
if (auto.value) start();
});
onUnmounted(stop);
</script>
<template>
<div v-if="!conn.connected">
<n-empty :show-icon="false" description="请先在「设备管理」页连接被控端" style="margin-top: 80px" />
</div>
<div v-else class="wrap">
<n-space justify="space-between" align="center" style="margin-bottom: 14px">
<n-space align="center">
<n-text depth="3">自动刷新(3s)</n-text>
<n-switch :value="auto" size="small" @update:value="toggleAuto" />
</n-space>
<n-button size="small" @click="refresh">手动刷新</n-button>
</n-space>
<n-grid :cols="4" :x-gap="16" :y-gap="16" item-responsive responsive="screen">
<n-grid-item span="4 s:2">
<n-card size="small" title="主机">
<n-statistic label="主机名" :value="info?.hostname ?? '-'" />
<div class="kv"><span>系统</span><b>{{ info?.os }} {{ info?.os_version }}</b></div>
<div class="kv"><span>架构</span><b>{{ info?.arch }}</b></div>
<div class="kv"><span>CPU</span><b>{{ info?.cpu_model }}</b></div>
<div class="kv"><span>核心</span><b>{{ info?.cpu_cores }}</b></div>
<div class="kv"><span>管理员</span><b>{{ info?.is_admin ? "是" : "否" }}</b></div>
<div class="kv"><span>开机</span><b>{{ formatDuration(info?.uptime_sec) }}</b></div>
</n-card>
</n-grid-item>
<n-grid-item span="4 s:2">
<n-card size="small" title="资源">
<div class="gauge">
<div class="gauge-label">CPU 使用率</div>
<n-progress
type="line"
:percentage="Math.round(status?.cpu_pct ?? 0)"
:height="10"
:color="(status?.cpu_pct ?? 0) > 80 ? 'var(--na-err)' : 'var(--na-ok)'"
/>
</div>
<div class="gauge">
<div class="gauge-label">
内存 {{ formatBytes(status?.memory_used) }} / {{ formatBytes(status?.memory_total) }}
</div>
<n-progress
type="line"
:percentage="Math.round(status?.memory_pct ?? 0)"
:height="10"
:color="(status?.memory_pct ?? 0) > 85 ? 'var(--na-err)' : 'var(--na-ok)'"
/>
</div>
<n-statistic
label="内存总量"
:value="formatBytes(info?.memory_total ?? status?.memory_total)"
/>
</n-card>
</n-grid-item>
<n-grid-item span="4">
<n-card size="small" title="磁盘">
<div v-if="status?.disks?.length" class="disks">
<div v-for="(d, i) in status.disks" :key="i" class="disk">
<div class="gauge-label">
{{ d.drive ?? "-" }}
<span class="dim">
{{ formatBytes(usedOf(d)) }} / {{ formatBytes(d.total) }} · 剩余
{{ formatBytes(d.free) }}
</span>
</div>
<n-progress
type="line"
:percentage="Math.round(d.used_pct ?? 0)"
:height="8"
:color="(d.used_pct ?? 0) > 90 ? 'var(--na-err)' : 'var(--na-ok)'"
/>
</div>
</div>
<n-empty :show-icon="false" v-else description="无磁盘数据" size="small" />
</n-card>
</n-grid-item>
<n-grid-item span="4">
<n-card size="small" title="网络适配器">
<div v-if="status?.net?.length" class="net">
<div v-for="(a, i) in status.net" :key="i" class="kv">
<span>{{ a.adapter ?? "-" }}</span>
<b>
{{ a.ip || "-" }}
<n-tag :type="a.up ? 'success' : 'default'" size="tiny" style="margin-left: 6px">
{{ a.up ? "已连接" : "未连接" }}
</n-tag>
</b>
</div>
</div>
<n-empty :show-icon="false" v-else description="无网络数据" size="small" />
</n-card>
</n-grid-item>
</n-grid>
</div>
</template>
<style scoped>
.wrap {
max-width: 1100px;
}
.kv {
display: flex;
justify-content: space-between;
font-size: 13px;
padding: 3px 0;
}
.kv span {
opacity: 0.6;
}
.gauge {
margin-bottom: 14px;
}
.gauge-label {
font-size: 13px;
margin-bottom: 6px;
opacity: 0.8;
}
.dim {
opacity: 0.5;
font-size: 12px;
margin-left: 8px;
}
.disks {
display: flex;
flex-direction: column;
gap: 12px;
}
</style>
Loaded 100 of 169 files, more files were not shown because too many files have changed in this diff. Show more