29 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
89 changed files with 8461 additions and 356 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
+44 -2
View File
@@ -12,11 +12,17 @@ on:
paths:
- 'apps/console/src-tauri/**'
- 'apps/console/sidecar/**'
- 'scripts/prepare-console.mjs'
- 'scripts/lib/bundle-agent.mjs'
- 'apps/agent/**'
- '.github/workflows/rust.yml'
pull_request:
paths:
- 'apps/console/src-tauri/**'
- 'apps/console/sidecar/**'
- 'scripts/prepare-console.mjs'
- 'scripts/lib/bundle-agent.mjs'
- 'apps/agent/**'
- '.github/workflows/rust.yml'
workflow_dispatch:
@@ -58,14 +64,50 @@ jobs:
version: 9
- run: pnpm install --frozen-lockfile
- name: 构建 workspace 包(sidecar 依赖其 dist)
run: pnpm -r --filter "./packages/*" build
# ⚠️ 顺序有依赖:prepare:node 现在**还要 esbuild 打包被控端**(控制台随包分发
# agent.mjs,用于托起本机被控端)。故 @nodeagent/agent 的 dist 必须先构建出来,
# 否则 prepare 阶段会以「未找到被控端构建产物」失败。
# (本项目已因「新步骤漏排前置产物」在 Windows CI 上红过一次,此处显式写全。)
- name: 构建 workspace 包(sidecar 与 agent 的 dist 都是前置产物)
run: pnpm --filter "./packages/*" --filter "@nodeagent/agent" build
# ⚠️ tauri-build 在**编译期**校验 tauri.conf.json 里 resources 的文件存在性,
# 故必须先产出 sidecar.mjs,否则 clippy/test 会在 build.rs 阶段就失败。
- name: 构建 sidecar(resources 引用的产物)
run: pnpm --filter nodeagent-console sidecar:build
# 随包分发的 Node 运行时 + 被控端 agent.mjs 由该脚本准备;它同时产出
# 「构建期注入片段」。在此真跑一遍:既验证脚本本身可用(含两份产物自检:
# node 必须真能执行、agent.mjs 必须含被控端标识),也验证注入片段声明就绪。
# 注意 Linux 上不会走 strip 分支(那是 macOS 专属,见脚本内注释),
# 故「strip 后必须 adhoc 重签」这条由 macOS 本机实测覆盖,CI 不重复验证。
- name: 准备 Node 运行时 + 被控端(验证 prepare-console.mjs)
run: pnpm --filter nodeagent-console prepare:node
- name: 注入片段应声明三份资源(node + sidecar + agent)
run: |
node -e '
const g = require("./apps/console/src-tauri/tauri.bundle.gen.json");
const r = g.bundle.resources;
const keys = Object.keys(r);
if (keys.length !== 3) { console.error("资源项应为 3,实际", keys); process.exit(1); }
const vals = Object.values(r);
for (const need of ["sidecar.mjs", "agent.mjs"]) {
if (!vals.includes(need)) { console.error("缺少资源", need, vals); process.exit(1); }
}
if (!vals.includes("node") && !vals.includes("node.exe")) {
console.error("缺少 node 运行时", vals); process.exit(1);
}
console.log("✓", JSON.stringify(r));
'
# 更强的一道:不是「文件在不在」,而是「产物能不能自举」——
# 真 spawn 打包内的 node 跑 sidecar 并走一次 JSON-RPC 握手,
# 并用打包内的 node 解析一次 agent.mjs(截断/损坏会在此暴露)。
# 这类问题只在产物中暴露(源码与单测都正常)。
- name: 验证运行时 + sidecar + agent 可自举
run: node apps/console/scripts/verify-bundle.mjs --runtime
- name: 缓存 cargo 构建产物
uses: Swatinem/rust-cache@v2
with:
+31
View File
@@ -48,6 +48,20 @@ jobs:
- name: 类型检查
run: pnpm typecheck
# 控制台「随包分发的 Node 运行时 + 被控端 agent.mjs」在 Windows 上的分支
# 此前零覆盖(准备脚本本机只在 macOS 上跑过)。此处真跑:复制 node.exe(无 strip
# 分支)→ 自检可执行 → 再验证它与 sidecar 能否走通一次 JSON-RPC 自举,
# 并用同一个 node 解析 agent.mjs。
# ⚠️ 必须先 sidecar:build —— prepare-console.mjs 的前置检查要求 sidecar.mjs 存在
# (首次推送时漏了这步,CI 在 Windows 上当场报「未找到 sidecar/dist/sidecar.mjs」)。
# agent.mjs 的前置是 `pnpm -r build`(上面的「构建」步骤已覆盖)。
- name: 控制台 Node 运行时 + 被控端(Windows 分支)与自举验证
run: |
pnpm --filter nodeagent-console sidecar:build
pnpm --filter nodeagent-console prepare:node
node apps/console/scripts/verify-bundle.mjs --runtime
- name: 单元测试 — 协议纯函数 / 清单守护 / 脚本生成
# 来自 ci/workflow-unit-tests 分支(4d73d6e)的实质贡献;该分支基于老 main,
# 直接 merge 会回退上万行,故只取其提交内容手工并入。
@@ -141,6 +155,23 @@ jobs:
}
Write-Host "OK - package complete; .ps1 has BOM / .cmd has none; no key shipped"
} finally { $z.Dispose() }
- name: 冒烟 — 双架构包(node.exe PE 架构校验)
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
# 真机教训(Parallels 27 + Win11 ARM):曾出现 arm64 包里装的其实是 x64 的 node.exe
# —— 两份包的 node.exe 字节完全相同。x64 node 在 ARM Windows 上**能 listen,
# 但 TLS 握手不完成**(客户端只见超时)→ 被控端实际不可用,且 macOS 侧 100% 测不出。
# 根因是 pack.mjs 的运行时缓存文件名不含架构。这里直接验**最终产物**的 PE 架构,
# 比只验脚本内部逻辑更靠近交付物(缓存串包 / 手工塞错文件都会被这一步拦下)。
# 校验实现与发版脚本共用(scripts/lib/zip-pe-arch.mjs)—— 避免两套实现各自漂移。
foreach ($arch in @('x64','arm64')) {
node scripts/pack.mjs --arch $arch
}
foreach ($arch in @('x64','arm64')) {
node scripts/lib/zip-pe-arch.mjs "release/nodeagent-win-$arch.zip" $arch
if ($LASTEXITCODE -ne 0) { throw "node.exe 架构校验失败($arch)—— 严禁发布该包" }
}
- name: 冒烟 — 被控端监听
shell: pwsh
run: |
+398
View File
@@ -5,6 +5,404 @@
---
## [未发布]
> 主题:**控制台「去命令行化」** —— 双击应用即用,启停/重启/更新/日志/托盘常驻/开机自启全部在界面里完成。
> 目标原文:「不再打开命令行窗口,运行应用之后,有启动、停止、重启、检查更新等功能,
> 有日志功能,可以后台运行和开机自启等等功能」。
>
> 末段另含两轮纯界面调整:**一律不使用图标**(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 项待验证项的过程中,
+35 -4
View File
@@ -343,8 +343,17 @@ nodeagent pull "C:\big.iso" --out ./big.iso # 下载(大文件自动
协议客户端由 **Node sidecar**(`apps/console/sidecar/`)承担(复用 `@nodeagent/client`,
Tauri 侧经 Cargo path 依赖),**不引入第二套协议实现**。
**打开即用、全程不碰命令行**:应用自带 Node 运行时与被控端脚本,
默认页面就是「本机服务」——点按钮即可启动 / 停止 / 重启本机被控端,
并支持检查更新、查看日志、后台常驻(托盘)、开机自启。
**界面**:导航为**纯文字**(不使用图标);配色为两套中性灰阶主题 ——
「简约白」与「高级灰」,侧栏底部一键切换。
| 页面 | 能力 |
|---|---|
| **本机服务**(默认) | 本机被控端启停/重启、连接本机、节点 ID 与密钥、开机自启、后台运行、异常自动重启、检查并应用更新 |
| **服务日志** | 被控端 stdout/stderr 实时推送、关键字过滤、暂停接收、导出、清空 |
| 设备管理 | 连接/断开、保存多台设备、能力清单与握手元信息、实时日志 |
| 状态总览 | CPU / 内存 / 磁盘 / 网卡,3s 轮询 |
| 终端 | `system.shell.exec`(可设超时)、历史回放 |
@@ -354,15 +363,37 @@ Tauri 侧经 Cargo path 依赖),**不引入第二套协议实现**。
| 审计 | `system.audit.list` 筛选 + `system.audit.verify` 链完整性校验 + 导出 JSONL |
```bash
pnpm -r build # 含 console 前端
cd apps/console && pnpm tauri dev # 开发态(起 Vite + Tauri 窗口)
cd apps/console && pnpm tauri build # 打包(macOS 实测 .app ≈ 6.8 MiB)
pnpm -r build # 含 console 前端
cd apps/console && pnpm tauri dev # 开发态(起 Vite + Tauri 窗口)
pnpm --filter nodeagent-console bundle # 打包(含 sidecar + Node 运行时 + 被控端脚本)
# macOS 实测:.app 92.2 MiB / .dmg 33.78 MiB(分发件,其中 Node 运行时 86MB)
```
Windows 版由 CI 产出(`.github/workflows/console-windows.yml`),**双架构各一套**:
| 架构 | runner | 安装包 | 实测体积 |
|---|---|---|---|
| x64 | `windows-latest` | `.msi`(企业批量部署)+ `-setup.exe`(NSIS,双击安装) | 34.2 MiB / 23.2 MiB |
| arm64 | `windows-11-arm` | `-setup.exe`(NSIS) | 19.5 MiB |
> 为什么必须两套:控制台内含 Node 运行时,且要用它**托起本机被控端**(即自己就是 TLS 服务端)。
> 若在 ARM Windows 上装到 x64 的运行时,会**能启动、能监听,但 TLS 握手不完成** ——
> 客户端只见超时,且开发机上 100% 复现不出。CI 里为此加了三道断言:
> 「构建机架构 ⇄ 目标架构」、「随包 `node.exe` 的 PE Machine」、
> 以及**解开安装包查内容**(三样随包资源齐全 + 包内 `node.exe` 的 PE Machine 对得上 ——
> 前两条查的是打包**前**的中间产物,这一条查的才是**最终交付物**;
> 实测解出 x64 `node.exe` 82.9 MB / `0x8664`、arm64 73.5 MB / `0xaa64`)。
> 控制台安装包目前**只作为 CI artifact 产出**,尚未接入 `scripts/release.mjs` 随 Release 发布。
⚠️ 平台差异:`app.*` 与 `system.service.list` **仅在被控端为 Windows 时可用**,
非 Windows 被控端会返回 `E_UNSUPPORTED_PLATFORM`。控制台连接后会探测
`system.info.os` 并**事前禁用**这些入口,而不是发出注定失败的请求。
⚠️ 控制台**已自带** Node 运行时与被控端脚本,目标机器无需预装 Node.js,也无需先手工安装被控端;
但**开机自启与被控端的图形能力**要求运行在**登录用户会话**中(Windows 计划任务用
`Interactive` + 登录用户;SYSTEM 会话 0 没有交互桌面,截图/窗口类能力会失败)。
## 安全模型
三层防护,逐层收窄权限:
@@ -640,7 +671,7 @@ nodeagent/
| **批次 15** | `screen.find` **图像模板匹配**(纯图标/无文字控件盲区)+ 一堆真机根因修复 | ✅ |
| **批次 16 ~ 26** | 网络两阶段提交 · 音频控制 · 构建指纹 · 自更新 · 证书钉住 · 每客户端 PSK · 连接层三防护 · 零信任安装 · 审计锚定 | ✅ |
| **批次 27 ~ 29** | **语义可断言**(`gui.await` + UIA 属性 + `where` 谓词 + `any_of`)· **状态可回看**(`log.query` + `monitor.*`) | ✅ |
| **发布** | **v2.0.0**(2026-10-10):51 项能力 · 273 单测 · Windows/Rust CI 全绿 | ✅ 已发布 |
| **发布** | **v2.0.2**(2026-10-11):51 项能力 · 274 单测 · Windows/Rust CI 全绿 —— 本版为**资产修复**(v2.0.1 的 `arm64` 包内实为 x64 node,详见 CHANGELOG) | ✅ 已发布 |
## 文档
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/agent",
"version": "2.0.1",
"version": "2.0.3",
"private": true,
"type": "module",
"main": "./dist/index.js",
+32 -1
View File
@@ -5,6 +5,7 @@ import { agentDir } from './config.js';
import {
judgeAnchors,
parseAnchorsFromJsonl,
AUDIT_TYPES,
type AnchorComparison,
type AnchorRecord,
} from '@nodeagent/protocol';
@@ -25,7 +26,37 @@ export type AuditType =
/** v19:agent 自更新(拉取式) */
| 'agent.update'
/** v2.0.0:连接空闲被主动断开 */
| 'session.idle_close';
| 'session.idle_close'
/**
* v2.0.3:**被拒的请求**统一留痕。
*
* 为什么单独一个类型:原来「畸形报文 / 未知方法 / 未认证调用 / 参数非法 / 能力不存在」
* 这类拒绝**完全不留痕**(U-01)—— 被扫描、被爆破、被误用时事后无从复盘。
* 归到 `invoke` 会污染正常调用统计,归到 `acl.denied` 又不准确(很多拒绝与 ACL 无关)。
*/
| 'request.rejected'
/**
* v2.0.3:**进程级未捕获错误 / 未处理 Promise 拒绝**。
*
* 为什么单独一个类型:这类错误是「最后一次兜底拦截」(`index.ts` 的
* `unhandledRejection` / `uncaughtException`)。既然选择了**不退出进程**,
* 就必须留下证据 —— 归到 `request.rejected` 会让「被扫描」与「我们自己有 bug」
* 混在同一类里,事后无法区分。
*/
| 'agent.error';
/**
* 编译期守卫(v2.0.3,修 C-01):本文件这份联合类型必须与 `@nodeagent/protocol` 的
* `AUDIT_TYPES` **双向一致**。
*
* 少了 → 「写进日志但协议不认」(控制台筛不到,用户以为没发生);
* 多了 → 「协议认但从不写」(下拉框里是个永远为空的选项)。
* 两种漂移都**不报错**,所以必须让 `tsc` 来说话。真正的真源在
* `packages/protocol/src/audit-types.ts`,此处保留逐条注释只为记录「哪个版本引入的」。
*/
type AssertExact<A, B> = [A] extends [B] ? ([B] extends [A] ? true : never) : never;
const _auditTypeInSyncWithProtocol: AssertExact<AuditType, (typeof AUDIT_TYPES)[number]> = true;
void _auditTypeInSyncWithProtocol;
/** 一条审计记录(JSONL 单行)。 */
export interface AuditEntry {
+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),
+5 -2
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>;
@@ -344,7 +345,7 @@ export async function macScreenFind(args: Args): Promise<unknown> {
try {
for (;;) {
const cap = await execCommand({
command: `screencapture -x -t png -R ${captureRegion.x},${captureRegion.y},${captureRegion.width},${captureRegion.height} ${JSON.stringify(shot)}`,
command: `screencapture -x -t png -R ${captureRegion.x},${captureRegion.y},${captureRegion.width},${captureRegion.height} ${shLit(shot)}`,
timeoutMs: 30_000,
});
if (cap.exit_code !== 0) {
@@ -359,8 +360,10 @@ export async function macScreenFind(args: Args): Promise<unknown> {
const scale =
img && captureRegion.width > 0 ? img.width / captureRegion.width : 1;
// ⚠️ `text` 来自控制端,`bin`/`shot` 是内部路径 —— 一律 shLit 包成 shell 单引号字面量。
// 曾用 JSON.stringify(双引号)→ `$(touch /tmp/p)` 会被 bash 执行(P1 D-01,实测复现)。
const r = await execCommand({
command: `${JSON.stringify(bin)} ${JSON.stringify(shot)} ${JSON.stringify(text)}`,
command: `${shLit(bin)} ${shLit(shot)} ${shLit(text)}`,
timeoutMs: 60_000,
maxOutputBytes: 4 * 1024 * 1024,
});
+56 -7
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,9 +27,49 @@ const HASH_LIMIT_BYTES = 8 * 1024 * 1024;
let allowedRoots: string[] = [];
export function setFsRoots(roots: string[] | undefined): void {
allowedRoots = (roots ?? []).map((r) => resolve(r));
// 白名单根也做一次 realpath 归一:否则「配置里写的是软链」会与后面 realpath 后的路径对不上
allowedRoots = (roots ?? []).map((r) => realPathBestEffort(resolve(r)));
}
/**
* 尽力取真实路径(解析符号链接)。
*
* - 路径存在 → 直接 `realpathSync`
* - 不存在(写入场景很常见)→ 向上找到**最近的已存在祖先**做 realpath,再把剩余段拼回去
*
* 这样即便目标文件本身还不存在,也能正确判断「它最终会落在哪里」,
* 从而拦住「用软链接把访问点引到白名单之外」这一类绕过。
*/
function realPathBestEffort(abs: string): string {
try {
return realpathSync.native(abs);
} catch {
/* 继续向上找 */
}
const parts: string[] = [];
let cur = abs;
for (;;) {
const parent = dirname(cur);
if (parent === cur) return abs; // 到根了仍不存在 → 原样返回
parts.unshift(cur.slice(parent.length + (parent.endsWith(sep) ? 0 : 1)));
try {
const base = realpathSync.native(parent);
return parts.length > 0 ? join(base, ...parts) : base;
} catch {
cur = parent;
}
}
}
/**
* 路径白名单守卫(v5)。
*
* ⚠️ 两道防线,缺一不可:
* 1. **解析符号链接后的真实路径**再做包含判断 —— 只比较字面路径时,
* `白名单/a_软链 → /etc` 这样的路径能直接穿出去(绕过 `fs_roots`)。
* 2. 前缀比较必须带上分隔符(`/root` 不得匹配 `/rootX`),且大小写按平台归一
* (Windows 文件系统不区分大小写)。
*/
export function guardPath(input: string): string {
if (typeof input !== 'string' || input.length === 0) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'path 不能为空');
@@ -36,14 +77,20 @@ export function guardPath(input: string): string {
const abs = resolve(input);
if (allowedRoots.length === 0) return abs;
const ok = allowedRoots.some((root) => {
if (abs === root) return true;
const real = realPathBestEffort(abs);
const norm = (p: string): string => (process.platform === 'win32' ? p.toLowerCase() : p);
const target = norm(real);
const ok = allowedRoots.some((rootRaw) => {
const root = norm(rootRaw);
if (target === root) return true;
const prefix = root.endsWith(sep) ? root : root + sep;
return abs.startsWith(prefix);
return target.startsWith(prefix);
});
if (!ok) {
throw new CapabilityError(ErrorCodes.ACL_DENIED, `路径不在允许范围内: ${abs}`, {
allowed_roots: allowedRoots,
resolved: real,
});
}
return abs;
@@ -207,8 +254,10 @@ export async function fsWrite(args: Args): Promise<unknown> {
if (append) {
appendFileSync(p, buf);
} else {
// 原子写:先写临时文件再重命名,避免中途失败留下半截文件
const tmp = `${p}.nodeagent-tmp-${process.pid}`;
// 原子写:先写临时文件再重命名,避免中途失败留下半截文件。
// ⚠️ 临时名必须**唯一**:原来只用 PID,同一进程内并发写同一路径(或两个会话同时写)
// 会互相踩对方的 tmp(先完成的 rename 走后,后一个 renameSync 会 ENOENT 失败)。
const tmp = `${p}.nodeagent-tmp-${process.pid}-${randomBytes(4).toString('hex')}`;
writeFileSync(tmp, buf);
renameSync(tmp, p);
}
+9
View File
@@ -19,6 +19,15 @@ import { logQuery, monitorStart, monitorReport, monitorStop, monitorList, monito
export interface HandlerContext {
/** 连接标识(事件订阅归属 / 断开清理) */
owner: string;
/**
* 调用方身份(`client_id`)。用于**跨控制端隔离**:
* 事件订阅的 list / unwatch / poll 只暴露给「创建它的那个 client_id」。
*
* 为什么按 client_id 而不是连接:同一次工作的多个连接(如 MCP 每次调用新建连接、
* CLI 的 watch 与 poll 分属两个进程)必须能互相看到自己的订阅,否则语义就断了。
* psk 模式下 client_id 由客户端自报(配置级约束);ed25519 + 每客户端独立 PSK 下身份才被真正绑定。
*/
clientId?: string;
/** 向该连接推送事件 */
emit: (evt: unknown) => void;
}
+27 -8
View File
@@ -6,6 +6,7 @@ import {
buildPostBody,
} from './input-script.js';
import { IS_WINDOWS, execCommand } from '../util/exec.js';
import { PS_DPI_AWARE } from '../util/dpi.js';
type Args = Record<string, unknown>;
@@ -213,15 +214,31 @@ public static class NAInput {
}
}`;
/** 组装:加类型定义 + 具体操作。 */
function psScript(body: string[]): string {
return [`$ErrorActionPreference = 'Stop'`, `$code = @'`, CS_CODE, `'@`, `Add-Type -TypeDefinition $code`, ...body].join(
'\n',
);
/**
* 组装:加 DPI 感知 + 类型定义 + 具体操作。
*
* 导出**仅供单测断言**(`tests/unit/dpi-awareness.test.mjs` 直接检查这里下发的
* PowerShell 是否带上了 DPI 感知、且位于坐标调用之前)——真机 2026-10-12 的
* 「点不准」缺陷就是这里漏了 DPI 感知,而源码扫描式守卫抓不到。
*/
export function buildInputPsScript(body: string[]): string {
return [
`$ErrorActionPreference = 'Stop'`,
// ⚠️ 必须最先执行,且**不可删**:不设 DPI 感知时 `SetCursorPos` / `mouse_event` 的坐标
// 会被系统按缩放比虚拟化(200% 下 ×2),而 `screen.find`(UIA)返回的是**物理**坐标 ——
// 「找得到、点不准」,且不报任何错。真机 2026-10-12 实证:
// 请求 (100,100) 实际落到 (200,200),请求 (552,1489) 实际落到 (1104,2019)。
PS_DPI_AWARE,
`$code = @'`,
CS_CODE,
`'@`,
`Add-Type -TypeDefinition $code`,
...body,
].join('\n');
}
async function runPs(body: string[], timeoutMs = 30_000): Promise<string> {
const r = await execCommand({ command: psScript(body), timeoutMs });
const r = await execCommand({ command: buildInputPsScript(body), timeoutMs });
if (r.exit_code !== 0) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '输入注入失败', {
detail: (r.stderr || r.stdout).slice(0, 400),
@@ -285,9 +302,11 @@ export async function mouseClick(args: Args): Promise<unknown> {
}
await runPs(body);
// 返回实际点击位置
// 返回实际点击位置。
// ⚠️ 读回光标位置同样要 DPI 感知,否则这里读到的是**逻辑**值(200% 下是真实值的一半),
// 于是「主叫方传物理坐标 → 返回被腰斩的坐标」,看起来像点错位置。真机 2026-10-12 排查。
const pos = await execCommand({
command: `Add-Type -AssemblyName System.Windows.Forms; $p = [System.Windows.Forms.Cursor]::Position; "{0},{1}" -f $p.X, $p.Y`,
command: `${PS_DPI_AWARE}; Add-Type -AssemblyName System.Windows.Forms; $p = [System.Windows.Forms.Cursor]::Position; "{0},{1}" -f $p.X, $p.Y`,
timeoutMs: 20_000,
});
const [cx, cy] = pos.stdout.trim().split(',').map(Number);
+68 -21
View File
@@ -17,8 +17,8 @@
import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { connect } from 'node:net';
import { exec } from 'node:child_process';
import { join } from 'node:path';
import { exec, execFile } from 'node:child_process';
import { join, resolve as resolvePath, sep } from 'node:path';
import { homedir, totalmem, freemem, cpus } from 'node:os';
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { logQuery, type LogLine } from './log.js';
@@ -60,6 +60,35 @@ function monitorDir(): string {
return d;
}
/**
* 校验监控 id:**唯一入口**,四个命令(start/report/stop/delete)都必须先过它。
*
* ⚠️ M-01/M-02(真机实测):`monitor.report` / `monitor.delete` 曾经**不校验 id**
* 就直接 `join(monitorDir(), id + '.jsonl')` → `id:"../escape-target"` 可读/删
* 数据目录外的任意 `.jsonl` 文件(路径穿越)。
* 这里除了字符白名单,还做一次**解析后前缀比对**(`..` 与符号链接都拦得住)。
*/
function safeMonitorId(raw: unknown): string {
const id = typeof raw === 'string' ? raw.trim() : '';
if (!id) throw new CapabilityError(ErrorCodes.PARAM_INVALID, '需要 id');
if (!/^[\w.-]{1,64}$/.test(id) || id.includes('..')) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'id 只允许字母数字与 . _ -(≤64 字符,不含 ..)', {
got: id,
});
}
return id;
}
/** 监控样本文件路径 —— id 已在 safeMonitorId 校验过,这里再兜一次「必须在 monitors 目录内」。 */
function monitorFile(id: string): string {
const dir = resolvePath(monitorDir());
const file = resolvePath(dir, `${id}.jsonl`);
if (!file.startsWith(dir + sep)) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'id 解析后越出 monitors 目录', { id });
}
return file;
}
// ---------------- 采样器 ----------------
/** port:TCP 连通性 + 握手耗时(跨平台,无额外依赖)。 */
@@ -81,11 +110,11 @@ function samplePort(host: string, port: number, timeoutMs: number): Promise<Samp
/** process:按名判断存活(Windows tasklist / POSIX ps,与 events.ts 同策略)。 */
function sampleProcess(name: string): Promise<Sample> {
return new Promise((resolve) => {
const cmd =
process.platform === 'win32'
? `tasklist /FI "IMAGENAME eq ${name}" /NH`
: `ps -axo pid=,comm= | grep -i "${name}" | grep -v grep`;
exec(cmd, { timeout: 8000 }, (err, stdout) => {
// ⚠️ D-02(真机实测):原来把 `name` 拼进 `tasklist /FI "IMAGENAME eq ${name}"` 与
// `ps … | grep -i "${name}"`,payload `zzz"; touch /tmp/m…; #` 会被 shell 执行。
// 正解:**不经过 shell** —— Windows 用 execFile 传参数数组,POSIX 直接把 ps 输出
// 交给 JS 过滤(不再 grep)。
const collect = (stdout: string, err: Error | null): void => {
const lines = stdout
.split('\n')
.map((l) => l.trim())
@@ -96,7 +125,28 @@ function sampleProcess(name: string): Promise<Sample> {
return m ? Number(m[1]) : null;
})
.filter((x): x is number => x !== null);
resolve({ ts: Date.now(), alive: pids.length > 0, pids, ...(err && pids.length === 0 ? { error: String(err.message) } : {}) });
resolve({
ts: Date.now(),
alive: pids.length > 0,
pids,
...(err && pids.length === 0 ? { error: String(err.message) } : {}),
});
};
if (process.platform === 'win32') {
// 无 shell:name 作为单个参数传入,`"` `;` `&` 等都不再具特殊含义
execFile('tasklist', ['/FI', `IMAGENAME eq ${name}`, '/NH'], { timeout: 8000 }, (err, stdout) =>
collect(stdout ?? '', err),
);
return;
}
execFile('ps', ['-axo', 'pid=,comm='], { timeout: 8000 }, (err, stdout) => {
const needle = name.toLowerCase();
const filtered = (stdout ?? '')
.split('\n')
.filter((l) => l.toLowerCase().includes(needle))
.join('\n');
collect(filtered, err);
});
});
}
@@ -171,15 +221,15 @@ export async function monitorStart(args: Args): Promise<unknown> {
});
}
const id = (args['id'] as string | undefined)?.trim() || `m_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
const id =
(args['id'] as string | undefined)?.trim() ||
`m_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
safeMonitorId(id); // 与 report/delete 同一套校验(防路径穿越 + 统一口径)
if (monitors.has(id)) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `监控 id 已存在: ${id}`, { hint: '换个 id,或先 monitor.stop' });
}
if (!/^[\w.-]{1,64}$/.test(id)) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'id 只允许字母数字与 . _ -(≤64 字符)', { got: id });
}
const file = join(monitorDir(), `${id}.jsonl`);
const file = monitorFile(id);
const rec: MonitorRecord = {
id, source, target, intervalMs, createdAt: Date.now(), samples: 0, file,
timer: setInterval(() => { void tick(rec); }, intervalMs),
@@ -214,10 +264,9 @@ async function tick(rec: MonitorRecord): Promise<void> {
}
export async function monitorReport(args: Args): Promise<unknown> {
const id = args['id'] as string;
if (!id) throw new CapabilityError(ErrorCodes.PARAM_INVALID, '需要 id');
const id = safeMonitorId(args['id']);
const rec = monitors.get(id);
const file = join(monitorDir(), `${id}.jsonl`);
const file = monitorFile(id);
if (!rec && !existsSync(file)) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `没有这个监控: ${id}`, { running: [...monitors.keys()] });
}
@@ -313,8 +362,7 @@ function summarize(samples: Sample[], source: MonitorSource): Record<string, unk
}
export async function monitorStop(args: Args): Promise<unknown> {
const id = args['id'] as string;
if (!id) throw new CapabilityError(ErrorCodes.PARAM_INVALID, '需要 id');
const id = safeMonitorId(args['id']);
const rec = monitors.get(id);
if (!rec) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `没有正在运行的监控: ${id}`, { running: [...monitors.keys()] });
@@ -346,12 +394,11 @@ export async function monitorList(_args: Args): Promise<unknown> {
/** 供 monitor.delete:删除历史文件(不影响运行中的实例)。 */
export async function monitorDelete(args: Args): Promise<unknown> {
const id = args['id'] as string;
if (!id) throw new CapabilityError(ErrorCodes.PARAM_INVALID, '需要 id');
const id = safeMonitorId(args['id']);
if (monitors.has(id)) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, '该监控仍在运行,请先 stop', { hint: `monitor.stop {"id":"${id}"}` });
}
const file = join(monitorDir(), `${id}.jsonl`);
const file = monitorFile(id);
if (!existsSync(file)) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `没有历史文件: ${id}`);
}
+22 -21
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,
});
}
@@ -83,10 +84,10 @@ export function withBom(content: string): string {
* 曾经用 JSON.stringify(双引号)或手动把 \ 翻倍当转义,结果生成 `C:\\Users\\...`
* 这种非法路径 → Out-File 失败 → 计划任务退出码 1 且不留日志(真机踩过)。
* 需要字面量时一律走本函数;需要插值时用双引号但避免反斜杠。
*
* 注:实现已收敛到 `util/quote.ts`(单一真源),此处保留同名导出以兼容既有引用与测试。
*/
export function psLit(v: string): string {
return `'${v.replace(/'/g, "''")}'`;
}
export const psLit = psLitShared;
/**
* 生成「注册一次性计划任务」的 PowerShell 脚本(唯一实现,两处复用)。
@@ -173,7 +174,7 @@ export interface IfaceSnapshot {
async function snapshotIface(iface: string): Promise<IfaceSnapshot> {
const script = `
$ErrorActionPreference='Continue'
$a = ${JSON.stringify(iface)}
$a = ${psLit(iface)}
$o = [ordered]@{ alias = $a; dhcp = $false; ipv4 = @(); gateway = @(); dns = @() }
$ipif = Get-NetIPInterface -InterfaceAlias $a -AddressFamily IPv4 -ErrorAction SilentlyContinue
if ($ipif) { $o.dhcp = ($ipif.Dhcp -eq 'Enabled') }
@@ -210,7 +211,7 @@ $o | ConvertTo-Json -Compress -Depth 4
/** 由结构化快照生成**原生 cmdlet** 回滚脚本(不用 netsh set —— 它在 Win11 上会挂起)。 */
export function buildRollbackScript(snap: IfaceSnapshot, backupTxt: string, logPath: string): string {
const a = JSON.stringify(snap.alias);
const a = psLit(snap.alias);
const L = psLit(logPath);
const lines = [
`# nodeagent 自动回滚(生成于 ${new Date().toISOString()})`,
@@ -229,23 +230,23 @@ export function buildRollbackScript(snap: IfaceSnapshot, backupTxt: string, logP
// 同样「先加后清 + 先清默认路由」——否则回滚本身也会踩 already exists(真机事故根因)
for (const addr of snap.ipv4) {
lines.push(
` New-NetIPAddress -InterfaceAlias ${a} -IPAddress ${JSON.stringify(addr.ip)} -PrefixLength ${addr.prefix} -ErrorAction SilentlyContinue | Out-Null`,
` New-NetIPAddress -InterfaceAlias ${a} -IPAddress ${psLit(addr.ip)} -PrefixLength ${addr.prefix} -ErrorAction SilentlyContinue | Out-Null`,
);
}
lines.push(` Get-NetRoute -InterfaceAlias ${a} -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue | Remove-NetRoute -Confirm:$false -ErrorAction SilentlyContinue`);
const keep = snap.ipv4.map((x) => JSON.stringify(x.ip)).join(',');
const keep = snap.ipv4.map((x) => psLit(x.ip)).join(',');
lines.push(` $keep = @(${keep})`);
lines.push(` Get-NetIPAddress -InterfaceAlias ${a} -AddressFamily IPv4 -ErrorAction SilentlyContinue |`);
lines.push(` Where-Object { $keep -notcontains $_.IPAddress -and $_.PrefixOrigin -ne 'WellKnown' } |`);
lines.push(` ForEach-Object { Remove-NetIPAddress -IPAddress $_.IPAddress -InterfaceAlias ${a} -Confirm:$false -ErrorAction SilentlyContinue }`);
if (snap.gateway[0]) {
lines.push(
` New-NetRoute -InterfaceAlias ${a} -DestinationPrefix '0.0.0.0/0' -NextHop ${JSON.stringify(snap.gateway[0])} -ErrorAction SilentlyContinue | Out-Null`,
` New-NetRoute -InterfaceAlias ${a} -DestinationPrefix '0.0.0.0/0' -NextHop ${psLit(snap.gateway[0])} -ErrorAction SilentlyContinue | Out-Null`,
);
}
if (snap.dns.length > 0) {
lines.push(
` Set-DnsClientServerAddress -InterfaceAlias ${a} -ServerAddresses ${snap.dns.map((d) => JSON.stringify(d)).join(',')} -ErrorAction SilentlyContinue`,
` Set-DnsClientServerAddress -InterfaceAlias ${a} -ServerAddresses ${snap.dns.map((d) => psLit(d)).join(',')} -ErrorAction SilentlyContinue`,
);
}
lines.push(` W 'restored: static'`);
@@ -260,14 +261,14 @@ export function buildRollbackScript(snap: IfaceSnapshot, backupTxt: string, logP
async function readIfaceWindows(iface: string): Promise<Record<string, unknown>> {
const script = `
$ErrorActionPreference='Continue'
$c = Get-NetIPConfiguration -InterfaceAlias ${JSON.stringify(iface)} -ErrorAction SilentlyContinue
$c = Get-NetIPConfiguration -InterfaceAlias ${psLit(iface)} -ErrorAction SilentlyContinue
if (-not $c) { Write-Output '{}'; return }
$o = [ordered]@{ interface = ${JSON.stringify(iface)} }
$o = [ordered]@{ interface = ${psLit(iface)} }
$o.ipv4 = @($c.IPv4Address | ForEach-Object { $_.IPAddress })
$o.prefix = @($c.IPv4Address | ForEach-Object { $_.PrefixLength })
$o.gateway = @($c.IPv4DefaultGateway | ForEach-Object { $_.NextHop })
$o.dns = @($c.DNSServer | Where-Object { $_.AddressFamily -eq 2 } | ForEach-Object { $_.ServerAddresses } | Select-Object -First 4)
$dhcp = Get-NetIPInterface -InterfaceAlias ${JSON.stringify(iface)} -AddressFamily IPv4 -ErrorAction SilentlyContinue
$dhcp = Get-NetIPInterface -InterfaceAlias ${psLit(iface)} -AddressFamily IPv4 -ErrorAction SilentlyContinue
$o.dhcp = ($dhcp.Dhcp -eq 'Enabled')
$o | ConvertTo-Json -Compress
`;
@@ -436,7 +437,7 @@ export async function netApply(args: Args): Promise<unknown> {
* netsh 仅保留用于「读取」与人工恢复用的 dump 备份(对操作者友好)。
*/
export function buildApplyCommand(mode: string, iface: string, args: Args, snap: IfaceSnapshot): string {
const a = JSON.stringify(iface);
const a = psLit(iface);
if (mode === 'command') {
const cmd = args['command'] as string | undefined;
if (!cmd) throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'mode=command 需要 command 参数');
@@ -459,7 +460,7 @@ export function buildApplyCommand(mode: string, iface: string, args: Args, snap:
}
const prefix = maskToPrefix(mask);
const dns = (args['dns'] as string[] | undefined) ?? [];
const ipLit = JSON.stringify(ip);
const ipLit = psLit(ip);
const lines = [
`$ErrorActionPreference='Continue'`,
// 关掉 DHCP(否则设静态地址会与 DHCP 租约打架)
@@ -477,12 +478,12 @@ export function buildApplyCommand(mode: string, iface: string, args: Args, snap:
];
if (gateway) {
lines.push(
`New-NetRoute -InterfaceAlias ${a} -DestinationPrefix '0.0.0.0/0' -NextHop ${JSON.stringify(gateway)} -ErrorAction SilentlyContinue | Out-Null`,
`New-NetRoute -InterfaceAlias ${a} -DestinationPrefix '0.0.0.0/0' -NextHop ${psLit(gateway)} -ErrorAction SilentlyContinue | Out-Null`,
);
}
if (dns.length > 0) {
lines.push(
`Set-DnsClientServerAddress -InterfaceAlias ${a} -ServerAddresses ${dns.map((d) => JSON.stringify(d)).join(',')} -ErrorAction SilentlyContinue`,
`Set-DnsClientServerAddress -InterfaceAlias ${a} -ServerAddresses ${dns.map((d) => psLit(d)).join(',')} -ErrorAction SilentlyContinue`,
);
}
lines.push(`Write-Output 'static applied'`);
@@ -495,10 +496,10 @@ export function buildApplyCommand(mode: string, iface: string, args: Args, snap:
* 而调用方还以为变更成功了(真机事故:机器掉到 APIPA 彻底失联)。
*/
export function buildSelfCheck(iface: string, expectIp: string | null, rollbackScriptPath: string): string {
const a = JSON.stringify(iface);
const rb = JSON.stringify(rollbackScriptPath);
const a = psLit(iface);
const rb = psLit(rollbackScriptPath);
const cond = expectIp
? `$ok = @(Get-NetIPAddress -InterfaceAlias ${a} -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -eq ${JSON.stringify(expectIp)} }).Count -gt 0`
? `$ok = @(Get-NetIPAddress -InterfaceAlias ${a} -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -eq ${psLit(expectIp)} }).Count -gt 0`
: `$ok = @(Get-NetIPAddress -InterfaceAlias ${a} -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.PrefixOrigin -ne 'WellKnown' }).Count -gt 0`;
return [
``,
@@ -551,7 +552,7 @@ async function posixCommandApply(
'utf8',
);
const launched = await execCommand({
command: `chmod +x ${JSON.stringify(rollbackPath)} && setsid nohup sh ${JSON.stringify(rollbackPath)} > ${JSON.stringify(join(dir, `rollback-${ts}.log`))} 2>&1 & echo started`,
command: `chmod +x ${shLit(rollbackPath)} && setsid nohup sh ${shLit(rollbackPath)} > ${shLit(join(dir, `rollback-${ts}.log`))} 2>&1 & echo started`,
timeoutMs: 15_000,
});
+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 屏幕录制权限)', {
+4 -1
View File
@@ -11,6 +11,7 @@ import { readAudit, readAuditAll, verifyAudit, computeAuditHead, anchorAudit, co
import { computeMetrics } from '../metrics.js';
import { allPsShellStats } from '../util/ps-helper.js';
import { startTask } from './task.js';
import { psLit } from '../util/quote.js';
type Args = Record<string, unknown>;
@@ -545,7 +546,9 @@ async function scheduleWindowsRestart(p: RestartParams): Promise<unknown> {
`$agentStart = (Get-Date).AddMilliseconds(-${Math.max(0, Math.round(process.uptime() * 1000))})`,
`$cut = $agentStart`,
`Get-ScheduledTask -TaskName 'nodeagent-selfrestart*' -ErrorAction SilentlyContinue | Where-Object { if ($_.State -ne 'Running') { return $true }; $ti = ($_ | Get-ScheduledTaskInfo -ErrorAction SilentlyContinue); if ($ti -and $ti.LastRunTime -and $ti.LastRunTime -lt $cut) { return $true }; return $false } | Unregister-ScheduledTask -Confirm:$false -ErrorAction SilentlyContinue`,
`$a = New-ScheduledTaskAction -Execute 'cmd.exe' -Argument '${cmdLine.replace(/'/g, "''")}'`,
// ⚠️ 不要手写 `replace(/'/g,"''")` —— 那是「又发明一份 psLit」,两份实现必然漂移。
// PS 里 `\` 不是转义符、双引号串还会插值,故 cmdLine 唯一安全的落法就是单引号字面量。
`$a = New-ScheduledTaskAction -Execute 'cmd.exe' -Argument ${psLit(cmdLine)}`,
// 不限时(默认 72h 后强制结束),保证 node 能长期运行
`$s = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Seconds 0)`,
// ① 先试「最高权限」:agent 通常以管理员运行,普通权限的 taskkill 杀不掉它,
@@ -7,17 +7,19 @@
*/
/**
* OCR 引擎预加载段(v14):截图 / DPI / WinRT 类型与 Await 辅助函数。
* OCR 引擎预加载段(v14):截图 / WinRT 类型与 Await 辅助函数。
* 注意:WinRT 类型加载用 try/catch 包住 —— 个别环境缺组件时只应让 OCR 退化,
* 不该让整个助手(连带 window.list/focus/UIA)起不来。
*
* ⚠️ DPI 感知(`SetProcessDPIAware`)**已从本段移出**,改由 WIN_HELPER_PRELUDE 最前面的
* `PS_DPI_AWARE` 无条件执行:放在这个 try 里会被 catch 顺带吞掉,一旦吞掉
* 助手就退回 DPI 不感知,`GetWindowRect` 与 UIA / 截图坐标口径不一致(真机 2026-10-12 排查)。
*/
export const OCR_PRELUDE = `
Add-Type -AssemblyName System.Drawing
Add-Type -AssemblyName System.Windows.Forms
try {
Add-Type -AssemblyName System.Runtime.WindowsRuntime
Add-Type -TypeDefinition 'using System.Runtime.InteropServices; public class NADPI { [DllImport("user32.dll")] public static extern bool SetProcessDPIAware(); }'
[void][NADPI]::SetProcessDPIAware()
$null = [Windows.Media.Ocr.OcrEngine, Windows.Foundation, ContentType=WindowsRuntime]
$null = [Windows.Graphics.Imaging.BitmapDecoder, Windows.Foundation, ContentType=WindowsRuntime]
$null = [Windows.Storage.Streams.InMemoryRandomAccessStream, Windows.Foundation, ContentType=WindowsRuntime]
+29 -11
View File
@@ -1,11 +1,13 @@
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { IS_WINDOWS, execCommand } from '../util/exec.js';
import { runPowerShellSmart } from '../util/ps-helper.js';
import { psLit } from '../util/quote.js';
import { agentDir } from '../config.js';
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { macScreenFind, macWindowFocus, macWindowList } from './darwin.js';
import { PS_DPI_AWARE } from '../util/dpi.js';
import { WIN32_PRELUDE } from './win-ps/prelude-win32.js';
import { LIST_BODY } from './win-ps/body-list.js';
import { UIA_ASSEMBLY_PRELUDE } from './win-ps/prelude-uia.js';
@@ -119,7 +121,17 @@ export function warmupWindowHelper(): void {
}, 1500).unref?.();
}
export const WIN_HELPER_PRELUDE = `${WIN32_PRELUDE}
/**
* 常驻助手的预加载段。
*
* ⚠️ `PS_DPI_AWARE` **必须排在最前**,且不能放进 `OCR_PRELUDE` 的 try/catch 里 ——
* 那段 catch 是为了「缺 WinRT 组件时只让 OCR 退化」,但它同时也会把
* `SetProcessDPIAware` 一起吞掉;一旦吞掉,助手退回 DPI 不感知,
* `GetWindowRect`(window.list)就与 UIA / 截图的口径不一致了。
* DPI 感知是**坐标口径前提**,不是可退化项。
*/
export const WIN_HELPER_PRELUDE = `${PS_DPI_AWARE}
${WIN32_PRELUDE}
${UIA_ASSEMBLY_PRELUDE}
${OCR_PRELUDE}
${IMAGE_MATCH_PRELUDE}`;
@@ -206,14 +218,18 @@ export async function windowFocus(args: Args): Promise<unknown> {
/**
* 聚焦脚本主体(不含 prelude)。⚠️ 严禁 exit —— 会杀掉常驻助手进程;
* 未找到时改为输出 `{"found":false}` 并 return。
*
* ⚠️⚠️ `title` / `hwnd` 来自控制端,**必须**用 `psLit` 包成 PS 单引号字面量。
* 曾用 `JSON.stringify` → 双引号串 + `\"` 被 PS 解析成「字面反斜杠 + 结束引号」
* → 任意命令执行(P0 W-01,真机实测「返回正常业务错误但命令已执行」)。
*/
function focusBody(title: string | undefined, hwnd: string | undefined): string {
return `
$h = [IntPtr]::Zero
$want = ${JSON.stringify(hwnd ?? '')}
$want = ${psLit(hwnd ?? '')}
if ($want -ne '') { $h = [IntPtr][Convert]::ToInt64($want, 16) }
else {
$re = ${JSON.stringify(title ?? '')}
$re = ${psLit(title ?? '')}
$fg = [NAWin32]::GetForegroundWindow()
$found = [IntPtr]::Zero
$cb = [NAWin32+EnumProc]{
@@ -337,11 +353,12 @@ export async function screenFind(args: Args): Promise<unknown> {
// ---------- UIA 引擎 ----------
const uiaFind = async (): Promise<Record<string, unknown>[]> => {
const ct = controlType ?? '';
// ⚠️ 全部来自控制端,一律 psLit(详见文件顶部 windowFocus 处的 P0 说明)
const body = `
$text = ${JSON.stringify(text)}
$text = ${psLit(text)}
$limit = ${Number(limit)}
$ct = ${JSON.stringify(controlType ?? '')}
$winTitle = ${JSON.stringify(windowTitle ?? '')}
$ct = ${psLit(controlType ?? '')}
$winTitle = ${psLit(windowTitle ?? '')}
# v2.0.0 属性过滤:where 只含 enabled/selected/value/toggle 四类键(白名单式)。
#
# ⚠️⚠️ 这里踩过**两次**同一个坑,真机 2026-10-09 连修两轮才通:
@@ -352,7 +369,7 @@ $winTitle = ${JSON.stringify(windowTitle ?? '')}
# ✓ 第三版:**PS 单引号字符串字面量**,JSON 内的单引号翻倍成 ''(本行)
# 并在真机现场验证过:$j = '{"enabled":true}' → ConvertFrom-Json 摊平成哈希表 → OK。
# 摊平是因为 Test-UiaWhere 依赖 .Keys 遍历,而 PSCustomObject 没有 Keys。
$whereJson = '${(whereArg ? JSON.stringify(whereArg) : '').replace(/'/g, "''")}'
$whereJson = ${psLit(whereArg ? JSON.stringify(whereArg) : '')}
$where = $null
if ($whereJson -ne '') {
$where = @{}
@@ -402,7 +419,8 @@ if ($out.Count -eq 0) { Write-Output '[]' } else { $out | ConvertTo-Json -Compre
const shot = join(tmpDir, `match-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.png`);
// PowerShell 单引号字面量:不插值(避免路径里的 $ 被展开)、不处理反斜杠转义。
// 不能用 JSON.stringify —— 它会产出双引号串,而 PS 双引号会做变量插值且把 \ 当字面两个反斜杠。
const q = (v: string): string => `'${v.replace(/'/g, "''")}'`;
// (统一走 util/quote.ts 的 psLit,避免各文件各写一份实现而漏改)
const q = psLit;
// 截图区域:显式 region 优先,否则整块虚拟屏
const capX = regionArg ? regionArg.x : 'VSX';
@@ -473,7 +491,7 @@ $xv = ${regionArg ? regionArg.x : -1}
$yv = ${regionArg ? regionArg.y : -1}
$wv = ${regionArg ? regionArg.width : -1}
$hv = ${regionArg ? regionArg.height : -1}
$winRe = ${JSON.stringify(regionArg ? '' : windowTitle ?? '')}
$winRe = ${psLit(regionArg ? '' : windowTitle ?? '')}
$x = 0; $y = 0
$vs = [System.Windows.Forms.SystemInformation]::VirtualScreen
$w = $vs.Width; $h = $vs.Height
@@ -533,11 +551,11 @@ $engine = [Windows.Media.Ocr.OcrEngine]::TryCreateFromUserProfileLanguages()
if ($null -eq $engine) { Write-Output '[]'; return }
$result = Await ($engine.RecognizeAsync($soft)) ([Windows.Media.Ocr.OcrResult])
$text = ${JSON.stringify(text)}
$text = ${psLit(text)}
$limit = ${Number(limit)}
# Windows OCR 会在中文字符间插入空格(如「我的加速」识别为「我 的 加 速」),
# 因此匹配一律在「去除空白后」的文本上进行(两边同样处理)。
$textPlain = ${JSON.stringify(text)}.Replace(' ','')
$textPlain = (${psLit(text)}).Replace(' ','')
if ($textPlain -eq '') { $textPlain = ($text -replace '\\s','') }
$out = New-Object System.Collections.ArrayList
foreach ($line in $result.Lines) {
+54 -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,12 +280,28 @@ export async function eventList(_args: Args): Promise<unknown> {
};
}
export async function eventPoll(args: Args): Promise<unknown> {
/** 拉取事件:只回「属于本 client_id 的订阅」产生的事件。 */
export async function eventPoll(args: Args, ctx?: WatchContext): Promise<unknown> {
const limit = Math.max(1, Math.min(500, (args['limit'] as number | undefined) ?? 50));
const since = (args['since'] as number | undefined) ?? 0;
const watchId = args['watch_id'] as string | undefined;
const callerId = ctx?.clientId ?? '';
// watch_id 显式指定时先验归属,避免「猜 id 就能读别人事件」
if (watchId) {
const rec = watches.get(watchId);
if (rec && !ownedBy(rec, callerId)) {
throw new CapabilityError(ErrorCodes.ACL_DENIED, `订阅 ${watchId} 属于其他控制端`, { watch_id: watchId });
}
}
let list = buffer.filter((e) => e.seq > since);
if (watchId) list = list.filter((e) => e.watch_id === watchId);
if (watchId) {
list = list.filter((e) => e.watch_id === watchId);
} else if (callerId) {
const allowed = new Set(
[...watches.values()].filter((w) => ownedBy(w, callerId)).map((w) => w.id),
);
list = list.filter((e) => allowed.has(e.watch_id));
}
const sliced = list.slice(0, limit);
const nextCursor = sliced.length > 0 ? sliced[sliced.length - 1]!.seq : since;
return { events: sliced, next_cursor: nextCursor, dropped };
+57
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({
+128 -11
View File
@@ -74,9 +74,27 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
const capabilityRegistry = createCapabilityRegistry(cfg);
/** v3:本次实例采用的认证模式(psk 向后兼容 / ed25519 零信任) */
const authMode: 'psk' | 'ed25519' = cfg.auth_mode === 'ed25519' ? 'ed25519' : 'psk';
const aclPolicy = cfg.acl ?? { default_effect: 'deny' as const, clients: [] };
/** 只要配置了 ACL 就启用能力级授权(无论 psk 还是 ed25519)。 */
const hasAcl = Boolean(cfg.acl?.clients?.length);
/**
* ACL 策略归一化。
*
* ⚠️ A-01(真机实测,P0):`clients` 必须在**这里**补齐成数组。
* 原实现只写 `cfg.acl ?? {…, clients: []}` —— 当用户配置 `{"acl":{"default_effect":"deny"}}`
* (很自然的写法,省略空的 clients)时,`cfg.acl` 非空 → `??` 不兜底 →
* `aclPolicy.clients` 为 `undefined` → 后面无条件执行 `.find()` → **每次 invoke 都崩掉被控端进程**。
*/
const aclPolicy = {
default_effect: cfg.acl?.default_effect === 'allow' ? ('allow' as const) : ('deny' as const),
clients: Array.isArray(cfg.acl?.clients) ? cfg.acl.clients : [],
};
/**
* 只要**配置了 ACL 对象**就启用能力级授权(无论 psk 还是 ed25519)。
*
* ⚠️ A-02(真机实测,P1):判据必须是「对象是否存在」,**不能**用 `clients.length`。
* 原实现 `Boolean(cfg.acl?.clients?.length)` 在 `{"acl":{"default_effect":"deny","clients":[]}}`
* 时得到 `false` → 整段 ACL 校验被跳过 → **fail-open 全放行**(配置写着默认拒绝却全通)。
* 空 clients 的正确语义是「没有任何调用方被授权」,即拒绝一切(由 default_effect 决定)。
*/
const hasAcl = Boolean(cfg.acl);
/** 按调用方的滑动窗口限速(每分钟)。 */
const rateBuckets = new Map<string, number[]>();
@@ -114,14 +132,37 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
function handleHello(ws: WebSocket, state: ConnState, req: RpcRequest): void {
const params = req.params as HelloParams | undefined;
const proto = params?.protocol ?? '';
// ⚠️ B-01(真机实测,P1):`protocol` 必须先做**类型**校验再 `.split()`。
// 原实现 `params?.protocol ?? ''` 对 `protocol: 123` 得到数字 → `proto.split` 抛
// TypeError → 未捕获的 rejection → Node ≥22 直接终止被控端进程(**免认证即可打崩**)。
const rawProto = params?.protocol;
if (typeof rawProto !== 'string' || rawProto === '') {
log('warn', `握手失败:protocol 非法(${typeof rawProto})`);
audit({
type: 'request.rejected',
remote: state.remote,
status: 'denied',
reason: `hello.protocol 类型非法: ${typeof rawProto}`,
});
sendError(ws, req.id, ErrorCodes.PARAM_INVALID, 'hello 的 protocol 必须是形如 "1.0" 的字符串', {
got: typeof rawProto,
});
return;
}
const proto = rawProto;
if (proto.split('.')[0] !== PROTOCOL_VERSION.split('.')[0]) {
audit({
type: 'request.rejected',
remote: state.remote,
status: 'denied',
reason: `协议版本不兼容: 客户端 ${proto}`,
});
sendError(ws, req.id, ErrorCodes.PROTOCOL_MISMATCH, `协议版本不兼容: 客户端 ${proto},服务端 ${PROTOCOL_VERSION}`);
return;
}
state.nonce = generateNonce();
state.nonceExpiresAt = Date.now() + NONCE_TTL_MS;
state.clientId = params?.client_id ?? null;
state.clientId = typeof params?.client_id === 'string' ? params.client_id : null;
// 新一轮握手开始:清除上一轮的认证态与授权
// (Hub 模式下同一连接会被不同控制端先后复用,必须重置,否则存在越权风险)
state.authenticated = false;
@@ -147,12 +188,16 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
failOut('挑战已过期,请重新握手');
return;
}
// ⚠️ B-02(真机实测,P1):nonce / hmac / signature 都要先做类型校验。
// `params?.nonce !== state.nonce` 对数字、对象等都只会走「不匹配」分支 —— 这步安全;
// 真正致命的是下一段:把 `hmac:1` 交给 `verifyHmac` → `Buffer.from(1, 'hex')` 抛
// `ERR_INVALID_ARG_TYPE` → 未捕获 rejection → 进程终止(免认证即可打崩)。
if (params?.nonce !== state.nonce) {
log('warn', '握手失败:nonce 不匹配');
failOut('nonce 不匹配', '请用 hello 返回的 nonce 计算签名');
return;
}
const clientId = params.client_id ?? '?';
const clientId = typeof params.client_id === 'string' ? params.client_id : '?';
let authorized: string[] | null = null;
let keyKindUsed = 'ed25519';
@@ -163,7 +208,7 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
failOut(`调用方未在 ACL 中注册: ${clientId}`, '在被控端 acl.clients 中加入该 client_id 与公钥');
return;
}
if (!verifyNonce(client.pubkey, state.nonce, params.signature ?? '')) {
if (!verifyNonce(client.pubkey, state.nonce, typeof params.signature === 'string' ? params.signature : '')) {
log('warn', `鉴权失败(Ed25519 验签不通过)client_id=${clientId}`);
failOut('Ed25519 签名校验失败', '确认用的是该 client_id 对应的私钥');
return;
@@ -176,10 +221,24 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
} else {
// v22:优先用「该 client_id 专属的钥匙」,未登记才回落到共享 key。
// 这样「知道 key A」就只能以 A 的身份进来 —— ACL 的身份维度在 psk 模式下才有意义。
const scopedKey = cfg.keys?.[clientId];
//
// ⚠️ B-03(真机实测,P1):必须用 **own-property** 判断,不能直接 `cfg.keys?.[clientId]`。
// `client_id:"constructor"` 会沿原型链取到 `Object.prototype.constructor`(一个函数)
// 并被当作 HMAC 密钥 → `Buffer.from(function…)` 抛错 → 进程终止。
const hasScopedKey = Boolean(cfg.keys && Object.prototype.hasOwnProperty.call(cfg.keys, clientId));
const scopedKey = hasScopedKey ? cfg.keys![clientId] : undefined;
const keyForClient = scopedKey ?? cfg.key;
const keyKind = scopedKey ? 'scoped' : cfg.keys ? 'shared-fallback' : 'shared';
if (!keyForClient || !verifyHmac(keyForClient, state.nonce, params.hmac ?? '')) {
// ⚠️ B-02:hmac 同样要做类型校验 + verifyHmac 自身不得把异常泄漏到顶层
const hmac = typeof params.hmac === 'string' ? params.hmac : '';
let hmacOk = false;
try {
hmacOk = Boolean(keyForClient) && hmac !== '' && verifyHmac(keyForClient as string, state.nonce, hmac);
} catch (err) {
log('warn', `鉴权失败(HMAC 计算异常):${err instanceof Error ? err.message : String(err)}`);
hmacOk = false;
}
if (!hmacOk) {
log('warn', `鉴权失败 client_id=${clientId} key=${keyKind}`);
failOut('预共享密钥校验失败', scopedKey
? '该 client_id 有专属密钥,需用那把 key'
@@ -220,11 +279,17 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
async function handleInvoke(ws: WebSocket, state: ConnState, req: RpcRequest): Promise<void> {
if (!state.authenticated) {
audit({
type: 'request.rejected',
remote: state.remote,
status: 'denied',
reason: '未完成认证即调用能力',
});
sendError(ws, req.id, ErrorCodes.AUTH_REQUIRED, '未完成认证,请先握手');
return;
}
const params = req.params as InvokeParams | undefined;
const name = params?.capability ?? '';
const name = typeof params?.capability === 'string' ? params.capability : '';
// 能力级 ACL:只要被控端配置了 ACL 即执行(deny 优先 → allow → 默认拒绝)。
// 注:psk 模式下 client_id 由客户端自报,此为「配置级约束」;对抗恶意调用方仍需 ed25519 绑定身份。
@@ -252,6 +317,7 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
}
// v3+:按调用方滑动窗口限速(ACL 的 rate_limits 按能力覆盖,未命中回退 max_calls_per_min)
// 注:aclPolicy.clients 已在 createAgentCore 里归一成数组(A-01),这里取不到就是 undefined。
const clientRule = aclPolicy.clients.find((c) => c.client_id === state.clientId);
// v11:来源 IP 白/黑名单 + 生效时段(仅在已配置该 client 规则时生效)
@@ -315,6 +381,14 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
const cap = findCapability(name);
const handler = capabilityRegistry[name];
if (!cap || !handler) {
audit({
type: 'request.rejected',
client_id: state.clientId ?? undefined,
remote: state.remote,
capability: name,
status: 'denied',
reason: `不支持的能力: ${name}`,
});
sendError(ws, req.id, ErrorCodes.CAPABILITY_NOT_FOUND, `不支持的能力: ${name}`, { capability: name });
return;
}
@@ -322,6 +396,15 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
const args = (params?.args ?? {}) as Record<string, unknown>;
const errors = validate(args, cap.params_schema);
if (errors.length > 0) {
// ⚠️ U-01:拒绝必须留痕(探测行为若不留审计,事后无法复盘被谁、用什么参数试过)
audit({
type: 'request.rejected',
client_id: state.clientId ?? undefined,
remote: state.remote,
capability: name,
status: 'denied',
reason: `参数校验失败: ${errors.slice(0, 3).join('; ')}`,
});
sendError(ws, req.id, ErrorCodes.PARAM_INVALID, '参数校验失败', { errors });
return;
}
@@ -333,6 +416,7 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
// v12:把连接上下文交给能力处理器(事件订阅需要「谁订阅的」与「往哪推」)
const data = await handler(finalArgs, {
owner: state.connId,
clientId: state.clientId ?? '',
emit: (evt: unknown) => send(ws, { jsonrpc: '2.0', method: 'event', params: evt }),
});
const duration = Date.now() - started;
@@ -379,10 +463,18 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
try {
req = JSON.parse(text) as RpcRequest;
} catch {
audit({ type: 'request.rejected', remote: states.get(ws)?.remote, status: 'denied', reason: '无法解析 JSON' });
sendError(ws, null, ErrorCodes.PARSE_ERROR, '无法解析 JSON');
return;
}
if (req?.jsonrpc !== '2.0' || typeof req.id !== 'string' || typeof req.method !== 'string') {
// 信封非法:可能是扫描器/协议错误,也留一条痕(原实现完全静默)
audit({
type: 'request.rejected',
remote: states.get(ws)?.remote,
status: 'denied',
reason: `请求信封无效(jsonrpc=${typeof req?.jsonrpc} id=${typeof req?.id} method=${typeof req?.method})`,
});
sendError(ws, typeof req?.id === 'string' ? req.id : null, ErrorCodes.INVALID_REQUEST, '请求信封无效');
return;
}
@@ -401,6 +493,12 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
return;
case Methods.Capabilities:
if (!state.authenticated) {
audit({
type: 'request.rejected',
remote: state.remote,
status: 'denied',
reason: '未认证即拉取能力清单',
});
sendError(ws, req.id, ErrorCodes.AUTH_REQUIRED, '未完成认证');
return;
}
@@ -410,6 +508,13 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
sendResult(ws, req.id, { pong: true, ts: Date.now() });
return;
default:
audit({
type: 'request.rejected',
client_id: state.clientId ?? undefined,
remote: state.remote,
status: 'denied',
reason: `未知方法: ${req.method}`,
});
sendError(ws, req.id, ErrorCodes.METHOD_NOT_FOUND, `未知方法: ${req.method}`);
}
}
@@ -481,7 +586,19 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
// v2.0.0:任何入站消息都算活跃(空闲断开以"完全静默"为准,不是"没有调用能力")
const st = states.get(ws);
if (st) st.lastActiveAt = Date.now();
void handleMessage(ws, raw.toString());
// ⚠️ 崩溃链根因(P0/P1 多条共用):原来这里是 `void handleMessage(...)` ——
// Promise 一旦 reject 就成为 unhandled rejection,Node ≥22 默认**终止进程**。
// 「畸形 hello」「畸形 auth」之所以能把被控端打崩,靠的就是这条路径。
// 这里显式兜底:任何处理异常只记日志 + 回一个错误响应,绝不冒泡到进程级。
void handleMessage(ws, raw.toString()).catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
log('warn', `处理消息时发生未预期异常(已拦截,不影响进程):${msg}`);
try {
sendError(ws, null, ErrorCodes.EXECUTION_FAILED, '服务端处理消息时发生未预期异常', { detail: msg });
} catch {
/* 连接可能已关闭 */
}
});
});
ws.on('close', () => {
log('info', `连接关闭: ${remote}`);
+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": "2.0.1",
"version": "2.0.3",
"private": true,
"type": "module",
"main": "./dist/index.js",
+3 -1
View File
@@ -57,7 +57,9 @@ export async function cmdConnect(host: string, opts: Options): Promise<void> {
const profile: NodeProfile = {
host,
port,
tls: !process.argv.includes('--no-tls'),
// 原来这里直接扫 `process.argv.includes('--no-tls')` —— 绕过了解析器,
// 于是「帮助里承诺的选项」与「解析器认识的选项」是两套(L-01 同类)。
tls: !opts.noTls,
insecure: opts.insecure,
key: opts.key ?? '',
auth_mode: authMode,
+10
View File
@@ -60,6 +60,16 @@ async function main(): Promise<void> {
return;
}
// ⚠️ `--help` 是**选项**、不是位置参数。
// parseArgs 开了 `strict: false`,`--help` / `-h` 会被收进 `values`(作 boolean),
// `positionals` 里压根没有它 —— 原来靠「`positionals[0] === undefined` 时打印帮助」
// **偶然**蒙对,一旦前面多一个位置参数就会掉进「未知命令」。
// L-01 总闸守卫(tests/unit/cli-options.test.mjs)扫出这类「承诺了却没接上」的选项。
if (opts.help) {
console.log(HELP);
return;
}
switch (command) {
case 'connect': {
const host = positionals[0];
+16 -1
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' },
@@ -89,6 +94,12 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
delay: { type: 'string' },
// v10 后台任务 / 剪贴板
'timeout-ms': { type: 'string' },
// ⚠️ L-01(v2.0.3 修):`--timeout` 曾经**没在这里声明**。因为 `strict: false`,
// 未声明选项会被当成 boolean 塞进 `values.timeout`(`true`),而它的值
// `30000` 则掉进 `positionals` —— 于是 `--timeout` 静默失效,
// 并且**污染位置参数**(可能把子命令/主机名解析错)。
// 帮助文本与 `cmd/gui.ts` 的 await 命令用的都是 `--timeout`,故必须显式声明。
timeout: { type: 'string' },
kill: { type: 'boolean', default: false },
offset: { type: 'string' },
set: { type: 'string' },
@@ -126,6 +137,8 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
});
return {
opts: {
help: Boolean(values['help']),
noTls: Boolean(values['no-tls']),
json: Boolean(values['json']),
insecure: Boolean(values['insecure']),
port: values['port'] as string | undefined,
@@ -165,7 +178,9 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
discoveryPort: values['discovery-port'] as string | undefined,
node: values['node'] as string | undefined,
delay: values['delay'] as string | undefined,
timeoutMs: values['timeout-ms'] as string | undefined,
// 两个写法都接受:`--timeout` 是帮助文本/`await` 命令里的正式写法,
// `--timeout-ms` 是早期在 `bg` 命令里引入的别名。二者同时给时以 `--timeout` 为准。
timeoutMs: (values['timeout'] ?? values['timeout-ms']) as string | undefined,
kill: Boolean(values['kill']),
offset: values['offset'] as string | undefined,
set: values['set'] as string | undefined,
+4
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 -2
View File
@@ -2,9 +2,23 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tauri + Vue + Typescript App</title>
<title>nodeagent 控制台</title>
<script>
// 首帧防闪:Vue 接管之前浏览器已经会画一次背景,若不先刷上——
// 「高级灰」用户每次启动都会看到一帧刺眼的白。
//
// 此处只重复**两个**底色值(真值在 src/theme/index.ts),因为在此之前
// 没有任何模块可依赖;其余所有颜色仍由 App.vue 运行期注入的 --na-* 负责。
// 改调色板底色时记得同步这两行。
try {
var isGray = localStorage.getItem("nodeagent.console.theme") === "gray";
document.documentElement.style.background = isGray ? "#1d1f22" : "#f4f4f5";
document.documentElement.style.colorScheme = isGray ? "dark" : "light";
} catch (e) {
/* localStorage 不可用(隐私模式)时走默认底色即可 */
}
</script>
</head>
<body>
+5 -2
View File
@@ -1,7 +1,7 @@
{
"name": "nodeagent-console",
"private": true,
"version": "2.0.1",
"version": "2.0.3",
"type": "module",
"scripts": {
"dev": "vite",
@@ -9,7 +9,10 @@
"preview": "vite preview",
"tauri": "tauri",
"sidecar:dev": "node sidecar/main.mjs",
"sidecar:build": "node sidecar/build.mjs"
"sidecar:build": "node sidecar/build.mjs",
"prepare:node": "node ../../scripts/prepare-console.mjs",
"verify:bundle": "node scripts/verify-bundle.mjs",
"bundle": "pnpm sidecar:build && pnpm --filter @nodeagent/agent build && pnpm prepare:node && tauri build --config src-tauri/tauri.bundle.gen.json && pnpm verify:bundle"
},
"dependencies": {
"@tauri-apps/api": "^2",
-6
View File
@@ -1,6 +0,0 @@
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

+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);
+130
View File
@@ -13,6 +13,8 @@
* {"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"}}
@@ -25,6 +27,9 @@
*/
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} */
@@ -73,6 +78,22 @@ function stateSnapshot() {
// ---------------- 方法实现 ----------------
/**
* 比较两个 `x.y.z` 版本号:a > b 返回正、相等返回 0、a < b 返回负。
*
* 按**数值**逐段比较,不做字符串比较 —— 否则 `"2.0.10" < "2.0.9"`(字符串序),
* 到两位数的补丁号时会误判「无更新」。
*/
function cmpVersion(a, b) {
const pa = String(a).split('.').map((x) => parseInt(x, 10) || 0);
const pb = String(b).split('.').map((x) => parseInt(x, 10) || 0);
for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
if (d !== 0) return d > 0 ? 1 : -1;
}
return 0;
}
const methods = {
/** 建立连接并完成握手。返回与旧 Rust client 的 ConnectInfo 同构的元信息。 */
async connect(params) {
@@ -155,6 +176,115 @@ const methods = {
}
},
/**
* 检查更新:拉项目最新 Release 的 `latest.json`,与本应用版本比对。
*
* ## 为什么放在 sidecar 而不是 Rust 壳
* 本壳的既定分工是「**进程管理 + 消息转发**,协议与 IO 逻辑集中在 Node 一侧」
* (见文件头注释)。更新检查要做 HTTPS + JSON + 后续大概率还要下载文件校验哈希,
* 在 Rust 侧等于再引一套 HTTP 栈;而 Node 有内置 `fetch`,一行到位。**单一实现**优先。
*
* `latest.json` 由发版脚本产出(含 version / commit / agent.url / agent.sha256),
* 走 `releases/latest/download/` 这个**稳定别名**,无需调 GitHub API、不占速率配额。
*/
async check_update(params) {
const consoleVersion = String(params?.console_version ?? '0.0.0');
const url =
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(),
};
+7
View File
@@ -5,3 +5,10 @@
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas
# 随包分发的 Node 运行时(由 scripts/prepare-console.mjs 生成,~87MB;不入库)
# 体积原因 + 它是从本机 node 复制/剥离而来,不是源代码。
/runtime/
# 构建期注入片段(生成物,引用 runtime/ 下的 node;不入库,见 prepare-console.mjs 注释)
/tauri.bundle.gen.json
+18 -1
View File
@@ -2101,13 +2101,14 @@ checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nodeagent-console"
version = "2.0.0"
version = "2.0.3"
dependencies = [
"serde",
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-opener",
"tauri-plugin-single-instance",
"tokio",
]
@@ -3519,6 +3520,22 @@ dependencies = [
"zbus",
]
[[package]]
name = "tauri-plugin-single-instance"
version = "2.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c142ed88deee222bd2d979269d35c73b6c1c0f6ebd5b79b4ff80066fcad6af1"
dependencies = [
"serde",
"serde_json",
"tauri",
"thiserror 2.0.21",
"tokio",
"tracing",
"windows-sys 0.61.2",
"zbus",
]
[[package]]
name = "tauri-runtime"
version = "2.12.1"
+8 -3
View File
@@ -1,7 +1,7 @@
[package]
name = "nodeagent-console"
version = "2.0.0"
description = "nodeagent 桌面控制台(主控 + 被控一体)"
version = "2.0.3"
description = "nodeagent 桌面控制台(主控 + 被控一体,含本机服务托管)"
authors = ["jkooe"]
edition = "2021"
@@ -18,8 +18,13 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
# `tray-icon`:托盘常驻(后台运行 / 开机自启的载体)必需。
# 不给这个 feature,`TrayIconBuilder` 根本不存在(编译期就找不到符号)。
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-opener = "2"
# ⚠️ 单实例保护(v2.0.3 修):缺了它,「开机自启的隐藏实例 + 用户手开的实例」会并存,
# 各自托管一个被控端 → 同机两个被控端抢同一个 8765。详见 src/lib.rs 顶部「单实例」一节。
tauri-plugin-single-instance = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# ⚠️ 2026-10:不再依赖 crates/nodeagent-client —— 协议实现改由 Node sidecar 承担
+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)
}
+577 -24
View File
@@ -4,18 +4,46 @@
//! 本层只做**进程管理 + 消息转发**,真正的协议实现只有 TS 一份(`@nodeagent/client`),
//! 由 `sidecar.rs` 拉起的 Node 进程承载。原先「Rust 版 client 与 TS 版并存」的漂移风险就此消除。
//!
//! command 面(**签名与语义保持不变,前端无需改动**):
//! `agent.rs` 是同一思路的延伸:本机**被控端**也由本层托管(启动 / 停止 / 重启 / 日志 /
//! 崩溃自愈),用户在界面上点按钮即可,全程不必碰命令行。
//!
//! ## command 面
//! 连接类(签名与语义从未变过):
//! - `connect` / `disconnect` / `invoke_capability` / `get_capabilities` / `get_state`
//!
//! Tauri 事件面(名字不变):`na-state` / `na-log` / `na-event`。
//! 本机服务类(2026-10-11 新增):
//! - `agent_status` / `agent_start` / `agent_stop` / `agent_restart`
//! - `agent_logs` / `agent_logs_clear`
//! - `autostart_status` / `autostart_set`
//! - `check_update` / `apply_update`
//! - `settings_get` / `settings_set` / `system_info`
//!
//! ## 事件面
//! - 连接:`na-state` / `na-log` / `na-event`
//! - 本机服务:`na-agent-state` / `na-agent-log`
//!
//! ## 启动模式
//! - 默认:显示主窗口
//! - `--agent-only`:**不显示窗口**,直接进托盘并托管本机被控端(开机自启用它,
//! 这样登录自启也不会有任何窗口弹出来)
//!
//! ## 单实例(v2.0.3)
//! 见下方「单实例」一节 —— 真机缺陷:无保护时同机会跑起两个被控端。
mod agent;
mod autostart;
mod settings;
mod sidecar;
use std::sync::Mutex;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use serde_json::{json, Value};
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
use tauri::{AppHandle, Emitter, Manager, State};
use agent::{AgentEvent, AgentManager};
use sidecar::{Sidecar, SidecarEvent};
/// 全局状态。
@@ -23,10 +51,32 @@ use sidecar::{Sidecar, SidecarEvent};
/// `connect` 成功后缓存「能力清单 / 版本 / 连接态」——这样 `get_capabilities`、
/// `get_state` 能保持**同步**(无需每次往返 sidecar),签名与改造前一致。
struct AppState {
sidecar: tokio::sync::Mutex<Option<std::sync::Arc<Sidecar>>>,
sidecar: tokio::sync::Mutex<Option<Arc<Sidecar>>>,
capabilities: Mutex<Value>,
agent_version: Mutex<Option<String>>,
conn_state: Mutex<String>,
/// 本机被控端管理器(在 `setup` 里建,因为需要 AppHandle 来桥接事件)
agent: Mutex<Option<Arc<AgentManager>>>,
settings: Mutex<settings::Settings>,
/// 控制台自身配置目录(设置存这里)
config_dir: PathBuf,
/// 控制台版本(由 `package_info` 注入,用于更新比对;不在代码里写死)
app_version: String,
}
impl AppState {
fn agent(&self) -> Result<Arc<AgentManager>, String> {
self.agent
.lock()
.unwrap()
.as_ref()
.map(Arc::clone)
.ok_or_else(|| "被控端管理器尚未初始化".to_string())
}
fn settings(&self) -> settings::Settings {
self.settings.lock().unwrap().clone()
}
}
/// 连接成功后回传给前端的元信息(字段名与改造前一致)。
@@ -40,22 +90,28 @@ struct ConnectInfo {
client_id: String,
}
impl Default for AppState {
fn default() -> Self {
Self {
sidecar: tokio::sync::Mutex::new(None),
capabilities: Mutex::new(Value::Array(vec![])),
agent_version: Mutex::new(None),
conn_state: Mutex::new("未连接".to_string()),
}
}
/// 本机被控端的三条路径。
struct AgentPaths {
node: PathBuf,
script: PathBuf,
data_dir: PathBuf,
}
/// 解析(并在需要时播种)本机被控端所需路径。
fn resolve_agent_paths(app: &AppHandle, state: &AppState) -> Result<AgentPaths, String> {
let resource_dir = app.path().resource_dir().ok();
let node = sidecar::resolve_node(resource_dir.as_deref())?;
let bundled = agent::resolve_bundled_agent(resource_dir.as_deref())?;
let data_dir = agent::agent_data_dir();
let script = agent::seed_agent_script(&bundled, &data_dir, &state.app_version)?;
Ok(AgentPaths { node, script, data_dir })
}
/// 懒启动 sidecar(首次调用时拉起,之后复用同一进程)。
async fn ensure_sidecar(app: &AppHandle, state: &AppState) -> Result<std::sync::Arc<Sidecar>, String> {
async fn ensure_sidecar(app: &AppHandle, state: &AppState) -> Result<Arc<Sidecar>, String> {
let mut guard = state.sidecar.lock().await;
if let Some(sc) = guard.as_ref() {
return Ok(std::sync::Arc::clone(sc));
return Ok(Arc::clone(sc));
}
// 打包后 node 与 sidecar.mjs 随 resource 分发;开发期用系统 node + 仓库内脚本
@@ -65,7 +121,7 @@ async fn ensure_sidecar(app: &AppHandle, state: &AppState) -> Result<std::sync::
// 事件桥接:sidecar 通知 → Tauri event(名字与改造前一致)
let app_events = app.clone();
let on_event = std::sync::Arc::new(move |evt: SidecarEvent| match evt {
let on_event = Arc::new(move |evt: SidecarEvent| match evt {
SidecarEvent::State(s) => {
let _ = app_events.emit("na-state", s);
}
@@ -81,12 +137,15 @@ async fn ensure_sidecar(app: &AppHandle, state: &AppState) -> Result<std::sync::
});
let sc = Sidecar::spawn(&node, &script, on_event)?;
// 连接态由事件驱动:先置「已就绪」,真正连接后会被 connected 覆盖
*state.conn_state.lock().unwrap() = "未连接".to_string();
*guard = Some(std::sync::Arc::clone(&sc));
*guard = Some(Arc::clone(&sc));
Ok(sc)
}
// ---------------------------------------------------------------------------
// 连接类 command(签名与语义保持不变)
// ---------------------------------------------------------------------------
/// 建立到被控端的连接并完成握手(默认 psk 模式、接受自签证书、开启自动重连)。
#[tauri::command]
async fn connect(
@@ -113,7 +172,6 @@ async fn connect(
)
.await?;
// 缓存快照,供同步 command 使用
let caps = snap.get("capabilities").cloned().unwrap_or_else(|| json!([]));
let ver = snap.get("agent_version").and_then(Value::as_str).map(str::to_string);
*state.capabilities.lock().unwrap() = caps.clone();
@@ -137,7 +195,7 @@ async fn connect(
/// 断开连接(停止自动重连)。
#[tauri::command]
async fn disconnect(state: State<'_, AppState>) -> Result<(), String> {
let sc = { state.sidecar.lock().await.as_ref().map(std::sync::Arc::clone) };
let sc = { state.sidecar.lock().await.as_ref().map(Arc::clone) };
if let Some(sc) = sc {
sc.request("disconnect", json!({}), 10).await?;
}
@@ -160,7 +218,7 @@ async fn invoke_capability(
.lock()
.await
.as_ref()
.map(std::sync::Arc::clone)
.map(Arc::clone)
.ok_or_else(|| "未连接".to_string())?
};
let mut params = json!({ "capability": capability, "args": args.unwrap_or_else(|| json!({})) });
@@ -184,17 +242,512 @@ 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())
.manage(AppState::default())
.setup(move |app| {
let handle = app.handle().clone();
let config_dir = handle
.path()
.app_config_dir()
.unwrap_or_else(|_| PathBuf::from("."));
let app_version = handle.package_info().version.to_string();
let loaded = settings::load(&config_dir);
// 事件桥接:被控端管理器 → Tauri event
let app_events = handle.clone();
let emit: agent::AgentEmit = Arc::new(move |evt: AgentEvent| match evt {
AgentEvent::Log(l) => {
let _ = app_events.emit("na-agent-log", l);
}
AgentEvent::State(s) => {
let _ = app_events.emit("na-agent-state", s);
}
});
let manager = AgentManager::new(emit);
manager.push_log(
"system",
format!(
"控制台 v{app_version} 就绪({} / {})",
std::env::consts::OS,
std::env::consts::ARCH
),
);
app.manage(AppState {
sidecar: tokio::sync::Mutex::new(None),
capabilities: Mutex::new(json!([])),
agent_version: Mutex::new(None),
conn_state: Mutex::new("未连接".to_string()),
agent: Mutex::new(Some(Arc::clone(&manager))),
settings: Mutex::new(loaded.clone()),
config_dir,
app_version,
});
build_tray(&handle)?;
// 仅服务模式:不显示窗口(开机自启时不该弹任何窗口出来)
if agent_only {
if let Some(w) = handle.get_webview_window("main") {
let _ = w.hide();
}
manager.push_log("system", "以「仅服务模式」启动(无窗口,常驻托盘)");
}
// 自动拉起被控端(默认开)——「运行应用之后就有服务」的期望
if loaded.auto_start_agent {
spawn_start_agent(&handle, loaded.auto_restart);
}
Ok(())
})
.on_window_event(|window, event| {
// 关窗 → 最小化到托盘(可在设置里关掉,改为直接退出)
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
let app = window.app_handle();
let state = app.state::<AppState>();
let close_to_tray = state.settings().close_to_tray;
if close_to_tray {
api.prevent_close();
let _ = window.hide();
if let Ok(mgr) = state.agent() {
mgr.push_log("system", "窗口已隐藏到托盘(被控端继续运行;从托盘菜单可退出)");
}
}
}
})
.invoke_handler(tauri::generate_handler![
// 连接
connect,
disconnect,
invoke_capability,
get_capabilities,
get_state
get_state,
// 本机服务
agent_status,
agent_start,
agent_stop,
agent_restart,
agent_logs,
agent_logs_clear,
autostart_status,
autostart_set,
// 更新
check_update,
apply_update,
// 设置与信息
settings_get,
settings_set,
system_info,
])
.build(tauri::generate_context!())
.expect("error while building tauri application")
@@ -204,7 +757,7 @@ pub fn run() {
if let tauri::RunEvent::Exit = event {
let state = app.state::<AppState>();
let sc = tauri::async_runtime::block_on(async {
state.sidecar.lock().await.as_ref().map(std::sync::Arc::clone)
state.sidecar.lock().await.as_ref().map(Arc::clone)
});
if let Some(sc) = sc {
tauri::async_runtime::block_on(async {
+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!({}))
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "nodeagent-console",
"version": "2.0.1",
"version": "2.0.3",
"identifier": "cn.jkooe.nodeagent-console",
"build": {
"beforeDevCommand": "pnpm dev",
+32 -9
View File
@@ -1,17 +1,40 @@
<script setup lang="ts">
import {
NConfigProvider,
NMessageProvider,
NDialogProvider,
darkTheme,
zhCN,
dateZhCN,
} from "naive-ui";
import { watchEffect } from "vue";
import { NConfigProvider, NMessageProvider, NDialogProvider, zhCN, dateZhCN } from "naive-ui";
import AppShell from "./components/AppShell.vue";
import { cssVars } from "./theme";
import { useThemeStore } from "./stores/theme";
const theme = useThemeStore();
/**
* 把调色板写成 `--na-*` CSS 变量挂到根元素。
*
* 色值只在 `theme/index.ts` 定义一份,此处在运行期注入给自定义样式用 ——
* 免得「TS 调色板」与「CSS 硬编码」各写一份后静默漂移(换主题时一半新一半旧,
* 不报错、类型也抓不到)。
*
* 同时设 `color-scheme`:不设的话滚动条、`<input>` 等原生控件会一直是浅色,
* 在「高级灰」下白得刺眼。
*/
watchEffect(() => {
const { palette, name } = theme.preset;
const root = document.documentElement;
for (const [k, v] of Object.entries(cssVars(palette))) {
root.style.setProperty(k, v);
}
root.style.colorScheme = palette.scheme;
root.dataset.theme = name;
});
</script>
<template>
<n-config-provider :theme="darkTheme" :locale="zhCN" :date-locale="dateZhCN">
<n-config-provider
:theme="theme.preset.base"
:theme-overrides="theme.preset.overrides"
:locale="zhCN"
:date-locale="dateZhCN"
>
<n-message-provider>
<n-dialog-provider>
<app-shell />
+82 -1
View File
@@ -1,6 +1,18 @@
/** Tauri command 调用封装(前端 → Rust 壳 → Node sidecar → 被控端)。 */
import { invoke } from "@tauri-apps/api/core";
import type { CapabilityDescriptor, ConnectInfo, InvokeResult } from "../types";
import type {
AgentActionResult,
AgentLogPage,
AgentStatus,
ApplyUpdateResult,
AutostartStatus,
CapabilityDescriptor,
ConnectInfo,
ConsoleSettings,
ConsoleSystemInfo,
InvokeResult,
UpdateInfo,
} from "../types";
/** 建立连接并握手。 */
export function connect(url: string, key: string, clientId = "console", insecure = true) {
@@ -86,3 +98,72 @@ export async function ok<T = unknown>(
}
return res.data as T;
}
// ---------------------------------------------------------------------------
// 本机服务(本机被控端)—— 全部走 Tauri command,不经被控端协议
// ---------------------------------------------------------------------------
/** 本机服务总览:进程状态 + 配置 + 自启 + 设置 + 版本。 */
export function agentStatus() {
return invoke<AgentStatus>("agent_status");
}
/** 启动本机被控端。`autoRestart` 省略时取设置里的值。 */
export function agentStart(autoRestart?: boolean) {
return invoke<AgentActionResult>("agent_start", { autoRestart });
}
/** 停止本机被控端(幂等)。 */
export function agentStop() {
return invoke<AgentActionResult>("agent_stop");
}
/** 重启本机被控端。 */
export function agentRestart(autoRestart?: boolean) {
return invoke<AgentActionResult>("agent_restart", { autoRestart });
}
/** 增量取日志(传上次拿到的最大 seq)。 */
export function agentLogs(since = 0) {
return invoke<AgentLogPage>("agent_logs", { since });
}
/** 清空日志缓冲。 */
export function agentLogsClear() {
return invoke<void>("agent_logs_clear");
}
/** 开机自启状态。 */
export function autostartStatus() {
return invoke<AutostartStatus>("autostart_status");
}
/** 开关开机自启。 */
export function autostartSet(enabled: boolean) {
return invoke<AutostartStatus>("autostart_set", { enabled });
}
/** 检查更新(比对最新 Release)。 */
export function checkUpdate() {
return invoke<UpdateInfo>("check_update");
}
/** 应用更新:下载 → 校验 → 替换本机被控端脚本 → 重启。 */
export function applyUpdate(url: string, sha256: string, version: string) {
return invoke<ApplyUpdateResult>("apply_update", { url, sha256, version });
}
/** 读设置。 */
export function settingsGet() {
return invoke<ConsoleSettings>("settings_get");
}
/** 合并式写设置(只传要改的字段)。 */
export function settingsSet(patch: Partial<ConsoleSettings>) {
return invoke<ConsoleSettings>("settings_set", { patch });
}
/** 控制台自身信息。 */
export function systemInfo() {
return invoke<ConsoleSystemInfo>("system_info");
}
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

Before

Width:  |  Height:  |  Size: 496 B

+105 -9
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, h, onMounted } from "vue";
import { computed, onMounted } from "vue";
import { RouterView, useRoute, useRouter } from "vue-router";
import {
NLayout,
@@ -11,25 +11,39 @@ import {
NButton,
NSpace,
NText,
NTooltip,
NRadioGroup,
NRadioButton,
type MenuOption,
} from "naive-ui";
import { navRoutes } from "../router";
import { navRoutes, type NavMeta } from "../router";
import { useConnStore } from "../stores/conn";
import { useAgentStore } from "../stores/agent";
import { useThemeStore } from "../stores/theme";
import { THEME_LIST } from "../theme";
const route = useRoute();
const router = useRouter();
const conn = useConnStore();
const agent = useAgentStore();
const theme = useThemeStore();
const themeName = computed({
get: () => theme.name,
set: (v) => theme.set(v),
});
onMounted(() => {
conn.bind();
agent.bind();
});
// 纯文字导航:应用内不使用图标(emoji 图标在部分渲染器里会退化成豆腐块)。
const menuOptions: MenuOption[] = navRoutes.map((r) => ({
key: r.name as string,
label: () => h("span", `${(r.meta as { icon: string }).icon} ${(r.meta as { title: string }).title}`),
label: (r.meta as NavMeta).title,
}));
const activeKey = computed(() => (route.name as string) ?? "nodes");
const activeKey = computed(() => (route.name as string) ?? "local");
function onMenu(key: string) {
router.push({ name: key });
@@ -46,6 +60,12 @@ const stateLabel = computed(() => {
if (conn.state === "reconnecting") return "重连中";
return "未连接";
});
/** 从侧栏快速启停本机服务(不必切页面)。 */
async function quickToggle() {
if (agent.running) await agent.stop();
else await agent.start();
}
</script>
<template>
@@ -56,6 +76,30 @@ const stateLabel = computed(() => {
<div class="brand-sub">桌面控制台</div>
</div>
<n-menu :options="menuOptions" :value="activeKey" @update:value="onMenu" />
<div class="sider-foot">
<div class="foot-label">主题</div>
<n-radio-group v-model:value="themeName" size="small" class="theme-switch">
<n-radio-button v-for="t in THEME_LIST" :key="t.name" :value="t.name">
{{ t.label }}
</n-radio-button>
</n-radio-group>
<div class="foot-sep"></div>
<div class="foot-row">
<n-text depth="3" style="font-size: 12px">本机被控端</n-text>
<n-tag :type="agent.meta.type" size="tiny" round>{{ agent.meta.label }}</n-tag>
</div>
<n-button
size="tiny"
block
:loading="agent.busy"
:disabled="agent.busy || agent.externalInstance"
@click="quickToggle"
>
{{ agent.running ? "停止服务" : "启动服务" }}
</n-button>
</div>
</n-layout-sider>
<n-layout>
@@ -67,7 +111,15 @@ const stateLabel = computed(() => {
· agent {{ conn.info.agent_version ?? "?" }} · {{ conn.info.capabilities.length }} 项能力
</n-text>
</n-space>
<n-button size="small" :disabled="!conn.info" @click="conn.disconnect()">断开</n-button>
<n-space align="center">
<n-tooltip v-if="agent.sysInfo?.agent_only" trigger="hover">
<template #trigger>
<n-tag size="small" type="info" round>仅服务模式</n-tag>
</template>
本控制台以「仅服务模式」启动(无窗口,常驻托盘)—— 通常来自开机自启
</n-tooltip>
<n-button size="small" :disabled="!conn.info" @click="conn.disconnect()">断开</n-button>
</n-space>
</n-layout-header>
<n-layout-content :native-scrollbar="false" content-style="padding: 20px;">
@@ -78,8 +130,12 @@ const stateLabel = computed(() => {
</template>
<style>
:root {
color-scheme: dark;
/* 配色只走 `--na-*` 变量(值在 src/theme/index.ts 单点定义,App.vue 运行期注入),
此处不留任何写死的色值 —— 否则换主题时会一半新一半旧,且不报错。
`color-scheme` 也由 App.vue 按主题设,这里不重复声明。 */
html,
body {
background: var(--na-bg);
}
body {
margin: 0;
@@ -90,12 +146,12 @@ body {
.brand-title {
font-size: 18px;
font-weight: 600;
color: #63e2b7;
color: var(--na-brand);
letter-spacing: 0.5px;
}
.brand-sub {
font-size: 12px;
opacity: 0.6;
color: var(--na-text-3);
margin-top: 2px;
}
.topbar {
@@ -105,4 +161,44 @@ body {
justify-content: space-between;
padding: 0 16px;
}
/* 侧栏做成纵向弹性布局,才能把底部快捷块压到底 */
:deep(.n-layout-sider-scroll-container) {
display: flex;
flex-direction: column;
}
.sider-foot {
margin-top: auto;
padding: 12px 14px 16px;
border-top: 1px solid var(--na-border);
}
.foot-label {
font-size: 11px;
color: var(--na-text-3);
margin-bottom: 6px;
}
.foot-sep {
height: 1px;
background: var(--na-border);
margin: 12px 0;
}
/* 两个主题按钮各占一半宽(侧栏只有 200px,默认内边距会挤出去) */
.theme-switch {
display: flex;
width: 100%;
}
.theme-switch :deep(.n-radio-button) {
flex: 1;
}
.theme-switch :deep(.n-radio-button__label) {
display: flex;
justify-content: center;
font-size: 12px;
padding: 0 2px;
}
.foot-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
</style>
+26 -10
View File
@@ -1,54 +1,70 @@
/** 路由表(7 页)。hash history 便于 Tauri 打包后稳定工作。 */
/** 路由表。hash history 便于 Tauri 打包后稳定工作。 */
import { createRouter, createWebHashHistory } from "vue-router";
// 刻意不设 icon 字段:应用内一律不使用图标,导航以纯文字呈现。
// (原因见 CHANGELOG [未发布]:emoji 图标在非系统字体栈的渲染器里会退化成豆腐块,
// 而跨平台「哪套字体栈一定带 emoji」并不成立 —— 与其赌字形,不如不用。)
export interface NavMeta {
title: string;
icon: string;
}
const routes = [
{ path: "/", redirect: "/nodes" },
// 默认落在「本机服务」—— 这是「打开应用就要能启动/停止/重启」的落点,
// 而不是先让用户去填一个连接地址。
{ path: "/", redirect: "/local" },
{
path: "/local",
name: "local",
component: () => import("../views/LocalServiceView.vue"),
meta: { title: "本机服务" } satisfies NavMeta,
},
{
path: "/logs",
name: "logs",
component: () => import("../views/LogsView.vue"),
meta: { title: "服务日志" } satisfies NavMeta,
},
{
path: "/nodes",
name: "nodes",
component: () => import("../views/NodesView.vue"),
meta: { title: "设备管理", icon: "🖥" } satisfies NavMeta,
meta: { title: "设备管理" } satisfies NavMeta,
},
{
path: "/overview",
name: "overview",
component: () => import("../views/OverviewView.vue"),
meta: { title: "状态总览", icon: "📊" } satisfies NavMeta,
meta: { title: "状态总览" } satisfies NavMeta,
},
{
path: "/terminal",
name: "terminal",
component: () => import("../views/TerminalView.vue"),
meta: { title: "命令执行", icon: "⌨" } satisfies NavMeta,
meta: { title: "命令执行" } satisfies NavMeta,
},
{
path: "/processes",
name: "processes",
component: () => import("../views/ProcessesView.vue"),
meta: { title: "进程与服务", icon: "⚙" } satisfies NavMeta,
meta: { title: "进程与服务" } satisfies NavMeta,
},
{
path: "/software",
name: "software",
component: () => import("../views/SoftwareView.vue"),
meta: { title: "软件管理", icon: "📦" } satisfies NavMeta,
meta: { title: "软件管理" } satisfies NavMeta,
},
{
path: "/files",
name: "files",
component: () => import("../views/FilesView.vue"),
meta: { title: "文件管理", icon: "📁" } satisfies NavMeta,
meta: { title: "文件管理" } satisfies NavMeta,
},
{
path: "/audit",
name: "audit",
component: () => import("../views/AuditView.vue"),
meta: { title: "审计日志", icon: "📜" } satisfies NavMeta,
meta: { title: "审计日志" } satisfies NavMeta,
},
];
+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 };
});
+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";
}
+132 -1
View File
@@ -204,7 +204,10 @@ export type AuditType =
| "net.change"
| "net.deny"
| "agent.update"
| "session.idle_close";
| "session.idle_close"
// v2.0.3:被拒请求统一留痕(U-01)+ 进程级兜底错误
| "request.rejected"
| "agent.error";
export interface AuditEntry {
ts?: number;
@@ -247,3 +250,131 @@ export interface AuditVerifyResult {
/** 是**对象**而非数字 */
broken_at?: AuditBrokenAt | null;
}
// ---------------------------------------------------------------------------
// 本机服务(本机被控端托管)—— 契约来源:src-tauri/src/agent.rs
// ---------------------------------------------------------------------------
/** 被控端进程状态机取值(与 Rust `agent::state` 一一对应)。 */
export type AgentState = "stopped" | "starting" | "running" | "stopping" | "crashed";
/** 本机被控端进程快照。 */
export interface AgentProcess {
state: AgentState;
pid?: number | null;
started_at?: number | null;
uptime_ms?: number | null;
/** 自动重启已尝试次数 */
restarts?: number;
last_exit?: string | null;
/** 实际使用的 node 可执行文件 */
node?: string;
/** 实际运行的那份 agent.mjs(在数据目录里,不在 app 包内) */
script?: string;
data_dir?: string;
}
/** 被控端配置(读自 `<数据目录>/agent.json`)。 */
export interface AgentConfigInfo {
data_dir: string;
config_path: string;
config_exists: boolean;
node_id: string;
port: number;
tls: boolean;
auth_mode: "psk" | "ed25519" | string;
/** PSK 明文(本机展示用;它本就在用户自己的 600 权限文件里) */
key: string;
/** 端口是否已被监听(可能是一个非本控制台托管的实例) */
port_in_use: boolean;
url: string;
}
export interface AutostartStatus {
enabled: boolean;
supported: boolean;
/** 落地位置(plist 路径 / 计划任务名) */
location: string;
app_exe: string;
/** 自启时传给应用的模式参数 */
mode: string;
note: string;
/** 本次操作详情(开关后返回) */
detail?: string;
}
export interface ConsoleSettings {
auto_start_agent: boolean;
auto_restart: boolean;
close_to_tray: boolean;
insecure: boolean;
}
export interface AgentStatus {
manager: AgentProcess;
config: AgentConfigInfo;
autostart: AutostartStatus;
settings: ConsoleSettings;
app_version: string;
config_dir: string;
/** 数据目录里那份脚本的版本(被控端自更新或控制台更新后写入) */
installed_agent_version?: string | null;
/** 随包内置的 agent.mjs 路径 */
bundled_agent?: string;
}
export interface AgentActionResult {
state?: AgentState;
pid?: number | null;
note?: string;
port_released?: boolean;
}
export interface AgentLogLine {
seq: number;
ts: number;
/** stdout / stderr / system */
stream: string;
line: string;
}
export interface AgentLogPage {
lines: AgentLogLine[];
total: number;
}
export interface UpdateInfo {
console_version: string;
latest_version: string;
has_update: boolean;
agent_has_update?: boolean;
installed_agent_version?: string;
published_at?: string | null;
commit?: string | null;
capabilities?: number | null;
agent: { url?: string | null; sha256?: string | null; bytes?: number | null };
release_url: string;
source: string;
checked_at: number;
}
export interface ApplyUpdateResult {
updated: boolean;
version: string;
bytes: number;
sha256: string;
backup: string;
restarted: boolean;
}
export interface ConsoleSystemInfo {
app_version: string;
config_dir: string;
agent_data_dir: string;
resource_dir: string;
platform: string;
arch: string;
node: string;
/** 是否以「仅服务模式」启动(开机自启时为 true) */
agent_only: boolean;
}
+24 -15
View File
@@ -28,19 +28,29 @@ const typeFilter = ref<string | null>(null);
const loading = ref(false);
const verify = ref<AuditVerifyResult | null>(null);
/**
* 审计筛选项(取值必须是 `@nodeagent/protocol` 里 `AUDIT_FILTER_TYPES` 的一员)。
*
* ⚠️ C-01(v2.0.3 修):这里原本列了 11 个**细粒度**取值(`auth.success`、`acl.denied`…),
* 而协议 manifest 的 enum 只有 `['invoke','auth','acl','agent']` → 被控端 schema 校验把
* 除 `invoke` 外的**全部**取值判成 `E_PARAM_INVALID`:下拉框形同虚设,且**不报类型错**。
*
* 改用**粗粒度前缀**:被控端按 `entry.type.startsWith(query)` 过滤,所以
* `agent` 一次覆盖 `agent.start/stop/update/error`,`request` 覆盖新增的 `request.rejected`。
* 覆盖面更全(每个审计类型都能被至少一项筛到),下拉框也不必膨胀成二十几项。
* 防漂移由 `tests/unit/audit-filter.test.mjs` 钉死(正反双向都比对真源)。
*/
const typeOptions = [
{ label: "全部", value: "" },
{ label: "invoke", value: "invoke" },
{ label: "auth.success", value: "auth.success" },
{ label: "auth.failure", value: "auth.failure" },
{ label: "acl.denied", value: "acl.denied" },
{ label: "capability.disabled", value: "capability.disabled" },
{ label: "rate.limited", value: "rate.limited" },
{ label: "net.deny", value: "net.deny" },
{ label: "net.change", value: "net.change" },
{ label: "agent.update", value: "agent.update" },
{ label: "agent.start", value: "agent.start" },
{ label: "agent.stop", value: "agent.stop" },
{ label: "调用 invoke", value: "invoke" },
{ label: "认证 auth", value: "auth" },
{ label: "ACL 拒绝 acl", value: "acl" },
{ label: "能力被禁 capability", value: "capability" },
{ label: "限速 rate", value: "rate" },
{ label: "网络 net", value: "net" },
{ label: "agent 生命周期 agent", value: "agent" },
{ label: "会话 session", value: "session" },
{ label: "被拒请求 request", value: "request" },
];
const cols: DataTableColumns<AuditEntry> = [
@@ -124,7 +134,7 @@ onMounted(() => {
<template>
<div v-if="!conn.connected">
<n-empty description="请先在「设备管理」页连接被控端" style="margin-top: 80px" />
<n-empty :show-icon="false" description="请先在「设备管理」页连接被控端" style="margin-top: 80px" />
</div>
<div v-else class="wrap">
@@ -141,7 +151,7 @@ onMounted(() => {
v-model:value="typeFilter"
:options="typeOptions"
size="small"
style="width: 140px"
style="width: 190px"
@update:value="load"
/>
<n-input-number v-model:value="limit" :min="10" :max="2000" :step="50" size="small" style="width: 110px" />
@@ -154,7 +164,6 @@ onMounted(() => {
<n-alert
v-if="verify"
:type="verify.ok ? 'success' : 'error'"
:show-icon="true"
style="margin-bottom: 12px"
>
完整性校验:{{ verify.ok ? "通过" : "发现问题" }} · 已校验 {{ verify.checked }} 条
@@ -165,7 +174,7 @@ onMounted(() => {
</template>
</n-alert>
<n-empty v-if="!entries.length" description="暂无审计记录" size="small" />
<n-empty :show-icon="false" v-if="!entries.length" description="暂无审计记录" size="small" />
<n-data-table v-else :columns="cols" :data="entries" :bordered="false" size="small" :max-height="560" />
</n-card>
</div>
+3 -3
View File
@@ -146,7 +146,7 @@ onMounted(() => {
<template>
<div v-if="!conn.connected">
<n-empty description="请先在「设备管理」页连接被控端" style="margin-top: 80px" />
<n-empty :show-icon="false" description="请先在「设备管理」页连接被控端" style="margin-top: 80px" />
</div>
<div v-else class="wrap">
@@ -172,7 +172,7 @@ onMounted(() => {
</n-card>
<n-card size="small" style="margin-top: 16px">
<n-empty v-if="!entries.length" description="目录为空或未加载" size="small" />
<n-empty :show-icon="false" v-if="!entries.length" description="目录为空或未加载" size="small" />
<n-data-table
v-else
:columns="cols"
@@ -228,7 +228,7 @@ onMounted(() => {
margin: 12px 0 0;
padding: 12px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.3);
background: var(--na-sunken);
font-family: ui-monospace, Menlo, monospace;
font-size: 12px;
line-height: 1.6;
+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>
+7 -7
View File
@@ -93,7 +93,7 @@ const capGroups = computed(() => {
指纹 {{ (conn.info.peer_cert_fp ?? "-").slice(0, 16) }}…
</n-text>
</div>
<n-empty v-else description="尚未连接" size="small" />
<n-empty :show-icon="false" v-else description="尚未连接" size="small" />
</n-space>
</n-card>
@@ -114,7 +114,7 @@ const capGroups = computed(() => {
</div>
<n-space>
<n-button size="tiny" type="primary" @click="connectTo(n.id)">连接</n-button>
<n-popconfirm @positive-click="nodeStore.remove(n.id)">
<n-popconfirm :show-icon="false" @positive-click="nodeStore.remove(n.id)">
<template #trigger>
<n-button size="tiny" quaternary>删除</n-button>
</template>
@@ -123,7 +123,7 @@ const capGroups = computed(() => {
</n-space>
</div>
</div>
<n-empty v-else description="暂无保存的设备" size="small" />
<n-empty :show-icon="false" v-else description="暂无保存的设备" size="small" />
<div class="add-row">
<n-input v-model:value="newName" placeholder="名称" size="small" style="flex: 1" />
@@ -149,7 +149,7 @@ const capGroups = computed(() => {
</div>
</n-collapse-item>
</n-collapse>
<n-empty v-else description="连接后展示" size="small" />
<n-empty :show-icon="false" v-else description="连接后展示" size="small" />
</n-card>
<n-card title="日志" size="small">
@@ -195,11 +195,11 @@ const capGroups = computed(() => {
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border: 1px solid rgba(255, 255, 255, 0.09);
border: 1px solid var(--na-border);
border-radius: 8px;
}
.node.active {
border-color: #63e2b7;
border-color: var(--na-accent);
}
.node-name {
font-size: 14px;
@@ -219,7 +219,7 @@ const capGroups = computed(() => {
font-size: 12px;
padding: 2px 8px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.06);
background: var(--na-sunken);
}
.logs {
font-family: ui-monospace, Menlo, monospace;
+6 -6
View File
@@ -69,7 +69,7 @@ onUnmounted(stop);
<template>
<div v-if="!conn.connected">
<n-empty description="请先在「设备管理」页连接被控端" style="margin-top: 80px" />
<n-empty :show-icon="false" description="请先在「设备管理」页连接被控端" style="margin-top: 80px" />
</div>
<div v-else class="wrap">
@@ -102,7 +102,7 @@ onUnmounted(stop);
type="line"
:percentage="Math.round(status?.cpu_pct ?? 0)"
:height="10"
:color="(status?.cpu_pct ?? 0) > 80 ? '#e88080' : '#63e2b7'"
:color="(status?.cpu_pct ?? 0) > 80 ? 'var(--na-err)' : 'var(--na-ok)'"
/>
</div>
<div class="gauge">
@@ -113,7 +113,7 @@ onUnmounted(stop);
type="line"
:percentage="Math.round(status?.memory_pct ?? 0)"
:height="10"
:color="(status?.memory_pct ?? 0) > 85 ? '#e88080' : '#63e2b7'"
:color="(status?.memory_pct ?? 0) > 85 ? 'var(--na-err)' : 'var(--na-ok)'"
/>
</div>
<n-statistic
@@ -138,11 +138,11 @@ onUnmounted(stop);
type="line"
:percentage="Math.round(d.used_pct ?? 0)"
:height="8"
:color="(d.used_pct ?? 0) > 90 ? '#e88080' : '#63e2b7'"
:color="(d.used_pct ?? 0) > 90 ? 'var(--na-err)' : 'var(--na-ok)'"
/>
</div>
</div>
<n-empty v-else description="无磁盘数据" size="small" />
<n-empty :show-icon="false" v-else description="无磁盘数据" size="small" />
</n-card>
</n-grid-item>
@@ -159,7 +159,7 @@ onUnmounted(stop);
</b>
</div>
</div>
<n-empty v-else description="无网络数据" size="small" />
<n-empty :show-icon="false" v-else description="无网络数据" size="small" />
</n-card>
</n-grid-item>
</n-grid>
+5 -5
View File
@@ -128,11 +128,11 @@ onMounted(() => {
<template>
<div v-if="!conn.connected">
<n-empty description="请先在「设备管理」页连接被控端" style="margin-top: 80px" />
<n-empty :show-icon="false" description="请先在「设备管理」页连接被控端" style="margin-top: 80px" />
</div>
<n-card v-else size="small">
<n-alert v-if="listError" type="error" :show-icon="true" style="margin-bottom: 12px">
<n-alert v-if="listError" type="error" style="margin-bottom: 12px">
{{ listError }}
</n-alert>
@@ -151,7 +151,7 @@ onMounted(() => {
</n-button>
<n-text depth="3" style="font-size: 12px">{{ filteredProcs.length }} / {{ procs.length }}</n-text>
</n-space>
<n-empty v-if="!filteredProcs.length" description="暂无进程数据" size="small" />
<n-empty :show-icon="false" v-if="!filteredProcs.length" description="暂无进程数据" size="small" />
<n-data-table
v-else
:columns="procCols"
@@ -163,7 +163,7 @@ onMounted(() => {
</n-tab-pane>
<n-tab-pane name="svc" tab="服务">
<n-alert v-if="!servicesAvailable" type="warning" :show-icon="true">
<n-alert v-if="!servicesAvailable" type="warning">
`system.service.list` 仅在被控端为 <b>Windows</b> 时可用;当前被控端为
<b>{{ conn.nodeOs ?? "未知" }}</b>。
</n-alert>
@@ -173,7 +173,7 @@ onMounted(() => {
<n-button size="small" :loading="loading" @click="loadServices">刷新</n-button>
<n-text depth="3" style="font-size: 12px">{{ services.length }} 项</n-text>
</n-space>
<n-empty v-if="!services.length" description="暂无服务数据" size="small" />
<n-empty :show-icon="false" v-if="!services.length" description="暂无服务数据" size="small" />
<n-data-table
v-else
:columns="svcCols"
+6 -6
View File
@@ -111,20 +111,20 @@ onMounted(() => {
<template>
<div v-if="!conn.connected">
<n-empty description="请先在「设备管理」页连接被控端" style="margin-top: 80px" />
<n-empty :show-icon="false" description="请先在「设备管理」页连接被控端" style="margin-top: 80px" />
</div>
<div v-else class="wrap">
<n-alert v-if="!available" type="warning" :show-icon="true" style="margin-bottom: 14px">
<n-alert v-if="!available" type="warning" style="margin-bottom: 14px">
软件能力(<code>app.list</code> / <code>app.install</code>)依赖被控端 <code>winget</code>,
<b>仅在被控端为 Windows 时可用</b>;当前被控端为 <b>{{ conn.nodeOs ?? "未知" }}</b>,
本页已禁用。
</n-alert>
<n-alert v-else type="info" :show-icon="true" style="margin-bottom: 14px">
<n-alert v-else type="info" style="margin-bottom: 14px">
软件安装依赖被控端 <code>winget</code>。安装可能耗时数分钟,已把调用超时放大到 10 分钟。
</n-alert>
<n-alert v-if="error" type="error" :show-icon="true" style="margin-bottom: 14px">
<n-alert v-if="error" type="error" style="margin-bottom: 14px">
{{ error }}
</n-alert>
@@ -157,7 +157,7 @@ onMounted(() => {
<n-text depth="3" style="font-size: 12px">{{ filtered.length }}</n-text>
</n-space>
</template>
<n-empty
<n-empty :show-icon="false"
v-if="!apps.length && !loading"
:description="available ? '暂无数据(点刷新)' : '当前被控端非 Windows,不支持'"
size="small"
@@ -175,7 +175,7 @@ onMounted(() => {
margin: 12px 0 0;
padding: 10px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.05);
background: var(--na-sunken);
font-size: 12px;
max-height: 200px;
overflow: auto;
+3 -3
View File
@@ -85,13 +85,13 @@ function reuse(c: string) {
<template>
<div v-if="!conn.connected">
<n-empty description="请先在「设备管理」页连接被控端" style="margin-top: 80px" />
<n-empty :show-icon="false" description="请先在「设备管理」页连接被控端" style="margin-top: 80px" />
</div>
<div v-else class="wrap">
<n-card size="small" title="命令执行(system.shell.exec)">
<template #header-extra>
<n-text depth="3" style="font-size: 12px">⚠ 高权限操作,请谨慎</n-text>
<n-text depth="3" style="font-size: 12px">高权限操作,请谨慎</n-text>
</template>
<n-input
v-model:value="cmd"
@@ -161,6 +161,6 @@ function reuse(c: string) {
opacity: 0.85;
}
.hist:hover code {
color: #63e2b7;
color: var(--na-accent);
}
</style>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/hub",
"version": "2.0.1",
"version": "2.0.3",
"private": true,
"type": "module",
"main": "./dist/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/mcp",
"version": "2.0.1",
"version": "2.0.3",
"private": true,
"type": "module",
"main": "./dist/index.js",
+238 -11
View File
@@ -19,7 +19,7 @@
| **刻意不可验** | 第三方杀软拦截(火绒等) | ❌ VM 里只有 Defender |
| | 显示 / DPI / 多显示器 / 坐标校准 | ❌ VM 显示栈不同 |
| | 性能指标(P95 时延等) | ❌ VM 数字不可比 |
| **需注意** | `win-x64` 包在 ARM Windows 上走 **Prism 模拟** | ⚠️ 能跑但慢;**这本身是值得记录的兼容性结论**(见 B5) |
| **需注意** | `win-x64` 包在 ARM Windows 上走 **Prism 模拟** | ❌ **实测结论(2026-10-11):能启动、能 listen,但 TLS 握手不完成**(控制端只见 `E_TIMEOUT`)→ 被控端实际不可用。ARM Windows **必须**用 `nodeagent-win-arm64.zip`(见 B5) |
---
@@ -33,11 +33,11 @@
└ 共享/NAT 下 VM 拿不到同网段 IP → UDP 广播发现(v4)测不了,Mac 也未必能直连
③ VM 内装 Node:优先 **ARM64 版**(避开 Prism 模拟层)
└ 若想验分发包,另见 B5(用项目打的 win-x64 包)
└ 若想验分发包,同样**必须用 `nodeagent-win-arm64.zip`** —— x64 包在 ARM 上握手不通(见 B5)
④ VM 内起被控端(二选一)
a. 免安装:双击 run-portable.cmd (不注册计划任务,适合快速验能力)
b. 正式装:解压 nodeagent-win-x64.zip → 双击 install.cmd(自提权,会建计划任务)
b. 正式装:解压 nodeagent-win-arm64.zip → 双击 install.cmd(自提权,会建计划任务)
⑤ Mac 端登记设备并连接
nodeagent --node vm connect <VM的IP> --key <密钥> --insecure
@@ -133,14 +133,35 @@ node scripts/anchor-audit.mjs verify --anchors /tmp/vm-anchor.jsonl --node vm
```
**判定**:`collect` 能拿到链头、云盘上传成功、`verify` 返回 `ok:true`。
#### B5. `win-x64` 包在 ARM Windows 上的兼容性(额外结论)
#### B5. 分发包在 ARM Windows 上的架构适配(✅ 已验,2026-10-11)
**结论:ARM Windows 上必须用 `nodeagent-win-arm64.zip`;`win-x64` 包不可用。**
同机、同密钥、同参数的对照实测:
| 包 | 内含 `node.exe` | 结果 |
|---|---|---|
| `nodeagent-win-arm64.zip` | ARM64(PE `0xaa64`) | ✅ TLS 握手成功 → PSK 认证 → 声明 51 项能力(两端一致)→ `info`/`status`/`process.list`/`service.list`/`shell.exec`/`audit verify` 全通;`system.info.arch` 报 `arm64`(原生,非模拟层) |
| `nodeagent-win-x64.zip` | x86-64(PE `0x8664`) | ❌ 能启动、能 listen,但 `connect` 报 `E_TIMEOUT` —— **TLS 握手不完成** |
> 踩坑记录:v2.0.1 的 arm64 资产曾**误装 x64 node**(`pack.mjs` 的运行时缓存文件名不含架构 →
> 打 arm64 时复用了 x64 的缓存),两份公开包内 `node.exe` 字节完全相同(同 sha256)。
> 该缺陷在 macOS 上 **100% 复现不出**,只有 ARM 目标机才暴露。
> 现 `pack.mjs` 解包后断言 PE Machine,CI 亦直接校验最终产物内 `node.exe` 的 PE 头。
**复验方式**:
```bash
# 把项目打的包解压到 VM,用**包内自带的 x64 Node**起被控端(走 Prism 模拟)
# 期望:能起、能被连接、基础能调用
# ① VM 内,用包内自带 node 起被控端(务必 arm64 包)
C:\nodeagent-test\node.exe C:\nodeagent-test\agent.mjs
# 注意:以 SYSTEM(会话 0)启动时 screen.capture 等图形能力必失败(会话 0 无交互桌面),
# 正式安装路径不受影响 —— install.ps1 的计划任务用登录用户 + LogonType Interactive。
# ② Mac 侧连入
nodeagent connect <VM的IP> --port 8765 --key <密钥> --insecure --id vm
```
**判定**:能跑 → 说明分发包对 ARM Windows 也友好(**值得写进 README 的加分结论**);
不能跑或异常 → 记录现象,并在文档里注明"分发包需 ARM64 版本方能原生运行"。
**判定**:`connect` 成功并列出 51 项能力 = 通。若只见 `E_TIMEOUT`,先查包内 `node.exe` 是不是 ARM64。
---
@@ -165,7 +186,7 @@ A 基础验收(verify.mjs)
| 架构无关项(B2/B3/B4)在 VM 验过 | `✅ 已验证(VM · Windows 11 ARM)` |
| 输入/系统能力(A/B1)在 VM 验过 | `✅ 已验证(VM)`,**若涉及坐标/DPI 则加注「坐标类待真机复验」** |
| 杀软交互 / 显示 / 性能 | `⏳ 待真机`(VM 不可替代,**不得**因 VM 通过就标已验) |
| x64 包在 ARM 上跑通(B5) | `✅ 兼容性结论:win-x64 包可在 ARM Windows 运行(经 Prism)` |
| 分发包架构适配(B5) | `✅ 已验证(VM · Windows 11 ARM):arm64 包原生可用;win-x64 包 TLS 握手不完成、不可用` |
> **一条纪律**:VM 与真机**架构不同**(真机 i7-12650H = x86_64)。任何"VM 通过"
> 都不能自动推出"真机通过";涉及**原生二进制、驱动、杀软、显示栈**的部分尤其如此。
@@ -181,11 +202,27 @@ A 基础验收(verify.mjs)
| 分辨率/坐标与真机不符 | VM 显示栈不同 | 只做逻辑验证,坐标待真机 |
| 性能数字明显偏低 | VM + 可能的 Prism 模拟 | **不要**采信 VM 的性能指标 |
| 杀软相关错误复现不了 | VM 里只有 Defender | 需真机(或另行安装第三方杀软) |
| x64 Node 启动慢/异常 | 走 Prism 模拟 | 换 ARM64 Node 复核 |
| **x64 Node 在 ARM Windows 上"启动慢/协议异常"** | 走 **Prism 模拟层**,TLS 握手下行为异常(能 listen,握手不完成) | 换 **ARM64 Node**(用 `nodeagent-win-arm64.zip`) |
| **`screen.capture` 报 `E_EXECUTION_FAILED`** | 被控端跑在 **SYSTEM(会话 0)**,会话 0 **无交互桌面** | 以**登录用户 + `LogonType Interactive`** 启动(见 §8 配方);**非产品缺陷** |
| **autologon 对微软账户(MSA)无效** | MSA 凭据由 **CloudAP** 处理,注册表 `DefaultPassword`(Winlogon→LSA)那条路对它不生效 | 建**本地账户**做 autologon(`LogonUser` 对 MSA 恒返回 `1326`) |
---
## 7. 实际验证结果(2026-10-11,Parallels 27 + Win11 ARM)
## 7. 实测记录(VM)
| 日期 | 项 | 结论 | 证据 |
|---|---|---|---|
| 2026-10-11 | **B5 架构适配** | ✅ arm64 包原生可用;❌ win-x64 包不可用 | 同机同参对照:arm64 握手成功 / x64 `E_TIMEOUT` |
| 2026-10-11 | 基础能力(A 子集) | ✅ | `system.info`(`arch=arm64`、能力 51 项两端一致)/ `status` / `process.list` / `service.list` / `shell.exec`(退出码 0)/ `audit.list` / `audit verify`(链完整)均正常 |
| 2026-10-11 | **图形能力** | ✅ **登录态下全通** | 以登录用户 + `LogonType Interactive` 启动后:`screen.capture` 返回**真实桌面** 1800×1010(含中文,渲染正常)/`window.list` 返回窗口列表(前台窗口 PID 与实测一致)/`screen` 枚举显示器/`audit verify` 链完整。**证实会话 0 的失败是身份问题,非产品缺陷** |
> 本轮**尚未执行**:B1(v1.5 四项输入)/ B2(v23 三防护)/ B3(v26 重启清理)/ B4(审计锚点端到端)。
> 另注:VM 的 `prlctl exec` 默认以 **SYSTEM(会话 0)** 身份执行,图形类能力须换登录用户会话方可复验。
> **图形能力复验配方(本轮实证可通过)**:见 §8 末节。
---
## 8. 实际验证结果(2026-10-11,Parallels 27 + Win11 ARM)
> 本节是**实测记录**,不是计划。环境:Parallels 共享网络,VM `10.211.55.9`,
> Mac 侧 `bridge100=10.211.55.2`;被控端 `-NodeId win-arm -AllowInput`。
@@ -229,3 +266,193 @@ ARM Windows(含 Parallels Win11 ARM)请用 **arm64 包**;用 x64 包会卡
| 计划任务启动后端口不通 | **node.exe 被手工进程占用**(覆盖失败)+ 8765 被占(任务起来即冲突退出) | 先 `Get-Process node \| Stop-Process -Force` 再解压/启动 |
| 自测时 `socket hang up` | **URL 写成 `ws://` 而服务端是 `wss`**(`tls:true`) | 用 `wss://` |
| 测 `max_connections` 把自己锁在外面 | 测试值太小 + 被拒连接会被计入握手失败 → 恶性循环 | ① 测试时留余量 ② **给自己加 `auth_ban.whitelist`** |
---
## 9. 图形能力复验配方(2026-10-11 实证)
图形类能力(`screen.capture` / `window.list` / `input.*` / `screen.find`)在 VM 里失败的**唯一原因**
是被控端跑在 **SYSTEM(会话 0)**——会话 0 没有交互桌面。要让它们可用,须让被控端跑在
**已登录用户的会话**里。完整配方(本轮实测可复现):
### ① 建立登录会话(关键坑:微软账户不行)
VM 无人登录时停在 LogonUI。可脚本化的路径只有 **autologon**,但:
> **微软账户(MSA)不支持传统 autologon。** MSA 凭据由 **CloudAP** 凭据提供程序处理,
> 而 autologon 走的是 Winlogon→LSA + 注册表 `DefaultPassword` 那条**老路**。
> 实测:对 MSA 账户调 `LogonUser` 四种域形式**全部返回 `1326`(用户名或密码错误)**,
> 且 Winlogon 日志里**连一次登录尝试都不记录**(账户解析阶段就被跳过)——
> 极易误判为"密码打错了"。
**配方**:新建一个**本地账户**再 autologon:
```powershell
$pw = ConvertTo-SecureString '<password>' -AsPlainText -Force
New-LocalUser -Name pdverify -Password $pw -PasswordNeverExpires -AccountNeverExpires
Add-LocalGroupMember -Group Administrators -Member pdverify
$k = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon'
Set-ItemProperty $k AutoAdminLogon '1'
Set-ItemProperty $k DefaultUserName 'pdverify'
Set-ItemProperty $k DefaultDomainName $env:COMPUTERNAME
Set-ItemProperty $k DefaultPassword '<password>' # 明文,验完务必清理
Restart-Computer -Force
```
### ② 以登录用户身份启动被控端(这一步才是图形能力的关键)
```powershell
$act = New-ScheduledTaskAction -Execute 'C:\nodeagent-test\node.exe' -Argument 'C:\nodeagent-test\agent.mjs'
$pri = New-ScheduledTaskPrincipal -UserId ($env:COMPUTERNAME + '\pdverify') -LogonType Interactive -RunLevel Highest
Register-ScheduledTask -TaskName 'na-gui' -Action $act -Principal $pri -Force
Start-ScheduledTask -TaskName 'na-gui'
```
`LogonType Interactive` = 「仅在用户登录时运行」→ 任务落在**该用户的交互会话**里。
### ③ 核对(三件事缺一不可)
| 检查 | 期望 |
|---|---|
| `Get-Process node \| Select Id,SessionId` | **SessionId = 1**(不是 0) |
| `Get-Process explorer \| Select SessionId` | 1(桌面已就绪) |
| `Get-LocalUser pdverify \| Select LastLogon` | 有值(确实登录过) |
### ④ 证书指纹会变(这是钉住机制在正常工作)
换了数据目录(新用户 → 新 `.nodeagent/`)→ agent **重新生成自签证书** →
客户端报 `E_CERT_MISMATCH`。用 `nodeagent connect <ip> --forget-cert --insecure` 重钉即可。
**注意**:这不代表被中间人,而是"换目录=换身份"的正常结果。
### ⑤ 验证完的清理(安全)
autologon 会把密码**明文**写进 `HKLM\...\Winlogon\DefaultPassword`:
```powershell
$k = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon'
Remove-ItemProperty $k DefaultPassword -ErrorAction SilentlyContinue
Set-ItemProperty $k AutoAdminLogon '0'
Unregister-ScheduledTask -TaskName 'na-gui' -Confirm:$false
Remove-LocalUser -Name pdverify # 临时账户一并删除
```
> 若需**保留**该环境供后续验证(重启后仍自动恢复图形会话),可暂不清理 ——
> 但要清楚:明文密码就留在注册表里。
---
## 10. 200% 缩放下的坐标口径,与 VM 输入通道的真机真相(2026-10-12)
全部在 **Parallels 27 + Win11 ARM64(200% 缩放)** 上实测。
§9 讲「怎么让图形能力跑起来」,本节讲「跑起来之后坐标为什么还是错的」以及「被控端自己不动了怎么救」。
### 10.1 缺陷本体:一个进程里并存两套坐标系
被控端的 PowerShell 助手进程**默认 DPI 不感知**(Windows 的默认行为):
| 调用 | 不感知时的结果 | 危害 |
|---|---|---|
| `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` 注入到**每个坐标脚本的最前面**。
### 10.2 复验配方(改完全部坐标路径后,这三条必须跑)
| 断言 | 怎么取 | 修复前 | 修复后应为 |
|---|---|---|---|
| 屏幕尺寸口径 | `screen.info` | `1800x1010` | **`3600x2020`**,且与**裸 Win32** `GetSystemMetrics(0/1)`(感知后)一致 |
| 缺省整屏截图 | `screen.capture`(**不传** `region`) | 只有左上 1/4 | 整屏(按 `scale` 缩放后返回) |
| 光标落点 | `input.mouse.move(x,y)` 后**用同样感知的助手**读回 `GetCursorPos` | 落到 (2x,2y) | **精确等于** `(x,y)` |
| 端到端零换算 | `screen.find` 的物理矩形中心**直接**喂 `input.mouse.click` | 点不中 | 命中(可用主题单选框的 UIA `selected` 翻转验证) |
> ⚠️ 读回光标时**助手也必须 DPI 感知**,否则读到的是逻辑值 —— 会得到「move 生效了但值差 2 倍」的假象。
### 10.3 被控端「输入注入整体失效」——VM 环境坑,**不是产品缺陷**
现象:`input.mouse.*` 与 `input.key.*` 全部无效,光标纹丝不动。
先把环境位逐条排干净:
| 检查 | 实测 |
|---|---|
| 会话 / 窗口站 / 桌面 | `session=1` / `WinSta0` / `Default` ✅ |
| `UserInteractive` | `True` ✅ |
| `OpenInputDesktop` | 成功 → `Default`;`SetThreadDesktop(它)` 报 `ERROR_BUSY(170)`(本就在该桌面)✅ |
| `ClipCursor` | 整屏 `0,0,3600,2020`,无裁剪 ✅ |
| 指点设备 | `HID-compliant mouse` + `Parallels USB Mouse Synchronization Device` 均 `Status OK` ✅ |
| `prl_mouf` 驱动 | `STATE: 4 RUNNING` ✅ |
| **进程来源** | 从 **node 子树** 与 **`prlctl enter`**(Parallels Tools 侧)两条**互不相干**的来源跑同一段探针,**结果完全一致** ❌ |
判决性证据(**纯 Win32 探针,不含任何本产品代码**):
```text
SetCursorPos(1500,1200) = FALSE ← 直改 win32k 指针:硬失败
SendInput(绝对) = 1 ← 已插入输入队列
SendInput(相对) = 1 ← 已插入输入队列
GetCursorPos = 410,1488 → 410,1488 ← 指针纹丝不动
GetAsyncKeyState(按键) = 0x0000 ← 连键盘注入也不被系统认可
```
**`SendInput` 报告成功,而键盘与鼠标一视同仁地无反应** —— 不是「指针被同步设备独占」,
而是**整条输入注入通道被吞在输入队列之后**。任何应用层代码都绕不过去。
复位手段实测:
| 手段 | 结果 |
|---|---|
| `--smart-mouse-optimize` 三档(`off` / `auto` / `on`) | ❌ 无效 |
| `prlctl suspend` + `start`(挂起 / 恢复) | ❌ 无效 —— resume 会**完整还原 win32k 状态**,卡住的输入态当然一起被还原 |
| **`prlctl stop --acpi` + `start`(真正冷启动 Windows)** | ✅ **立即恢复** |
> 结论:VM 输入栈瞬时卡死,**只有冷启动能复位**。
> 开工前先让宿主机把 VM 窗口真正当前台窗口交互一次(宿主机侧绝对鼠标集成会把 guest 指针从属过去;
> 实测 `--smart-mouse-optimize` 三档都不改变这一点)。
### 10.4 被控端「活着但不响应」——两条运维事实
**① 计划任务以 `RunLevel Highest` 拉起时,普通权限 `taskkill` 会「拒绝访问」。**
```text
na-gui user=pdverify logon=Interactive runlevel=Highest
```
→ 必须从**更高权限通道**杀:`prlctl enter "Windows 11"`(**不带** `--current-user`)
拿到 `nt authority\system` 的 shell,`taskkill /F /PID <pid>` 才会成功。
另外:旧进程仍占着 8765 时,`schtasks /run /tn na-gui` 会**报「执行成功」但新实例立即退出**
(表现为端口仍被旧 PID 占着)→ 必须**先杀后起**。
**② 客户端异常断开后,被控端会留一条 `CLOSE_WAIT` 并拒收新连接。**
```text
netstat -ano | findstr 8765
TCP 10.211.55.9:8765 10.211.55.2:57182 CLOSE_WAIT 9396
```
此时新连接**能完成 TCP 三次握手,但 TLS 握手永不完成**(控制端只见 `E_TIMEOUT`),
**不会自愈**,只能重启被控端进程。触发方式:客户端在请求进行中被 `SIGTERM`/`SIGKILL`
(即「不做优雅关闭」)。
> ⚠️ **尚未定论**是产品缺陷还是环境使然 —— 本轮只观察到现象与可复现路径,未取到被控端的卡点证据。
> 记此供后续专门排查。若成因是「`console.log` 写满无人读取的管道 → 事件循环阻塞」,
> 那对「被控端常驻、控制端随时可能硬断开」这种使用模式是个真问题。
### 10.5 两条 `prlctl` 通道的语义差异(本轮踩出来的)
| 命令 | 实测行为 |
|---|---|
| `prlctl exec <VM> -- <cmd>` | **返回 exit=0 但完全空转**:无输出、无副作用(用「guest 反连 Mac 监听端口」判决)→ **不可用** |
| `prlctl enter <VM>` | 默认 `nt authority\system`(会话 0,无交互桌面) |
| `prlctl enter <VM> --current-user` | 登录用户的**真实交互** shell ✅ |
另两条:
- `prlctl enter` **没有执行参数**(加 `--` 报 `Unrecognized option`),它是交互式 shell,
命令要**经 stdin 喂**:`printf 'cmd\r\n...\r\nexit\r\n' | prlctl enter ...`
- **`prlctl` 的参数里带逗号 / `&&` / 重定向会报 `Invalid argument`** —— 复杂逻辑一律先落成 `.cmd` / `.ps1` 再调用。
> 仅供技术参考,不构成任何投资建议。
+442 -10
View File
@@ -224,9 +224,18 @@ nodeagent/
| 命令闭环成功率 | 成功返回 ÷ 发起 | ≥ 95% |
| 内网调用 P95 时延 | 前端点击到结果渲染 | < 3s |
| 应用内存占用 | Mac 版空闲常驻 RSS | < 150MB(远低于 Electron) |
| 打包体积 | 单端安装包 | Mac `.app` < 30MB / Win 安装包 < 40MB |
| 打包体积 | **用户实际下载的分发件** | Mac `.dmg` < 40MB(实测 **33.8MB** ✓)/Win 安装包 < 40MB(实测 ✓ `.msi` **34.2 MiB**、NSIS **19.5~23.2 MiB**) |
| 双端编译 | `pnpm build` 一次产出两端 | 100% |
> **⚠️ 口径调整(2026-10-11,待确认)**:原指标写的是「Mac `.app` < 30MB / Win 安装包 < 40MB」。
> 那条是 **Tauri + Rust client** 时代的数字 —— 当时应用内不含 JS 运行时。
> 改为 Node sidecar 方案后,应用必须自带 Node 运行时(sidecar 是 `.mjs`,不能假定用户装了 Node),
> 故 `.app` 实测 **90.8 MiB**(其中 Node 运行时 86MB)。
> **但用户下载的是 `.dmg`,实测 33.78 MiB** —— 仍低于原定 40MB。
> 故本表口径改为「分发件体积」。作为对照:本机实测 Electron 运行时本体
> (`Electron Framework.framework`)为 **207MB**,即 Tauri + Node 的运行时开销
> 仍不到 Electron 的 **一半**(86MB vs 207MB)。
---
## 9. 里程碑拆分
@@ -414,16 +423,47 @@ Rust 侧从 resource 目录解析路径后 spawn(开发期直接用系统 node
**真跑 Node 子进程**验证 spawn / 请求-响应匹配 / 错误不崩 —— 不必等到人工开窗口才敢确认。
`cargo test` 实测:**3/3 通过**(0.49s)。
### ⚠️ 两处已知待办(本轮未做,均需真机/图形环境)
### Node 运行时分发(2026-10-11 完成)
1. **Node 运行时随包分发**:sidecar 是 `.mjs`,目标机器需有 Node。开发期用系统 node
(`resolve_node` 会回退到 PATH);**分发给他人时**需把 Node 运行时作为 resource 打进包。
计划:写 `scripts/prepare-console.mjs` 下载对应平台 node 到 `src-tauri/resources/`,
并在打包时用 `tauri build --config` 注入 `bundle.resources` 的 node 项
(**不能写死在默认 conf 里** —— `tauri-build` 会在编译期校验文件存在性,
声明未下载的文件会让 `cargo check/test` 直接失败,本轮已踩并修复)。
2. **GUI 端到端人工验证**:开窗口 → 连接被控端 → 操作界面。
沙箱无法运行图形应用,故本轮的验证止于 `cargo test`(真跑 sidecar)+ 单测/构建全绿。
sidecar 是 `.mjs`(不是可执行文件,故**不满足 Tauri `externalBin` 的要求**),
分发给他人时须把 Node 运行时一并打进包。做法沿袭被控端 `scripts/pack.mjs` 的既有路子:
**把 Node 运行时当作 Tauri resource**。
```
scripts/prepare-console.mjs ① 从本机 node 复制 → apps/console/src-tauri/runtime/node
② macOS:strip -x(108MB→86MB)**并 adhoc 重签**
③ 生成 apps/console/src-tauri/tauri.bundle.gen.json
{ "bundle": { "resources": {
"../sidecar/dist/sidecar.mjs": "sidecar.mjs",
"runtime/node": "node" } } }
pnpm --filter nodeagent-console bundle
= sidecar:build → prepare:node → tauri build --config tauri.bundle.gen.json → verify:bundle
```
**三处必须记住的约束**(都是实测踩出来的):
1. **绝不写进默认 `tauri.conf.json`** —— `bundle.resources` 是**编译期契约**,
`tauri-build` 会校验声明的每个文件必须存在;声明一个「运行期才准备好」的文件会让
`cargo check/test` 直接失败(2026-10-10 已踩)。故一律走 `--config` 注入。
2. **macOS 上 strip 之后必须 adhoc 重签** —— `strip` 会使 Mach-O 代码签名失效,
而 macOS 内核对签名无效的可执行文件**直接 SIGKILL(exit 137)**,无任何有用报错。
实测对照:原样复制 ✓|strip 不重签 ✗(137)|strip + `codesign --force --sign -` ✓。
3. **`resolve_node` 与 resource 映射必须对齐** —— 映射 `runtime/node → node`
使产物落在 `Contents/Resources/node`,正是 `src-tauri/src/sidecar.rs` 里
`resolve_node(resource_dir)` 查找的路径。
**验证(不只看「打包成功」)**:`apps/console/scripts/verify-bundle.mjs` 真 spawn 打包内的
node 去跑打包内的 sidecar,并走一次真实 JSON-RPC 握手 —— 源码与单测都正常、
只在产物中暴露的问题(如上述 137)靠它拦下。已接入 CI(Linux 与 Windows 双覆盖)。
**实测体积**:`.app` **90.8 MiB**(6.75 MiB → 90.8 MiB,增量即 Node 运行时 86MB)/
`.dmg` **33.78 MiB**(分发件,仍 < 40MB 目标)。
### ⚠️ 剩余一处已知待办(需图形环境)
**GUI 端到端人工验证**:开窗口 → 连接被控端 → 操作界面。
沙箱无法运行图形应用,故验证止于 `cargo test`(真跑 sidecar)+ `verify:bundle`(真跑产物)
+ 应用进程可启动(实测存活、无崩溃输出)。**界面交互仍需在图形环境点一遍。**
### 一个易踩的构建约束(本轮 CI 失败后查明)
@@ -432,3 +472,395 @@ Rust 侧从 resource 目录解析路径后 spawn(开发期直接用系统 node
- 只声明**构建期必会生成**的产物(如 `sidecar.mjs`,由 beforeBuildCommand 产出)
- CI 里在 clippy/test **之前**必须先生成它,否则 build.rs 阶段就失败
- 本地复现:删掉 `sidecar/dist/` → `cargo check` 报 "failed to run custom build command" ✓
---
## 本机服务托管(2026-10-11):把「命令行那一步」从用户手里拿走
### 需求原文与逐条落点
> 「不再打开命令行窗口,运行应用之后,有启动、停止、重启、检查更新等功能,
> 有日志功能,可以后台运行和开机自启等等功能」
| # | 要求 | 落点 | 实现位置 |
|---|---|---|---|
| 1 | **不再打开命令行窗口** | 三处(见下表) | `agent.rs` / `autostart.rs` / Tauri 自身 |
| 2 | 启动 / 停止 / 重启 | 「本机服务」页三按钮 + 托盘菜单 + 侧栏快捷按钮 | `AgentManager` |
| 3 | 检查更新 | 「更新」卡片:检查 → 更新 → 打开 Release 页 | `check_update` / `download` / `apply_update` |
| 4 | 日志功能 | 「服务日志」页(实时推送 + 过滤 + 暂停 + 导出) | `na-agent-log` 事件 + `agent_logs` |
| 5 | 后台运行 | 托盘常驻;关窗 → 最小化到托盘 | `build_tray()` + `on_window_event` |
| 6 | 开机自启 | 「运行方式」卡片开关 | `autostart.rs` |
### 消灭命令行窗口:三条措施缺一不可
| # | 措施 | 不加会怎样 |
|---|---|---|
| 1 | 控制台本身是 GUI 子系统程序 | 打开应用先弹一个黑窗 |
| 2 | spawn `node.exe` 时带 `CREATE_NO_WINDOW`(`0x0800_0000`) | **每次点「启动」都闪一个黑窗** —— 正是要消灭的现象 |
| 3 | 开机自启拉起的是控制台(`--agent-only`),不是裸 `node` | 开机时黑窗一闪而过 |
> 第 2 条是最容易被漏掉的一条:Rust 的 `std::process::Command` **默认不设**这个标志,
> 于是「界面上点一下启动」= 屏幕上闪一个控制台窗口。它不报错、不影响功能,
> 所以只有在**真的看着屏幕点**的时候才会发现。
### 架构:控制台自己就是被控端的「进程管理者」
```
┌──────────────────── apps/console ────────────────────┐
│ Vue:本机服务页 / 服务日志页 │
│ │ invoke ▲ event │
│ ▼ │ na-agent-state │
│ Rust:AgentManager ──────────────────────────────┐ │
│ │ ① spawn node agent.mjs(CREATE_NO_WINDOW)│ │
│ │ ② 800ms 轮询 try_wait(不把 Child 交给 wait()) │
│ │ ③ stdout/stderr 双泵 → 环形 4000 行 + seq │ │
│ ▼ │ │
│ Node 被控端(本机,loopback 8765)◄──── 同一个 agent 复用 ─┘
└──────────────────────────────────────────────────────┘
```
**复用同一个被控端实现**:控制台托管的这台本机被控端,与控制端连远端用的是**同一份 `agent.mjs`**,
不做「控制台内嵌一个第二套被控端」。这与 §「主控被控一体」的既有决策一致。
### 四条实测踩出来的关键约束
| # | 约束 | 为什么(踩坑记录) |
|---|---|---|
| 1 | **退出监控用轮询 `try_wait`,绝不把 `Child` 交给 `wait()`** | `wait()` 会**取得 `Child` 的所有权**,此后 `stop()` 再也拿不到它去 kill → 点「停止」无效 |
| 2 | **日志游标用独立单调 `seq`,不用位置下标** | 环形缓冲淘汰旧行后位置下标会漂移 → 前端增量拉取**静默错位**(重复行 / 丢行,且不报错) |
| 3 | **`start()` / `restart()` 的接收者必须是 `self: &Arc<Self>`** | 监控任务要持有自持引用;`&self` 拿不到 `Arc`,编译期 E0599/E0502 |
| 4 | **macOS `gui/<uid>` 的 uid 运行时 `id -u` 现取** | 硬编码 `501` 只是首个本地用户的常见值,多用户机不成立 |
### 播种(seeding):为什么必须有版本比较
控制台随包带一份 `agent.mjs`,首次运行复制到数据目录。规则:
```
随包版本 > 已安装版本 → 覆盖(用户拿到了更新的控制台,应带上更新的被控端)
随包版本 ≤ 已安装版本 → 保留数据目录里的(用户可能已自更新到更新版本)
```
若写成「同版本才跳过」,则**用户自更新过被控端后,下次开控制台会被静默降回旧版** ——
无任何提示,能力凭空变少,且极难归因。已用单测钉死「随包版本更低时不得覆盖」。
版本比较按**数值逐段**(`2.0.10 > 2.0.9`):字符串比较会把前者判小。
### 判定「实例是否在跑」为什么不靠单一信号
控制台同时面对两种被控端:
| 情况 | 判定方式 | 界面处置 |
|---|---|---|
| 本控制台托管的实例 | 管理器状态 = `RUNNING` | 可停止 / 重启 |
| **手工安装的非托管实例**(用户先跑过 `install.ps1` / `install-macos.sh`) | 管理器状态 = `STOPPED` **但** TCP 探测端口已被占用 | **明确告警**:这不是本控制台启的,无法代它停止;给出两条出路 —— 手工停掉,或用「连接本机」直接复用 |
只看管理器状态会把第二种情况判成「未运行」,然后用户点「启动」得到一个端口冲突;
只看端口探测则会把「别人的服务」误报成本控制台托管的实例。**两个信号交叉判**才能给出正确建议。
### 契约守卫:钉住三类「不报错的失效」
界面点按钮没反应、日志不刷新,往往**没有任何异常** —— 因为失效发生在「两边名字不一致」这种
编译器和类型系统都看不见的地方。新增 `tests/unit/console-commands.test.mjs`(8 用例)覆盖:
| 守卫 | 若缺失会怎样 |
|---|---|
| `invoke("x")` ⇄ Rust `generate_handler!` 双向 | 前端调不存在的命令;或改名后残留死命令 |
| `emit("na-*")` ⇄ `listen("na-*")` 双向 | **界面永不刷新,零报错**(最阴的一类) |
| `tauri.bundle.gen.json` 资源名 ⇄ Rust `resolve_*` 字面量 | 生产包内找不到 sidecar / node / agent |
| sidecar 方法名 ⇄ Rust `.request("x")` | 跨语言调用错名 |
| 路由表 ⇄ 实际 view 文件 + 默认路由 | 写了页面进不去;循环依赖导致路由推导成 `any` |
| `var(--na-*)` 使用点 ⇄ `cssVars()` 定义 | 拼错变量名**不报错**,颜色静默落回继承值 |
| `index.html` 首帧兜底色 ⇄ 调色板 `bg` | 首帧兜底是全应用唯一允许的色值重复处,允许重复就必须有守卫 |
> 该测试**已注入故障实测会红**:① 改 `agent_start` → `agent_startX`、`na-agent-state` → `na-agent-statee`
> 当场 2 条失败;② 把 `var(--na-sunken)` 拼成 `var(--na-sunkenTypo)`(含大写)与 `var(--na-sunkenx)`,
> 变量闭合那条均失败;③ 把 `index.html` 的 `#f4f4f5` 改回 `#ffffff`,首帧底色那条失败。
> 全部复原后 `#pass = 8 / #fail = 0`,文件逐字节还原。
>
> 第 ② 条暴露过一次**守卫自身漏报**:正则原写 `[a-z0-9-]+`,遇到含大写的拼错会在中途停下导致
> 整个模式不匹配 —— 于是「写错的变量名」反而逃过检查。放宽到 `[^)\s,;]+` 后才真正生效。
> 教训:**注入故障不只是验证守卫有没有,还要验证它是否覆盖了目标形态**。
## Windows 端控制台(2026-10-11)
### 起因
控制台此前**只在 macOS 上出过包**(`.app` / `.dmg`)。而它的主要用户场景恰恰是
「在 Windows 机器上管控其他机器」——只出 macOS 版等于把主战场让开了。
排查后发现:代码层面对 Windows 其实是备好的(`cfg!(windows)` 分支、`node.exe` 路径、
`creation_flags`、PowerShell 计划任务、`.ico` 图标、`prepare-console.mjs` 的平台自适应都在),
**缺的只是「真在 Windows 上构建一次」这一环**。
> 这里有个容易被忽视的点:macOS 上的 `cargo check` **不会编译 `cfg(windows)` 代码**,
> 所以那些 Windows 分支在此之前**从未被编译器看过一眼**。而 `cfg!(windows)` 这种运行时宏
> 虽然两边都会编译,却也只是类型检查、从未产出过 Windows 产物。
### 为什么必须双架构
被控端发布包出过「arm64 包里装的其实是 x64 `node.exe`」的事故,后果是:
在 ARM Windows 上**能启动、能 listen,但 TLS 握手不完成**(客户端只见超时),
且 macOS 侧 100% 复现不出。
控制台同理,而且更甚 —— 它内含 node 运行时,还要用这个 node **托起本机被控端**,
即自己就是 TLS 服务端。所以 x64 / arm64 必须各出一套,不能只出默认架构。
### 为什么用原生 ARM runner,而不是交叉编译
GitHub 提供 ARM64 Windows runner(`windows-11-arm`,公共仓库免费),
本仓库是 public,故 arm64 可以在它上面**原生构建**。
交叉编译(在 x64 runner 上编 arm64)看似省事,实则要付两笔代价:
1. 需要 VS 的 ARM64 工具链,且 Tauri 的 NSIS/MSI bundler 对 arm64 支持面较窄;
2. 更关键:`prepare-console.mjs` 的**自检要真执行 `node.exe`**
(x64 机器跑不了 arm64 二进制),会逼着引入「跳过自检」这类**削弱验证**的开关。
而当产出的是「架构错的运行时」时,自检正是唯一能拦住它的那道闸。
宁可等原生 runner,也不引入削弱验证的分支。
### 三条断言是必需项,不是顺手检查
| 断言 | 为什么不能省 |
|---|---|
| **构建机架构 ⇄ 目标架构**(`node -p process.arch`) | ARM runner 镜像里确实存在 x64 模拟的组件(社区实测 python 即为 AMD64)。一旦 Node 也是模拟版,`process.arch` 会是 `x64` → 把 x64 的 `node.exe` 打进 arm64 安装包 → **真机上能启动但 TLS 握手不完成,而 CI 全绿** |
| **随包 `node.exe` 的 PE Machine**(打包前,查 `src-tauri/runtime/`) | 与 `pack.mjs` 的双架构校验同源:不只看「脚本跑完了」,而是直接读最终要打进包的那个二进制头。中间逻辑再自洽,也可能因上游给了错架构的二进制而产出错包。放在打包前,是为了让失败点更靠近原因 |
| **安装包里确实装着三样东西**(打包后,解开安装包查内容) | 见下节。它才是**最终交付物级**的断言 |
### 首个 run 双双失败:错的不是构建,是断言本身
`console-windows.yml` 的第一个 run(`38151366534`)两个 job 全红,且**失败点完全一致**。
但取日志后发现,**构建链路一路全绿**:
| 步骤 | x64 job | arm64 job |
|---|---|---|
| 构建机架构 | ✓ | ✓ `process.arch = arm64`(原生 ARM runner 属实) |
| 随包 `node.exe` PE | ✓ `0x8664` | ✓ `0xaa64`(73.5 MB) |
| `tauri build` | ✓ `.msi` 34.2 MiB + `-setup.exe` 23.2 MiB | ✓ `-setup.exe` 19.5 MiB |
| 产物自举 | ✓ 打包内 node v22.23.3 真跑起打包内 sidecar | ✓ |
| **体积断言** | ✗ `34.2 < 50` | ✗ `19.5 < 50` |
死的是最后一步,而这一步是**守卫自己写错了**。原断言是「安装包体积 > 50MB」,
> 另断言安装包体积 > 50MB:含 Node 运行时时 x64 约 82MB / arm64 约 72MB,
> 体积过小意味着运行时没打进去 —— 那种包能装,但一启动就找不到 node。
错在把**未压缩的目录形态**(`.app` ≈ 92 MiB)当成了**压缩安装包的体积**。
安装包是压缩容器:LZMA 下 `node.exe` 自身就能压到 1/3~1/4,实测 `.msi` 34.2 MiB、
NSIS 19.5~23.2 MiB,阈值 50MB 必然误杀。
> 更根本的问题:**体积阈值属于「中间逻辑自洽」式断言**。包大不等于 `node.exe` 真在里面
> (可能打进的是别的大文件),包小也不等于没在里面(压缩率会随 Node 版本漂移)。
> 这与「被控端 zip 里塞了错架构的 node」那条教训是同一类 —— 判据必须落在**产物内容**上,
> 不能落在**间接指标**上。
改为**解开安装包、查内容**(`.msi` 走 `msiexec /a` 管理安装、NSIS 走 `/S /D=` 静默安装,
两者都是各自格式的核心能力,不引外部依赖),一次钉死三件事:
| 检查 | 挡住什么 |
|---|---|
| `node.exe` / `sidecar.mjs` / `agent.mjs` **三样都在** | 缺任一,真机上就是「应用能开、但托不起被控端」 |
| `node.exe` 的 **PE Machine = 目标架构** | 「arm64 包里装的是 x64 node.exe」那类**只在真机暴露**的事故 |
| `node.exe` 解压后 **> 40MB** | 塞了个占位文件(这里是「体积」的**正确用法**:作为内容已验明后的自明性检查,而不是作为「运行时在不在」的**替代判据**) |
附带收益:**真装一遍本身就是安装器冒烟** —— 比只读目录更强。
另给该步加 `timeout-minutes: 8`,避免静默安装万一卡住把 job 拖满 60 分钟。
### 第二个 run 仍红:`&` 调 GUI 程序不等待,也不设退出码
第二个 run(`38152850819`)两个 job **仍同点红**,但已是另一处原因,且同样是**静默失效**。
日志里只有一行关键信息:
```
msiexec /a 失败(退出码 ):nodeagent-console_2.0.2_x64_en-US.msi
```
`$LASTEXITCODE` 是**空的**,而整步从开始到抛错只过了 **0.8 秒** —— 根本没等安装器跑完。
**根因**:`msiexec.exe` 与 NSIS 安装包都是 **GUI 子系统程序**,PowerShell 用 `&` 调用时
**既不等待其结束、也不设置 `$LASTEXITCODE`**。于是 `$null -ne 0` 判真,
在安装器还没跑完时就当场抛错 —— 报错读起来像「安装失败」,实为「压根没等」。
(arm64 那条没有 MSI、直奔 NSIS,失败形态一模一样,正是同一根因的第二个样本。)
**修法**:
- 两处都改 `Start-Process -Wait -PassThru`,读 `$p.ExitCode`(确定性等待),并打印实际退出码;
- 参数拼成**单个字符串**传 `-ArgumentList`,避免数组被 PowerShell 自动加引号 ——
那会破坏 NSIS `/D=`「必须原样、不加引号、且是最后一个参数」的硬要求;
- 新增 `Wait-PayloadRoot`(最多 30 秒收敛):安装器可能「自己先退出、由子进程继续装」,
避免竞态误报「找不到 node.exe」;
- `msiexec` 退出码 3010(成功但需重启)按成功处理。
### 第三个 run:双绿,且断言给出了产物实证
第三个 run(`38154282653`)**两个 job 全绿**,第 14 步的实际输出:
| 检查对象 | 安装包体积 | 安装器退出码 | 解开后 `node.exe` | PE Machine | 另两样 |
|---|---|---|---|---|---|
| x64 `.msi` | 34.2 MiB | 0 | **82.9 MB** | **`0x8664`** ✓ | sidecar 232 KB / agent 1060 KB |
| x64 `-setup.exe`(NSIS) | 23.2 MiB | 0 | **82.9 MB** | **`0x8664`** ✓ | 同上 |
| arm64 `-setup.exe`(NSIS) | 19.5 MiB | 0 | **73.5 MB** | **`0xaa64`** ✓ | 同上 |
这一步的价值在于:**它证明的正是「19.5MB 的压缩包里真的装着 73.5MB 的 arm64 `node.exe`」** ——
这恰是体积断言永远无法区分的那件事(19.5MB 与「没打运行时」的包在体积上无法区分)。
另:`/D=` 确实生效(安装位置即 `pkg-extract\nsis`),故 NSIS 的默认安装位置候选列表是备用保险。
### 产物形态
| 架构 | runner | 安装包 | 实测体积 |
|---|---|---|---|
| x64 | `windows-latest` | `.msi`(企业批量部署)+ `-setup.exe`(NSIS,普通用户双击) | **34.2 MiB** / **23.2 MiB** |
| arm64 | `windows-11-arm` | `-setup.exe`(NSIS)—— WiX 的 arm64 MSI 支持面较窄,不值得为它引入不确定性 | **19.5 MiB** |
对照 macOS:`.app` 92.2 MiB(目录形态)/`.dmg` 33.8 MiB(压缩分发件)。
**两者不可直接比** —— 这正是上面那条错断言的来源。
工作流 `console-windows.yml` 与 `windows-e2e.yml` **刻意分开**:
后者是「被控端在 Windows 上的行为验证」,跑一遍约 4 分钟、含安装/卸载真机冒烟;
前者要装 Rust 工具链、编 Tauri(冷编 10 分钟级)。目标不同、耗时量级不同,
混在一起会让「改一行前端也要等 15 分钟」。
### 仍未做
- **控制台尚未接入 `scripts/release.mjs`**(该脚本目前只出被控端包)。
控制台的安装包目前只作为 CI artifact 产出,未随 Release 发布。
- 真机安装冒烟(在真实 Windows 上装一遍、点一遍)仍需图形环境人工执行。
## 界面观感约定(2026-10-11)
两轮纯界面调整,方向都是「少即是多」。理由记下来,免得日后被当成「没做完」再补回去。
### 一、一律不使用图标
触发原因是一次真实的渲染事故:侧栏的 emoji 图标在非系统字体栈的渲染器里退化成**豆腐块**。
试过三法,分别无效或更糟 —— 给图标指定 `Apple Color Emoji`、全局字体栈加 `!important`、
乃至 `font-variant-emoji: emoji`(最后一招把 ASCII 数字也拆成了 emoji 呈现)。
「哪套字体栈一定带 emoji」跨平台并不成立 —— **与其赌字形,不如不用**。
去掉的范围比"看得见的图标"宽得多(下面这三类都算):
| 类别 | 处数 | 关法 |
|---|---|---|
| 侧栏 emoji | 9 | 路由表 `meta.icon` 字段**整体删除**,`NavMeta` 接口同步收窄 |
| `n-empty` 空态**插画** | **16** | `:show-icon="false"` —— 插画比图标更「图形」,散在 9 个页面,**最易漏** |
| `n-alert` 类型图标 | 7 | naive-ui 的 `showIcon` **默认就是 `true`**,不显式关它就一直都在 |
| `n-popconfirm` 询问图标 | 3 | 同上 |
| 文案里的符号 | 1 | 终端页「⚠ 高权限操作,请谨慎」保留纯文字 |
顺带清掉 `public/{vite,tauri}.svg` 与 `src/assets/vue.svg`:`public/` 下的文件会被**整体复制进产物**,
此前等于把 Vite / Tauri 的 logo 一并发了出去(且 `index.html` 的 favicon 正指向它)。
`index.html` 的 `<title>` 还是模板默认的「Tauri + Vue + Typescript App」,也一并改齐
(原生窗口标题取自 `tauri.conf.json`,故此前只是 dev 态标签页不一致)。
**刻意未动**:① 托盘图标 —— 菜单栏没图标就点不到,关窗后应用无法寻回,属**功能必需**;
② 被控端 CLI 自己打印的文本标记(`⚠️`/`ℹ️`/`✅`)—— 是**程序输出内容**,会原样出现在「服务日志」页里,
但不是界面元素;③ 控件的结构性部件(下拉/折叠箭头、输入框清除钮、表格排序箭头)。
### 二、配色收敛为两套中性灰阶主题
`简约白`(浅)/`高级灰`(深),侧栏底部一行切换,选择存 `localStorage`。
| 决定 | 理由 |
|---|---|
| **主色位改中性灰** | naive-ui 默认主色是**绿色**、原设计的品牌字是**青绿 `#63e2b7`** —— 都是"颜色",与「简单些」相悖。主按钮/开关激活/进度条/勾选框统一吃这一个值 |
| 白主题**页底不是纯白**(`#f4f4f5`) | 卡片本身是白的,两者同色就只能靠 1px 分线撑层次,整页发「糊」。压一档浅灰后层次为「页底 < 侧栏 < 卡片」,且仍是白为主 |
| 语义色**降饱和**而非去掉 | 这是运维工具,「运行中 / 出错」必须一眼可辨,全部灰化会导致误读。故它们只出现在徽章与告警这类**小面积**上,底色观感仍是「白 + 灰」 |
| **色值只定义一份** | `src/theme/index.ts` 的 `Palette` 同时喂 naive-ui 的 `themeOverrides` 与 `--na-*` CSS 变量(`App.vue` 运行期注入)。两处各写一份,换主题时会**一半新一半旧且不报错、类型也抓不到** |
| 主题**只存 `localStorage`**,不落 Rust settings | Rust 那套 `Settings` 是给「仅服务模式」(无窗口、常驻托盘)也要生效的偏好用的;主题是纯图形偏好,**没有窗口时毫无意义**。为一件纯前端的事去动 Rust 命令面与契约守卫不划算 |
清掉散在 **8 个文件**里的 **17 处写死色值**(日志三流着色、代码块/预览底色、节点选中边框、历史悬停…),
全部改为 `var(--na-*)`。复扫后自定义样式里的硬编码色为 **0**。
**首帧防闪**:`index.html` 内联一小段脚本,在 Vue 接管前先把上次主题的底色刷上
(否则「高级灰」用户每次启动都会看到一帧刺眼的白)。
此处**刻意重复了 2 个底色值**,已在 `index.html` 与 `theme/index.ts` 两处注明「改底色时同步」——
因为在它执行之前没有任何模块可依赖。除此之外全应用再无第二份色值。
### 本轮验证记录
| 条目 | 结果 |
|---|---|
| `cargo check --all-targets` | ✅ 0 错 0 警 |
| `cargo test --lib` | ✅ **10/10**(含真起 node 子进程:启动/日志/停止/崩溃自愈/重复启动拒绝) |
| `cargo clippy --all-targets -- -D warnings` | ✅ 0 警告 |
| `npx vue-tsc --noEmit` | ✅ 通过 |
| `node tests/unit/console-commands.test.mjs` | ✅ 8/8(三条注入故障均验证会红) |
| `pnpm test:unit` | ✅ **282/282**(274 → 282) |
| `node scripts/prepare-console.mjs` | ✅ agent.mjs 1059 KB + Node 运行时 86.3 MB = 87.6 MB |
| `node apps/console/scripts/verify-bundle.mjs --runtime` | ✅ 打包内 node 真跑起打包内 sidecar(`agent.mjs` 语法解析通过) |
### ⚠️ 仍需图形环境人工验证的部分
沙箱内无法运行图形应用,故以下**只做了代码级与产物级验证,未实点**:
| 待验 | 怎么验 |
|---|---|
| 托盘图标 / 菜单 / 左键唤出 | 打开打包后的 `.app`,点图标与菜单项 |
| 关窗 → 最小化到托盘(不退出) | 点窗口关闭按钮,确认进程仍在、图标仍在 |
| 界面点「启动/停止/重启」 | 点三按钮,看状态与日志是否实时变化 |
| 开机自启实际生效 | macOS:登出再登入 / 重启;Windows:重启后看计划任务 |
| 黑窗是否真的没有 | **全程盯着屏幕操作一遍**(这条只能靠眼睛) |
---
## ARM64 真机安装冒烟与界面实点(2026-10-12)
上一节那张表里的条目,本轮在 **Parallels 27 + Win11 ARM64** 上逐条做完;
过程中还挖出并修掉一个**只在真机上才暴露**的缺陷(见本节末「真机暴露的缺陷」)。
### 怎么把包送进去
目标机的 SSH / 445 / 3389 / 5985 全被防火墙挡着,**只有 8765 开着**。
于是让**产品自己去搬产品**:`fs.write`(分块 base64)传包 → `system.shell.exec` 静默安装 → `fs.read` 取回结果。
这条路子本身也顺带验证了分块传输与 base64 往返的正确性。
| 项 | 实测 |
|---|---|
| 传输 | **20,445,580 B,0 块损坏**(两端 sha256 一致) |
| 静默安装 | `EXIT=0` |
| 包内 `node.exe` | PE `aa64` / subsystem **3 (CONSOLE)** |
| 包内 `nodeagent-console.exe` | PE `aa64` / subsystem **2 (GUI)** ← **结构上就不可能弹黑窗** |
| 包内 `uninstall.exe` | PE `014c` / subsystem 2 |
| 随包 Node 自报 | `v22.23.3` / `arm64`(v2.0.2 的修复确实生效) |
| 安装位置 | `C:\Users\<user>\AppData\Local\nodeagent-console`(**按用户安装**,非 Program Files) |
### 界面实点结果
| 条目 | 结果 | 判据 |
|---|---|---|
| 启动 | ✅ `pid=9052 / session=1`,工作集 **30~32 MB**(目标 <150 MB) | 进程表 |
| **主题切换** | ✅ 切到「高级灰」→ 选中态翻转 + 整窗深色,且**可逆** | UIA `selected` 翻转(不是看截图猜的)+ 截图 |
| **关窗 → 托盘** | ✅ 发 `WM_CLOSE` 后窗口从 `window.list` 消失、**进程仍存活 32 MB** | 窗口列表对比 |
| **无黑窗** | ✅ 结构性(PE subsystem=2)+ 视觉确认 | PE 头 + 截图 |
| 输入默认禁 | ✅ `input.mouse.move` → `E_CAPABILITY_DISABLED` | 错误名 |
| 开机自启 | ✅ 计划任务 `cn_jkooe.nodeagent-console` **不存在**,与 UI 开关=关 一致 | `schtasks` |
| 数据目录显示 | ✅ UI 显示 `C:\Users\pdverify\.nodeagent`,与真实生效目录一致 | 配置比对 |
| 「最近日志」 | ✅ 正确记下「端口 8765 已被占用」并给出降级提示 | 截图 |
### ⚠️ 本轮新发现(**未修**):控制台没有单实例保护
复现:关窗到托盘(进程仍在)→ **再次双击启动** → **起出第二个控制台进程**(9052 + 9232 并存)。
`apps/console/src-tauri/Cargo.toml` 里只有 `tauri-plugin-opener`,**没有单实例插件**。
后果:两份托盘图标、两套互不知情的状态机,且**都会去抢被控端的 8765**。
建议:引入 `tauri-plugin-single-instance`,第二次启动只 `show()` + `set_focus()` 已有窗口。
### 真机暴露的缺陷:200% 缩放下图形能力坐标错位(**本机已修**)
同一台机器上还实测到一个**只在真机暴露、单测与 CI 全绿**的缺陷:
被控端的 PowerShell 助手进程**默认不 DPI 感知**,于是在 200% 缩放下同一个进程里并存两套坐标系 ——
| 调用 | 不感知时的结果 |
|---|---|
| `Screen.Bounds` / `SystemInformation.VirtualScreen` | 报**逻辑**尺寸 1800×1010 |
| `CopyFromScreen` | 按**物理**像素裁剪 → `screen.capture` 缺省只截到左上 1/4 |
| `SetCursorPos` | 被系统 ×2(喂 100,100 → 光标到 200,200) |
| UIA(`screen.find`) | 报**物理**坐标(与进程是否感知无关) |
净效果:`screen.find` 给出的物理矩形**不能**直接喂给 `input.mouse.*`,
必须人工除以 `scale` —— 上一轮就是靠这个临时绕法才点中的主题按钮。
修法:新增 `apps/agent/src/util/dpi.ts`,把 `SetProcessDPIAware()` 注入到**全部坐标相关脚本的最前面**
(5 个文件),并配 8 条单测守卫。详见证见 `CHANGELOG.md` 与 `docs/VM-VERIFICATION.md` §10。
> 仅供技术参考,不构成任何投资建议。
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "nodeagent",
"private": true,
"version": "2.0.1",
"version": "2.0.3",
"description": "跨机 AI 接管框架 —— 让 Mac 上的 AI 无缝接管 Windows,装软件、查状态",
"type": "module",
"engines": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/client",
"version": "2.0.1",
"version": "2.0.3",
"private": true,
"type": "module",
"main": "./dist/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/protocol",
"version": "2.0.1",
"version": "2.0.3",
"private": true,
"type": "module",
"main": "./dist/index.js",
+5
View File
@@ -122,6 +122,11 @@ export function normalizeIp(remote: string | undefined | null): string {
export function ipMatchesCidr(ip: string, cidr: string): boolean {
const target = cidr.includes('/') ? cidr : `${cidr}/32`;
const [net, bitsRaw] = target.split('/');
// ⚠️ F-01(实测复现):原来只做 `Number.isFinite(Number(bitsRaw))`,
// 而 `Number('') === 0` → `10.0.0.0/`(掩码缺失)会被当成 `/0` = **匹配全部**,
// 一条本该拦住的 CIDR 规则变成放行任意来源(ACL 静默失效)。
// 这里要求掩码是**非空纯数字**,且长度合理。
if (typeof bitsRaw !== 'string' || !/^\d{1,2}$/.test(bitsRaw)) return false;
const bits = Number(bitsRaw);
if (!net || !Number.isFinite(bits)) return false;
// 仅处理 IPv4(IPv6 或主机名直接不匹配,避免误放行)
+71
View File
@@ -0,0 +1,71 @@
/**
* 审计事件类型 —— **唯一真源**。
*
* ## 为什么单独成文件(v2.0.3,修 C-01)
* 审计类型此前散在三处、各写一份,于是**静默漂移**:
* - `apps/agent/src/audit.ts` 的 `AuditType` 联合类型 —— 实际会写入日志的类型;
* - manifest `system.audit.list` 的 `enum: ['invoke','auth','acl','agent']` —— 只认 4 个;
* - 控制台下拉框的 11 个**细粒度**取值(`auth.success` / `acl.denied` / …)。
* 三者交集只剩 `invoke`:**除 `invoke` 外任何筛选都被 schema 判为 `E_PARAM_INVALID`**,
* 下拉框形同虚设。而漂移**不报错、类型也抓不到**(和「静默空列」同一类病)。
*
* 现在:真源在这里 → manifest 的 `enum` 由它派生 → 控制台取值受 `tests/unit/audit-filter.test.mjs`
* 守卫。**新增审计类型只需改这一处**,忘了同步会被测试拦下。
*
* ## 过滤用 `startsWith`(故粗粒度前缀也是合法取值)
* 被控端 `readAudit()` 的判据是 `entry.type.startsWith(query)`,所以 `auth` 能命中
* `auth.success` + `auth.failure`,`agent` 能命中 `agent.start/stop/update/error`。
* 这正是控制台用粗粒度分组的原因 —— 覆盖面更全,下拉框也不会膨胀成二十几项。
*/
/** 会被真正写入 `audit.log` 的全部类型。 */
export const AUDIT_TYPES = [
'agent.start',
'agent.stop',
/** v2.0.3:进程级未捕获错误 / 未处理 Promise 拒绝(兜底拦截,不退出) */
'agent.error',
'auth.success',
'auth.failure',
'invoke',
'acl.denied',
'capability.disabled',
'rate.limited',
/** v16:网络配置变更(两阶段提交) */
'net.change',
/** v21:来源不在 allow_from 被拒 */
'net.deny',
/** v19:agent 自更新(拉取式) */
'agent.update',
/** v2.0.0:连接空闲被主动断开 */
'session.idle_close',
/** v2.0.3:被拒的请求统一留痕(畸形报文 / 未认证 / 参数非法 / 能力不存在) */
'request.rejected',
] as const;
export type AuditType = (typeof AUDIT_TYPES)[number];
/**
* 过滤用的**粗粒度前缀**。
*
* 每个前缀都必须能命中至少一个 `AUDIT_TYPES`(由单测钉死)—— 否则下拉框里会出现
* 「选了永远是空」的选项,用户只会以为「这段时间没出事」。
*/
export const AUDIT_TYPE_PREFIXES = [
'invoke',
'auth',
'acl',
'capability',
'rate',
'net',
'agent',
'session',
'request',
] as const;
/**
* `system.audit.list` 的 `type` 参数**允许取的全部值**(精确类型 ∪ 粗粒度前缀)。
* manifest 的 `enum` 直接用它,故「控制台能点的」与「协议认的」必然一致。
*/
export const AUDIT_FILTER_TYPES: readonly string[] = Array.from(
new Set<string>([...AUDIT_TYPES, ...AUDIT_TYPE_PREFIXES]),
);
@@ -1,6 +1,8 @@
import type { CapabilityDescriptor } from '../../messages.js';
import { CapabilityNames } from '../names.js';
import { filterSchema } from '../schemas.js';
// ⚠️ 审计类型的**唯一真源**(v2.0.3,修 C-01):enum 不再手写,否则必然与控制台下拉框漂移。
import { AUDIT_FILTER_TYPES } from '../../audit-types.js';
/** system 组能力清单 */
export const SYSTEM_CAPABILITIES: CapabilityDescriptor[] = [
@@ -205,7 +207,15 @@ export const SYSTEM_CAPABILITIES: CapabilityDescriptor[] = [
limit: { type: 'integer', default: 50, minimum: 1, maximum: 1000 },
since: { type: 'integer', description: '仅返回该时间戳(Unix 毫秒)之后的记录' },
client_id: { type: 'string', description: '按调用方筛选' },
type: { type: 'string', enum: ['invoke', 'auth', 'acl', 'agent'], description: '按事件类别筛选' },
// ⚠️ enum 由 `AUDIT_FILTER_TYPES` 派生(真源见 packages/protocol/src/audit-types.ts)。
// 曾经手写成 `['invoke','auth','acl','agent']`,而控制台下拉框给的是细粒度取值
// → 除 `invoke` 外全被判 E_PARAM_INVALID(C-01,静默失效)。
// 被控端按 `startsWith` 过滤,故精确类型与粗粒度前缀都是合法取值。
type: {
type: 'string',
enum: [...AUDIT_FILTER_TYPES],
description: '按事件类别筛选(前缀匹配,如 auth 命中 auth.success/auth.failure)',
},
},
additionalProperties: false,
},
+1
View File
@@ -10,3 +10,4 @@ export * from './capabilities.js';
export * from './net.js';
export * from './hotkeys.js';
export * from './audit-anchor.js';
export * from './audit-types.js';
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env node
/**
* 被控端单文件打包(esbuild)—— **单一实现**。
*
* 为什么抽出来:`agent.mjs` 现在有**两个消费方** ——
* 1. `scripts/pack.mjs` → Windows 便携包 / 自更新载荷(Release 资产)
* 2. `scripts/prepare-console.mjs` → 桌面控制台的随包资源(控制台要能托起本机被控端)
*
* 若各写一份 esbuild 配置,迟早漂移(本项目已多次吃过「两份真相」的亏:改了一处漏另一处,
* 构建过、单测过,只在真机暴露)。故配置只此一份。
*
* ## 几个「不这么写就会错」的细节
* - **用 esbuild 的 JS API,不 spawn `node_modules/.bin/esbuild`** —— pnpm 隔离布局下
* 那个 shim 在 Windows 上不存在(真机 CI 踩过 `ENOENT`);JS API 只依赖 `require('esbuild')`。
* - **不注入构建时间** —— 注入后同一份代码每次打包字节都不同,「指纹是否最新」永远失真。
* - `define` 的值按 **JS 表达式**解析,故用 `JSON.stringify` 得到纯双引号;写成单引号包裹
* 会让引号成为值的一部分(真机踩过:版本号显示成 `v"1.4.0"`)。
*/
import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
/** 仓库根目录(本文件在 scripts/lib/ 下)。 */
export const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
/** 被控端与协议包的版本源:**根** `package.json`(非 apps/agent 的)。 */
export function repoVersion(root = repoRoot) {
return JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version;
}
/** 当前 git 短 sha(取不到时返回 `unknown`,不抛)。 */
export function gitShortCommit(root = repoRoot) {
try {
return execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: root }).toString().trim();
} catch {
return 'unknown';
}
}
/**
* 把 `apps/agent/dist/index.js` 打包成自包含单文件。
*
* @param {object} opts
* @param {string} opts.outfile 产物路径
* @param {string} [opts.root] 仓库根
* @param {string} [opts.logLevel]
* @returns {Promise<{ outfile: string, version: string, commit: string, bytes: number }>}
*/
export async function bundleAgent({ outfile, root = repoRoot, logLevel = 'info' }) {
const entry = join(root, 'apps', 'agent', 'dist', 'index.js');
if (!existsSync(entry)) {
throw new Error(
`未找到被控端构建产物:${entry}\n` +
' 先构建:pnpm --filter nodeagent-agent build(或 pnpm -r build)',
);
}
const version = repoVersion(root);
const commit = gitShortCommit(root);
// 动态 import:只有真正需要打包时才解析 esbuild(prepare-console 的其它分支不受影响)
const esbuild = await import('esbuild');
await esbuild.build({
entryPoints: [entry],
bundle: true,
platform: 'node',
target: 'node22',
format: 'esm',
banner: {
js: 'import { createRequire } from "node:module"; const require = createRequire(import.meta.url);',
},
define: {
__AGENT_VERSION__: JSON.stringify(version),
__BUILD_COMMIT__: JSON.stringify(commit),
},
outfile,
logLevel,
});
const { statSync } = await import('node:fs');
return { outfile, version, commit, bytes: statSync(outfile).size };
}
+182
View File
@@ -0,0 +1,182 @@
/**
* 版本号写入 —— 发版脚本与测试**共用**的唯一实现。
*
* 为什么单独成模块(2026-10-13):
* `scripts/release.mjs` 原先只覆盖 `package.json` + `tauri.conf.json`,
* 但 `apps/console/src-tauri/Cargo.toml` 与 `Cargo.lock` 里**同样存着版本号**。
* 发版后这两处会**滞留旧版本**,与 `tauri.conf.json` 悄悄分叉 ——
* 而分叉不报错(Tauri 打包以 `tauri.conf.json` 为准),属于最难发现的那类漂移。
*
* 为什么把它从脚本里抽出来:
* 铁律「守卫要落在**最终产物字节**上」。若版本写入逻辑埋在 `release.mjs` 里,
* 单测只能断言「源码里出现过某个字符串」—— 那种守卫注入故障都不会变红。
* 抽成纯函数后,测试可以对**临时目录里的真实文件**跑一遍再断言文件内容。
*/
import { execFileSync } from 'node:child_process';
import { readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
/** 拿 `git ls-files` 的结果(只认已入库文件 —— 未入库的构建产物不该被改版本号)。 */
function lsFiles(root, pattern) {
try {
return execFileSync('git', ['ls-files', pattern], { cwd: root, encoding: 'utf8' })
.split('\n')
.map((s) => s.trim())
.filter(Boolean);
} catch {
return [];
}
}
/**
* 枚举「存着版本号、需要一起改」的文件。
* Cargo 相关文件用 `optional: true` 标记:没有 Rust 侧时不该让发版直接失败。
*/
export function versionTargets(root = process.cwd()) {
const pkgFiles = ['package.json'];
for (const f of lsFiles(root, '*package.json')) {
if (f === 'package.json' || f.includes('node_modules')) continue;
pkgFiles.push(f);
}
return {
pkgFiles,
tauriConfFiles: lsFiles(root, '*src-tauri/tauri.conf.json'),
cargoTomlFiles: lsFiles(root, '*src-tauri/Cargo.toml'),
cargoLockFiles: lsFiles(root, '*src-tauri/Cargo.lock'),
};
}
/**
* 改 `Cargo.toml` 的版本号 —— **只在 `[package]` 段内**改。
*
* 为什么要限定段落:`Cargo.toml` 里 `version = "..."` 这个形状还出现在别处
* (例如 `[dependencies]` 里 `some-crate = { version = "1" }` 是行内的,
* 但某些写法会把 `version = "..."` 单独成行)。不做限定就成了「全文盲替换」,
* 会改坏依赖约束 —— 那比不改更糟。
*/
export function setCargoTomlVersion(text, version, label = 'Cargo.toml') {
const lines = text.split('\n');
let inPackage = false;
let done = false;
for (let i = 0; i < lines.length; i += 1) {
const trimmed = lines[i].trim();
if (trimmed.startsWith('[')) {
inPackage = trimmed === '[package]';
continue;
}
if (!inPackage || done) continue;
if (/^version\s*=/.test(trimmed)) {
lines[i] = lines[i].replace(/^(\s*version\s*=\s*")[^"]*(".*)$/, `$1${version}$2`);
done = true;
}
}
if (!done) throw new Error(`${label} 的 [package] 段里找不到 version 行`);
return lines.join('\n');
}
/** 从 `Cargo.toml` 的 `[package]` 段读 crate 名(用于去 `Cargo.lock` 里定位同一个包)。 */
export function readCargoPackageName(text) {
const lines = text.split('\n');
let inPackage = false;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('[')) {
inPackage = trimmed === '[package]';
continue;
}
if (!inPackage) continue;
const m = /^name\s*=\s*"([^"]*)"$/.exec(trimmed);
if (m) return m[1];
}
return null;
}
/**
* 改 `Cargo.lock` 里**指定 crate** 的版本号。
*
* `Cargo.lock` 是「按 `[[package]]` 分块、块内 `name` 后跟 `version`」的结构,
* 且全文散布着**大量同名依赖版本**(`thiserror 2.0.21`、`proc-macro-crate 2.0.2` …)。
* 直接 `replace('2.0.2', ...)` 会把依赖版本一起改坏 —— 所以必须按块定位。
* 找不到该包时返回原文本(未构建过的 checkout 里可能没有),不报错。
*/
export function setCargoLockVersion(text, pkgName, version) {
const lines = text.split('\n');
let currentName = null;
let done = false;
for (let i = 0; i < lines.length; i += 1) {
const trimmed = lines[i].trim();
if (trimmed === '[[package]]') {
currentName = null;
continue;
}
const nameMatch = /^name\s*=\s*"([^"]*)"$/.exec(trimmed);
if (nameMatch) {
currentName = nameMatch[1];
continue;
}
if (currentName === pkgName && !done && /^version\s*=/.test(trimmed)) {
lines[i] = lines[i].replace(/^(\s*version\s*=\s*")[^"]*(".*)$/, `$1${version}$2`);
done = true;
}
}
return { text: lines.join('\n'), changed: done };
}
/**
* 把版本号写到**全部**存放它的地方。
*
* @returns {{ changed: Array<{file:string, before:string, kind:string}>, skipped: string[] }}
*/
export function writeVersionEverywhere(root, version, { dryRun = false } = {}) {
const targets = versionTargets(root);
const changed = [];
const skipped = [];
// ---- JSON 类:package.json 与 tauri.conf.json 同构,写回时保持 2 空格缩进 ----
for (const rel of [...targets.pkgFiles, ...targets.tauriConfFiles]) {
const abs = join(root, rel);
let parsed;
try {
parsed = JSON.parse(readFileSync(abs, 'utf8'));
} catch {
skipped.push(`${rel}(读不出 JSON)`);
continue;
}
if (!parsed.version) {
skipped.push(`${rel}(无 version 字段)`);
continue;
}
changed.push({ file: rel, before: parsed.version, kind: 'json' });
if (dryRun) continue;
parsed.version = version;
writeFileSync(abs, `${JSON.stringify(parsed, null, 2)}\n`);
}
// ---- Cargo.toml + Cargo.lock:成对处理(lock 里要按 crate 名定位) ----
for (const rel of targets.cargoTomlFiles) {
const abs = join(root, rel);
const text = readFileSync(abs, 'utf8');
const before = /\[package\][\s\S]*?^version\s*=\s*"([^"]*)"/m.exec(text)?.[1];
const crateName = readCargoPackageName(text);
changed.push({ file: rel, before: before ?? '?', kind: 'cargo-toml' });
if (!dryRun) writeFileSync(abs, setCargoTomlVersion(text, version, rel));
if (!crateName) continue;
const lockRel = targets.cargoLockFiles.find((f) => f.startsWith(rel.slice(0, rel.length - 'Cargo.toml'.length)));
if (!lockRel) continue;
const lockAbs = join(root, lockRel);
const lockText = readFileSync(lockAbs, 'utf8');
const lockBefore = new RegExp(`name\\s*=\\s*"${crateName}"\\nversion\\s*=\\s*"([^"]*)"`).exec(lockText)?.[1];
if (lockBefore === undefined) {
skipped.push(`${lockRel}(未找到 crate ${crateName},可能尚未构建)`);
continue;
}
changed.push({ file: lockRel, before: lockBefore, kind: 'cargo-lock' });
if (dryRun) continue;
const result = setCargoLockVersion(lockText, crateName, version);
if (result.changed) writeFileSync(lockAbs, result.text);
}
return { changed, skipped };
}
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env node
/**
* 交付物级断言:直读 zip 内的可执行文件、取 PE 头判断目标架构。
*
* 为什么单独成模块(2026-10-11 事故复盘):
* `nodeagent-win-arm64.zip` 里曾装进 x64 的 `node.exe`,而该包在 ARM Windows 上
* **能启动、能 listen,只是 TLS 握手不完成**(控制端只见 E_TIMEOUT)——
* 单测、CI、macOS 侧全都抓不到,两个公开包内 `node.exe` 的 sha256 还完全相同。
* 打包流程内部(pack.mjs)已有断言,但那仍是「中间逻辑」的断言;
* 真正该钉死的是**交付物本身**。故这里只做一件事:解出最终 zip 里的 exe,读 PE 头。
*
* 实现约束:只用 Node 内置能力(`node:zlib` 手解 zip)。
* 不依赖 `unzip` / `Expand-Archive` —— Windows runner 上没有 unzip,
* 而发版与 CI 都要在 Windows 上跑。
*
* 自检用法:
* node scripts/lib/zip-pe-arch.mjs <zip路径> <x64|arm64>
* (退出码 0 = 架构相符;1 = 不符;2 = 读不了文件/不是有效 zip)
*/
import { readFileSync } from 'node:fs';
import { pathToFileURL } from 'node:url';
import { inflateRawSync } from 'node:zlib';
/** PE `Machine` 字段 → 人类可读名 */
export const PE_MACHINE = { 0x8664: 'x86-64', 0xaa64: 'ARM64', 0x14c: 'i386' };
/** 本项目的架构标识 → 期望的 PE Machine */
export const ARCH_MACHINE = { x64: 0x8664, arm64: 0xaa64 };
/** 从 zip 中取出指定条目的内容(支持 stored / deflate;zip64 不适用本仓产物) */
export function readZipEntry(zipPath, entryName) {
const buf = readFileSync(zipPath);
let eocd = -1;
const minPos = Math.max(0, buf.length - 22 - 65535);
for (let i = buf.length - 22; i >= minPos; i -= 1) {
if (buf.readUInt32LE(i) === 0x06054b50) {
eocd = i;
break;
}
}
if (eocd < 0) throw new Error(`未找到 EOCD(不是有效 zip?): ${zipPath}`);
const count = buf.readUInt16LE(eocd + 10);
let off = buf.readUInt32LE(eocd + 16);
for (let n = 0; n < count; n += 1) {
if (buf.readUInt32LE(off) !== 0x02014b50) throw new Error(`中央目录签名异常: ${zipPath}`);
const method = buf.readUInt16LE(off + 10);
const compSize = buf.readUInt32LE(off + 20);
const nameLen = buf.readUInt16LE(off + 28);
const extraLen = buf.readUInt16LE(off + 30);
const commentLen = buf.readUInt16LE(off + 32);
const localOff = buf.readUInt32LE(off + 42);
const name = buf.toString('utf8', off + 46, off + 46 + nameLen);
// 条目名匹配用 **basename**,而不是完整名 —— 因为两平台的打包器不一样:
// macOS/Linux:`cd <dir> && zip -r <out> .` → 条目是裸名 `node.exe`
// Windows :`Compress-Archive -Path <dir>\*` → 条目带前缀 `<dir>/node.exe`
// 2026-10-11 真例:只比完整名会让「本地(macOS)全绿、CI(Windows)全红」,
// 且方向恰好相反 —— 而 CI 是唯一会跑 Windows 打包路径的地方。
const entryBase = name.replace(/\\/g, '/').split('/').pop();
if (entryBase === entryName) {
const lNameLen = buf.readUInt16LE(localOff + 26);
const lExtraLen = buf.readUInt16LE(localOff + 28);
const start = localOff + 30 + lNameLen + lExtraLen;
const raw = buf.subarray(start, start + compSize);
return method === 0 ? raw : inflateRawSync(raw);
}
off += 46 + nameLen + extraLen + commentLen;
}
throw new Error(`zip 内未找到条目 ${entryName}: ${zipPath}`);
}
/** 读 PE 头的 Machine 字段 */
export function peMachine(buf) {
const peOff = buf.readUInt32LE(0x3c);
if (buf.readUInt32LE(peOff) !== 0x00004550) throw new Error('不是有效的 PE 文件');
return buf.readUInt16LE(peOff + 4);
}
/**
* 校验 zip 内条目的架构。
* @returns {{ok:boolean, got:number, want:number, gotName:string, wantName:string}}
*/
export function checkZipArch(zipPath, arch, entryName = 'node.exe') {
const want = ARCH_MACHINE[arch];
if (!want) throw new Error(`未知架构标识: ${arch}(可选 ${Object.keys(ARCH_MACHINE).join('/')})`);
const got = peMachine(readZipEntry(zipPath, entryName));
return {
ok: got === want,
got,
want,
gotName: PE_MACHINE[got] ?? `0x${got.toString(16)}`,
wantName: PE_MACHINE[want],
};
}
// ---------- CLI 自检:可被人工/CI 直接调用 ----------
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const [, , zipPath, arch] = process.argv;
if (!zipPath || !arch) {
console.error('用法: node scripts/lib/zip-pe-arch.mjs <zip路径> <x64|arm64>');
process.exit(2);
}
try {
const r = checkZipArch(zipPath, arch);
if (r.ok) {
console.log(`✓ ${zipPath} → node.exe = ${r.gotName}(期望 ${arch})`);
process.exit(0);
}
console.error(`✗ ${zipPath} → node.exe = ${r.gotName},但期望 ${arch}(${r.wantName})`);
process.exit(1);
} catch (err) {
console.error(`✗ ${err.message}`);
process.exit(2);
}
}
+39 -42
View File
@@ -25,6 +25,7 @@ import { get } from 'node:https';
import { randomBytes } from 'node:crypto';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { bundleAgent } from './lib/bundle-agent.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, '..');
@@ -76,48 +77,19 @@ async function main() {
execSync('pnpm -r build', { cwd: root, stdio: 'inherit' });
log('\n=== ② esbuild 打包被控端 ===');
// 单一版本源:根 package.json 的 version + git sha + 构建时间,
// 经 esbuild --define 注入,避免「版本号写在多处必然不同步」。
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
const commit = (() => {
try {
return execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: root }).toString().trim();
} catch {
return 'unknown';
}
})();
const builtAt = new Date().toISOString();
log(` 版本 ${pkg.version} @ ${commit}(${builtAt})`);
// 版本源(根 package.json 的 version + git sha)与 esbuild 配置都在
// `scripts/lib/bundle-agent.mjs` 里**单一实现** —— 控制台打包(prepare-console.mjs)
// 用的是同一个函数,避免两处配置各自漂移。
mkdirSync(OUT_DIR, { recursive: true });
const bundleOut = join(OUT_DIR, 'agent.mjs');
// 用 esbuild 的 **JS API** 而非 spawn node_modules/.bin/esbuild —— pnpm 的隔离布局下
// 那个 shim 在 Windows 上不存在(真机 CI 踩过:spawnSync ...\.bin\esbuild ENOENT),
// 而 JS API 只依赖 require('esbuild') 解析,跨平台一致。
const esbuild = await import('esbuild');
await esbuild.build({
entryPoints: [join(root, 'apps', 'agent', 'dist', 'index.js')],
bundle: true,
platform: 'node',
target: 'node22',
format: 'esm',
banner: {
js: 'import { createRequire } from "node:module"; const require = createRequire(import.meta.url);',
},
// 刻意**不注入构建时间**:注入后同一份代码每次打包字节都不同,
// 「指纹比对是否最新」就永远失真(真机踩过:明明同版本却报不一致)。
// 构建时间改由入口文件的 mtime 反映(见 system.ts 的 build.built_at)。
// 注意:define 的值按 **JS 表达式** 解析,故用 JSON.stringify 得到纯双引号;
// 写成单引号包裹会让引号成为值的一部分(真机踩过:版本显示成 v"1.4.0")。
define: {
__AGENT_VERSION__: JSON.stringify(pkg.version),
__BUILD_COMMIT__: JSON.stringify(commit),
},
outfile: bundleOut,
logLevel: 'info',
});
const bundled = await bundleAgent({ outfile: bundleOut });
log(` 版本 ${bundled.version} @ ${bundled.commit}(${(bundled.bytes / 1024 / 1024).toFixed(1)}MB)`);
log('\n=== ③ 下载 Node.js Windows 运行时 ===');
const tmpZip = join(OUT_DIR, 'node-runtime.zip');
log(`\n=== ③ 下载 Node.js Windows 运行时(${NODE_ARCH})===`);
// 缓存文件名**必须含架构**。曾用固定名 node-runtime.zip:release 先打 x64(下载并缓存),
// 再打 arm64 时误判「已缓存」→ 跳过下载 → 把 x64 的 node.exe 装进了 arm64 包。
// (真例:v2.0.1 的 nodeagent-win-arm64.zip 内 node.exe 实为 x86-64。)
const tmpZip = join(OUT_DIR, `node-runtime-${NODE_ARCH}.zip`);
if (existsSync(tmpZip)) {
log(' 已缓存,跳过下载');
} else {
@@ -128,6 +100,23 @@ async function main() {
rmSync(PKG_DIR, { recursive: true, force: true });
mkdirSync(PKG_DIR, { recursive: true });
execSync(`unzip -o -j "${tmpZip}" "*/node.exe" -d "${PKG_DIR}"`, { stdio: 'inherit' });
// 防呆 C:产物 node.exe 的 PE 架构必须与 --arch 一致。
// 这是**根治性守卫**:缓存串包、镜像发错、手工塞错文件,都会在此当场红,
// 而不是拖到「被控端在 ARM Windows 上 TLS 握手不完成」才由真机暴露。
// PE 头:偏移 0x3c 处是 PE 签名偏移,+4 是 Machine 字段(0x8664=x86-64,0xaa64=ARM64)。
const PE_MACHINE = { x64: 0x8664, arm64: 0xaa64 };
const PE_NAME = { 0x8664: 'x86-64', 0xaa64: 'ARM64', 0x14c: 'i386' };
const nodeExe = join(PKG_DIR, 'node.exe');
{
const head = readFileSync(nodeExe).subarray(0, 4096);
const machine = head.readUInt16LE(head.readUInt32LE(0x3c) + 4);
if (machine !== PE_MACHINE[NODE_ARCH]) {
fail(`node.exe 架构不符:--arch=${NODE_ARCH},实际 ${PE_NAME[machine] ?? `0x${machine.toString(16)}`}。` +
`多半是缓存串包 —— 删掉 ${OUT_DIR}/node-runtime-*.zip 后重试。`);
}
log(` node.exe 架构校验通过(${NODE_ARCH} / ${PE_NAME[machine]})`);
}
copyFileSync(bundleOut, join(PKG_DIR, 'agent.mjs'));
copyFileSync(join(root, 'scripts', 'install.ps1'), join(PKG_DIR, 'install.ps1'));
copyFileSync(join(root, 'scripts', 'control.ps1'), join(PKG_DIR, 'control.ps1'));
@@ -229,15 +218,23 @@ async function main() {
// 把预共享密钥打进包里等于公开密钥。install.cmd 在文件缺失时会自动生成新密钥
// (生成在安装目录,只留在被控端本机),所以排除它不影响安装。
if (process.platform === 'win32') {
// Windows:GitHub runner / 多数 Windows 没有 Info-ZIP,改用 PowerShell 的 Compress-Archive。
// 先删掉 PSK.txt(若存在)再压 —— Compress-Archive 无 -x 排除参数。
// Windows:GitHub runner / 多数 Windows 没有 Info-ZIP。
// 先删掉 PSK.txt(若存在)再压 —— 打包器无法按需排除单个文件。
const psk = join(PKG_DIR, 'PSK.txt');
if (existsSync(psk)) rmSync(psk, { force: true });
// ⚠️ 刻意不用 `Compress-Archive -Path '<dir>\*'`:它会把**目录名当前缀**写进条目
// (产物变成 `<dir>/node.exe`),而 macOS 的 `cd <dir> && zip -r .` 是**裸名** `node.exe`
// —— 两平台产物**结构不一致**。看着是小事,代价却不小:
// ① 按条目名取文件的校验会失配(2026-10-11 真例:CI 在 Windows 上报
// 「zip 内未找到条目 node.exe」,而 macOS 本地全绿 → 方向恰好相反);
// ② CI 校验的产物与发布产物**不同构**,校验价值打折。
// 改用 .NET 的 ZipFile::CreateFromDirectory —— 它打包**目录内容**,条目同样是裸名。
execFileSync(
'powershell',
[
'-NoProfile', '-Command',
`Compress-Archive -Path '${PKG_DIR}\*' -DestinationPath '${zipOut}' -Force`,
`Add-Type -AssemblyName System.IO.Compression.FileSystem; ` +
`[IO.Compression.ZipFile]::CreateFromDirectory('${PKG_DIR}', '${zipOut}')`,
],
{ stdio: 'inherit' },
);
+239
View File
@@ -0,0 +1,239 @@
#!/usr/bin/env node
/**
* 为桌面控制台(apps/console)准备**随包分发的 Node 运行时**,并生成构建期注入的配置。
*
* 用法:
* node scripts/prepare-console.mjs [--node <路径>] [--out <目录>] [--no-strip]
*
* 背景
* ----
* 控制台的 sidecar(`apps/console/sidecar/dist/sidecar.mjs`,227KB)是 **.mjs**,
* 需要 Node 解释器才能运行;而 sidecar 是 `.mjs` 而非可执行文件,
* **不满足 Tauri `externalBin` 的要求**(externalBin 只接受可执行文件)。
* 故采用与被控端 `scripts/pack.mjs` 一致的做法:**把 Node 运行时当作 Tauri resource 分发**。
*
* 为什么必须走 `--config` 注入、而不写进默认 `tauri.conf.json`
* ------------------------------------------------------------
* Tauri 的 `bundle.resources` 是**编译期契约**:`tauri-build`(build.rs)会校验
* 声明的每个资源**必须存在**,否则 build.rs 直接失败(2026-10-10 真机踩过:
* 声明了尚未下载的 node 与尚未生成的 sidecar.mjs → "failed to run custom build command")。
* 而本脚本的产物(Node 运行时)正是「运行期才需要、构建期未必存在」的东西,
* 故一律由本脚本生成一份**注入片段**,打包时用 `tauri build --config` 合并,
* 默认 conf 里**永远不出现** runtime 路径。
*
* 产物
* ----
* 1. `apps/console/src-tauri/runtime/<node|node.exe>` Node 运行时(已 strip,见下)
* 2. `apps/console/src-tauri/runtime/agent.mjs` 被控端单文件(架构无关的 JS)
* 3. `apps/console/src-tauri/tauri.bundle.gen.json` 构建期注入片段(生成物,不入库)
*
* 为什么要带被控端(agent.mjs)
* ----------------------------
* 用户要的是「打开应用就能启动 / 停止 / 重启被控端」,不该再让他去别处下载一个包。
* 被控端是 `.mjs`(架构无关),复用同一个 Node 运行时即可,增量仅约 1MB。
* 打包配置复用 `scripts/lib/bundle-agent.mjs`(与 `pack.mjs` 同一份实现)。
*
* 体积
* ----
* Node 二进制约 100MB 级(macOS arm64 原始 108MB,`strip -x` 后 91MB)——
* 这是「开箱即用、不要求用户预装 Node」的代价,已在 docs/console-plan.md 记明。
*/
import { execFileSync } from 'node:child_process';
import {
chmodSync,
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { bundleAgent } from './lib/bundle-agent.mjs';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const SRC_TAURI = join(root, 'apps/console/src-tauri');
const SIDECAR = join(root, 'apps/console/sidecar/dist/sidecar.mjs');
/** 极简参数解析(不引依赖)。 */
function arg(name, dflt) {
const i = process.argv.indexOf(`--${name}`);
return i >= 0 && process.argv[i + 1] && !process.argv[i + 1].startsWith('--')
? process.argv[i + 1]
: dflt;
}
const flag = (name) => process.argv.includes(`--${name}`);
const isWin = process.platform === 'win32';
const nodeName = isWin ? 'node.exe' : 'node';
const runtimeDir = resolve(arg('out', join(SRC_TAURI, 'runtime')));
const srcNode = resolve(arg('node', process.execPath));
const doStrip = !flag('no-strip') && !flag('noStrip') && process.platform === 'darwin';
const log = (m) => console.log(m);
const fail = (m) => {
console.error(`✗ ${m}`);
process.exit(1);
};
const mb = (b) => `${(b / 1024 / 1024).toFixed(1)} MB`;
// ---------- ⓪ 前置检查:sidecar 必须已构建(它才是随包分发的主体)----------
if (!existsSync(SIDECAR)) {
fail(
`未找到 ${SIDECAR}\n` +
' 先构建 sidecar:pnpm --filter nodeagent-console sidecar:build',
);
}
if (!existsSync(srcNode)) fail(`Node 可执行文件不存在:${srcNode}`);
log(`=== 准备控制台 Node 运行时(${process.platform}/${process.arch})===`);
log(` 源:${srcNode}(${mb(statSync(srcNode).size)})`);
// ---------- ① 复制到 runtime/(幂等:同尺寸同路径则跳过)----------
mkdirSync(runtimeDir, { recursive: true });
const dstNode = join(runtimeDir, nodeName);
const stamp = join(runtimeDir, '.source.json');
const fingerprint = { source: srcNode, size: statSync(srcNode).size, platform: process.platform, arch: process.arch };
let copied = false;
if (existsSync(dstNode) && existsSync(stamp)) {
try {
const prev = JSON.parse(readFileSync(stamp, 'utf8'));
const same =
prev.size === fingerprint.size &&
prev.platform === fingerprint.platform &&
prev.arch === fingerprint.arch;
if (same) log(` 目标已存在且一致,跳过复制:${dstNode}`);
else copied = true;
} catch {
copied = true;
}
} else {
copied = true;
}
if (copied) {
if (existsSync(dstNode)) rmSync(dstNode);
copyFileSync(srcNode, dstNode);
chmodSync(dstNode, 0o755);
log(` 已复制 → ${dstNode}`);
}
// ---------- ② strip(仅 macOS;Windows 无此步骤,且不得假设 unix 命令存在)----------
//
// ⚠️ 致命细节(2026-10-11 实测踩到):strip 会**使 Mach-O 的代码签名失效**,
// 而 macOS 内核对签名无效的可执行文件直接 **SIGKILL(exit 137)**——
// 不是报错、不是警告,是静默无法运行(本地实测:strip 后 `./node -e ...` → 137)。
// 故 strip 之后**必须**重新 adhoc 签名(`codesign --force --sign -`)才能恢复可执行。
// 实测对照:未 strip(保持原签名)可运行 ✓;strip + 重签 可运行 ✓;strip 不重签 ✗(137)。
// 省 19% 体积(108MB → 87MB)值得,但重签这一步不能少。
if (doStrip && copied) {
const before = statSync(dstNode).size;
let stripped = false;
try {
execFileSync('strip', ['-x', dstNode], { stdio: 'pipe' });
stripped = true;
} catch (e) {
// strip 不是必须的(只是省体积);缺 Xcode CLT 时跳过,但不假装成功
log(` ⚠ 跳过 strip(${String(e.message).split('\n')[0]})——不影响可用性,仅体积偏大`);
}
if (stripped) {
// 重签是 strip 的**必要配套**,不是可选优化:不重签则产物无法运行。
execFileSync('codesign', ['--force', '--sign', '-', dstNode], { stdio: 'pipe' });
const after = statSync(dstNode).size;
log(
` strip -x:${mb(before)} → ${mb(after)}(省 ${(((before - after) / before) * 100).toFixed(0)}%)` +
',已 adhoc 重签',
);
}
} else if (!doStrip && process.platform === 'darwin' && copied) {
log(' 按 --no-strip 跳过符号剥离(保留原签名)');
}
// ---------- ②′ 自检:产物必须真的能跑 ----------
// 「守卫不能是空转的」:strip / 重签任一步出问题,产物会静默变成一个 87MB 的**不可执行文件**,
// 直到用户在真机上双击应用才暴露(且报错信息毫无指向性)。故此处当场跑一次。
// 复用已有产物(跳过复制)时同样自检 —— 否则上一轮的坏产物会被一直沿用。
try {
const ver = execFileSync(dstNode, ['-e', 'process.stdout.write(process.version)'], {
stdio: 'pipe',
})
.toString()
.trim();
log(` 自检通过:${nodeName} 可执行(${ver})`);
} catch (e) {
rmSync(dstNode, { force: true });
rmSync(stamp, { force: true });
fail(
`产出的 Node 运行时无法执行(已删除产物,请重跑本脚本):${String(e.message).split('\n')[0]}\n` +
' 提示:macOS 上 strip 会使代码签名失效,内核对签名无效的可执行文件直接 SIGKILL(exit 137),\n' +
' 故 strip 之后必须 adhoc 重签(codesign --force --sign -)。',
);
}
// ---------- ②″ 打包被控端 agent.mjs(控制台要能托起本机被控端)----------
//
// 为什么控制台也要带被控端:用户要的是「打开应用就能启动/停止/重启被控端」,
// 不该再让他去别处下载一个包。故把 agent.mjs(架构无关的 JS,约 1MB)也随包分发。
// esbuild 配置复用 `lib/bundle-agent.mjs`(与 pack.mjs 同一份实现,避免漂移)。
const AGENT_OUT = join(runtimeDir, 'agent.mjs');
log('\n=== 打包被控端(随包资源)===');
let agentBytes = 0;
try {
const r = await bundleAgent({ outfile: AGENT_OUT, root, logLevel: 'warning' });
agentBytes = r.bytes;
log(` agent.mjs v${r.version} @ ${r.commit} → ${AGENT_OUT}(${(r.bytes / 1024).toFixed(0)} KB)`);
} catch (e) {
fail(String(e.message ?? e));
}
// 自检:产物必须是可解析的 ES 模块(不是空文件、不是半截)
try {
const txt = readFileSync(AGENT_OUT, 'utf8');
if (txt.length < 10_000) fail(`agent.mjs 体积异常小(${txt.length} 字节),构建可能未完成`);
if (!txt.includes('__AGENT_VERSION__') && !/CAPABILITY_MANIFEST|node_id/.test(txt)) {
fail('agent.mjs 内容不像被控端产物(未找到预期标识)——拒绝打进包');
}
log(` 自检通过:agent.mjs ${(txt.length / 1024).toFixed(0)} KB,含被控端标识`);
} catch (e) {
fail(`agent.mjs 自检失败:${String(e.message ?? e)}`);
}
// ---------- ③ 生成构建期注入片段 ----------
// ⚠️ 两份资源都写明:既能保证「无论 Tauri 的 config 合并是深合并还是替换」都正确,
// 也让注入文件自解释(单看它就知道应用内会带哪些 resource)。
// key 相对 tauri.conf.json 所在目录(src-tauri);value 相对应用内 Resources 目录。
const gen = {
bundle: {
resources: {
'../sidecar/dist/sidecar.mjs': 'sidecar.mjs',
[`runtime/${nodeName}`]: nodeName,
'runtime/agent.mjs': 'agent.mjs',
},
},
};
const genPath = join(SRC_TAURI, 'tauri.bundle.gen.json');
writeFileSync(genPath, JSON.stringify(gen, null, 2) + '\n');
log(` 注入片段 → ${genPath}`);
log(` ${JSON.stringify(gen.bundle.resources)}`);
writeFileSync(stamp, JSON.stringify({ ...fingerprint, stripped: doStrip }, null, 2) + '\n');
// ---------- ④ 体积报告(让「代价」可见,而不是悄悄变大)----------
const nodeSize = statSync(dstNode).size;
const sizeOf = (p) => (existsSync(p) ? statSync(p).size : 0);
const sidecarSize = sizeOf(SIDECAR);
log('');
log('=== 体积 ===');
log(` Node 运行时 ${mb(nodeSize)}`);
log(` sidecar ${(sidecarSize / 1024).toFixed(0)} KB`);
log(` agent.mjs ${(agentBytes / 1024).toFixed(0)} KB`);
log(` 合计 ${mb(nodeSize + sidecarSize + agentBytes)}(未压缩,即 .app 内的增量)`);
log('');
log('下一步:');
log(' pnpm --filter nodeagent-console bundle');
log(' (= sidecar:build + prepare-console + tauri build --config src-tauri/tauri.bundle.gen.json)');
+49 -33
View File
@@ -18,6 +18,8 @@ import { createHash } from 'node:crypto';
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { checkZipArch } from './lib/zip-pe-arch.mjs';
import { writeVersionEverywhere } from './lib/version-write.mjs';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const argv = process.argv.slice(2);
@@ -97,41 +99,25 @@ const remote = sh('git', ['remote', 'get-url', 'origin'])
const repoPath = remote.replace(/^https:\/\/github\.com\//, '');
if (!/^[\w.-]+\/[\w.-]+$/.test(repoPath)) fail(`无法从 remote 推断 GitHub 仓库: ${remote}`);
// ---------- ② 改版本(单一版本源:根 + 各 workspace + Tauri 清单)----------
const pkgFiles = ['package.json'];
for (const f of sh('git', ['ls-files', '*package.json']).split('\n').filter(Boolean)) {
if (f === 'package.json' || f.includes('node_modules')) continue;
pkgFiles.push(f);
}
// Tauri 的版本号不在 package.json 里,另存于 src-tauri/tauri.conf.json,
// 且它才是最终打进 .app / 安装包的版本。漏掉会「包里是旧版本号」,故一并纳入。
const tauriConfFiles = sh('git', ['ls-files', '*src-tauri/tauri.conf.json'])
.split('\n')
.filter(Boolean);
// ---------- ② 改版本(单一版本源:根 + 各 workspace + Tauri/Cargo 清单)----------
// 2026-10-13:原先这里手写两段 for 循环,只覆盖 package.json + tauri.conf.json,
// 于是 src-tauri/{Cargo.toml,Cargo.lock} 里的版本号在发版后会**滞留**(dry-run 实证:8+1 处,
// Cargo 两处漏网),与 tauri.conf.json 悄悄分叉 —— 不报错,属最难发现的漂移。
// 现收敛进 lib/version-write.mjs(纯函数),由 tests/unit/version-write.test.mjs 对**真实文件**断言。
const { changed, skipped } = writeVersionEverywhere(root, version, { dryRun });
if (dryRun) {
// dry-run 绝不改文件 —— 演练就该是无副作用的
console.log(`(dry-run)将把 ${pkgFiles.length} 个 package.json 的 version 改为 ${version}`);
if (tauriConfFiles.length) {
console.log(`(dry-run)以及 ${tauriConfFiles.length} 个 tauri.conf.json:${tauriConfFiles.join(', ')}`);
}
// dry-run 绝不改文件 —— 演练就该是无副作用的(writeVersionEverywhere 已按 dryRun 只读)
const byKind = (k) => changed.filter((c) => c.kind === k).map((c) => c.file);
console.log(`(dry-run)将把 ${changed.length} 处版本号改为 ${version}:`);
for (const rel of byKind('json')) console.log(` ${rel}`);
for (const rel of byKind('cargo-toml')) console.log(` ${rel}`);
for (const rel of byKind('cargo-lock')) console.log(` ${rel}`);
for (const s of skipped) console.log(` (跳过)${s}`);
console.log('(dry-run)将执行:构建 → pack:win → pack:mac → 生成资产 → 提交打标签 → gh release create');
process.exit(0);
}
for (const f of pkgFiles) {
const p = join(root, f);
const j = JSON.parse(readFileSync(p, 'utf8'));
if (!j.version) continue;
j.version = version;
writeFileSync(p, JSON.stringify(j, null, 2) + '\n');
}
for (const f of tauriConfFiles) {
const p = join(root, f);
const j = JSON.parse(readFileSync(p, 'utf8'));
if (!j.version) continue;
j.version = version;
writeFileSync(p, JSON.stringify(j, null, 2) + '\n');
}
console.log(`✓ 版本号已写入 ${pkgFiles.length} 个 package.json + ${tauriConfFiles.length} 个 tauri.conf.json`);
for (const s of skipped) console.warn(`(跳过)${s}`);
console.log(`✓ 版本号已写入 ${changed.length} 处(package.json + tauri.conf.json + Cargo 清单)`);
// ---------- ③ 构建 + 打包 ----------
console.log('=== 构建 ===');
@@ -157,6 +143,33 @@ for (const z of [zipX64, zipArm64]) {
if (!existsSync(z)) fail(`找不到 zip 产物: ${z}`);
}
// ---------- ③.5 交付物断言:直读**最终 zip 内**的 node.exe 架构 ----------
// 2026-10-11 事故复盘:arm64 包里曾装进 x64 的 node.exe,而该包在 ARM Windows 上
// **能启动、能 listen,只是 TLS 握手不完成**(控制端只见 E_TIMEOUT)—— 单测、CI、
// macOS 侧全都抓不到,两个公开包内 node.exe 的 sha256 还完全相同。
// pack.mjs 里已有断言,但那仍是**打包流程内部**的断言;真正该钉死的是**交付物本身**
// 的性质。故此处解出最终 zip 里的 node.exe、读 PE 头,与架构期望比对。
// 实现见 scripts/lib/zip-pe-arch.mjs(纯内置 zlib,不依赖 unzip / Expand-Archive)。
console.log('=== 交付物断言(zip 内 node.exe 架构)===');
for (const [arch, zipPath] of [
['x64', zipX64],
['arm64', zipArm64],
]) {
let r;
try {
r = checkZipArch(zipPath, arch);
} catch (err) {
fail(`无法校验 ${zipPath}:${err.message}`);
}
if (!r.ok) {
fail(
`${zipPath} 内的 node.exe 架构不符:期望 ${arch}(${r.wantName}),实际 ${r.gotName}。\n` +
' 该错误包在 ARM Windows 上能启动/listen,却 TLS 握手不通 —— 严禁发布。',
);
}
console.log(` ✓ nodeagent-win-${arch}.zip → node.exe = ${r.gotName}`);
}
// ---------- ④ 生成资产(校验和 + latest.json)----------
const sha256 = (p) => createHash('sha256').update(readFileSync(p)).digest('hex');
const distDir = join(root, 'release', 'dist');
@@ -233,9 +246,12 @@ if (untracked.length > 0) {
// 同样会提交已修改的跟踪文件,而它们不在第一层的视野里。
// 真例:`pnpm install` 把某个应用的依赖写进了跟踪中的根 pnpm-lock.yaml
// (+1401 行),发版时静默推走,第一层闸门完全不响。
// 故此处用「脏文件白名单」:除脚本自己改的 package.json 外,一律不许有别处改动。
// 故此处用「脏文件白名单」:除脚本自己改的版本号文件外,一律不许有别处改动。
// 正常发版应在**干净工作区**上开始,所以这条不该有例外。
const allowedDirty = new Set([...pkgFiles, ...tauriConfFiles]);
// ⚠️ 白名单**必须**取自 writeVersionEverywhere 的实际改写清单(changed),
// 而不是重新手写一份文件名列表 —— 手写的那份一旦与写入逻辑漂移,
// 要么把该放行的拦下来(发版卡死),要么把不该放行的放过去(静默推走改动)。
const allowedDirty = new Set(changed.map((c) => c.file));
const dirty = sh('git', ['diff', '--name-only', 'HEAD'])
.split('\n')
.filter(Boolean);
+411
View File
@@ -9,6 +9,7 @@
import { spawn } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -16,6 +17,11 @@ import assert from 'node:assert/strict';
import { NodeAgentClient, runMacro } from '../../packages/client/dist/index.js';
import { generateKeyPair, findCapability } from '../../packages/protocol/dist/index.js';
// 裸 WebSocket:专门用来发**畸形报文**(正常路径一律走 NodeAgentClient)。
// pnpm 的严格隔离下 `ws` 不在本目录的解析链上,故按包名从 client 包的上下文里取。
const requireFromClient = createRequire(join(dirname(fileURLToPath(import.meta.url)), '../../packages/client/package.json'));
const RawWebSocket = requireFromClient('ws');
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, '../..');
@@ -106,6 +112,163 @@ async function connect(key = KEY) {
return c;
}
// ---------------------------------------------------------------------------
// v2.0.3 硬化回归的辅助设施
// ---------------------------------------------------------------------------
/** 启动一个**指定端口 + 指定配置片段**的被控端(独立 HOME,避免污染真实配置)。 */
function startAgentOn(port, extra = {}) {
const dataDir = mkdtempSync(join(tmpdir(), `nodeagent-e2e-${port}-`));
writeFileSync(
join(dataDir, 'agent.json'),
JSON.stringify({
node_id: `e2e_${port}`,
host: '127.0.0.1',
port,
tls: false,
key: KEY,
log_level: 'info',
...extra,
}),
);
const child = spawn(process.execPath, [join(root, 'apps/agent/dist/index.js')], {
env: { ...process.env, NODEAGENT_HOME: dataDir, HOME: dataDir, USERPROFILE: dataDir },
stdio: ['ignore', 'pipe', 'pipe'],
});
child.stdout.on('data', (d) => process.stdout.write(`[agent:${port}] ${d}`));
child.stderr.on('data', (d) => process.stderr.write(`[agent:${port}:err] ${d}`));
child.on('exit', (code, signal) => {
console.log(`\n⚠️ [agent:${port} 进程退出] code=${code} signal=${signal}\n`);
});
return child;
}
/** 等某个端口上的被控端就绪(被控端提前退出则立即失败)。 */
async function waitReadyOn(port, timeoutMs = 30_000, child = null) {
const url = `ws://127.0.0.1:${port}`;
const deadline = Date.now() + timeoutMs;
let lastErr = null;
while (Date.now() < deadline) {
if (child && child.exitCode !== null) {
throw new Error(`被控端(port ${port})已退出 code=${child.exitCode},请查看上方 agent 输出`);
}
try {
const c = new NodeAgentClient({ url, key: KEY, clientId: 'probe' });
await c.connect();
c.close();
return url;
} catch (err) {
lastErr = err;
await new Promise((r) => setTimeout(r, 300));
}
}
throw new Error(`被控端(port ${port})未在超时内就绪:${lastErr?.message ?? ''}`);
}
/** 裸 WebSocket 连接(只用于发畸形报文)。 */
function rawConnect(url) {
return new Promise((resolve, reject) => {
const sock = new RawWebSocket(url, { rejectUnauthorized: false });
const timer = setTimeout(() => reject(new Error('裸连接超时')), 8000);
sock.on('open', () => {
clearTimeout(timer);
resolve(sock);
});
sock.on('error', (err) => {
clearTimeout(timer);
reject(err);
});
});
}
/**
* 发一帧,等**第一帧**回复(超时或非 JSON 回复都返回 `null`)。
*
* 注意:畸形报文**可能没有回复**(例如根本不是 JSON),所以这里不把「无回复」当成失败 ——
* 判据一律落在「进程还活着吗 / 之后还能正常干活吗」(见 `assertProcessAlive`)。
*/
function sendOnce(sock, frame, timeoutMs = 4000) {
return new Promise((resolve) => {
const onMsg = (data) => {
cleanup();
try {
resolve(JSON.parse(data.toString()));
} catch {
resolve(null);
}
};
const timer = setTimeout(() => {
cleanup();
resolve(null);
}, timeoutMs);
function cleanup() {
clearTimeout(timer);
sock.off('message', onMsg);
}
sock.on('message', onMsg);
sock.send(typeof frame === 'string' ? frame : JSON.stringify(frame));
});
}
/**
* 「进程还活着」——**唯一可信的崩溃判据**。
*
* ⚠️ 为什么不能只看接口响应:真机缺陷里最险的形态正是「接口返回了一个正常业务错误,
* 但进程已经死了 / 命令已经执行了」(W-01、A-01)。只看响应会把缺陷判成通过。
* 故任何「不该崩」的用例都必须落到这里。
*/
function assertProcessAlive(child, label) {
assert.equal(
child.exitCode,
null,
`${label}:被控端进程不应退出(exitCode=${child.exitCode})`,
);
}
/** 收尾校验:进程不但「没死」,还得**还能干活**。 */
async function assertStillServing(port, label, expectedFailure = null) {
const c = new NodeAgentClient({ url: `ws://127.0.0.1:${port}`, key: KEY, clientId: 'e2e_mac' });
await c.connect();
const r = await c.invoke('system.info').then(
(x) => ({ ok: x }),
(e) => ({ err: e }),
);
c.close();
if (expectedFailure) {
// 「全拒」配置下,「还能干活」表现为**收到一个干净的、可解释的业务拒绝** ——
// 而不是连接失败 / 超时 / 进程已退出。
const name = r.err?.name ?? r.err?.code ?? r.ok?.error?.name;
assert.equal(
name,
expectedFailure,
`${label}:应返回 ${expectedFailure}(证明进程仍在正常处理请求),实际 ${JSON.stringify(
r.err?.message ?? r.ok,
)}`,
);
return;
}
assert.ok(r.ok && r.ok.status === 'ok', `${label}:之后应仍能正常调用能力,实际 ${JSON.stringify(r.err?.message ?? r.ok)}`);
}
/**
* 调用一个**预期失败**的能力,把两种失败形态归一成 `{name,message}`;真的成功了返回 `null`。
*
* ⚠️ 被控端有**两种**失败形态,测试必须都认:
* - **协议级**错误(未认证 / ACL 拒绝 / 未知能力)→ JSON-RPC error 帧 → 客户端 **throw**;
* - **能力内部**抛的 `CapabilityError`(参数非法等)→ 归一成 `{status:'failed', error}` 信封 → **不抛**。
* 真机实测(v2.0.3):`monitor.report` 的 id 穿越、`screen.capture` 的超大 region 都走**后者**。
* 只判「抛没抛」会把它们全误判成「没有被拒绝」。
*/
async function expectFailure(client, capability, args = {}) {
try {
const r = await client.invoke(capability, args);
if (r && r.status === 'failed' && r.error) return { name: r.error.name, message: r.error.message };
return null;
} catch (e) {
return { name: e?.name ?? e?.code, message: e?.message ?? String(e) };
}
}
/** TLS 场景:被控端以 wss 启动,控制端以自签证书 + insecure 连接。 */
async function testTlsMode() {
const dataDir = mkdtempSync(join(tmpdir(), 'nodeagent-tls-'));
@@ -1183,6 +1346,247 @@ async function testContractShapes() {
}
}
// ---------------------------------------------------------------------------
// v2.0.3 硬化回归(针对 2026-10-13 实测出的 18 条缺陷)
//
// 方法学(铁律 19 / 21):
// - **不能只看接口响应**。真机缺陷里最险的形态是「接口返回了正常业务错误,但进程已经死了 /
// 命令已经执行了」(W-01、A-01)。所以「不该崩」的用例,判据一律落到
// `assertProcessAlive` + 「之后还能正常调用能力」。
// - **对照组要逐条推演**(铁律 20):A-02 既要有「空 clients → 拒绝」,也要有
// 「有一个获授权的 client → 放行」,否则「一律拒绝」这种错误实现也能骗过测试。
// ---------------------------------------------------------------------------
/**
* ⚠️ 每个场景**各占一个端口**,不要复用。
* 复用会踩「上一块的进程刚 kill、端口还没释放,下一块的新实例 bind 失败退出,
* 而 waitReadyOn 连上的是**上一块残留的**那个实例」——于是断言的其实是别人的配置
* (真踩过:对照组被上一块的空 clients 配置拒了,报「调用方未注册」)。
* 独立端口把这个竞态从根上消掉,比加 sleep 可靠。
*/
const HARDEN_PORTS = {
aclMissingClients: 18810,
aclEmptyClients: 18811,
aclAllowControl: 18812,
keysProto: 18813,
};
/** B-01 / B-02 / 崩溃链:畸形报文不得打死被控端(且必须留下审计)。 */
async function testMalformedFrames(agent) {
const cases = [
{
name: 'B-01 畸形 hello(protocol 传数字)',
frame: { jsonrpc: '2.0', id: 'h1', method: 'hello', params: { protocol: 123, client_id: 'x' } },
expect: 'E_PARAM_INVALID',
},
{
name: 'B-01b 畸形 hello(protocol 传对象)',
frame: { jsonrpc: '2.0', id: 'h2', method: 'hello', params: { protocol: { a: 1 } } },
expect: 'E_PARAM_INVALID',
},
{
name: 'B-02 畸形 auth(hmac 传数字)',
frame: { jsonrpc: '2.0', id: 'a1', method: 'auth', params: { nonce: 'n', client_id: 'x', hmac: 1 } },
expect: null, // nonce 先不匹配 → 走 AUTH_FAILED 分支,同样不得崩
},
{
name: 'B-02b 畸形 auth(hmac 传对象 + nonce 为 null)',
frame: { jsonrpc: '2.0', id: 'a2', method: 'auth', params: { nonce: null, client_id: 'x', hmac: { k: 1 } } },
expect: null,
},
{
name: '未认证直接 invoke(U-01:必须留审计且不崩)',
frame: { jsonrpc: '2.0', id: 'i1', method: 'invoke', params: { capability: 'system.info', args: {} } },
expect: 'E_AUTH_REQUIRED',
},
{
name: '畸形 invoke(capability 传数字)',
frame: { jsonrpc: '2.0', id: 'i2', method: 'invoke', params: { capability: 42, args: {} } },
expect: 'E_AUTH_REQUIRED',
},
];
for (const c of cases) {
const sock = await rawConnect(URL);
const resp = await sendOnce(sock, c.frame);
sock.close();
// ① 进程必须还活着(这是本组用例的**主判据**)
assertProcessAlive(agent, c.name);
// ② 有确定性预期的,顺带核对错误名
if (c.expect) {
assert.ok(resp && resp.error, `${c.name}:应返回错误帧,实际 ${JSON.stringify(resp)}`);
assert.equal(resp.error.name, c.expect, `${c.name}:错误名不符(${JSON.stringify(resp.error)})`);
}
}
// 不是 JSON 的裸帧:解析失败也必须只记审计、不崩
{
const sock = await rawConnect(URL);
const resp = await sendOnce(sock, 'not-json-at-all{{{');
sock.close();
assertProcessAlive(agent, '非 JSON 裸帧');
assert.ok(resp && resp.error, `非 JSON 裸帧:应返回 PARSE_ERROR,实际 ${JSON.stringify(resp)}`);
assert.equal(resp.error.name, 'E_PARSE_ERROR');
}
// ③ 收尾:不是「没死」就行,还得**还能干活**
await assertStillServing(PORT, '畸形报文组');
}
/**
* A-01 / A-02:ACL 归一化。
*
* 真机缺陷回顾:`{"acl":{"default_effect":"deny"}}`(省略空的 clients,很自然的写法)
* 原来会让**每一条 invoke 都崩掉被控端进程**;而 `clients: []` 更糟 —— 整段 ACL 校验被
* `Boolean(clients.length)` 判为 false → **fail-open 全放行**(配置写着默认拒绝却全通)。
* 正确语义:空 clients = 没有任何调用方被授权 = 拒绝一切(由 default_effect 决定)。
*/
async function testAclNormalization() {
// ① A-01:只写 default_effect,省略 clients
{
const port = HARDEN_PORTS.aclMissingClients;
const child = startAgentOn(port, { acl: { default_effect: 'deny' } });
try {
await waitReadyOn(port, 30_000, child);
const url = `ws://127.0.0.1:${port}`;
const c = new NodeAgentClient({ url, key: KEY, clientId: 'e2e_mac' });
await c.connect();
// 握手能过(psk),但能力级 ACL 应拒绝一切
const denied = await c.invoke('system.info').then(
() => null,
(err) => err,
);
assert.ok(denied, 'A-01:省略 clients 且 default_effect=deny 时应拒绝调用');
assert.equal(denied.name ?? denied.code, 'E_ACL_DENIED', `A-01:期望 E_ACL_DENIED,实际 ${denied.message}`);
c.close();
// 主判据:**没有崩**;且之后仍能拿到干净的业务拒绝(= 进程在正常处理请求)
assertProcessAlive(child, 'A-01');
await assertStillServing(port, 'A-01', 'E_ACL_DENIED');
} finally {
child.kill('SIGKILL');
}
}
// ② A-02:显式空 clients(原来 fail-open)
{
const port = HARDEN_PORTS.aclEmptyClients;
const child = startAgentOn(port, { acl: { default_effect: 'deny', clients: [] } });
try {
await waitReadyOn(port, 30_000, child);
const url = `ws://127.0.0.1:${port}`;
const c = new NodeAgentClient({ url, key: KEY, clientId: 'e2e_mac' });
await c.connect();
const denied = await c.invoke('system.info').then(
() => null,
(err) => err,
);
assert.ok(denied, 'A-02:空 clients + default_effect=deny 必须拒绝(原实现是 fail-open 全放行)');
assert.equal(denied.name ?? denied.code, 'E_ACL_DENIED', `A-02:期望 E_ACL_DENIED,实际 ${denied.message}`);
c.close();
assertProcessAlive(child, 'A-02');
await assertStillServing(port, 'A-02', 'E_ACL_DENIED');
} finally {
child.kill('SIGKILL');
}
}
// ③ A-02 对照组:有一个获授权的 client → 必须放行。
// 没有这一组,「一律拒绝」这种明显过头的实现也能骗过上面两条。
{
const port = HARDEN_PORTS.aclAllowControl;
const child = startAgentOn(port, {
acl: { default_effect: 'deny', clients: [{ client_id: 'e2e_mac', pubkey: 'x', allow: ['system.info'] }] },
});
try {
await waitReadyOn(port, 30_000, child);
const url = `ws://127.0.0.1:${port}`;
const c = new NodeAgentClient({ url, key: KEY, clientId: 'e2e_mac' });
await c.connect();
const r = await c.invoke('system.info');
assert.equal(r.status, 'ok', `A-02 对照组:获授权的 client 应被放行,实际 ${JSON.stringify(r)}`);
c.close();
} finally {
child.kill('SIGKILL');
}
}
// ④ B-03:`keys: {}` + `client_id: "constructor"` —— 原型链取值曾让进程崩溃
{
const port = HARDEN_PORTS.keysProto;
const child = startAgentOn(port, { keys: {} });
try {
await waitReadyOn(port, 30_000, child);
const url = `ws://127.0.0.1:${port}`;
const sock = await rawConnect(url);
// 先正常握手拿 nonce(不解析,只为了走到 auth 分支)
const hello = await sendOnce(sock, {
jsonrpc: '2.0',
id: 'h',
method: 'hello',
params: { protocol: '1.0', client_id: 'constructor' },
});
assert.ok(hello?.result?.nonce, `B-03:hello 应返回 nonce,实际 ${JSON.stringify(hello)}`);
const authResp = await sendOnce(sock, {
jsonrpc: '2.0',
id: 'a',
method: 'auth',
params: { nonce: hello.result.nonce, client_id: 'constructor', hmac: '00' },
});
sock.close();
assert.ok(authResp && authResp.error, `B-03:应返回鉴权失败,实际 ${JSON.stringify(authResp)}`);
assertProcessAlive(child, 'B-03');
assert.equal(
child.exitCode,
null,
'B-03:client_id=constructor 不得沿原型链取到 Object.prototype.constructor 而崩溃',
);
await assertStillServing(port, 'B-03');
} finally {
child.kill('SIGKILL');
}
}
}
/** M-01/M-02(monitor id 路径穿越)+ S-01(region 无上界):参数必须在落盘/分配内存之前被挡住。 */
async function testInputBounds() {
const c = await connect();
try {
// M-01:用 id 穿越到数据目录之外去读(能力名是 `monitor.*`,不是 `system.monitor.*`)
for (const bad of ['../../agent', '..%2f..%2fagent', 'a/b', '', 'x'.repeat(65)]) {
const err = await expectFailure(c, 'monitor.report', { id: bad });
assert.ok(err, `M-01:monitor.report 的 id=${JSON.stringify(bad)} 应被拒绝`);
assert.equal(err.name, 'E_PARAM_INVALID', `M-01:期望 E_PARAM_INVALID,实际 ${err.message}`);
}
// M-02:删除同理(真删不到数据目录外的东西)
const delErr = await expectFailure(c, 'monitor.delete', { id: '../../agent.json' });
assert.ok(delErr, 'M-02:monitor.delete 的穿越 id 应被拒绝');
assert.equal(delErr.name, 'E_PARAM_INVALID', `M-02:期望 E_PARAM_INVALID,实际 ${delErr.message}`);
// S-01:region 无上界曾可让被控端 OOM(100000×100000 ≈ 40GB 位图)
for (const region of [
{ x: 0, y: 0, width: 100000, height: 100000 },
{ x: 0, y: 0, width: 20000, height: 10 },
{ x: 0, y: 0, width: 10000, height: 10000 },
]) {
const err = await expectFailure(c, 'screen.capture', { region, format: 'jpeg' });
assert.ok(err, `S-01:region ${region.width}×${region.height} 应被拒绝`);
assert.equal(err.name, 'E_PARAM_INVALID', `S-01:期望 E_PARAM_INVALID,实际 ${err.message}`);
}
// 边界内的 region 不该被误伤(否则「一刀切拒绝」也能骗过上面三条)
const okRegion = await expectFailure(c, 'screen.capture', {
region: { x: 0, y: 0, width: 64, height: 64 },
format: 'jpeg',
});
assert.ok(
okRegion === null || okRegion.name !== 'E_PARAM_INVALID',
`S-01 对照组:小 region 不应被判 PARAM_INVALID,实际 ${JSON.stringify(okRegion)}`,
);
} finally {
c.close();
}
}
async function main() {
console.log('\nnodeagent 端到端测试\n');
const agent = startAgent();
@@ -1391,6 +1795,13 @@ async function main() {
// ---------- 契约形状(agent 实现 ↔ 协议契约)----------
await test('契约形状:只读能力返回字段与 manifest returns_schema 一致', testContractShapes);
// ---------- v2.0.3 硬化回归(2026-10-13 实测缺陷)----------
await test('v2.0.3 畸形报文不得打死被控端(B-01/B-02 + 崩溃链 + U-01 审计)', () =>
testMalformedFrames(agent),
);
await test('v2.0.3 ACL 归一化:省略 clients 不崩 / 空 clients 不 fail-open / B-03 原型链', testAclNormalization);
await test('v2.0.3 参数上界:monitor id 穿越(M-01/02)+ region 上界(S-01)', testInputBounds);
} finally {
agent.kill('SIGTERM');
await new Promise((r) => setTimeout(r, 300));
+18
View File
@@ -63,6 +63,24 @@ test('CIDR:非法输入不误放行(IPv6/主机名/越界位)', () => {
assert.equal(ipMatchesCidr('192.168.1.999', '192.168.1.0/24'), false);
});
test('F-01:空的掩码位数必须判非法,不得被当成 /0 而放行全部', () => {
// 真机复现:原来只做 `Number.isFinite(Number(bitsRaw))`,而 **Number('') === 0**
// → '10.0.0.0/' 被当成 /0 = 匹配全部,于是 `ipMatchesCidr('8.8.8.8','10.0.0.0/')` 返回 true。
// 这类「配置写漏了却被读成最宽松」的形态最危险:不报错、且方向正好相反。
assert.equal(ipMatchesCidr('8.8.8.8', '10.0.0.0/'), false, '空掩码不得被当成 /0');
assert.equal(ipMatchesCidr('10.0.0.1', '10.0.0.0/'), false);
assert.equal(ipMatchesCidr('8.8.8.8', '0.0.0.0/'), false);
// 其它畸形位长同样不得放行
assert.equal(ipMatchesCidr('8.8.8.8', '0.0.0.0/ '), false);
assert.equal(ipMatchesCidr('8.8.8.8', '0.0.0.0/+0'), false);
assert.equal(ipMatchesCidr('8.8.8.8', '0.0.0.0/0x0'), false);
assert.equal(ipMatchesCidr('8.8.8.8', '0.0.0.0/-1'), false);
// 对照组(铁律 20:预期逐条推演,别把正确行为误报成缺陷)
assert.equal(ipMatchesCidr('8.8.8.8', '0.0.0.0/0'), true, '显式的 /0 是合法的「匹配全部」');
assert.equal(ipMatchesCidr('10.0.0.0', '10.0.0.0/32'), true, '/32 只含这一个地址');
assert.equal(ipMatchesCidr('10.0.0.1', '10.0.0.0/32'), false, '/32 不含 10.0.0.1');
});
test('IP 白名单:deny 优先、白名单为空则放行', () => {
assert.equal(ipAllowed({ client_id: 'a', allow: [] }, '1.2.3.4').allowed, true);
assert.equal(
+125
View File
@@ -0,0 +1,125 @@
/**
* C-01 守卫:审计筛选项不得与协议 enum 漂移。
*
* ⚠️ 背景(真机实测 2026-10-13,P1):控制台下拉框列了 11 个**细粒度**取值
* (`auth.success` / `acl.denied` / …),而 manifest 的 `enum` 只有
* `['invoke','auth','acl','agent']` → 被控端 schema 校验把**除 `invoke` 外**的取值
* 全部判成 `E_PARAM_INVALID`。下拉框形同虚设,而且**不报类型错**
* (和「静默空列」同一类病:类型系统抓不到,只有把真源钉死才防得住)。
*
* 真源:`packages/protocol/src/audit-types.ts` 的 `AUDIT_TYPES` / `AUDIT_TYPE_PREFIXES`。
* 本文件把三处一致性都钉住:真源 ↔ manifest enum ↔ 控制台下拉框;
* 另外用真实 `readAudit()` 验证「过滤是按前缀匹配」这一被依赖的语义。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, mkdtempSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { tmpdir } from 'node:os';
import {
AUDIT_TYPES,
AUDIT_TYPE_PREFIXES,
AUDIT_FILTER_TYPES,
findCapability,
} from '../../packages/protocol/dist/index.js';
import { initAudit, audit, readAudit } from '../../apps/agent/dist/audit.js';
const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(here, '..', '..');
const AUDIT_VIEW = join(repoRoot, 'apps', 'console', 'src', 'views', 'AuditView.vue');
/** 从 AuditView.vue 的 `typeOptions` 里取出全部非空 `value:` 字面量。 */
function consoleTypeOptions() {
const text = readFileSync(AUDIT_VIEW, 'utf8');
const start = text.indexOf('const typeOptions');
assert.ok(start >= 0, 'AuditView.vue 里找不到 typeOptions');
const end = text.indexOf('];', start);
assert.ok(end > start, 'typeOptions 数组未正常结束');
const block = text.slice(start, end);
return [...block.matchAll(/value:\s*"([^"]*)"/g)].map((m) => m[1]).filter((v) => v !== '');
}
test('C-01:协议真源自洽(精确类型 ∪ 粗粒度前缀,去重)', () => {
assert.equal(new Set(AUDIT_TYPES).size, AUDIT_TYPES.length, 'AUDIT_TYPES 不得有重复项');
const expect = Array.from(new Set([...AUDIT_TYPES, ...AUDIT_TYPE_PREFIXES]));
assert.deepEqual([...AUDIT_FILTER_TYPES].sort(), expect.sort());
});
test('C-01:manifest 的 enum 与真源完全一致', () => {
const cap = findCapability('system.audit.list');
assert.ok(cap, '找不到 system.audit.list');
const enumVals = cap.params_schema.properties.type.enum;
assert.deepEqual(
[...enumVals].sort(),
[...AUDIT_FILTER_TYPES].sort(),
'manifest enum 与 AUDIT_FILTER_TYPES 漂移 —— 曾经手写成 4 个值,导致控制台筛选静默失效',
);
});
test('C-01:每个审计类型都必须能被至少一个合法筛选值命中(没有筛不到的事件)', () => {
for (const t of AUDIT_TYPES) {
const hit = AUDIT_FILTER_TYPES.some((f) => t.startsWith(f));
assert.ok(hit, `审计类型 ${t} 无法被任何合法筛选值命中 —— 用户会以为它从未发生`);
}
});
test('C-01:每个粗粒度前缀都必须真能命中至少一个类型(不得有「永远为空」的选项)', () => {
for (const p of AUDIT_TYPE_PREFIXES) {
const hits = AUDIT_TYPES.filter((t) => t.startsWith(p));
assert.ok(hits.length > 0, `前缀 ${p} 命中不了任何审计类型 —— 下拉框里会是个永远为空的选项`);
}
});
test('C-01:控制台下拉框取值 ⊆ 真源,且覆盖全部粗粒度前缀', () => {
const values = consoleTypeOptions();
assert.ok(values.length > 0, '未能从 AuditView.vue 解析出 typeOptions(解析规则需同步更新)');
for (const v of values) {
assert.ok(
AUDIT_FILTER_TYPES.includes(v),
`控制台选项 ${v} 不在 AUDIT_FILTER_TYPES 里 —— 会被判 E_PARAM_INVALID(C-01 复发)`,
);
}
for (const p of AUDIT_TYPE_PREFIXES) {
assert.ok(values.includes(p), `控制台下拉框缺少粗粒度前缀 ${p}`);
}
});
test('C-01:readAudit 的 type 过滤确实是前缀匹配(控制台粗粒度分组依赖它)', () => {
const dir = mkdtempSync(join(tmpdir(), 'na-audit-filter-'));
initAudit({ enabled: true, logArgs: false }, dir);
audit({ type: 'auth.success', client_id: 'a' });
audit({ type: 'auth.failure', client_id: 'a' });
audit({ type: 'invoke', capability: 'system.info' });
audit({ type: 'acl.denied', capability: 'fs.read' });
audit({ type: 'request.rejected', reason: '畸形报文' });
audit({ type: 'agent.start' });
const auth = readAudit({ type: 'auth', limit: 100 });
assert.equal(auth.entries.length, 2, `auth 应命中 auth.success + auth.failure,实际 ${auth.entries.length}`);
assert.ok(auth.entries.every((e) => e.type.startsWith('auth')));
const agent = readAudit({ type: 'agent', limit: 100 });
assert.ok(
agent.entries.some((e) => e.type === 'agent.start'),
'agent 前缀应命中 agent.start',
);
const exact = readAudit({ type: 'acl.denied', limit: 100 });
assert.equal(exact.entries.length, 1, '精确类型也应可用(真实类型本身就是自己的前缀)');
const rejected = readAudit({ type: 'request', limit: 100 });
assert.equal(rejected.entries.length, 1, 'request 前缀应命中 request.rejected(U-01 新增类型必须可筛)');
const all = readAudit({ limit: 100 });
assert.equal(all.entries.length, 6, '不带 type 时应返回全部');
});
test('C-01:U-01 的 request.rejected 与兜底的 agent.error 都在真源里', () => {
assert.ok(AUDIT_TYPES.includes('request.rejected'), '被拒请求必须可被筛选(否则 U-01 等于白修)');
assert.ok(AUDIT_TYPES.includes('agent.error'), '进程级兜底错误必须可被筛选');
for (const t of ['request.rejected', 'agent.error']) {
assert.ok(AUDIT_FILTER_TYPES.includes(t));
}
});
+114
View File
@@ -0,0 +1,114 @@
/**
* L-01 守卫:CLI 的 `--timeout` 曾经**静默失效**。
*
* ⚠️ 背景(真机实测 2026-10-13,P2):`--timeout` 没在 `parseArgs` 的 `options` 里声明,
* 而解析器开了 `strict: false` → 未声明选项被当成 **boolean**(`values.timeout = true`),
* 它的值 `30000` 掉进 **positionals**。双重后果:
* ① 超时静默不生效(命令按默认超时跑);
* ② **位置参数被污染** —— 可能把子命令 / 主机名解析错。
* 而帮助文本与 `await` 命令用的正是 `--timeout`,所以这是「文档让你用、实际不管用」。
*
* 本文件钉两件事:① 两个写法都生效且不污染 positionals;
* ② **帮助文本里出现的每个 `--flag` 都必须在 options 里声明**(这一类病的总闸)。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import * as fsMod from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseOptions } from '../../apps/cli/dist/options.js';
const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(here, '..', '..');
const OPTIONS_TS = join(repoRoot, 'apps', 'cli', 'src', 'options.ts');
const CLI_SRC = join(repoRoot, 'apps', 'cli', 'src');
test('L-01:`--timeout 30000` 生效,且不污染位置参数', () => {
const { opts, positionals } = parseOptions(['--timeout', '30000']);
assert.equal(opts.timeoutMs, '30000', '--timeout 的值必须落到 timeoutMs(原来是静默丢弃)');
assert.deepEqual(positionals, [], `--timeout 的值不得掉进 positionals:${JSON.stringify(positionals)}`);
});
test('L-01:与子命令混用时位置参数仍正确', () => {
const { opts, positionals } = parseOptions(['await', '--condition', 'window', '--timeout', '30000']);
assert.equal(opts.timeoutMs, '30000');
assert.deepEqual(positionals, ['await'], '只有子命令应留在 positionals');
assert.equal(opts.condition, 'window');
});
test('L-01:`--timeout-ms`(bg 命令的别名)仍然生效', () => {
const { opts, positionals } = parseOptions(['bg', 'echo hi', '--timeout-ms', '5000']);
assert.equal(opts.timeoutMs, '5000');
assert.deepEqual(positionals, ['bg', 'echo hi']);
});
test('L-01:两个写法同时给出时以 `--timeout` 为准(文档写法优先)', () => {
const { opts } = parseOptions(['--timeout-ms', '1111', '--timeout', '2222']);
assert.equal(opts.timeoutMs, '2222');
});
/** 从 options.ts 的 `options: {…}` 块里取出声明过的选项名。 */
function declaredOptionNames() {
// ⚠️ 必须**先去注释**:本仓库的注释里常引用被删掉的旧声明(例如
// `// timeout: { type: 'string' },` 这种「曾经漏了、现已补上」的说明)。
// 不去注释就会把「注释里的声明」当成真的声明 → 守卫假阴性。
// (注入故障实测:不去注释时,把声明注释掉守卫仍然全绿。)
const src = readFileSync(OPTIONS_TS, 'utf8')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/^\s*\/\/.*$/gm, '');
const start = src.indexOf('options: {');
assert.ok(start >= 0, 'options.ts 里找不到 options: {');
const end = src.indexOf('allowPositionals', start);
assert.ok(end > start, '找不到 options 块的结束位置');
const block = src.slice(start, end);
const names = new Set();
for (const m of block.matchAll(/(?:'([\w-]+)'|"([\w-]+)"|([A-Za-z_][\w-]*))\s*:\s*\{\s*type\s*:/g)) {
names.add(m[1] ?? m[2] ?? m[3]);
}
assert.ok(names.size > 30, `只解析出 ${names.size} 个选项,正则可能失配(需同步更新)`);
return names;
}
/**
* 取出 CLI 里**承诺过的**所有 `--flag`。
*
* ⚠️ 必须扫**全部** src,不能只扫 `help.ts`:L-01 的那条 `--timeout` 根本不在 `help.ts` 里,
* 它只出现在 `cmd/gui.ts` 的 `fail('用法: … --timeout <ms> …')` 提示里。
* 只扫 help.ts 的守卫**放过**了真缺陷(注入故障实测:把 timeout 声明注释掉,守卫仍全绿)。
* 凡是「会出现在用户眼前的用法提示」都算承诺,故扫全目录。
*/
function promisedFlags() {
const flags = new Set();
for (const file of walkTs(CLI_SRC)) {
// 去注释:注释里会讲「曾经漏了某某选项」,那不是承诺
const text = readFileSync(file, 'utf8')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/^\s*\/\/.*$/gm, '');
for (const m of text.matchAll(/--([A-Za-z][\w-]*)/g)) flags.add(m[1]);
}
return flags;
}
function walkTs(dir) {
const { readdirSync, statSync } = fsMod;
const out = [];
for (const name of readdirSync(dir)) {
const p = join(dir, name);
if (statSync(p).isDirectory()) out.push(...walkTs(p));
else if (p.endsWith('.ts')) out.push(p);
}
return out;
}
test('L-01 总闸:用户可见的用法提示里每个 `--flag` 都必须在 parseArgs 里声明', () => {
const declared = declaredOptionNames();
const promised = promisedFlags();
assert.ok(promised.size > 30, `只从 CLI 源码里取到 ${promised.size} 个选项名,正则可能失配`);
const missing = [...promised].filter((f) => !declared.has(f)).sort();
assert.deepEqual(
missing,
[],
`用法提示承诺了但 parseArgs 未声明的选项(会静默失效 + 污染 positionals):\n ${missing.join('\n ')}`,
);
});
+232
View File
@@ -0,0 +1,232 @@
/**
* 控制台「命令 / 事件 / 资源」三组契约守卫。
*
* ⚠️ 为什么必须钉死(本项目反复吃过「两份真相」的亏)
* -------------------------------------------------
* 前端调 `invoke("agent_start")`,若 Rust 侧忘了在 `generate_handler!` 里注册,
* **编译不报错、类型检查也不报错** —— 只在用户点下按钮时得到一个
* "command agent_start not found"。事件名同理:Rust `emit("na-agent-state")`
* 与前端 `listen("na-agent-state")` 拼错一个字母,界面就**永远不刷新**且毫无提示。
* 资源映射同理:`resolve_*` 在 Rust 里找 `agent.mjs`,而打包映射写成别的名字,
* 结果是「开发期全绿、装完包找不到文件」。
*
* 三类都属于**静默失效**,故用本用例把三份契约对齐:
* ① Rust `generate_handler![...]` ⇄ 前端 `invoke("...")`
* ② Rust `emit("na-...")` ⇄ 前端 `listen("na-...")`
* ③ `tauri.bundle.gen.json` 的 resource 目标名 ⇄ Rust `resolve_*` 查找的字面量
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, existsSync, readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(here, '..', '..');
const CONSOLE = join(repoRoot, 'apps', 'console');
const LIB_RS = join(CONSOLE, 'src-tauri', 'src', 'lib.rs');
const AGENT_RS = join(CONSOLE, 'src-tauri', 'src', 'agent.rs');
const SIDECAR_RS = join(CONSOLE, 'src-tauri', 'src', 'sidecar.rs');
const API_TS = join(CONSOLE, 'src', 'api', 'index.ts');
const STORE_TS = join(CONSOLE, 'src', 'stores', 'agent.ts');
const GEN_JSON = join(CONSOLE, 'src-tauri', 'tauri.bundle.gen.json');
const libRs = readFileSync(LIB_RS, 'utf8');
const apiTs = readFileSync(API_TS, 'utf8');
/** 取 Rust `generate_handler![...]` 里注册的命令名。 */
function rustCommands() {
const m = /generate_handler!\s*\[([\s\S]*?)\]\s*\)/.exec(libRs);
assert.ok(m, '未在 lib.rs 找到 generate_handler! 列表');
return new Set(
m[1]
.split(',')
.map((s) => s.replace(/\/\/.*$/gm, '').trim())
.filter((s) => /^[a-z_][a-z0-9_]*$/.test(s)),
);
}
/** 取前端 `invoke<T>("name", ...)` 调用到的命令名。 */
function tsCommands() {
const out = new Set();
// 形如:invoke<AgentStatus>("agent_status") / invoke<void>("disconnect")
// / invoke<InvokeResult<T>>("invoke_capability") ← 泛型可嵌套(`InvokeResult<T>`),
// 故字符集要排除 `(` `)` `;`,否则会在第一个 `>` 处提前截断而漏掉这一条。
for (const m of apiTs.matchAll(/invoke\s*(?:<[^;()]{0,200}>)?\s*\(\s*"([a-z_][a-z0-9_]*)"/g)) {
out.add(m[1]);
}
return out;
}
test('前端调用的每个 Tauri 命令都已在 Rust 注册', () => {
const rust = rustCommands();
const ts = tsCommands();
assert.ok(rust.size >= 15, `Rust 命令解析异常,只取到 ${rust.size} 个`);
assert.ok(ts.size >= 15, `前端命令解析异常,只取到 ${ts.size} 个`);
const missing = [...ts].filter((c) => !rust.has(c)).sort();
assert.deepEqual(
missing,
[],
`前端调用了未注册的命令(运行时会报 "command not found"):${missing.join(', ')}`,
);
});
test('Rust 注册的每个命令都被前端使用(无死命令)', () => {
const rust = rustCommands();
const ts = tsCommands();
const unused = [...rust].filter((c) => !ts.has(c)).sort();
assert.deepEqual(
unused,
[],
`Rust 注册了但前端从未调用的命令(要么删掉、要么补上前端调用):${unused.join(', ')}`,
);
});
test('Rust 发出的事件名与前端监听的一致', () => {
const emitted = new Set([...libRs.matchAll(/emit\(\s*"(na-[a-z0-9-]+)"/g)].map((m) => m[1]));
const stores = readFileSync(STORE_TS, 'utf8')
+ readFileSync(join(CONSOLE, 'src', 'stores', 'conn.ts'), 'utf8');
const listened = new Set([...stores.matchAll(/listen<[^>]*>\(\s*"(na-[a-z0-9-]+)"/g)].map((m) => m[1]));
assert.ok(emitted.size >= 5, `Rust 事件名解析异常,只取到 ${emitted.size} 个:${[...emitted]}`);
assert.ok(listened.size >= 5, `前端事件名解析异常,只取到 ${listened.size} 个:${[...listened]}`);
const notListened = [...emitted].filter((e) => !listened.has(e)).sort();
assert.deepEqual(notListened, [], `Rust 发出但前端无监听的事件:${notListened.join(', ')}`);
const notEmitted = [...listened].filter((e) => !emitted.has(e)).sort();
assert.deepEqual(notEmitted, [], `前端监听了但 Rust 从不发出的事件:${notEmitted.join(', ')}`);
});
test('打包资源映射的目标名与 Rust 查找的字面量一致', () => {
// gen 片段是 prepare-console.mjs 的产物;测它的**目标名**(value)与 Rust 侧一致。
// 若未生成(例如只跑单测、没跑打包准备),则跳过而不是误报。
if (!existsSync(GEN_JSON)) return;
const gen = JSON.parse(readFileSync(GEN_JSON, 'utf8'));
const values = new Set(Object.values(gen.bundle.resources));
// Rust 侧实际查找的文件名(必须在 resource 目标名里出现,否则装完包找不到)
// node 在 Windows 上叫 node.exe —— 两者任一即可
const hasNode = values.has('node') || values.has('node.exe');
assert.ok(hasNode, `资源映射里没有 node 运行时:${[...values].join(', ')}`);
for (const name of ['sidecar.mjs', 'agent.mjs']) {
assert.ok(values.has(name), `资源映射里缺少 ${name}:${[...values].join(', ')}`);
}
// 反向:Rust 源码里确实在找这几个名字(防止「映射改了、Rust 没跟上」)
const agentRs = readFileSync(AGENT_RS, 'utf8');
const sidecarRs = readFileSync(SIDECAR_RS, 'utf8');
assert.match(agentRs, /"agent\.mjs"/, 'agent.rs 未在查找 agent.mjs');
assert.match(sidecarRs, /"sidecar\.mjs"/, 'sidecar.rs 未在查找 sidecar.mjs');
});
test('sidecar 与 Rust 的方法名对齐(check_update / download)', () => {
const sidecarSrc = readFileSync(join(CONSOLE, 'sidecar', 'main.mjs'), 'utf8');
for (const method of ['check_update', 'download', 'connect', 'disconnect', 'invoke', 'state']) {
assert.match(
sidecarSrc,
new RegExp(`\\b${method}\\s*\\(|\\b${method}\\s*:`),
`sidecar 未实现方法 ${method}`,
);
}
// Rust 侧调用的方法名必须都在 sidecar 里存在
const called = new Set(
[...libRs.matchAll(/\.request\(\s*"([a-z_]+)"/g)].map((m) => m[1]),
);
for (const m of called) {
assert.match(
sidecarSrc,
new RegExp(`\\b${m}\\s*\\(|\\b${m}\\s*:`),
`Rust 调用了 sidecar 的 "${m}",但 sidecar 未实现`,
);
}
});
test('新页面已在路由表中注册', () => {
const router = readFileSync(join(CONSOLE, 'src', 'router', 'index.ts'), 'utf8');
for (const name of ['local', 'logs']) {
assert.match(router, new RegExp(`name:\\s*"${name}"`), `路由缺少 ${name}`);
}
// 首页必须是「本机服务」—— 否则用户打开应用先看到一个要填地址的页面,
// 与「打开就能启动/停止/重启」的目标相悖
assert.match(router, /path:\s*"\/",\s*redirect:\s*"\/local"/, '默认路由应指向 /local');
// 每个 view 文件都必须真实存在(懒加载是字符串,写错文件名编译期不报错)
const views = readdirSync(join(CONSOLE, 'src', 'views'));
for (const m of router.matchAll(/import\(\s*"\.\.\/views\/([\w]+)\.vue"\s*\)/g)) {
assert.ok(views.includes(`${m[1]}.vue`), `路由引用了不存在的视图:${m[1]}.vue`);
}
});
test('自定义样式用到的 --na-* 变量都由调色板定义', () => {
const themeTsPath = join(CONSOLE, 'src', 'theme', 'index.ts');
const themeTs = readFileSync(themeTsPath, 'utf8');
// `cssVars()` 返回的键就是可用变量全集
const defined = new Set(
[...themeTs.matchAll(/"(--na-[^"]+)"\s*:/g)].map((m) => m[1]),
);
assert.ok(
defined.size >= 10,
`调色板变量只解析到 ${defined.size} 个 —— 守卫自身失效,先查正则`,
);
// 扫全 src 的使用点(定义文件自身豁免)
//
// 字符集刻意放宽到 `[^)\s,;]+` 而不是 `[a-z0-9-]+`:后者遇到
// `var(--na-sunkenTypo)` 这种含大写的拼错,会在 `T` 处停下导致整个模式**不匹配**,
// 于是「写错的变量名」反而逃过检查 —— 守卫静默漏报,比没有守卫更危险。
const used = new Map(); // 变量 → 首个出现位置
const walk = (dir) => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
if (e.name === 'node_modules') continue;
const p = join(dir, e.name);
if (e.isDirectory()) {
walk(p);
} else if (/\.(vue|ts|css)$/.test(e.name) && p !== themeTsPath) {
const src = readFileSync(p, 'utf8');
for (const m of src.matchAll(/var\(\s*(--na-[^)\s,;]+)\s*\)/g)) {
if (!used.has(m[1])) used.set(m[1], p);
}
}
}
};
walk(join(CONSOLE, 'src'));
assert.ok(used.size > 0, '未扫到任何 --na-* 使用点 —— 守卫自身失效');
// 关键:`var(--na-typo)` 这种拼错的变量**不会报错**,颜色只是静默落回继承值,
// 类型检查、构建、运行都不吭声。只能靠这条断言。
for (const [v, where] of used) {
assert.ok(
defined.has(v),
`${v}(${where})被使用但 cssVars() 未定义 —— 颜色会静默落回继承值`,
);
}
});
test('index.html 的首帧兜底色与调色板一致', () => {
const themeTs = readFileSync(join(CONSOLE, 'src', 'theme', 'index.ts'), 'utf8');
const blockOf = (name) => {
const i = themeTs.indexOf(`const ${name}: Palette = {`);
assert.ok(i > 0, `未找到 ${name} 调色板`);
return themeTs.slice(i, themeTs.indexOf('\n};', i));
};
const bgOf = (name) => {
const m = /bg:\s*"(#[0-9a-fA-F]{6})"/.exec(blockOf(name));
assert.ok(m, `未从 ${name} 调色板解析出 bg`);
return m[1];
};
// index.html 在 Vue 接管前先刷底色,是**唯一**允许的色值重复处(见文件内注释)。
// 允许重复就必须有守卫:改调色板忘了改它 → 深色用户每次启动闪一帧白。
const html = readFileSync(join(CONSOLE, 'index.html'), 'utf8');
for (const name of ['WHITE', 'GRAY']) {
const bg = bgOf(name);
assert.ok(
html.includes(bg),
`index.html 的首帧兜底色缺少 ${name}.bg = ${bg} —— 换主题时会闪一帧错色`,
);
}
});
+198
View File
@@ -0,0 +1,198 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join, relative } from 'node:path';
import { WIN_HELPER_PRELUDE } from '../../apps/agent/dist/capabilities/window.js';
import { buildListDisplaysScript, buildCaptureScript } from '../../apps/agent/dist/capabilities/screen.js';
import { buildInputPsScript } from '../../apps/agent/dist/capabilities/input.js';
import { PS_DPI_AWARE } from '../../apps/agent/dist/util/dpi.js';
/**
* 回归守卫:**屏幕坐标口径必须统一为物理像素**。
*
* ## 背景(2026-10-12 真机实证,Parallels Win11 ARM / 主机 200% 缩放 / 物理 3600×2020)
*
* PowerShell 默认 DPI 不感知,于是**同一个进程里并存两套坐标系**:
* - `Screen.Bounds` / `SystemInformation.VirtualScreen` → **逻辑**(1800×1010)
* - `CopyFromScreen` / `SetCursorPos` → **物理**
* (缺省整屏只截到左上 1/4;(100,100) 实际落到 (200,200))
* 而 UIA(`screen.find`)返回的**一直是物理坐标**。结果:
* ① 缺省 `screen.capture` 只截到屏幕左上 1/4,`screen.info` 报错尺寸;
* ② `screen.find` 的坐标直接喂 `input.mouse.click` → **点到两倍远**(「找得到、点不准」)。
* 两者都**不报任何错**,只是结果不对 —— 属于最难发现的一类缺陷。
*
* 修法:所有坐标路径统一执行 `PS_DPI_AWARE`(见 apps/agent/src/util/dpi.ts)。
*
* ## 为什么断言「下发的脚本串」而不是「源码里出现过常量」
* 首版守卫用源码扫描(文件里是否出现 `PS_DPI_AWARE`),**注入故障实测不会变红** ——
* 因为同一个文件里别处也引用了该常量。现改为直接断言各能力**真正发给 PowerShell 的脚本文本**,
* 以及调用的先后顺序(顺序错了同样不生效)。
*/
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '../..');
const read = (p) => readFileSync(join(ROOT, p), 'utf8');
/**
* 把任意平台的路径归一到 posix 形态。
*
* ⚠️ 必须显式归一:`path.relative()` 在 Windows 返回 `apps\agent\…`,
* 而在 macOS/Linux 返回 `apps/agent/…`。任何用 `/` 硬编码的 `includes()` 判断
* 在 Windows 上恒为 false —— 本地永远绿,CI 才红(2026-10-12 真例)。
*/
export const toPosix = (p) => p.replaceAll('\\', '/');
/**
* `capabilities/win-ps/` 下的片段文件是否应被兜底扫描排除。
*
* 这些片段(`prelude-*` / `body-*`)不自带 DPI 感知,而是由 `WIN_HELPER_PRELUDE`
* 在拼接时于**最前面**统一注入 —— 其序位已由「助手前置段:DPI 感知必须最先执行」断言。
* 因此排除它们是正确的,**但判断本身不得依赖平台分隔符**(函数内自行归一)。
*/
export const isWinPsFragment = (relPath, name) =>
toPosix(relPath).includes('capabilities/win-ps/') && /^(prelude|body)-/.test(name);
/** 断言:脚本里注入了 DPI 感知,且**早于**第一个依赖它的调用。 */
function assertDpiInjectedFirst(script, { before, label }) {
assert.ok(script.includes(PS_DPI_AWARE), `${label}:未注入 PS_DPI_AWARE`);
const iDpi = script.indexOf('::SetProcessDPIAware()');
const iUse = script.indexOf(before);
assert.ok(iDpi >= 0, `${label}:脚本里找不到 SetProcessDPIAware() 调用`);
assert.ok(iUse > 0, `${label}:脚本里找不到锚点 "${before}"(测试本身需要更新)`);
assert.ok(
iDpi < iUse,
`${label}:DPI 感知必须早于 "${before}" —— 晚于它执行等于没设,坐标仍按逻辑空间处理`,
);
}
test('PS_DPI_AWARE:确实调用 SetProcessDPIAware(先声明 P/Invoke 再调用)', () => {
assert.match(PS_DPI_AWARE, /DllImport\("user32\.dll"\)/, '缺少 user32 P/Invoke 声明');
assert.match(PS_DPI_AWARE, /\[void\]\[NADpiAware\]::SetProcessDPIAware\(\)/, '缺少 SetProcessDPIAware 调用');
assert.ok(
PS_DPI_AWARE.indexOf('Add-Type') < PS_DPI_AWARE.indexOf('[void][NADpiAware]'),
'必须先 Add-Type 声明类型,再调用',
);
});
test('screen.info:display 列表脚本必须 DPI 感知(否则报逻辑尺寸)', () => {
assertDpiInjectedFirst(buildListDisplaysScript(), { before: 'Screen]::AllScreens', label: 'screen.info' });
});
test('screen.capture:截图脚本必须 DPI 感知(否则缺省整屏只截到 1/4)', () => {
const script = buildCaptureScript({ x: 0, y: 0, width: 10, height: 10 }, { scale: 1, format: 'jpeg', quality: 85 });
assertDpiInjectedFirst(script, { before: 'CopyFromScreen', label: 'screen.capture' });
});
test('input.mouse/keyboard:输入脚本必须 DPI 感知(否则与 screen.find 的物理坐标对不上)', () => {
const script = buildInputPsScript(['[NAInput]::MoveTo(1, 1)']);
assertDpiInjectedFirst(script, { before: 'Add-Type -TypeDefinition $code', label: 'input.*' });
assert.match(script, /SetCursorPos/, '输入脚本应含 SetCursorPos(锚点自检)');
});
test('助手前置段:DPI 感知必须最先执行', () => {
assert.ok(
WIN_HELPER_PRELUDE.startsWith(PS_DPI_AWARE),
'WIN_HELPER_PRELUDE 必须以 PS_DPI_AWARE 开头 —— DPI 感知必须在任何 System.Windows.Forms / ' +
'截屏 / 光标调用之前生效,否则整段白设',
);
});
test('助手前置段:DPI 感知只有一份,且不得退回到 OCR 的 try/catch 里', () => {
// 只数**调用**形态(P/Invoke 声明是 `bool SetProcessDPIAware();`,不带 `::`)
const hits = WIN_HELPER_PRELUDE.match(/::SetProcessDPIAware\(\)/g) ?? [];
assert.equal(
hits.length,
1,
`::SetProcessDPIAware() 调用出现 ${hits.length} 次 —— 应恰好 1 次(唯一位于前置段开头的 ` +
'PS_DPI_AWARE)。OCR 段的 try/catch 会吞掉放进去的调用,造成「有时感知、有时不感知」的漂移',
);
const idxDpi = WIN_HELPER_PRELUDE.indexOf('::SetProcessDPIAware()');
const idxForms = WIN_HELPER_PRELUDE.indexOf('Add-Type -AssemblyName System.Windows.Forms');
const idxRect = WIN_HELPER_PRELUDE.indexOf('GetWindowRect');
assert.ok(idxForms > 0 && idxRect > 0, '前置段应含 Forms 程序集与 GetWindowRect 声明');
assert.ok(idxDpi < idxForms, 'DPI 感知必须早于 System.Windows.Forms 加载');
assert.ok(idxDpi < idxRect, 'DPI 感知必须早于 GetWindowRect 的使用段');
});
test('screen.record:全屏探测必须 DPI 感知(否则录到左上 1/4)', () => {
const src = read('apps/agent/src/capabilities/record.ts');
assert.match(
src,
/\$\{PS_DPI_AWARE\};\s*Add-Type -AssemblyName System\.Windows\.Forms;\s*\$vs =/,
'record.ts 的全屏探测里 PS_DPI_AWARE 必须紧贴在 VirtualScreen 读取之前',
);
});
/**
* 兜底(nudge,非红证):任何**新**写进 capabilities 的坐标文件都会被点出来。
* 它拦不住「文件里引用了常量但没接到实际脚本」这种退化 —— 那种由上面的脚本级断言负责。
*/
test('兜底:capabilities 下命中坐标信号的新文件必须引用 PS_DPI_AWARE', () => {
const COORD_SIGNALS = [
'CopyFromScreen',
'SystemInformation]::VirtualScreen',
'Cursor]::Position',
'Screen]::AllScreens',
'Screen]::PrimaryScreen',
];
const dir = join(ROOT, 'apps/agent/src/capabilities');
const offenders = [];
const walk = (d) => {
for (const name of readdirSync(d)) {
const full = join(d, name);
if (statSync(full).isDirectory()) {
walk(full);
continue;
}
if (!name.endsWith('.ts') || name.endsWith('.d.ts')) continue;
const rel = toPosix(relative(ROOT, full));
// 片段文件由 WIN_HELPER_PRELUDE 统一提供 DPI 感知(上面已断言其序位)
if (isWinPsFragment(rel, name)) continue;
const src = readFileSync(full, 'utf8');
const hit = COORD_SIGNALS.find((s) => src.includes(s));
if (hit && !src.includes('PS_DPI_AWARE')) offenders.push(`${rel}(命中 "${hit}")`);
}
};
walk(dir);
assert.deepEqual(
offenders,
[],
'以下文件在用屏幕坐标却没引用 PS_DPI_AWARE —— 请 import 并在坐标调用前执行:\n' +
offenders.map((o) => ` - ${o}`).join('\n'),
);
});
/**
* 跨平台回归:排除逻辑必须与路径分隔符无关。
*
* 首版写的是 `rel.includes('capabilities/win-ps/')` —— 在 Windows 上 `rel` 形如
* `apps\agent\…`,该判断**恒为 false** → 片段文件被误报成违规,于是
* **本地 macOS 全绿、CI 才红**(2026-10-12 真例:`prelude-image.ts` 命中
* `CopyFromScreen` 被点名)。本测试直接喂 Windows 形态路径,把这类差异
* 钉进「本地可验」的范围,不再依赖真的跑在 Windows 上才发现。
*/
test('兜底守卫:片段文件排除逻辑必须与路径分隔符无关', () => {
const win = 'apps\\agent\\src\\capabilities\\win-ps\\prelude-image.ts';
const posix = 'apps/agent/src/capabilities/win-ps/prelude-image.ts';
assert.equal(toPosix(win), posix, 'toPosix 必须把反斜杠归一为斜杠');
assert.equal(isWinPsFragment(win, 'prelude-image.ts'), true, 'Windows 反斜杠路径必须被识别为片段');
assert.equal(isWinPsFragment(posix, 'prelude-image.ts'), true, 'posix 路径必须被识别为片段');
assert.equal(isWinPsFragment('apps\\agent\\src\\capabilities\\win-ps\\body-list.ts', 'body-list.ts'), true);
// 反向:非片段、或不在 win-ps 目录下的一律**不得**被排除(否则兜底形同虚设)
assert.equal(
isWinPsFragment('apps\\agent\\src\\capabilities\\screen.ts', 'screen.ts'),
false,
'capabilities 顶层文件不得被排除',
);
assert.equal(
isWinPsFragment('apps\\agent\\src\\capabilities\\win-ps\\helper.ts', 'helper.ts'),
false,
'win-ps 下的非片段文件(无 prelude-/body- 前缀)不得被排除',
);
});
+12 -2
View File
@@ -58,8 +58,17 @@ test('apply:不得使用会在 Win11 挂起的 netsh set address', () => {
test('apply(dhcp):切 DHCP 用原生 cmdlet', () => {
const s = buildApplyCommand('dhcp', 'Ethernet', { mode: 'dhcp' }, snap);
assert.match(s, /Set-NetIPInterface -InterfaceAlias "Ethernet" -Dhcp Enabled/);
// ⚠️ 断言方向在 v2.0.3 **翻转过**:原来写的是 `-InterfaceAlias "Ethernet"`(双引号)。
// 双引号形态正是 W-01 的根因 —— PS 双引号串会做变量插值与 `$(...)` 展开,
// 而 `JSON.stringify` 产出的 `\"` 在 PS 里根本不是转义(PS 用反引号)。
// 现在接口名统一走 `psLit()`(单引号字面量,什么都不展开)。
// 「CI 断言会过期、而且方向可能反」—— 这条就是活的例子。
assert.match(s, /Set-NetIPInterface -InterfaceAlias 'Ethernet' -Dhcp Enabled/);
assert.match(s, /Set-DnsClientServerAddress/);
assert.ok(
!/Set-NetIPInterface[^\n]*"/.test(s),
'接口名不得再出现在双引号串里(PS 双引号会插值,`${`…`}` 形同注入入口)',
);
});
test('自检:失败时自动执行回滚脚本(自愈)', () => {
@@ -67,7 +76,8 @@ test('自检:失败时自动执行回滚脚本(自愈)', () => {
assert.match(chk, /Get-NetIPAddress/);
assert.match(chk, /192\.168\.1\.88/, '应校验目标地址是否真的生效');
assert.match(chk, /selfcheck FAILED -> auto rollback/);
assert.match(chk, /& "C:\/rb\.ps1"/, '失败必须回滚,而不是把机器留在无地址状态');
// 同上:路径改为 PS 单引号字面量(原为 `& "C:/rb.ps1"`)
assert.match(chk, /& 'C:\/rb\.ps1'/, '失败必须回滚,而不是把机器留在无地址状态');
});
test('rollback:同样先加后清 + 先清路由(否则回滚自己也会失败)', () => {
+168
View File
@@ -0,0 +1,168 @@
/**
* 安全字面量(`util/quote.ts`)的行为守卫 + W-01 根因的**源码形态**守卫。
*
* ⚠️ 背景(P0,真机实测 2026-10-13):`window.focus` 把 `JSON.stringify(title)` 插进
* PowerShell 双引号串,payload 直接执行了任意命令 —— 而接口**还返回「正常的业务错误」**,
* 调用方完全看不出异常(最险的正是「静默」)。
* 根因有两条:PS 里 `\` 不是转义符;PS 双引号串会插值 `$(...)`。
* 所以本文件测两件事:
* ① `psLit`/`shLit` 的行为(唯一正确的字面量构造方式);
* ② 全仓**不得**再把 `${JSON.stringify(…)}` 拼进命令串(形态守卫,防止回归)。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
psLit,
psLitArray,
shLit,
assertSingleQuotedLiteral,
isSafeBareWindowsPathArg,
} from '../../apps/agent/dist/util/quote.js';
const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(here, '..', '..');
const AGENT_SRC = join(repoRoot, 'apps', 'agent', 'src');
// ---------------------------------------------------------------------------
// ① 行为
// ---------------------------------------------------------------------------
test('psLit:单引号翻倍,其余字符($ / 反引号 / 反斜杠)一律原样保留', () => {
assert.equal(psLit("a'b"), "'a''b'");
// 关键:**不做**任何反斜杠转义(PS 里 `\` 不是转义符,转了反而是错的)
assert.equal(psLit('C:\\tmp\\a'), "'C:\\tmp\\a'");
// $ 与反引号在单引号串里不展开 —— 这是选择单引号的全部理由
assert.equal(psLit('$env:PATH'), "'$env:PATH'");
assert.equal(psLit('`n`t'), "'`n`t'");
// 每个单引号都成对,形状自检通过
assert.ok(assertSingleQuotedLiteral(psLit("x'; New-Item -Path /tmp/pwned; '")));
});
test('psLit:W-01 的原始 payload 不再能逃出字符串', () => {
const payload = 'x"; New-Item -Path C:\\pwned -ItemType File; #';
const lit = psLit(payload);
// 形状:首尾单引号,且内部没有落单的单引号
assert.ok(lit.startsWith("'") && lit.endsWith("'"));
assert.ok(assertSingleQuotedLiteral(lit), `形状自检应通过:${lit}`);
// 语义:把外层单引号剥掉,必须**一字不差**地还原原串 —— 说明没有任何字符被吞掉/被当语法
assert.equal(lit.slice(1, -1), payload);
// payload 里的 `"` 是被关在单引号里的普通字符;PS 单引号串不做插值与子表达式展开,
// 所以 `$(…)` 这种形态也必须原样保留(这才是「逃不出去」的真正判据)
const cmdSub = '$(touch /tmp/pwned)';
assert.equal(psLit(cmdSub).slice(1, -1), cmdSub);
});
test('shLit:把单引号拆成 闭合→转义→重开,单引号内无任何展开', () => {
assert.equal(shLit("a'b"), "'a'\\''b'");
assert.equal(shLit('$(touch /tmp/pwned)'), "'$(touch /tmp/pwned)'");
assert.equal(shLit('`id`'), "'`id`'");
});
test('psLitArray:数组字面量(空数组产出 @())', () => {
assert.equal(psLitArray(['a', "b'c"]), "@('a','b''c')");
assert.equal(psLitArray([]), '@()');
});
test('assertSingleQuotedLiteral:未翻倍的单引号必须被判为不合法', () => {
assert.ok(assertSingleQuotedLiteral("'a''b'"));
// `'a'b'` 内部有一个落单的 `'` → 会把字符串提前闭合
assert.equal(assertSingleQuotedLiteral("'a'b'"), false);
assert.equal(assertSingleQuotedLiteral('a'), false);
assert.equal(assertSingleQuotedLiteral("'a"), false);
});
test('isSafeBareWindowsPathArg:正常临时路径可裸拼,含元字符的必须被拒', () => {
assert.ok(isSafeBareWindowsPathArg('C:\\Users\\u\\AppData\\Local\\Temp\\na-ps-x1\\run.ps1'));
assert.ok(isSafeBareWindowsPathArg('/tmp/na-ps-x1/run.ps1'));
for (const bad of [
'C:\\a"b\\run.ps1',
"C:\\a'b\\run.ps1",
'C:\\a&b\\run.ps1',
'C:\\a|b\\run.ps1',
'C:\\a%b\\run.ps1',
'C:\\a$b\\run.ps1',
'C:\\a`b\\run.ps1',
'C:\\a<b\\run.ps1',
'C:\\dir\\',
'C:\\a\nb\\run.ps1',
'',
]) {
assert.equal(isSafeBareWindowsPathArg(bad), false, `应判为不安全:${JSON.stringify(bad)}`);
}
});
// ---------------------------------------------------------------------------
// ② 形态守卫:全仓不得再把 JSON.stringify 拼进命令串
// ---------------------------------------------------------------------------
function walk(dir) {
const out = [];
for (const name of readdirSync(dir)) {
const p = join(dir, name);
if (statSync(p).isDirectory()) out.push(...walk(p));
else if (p.endsWith('.ts')) out.push(p);
}
return out;
}
/**
* 去掉注释后再扫。
*
* ⚠️ 必须去:本仓库大量注释在**讲解这个漏洞**,里面会原样出现 `replace(/'/g, "''")`
* 这种形态。不去注释就会把「讲解」当成「犯案」——守卫自己错(铁律 3 的第 ② 类)。
* 局限:只处理整行 `//` 与块注释;行尾 `//` 后面的内容仍会被扫到(宁可多报不漏报)。
*/
function stripComments(text) {
return text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
}
test('W-01 根因守卫:源文件里不得出现 `${JSON.stringify(…)}` 拼进命令串', () => {
// 命令串的判据词:出现任一,说明这一行在构造要交给 shell / PowerShell 的文本
const SHELL_MARKERS = [
'powershell',
'-Command',
'-File ',
'osascript',
'screencapture',
'winget',
'taskkill',
'netstat',
'tasklist',
'cmd /c',
'sh -c',
];
const offenders = [];
for (const file of walk(AGENT_SRC)) {
const rel = file.slice(AGENT_SRC.length + 1);
const lines = stripComments(readFileSync(file, 'utf8')).split('\n');
lines.forEach((line, i) => {
if (!line.includes('${JSON.stringify(')) return;
if (!SHELL_MARKERS.some((m) => line.includes(m))) return;
offenders.push(`${rel}:${i + 1} ${line.trim()}`);
});
}
assert.deepEqual(
offenders,
[],
`发现把 JSON.stringify 结果拼进命令串(W-01 同类,PS 里 \\ 不是转义符):\n${offenders.join('\n')}`,
);
});
test('W-01 根因守卫:PowerShell 单引号字面量必须来自 util/quote.ts(唯一真源)', () => {
// 允许的实现文件(它们就是那份唯一真源 / 明确 re-export)
const ALLOWED = new Set(['util/quote.ts', 'capabilities/network.ts']);
const offenders = [];
for (const file of walk(AGENT_SRC)) {
const rel = file.slice(AGENT_SRC.length + 1);
if (ALLOWED.has(rel)) continue;
const text = stripComments(readFileSync(file, 'utf8'));
// 手写 `replace(/'/g, "''")` 就是「又发明了一份 psLit」——两份实现必然漂移
if (/replace\(\s*\/'\s*\/g\s*,\s*["'`]{1,2}''["'`]{1,2}\s*\)/.test(text)) {
offenders.push(rel);
}
}
assert.deepEqual(offenders, [], `以下文件手写了 PS 单引号转义,应改用 util/quote.ts:\n${offenders.join('\n')}`);
});
+225
View File
@@ -0,0 +1,225 @@
/**
* 版本号写入(`scripts/lib/version-write.mjs`)的行为守卫 + 发版漂移守卫。
*
* ⚠️ 背景(2026-10-13):`scripts/release.mjs` 原先只改 `package.json` + `tauri.conf.json`,
* 于是 `apps/console/src-tauri/{Cargo.toml,Cargo.lock}` 里的版本号**发版后会滞留**。
* `release.mjs patch --dry-run` 实证:报「8 个 package.json + 1 个 tauri.conf.json」,
* Cargo 两处**完全不在视野里** —— 而它们同样写着 2.0.2,会与 tauri.conf.json 悄悄分叉。
* 分叉不报错(Tauri 打包以 tauri.conf.json 为准)→ 属最难发现的漂移。
*
* 为什么这样测(铁律「守卫要落在最终产物字节上」):
* 首版若断言「release.mjs 源码里出现过 Cargo.toml 字样」,注入故障都不会变红。
* 本文件改为:在临时目录里铺**真实文件**(含 git init + git add,让 `git ls-files` 真的工作),
* 跑一遍写入,再**读回文件内容**断言 —— 这是能被注入故障打红的守卫。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
readCargoPackageName,
setCargoLockVersion,
setCargoTomlVersion,
versionTargets,
writeVersionEverywhere,
} from '../../scripts/lib/version-write.mjs';
const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(here, '..', '..');
// ---------------------------------------------------------------------------
// ① 纯函数:Cargo.toml
// ---------------------------------------------------------------------------
test('Cargo.toml:只改 [package] 段的 version', () => {
const src = [
'[package]',
'name = "nodeagent-console"',
'version = "2.0.2"',
'edition = "2021"',
'',
'[build-dependencies]',
'tauri-build = { version = "2", features = [] }',
'',
// 关键陷阱:非 [package] 段里**同样存在** `version = "2.0.2"` 这种独占一行的写法。
// 不做段落限定的话,全文盲替换会把它一起改坏(TOML 合法,改完不报错)。
'[package.metadata.deb]',
'version = "2.0.2"',
'',
].join('\n');
const out = setCargoTomlVersion(src, '9.9.9', 'Cargo.toml');
assert.match(out, /\[package\]\nname = "nodeagent-console"\nversion = "9\.9\.9"\n/);
assert.equal(out.match(/version = "2\.0\.2"/g)?.length, 1, '非 [package] 段的那处必须原样保留');
assert.equal(out.match(/version = "9\.9\.9"/g)?.length, 1, '被改的只能有一处');
});
test('Cargo.toml:缺 [package] version 时大声失败,不静默跳过', () => {
assert.throws(() => setCargoTomlVersion('[package]\nname = "x"\n', '9.9.9', 'Cargo.toml'), /找不到 version 行/);
});
test('Cargo.toml:读到 crate 名(用于去 Cargo.lock 里定位同名包)', () => {
assert.equal(readCargoPackageName('[package]\nname = "nodeagent-console"\nversion = "2.0.2"\n'), 'nodeagent-console');
assert.equal(readCargoPackageName('[dependencies]\nname = "not-this"\n'), null);
});
// ---------------------------------------------------------------------------
// ② 纯函数:Cargo.lock
// ---------------------------------------------------------------------------
test('Cargo.lock:只改指定 crate 的版本,同名依赖版本一律不动', () => {
const src = [
'version = 4',
'',
'[[package]]',
'name = "nodeagent-console"',
'version = "2.0.2"',
'dependencies = [',
' "thiserror",',
']',
'',
'[[package]]',
'name = "proc-macro-crate"',
'version = "2.0.2"',
'',
'[[package]]',
'name = "statuses"',
'version = "2.0.2"',
'',
].join('\n');
const { text, changed } = setCargoLockVersion(src, 'nodeagent-console', '9.9.9');
assert.equal(changed, true);
assert.match(text, /name = "nodeagent-console"\nversion = "9\.9\.9"/);
assert.equal(
text.match(/version = "2\.0\.2"/g)?.length,
2,
'两个同名版本的依赖(proc-macro-crate / statuses)必须原样保留',
);
// 顶部那个 `version = 4`(lock 格式版本)也不能被动
assert.match(text, /^version = 4\n/);
});
test('Cargo.lock:找不到该 crate 时返回原文并报 changed=false(未构建的 checkout)', () => {
const { text, changed } = setCargoLockVersion('[[package]]\nname = "other"\nversion = "1.0.0"\n', 'nodeagent-console', '9.9.9');
assert.equal(changed, false);
assert.match(text, /name = "other"\nversion = "1\.0\.0"/);
});
// ---------------------------------------------------------------------------
// ③ 编排:对临时目录里的**真实文件**跑一遍
// ---------------------------------------------------------------------------
function scaffold() {
const dir = mkdtempSync(join(tmpdir(), 'na-version-'));
const tauriDir = join(dir, 'apps', 'console', 'src-tauri');
mkdirSync(tauriDir, { recursive: true });
writeFileSync(join(dir, 'package.json'), `${JSON.stringify({ name: 'nodeagent', version: '2.0.2' }, null, 2)}\n`);
writeFileSync(join(tauriDir, 'tauri.conf.json'), `${JSON.stringify({ productName: 'x', version: '2.0.2' }, null, 2)}\n`);
writeFileSync(
join(tauriDir, 'Cargo.toml'),
['[package]', 'name = "nodeagent-console"', 'version = "2.0.2"', 'edition = "2021"', ''].join('\n'),
);
writeFileSync(
join(tauriDir, 'Cargo.lock'),
[
'version = 4',
'',
'[[package]]',
'name = "nodeagent-console"',
'version = "2.0.2"',
'',
'[[package]]',
'name = "statuses"',
'version = "2.0.2"',
'',
].join('\n'),
);
// 必须真的进一次 git 索引 —— `versionTargets` 走 `git ls-files`,
// 只认「已入库文件」,否则临时目录里它会返回空集,测试就会假绿。
execFileSync('git', ['init', '-q'], { cwd: dir });
execFileSync('git', ['add', '-A'], { cwd: dir });
return dir;
}
test('四处版本号一起改:package.json + tauri.conf.json + Cargo.toml + Cargo.lock', () => {
const dir = scaffold();
const res = writeVersionEverywhere(dir, '9.9.9');
const files = res.changed.map((c) => c.file);
assert.deepEqual(files.sort(), [
'apps/console/src-tauri/Cargo.lock',
'apps/console/src-tauri/Cargo.toml',
'apps/console/src-tauri/tauri.conf.json',
'package.json',
].sort());
assert.equal(res.skipped.length, 0, `不该有跳过项:${res.skipped.join(', ')}`);
assert.equal(JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).version, '9.9.9');
assert.equal(JSON.parse(readFileSync(join(dir, 'apps/console/src-tauri/tauri.conf.json'), 'utf8')).version, '9.9.9');
assert.match(readFileSync(join(dir, 'apps/console/src-tauri/Cargo.toml'), 'utf8'), /version = "9\.9\.9"/);
const lock = readFileSync(join(dir, 'apps/console/src-tauri/Cargo.lock'), 'utf8');
assert.match(lock, /name = "nodeagent-console"\nversion = "9\.9\.9"/);
assert.match(lock, /name = "statuses"\nversion = "2\.0\.2"/, '依赖版本不得被连带改掉');
// JSON 写回后仍要是「2 空格缩进 + 尾换行」(否则会污染 diff)
const rawPkg = readFileSync(join(dir, 'package.json'), 'utf8');
assert.ok(rawPkg.endsWith('}\n') && rawPkg.includes('\n "version"'), 'JSON 格式应与既有文件一致');
});
test('dry-run 一个字节都不改', () => {
const dir = scaffold();
const before = readFileSync(join(dir, 'apps/console/src-tauri/Cargo.toml'), 'utf8');
const res = writeVersionEverywhere(dir, '9.9.9', { dryRun: true });
assert.equal(res.changed.length, 4);
assert.equal(readFileSync(join(dir, 'apps/console/src-tauri/Cargo.toml'), 'utf8'), before);
assert.match(readFileSync(join(dir, 'package.json'), 'utf8'), /"version": "2\.0\.2"/);
});
// ---------------------------------------------------------------------------
// ④ 发版漂移守卫:真实仓库里 11 处版本号必须互一致
// ---------------------------------------------------------------------------
test('仓库内全部版本号互一致(发版漏项会在这里变红)', () => {
const t = versionTargets(repoRoot);
// 防「枚举失效 → 空集通过」的假绿:数量不对就直接失败
assert.equal(t.pkgFiles.length, 8, `package.json 应枚举到 8 个,实际 ${t.pkgFiles.length}`);
assert.equal(t.tauriConfFiles.length, 1, 'tauri.conf.json 应枚举到 1 个');
assert.equal(t.cargoTomlFiles.length, 1, 'Cargo.toml 应枚举到 1 个');
assert.equal(t.cargoLockFiles.length, 1, 'Cargo.lock 应枚举到 1 个');
const versions = new Map();
for (const f of [...t.pkgFiles, ...t.tauriConfFiles]) {
versions.set(f, JSON.parse(readFileSync(join(repoRoot, f), 'utf8')).version);
}
for (const f of t.cargoTomlFiles) {
const m = /\[package\][\s\S]*?^version\s*=\s*"([^"]*)"/m.exec(readFileSync(join(repoRoot, f), 'utf8'));
versions.set(f, m?.[1]);
}
for (const f of t.cargoLockFiles) {
const m = /name\s*=\s*"nodeagent-console"\nversion\s*=\s*"([^"]*)"/.exec(readFileSync(join(repoRoot, f), 'utf8'));
versions.set(f, m?.[1]);
}
const distinct = new Set(versions.values());
const dump = [...versions].map(([f, v]) => ` ${v} ${f}`).join('\n');
assert.equal(distinct.size, 1, `版本号出现分叉(发版时漏改了某个清单):\n${dump}`);
assert.ok(!distinct.has(undefined), `有清单读不出版本号:\n${dump}`);
});
test('发版脚本用「实际改写清单」做脏文件白名单,不得回退成手写文件名列表', () => {
const src = readFileSync(join(repoRoot, 'scripts', 'release.mjs'), 'utf8');
assert.match(src, /writeVersionEverywhere/, 'release.mjs 必须走 lib/version-write.mjs');
assert.match(
src,
/const allowedDirty = new Set\(changed\.map/,
'白名单必须取自写入结果(手写列表会与写入逻辑漂移)',
);
assert.doesNotMatch(
src,
/allowedDirty = new Set\(\[\.\.\.pkgFiles/,
'不得再引用已删除的 pkgFiles/tauriConfFiles 变量',
);
});