105 Commits
Author SHA1 Message Date
jkooe 9b6b063e2b docs: 更新 README 单测计数为 483 2026-11-03 20:33:36 +08:00
jkooe cf51e0415a fix(scripts): 自重启任务纳入卸载清理 —— 修「僵尸任务反复拉起已删文件」
真机发现:`system.agent.restart` 每次重启注册 `nodeagent-selfrestart-<毫秒>`,
而 TASK_WHITELIST 只有 nodeagent/na-gui/nodeagent-console → 卸载后这类任务残留,
会持续尝试拉起已被删除的 agent.mjs。

但它不能当垃圾随手清:**Running 的那一个往往就是当前 agent 的父任务**
(删了会连带杀掉 agent)。故只纳入「卸载清理」判定集合,由调用方在停掉
agent 之后使用。

匹配用严格命名空间 `^nodeagent-selfrestart-\d{6,}$`(纯数字时间戳形态),
不做任何通配;`nodeagent-selfrestart-abc`/前缀不全/带额外后缀一律不认,
单测同时钉死「第三方任务(Adobe/OneDrive)绝不出现」。
2026-10-27 13:06:51 +08:00
jkooe c062d1cc8b fix(agent): ETIMEDOUT 给出「配代理」提示 —— 境内直连最高频失败形态无指引
真机(Windows 192.168.0.160)验证自更新出网时发现:
`connect ETIMEDOUT 20.205.243.166:443` 落到 describeNetError 的兜底分支,
只回一串 IP:端口超时 —— 而这正是境内直连 GitHub 最常见的形态,用户唯一
有效动作就是配代理,却完全没被告知。

修法:新增 ETIMEDOUT / EHOSTUNREACH / ENETUNREACH 分支,点明
「设置 NA_PROXY=<代理地址> 或配置系统代理后重试」。与 ECONNRESET 的
「代理或线路问题」保持区分(两类原因不同,单测钉死互不串味)。
真机复验:重新部署后同一命令已输出新提示。
2026-10-27 13:06:51 +08:00
jkooe 0b858bb343 docs(changelog): 记录本轮四项遗留修复 + 单测数 477→480
- CHANGELOG [未发布] 补:依赖彻底清零、看门狗日志轮换、L2 参数常显、
  e2e 子进程兜底清理;
- README 单测数同步 480(docs-numbers 守卫强制耦合)。
2026-10-27 08:04:33 +08:00
jkooe a769479a2a chore(deps): 剩余中低危清零 —— hono / ip-address / fast-uri 抬到修复版
上一轮把 3 个 high 压掉后,复扫仍余 6 条(2 low / 4 moderate),全部经
@modelcontextprotocol/sdk 传递:

- hono <4.13.11(serveStatic 二次解码绕过中间件)→ ^4.13.14;
- ip-address <=10.7.3(zone 标识符二次方耗时 DoS)→ ^10.7.5;
- fast-uri <3.1.9(scheme 伪造 / host 混淆 / 路径等价)→ ^3.1.9。

与 dependabot #3/#6/#7 同源,覆盖后那三个 PR 无增量,随本提交关闭。
当前 pnpm audit:No known vulnerabilities found。
2026-10-27 08:04:32 +08:00
jkooe 5e32d7fa55 test(e2e): 子进程统一登记 + 退出兜底 SIGKILL —— 修连跑偶发失败
套件有 8 处 spawn,各自 finally 只发 kill()(SIGTERM)就 process.exit(),
被控端可能来不及退出 → 下一轮固定端口仍被占用,表现为「连跑第二次偶发失败、
重跑又绿」这种最难查的形态。

做法:把 spawn 包一层统一登记(同名函数遮蔽,调用点零改动),进程退出时对
仍存活的子进程 SIGKILL。单点收口,将来新增 spawn 也不会漏。

实测:连跑两次 34/34,退出后 pgrep 无残留 agent 进程。
2026-10-27 08:04:27 +08:00
jkooe eebfb45f73 fix(console): L2 采集参数常显 —— 不再「必须先出一次结果」才出现输入框
「采集窗口(小时)」「条数上限」两个输入框原先嵌在 v-if="l2Rows.length" 的
容器里,于是想「先设 72 小时再采集」做不到,只能先按默认值点一次再改。

参数与结果没有生命周期关系,拆出容器常显;结果区仍由 l2Rows.length 控制。
2026-10-27 08:04:26 +08:00
jkooe 419f6dca13 fix(agent): 看门狗日志按 1MB×4 轮换 —— 修常驻被控端的无界增长
startup-guard.log 一直是 appendFileSync 无限追加。看门狗常驻被控端,
「坏版本 + supervisor 反复重启」时写得很快,磁盘写满会连带把 agent 的
审计写入一起拖死(看门狗自己就在同一台机器上)。

- core(零依赖纯函数):shouldRotate(严格大于阈值,NaN 不触发)+ rotationTargets;
- guard:超阈值先丢最旧 .N → 逐个后移 → 当前滚为 .1,滚动与写入均失败静默
  (日志问题绝不能反过来拦住被控端启动);
- 单测 +3:阈值边界(含恰好等于/Nan)、命名序列、真实滚存三轮(验证只留 3 份、
  当前日志重新开始、未超阈值只追加)。
2026-10-27 08:04:20 +08:00
jkooe 1a2d1c7825 fix(ci): 补挂安装包断言改为按架构校验 —— 修「每次必红」的漂移守卫
v2.1.0 的 Console Release 在 publish job 红:
`安装包数量不足(3 < 4),架构可能缺失`。

根因是**守卫与实现漂移**,不是产物缺失:build 矩阵是
x64 → msi+nsis(2 个)、arm64 → 仅 nsis(1 个,WiX 的 arm64 MSI 支持面窄,
刻意不出),合计恒为 3;而断言写死「两架构各一套(exe+msi)= 4 个」,
于是从 arm64 去掉 msi 那天起就**每次都红** —— 红门禁等于没有门禁。

改为按架构断言(x64 ≥2 / arm64 ≥1),缺失时精确报出是哪一架构;
矩阵再变也只需改一行期望值。

附带修一个只在 CI 爆的坑:`$dir)`(变量紧贴全角括号)在 set -u 下被 bash
当成变量名的一部分 → unbound variable,本地肉眼完全正常。变量引用统一改
${var} 形式,并用抽取工作流原文的方式在本地跑通正例/反例两个分支。
2026-10-27 00:36:09 +08:00
jkooe b6e58dbcc4 docs(changelog): v2.1.0 定稿 + 修正历史改写后的悬空 hash 引用
- `## [未发布]` 中已发布的 v2.1.0 段改为 `## [2.1.0] - 2026-10-26`;
  新开 [未发布] 记录发版脚本修复与依赖漏洞清零两条。
- 历史改写(脱敏)后旧提交 hash 不再可达,文档里的引用会变成死链:
  CHANGELOG `efe4744` → `a75c5c9`;ROADMAP `efe4744`/`b09ca68`/`e864486`/`d106214`
  → `a75c5c9`/`5a2c5f9`/`68ea3cd`/`560b3c4`。全仓已无旧 hash 残留。
2026-10-27 00:27:17 +08:00
jkooe 5bacade667 chore(deps): 依赖漏洞清零 —— zod/body-parser 覆盖 + vite 升 8.3.5
Quality Gate 的「依赖漏洞扫描(SCA)」在 451d44a 上红:`pnpm audit
--audit-level=high` 报 3 个 high,全部不是本仓直接可控的代码:

- zod <4.6.6(GHSA-mrhx-c7xc-m3w5),路径 apps/mcp → @modelcontextprotocol/sdk;
- body-parser <2.4.0(GHSA-27r3-jxmh-p828),同上经 sdk→express;
- vite <8.3.3(GHSA-587h-7wmj-h5pm),控制台 devDependency。

处置:前两者用根 pnpm-workspace.yaml 的 overrides 抬到修复版(同 major patch 级);
vite 直接改依赖范围 ^8.3.5。实测 14 → 6 条(**0 high**),门禁转绿。

注意(真坑):pnpm 12 已不读 package.json 的 `pnpm` 字段,overrides 写在那里会被
**静默忽略**(install 报 "Already up to date" 但审计毫无变化)—— 必须写进
pnpm-workspace.yaml,理由与命令均落在文件注释里。
2026-10-27 00:26:55 +08:00
jkooe 451d44af72 fix(release): 版本号已钉在树中时不再卡在「nothing to commit」
发版脚本无条件执行 `git commit`。v2.1.0 的流程是先把版本号作为
`chore(release)` 提交进树、之后才发版 —— 此时 `git add -A` 后暂存区为空,
`git commit` 以 "nothing to commit" 非零退出,发版停在最后一步,且报错来自
git 而非发布逻辑,容易误判为工作区问题。

改为:暂存区为空则跳过 commit,标签与 Release 照常创建。
向后兼容 —— 版本号需要变更时行为完全不变。
2026-10-27 00:00:26 +08:00
jkooe 2d5be044ee chore(privacy): 交付流水线文档去掉其他项目名
`docs/DELIVERY-PIPELINE.md:10` 原以三个真实项目名举例说明「原方案面向 Web/容器
服务」,属审计基线的树内 PII 之一(存量,非本批次引入)。公开仓库不宜暴露项目
组合,改为中性表述「同类多项目实践」,论证语义不变。

注:该处 PII 已存在于既有历史 blob(2026-10-17 起),彻底清除需重写整段历史,
属独立决策,不在本批次范围。
2026-10-26 21:11:43 +08:00
jkooe 9baf3b0443 fix(cli): rollback 指纹校验容忍 12/64 位两种口径
QA 独立验证路由的 P0 缺陷(tests/unit/qa-v210-supplement.test.mjs C⑦ 红):

- 写后校验(rollback.ts:462):back.hash 是 64 位流式 sha256,而老 LKG 记录的
  expectHash 只有 12 位(无 hash_full)→ 严格 === 必不成立 → 老记录回滚永远
  被判「写后校验失败」并用回滚源还原,向后兼容路径实际不可用。
- 重启复核(rollback.ts:519):新 LKG 记录带 hash_full(64 位)时,p.buildHash
  是 12 位 → 严格 === 必不成立 → 回滚其实成功却被误报「90s 内未复核到回滚版本」
  并以退出码 1 结束(--backup 逃生门同样命中此缺陷)。

修法:新增纯函数 hashMatchesEither(短的一方是长的一方前缀即命中),仅用于
「校验/复核」方向;挑回滚源仍用单向 hashMatches —— hash_full 在手时不许前缀
糊弄(C⑥ 钉死),两条语义各有其守卫。

README 单测数 476 → 477(新增 hashMatchesEither 用例,docs-numbers 守卫强制耦合)。
2026-10-26 21:00:26 +08:00
jkooe dadd861fdd test: QA 补充用例(代理重定向/NO_PROXY 组合、看门狗边界与恢复日志、rollback 双匹配路径、README 数字精度)
- A:httpGet 重定向链经代理逐跳绝对形态+共享预算;maxRedirects 上限边界;
  NO_PROXY 命中直连/端口不匹配不误短路/miss+死代理大声报错(agent-proxy 已有
  单跳经代理与直连重定向预算,组合形态空缺)
- B:看门狗宽限期精确边界(livedMs === STARTUP_GRACE_MS 按运行期不计数);
  无指纹匹配时恢复日志须带回候选指纹且 install-dir 不动
- C:pickBackupByHash 在 hash_full 在场时不许仅凭 12 位前缀命中;
  编排层老 LKG(无 hash_full)端到端前缀回滚 —— 后者当前为红,是路由给
  工程师的 P0 缺陷(rollback.ts:462 全量 64 位指纹与 12 位 targetHash 严格
  比较,详见缺陷单),修复后自绿
- D:README e2e 拆分口径(34 通用 + 16 Windows 专属按文件分别核对);
  命令速查必须含 nodeagent rollback [--dry-run]
- README 单测数 467 → 476(docs-numbers 守卫强制耦合,非手滑)
2026-10-26 01:22:55 +08:00
jkooe a7827ba986 docs: 增量 PRD / ARCH 与设计图入库(v2.1.0 全面完善)
本轮「全面完善」的四份设计产出此前后在 git 外(untracked)——
阶段 3 实现所依据的需求/架构/类图/时序图随收口一并入库,
使「代码 ← 设计」的引用链在仓库内闭环(代码注释已按 ARCH 任务号引用)。
2026-10-26 01:01:32 +08:00
jkooe 0074d09f08 docs: v2.1.0 收口回填(README/CHANGELOG/DEVELOPMENT/ROADMAP + 数字守卫)
- README:数字刷新为实测(52 能力 · 42 MCP · 467 单测 · 50 e2e · 23 Rust);
  补 na_audit_l2 工具表项、看门狗三端接入段;删「控制台安装包只作为
  CI artifact」过期行(9f95560 起已随 Release 发布)。
- CHANGELOG:[未发布] 定稿 v2.1.0 全部主题(rollback / 看门狗 /
  na-gui 清理 / e2e 与文档收口),与 A 线已回填段共存无重复。
- DEVELOPMENT:新增 §7.5 selfupdate 代理出网机制(NA_PROXY→环境变量→
  系统代理、PAC/socks5 明确报错、回环恒直连、计划任务环境变量告诫);
  §7.4 补 rollback/看门狗双通道分工表与「为什么运行期不做自动回滚」
  三问引述。
- ROADMAP:新增第十节 —— v2.1.0 已落地项划掉、P2 登记(控制台自更新
  v2.2.0 / Linux auditd / macOS ESF / macOS 安装器 / guard 日志轮换)。
- tests/unit/docs-numbers.test.mjs(新,6 例):README 五个数字与可
  复现实测口径钉死(manifest 条目 / defs.ts 工具 / node --test 实测 /
  e2e 源码 / Rust 属性),「增能力不改 README」即红。单测数实测经
  NODE_TEST_CONTEXT 剥离 + NA_DOCS_NUMBERS_CHILD 递归防护;注入故障
  (52→53)实测 2 处守卫变红、恢复全绿。
2026-10-26 01:01:19 +08:00
jkooe b7f44f7a64 feat(scripts): na-gui 陈旧计划任务白名单清理(ARCH B-P1-6)
历史版本在被控端上注册过 na-gui 等旧名计划任务,漏删会留下「端口被占、
新版本起不来」的幽灵任务。误删用户自己的任务则是比漏删严重得多的
生产事故 —— 故只按白名单删。

- scripts/lib/scheduled-tasks.mjs(新):TASK_WHITELIST(nodeagent /
  na-gui / nodeagent-console,只增不改)+ planTaskCleanup 纯函数
  (查询结果注入,白名单外一律不删、精确匹配大小写敏感、重复名去重)。
- scripts/uninstall.ps1:卸载末尾按白名单逐个注销(-ccontains 存在性
  检查 + 动作进日志)。
- scripts/control.ps1:新增 cleanup 子命令(ValidateSet + Clear-StaleTasks),
  Uninstall-Agent 复用同一白名单;control.cmd 菜单加第 8 项
  (CRLF 安全插入,纯 ASCII 不变)。
- tests/unit/na-gui-cleanup.test.mjs(新,10 例):白名单命中才删 /
  白名单外零动作 / 空列表 / 重复名 / 脏输入 + ps1 源码守卫与跨端
  一致性守卫(白名单 mjs 与 ps1 漂移即红 —— 注入故障实测 2 处变红,
  恢复后全绿)。
2026-10-26 01:01:05 +08:00
jkooe 654d694ff6 test(e2e): windows.mjs 补 L2 status/collect/verify 三条(T05)
字段与 manifest system.audit.l2 的 returns_schema(四 action 属性并集)
逐字对齐:status 全字段断言(platform/supported/auditpol/
legacy_policy_disabled/cmdline_logging/sysmon_installed/file/file_exists);
collect 断 since/limit 回显、written<=collected、信任边界 note 必返;
verify 断 ok/checked。

collect 对非提权环境(读 Security 日志需管理员)skip 容错 —— 与 winget
缺失同一哲学,不把「环境没条件」伪装成「功能坏了」。

Mac 上不跑 windows.mjs:node --check + 静态自证(参数名/能力名/字段
与 manifest returns_schema 逐字对齐),不假跑。e2e 总数 33 → 50。
2026-10-26 01:00:50 +08:00
jkooe 6152a68b4a test(e2e): 补 L2 平台门控端到端用例(T04)
T04 把 audit-l2.ts 的平台门控从裸 throw 改成统一 assertWindows 后,
需要端到端证据:非 Windows 被控端上 system.audit.l2 必须明确失败
(result.status=failed + E_UNSUPPORTED_PLATFORM),不得静默走别的
分支、也不得报成误导性错误;Windows 上 status 可用(双平台各验一边)。

第一版按 assert.rejects 写,实测不会红 —— 能力执行失败走
result.status=failed(resolve),只有协议层错误才 reject。
已按 client 真实行为改为断言 failed + error.name(按 name 不按 code)。

e2e 34/34 全绿
2026-10-26 00:38:01 +08:00
jkooe fbd42e1c3b docs(changelog): [未发布] 回填本轮提交(出网修复 / P0 三修 / L2 三端暴露)
- selfupdate 代理出网(P0-1,db65bd1):裸 fetch 不读代理 → net/proxy.ts 移植;
  回环恒直连;错误分类移植;maxBytes 双闸
- LKG 内容指针(A-P0-3):hash_full + backup_glob,lkgHashMatches 不猜
- 架构审查三个 P0(e864486 / d106214):validate 原型链 / 限速桶 key /
  describeArgs 截断
- L2 三端暴露(b09ca68):MCP na_audit_l2 + 控制台 L2 分区 + agent.l2 类型

同时修正两处过期记述(代码已变、文字没跟上):
- 「刻意不做自动回滚」→ 写清两档边界:启动期自动恢复已由看门狗补上,
  运行期自动回滚仍刻意不做(与 B 线 docs/DELIVERY-PIPELINE.md 同款改法)
- L2 小节「CLI 已有入口」→ 补「v2.1.0 起 MCP 与控制台亦暴露」
2026-10-26 00:31:08 +08:00
jkooe 5a2c5f9c55 feat: 三端暴露 L2 系统级审计(MCP na_audit_l2 + 控制台 L2 分区 + agent.l2 类型)
决策「L2 同时暴露 MCP+控制台」。此前 system.audit.l2 只存在于
被控端实现里:MCP 无工具(模型看不到)、控制台无入口无类型
(用户点不到)、审计事件写成 agent.start(与进程生命周期混类筛不出)。

MCP(41 → 42 个工具):
- defs.ts 在 na_audit 后新增 na_audit_l2(action: status/enable/
  collect/verify + since/limit,additionalProperties: false)
- index.ts TOOL_TO_CAPABILITY 映射 CapabilityNames.AuditL2,
  分发走 default 直通(参数名与被控端一致,无需改写)
- mcp-tools 守卫同步:41→42、BASELINE_SEQ_SHA 重算、
  新增「na_audit_l2 必须紧随 na_audit」顺序断言

控制台:
- conn.ts WINDOWS_ONLY_CAPS += system.audit.l2(非 Windows 被控端
  上按钮禁用并明示原因,不搞「点了才报错」)
- types/index.ts 新增 AuditL2Result(四 action 属性并集,对齐
  manifest returns_schema)+ AuditType += agent.l2;
  AuditEntry 同步补 args_bytes/args_truncated(A-P0-4 新字段)
- AuditView.vue 新增 L2 卡片:查状态/拉取采集/校验 L2 链/启用策略
  (NPopconfirm 二次确认 + 管理员权限提示);信任边界如实说明
  (采集器与 agent 同权限,防篡改依赖转发出机,不夸大本地副本)

agent.l2 审计类型四端同源:
- protocol audit-types.ts AUDIT_TYPES(真源)+= agent.l2
- agent audit.ts AuditType 联合同步(AssertExact 编译期守卫兜底)
- audit-l2.ts 两处写入点 agent.start → agent.l2(L2 策略操作与
  进程生命周期无关,混类会污染两边统计与筛选)

守卫加强(console-contract 平台门控):
- audit-l2.ts 平台门控由裸 throw 改用统一 assertWindows
  (错误信息格式统一 + 可被守卫扫到)
- 守卫扫描范围 +audit-l2.ts、模式 +assertWindows —— 覆盖面扩大;
  否则把 system.audit.l2 加进 WINDOWS_ONLY_CAPS 反而报红
- mcp-tools 正则盲区修复:原 na_[a-z_]+ 匹配不了带数字的工具名,
  na_audit_l2 会整个漏扫(新增了工具两个守卫却无觉)→ 放至 [a-z0-9_]+

新增 tests/unit/console-l2.test.mjs 5 例:前端字段 ⊆ returns_schema /
契约关键字段 ⊇ 前端渲染 / MCP 工具存在且映射 / agent.l2 四端同源 /
界面纪律(信任边界文案 + enable 二次确认 + 平台门控)
故障注入实测:审计类型退回 agent.start、WINDOWS_ONLY_CAPS 删项 → 3 处红;恢复 → 绿

单测 451/451、e2e 33/33、typecheck 全绿
2026-10-26 00:28:44 +08:00
jkooe aad137f04a fix(startup-guard): 强制向被看守进程注入 NODEAGENT_HOME = --data-dir
全局一致性审查发现:Windows 计划任务的 New-ScheduledTaskAction 无法
携带环境变量,若安装进程持有进程级 NODEAGENT_HOME,agent 运行时的
agentDir()(LKG/状态文件所在地)会与守卫的 --data-dir 漂移 ——
守卫读不到 LKG/状态文件(安全退化为 no-lkg 放行,但看门狗白养)。

由 spawnEnv 在 spawn 时把 data-dir 显式注入被看守进程,三端行为与
supervisor 环境解耦且一致(Linux unit / macOS plist 的
EnvironmentVariables 本就是同值,双保险,幂等无害)。
新增 spawnEnv 单测;真机验证子进程可见注入值。
2026-10-26 00:17:15 +08:00
jkooe 560b3c44e4 fix(agent): 限速桶 key 改能力名 + describeArgs 截断(A-P0-2 / A-P0-4)
架构审查两个 P0:

A-P0-2 限速桶 key 用错值(server.ts:364):
原实现 `allowByRate(clientId, limit, String(limit))` 把**限速值**
当桶名——ACL 支持按能力限速(精确 > glob > 全局),配
「screen.capture ≤5/min、app.install ≤5/min」两条同值规则时
两者 key 都是 `clientId|5` 共用一个桶:截图打满后装软件被误伤,
且两者总量仍按 5/min(漏过)。同时 rateBuckets 只增不删 +
psk 模式 client_id 自报 → 外部可枚举任意 client_id 造键 →
长期运行内存无界增长。
- 抽到 util/rate-limit.ts:key = `${认证身份 ?? 远端地址}:${能力名}`
  (未认证时退化按远端地址,不用空串把所有人混为一桶)
- 过期键清扫(滑动窗口边界与旧实现逐字节一致:now - t < 60_000
  才算窗口内)+ 容量上限淘汰最旧(Map 插入序 = 时间序)
- e2e limited 客户端改配两个同限速值能力(各 2/min),
  打满 system.info 后 system.audit.list 必须仍有完整配额

A-P0-4 describeArgs 大参数同步序列化阻塞事件循环(audit.ts:172):
原实现每次 invoke 全量 JSON.stringify + sha256。fs.write 的 data
可达数 MB——同步序列化 + 哈希 ≈ 数十毫秒级 CPU 且阻塞事件循环,
期间 GUI 注入、事件推送、保活 ping 全部延迟。
- 超过 64KB 只哈希前 64KB(按**字节**切:多字节字符下按字符切
  会得到非确定性边界),总长记 args_bytes、截断记 args_truncated
- 口径从「全量哈希」变为「前 64KB 哈希」只影响审计摘要,
  不影响任何安全属性(摘要用途是比对与防泄密,不是完整性证明)

测试(均故障注入实测会红):
- rate-limit 6 例:桶名是能力名 / 身份进 key / 不限速不建桶 /
  过期键清扫(59.999s 窗口内、恰好 60s 出窗)/ 容量上限 /
  同值多能力各占一桶
- audit-args-digest 6 例:小参数口径不回退 / 大参数截断口径 /
  按字节切(字符切口径必须不同)/ 尾部不进摘要 / 端到端入链
  校验通过 / logArgs 默认关不产预览
- e2e 注入验证:桶名退回 String(rateLimit) → v3 场景红;恢复 → 绿

单测 445/445、e2e 33/33、typecheck 全绿
2026-10-26 00:14:17 +08:00
jkooe 68ea3cd20e fix(protocol): validate 拒检原型链键(A-P0-1,in → Object.hasOwn)
架构审查 A-P0-1:validate.ts:69 用 `key in schema.properties` 判
「附加属性是否允许」,而 `in` 沿原型链查找——JSON 里写
`"constructor"` / `"toString"` / `"__proto__"` 等键会命中
Object.prototype 成员被判为「schema 已声明」从而跳过拒绝,
fail-open(与 server.ts B-03 同源复发:那个修的是 ACL clients,
这个修的是 returns_schema 校验)。

- 改为 `Object.prototype.hasOwnProperty.call(schema.properties, key)`,
  只认真实声明的属性
- 补 2 个「注入即红」用例:原型键必须被判 additional property;
  真实 manifest 能力 system.agent.update 同样拒原型键、合法调用照过
- 故障注入实测:改回 `in` → 2 例红;恢复 → 全绿

安全属性收紧(原行为是漏放),无任何放宽。
2026-10-26 00:14:00 +08:00
jkooe cb468df7da feat(agent): 看门狗自动恢复 LKG —— 启动连续失败 N 次从 LKG 恢复(决策 R-1)
scripts/startup-guard.mjs + scripts/lib/startup-guard-core.mjs(新增):
- 被控端启动连续失败 N 次(默认 3)自动从 LKG 指纹匹配的备份恢复入口脚本
- 30s 启动宽限期 + 端口探活,避免计划内重启与 supervisor 重启撞车被误计数
- 无 LKG / 无指纹匹配只记日志放行,绝不擦除 install-dir
- 恢复成功或服务已就绪均归零失败计数;被控端启动成功后 resetStartupGuardState 归零
  (apps/agent/src/index.ts 为本任务对 agent 的唯一改动点)

三端 supervisor 接入:
- Windows install.ps1:计划任务包一层 guard,RestartCount 3→10(保证阈值可达)
- Linux install-linux.sh:systemd unit 的 ExecStart 包一层 guard
- macOS launchd:无安装器,docs/DEVELOPMENT.md §7.4 给出完整接法

docs:DELIVERY-PIPELINE §6 由「不做自动回滚」收窄为
「手动 rollback + 看门狗自动恢复 + 边界明示」,附录 C 同步;
DEVELOPMENT.md 新增 §7.4 三端 supervisor 接入说明。
2026-10-26 00:09:56 +08:00
jkooe 1249c90725 feat(agent): selfupdate 代理出网 + LKG 内容指针 + L2/AgentUpdate 契约补全
P0-1 被控端自更新境内必然失败(裸 fetch 不读代理):
- 新增 apps/agent/src/net/proxy.ts + index.ts:sidecar 已验证出网方案的
  TS 移植(NA_PROXY → 环境变量 → 系统代理;PAC/socks 明确报错不静默降级;
  重定向链共享超时预算;CONNECT 自管 abort;TLS 默认校验证书)。
  只依赖 node 内置模块,不 import 项目内任何模块(依赖严格单向)
- selfupdate.download 改走代理;成功/失败均带回 via/proxy/proxy_source/
  outbound_notes 诊断;错误分类同款移植 sidecar main.mjs:146-165
  (ECONNRESET 不得报成「请求超时」——真机教训)
- httpGet 增 maxBytes 双闸(content-length 预检 + 流式中断),
  保住「64MB 上限」安全属性
- 回环目标恒直连(CI/dev 机 HTTP_PROXY 不得误送自建镜像进代理——
  e2e 实踩:代理另一侧的 localhost 不是本机 → 404)

A-P0-3 LKG 只有指纹没有内容指针:
- LkgRecord 只增 hash_full(64 位全量)+ backup_glob(.bak*)两字段;
  新增 lkgHashMatches(全量优先、老记录回落 12 位前缀,不猜)
- selfupdate 备份保留最近 3 个(planBackupCleanup 纯函数,最旧先删)

P1-4 system.audit.l2 returns_schema 空壳 → 四 action 属性并集补全;
AgentUpdate returns 增 via/proxy/proxy_source/outbound_notes 四个只增字段

安全属性逐条不变:sha256 强制校验 / 先校验后落盘 / 写后校验失败回滚 /
TLS 默认校验证书 / 只接受 http(s)

测试:新增 agent-proxy 17 例 + selfupdate-backup-cleanup 8 例 +
lkg 新增 4 例;单测 403/403、e2e 33/33、typecheck 全绿
2026-10-25 23:58:30 +08:00
jkooe 6b2fa29710 feat(cli): nodeagent rollback —— 从 LKG 回滚被控端(ARCH T03 / PRD P0-2)
- 新增 apps/cli/src/cmd/rollback.ts:读 LKG → 指纹比对定位备份 →
  覆盖写入口(同 deploy 姿势)→ 写后校验(失败用回滚源还原)→
  受控重启 → probeOnce 复核 build.hash == LKG(90s 墙钟)
- 纯函数 planRollback/pickBackupByHash/hashMatches/backupNameRegex +
  流式 hashRemoteFile(client 注入,可假测);hash_full 全量优先、
  老记录回落 12 位前缀(A-P0-3/R-2 向后兼容)
- --dry-run 只读零写;--backup 逃生门直指备份路径;退出码 0/1/2 分明
- HELP/README 写明能力圈边界:RPC 命令需被控端可达,「完全起不来」
  走看门狗自动恢复或带外恢复;不提供自动回滚(DELIVERY-PIPELINE §6)
- 19 条单测:无 LKG 零调用 / already-lkg 零写 / 写后校验失败自动还原 /
  无匹配零写 / dry-run 只读 / 多候选 mtime 新→早 / Windows 路径与元字符
2026-10-25 23:45:02 +08:00
jkooe a75c5c9bc5 chore(release): 版本统一至 v2.1.0 + CHANGELOG 回填未发布提交
- 经 scripts/lib/version-write.mjs 写入全部 11 处(8 package.json +
  tauri.conf.json + Cargo.toml/lock),无散点遗漏、无 skipped
- CHANGELOG [未发布] 段补入 v2.0.3..HEAD 全部提交主题:
  L2 系统级审计(7d781d1)、交付流水线(2bf45fd)、Linux 适配(00992e5)、
  质量门禁与 CI(141f479 等 4 个)、console-release(9f95560);
  并修正「安装包尚未接入 Release」的过期记述
- version-write 单测 12/12 绿(含仓库版本号一致性守卫)
2026-10-25 23:37:52 +08:00
jkooe 3680218010 fix(ci): typecheck 前改为全量构建 —— 只建 protocol 不够
## 上一版没修透
先只加 `pnpm --filter @nodeagent/protocol build`,CI 换了个错法:
`apps/cli` 报 `Cannot find module '@nodeagent/client'`。

依赖链是 **`protocol → client → cli / mcp / agent`**,逐层都要 dist/*.d.ts。
逐个补依赖是打地鼠,下次加包还会漏 —> 直接用 `pnpm -r build` 把整条链建出来。

## 顺带修掉一个顺序矛盾
原工作流是 `typecheck` 在前、`pnpm -r build` 在后,而后者注释写着
「单测 import 的是 dist/,必须先构建」。既然后面无论如何都要 build,
不如提前一次到位 —— 现在 build 在 typecheck 之前,只保留一份。

**注入验证(模拟 CI 全新检出)**:把 `packages/*/dist`、`apps/*/dist` 全部移走 →
`pnpm -r build` → `pnpm typecheck` **零错误**;`pnpm test:unit` **357 项全绿**。
2026-10-17 23:24:36 +08:00
jkooe 141f479e4a fix(ci): 让质量门禁真正生效 —— 修两处「一直红所以等于没有」,并清零高危漏洞
## 发现
今天 8 次质量门禁运行**全部失败**(从今天第一个 commit 起就没绿过)。
一道长期红的门禁等于没有门禁 —— 没人会看它,也就保护不了任何东西。
两处独立原因:

### ① SCA:audit 打到了镜像
仓库 `.npmrc` 指向 `registry.npmmirror.com`(加速用),但**镜像不实现 audit 接口**,
`pnpm audit` 必报 `ERR_PNPM_AUDIT_ENDPOINT_NOT_EXISTS`。
→ 显式加 `--registry=https://registry.npmjs.org`。

### ② typecheck:缺 build 顺序
各包 import 的是 `@nodeagent/protocol` 的**类型产物**(dist/*.d.ts)。
CI 全新检出没有 dist,直接 typecheck 集体报
`TS2307: Cannot find module '@nodeagent/protocol'` —— 而本地因为 dist 还在,看不出来
(典型「本地绿 / CI 红」)。
→ 在 typecheck 前插入 `pnpm --filter @nodeagent/protocol build`(只建这一个包,够用且快)。

**注入验证**:把 `packages/protocol/dist` 移走 → 复现 TS2307 → 执行新增的 build 步骤 →
typecheck 零错误。(先证明诊断对,再证明修复有效。)

## audit 通了以后暴露 2 个 high(之前被「假绿」掩盖)
1. **node-forge <=1.4.0**(路径 `apps/agent > selfsigned > node-forge`)
   上游**无修复版本**(patched_versions: None)→ 只能移除依赖。
   `selfsigned` 5.x 已整体改用 `@peculiar/x509` + pkijs,node-forge 被彻底删掉。
   - 升级带来两处不兼容:`generate` 改为**异步**、`days` 改为 `notAfterDate`
   - `ensureCert()` 随之改 async(唯一调用点在 async 的 `main()` 里)
2. **@modelcontextprotocol/sdk <1.31.0**(OAuth 凭据可能被发往 MCP 服务器指定的授权服务器)
   → 升到 `^1.32.1`

## 证书不能只看编译通过(权威本上一条真机教训)
用临时数据目录起真被控端实测:
- 证书 `notBefore=2026-10-17 / notAfter=2036-10-14`(10 年,换算正确)
- key.pem 权限仍为 600
- 客户端握手成功:**52 项能力**,验收 **13 通过 / 0 失败**
- 审计:`No known vulnerabilities found`

验证:`pnpm typecheck` 通过;`pnpm test:unit` **357 项全绿**;
`pnpm install --frozen-lockfile` 通过(lock 与 manifest 一致)。
2026-10-17 23:21:51 +08:00
jkooe 9f95560268 build(ci): 控制台安装包接入 Release —— 修「修复发了但没人拿得到」
## 问题
`scripts/release.mjs` 只产出 agent / cli / mcp,`apps/console` 从来没接进去。
后果很具体:今天修的 6 个真机缺陷(转圈、状态卡死、退出确认、复用、断管、ws/wss)
**只存在于 CI artifact**,而 artifact retention 是 7 天。
用户从 Release 页面下载到的,永远是旧控制台。

## 做法
1. `console-windows.yml` 加 `workflow_call:`,并新增 `publish` job:
   - `if: startsWith(github.ref, 'refs/tags/v')` —— 只有 tag 才发布,日常分支构建跳过
   - 下载两架构产物 → 断言安装包数 ≥ 4(exe+msi × x64/arm64),少了直接报红,
     **绝不静默少发一个架构**
   - `gh release upload --clobber` 挂到 `release.mjs` 建好的那个 Release
   - release.mjs 在本地跑、与本工作流并行,故先轮询等 Release 出现(最多 10 分钟);
     等不到就给 `::warning::` 并跳过,不阻塞流水线
2. 新增 `console-release.yml`:只认 `push: tags: ['v*']`,`uses:` 调用上面那个工作流

## 为什么不直接把 tags 写进 console-windows.yml
它带 `paths` 过滤。`paths` 与 `tags` 并存时,tag 推送会被路径判定挡掉而**静默不触发** ——
失败形态是「什么都没发生」,最难察觉。拆成「日常验证走分支 + 发布只认 tag」最稳。

验证:PyYAML 解析通过;triggers / jobs 结构正确;shell 转义已核对。
(tag 发布路径需真打一次 tag 才能端到端验证,见下文待办)
2026-10-17 23:12:44 +08:00
jkooe 240c52c44f fix(console): 「连接本机」必失败 —— 地址硬编码 ws:// 无视 tls
## 皇上实测
点「连接本机」→ 界面显示**连接失败**。

## 根因(协议层,不是鉴权)
`agent.rs` 里拼监听地址时**硬编码** `ws://127.0.0.1:{port}`,
完全没看同一个函数里刚解析出来的 `tls` 字段:

```rust
"url": format!("ws://127.0.0.1:{port}"),   // ← tls = true 时这是错的
```

而被控端 `agent.json` 默认 `tls: true`(只接受 TLS)。于是控制台用
**明文 WebSocket** 去连一个**只监听 TLS** 的端口:服务端在等 TLS ClientHello,
收到的却是 HTTP GET,握手当场失败。

**鉴别特征**:被控端审计里连一条记录都没有(根本没进到鉴权层),
所以「密钥对不对」根本轮不到 —— 这类故障看审计有无 `auth.success` 即可一刀切开:
- 有记录 → 问题在鉴权/ACL
- 无记录 → 问题在协议/网络层(本次就是)

## 修法
- `url` 改为按 tls 选择:`if tls { "wss" } else { "ws" }`
- 顺带修两处同类默认值(同一个坑):
  - `stores/conn.ts` 兜底地址 → `wss://`
  - `NodesView` 远程节点输入默认值与占位 → `wss://`

## 守卫
新增单测 `连接地址应跟随tls开关而非硬编码ws`:分别写 tls=true/false 的
agent.json,断言得到 `wss://` 与 `ws://`。防止将来又被"简化"回硬编码。

验证:`cargo test --lib` **22 项全绿**;`vue-tsc --noEmit` 通过。
2026-10-17 22:15:17 +08:00
jkooe 0782d901a3 fix(console): 消除两处同步阻塞调用 —— 修「按钮一直转圈、PID 不再更新」
## 真机现象
被控端明明在跑,界面上「启动/停止/重启」三个按钮**同时**转圈不结束,
PID 停在旧值(显示 30160,实际已是 22948),点导航也不切页。
只能重开控制台。

## 根因一(臣上一版自己引入的)
`looks_like_agent()` 用 `std::process::Command::output()` 调 PowerShell:
**同步阻塞 + 无超时**。而这台机器上 PowerShell/CIM 本身就慢甚至偶发卡死
(实测 `Get-ScheduledTask` 2.0s、`Get-NetTCPConnection` 1.7s,
另有一次 CIM 查询直接 30s 超时)。

它在 `start()` 里被调用(复用判定),一旦卡住就**永久占住运行时线程** →
`run()` 的 finally 里的 `refresh()` 永远走不到 → `busy` 卡死、状态不再更新。
连带 sidecar 的请求也拿不到执行机会,表现为「重连中」但审计里没有任何握手记录。

改:`looks_like_agent` 改 async + `tokio::process` + **3 秒超时**。
超时/失败一律**判为「不是被控端」**(拒绝接管)—— 宁可让用户看到「端口被占用」
去手工排查,也不能误杀别人的进程。

## 根因二
`autostart::is_enabled()` 每次 `agent_status` 都会跑一次 `schtasks /Query`
(实测约 2 秒)。每刷新一次界面白等 2 秒,叠加后同样表现为转圈不结束。

改:加 5 秒 TTL 缓存(自启是低频状态),并在 `set()` 后 `invalidate_cache()`,
保证用户点完开关立刻看到新值而不是 5 秒前的旧值。

验证:`cargo build` / `cargo clippy` 干净;`cargo test --lib` **21 项全绿**。
2026-10-17 21:55:44 +08:00
jkooe 9af6e0f73a fix(agent): stdout 断管容错 —— 修「退出控制台后被控端假死」
## 真机现象(两次踩到,都能复现)
退出/重启控制台后,被控端**端口仍然 OPEN,但所有 RPC 全部超时**(连握手都不回),
持续数分钟不自愈,只能再重开一次控制台。外部看像是「进程活着,但哑了」。

## 根因
被控端由控制台 spawn 时,stdout/stderr 是**管道**而非终端。控制台一退出
(尤其是本就要支持「保留被控端」这个场景),管道**读端消失**,而 agent 仍在往死管道写日志:
- Node 对未监听 'error' 的 stream 会把 `EPIPE` **抛成未捕获异常** → 进程退出;
- 写端在无读者时还可能**阻塞**,把事件循环一起卡住 —— 端口保住了(OS 层 socket 还在),
  但 JS 再也不执行。这正是「端口 OPEN 却全员超时」的形态。

全仓此前**没有任何** stdout/stderr 错误处理,grep 可证。

## 做法
- 给 `process.stdout` / `process.stderr` 挂 'error' 监听,吞掉 EPIPE / DESTROYED;
- 一旦判定通道已断,**短路后续写**(包一层 console.log / console.error),
  不再往死管道里堆字节;
- 任何写异常都只置标志、不抛 —— 日志通道故障不该打死常驻服务。

日志不丢:审计走文件(audit.log),控制台在时照常收管道输出。
宁可少一行 stdout,也不能让常驻服务因为「没人看日志」而假死。

## 与「复用已运行实例」的关系
上一提交做了「下次启动复用孤儿被控端」,但如果孤儿本身已被断管卡死,
复用就只是把一个哑掉的进程认领回来。**这条修复才让复用真正有意义。**
2026-10-17 21:23:20 +08:00
jkooe 00659165b1 fix(console): 状态永远停在「启动中」—— refresh 被 catch 跳过
## 真机症状
被控端明明在跑(点「启动」后端答「**被控端已在运行**」、端口在听、验收 15/15 全通),
界面却一直显示「启动中」,「停止 / 重启」永久灰着 —— 用户完全无法操作。

## 根因(两条更新通道同时失效)
1. `run()` 把 `await refresh()` 写在 **try 里**:
   ```ts
   try { const r = await fn(); await refresh(); return r; }
   catch (e) { ... }        // ← 动作一抛错,refresh 就被跳过
   finally { busy = false }
   ```
   那一次 `start()` 抛了「已在运行」,于是 refresh 被跳过,界面停在旧值。
2. `na-agent-state` 推送在 **WebView 就绪前**发出,前端 `bind()` 的监听还没挂上 → 事件丢失。
3. `LocalServiceView` 的 `onMounted` 只调 `agent.bind()`,而 `bind()` 有 `bound`
   守卫 —— 第二次进页面直接 return,**不会刷新**。所以切页也救不回来。

后端状态其实是对的(`inner.state` 已是 running),纯前端未同步。

## 修法
- `run()`:`refresh()` 移入 **finally** —— 成败都同步状态。
  `refresh()` 自身已有 try/catch,不会掩盖原始错误。
- `LocalServiceView.onMounted`:显式再 `await agent.refresh()`,
  绕过 `bind()` 的 bound 守卫,每次进页面都拉一次(最便宜的状态自愈)。
- `snapshot()`:加状态自愈兜底 —— `STARTING` 且已有 PID 且超 2 秒,就按客观事实
  校正为 `RUNNING`(拿得到 PID 是进程已起的硬证据);`STOPPING` 且无 PID 则校正为 `STOPPED`。

## 自愈的三条单测(含反向守卫)
- 已起 + 超 2 秒 → 必须 `running`
- 刚起(<2 秒)→ **不得**提前自愈(否则真实启动失败会被伪装成运行中)
- 无 PID → 不得自愈(进程根本没起来,不能装成 running)

验证:`cargo build` / `cargo test --lib`(17 项全绿)/ `vue-tsc --noEmit` 均通过。
2026-10-17 21:09:13 +08:00
jkooe 704fb8cfc9 feat(console): 退出前确认是否保留被控端 + 下次启动复用已运行实例
## 问题(皇上实测)
1. 退出控制台后仍有一个被控端在跑 —— 因为 `kill_on_drop(false)`,
   agent 成了孤儿继续监听端口。用户以为关了窗口就干净了。
2. 下次启动时该孤儿占着 8765,`start()` 直接报「端口已被占用」,
   明明有个健康实例却用不上。

## 需求 1:退出前确认
托盘「退出」与「关 X(未开启最小化到托盘时)」两条路都会留下孤儿,
故两条都必须问,否则从 X 走就绕过了确认。

- Rust:`quit` 不再直接 `app.exit(0)`,改为 emit `na-quit-requested` + 唤出窗口
- Rust:新增 `quit_app(keep_agent)` 命令 —— true 直接退出(保留 agent),
  false 先 `stop()` 再退出
- 前端:新增 `QuitGuard.vue`(挂在 `n-dialog-provider` 内),三选一:
  保留被控端退出 / 停止被控端退出 / 关闭弹窗即取消

为什么不加 `tauri-plugin-dialog`:前端已有 `n-dialog-provider`,
不必新增依赖,视觉也与全应用一致。

## 需求 2:复用已运行实例(adopt)
`start()` 遇到端口占用时不再一刀切报错,而是先分清占用者:

- 占用者**确实是** nodeagent 被控端(`looks_like_agent`:命令行含 `agent.mjs`)
  → **复用**:记 PID、置 RUNNING、`external=true`,返回 `adopted:true`
- 否则(用户自己的服务 / 别的应用)→ 仍报「端口已被占用,且占用者不是被控端」
  —— 绝不误接管别人的进程

配套:
- `Inner` 增 `external` 标记;`snapshot()` 暴露给前端
- `stop()` 支持外部实例(无 Child 句柄,走 `stop_pid()` → taskkill /T /F),
  并**等待端口真正释放**再返回,否则 restart 会误判「还活着」又复用同一个 PID
- `restart()` 无需改动:stop 收掉外部实例 + 600ms + start 自己 spawn
  → 天然把控制权收回本控制台
- 前端:状态栏加「已复用外部实例」标签

## 顺带修复(同一类问题)
上一提交把「检查更新」从全局 `busy` 拆出后,发现 `applyUpdate` 也占全局 `busy`
(下载 RPC 超时 330s),一并迁到 `updateBusy`。

验证:`cargo build` / `cargo clippy` 干净;`vue-tsc --noEmit` 通过。
2026-10-17 20:18:32 +08:00
jkooe 618ebc986f fix(console): 更新检查不再锁死整页按钮(全局 busy 串台)
真机现象:点「检查更新」后,启动/停止/重启等**所有**按钮一起转圈,
持续两分半,看起来像「点什么都卡死」。

## 根因(两个缺陷叠加)
1. **`busy` 是全页共享的**:`LocalServiceView` 里每个按钮都是 `:loading="busy"`。
2. **`check_update` 是网络操作且超时很长**:出网 120s / RPC 150s。
   而这两个值**不能缩短** —— `sidecar.rs` 的注释与编译期不变式记录了真机实测:
   跟随两次重定向的耗时出现过 2.0 / 10.0 / 39.7 / 68.6 / >109s,
   回落到 60s 会让 68.6s 那次变成「假失败」。
   故超时保持不动,**只修隔离**。

于是任何一次「检查更新」都会把全页按钮锁死 —— 更新检查本是
**只读、可等待、与启停无关**的操作,不该拖累其他按钮。

## 修法
- 新增 `updateBusy`,只服务「检查更新 / 更新被控端」
- 全局 `busy` 只留给毫秒级的启停与设置
- `applyUpdate` 同步迁移(下载 RPC 超时 330s,占用全局 busy 更糟)
- 视图里检查更新按钮与更新按钮改绑 `updateBusy`

## 刻意不改的
- `UPDATE_CHECK_TIMEOUT_MS` 保持 120s、RPC 150s —— 见上述实测,缩短会退回「假失败」
- `busy` 仍保留全局语义(启停确实应互斥)

验证:`vue-tsc --noEmit` 通过;`pnpm --filter nodeagent-console build` 通过。
2026-10-17 19:54:04 +08:00
jkooe c7d596e087 fix(ci): quality-gate 补 pnpm 版本(首次运行 10 秒即失败)
首次推送后 Quality Gate 立即失败,两个 job 都在 pnpm/action-setup 步骤报:
  Error: No pnpm version is specified.

根因:本仓库其他三个工作流(console-windows / rust / windows-e2e)
都显式写了 `with: version: 12`,臣新加的这份漏了 —— 而 package.json
里没有 `packageManager` 字段,action 无从推断版本。

修:两处 pnpm/action-setup 均补 `version: 12`,与既有工作流保持一致。

教训:新增 CI 工作流不能只照抄步骤名,**版本/参数要与既有工作流对齐**;
否则门禁自己先挂,比没有门禁更误导(看起来"有 CI",其实从未真正跑过检查)。
2026-10-17 19:17:39 +08:00
jkooe 630ea7dd7a chore(security): 精确忽略 4 处测试占位密钥误报
全局 pre-push 钩子的 `gitleaks git` 全历史扫描报 4 处命中,全部位于测试文件:
  tests/e2e/run.mjs        sec-e2e-key-0123456789abcdef0123456789
                           fs-key-0123456789abcdef0123456789abcdef
                           e2e-test-key-0123456789abcdef0123456789abcdef
  tests/unit/hmac.test.mjs test-key-0123456789abcdef

逐一核对:均为明显的递增占位值(含 0123456789abcdef 模式),
**均不等于被控端真实 PSK,均不含 64 位十六进制串** → 判定误报。

## 为什么用 .gitleaksignore 而非 .gitleaks.toml
- 顶层 `[allowlist].paths = ['tests/**']` 会对**所有规则**放行,
  于是 private-key / aws-access-token 等在测试目录下也失效 ——
  而「为造样本把真实私钥贴进 fixture」恰是最常见的泄露方式。
- `[[rules]]` 重定义 generic-api-key 会**替换**内置规则(gitleaks 要求重写 regex),
  等于自己维护一份正则,更容易漏。
- 指纹格式 `<commit>:<file>:<rule>:<line>` 只忽略这一条,其余照旧。

## 验证(不是「让 CI 变绿」)
- 加配置后全历史重扫:`no leaks found`
- 故障注入:在 tests 目录新造一条**未列入忽略**的密钥
  (`apiKey = 'sk-live-…'`)→ **仍被检出**(leaks found: 1)
  → 证明忽略是精确的,没有按路径削弱规则
2026-10-17 19:03:56 +08:00
jkooe 7d781d132e feat(audit): L2 系统级审计兜底 —— 三端设计 + Windows 落地
## 它补的是 L1 结构上记不到的那块
L1 是 agent 自己记的,只看得见经过自己 RPC 的调用。攻击者绕过 agent
直接跑系统命令、或干脆把 agent 停掉 —— L1 全部盲区。L2 采集操作系统
自己的审计事件,补的正是这块。设计见 docs/AUDIT-L2.md。

## 必须先说清的信任边界(不夸大作用)
采集器与 agent 同进程同权限 —— 攻击者能停 agent 就能停采集器。
故本地 audit-l2.log 只是一份**归一化副本**,防篡改价值有限。
真正的独立性来自**把记录转发出机**(与 SECURITY.md §4.8 锚定同一思路)。
本实现不自动转发,只提供导出接口;这一点已写入文档与能力描述。

## 三端
- Windows:**落地**。选内置 auditpol 而非 Sysmon —— Sysmon 是第三方驱动,
  需单独分发、需重启生效、版本行为有差异;内置策略零依赖即可立起来。
  检测到装了 Sysmon 则并用,未装不尝试下载。
  - status:查策略是否启用,并提示两个关键开关
    * SCENoApplyLegacyAuditPolicy —— 不设它,组策略旧式类别策略会盖掉
      subcategory,表现为「auditpol 显示已启用但事件日志里没有 4688」
    * ProcessCreationIncludeCmdLine_Enabled —— 不开则 4688 只有进程名
  - enable:开启进程创建 4688 / 登录 4624,4625 / 进程终止 4689(需管理员)
  - collect:Get-WinEvent 拉取 → 归一化 → 独立落盘(带哈希链)
  - verify:L2 链完整性校验
- Linux:仅设计(auditd,需预装),未落地
- macOS:仅设计,ESF 需 Apple entitlement 且必须是签名 System Extension,
  个人自签无法启用 —— 不写假装能用的代码

## 落盘与 schema
独立文件 audit-l2.log,不与 L1 混 —— 混了就无法区分「agent 记的」与
「OS 记的」。记录含 ts(OS 事件时间)、src、event_id(保留原始便于核对)、
kind(归一化类别)、collected_at(采集时刻,与 ts 差过大说明曾离线)。

## 实测
- 新增 8 条单测,含两条故障注入:篡改内容检出「哈希不符」、
  删中间条目检出「链断裂」
- 既有契约守卫两次报红并已被修复:manifest 新增 lkg 字段 → SystemInfo 接口缺声明;
  新增 system.audit.l2 → 能力清单断言过期(51→52)
- 全量单测 349 → 357 全绿
2026-10-17 03:24:17 +08:00
jkooe 2bf45fdf98 feat(pipeline): 交付流水线规范落地 —— 门禁 + LKG 回滚锚点
将《代码审计 → 上线 完整交付方案》v1.0 适配到 nodeagent,产出
docs/DELIVERY-PIPELINE.md,并落地其中可执行的部分。

## 为什么要适配而不是照搬
原方案面向 Web/容器服务(有服务端、有镜像、有多环境)。nodeagent 是
跑在用户机器上的跨机接管框架,无服务端、无容器、无多环境。照搬会产生
「跑得很好但守不住任何东西」的假门禁。故每个阶段给三档判定:
适用 / 改造后适用 / 不适用(但必须给替代,不允许只写 N/A)。

关键替换:
- 容器镜像扫描 → 交付物 PE 架构断言(arm64 包误装 x64 node.exe 的真实事故)
- DAST → 协议健壮性测试(本项目对外输入面是 WebSocket RPC,非 HTTP)
- dev/staging/prod → 真机矩阵(Win x64/arm64、macOS、Linux)
- 金丝雀 → 设备分批自更新(先一台试点,latest.json 灰度)
- Prometheus → system.metrics(闭环率/P95/拦截率)+ 发布后复核清单

## 本次落地
1. 覆盖率门禁 scripts/ci/check-coverage.mjs
   - 全量**只观测不阻断**(实测基线 66.9%,照搬 80% 全量不现实)
   - 变更文件行覆盖率 ≥70% 阻断;单测 import 的是 dist/,需 src→dist 映射
   - 无覆盖数据的变更文件不阻断,单列供人工判断(算 0% 会让重构无法合入)
2. 提交信息校验 scripts/ci/check-commit-msg.mjs
   - 项目实际已在用 Conventional Commits,但无强制 → 变成会红的门禁
   - 不引 commitlint+husky:本项目只需类型白名单,几十行即可且 CI 本地都能跑
3. .github/workflows/quality-gate.yml:gitleaks + pnpm audit + typecheck + 覆盖率 + 提交规范
4. CI artifact retention-days 30 → 7(公开可下载,留存越久暴露面越大)
5. LKG(最后已知可用版本)apps/agent/src/lkg.ts
   - 只在**启动成功后**才写,不在更新写完时写(否则等于把未验证版本当已知可用)
   - 暴露为 system.info.lkg,matches=false 表示当前版本尚未被证明能启动
   - 控制端据此暂缓向其余设备放量

## 刻意不做的事:自动回滚
远程设备上无法区分「更新坏了」与「网络抖动」;自动回滚可能形成回滚循环;
被控端自作主张改自己会让控制端失去掌控。故 LKG 只提供确定的回滚目标,
决策权留给控制端。已在文档中写明取舍理由。

## 实测证据
- 覆盖率门禁三条故障注入均 exit=1(阈值100 / 全量99 / lcov缺失),正常 exit=0
- 提交规范反例 3 类均红,Merge 与无 scope 正确豁免,历史 20 条提交一次通过
- LKG 新增 8 条单测(含篡改 hash 必须检出 matches=false)
- 既有契约测试在新增 lkg 字段后立刻报红(SystemInfo 缺声明),补接口后转绿
  —— 这本身是门禁有效的实证
- 全量单测 341 → 349 全绿
2026-10-17 03:13:18 +08:00
jkooe 590f62e968 docs(changelog): 回填「检查更新」修复的真机复验与守卫口径
CHANGELOG 与 docs/console-plan.md 的该节写于「15 用例 / 248KB」时点(随后被隐私脱敏
提交一并带走),已落后于代码,且当时「经代理成功」那一组受宿主线路拥塞未取到。
本次回填三处:

- 用例数 15 → **16**,补第 15 条守卫(错误映射:连接被中途重置不得报成「请求超时」)
  及其注入验证行。
- 补记**第三个缺陷**:ECONNRESET 被误归 abort 类 → 真机耗时 82.9s 却报「请求超时
  (120000ms 内未完成)」,把「对端掐断连接」这条最有价值的信息抹掉。
- 产物 248KB → **249KB**;真机复验表补 D/E 两组(Win11 ARM64 VM,用随包 node.exe 直跑
  dist/sidecar.mjs,两侧 sha256 一致):
  · D 对照(不配系统代理、不给任何环境变量)→ 30877ms 失败,但报真实原因 socket hang up,
    data 带 via=直连 / timeout_ms / 可操作提示;
  · E 实验(只设 WinINET 系统代理、仍不给任何环境变量)→ **成功 28883ms**,
    via_proxy=system、日志「(来源 system)」、退出码 0。
  实验组走通的正是本次新增的系统代理发现;其 28.9s 耗时同时反证旧 20s 内部超时必然失败。
  复验后 VM 的 ProxyEnable/ProxyServer 已原样还原。

无代码改动。
2026-10-13 22:47:36 +08:00
jkooe b34e8c73e0 fix(console): 让「检查更新」在境内真的能用 —— 自持出网 + 系统代理发现
真机症状:控制台点「检查更新」必报 `检查更新失败:fetch failed`。
上一轮曾把此项归因为「环境网络问题」而收工;本轮按「即使环境本身不通,
代码也不该让用户无从自救」重新定位,确认为**产品缺陷**。

三条根因(均经隔离实测)
- Node 内建 fetch(undici)默认不读任何代理环境变量。`NODE_USE_ENV_PROXY=1` 可开,
  未开时 env 被**完全忽略** —— `env -i` 对照实验:带 flag 得 `fetch failed`(证明 flag 生效),
  不带得 `timeout`(证明 env 被忽略)。
- 系统代理(WinINET / macOS 网络设置)Node 亦不读;而 `fetch(url,{dispatcher})` 走不通 ——
  dispatcher 必须是 undici 的 Dispatcher 实例,Node 未把 undici 暴露为可导入模块,
  整包 vendored 会撑胖 sidecar 且其依赖 .wasm 解析器,esbuild 单文件打包有风险。
- 内部超时 20s 对真实重定向链必然失败:`latest.json` → 版本化 URL → CDN,
  实测耗时 2.0 / 10.0 / 39.7 / 68.6 / >109 秒。

修法
- 新增 `apps/console/sidecar/proxy.mjs`(自持出网,零新依赖):https 目标走 `CONNECT host:443`
  隧道后叠 TLS(隧道返回的 socket 已是 TLS,须用 `http.request` 承载,用 `https.request` 会二次握手;
  且**不可把 URL 对象当第一参**,否则会覆盖手搭的代理字段退化成直连);http 目标走绝对形态请求行;
  含 NO_PROXY 匹配、系统代理发现(`reg query` / `scutil --proxy`,PAC 不可解析时记 note 而非静默)、
  socks5 等看不懂的形态一律记 note。
- `check_update` / `download` 全部改走 `httpGet`,整条重定向链共享一个超时预算与 AbortController。
- 内外超时成对下发:`sidecar.rs` 新增 `UPDATE_CHECK_TIMEOUT_MS`/`_RPC_TIMEOUT_SECS` 与
  `UPDATE_DOWNLOAD_TIMEOUT_MS`/`_RPC_TIMEOUT_SECS` 两组常量 + **编译期不变式**
  (`const _: () => assert!(...)`),保证「出网超时 < RPC 超时」——否则用户只会看到
  丢掉真实原因的「check_update 超时」假失败。订正 6 处因超时改动而变陈旧的注释。
- 失败信息带 `url` + `via`/`proxy`/`proxy_source`/`timeout_ms`/`hint`;界面新增「出网」行,
  并修掉重复前缀(`检查更新失败:检查更新失败:…`)。

顺带修掉两个真机暴露的缺陷
- **隧道/TLS 阶段的 abort 无人受理**:`createConnection` 回调返回前外层请求尚未建立,
  外层 `signal`/`timeout` 无人受理 → 真机实测配 75s 超时却挂了 109s。
- **`ECONNRESET` 被归入 abort 类** → 报成「请求超时」(真机:耗时 82.9s 却报「120000ms 内未完成」)。
  改为只认真 abort,并新增网络错误翻译(ENOTFOUND/EAI_AGAIN/ECONNREFUSED/ECONNRESET/EPIPE)。

守卫与验证
- 新增 `tests/unit/sidecar-proxy.test.mjs`(16 用例,四段:纯函数 / 真发请求 / 端到端
  spawn sidecar 跑 JSON-RPC / 形态守卫)。夹具用本机真 `scutil --proxy` 与真 `reg query` 输出,
  地址改为 RFC 5737 文档段,不写内网 IP、不写工具实名。
- 守卫落在「最终下发的请求」与「产物字节」上,逐条**注入故障实测会红、还原后逐字节一致**:
  ① `check_update` 退回裸 `fetch` → 代理未收到该次请求 → 红
  ② 去掉 `isBypassed` 后缀匹配 → 红
  ③ 出网/RPC 超时改回反序 → 红 / `error[E0080]`
  ④ 去掉隧道 abort 挂载 → 红(`实际 4002ms`)
  ⑤ `ECONNRESET` 塞回 abort 类 → 红
- 全量:`unit` **341/341**、`rust` **18/18**、`clippy -D warnings` 干净、`vue-tsc` 退出码 0。

未覆盖项(如实记档)
- https 经 CONNECT 隧道叠 TLS 无离线自动化用例:`selfsigned` 只在 `apps/agent` 包内,
  测试进程解析不到 → 无法离线造受信任 TLS 站点;该路径仅真机验证。
- 真机「经代理取 GitHub 成功」的那一组未取到(宿主两条代理线路当时都取不到 GitHub),
  属环境而非产品缺陷;错误信息已可操作(含 `via`/`proxy_source`/`hint`)。
- 同类缺陷**第三处未改**:`apps/agent/src/capabilities/selfupdate.ts` 仍是裸 `fetch`
  (属被控端变更,改则需升 PATCH)。

注:本项为上壳变更,不触发 release patch;`apps/console` 至今未接入 `release.mjs`。
2026-10-13 22:41:02 +08:00
jkooe b9236dfaf1 chore(privacy): 公开仓库隐私脱敏 —— 清除可定位到个人与设备的字面量
按 security-reports/ 的隐私审查台账逐项收口。纯脱敏与表述泛化,
不含任何业务逻辑变更;实测结论与排查步骤一字未动。

P1(可定位到具体设备/个人)
- scripts/verify-b2.mjs:NA_URL 去掉硬编码缺省值(原指向一台真实机器),改为必填。
  这是全仓库唯一「拿着就能连到某台真机」的常量。
- 测试账户名 pdverify → na-test(docs/VM-VERIFICATION.md 7 处、docs/console-plan.md 1 处)
- 虚拟机与宿主的真实网段 → RFC 5737 文档保留段 192.0.2.x(含 CLOSE_WAIT 记录)
- 跨项目名与私人称呼 → 中性表述(docs/PRD.md、docs/DEVELOPMENT.md、docs/console-plan.md 等)

P2(低风险设备指纹)
- 虚拟化产品及其 CLI / 访客工具名 → 中性表述(12 个文件 + 1 个工作流注释)
- 测试目录 C:\nodeagent-test → C:\na-test
- NAS 设备型号与系统名 → NAS 设备 / NAS 系统(docs/DEPLOY-LINUX.md、scripts/install-linux.sh、README.md)
- 真机 CPU 型号、真机物理分辨率、代理软件品牌与监听端口、杀软品牌 → 泛化
- 单元测试示例段去掉真机 IP 尾八位:acl-v11 / net-scripts 的 fixture 与断言同步更新
- .gitignore 补 config.json / agent.json(含真实密钥与节点地址,绝不入库)
- 顺带移除混入技术文档的「不构成投资建议」字样(与文档主题无关)

验证:pnpm test:unit 340/340 通过;独立扫描器对 235 个跟踪文件做 17 类模式
全量扫描,命中 0。
2026-10-13 22:35:40 +08:00
jkooe 00992e5c1c feat(linux): Linux 被控端适配 —— 安装器 + systemd 自启 + 平台守卫 + 部署文档
面向「服务器型被控端」(命令/状态/文件/审计;无 GUI),实测环境 Debian 12(fnOS 底座)。

## 新增
- `scripts/install-linux.sh`:一键安装器(对齐 Windows `install.ps1` 的语义)
  · 自动获取 Node 运行时(系统无 Node 22+ 时下载 linux-<arch>,走 npmmirror)
  · **systemd 服务**(系统级 / 用户级+linger / `--no-service` 三种模式),开机自启
  · 幂等:复用既有密钥(并自动备份 agent.json);卸载保留配置(`--purge` 才删)
  · `--node-id/--port/--allow-input` 等参数;缺 `ip`/`ifconfig` 时 IP 展示多级回退
- `apps/agent/src/util/platform.ts`:**统一平台守卫**
  · `assertPlatform(cap, ...allowed)` / `assertGuiPlatform` / `assertWindows`
  · 抛 `E_UNSUPPORTED_PLATFORM` 并带上「支持哪些平台」,让"未实现"不再伪装成"参数错/环境缺依赖"
- `docs/DEPLOY-LINUX.md`:部署指南 + **实测能力清单** + 故障排查表

## 修改
- `screen.ts` / `record.ts` / `clipboard.ts` / `audio.ts` / `input.ts`:入口接入平台守卫。
  修复前的表现是**误导性错误**(`screen.capture` 在 Linux 上报 `region 宽高必须 ≥ 1`,
  像是参数没传对),现在是 `screen.capture 未在 Linux 上实现(仅支持 Windows / macOS)`。
- `scripts/verify.mjs`:v2.1 / v2.3 补 `E_UNSUPPORTED_PLATFORM → skip`
  (v2.2 早已处理;此前把"平台不支持"记成失败)
- `README.md`:文档区链入 DEPLOY-LINUX

## 实测(Docker · Debian 12)
- 安装流程端到端:装文件 → 写配置(600) → 提取密钥 → 启动监听 → **幂等复装密钥不变** ✓
- 验收:**9 通过 / 1 失败 / 6 跳过**(对比 macOS 15/0/1)
  · 1 失败 = 容器缺 `procps`(`process.list` 返回空),非代码问题;NAS 自带该包
  · 6 跳过 = GUI 类能力(平台诚实拒绝)
- 单元测试 325 全绿;7 包构建通过

## 途中修正的三个"差点误判"
1. **Alpine 误判**:`spawn /bin/bash ENOENT` 是**镜像没 bash**,不是 Linux 不支持
2. **`set -e` 静默退出**:`LAN_IP=$(ip ...)` 在无 `ip` 的环境失败会**直接终止脚本**
   (`2>/dev/null` 把原因吞了,表现为"跑到一半就没了")→ 已改多级回退
3. **slim 镜像缺 procps** → `process.list` 空列表,被误读为"Linux 特性"
2026-10-13 21:15:59 +08:00
jkooe c9e34b93a2 docs(changelog): 补黑窗修复的真机复验结果
- sidecar 在点击那一刻被拉起(pid 4056 @ 20:52:58),命令行两侧均无 \\?\;再次点击
  复用存活实例、不重建,符合设计
- 黑窗消失:MainWindowTitle -like '*nodeagent-console*' 返回空(修复前命中一个
  WindowsTerminal 窗口,标题正是 …\nodeagent-console\node.exe)
- 附带记档(与本次修复无关的环境问题):该次点击时 VM 连不上 GitHub,
  界面报 "fetch failed",同机 Invoke-WebRequest 21.5s 无法连接;更早一次网络通时
  已成功返回结果,故功能本身已验证

CHANGELOG 不在 console-windows.yml 的 paths 里,本次提交不触发新构建。
2026-10-13 21:05:52 +08:00
jkooe 9384b64c42 fix(console): sidecar 补 CREATE_NO_WINDOW(不再弹常驻黑窗)
真机复验 verbatim 修复时暴露的既有缺陷:点「检查更新」后屏幕上多出一个不消失的
黑窗(WindowsTerminal 承载,标题即 node.exe 路径)。

根因:控制台自身是 GUI 子系统,spawn 控制台子系统程序(node.exe)时 Windows 会
新建控制台,必须 CREATE_NO_WINDOW 抑制。AgentManager::start 早有此标志,
sidecar_command 漏了。修复 verbatim 前 sidecar 秒退、黑窗一闪而过;
sidecar 常驻后它才留在屏幕上。

修法:sidecar_command 补 creation_flags;常量提为 sidecar::CREATE_NO_WINDOW
由两处共用,避免值漂移。

本项无自动化守卫(如实记档):creation_flags 只有 setter 没有 getter,
断言不到最终值;扫源码属源码级检查,违反铁律 1。靠真机截图对照把关。
clippy 零告警,test:rust 18/18(行为零变化的注释/常量整理 + 一处新标志)。
2026-10-13 20:41:02 +08:00
jkooe 2437b0e863 fix(console): unverbatim 用 to_str 而非 to_string_lossy(不损坏非 UTF-8 路径)
to_string_lossy 会把非法字节替换成 U+FFFD 再重建路径 —— 那是损坏路径,
比不净化更糟:原本只是 node 报 EISDIR,改完变成指向一个不存在的目录。

改为 to_str():非 UTF-8 → None → 原样返回(Windows 上是含未配对代理项的路径,
Unix 上是含非法字节的文件名),绝不悄悄改写。

新增守卫 unverbatim_对非_utf8_的_verbatim_路径必须原样返回不得损坏(#[cfg(unix)]):
构造必须同时含 \\?\ 前缀与非法字节才有区分力 —— 不带前缀时新旧实现都走
「原样返回」那一步,测不出差异。
注入故障(退回 to_string_lossy)实测该守卫如期变红;还原后 sha256 逐字节一致。
pnpm test:rust 18/18(+1),clippy 零告警。
2026-10-13 20:21:51 +08:00
jkooe 56f64e1cc1 docs(console): 把 verbatim 路径的证据精确到「脚本参数」这一维
真机二维四格实验(ProcessStartInfo,精确对应 Command::new(prog).arg(script)):
  G1 plain-prog + plain-script    -> exit 0,正常
  G2 plain-prog + VERBATIM-script -> exit 1,EISDIR lstat 'C:'
  G3 VERBATIM-prog + plain-script -> exit 0,正常
  G4 VERBATIM-prog + VERBATIM     -> exit 1,EISDIR lstat 'C:'

结论:炸的是「脚本参数」带 verbatim,不是「可执行文件」带 verbatim。
旁证:真机上被控端与 sidecar 的 program 都带 \\?\ 却长期存活。

因此改准注释口径:净化 script 是关键修复,净化 node 属防御性
(守住「凡交给外部进程的路径都干净」这条单一纪律,不靠记位置)。
行为零变化(纯注释 + CHANGELOG)。
2026-10-13 20:16:07 +08:00
jkooe eec76ed91e fix(console): 交给 node 的路径去掉 Windows verbatim 前缀(真机 sidecar 秒退根因)
真机现象(Windows 11 ARM64,控制台 v2.0.3):点「检查更新」立刻报
「sidecar 已退出(stdout 关闭),请重试」—— sidecar 在请求进行中真的退出了。

真机 A/B(同一个 node.exe、同一份 sidecar.mjs,只换脚本参数写法):
  C:\…\sidecar.mjs        → 2.5 秒返回结果,进程常驻
  \\?\C:\…\sidecar.mjs    → 0.47 秒退出,exit 1,零 JS 执行

根因:Tauri 的 resource_dir() 在 Windows 上返回 canonicalize 后的 verbatim 路径,
把它当参数交给 node,node 启动期 realpathSync 把 `\\?\C:` 拆成 `C:` →
EISDIR ... lstat 'C:' → exit 1,一行 JS 都来不及执行。
本地/CI/开发模式全测不出:开发期走 CARGO_MANIFEST_DIR 的普通路径,只有打包后才暴露。

修法:
- 新增 unverbatim():剥 \\?\UNC\ → \\、剥 \\?\ → 无前缀,纯字符串处理,全平台可调用
- 新增 sidecar_command():唯一把路径交给 node 的地方,单测断言其最终 program/args
- resolve_node / resolve_sidecar / resolve_bundled_agent 全部出口加闸
- Sidecar::spawn 与 AgentManager::start 进程边界对 node/script/data_dir 三条路径净化
- autostart::app_exe() 净化(该路径进计划任务 -Execute)

回归:新增 3 条守卫;注入故障(去掉 sidecar_command 的 unverbatim)实测第三条如期变红,
还原后 sha256 逐字节一致。pnpm test:rust 17/17(14 → +3),clippy 零告警。
2026-10-13 20:11:03 +08:00
jkooe 6adf11c85a fix(test): 两条守卫在 Windows 上必红(CRLF 与路径分隔符)
CI run 38380306200(提交 73ed06f)Windows 单元测试 319/321,macOS 全绿。

① 仓库内全部版本号互一致 → 报 undefined apps/console/src-tauri/Cargo.lock
   根因:actions/checkout 在 Windows 默认 core.autocrlf=true → CRLF,
   而 Cargo.lock 的版本提取把 \n 写死。**同一隐患也在生产代码里**:
   version-write.mjs 另有一条 name…\nversion… 正则读旧值,CRLF 下返回
   undefined → 把 Cargo.lock 记入 skipped 并跳过 → 发版后滞留旧版本
   (正是本模块要消灭的分叉,且不报错)。
   修法:读旧值收敛到 readCargoTomlVersion / setCargoLockVersion().before
   单一真源,删掉重复正则;新增 splitLineEol() 拆「正文+行尾」后改写
   (不能写 ("".*)$ 兜行尾 —— JS 的 . 不匹配 \r)。

② W-01 根因守卫 → 把唯一真源自己判成违规(util\quote.ts)
   根因:file.slice(dir.length+1) 在 Windows 得到反斜杠路径,与白名单
   的 util/quote.ts 比不中 → 守卫自己错。
   修法:新增 relPosix() 归一成 /。

新增 4 条守卫:Cargo.lock / Cargo.toml 的 CRLF 纯函数用例、
CRLF 工作区上真跑 writeVersionEverywhere 再读回文件、
以及用 path.win32 形态字符串在 macOS 上直接测 Windows 分支。
注入故障实测 4 条如期变红;test:unit 325/325(321 + 4)。
2026-10-13 18:57:04 +08:00
jkooe 73ed06f5f1 fix(console): 修复 sidecar 退出后被复用(Windows 更新超时根因)
现象(Win11 ARM64 真机):点「检查更新」等满 45 秒报「check_update 超时」。
对照:同一台机器手工拉起 sidecar 跑同一请求 6.989s 成功;现场 node.exe
只有被控端一个 —— 控制台的 sidecar 进程根本不存在。

根因:ensure_sidecar 只判「缓存里有没有实例」,不判「那个实例还活着没」。
往已退出进程的管道写,在 Windows 上未必立刻报错(缓冲区吞下即返回成功),
于是请求既不报错也永远等不到响应,只能干等到 request 的超时。

修法:
- Sidecar 增 dead: Arc<AtomicBool>,三处置死(stdout EOF / 写入或 flush 失败
  / shutdown 主动关闭);stdout 关闭时唤醒所有在等的请求,让它们立刻失败。
- 新增 is_alive():dead / child.try_wait() / 已被 take 走,拿不准一律按不可用。
- request() 入口先判存活,不可用即秒失败。
- ensure_sidecar 取到缓存实例先判存活,已退出则丢弃重建。
- 新增 3 条单测(含「对已退出的 sidecar 发请求必须秒失败」——给 30s 超时但
  断言耗时 < 5s,因为修复前的表现正是干等到超时)。注入故障实测守卫变红。
- pnpm test:rust 14/14;test:unit 321/321。

属控制台上壳变更,不触发 release patch;但需重建控制台安装包才能让用户拿到修复。
2026-10-13 17:59:49 +08:00
jkooe 79969e9d5d chore(release): v2.0.3 2026-10-13 17:36:54 +08:00
jkooe a1c51d62a0 docs(changelog): 记入本批三类改动,并统一版本叙述为 v2.0.3
- 安全加固批次:18 条实机复现缺陷(含两条 P0 的完整根因与修法、崩溃链共同根因)
- 控制台单实例保护(症状:Windows 端两个被控端 / 旧服务端在跑时控制台起不来)
- 发版脚本漏改 Cargo 清单导致的版本号分叉
- 版本叙述统一:注释里原先散落 v2.0.4,与下一版号不符,全部改为 v2.0.3

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

留下两条可迁移教训:
1. 体积阈值属于「中间逻辑自洽」式断言 —— 判据要落在产物内容上;
   且压缩容器与未压缩目录的体积不可直接比。
2. `msiexec.exe` / NSIS 安装包是 GUI 子系统程序,PowerShell `&` 调用
   既不等待也不设 $LASTEXITCODE → 必须 Start-Process -Wait -PassThru。
2026-10-12 00:20:50 +08:00
jkooe e616e80819 fix(ci): 取安装器退出码改用 Start-Process -Wait,& 调 GUI 程序不等待也不设退出码
第二个 run(38152850819)两 job 仍同点红,但已是另一处原因,且同样是
「静默失效」:日志里 `msiexec /a 失败(退出码 )` —— $LASTEXITCODE 是空的,
而整步从开始到抛错只过了 0.8 秒。

根因:msiexec.exe 与 NSIS 安装包都是 **GUI 子系统程序**,PowerShell 用 `&`
调用时**既不等待其结束、也不设置 $LASTEXITCODE**(于是 $null -ne 0 判真,
在安装器还没跑完时就当场抛错)。arm64 那条没有 MSI、直奔 NSIS,
失败形态一模一样,正是同一根因的第二个样本。

修法:
- 两处都改 Start-Process -Wait -PassThru,读 $p.ExitCode(确定性等待)。
- 参数拼成**单个字符串**传入,避免 -ArgumentList 数组被 PowerShell 自动加引号
  而破坏 NSIS `/D=` 的「必须原样、不加引号、且是最后一个参数」语义。
- 新增 Wait-PayloadRoot:安装器可能「自己先退出、由子进程继续装」,
  给最多 30 秒收敛时间再判定「找不到 node.exe」,避免竞态误报。
- 打印安装器实际退出码,便于下次一眼看出是哪一类失败。
2026-10-12 00:05:01 +08:00
jkooe 71f2cd2597 fix(ci): 控制台 Windows 断言改为解开安装包查内容,不再用体积阈值
首个 run(38151366534)两个 job 双双失败,且失败点完全一致:
第 14 步「断言安装包存在且体积合理」。构建链路本身全绿 ——
tauri build(msi 34.2MB / nsis 19.5~23.2MB)、产物自举
(打包内 node v22.23.3 真跑起打包内 sidecar)都成功,
是守卫自己错了。

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

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

另加 timeout-minutes: 8,避免静默安装万一卡住把 job 拖满 60 分钟。
2026-10-11 23:48:29 +08:00
jkooe 1471708f35 ci: 新增 Windows 控制台安装包构建(x64 + arm64 双架构)
在此之前,控制台的 Windows 侧只被验证到「prepare:node + 自举」(见 windows-e2e),
**从未真跑过 tauri build**。而 Rust 里那些 cfg!(windows) 分支(taskkill 收进程树、
PowerShell 计划任务、node.exe 路径解析)在 macOS 上只被类型检查、
从未被编译成 Windows 目标,更没有产物级验证 —— 本工作流是它们的第一道真闸。

为什么必须双架构
----------------
被控端发布包出过「arm64 包里装的是 x64 node.exe」的事故:在 ARM Windows 上
**能 listen、但 TLS 握手不完成**,且 macOS 侧 100% 复现不出。
控制台同理 —— 它内含 node 运行时,且要用它**托起本机被控端**(即 TLS 服务端)。
故 x64 / arm64 必须各出一套。

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

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

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

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

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

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

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

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

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

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

几处刻意的选择:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

两处一起修:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## 本次改动

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## 验证
工作区残留「主流杀软/杀软/Ethernet」= **0 处**;单测 273 全绿;7 包构建通过。
2026-10-10 12:26:41 +08:00
jkooe d9e2103be7 docs(roadmap): 同步 v2.0.0 已发布状态
- 头部基线:v2.0.0(2026-10-10 / a515eb2),单测 273,能力 51,CI 双工作流全绿
- §九 台账补「发版 v2.0.0」行 + 「v1.5 四项与 v26 清理策略真机验证仍待补」的显式标记
2026-10-10 12:18:28 +08:00
196 changed files with 23509 additions and 4525 deletions

No files matched your search

+28
View File
@@ -0,0 +1,28 @@
name: Console Release
# 控制台发布工作流(tag 触发)。
#
# 为什么不把 `tags` 直接写进 console-windows.yml 的 push 里
# ------------------------------------------------
# 那个工作流带 `paths` 过滤。`paths` 与 `tags` 同时出现时,tag 推送会被
# 路径判定挡掉而**静默不触发** —— 这是 GitHub Actions 的经典坑,
# 且失败形态是「什么都没发生」,最难察觉。
# 故拆成:日常验证走 console-windows(分支 + paths),发布走本工作流(只认 tag)。
#
# 交付物去向:本工作流构建两台架构的安装包,再由 console-windows 里的
# `publish` job 补挂到 `scripts/release.mjs` 创建的那个 Release 上。
on:
push:
tags:
- 'v*'
# 发布需要写 Release
permissions:
contents: write
jobs:
console:
uses: ./.github/workflows/console-windows.yml
permissions:
contents: write
+408
View File
@@ -0,0 +1,408 @@
name: Console Windows
# 控制台(apps/console)的 Windows 安装包构建。
#
# 为什么单独一个工作流、而不塞进 windows-e2e
# ----------------------------------------
# windows-e2e 是「被控端在 Windows 上的行为验证」,跑一遍约 4 分钟、含安装/卸载真机冒烟;
# 而控制台打包要装 Rust 工具链、编 Tauri(首次冷编 10 分钟级)。两者目标不同、
# 耗时量级不同,混在一起会让「改一行前端也要等 15 分钟」。
#
# ⚠️ 在此之前,控制台的 Windows 侧只被验证到 prepare:node 与自举(见 windows-e2e),
# **从未真跑过 tauri build**。而 Rust 里那些 `cfg!(windows)` 分支
# (taskkill 收进程树、PowerShell 计划任务、node.exe 路径)在 macOS 上
# 只被类型检查、从未被编译成 Windows 目标,更没有产物级验证 —— 故本工作流是它们的第一道真闸。
#
# 架构(为什么必须两套)
# --------------------
# 被控端发布包曾出过「arm64 包里装的是 x64 node.exe」的事故,后果是
# **在 ARM Windows 上能 listen、但 TLS 握手不完成**,且 macOS 侧 100% 复现不出。
# 控制台同理:它内含 node 运行时,且要用它**托起本机被控端**(即 TLS 服务端)。
# 故 x64 与 arm64 必须各出一套,且都要能装到对应架构的 Windows 上。
#
# 这里用 GitHub 的 ARM64 Windows runner(`windows-11-arm`,公共仓库免费)
# **原生构建**,而不是在 x64 runner 上交叉编译 —— 交叉编译需要 VS 的 ARM64 工具链,
# 且 `prepare-console.mjs` 的自检要真执行 node.exe(x64 机器跑不了 arm64 二进制),
# 会逼着引入「跳过自检」这类削弱验证的开关。原生构建则自检照跑、无特殊分支。
on:
push:
branches: [main]
paths:
- 'apps/console/**'
- 'apps/agent/**'
- 'packages/**'
- 'scripts/prepare-console.mjs'
- 'scripts/lib/bundle-agent.mjs'
- 'package.json'
- 'pnpm-lock.yaml'
- '.github/workflows/console-windows.yml'
pull_request:
branches: [main]
paths:
- 'apps/console/**'
- '.github/workflows/console-windows.yml'
workflow_dispatch:
# 供 `console-release.yml` 在 tag 推送时调用。
#
# 为什么不直接把 `tags` 加进上面的 push:`paths` 与 `tags` 同时存在时,
# tag 推送会因为「路径过滤」判定不通过而**静默不触发**(这是 GitHub Actions
# 的经典坑)。拆成「主工作流只跑分支 + 发布工作流只认 tag」最不容易出错。
workflow_call:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
name: 控制台安装包 · ${{ matrix.arch }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 60
strategy:
# 一条架构挂了不要拖停另一条 —— 否则「arm64 失败」会掩盖「x64 到底成没成」
fail-fast: false
matrix:
include:
- arch: x64
runner: windows-latest
# x64 给两种安装形态:msi(企业批量部署)+ nsis(普通用户双击)
bundles: msi,nsis
pe: '0x8664'
- arch: arm64
runner: windows-11-arm
# arm64 只出 nsis:WiX 的 arm64 MSI 支持面较窄,不值得为它引入不确定性
bundles: nsis
pe: '0xaa64'
steps:
- name: 检出代码
uses: actions/checkout@v5
- name: 安装 pnpm
uses: pnpm/action-setup@v4
with:
version: 12
- name: 安装 Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: 安装 Rust 工具链
uses: dtolnay/rust-toolchain@stable
- name: 缓存 Rust 构建产物
uses: Swatinem/rust-cache@v2
with:
# 只缓存 src-tauri(控制台壳),不牵动仓库根的 Rust 工作区
workspaces: apps/console/src-tauri
- name: 安装依赖
run: pnpm install --frozen-lockfile
# ⚠️ 这条断言是必需项,不是「顺手检查」。
# ARM runner 的镜像里确实存在 x64 模拟的组件(社区实测 python 即为 AMD64),
# 一旦 Node 也是模拟版,process.arch 会是 x64 → prepare-console 会把 x64 的 node.exe
# 打进 arm64 安装包 → **装到真机上能启动、TLS 握手不完成**,且本机 CI 全绿。
# 故先断言「我到底在什么架构上编」,再往下走。
- name: 断言构建机架构与目标一致
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$arch = node -p "process.arch"
Write-Host "node process.arch = $arch"
Write-Host "runner = ${{ matrix.runner }}"
if ($arch -ne '${{ matrix.arch }}') {
throw "架构不符:期望 ${{ matrix.arch }},实际 $arch —— 打进包的 node.exe 会是错的架构"
}
- name: 构建 workspace 包(含被控端)
# agent 的 dist 是 prepare-console 打包被控端的前置产物,顺序不能调
run: pnpm --filter "./packages/*" --filter "@nodeagent/agent" build
# prepare-console 的前置检查要求 sidecar.mjs 已存在;它自己也会调 esbuild 打 agent.mjs
- name: 构建 sidecar 并准备随包资源(node + agent.mjs)
working-directory: apps/console
run: |
pnpm sidecar:build
pnpm prepare:node
# ⚠️ 交付物级断言(与 pack.mjs 的双架构校验同源思路):
# 不只看「脚本跑完了」,而是直接读最终要打进包的那个 node.exe 的 PE 头。
# 中间逻辑再自洽,也可能因为上游给了错架构的二进制而产出错包。
- name: 断言随包 node.exe 的 PE 架构
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$node = 'apps/console/src-tauri/runtime/node.exe'
if (-not (Test-Path $node)) { throw "缺少随包 Node 运行时:$node" }
$bytes = [IO.File]::ReadAllBytes((Resolve-Path $node))
$peOff = [BitConverter]::ToUInt32($bytes, 0x3C)
$machine = [BitConverter]::ToUInt16($bytes, $peOff + 4)
$actual = '0x{0:x4}' -f $machine
Write-Host "node.exe PE Machine = $actual(体积 $([math]::Round($bytes.Length/1MB,1)) MB)"
if ($actual -ne '${{ matrix.pe }}') {
throw "随包 node.exe 架构错误:期望 ${{ matrix.pe }},实际 $actual —— 严禁发布该包"
}
- name: 打包安装包(${{ matrix.bundles }})
working-directory: apps/console
# beforeBuildCommand(vite build)由 tauri 自己触发;资源靠 --config 注入,
# 默认 conf 里绝不出现 runtime 路径(bundle.resources 是编译期契约,声明了不存在的文件 build.rs 会直接失败)
run: pnpm tauri build --config src-tauri/tauri.bundle.gen.json --bundles ${{ matrix.bundles }}
- name: 验证产物自举
# 真 spawn 产物内的 node 去跑产物内的 sidecar,走一次 JSON-RPC
# (这在 macOS 上抓出过 strip 破坏签名导致 SIGKILL 的问题,Windows 侧同样值得钉住)
run: node apps/console/scripts/verify-bundle.mjs --runtime
# ⚠️ 断言的是**最终交付物**,不是中间产物。
#
# 这一版是本步的第二次实现。首版写成「安装包体积 > 50MB」,理由是
# 「含 Node 运行时(约 80MB),故不可能是个小文件」—— 这个推理把
# **未压缩的目录形态**(.app ≈ 92MB)当成了**压缩安装包的体积**。
# 2026-10-11 首个 run 两个 job 双双死在这一行:实测 msi 34.2MB、
# nsis 19.5~23.2MB(LZMA 下 node.exe 自身就能压到 1/3~1/4),阈值必然误杀。
#
# 更根本的问题是:**体积阈值属于「中间逻辑自洽」式断言** —— 包大
# 不等于 node.exe 真在里面,包小也不等于没在里面(压缩率会随版本漂)。
# 故改为**解开包、查内容**,一次钉死三件事:
# ① 三个随包资源(node.exe / sidecar.mjs / agent.mjs)必须都在 ——
# 缺任一,装到真机上就是「应用能开、但托不起被控端」;
# ② node.exe 的 PE Machine 必须等于目标架构 —— 最终产物的架构断言,
# 直接对治「arm64 包里装的是 x64 node.exe」那类只在真机暴露的事故;
# ③ node.exe 解压后体积必须 > 40MB —— 挡住「塞了个占位文件」。
# 附带收益:真装一遍本身就是安装器冒烟(比只读目录更强)。
- name: 断言安装包内容(随包 node.exe 齐全且架构正确)
shell: pwsh
# 静默安装理论上会自行结束;给个上限,免得万一卡住把 job 拖满 60 分钟
timeout-minutes: 8
run: |
$ErrorActionPreference = 'Stop'
$expect = '${{ matrix.pe }}'
$bundle = 'apps/console/src-tauri/target/release/bundle'
function Get-PeMachine([string]$path) {
$fs = [System.IO.File]::OpenRead($path)
try {
$br = [System.IO.BinaryReader]::new($fs)
$fs.Position = 0x3C
$peOff = $br.ReadInt32()
$fs.Position = $peOff
if ($br.ReadUInt32() -ne 0x00004550) { throw "不是 PE 文件:$path" }
return '0x{0:x4}' -f $br.ReadUInt16()
} finally { $fs.Dispose() }
}
# 在候选目录里找出真正装了 node.exe 的那个(安装位置随安装形态而异)
function Find-PayloadRoot([string[]]$candidates) {
foreach ($c in $candidates) {
if (-not (Test-Path $c)) { continue }
$hit = Get-ChildItem -Path $c -Recurse -Filter 'node.exe' -ErrorAction SilentlyContinue |
Select-Object -First 1
if ($hit) { return $c }
}
return $null
}
# 安装器可能「自己先退出、再由子进程继续装」,故给它一段收敛时间
function Wait-PayloadRoot([string[]]$candidates, [int]$seconds = 30) {
$deadline = (Get-Date).AddSeconds($seconds)
do {
$r = Find-PayloadRoot $candidates
if ($r) { return $r }
Start-Sleep -Seconds 1
} while ((Get-Date) -lt $deadline)
return $null
}
# 解开后的目录里必须三样齐全,且 node.exe 架构要对
function Assert-Payload([string]$root, [string]$label) {
$node = Get-ChildItem -Path $root -Recurse -Filter 'node.exe' -ErrorAction SilentlyContinue |
Select-Object -First 1
$side = Get-ChildItem -Path $root -Recurse -Filter 'sidecar.mjs' -ErrorAction SilentlyContinue |
Select-Object -First 1
$agent = Get-ChildItem -Path $root -Recurse -Filter 'agent.mjs' -ErrorAction SilentlyContinue |
Select-Object -First 1
$listing = (Get-ChildItem -Path $root -Recurse -File -ErrorAction SilentlyContinue |
Select-Object -First 40 -ExpandProperty FullName) -join "`n"
if (-not $side) { throw "$label 内未找到 sidecar.mjs`n实际内容:`n$listing" }
if (-not $agent) {
throw "$label 内未找到 agent.mjs —— 被控端未随包,装上后启动不了本机被控端`n实际内容:`n$listing"
}
if (-not $node) {
throw "$label 内未找到 node.exe —— 随包 Node 运行时未打入,装上后一启动就找不到 node`n实际内容:`n$listing"
}
$mb = [math]::Round($node.Length / 1MB, 1)
$machine = Get-PeMachine $node.FullName
Write-Host " node.exe $mb MB PE Machine=$machine"
Write-Host " sidecar.mjs $([math]::Round($side.Length / 1KB)) KB"
Write-Host " agent.mjs $([math]::Round($agent.Length / 1KB)) KB"
if ($mb -lt 40) { throw "$label 内 node.exe 仅 $mb MB —— 体积异常,疑似占位文件" }
if ($machine -ne $expect) {
throw "$label 内 node.exe 架构错误:期望 $expect,实际 $machine —— 严禁发布该包"
}
}
$msis = @(Get-ChildItem -Path "$bundle/msi/*.msi" -ErrorAction SilentlyContinue)
$nsis = @(Get-ChildItem -Path "$bundle/nsis/*.exe" -ErrorAction SilentlyContinue)
if (($msis.Count + $nsis.Count) -eq 0) { throw "未产出任何安装包($bundle 下为空)" }
$checked = 0
foreach ($m in $msis) {
Write-Host "── MSI:$($m.Name) $([math]::Round($m.Length / 1MB, 1)) MB"
# 管理安装(/a)= 只解包不注册,正合此处用途
$dest = Join-Path $PWD 'pkg-extract/msi'
Remove-Item -Recurse -Force $dest -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $dest | Out-Null
$log = Join-Path $PWD 'msi-extract.log'
# ⚠️ 必须用 Start-Process -Wait -PassThru 取退出码,**不能**用 `& msiexec.exe`:
# msiexec 是 GUI 子系统程序,脚本里 `&` 调它**既不等待、也不设 `$LASTEXITCODE`**
# (实测:整个步骤 0.8 秒就跑到下一行,`$LASTEXITCODE` 为空 → `$null -ne 0` 判真 → 当场抛错)。
# 参数拼成**单个字符串**传递,避免 -ArgumentList 数组被 PowerShell 自动加引号而破坏 /D= 语义。
$argLine = "/a `"$($m.FullName)`" /qn TARGETDIR=`"$dest`" /l*v `"$log`""
$p = Start-Process -FilePath 'msiexec.exe' -ArgumentList $argLine -Wait -PassThru
Write-Host " msiexec 退出码 = $($p.ExitCode)"
# 3010 = 成功但需重启,不是失败
if ($p.ExitCode -ne 0 -and $p.ExitCode -ne 3010) {
$tail = (Get-Content $log -Tail 30 -ErrorAction SilentlyContinue) -join "`n"
throw "msiexec /a 失败(退出码 $($p.ExitCode)):$($m.Name)`n$tail"
}
$root = Wait-PayloadRoot @($dest)
if (-not $root) { throw "MSI 管理安装后未在 $dest 下找到 node.exe" }
Assert-Payload $root $m.Name
$checked++
}
foreach ($n in $nsis) {
Write-Host "── NSIS:$($n.Name) $([math]::Round($n.Length / 1MB, 1)) MB"
# /S 静默、/D 指定目录:两者都由 NSIS **运行时自身**在 .onInit 之前解析
# (核心参数,不依赖 Tauri 模板),故对任何 NSIS 安装包都成立。
# /D= 必须是最后一个参数且**不加引号**,故这里用仓库目录下的相对路径
# (不含空格),而不是通常带空格的用户目录。
$dest = Join-Path $PWD 'pkg-extract/nsis'
Remove-Item -Recurse -Force $dest -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $dest | Out-Null
# 同 MSI:安装器也是 GUI 子系统程序,必须 Start-Process -Wait 才拿得到退出码。
# 单个字符串传参 → `/D=` 保持原样不加引号(NSIS 的硬要求)。
$p = Start-Process -FilePath $n.FullName -ArgumentList "/S /D=$dest" -Wait -PassThru
Write-Host " 安装器退出码 = $($p.ExitCode)"
if ($p.ExitCode -ne 0 -and $p.ExitCode -ne 3010) {
throw "NSIS 静默安装失败(退出码 $($p.ExitCode)):$($n.Name)"
}
# /D= 理论上一律生效,但若模板在 .onInit 里改写了 $INSTDIR 就未必;
# 故把默认安装位置一并列为候选,避免「装成功了却因为找不到目录而误报」。
$root = Wait-PayloadRoot @(
$dest,
(Join-Path $env:LOCALAPPDATA 'nodeagent-console'),
(Join-Path $env:LOCALAPPDATA 'Programs/nodeagent-console'),
(Join-Path $env:ProgramFiles 'nodeagent-console')
)
if (-not $root) {
throw "NSIS 静默安装后未找到随包 node.exe —— 已查:$dest 与默认安装位置"
}
Write-Host " 安装位置:$root"
Assert-Payload $root $n.Name
$checked++
}
Write-Host ""
Write-Host "✓ 已核对 $checked 个安装包:node.exe / sidecar.mjs / agent.mjs 齐全,node.exe 架构 = $expect"
- name: 上传安装包
uses: actions/upload-artifact@v4
with:
name: nodeagent-console-windows-${{ matrix.arch }}
path: |
apps/console/src-tauri/target/release/bundle/msi/*.msi
apps/console/src-tauri/target/release/bundle/nsis/*.exe
# 一条架构没产出就报错,避免「jobs 全绿但产物不全」
if-no-files-found: error
# 交付流水线 §4:CI artifact 公开可下载,留存越久暴露面越大,30 → 7
retention-days: 7
# 发布:tag 推送时把两个架构的安装包补挂到 release.mjs 建好的 Release 上。
#
# 为什么必须有这一步(2026-10-17 真机教训)
# --------------------------------------
# `scripts/release.mjs` 只产出 agent / cli / mcp,控制台的 NSIS/MSI
# **从未进入过 Release** —— 后果是:用户从 Release 页面下载到的永远是旧控制台,
# 而当天修的 6 个真机缺陷只存在于 CI artifact(retention 7 天,到期自动删除)。
# 一句话:**修复发出去了,但没人拿得到。**
publish:
name: 补挂安装包到 Release
# 只有 tag 才发布;分支构建(日常验证)一律跳过
if: startsWith(github.ref, 'refs/tags/v')
needs: build
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: write
steps:
- name: 下载两个架构的安装包
uses: actions/download-artifact@v4
with:
path: dist
pattern: nodeagent-console-windows-*
- name: 列出待发布文件(先看清再上传)
run: |
set -euo pipefail
echo "--- dist 实际内容 ---"
find dist -type f | sort | head -40
echo "--- 安装包 ---"
find dist -type f \( -name '*.exe' -o -name '*.msi' \) -printf '%10s %p\n' | sort -k2
# 期望值必须与上面的 build 矩阵一致:
# x64 → msi + nsis(2 个)
# arm64 → 仅 nsis(1 个,WiX 的 arm64 MSI 支持面窄,刻意不出)
# 合计 3 个。
#
# ⚠️ 这里曾写死「两架构各一套(exe+msi)= 4 个」,与矩阵漂移后**每次必红** ——
# 红门禁等于没有门禁(本项目反复踩的同一类坑:断言与实现各改各的)。
# 故改为**按架构**断言,并在缺失时报出具体是哪个架构,便于一眼定位。
fail=0
for spec in "x64:2" "arm64:1"; do
arch=${spec%%:*}; want=${spec##*:}
dir="dist/nodeagent-console-windows-$arch"
# ⚠️ 变量引用一律用 ${var} 形式:`$dir)` 这种「$var + 全角括号」在
# set -u 下会被 bash 当成变量名的一部分 → "unbound variable",
# 而写法在本地肉眼完全正常(本地实测复现过)。
if [ ! -d "${dir}" ]; then
echo "::error::缺少 ${arch} 的产物目录(${dir})—— 该架构构建失败或 artifact 名称变更"
fail=1
continue
fi
got=$(find "${dir}" -type f \( -name '*.exe' -o -name '*.msi' \) | wc -l)
echo " ${arch}: ${got} / ${want}"
if [ "${got}" -lt "${want}" ]; then
echo "::error::${arch} 安装包不足(${got} < ${want})—— 产物缺失,或矩阵已变更但本断言未同步"
fail=1
fi
done
[ "${fail}" -eq 0 ] || exit 1
echo "✓ 两架构安装包齐全"
- name: 挂到对应 Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
# release.mjs 在本地跑、与本工作流并行,故先等 Release 出现(最多 10 分钟)
for i in $(seq 1 60); do
if gh release view "$TAG" >/dev/null 2>&1; then echo "✓ Release $TAG 已存在"; break; fi
echo "等待 Release $TAG 创建… ($i/60)"; sleep 10
done
if ! gh release view "$TAG" >/dev/null 2>&1; then
echo "::warning::Release $TAG 尚未创建,安装包仅留在 CI artifact。请先跑 scripts/release.mjs"
exit 0
fi
find dist -type f \( -name '*.exe' -o -name '*.msi' \) -print0 \
| xargs -0 gh release upload "$TAG" --clobber
echo "✓ 已挂上的资产:"
gh release view "$TAG" --json assets -q '.assets[].name'
+129
View File
@@ -0,0 +1,129 @@
# 交付流水线左移门禁(见 docs/DELIVERY-PIPELINE.md §1 §2 §8)
#
# 本文件只做「快且能阻断」的检查:密钥、依赖漏洞、类型、覆盖率、提交规范。
# 真机链路(install.cmd / control.cmd 双击、Windows 能力)留在 windows-e2e.yml
# —— 那一环跑得慢但价值最高,不可被这条更快的流水线替代。
#
# ⚠️ 阈值说明(别照搬通用 80%):详见 docs/DELIVERY-PIPELINE.md §2。
# 全量覆盖率当前基线约 66.9%,故全量只观测;阻断只针对**变更文件**(≥70%)。
name: Quality Gate
on:
pull_request:
branches: [main]
push:
branches: [main]
permissions:
contents: read
jobs:
# ① 代码审计:密钥 + 依赖漏洞
audit:
name: 代码审计(密钥 + 依赖)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
# gitleaks 扫历史提交,需要完整历史
fetch-depth: 0
# 密钥扫描:nodeagent 的密钥面比一般项目大 ——
# PSK、Ed25519 私钥、TLS 自签证书、PSK.txt、agent.json
- name: 密钥与敏感信息扫描
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: pnpm/action-setup@v4
with:
# 与本仓库其他工作流保持一致(console-windows.yml / rust.yml / windows-e2e.yml 均为 12)
version: 12
- uses: actions/setup-node@v4
with:
node-version: 22
- run: pnpm install --frozen-lockfile
# 依赖漏洞:High 及以上阻断
- name: 依赖漏洞扫描(SCA)
# ⚠️ 必须显式指定公共 registry(2026-10-17)
# 仓库 `.npmrc` 指向 registry.npmmirror.com(镜像加速),但**镜像不实现
# audit 接口**,`pnpm audit` 会直接报 ERR_PNPM_AUDIT_ENDPOINT_NOT_EXISTS。
# 结果就是这道门禁从加上那天起**一直是红的**,等于没有门禁。
run: pnpm audit --audit-level=high --registry=https://registry.npmjs.org
# ② 质量门禁:类型 + 覆盖率
gate:
name: 质量门禁(类型 + 覆盖率)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
# 覆盖率门禁要算「变更文件」,需与基线做 diff
fetch-depth: 0
- uses: pnpm/action-setup@v4
with:
# 与本仓库其他工作流保持一致(console-windows.yml / rust.yml / windows-e2e.yml 均为 12)
version: 12
- uses: actions/setup-node@v4
with:
node-version: 22
- run: pnpm install --frozen-lockfile
# ⚠️ 必须先全量构建,再查类型(2026-10-17)
#
# 各包 import 的是**同仓其它包的编译产物**(dist/*.d.ts),依赖链是
# `protocol → client → cli / mcp / agent`。CI 全新检出没有 dist,
# 直接 typecheck 会逐层报 `TS2307: Cannot find module '@nodeagent/xxx'`;
# 而本地因为 dist 还在,完全看不出来 —— 典型「本地绿 / CI 红」。
#
# 一开始只想「只构建 protocol 就够」,实测不够:cli 又报找不到 `@nodeagent/client`。
# 与其逐个补依赖(下次加包还会漏),不如直接用 `-r build` 把整条链建出来。
# 顺带:单测 import 的也是 dist/,这一步同时保证了覆盖率不为空(原步骤在 typecheck 之后,
# 顺序本就矛盾 —— 既然后面也要 build,不如提前一次到位)。
- name: 构建 workspace(typecheck 与单测都依赖 dist)
run: pnpm -r build
- run: pnpm typecheck
- name: 采集覆盖率
run: |
mkdir -p coverage
node --test --experimental-test-coverage \
--test-reporter=lcov \
--test-reporter-destination=coverage/lcov.info \
"tests/unit/**/*.test.mjs"
- name: 覆盖率门禁
run: node scripts/ci/check-coverage.mjs --lcov coverage/lcov.info --base origin/main --threshold 70
# coverage/ 已在 .gitignore 中,此处留档便于人工查看
- uses: actions/upload-artifact@v4
if: always()
with:
name: coverage
path: coverage/
retention-days: 7
# ③ 提交信息规范
commits:
name: 提交信息规范
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 22
- name: 校验 Conventional Commits
run: node scripts/ci/check-commit-msg.mjs --base ${{ github.event.pull_request.base.sha }}
+88 -22
View File
@@ -1,21 +1,28 @@
name: Rust Client
name: Rust Shell
# crates/nodeagent-client 是桌面控制台的协议客户端(Tauri 壳经 Cargo path 依赖它)。
# 它是纯 Rust、无平台相关代码,故单开一个 Linux 快车道:
# 比 Windows 那条 E2E 长流程反馈快得多,失败归属也一目了然。
# 2026-10:原 crates/nodeagent-client(Rust 版协议客户端)已退役 ——
# 协议实现改由 Node sidecar 承担(apps/console/sidecar/),避免两套实现静默漂移。
# 本工作流随之改为验证 **console 的 Rust 壳**(sidecar 管理 + command 转发)。
#
# ⚠️ 本工作流与仓库其余部分的铁律不同:**不涉及** .ps1/.cmd 编码,无需 BOM 守卫。
# 它的单测是**真跑** Node sidecar 子进程的(见 apps/console/src-tauri/src/sidecar.rs),
# 不依赖 Tauri GUI,因此能在 Linux 快车道上跑。
on:
push:
branches: [main]
paths:
- 'crates/**'
- 'packages/protocol/**'
- 'apps/console/src-tauri/**'
- 'apps/console/sidecar/**'
- 'scripts/prepare-console.mjs'
- 'scripts/lib/bundle-agent.mjs'
- 'apps/agent/**'
- '.github/workflows/rust.yml'
pull_request:
paths:
- 'crates/**'
- 'packages/protocol/**'
- 'apps/console/src-tauri/**'
- 'apps/console/sidecar/**'
- 'scripts/prepare-console.mjs'
- 'scripts/lib/bundle-agent.mjs'
- 'apps/agent/**'
- '.github/workflows/rust.yml'
workflow_dispatch:
@@ -24,31 +31,90 @@ env:
jobs:
test:
name: Rust 客户端单测 + E2E
name: Rust 壳单测(真跑 sidecar)
runs-on: ubuntu-latest
steps:
- name: 检出代码
uses: actions/checkout@v5
# 版本**锁死**而不跟 stable 漂 —— 否则「本地全绿」与「CI 绿」会因为 lint 集合随
# 工具链变化而分叉(本仓库已因同类原因吃过 8 轮 Windows CI 的苦头)。
# 当前值与 crates/ 开发环境一致:rustc/cargo 1.98.1、clippy 0.1.98。
# 升级时两边同改,升完先本地复跑 `cargo clippy ... -- -D warnings`。
- name: 安装 Rust 工具链(1.98.1,含 clippy)
# 版本**锁死**而不跟 stable 漂 —— 否则「本地全绿」与「CI 绿」会因 lint 集合随工具链
# 变化而分叉(本仓库已因同类原因吃过 8 轮 Windows CI 的苦头)。
- name: 安装 Rust 工具链(含 clippy)
uses: dtolnay/rust-toolchain@master
with:
toolchain: 1.98.1
toolchain: stable
components: clippy
# Tauri 壳在 Linux 上编译需要这些系统库(webkit2gtk 等)。sidecar 单测本身
# 不需要 GUI,但整个 crate 要能编过。
- name: 安装 Tauri 系统依赖
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev \
librsvg2-dev patchelf libgtk-3-dev
- name: 安装 Node(sidecar 单测要真跑它)
uses: actions/setup-node@v4
with:
node-version: '22'
- name: 安装 pnpm 与依赖(sidecar 需 @nodeagent/client)
uses: pnpm/action-setup@v4
with:
version: 9
- run: pnpm install --frozen-lockfile
# ⚠️ 顺序有依赖:prepare:node 现在**还要 esbuild 打包被控端**(控制台随包分发
# agent.mjs,用于托起本机被控端)。故 @nodeagent/agent 的 dist 必须先构建出来,
# 否则 prepare 阶段会以「未找到被控端构建产物」失败。
# (本项目已因「新步骤漏排前置产物」在 Windows CI 上红过一次,此处显式写全。)
- name: 构建 workspace 包(sidecar 与 agent 的 dist 都是前置产物)
run: pnpm --filter "./packages/*" --filter "@nodeagent/agent" build
# ⚠️ tauri-build 在**编译期**校验 tauri.conf.json 里 resources 的文件存在性,
# 故必须先产出 sidecar.mjs,否则 clippy/test 会在 build.rs 阶段就失败。
- name: 构建 sidecar(resources 引用的产物)
run: pnpm --filter nodeagent-console sidecar:build
# 随包分发的 Node 运行时 + 被控端 agent.mjs 由该脚本准备;它同时产出
# 「构建期注入片段」。在此真跑一遍:既验证脚本本身可用(含两份产物自检:
# node 必须真能执行、agent.mjs 必须含被控端标识),也验证注入片段声明就绪。
# 注意 Linux 上不会走 strip 分支(那是 macOS 专属,见脚本内注释),
# 故「strip 后必须 adhoc 重签」这条由 macOS 本机实测覆盖,CI 不重复验证。
- name: 准备 Node 运行时 + 被控端(验证 prepare-console.mjs)
run: pnpm --filter nodeagent-console prepare:node
- name: 注入片段应声明三份资源(node + sidecar + agent)
run: |
node -e '
const g = require("./apps/console/src-tauri/tauri.bundle.gen.json");
const r = g.bundle.resources;
const keys = Object.keys(r);
if (keys.length !== 3) { console.error("资源项应为 3,实际", keys); process.exit(1); }
const vals = Object.values(r);
for (const need of ["sidecar.mjs", "agent.mjs"]) {
if (!vals.includes(need)) { console.error("缺少资源", need, vals); process.exit(1); }
}
if (!vals.includes("node") && !vals.includes("node.exe")) {
console.error("缺少 node 运行时", vals); process.exit(1);
}
console.log("✓", JSON.stringify(r));
'
# 更强的一道:不是「文件在不在」,而是「产物能不能自举」——
# 真 spawn 打包内的 node 跑 sidecar 并走一次 JSON-RPC 握手,
# 并用打包内的 node 解析一次 agent.mjs(截断/损坏会在此暴露)。
# 这类问题只在产物中暴露(源码与单测都正常)。
- name: 验证运行时 + sidecar + agent 可自举
run: node apps/console/scripts/verify-bundle.mjs --runtime
- name: 缓存 cargo 构建产物
uses: Swatinem/rust-cache@v2
with:
workspaces: crates
workspaces: apps/console/src-tauri
# clippy 单列为一步:它拦的是「能编译但不该这么写」,与测试失败是两类问题。
# -D warnings 让告警即失败 —— 该 crate 的既定标准是**零告警**。
- name: Clippy(告警即失败)
run: cargo clippy --manifest-path crates/nodeagent-client/Cargo.toml --all-targets -- -D warnings
run: cargo clippy --manifest-path apps/console/src-tauri/Cargo.toml --all-targets -- -D warnings
- name: 单测 + E2E
run: cargo test --manifest-path crates/nodeagent-client/Cargo.toml
- name: 单测(真跑 Node sidecar 进程)
run: cargo test --manifest-path apps/console/src-tauri/Cargo.toml
+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"
# 真机教训(虚拟化平台 + 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: |
+4
View File
@@ -54,3 +54,7 @@ keys/
# 本地 agent 工作数据(memory / automations 等,属本机状态,不入库)
.workbuddy/
# 被控端 / 控制端本机运行配置(含真实密钥与节点地址,绝不入库)
config.json
agent.json
+25
View File
@@ -0,0 +1,25 @@
# gitleaks 误报台账(按指纹精确忽略,不影响任何规则)
#
# ## 为什么用 .gitleaksignore 而不是 .gitleaks.toml 的 allowlist
# - `.gitleaks.toml` 的顶层 `[allowlist].paths = ['tests/**']` 会对**所有规则**放行,
# 于是 `private-key`(私钥块)、`aws-access-token` 等在测试目录下也失效 ——
# 而「为了造样本把真实私钥贴进 fixture」恰恰是最常见的泄露方式。
# - `[[rules]]` 重定义 `generic-api-key` 会**替换**内置规则(gitleaks 要求重写 regex),
# 等于自己维护一份正则,反而更容易漏。
# - 指纹是 `<commit>:<file>:<rule>:<line>`,**只忽略这一条**,其余一概照旧。
#
# ## 逐条核对记录(2026-10-17)
# 4 条命中全部位于测试文件,值均为明显的递增占位(含 `0123456789abcdef` 模式):
# sec-e2e-key-0123456789abcdef0123456789
# test-key-0123456789abcdef
# fs-key-0123456789abcdef0123456789abcdef
# e2e-test-key-0123456789abcdef0123456789abcdef
# 已逐一核对:**均不等于被控端真实 PSK,均不含 64 位十六进制串** → 判定为误报。
#
# ## 若日后有人在此文件里追加指纹
# 必须先做同样的核对:确认该值不是真实凭据。**这个文件不是「让 CI 变绿」的开关。**
#
0d59d6032b96c42f9eda380c4b6c2e95d253f856:tests/e2e/run.mjs:generic-api-key:906
f4c605cc6d26e85c81b50e10da3a466f755d8221:tests/unit/hmac.test.mjs:generic-api-key:6
4ff1b4fd9021755d7ed060da5baadc5618dcbc3d:tests/e2e/run.mjs:generic-api-key:30
77c024703abf5ac07e63f73c551f623539ff0856:tests/e2e/run.mjs:generic-api-key:21
+1033 -5
View File
File diff suppressed because it is too large. Load diff
+92 -37
View File
@@ -34,7 +34,7 @@ nodeagent 把一台 Windows 机器的能力**标准化成一组可授权、可
| **状态采样** | `log.query` · `monitor.start` · `monitor.report` · `monitor.stop` · `monitor.list` · `monitor.delete` | 日志**在被控端侧过滤**(不上传整份)/ 定时采样落盘 + **摘要回看**(断几次、最长断多久、CPU/内存值域) |
| **审计锚定** | `system.audit.head` · `system.audit.anchor` | 链头可读 + 锚点**追加**落盘,让「有 root 者整链重写」可被发现 |
## 快捷键(hotkey)控制 —— v1.5
## 快捷键(hotkey)控制 —— v1.5.0
`input.key.press` 支持 **4 种输入形式**(一次只用一种):
@@ -84,7 +84,7 @@ OEM 符号键(`oem_plus`/`oem_comma`/`oem_period`… → 支持 `win+d`、`ctr
> MCP 侧同名工具为 `na_key`(`action: "press"`),支持同一套参数。
## 等待条件 gui.await —— v1.6
## 等待条件 gui.await —— v1.6.0
GUI 操作从「按一下→睡几秒→截图碰运气」变成**可断言**流程:
@@ -105,7 +105,7 @@ nodeagent --node win invoke gui.await --args '{"condition":"file","path":"C:\\lo
**典型闭环**:`await control(text:"立即安装")` → `mouse.click(x, y)` → `await control(text:"完成", timeout_ms:60000)` → `click` → `await absent(text:"安装中")`。
### 属性谓词与组合(v1.7)
### 属性谓词与组合(v2.0.0)
`screen.find` 现在返回 UIA 属性,`gui.await` 可据此等**状态**而不只是「出现」:
@@ -130,7 +130,7 @@ nodeagent --node win await --any_of '[{"condition":"window","title":"安装完
---
## 状态采样:日志过滤与定时采样 —— v1.8
## 状态采样:日志过滤与定时采样 —— v2.0.0
治**间歇性**问题(代理不通、杀软拦截、端口时开时闭)—— `system.status` 只能看当下快照,
而 `event.watch` 只解决「有事件时通知」,没有「**持续记录并回看**」。
@@ -151,7 +151,7 @@ nodeagent --node win monitor stop probe
---
## 审计链外部锚定 —— v25
## 审计链外部锚定 —— v2.0.0
链内哈希只能发现「改一条」。**有 root 的攻击者可以整链重写** —— 从零重建一份自洽的假日志,
`audit.verify` 照样通过。外部锚定是唯一能戳破它的手段:把链头记到**链外**。
@@ -168,7 +168,7 @@ nodeagent --node win audit anchor --note "每日例行" # 追加一个锚点
> 真正发挥价值的前提:把锚点文件指向**另一台机器 / 网盘同步目录**。
> 本机文件被整盘重写时,锚点也随之消失 —— 那就等于没锚。
**51 项能力** · **41 个 MCP 工具** · **269 项单元测试** · **30 项端到端用例** · **9 项 Rust 单测**(CI 在真实 Windows / Linux runner 上验证)
**52 项能力** · **42 个 MCP 工具** · **483 项单元测试** · **50 项端到端用例**(34 通用 + 16 Windows 专属) · **23 项 Rust 壳单测**(真跑 Node sidecar)(CI 在真实 Windows / Linux runner 上验证)
> 关键里程碑:**GUI 语义**(`window.list` + `screen.find`,UIA 找不到自动降级 OCR)
> 让 AI 从「看得到画面但读不懂界面」变成「按名字取坐标点下去」。
@@ -195,7 +195,7 @@ pnpm test:rust # Rust 控制端客户端(需 cargo)
1. 在控制端构建安装包(**需在项目目录执行**):
```bash
cd <项目目录> # 例如 ~/WorkBuddy/NodeAgent/nodeagent
cd <项目目录> # 例如 ~/projects/nodeagent
pnpm pack:win
# 或从任意目录调用:
@@ -340,11 +340,20 @@ nodeagent pull "C:\big.iso" --out ./big.iso # 下载(大文件自动
由控制台经 loopback 直连(复用同一个 Agent,不做第二套被控端实现)。
技术栈 Tauri 2(Rust 壳 + 系统 WebView)+ Vue 3 + TypeScript + Pinia + Naive UI。
协议客户端是 `crates/nodeagent-client`(Rust 复刻 `@nodeagent/client`,
协议客户端由 **Node sidecar**(`apps/console/sidecar/`)承担(复用 `@nodeagent/client`,
Tauri 侧经 Cargo path 依赖),**不引入第二套协议实现**。
**打开即用、全程不碰命令行**:应用自带 Node 运行时与被控端脚本,
默认页面就是「本机服务」——点按钮即可启动 / 停止 / 重启本机被控端,
并支持检查更新、查看日志、后台常驻(托盘)、开机自启。
**界面**:导航为**纯文字**(不使用图标);配色为两套中性灰阶主题 ——
「简约白」与「高级灰」,侧栏底部一键切换。
| 页面 | 能力 |
|---|---|
| **本机服务**(默认) | 本机被控端启停/重启、连接本机、节点 ID 与密钥、开机自启、后台运行、异常自动重启、检查并应用更新 |
| **服务日志** | 被控端 stdout/stderr 实时推送、关键字过滤、暂停接收、导出、清空 |
| 设备管理 | 连接/断开、保存多台设备、能力清单与握手元信息、实时日志 |
| 状态总览 | CPU / 内存 / 磁盘 / 网卡,3s 轮询 |
| 终端 | `system.shell.exec`(可设超时)、历史回放 |
@@ -354,15 +363,35 @@ Tauri 侧经 Cargo path 依赖),**不引入第二套协议实现**。
| 审计 | `system.audit.list` 筛选 + `system.audit.verify` 链完整性校验 + 导出 JSONL |
```bash
pnpm -r build # 含 console 前端
cd apps/console && pnpm tauri dev # 开发态(起 Vite + Tauri 窗口)
cd apps/console && pnpm tauri build # 打包(macOS 实测 .app ≈ 6.8 MiB)
pnpm -r build # 含 console 前端
cd apps/console && pnpm tauri dev # 开发态(起 Vite + Tauri 窗口)
pnpm --filter nodeagent-console bundle # 打包(含 sidecar + Node 运行时 + 被控端脚本)
# macOS 实测:.app 92.2 MiB / .dmg 33.78 MiB(分发件,其中 Node 运行时 86MB)
```
Windows 版由 CI 产出(`.github/workflows/console-windows.yml`),**双架构各一套**:
| 架构 | runner | 安装包 | 实测体积 |
|---|---|---|---|
| x64 | `windows-latest` | `.msi`(企业批量部署)+ `-setup.exe`(NSIS,双击安装) | 34.2 MiB / 23.2 MiB |
| arm64 | `windows-11-arm` | `-setup.exe`(NSIS) | 19.5 MiB |
> 为什么必须两套:控制台内含 Node 运行时,且要用它**托起本机被控端**(即自己就是 TLS 服务端)。
> 若在 ARM Windows 上装到 x64 的运行时,会**能启动、能监听,但 TLS 握手不完成** ——
> 客户端只见超时,且开发机上 100% 复现不出。CI 里为此加了三道断言:
> 「构建机架构 ⇄ 目标架构」、「随包 `node.exe` 的 PE Machine」、
> 以及**解开安装包查内容**(三样随包资源齐全 + 包内 `node.exe` 的 PE Machine 对得上 ——
> 前两条查的是打包**前**的中间产物,这一条查的才是**最终交付物**;
> 实测解出 x64 `node.exe` 82.9 MB / `0x8664`、arm64 73.5 MB / `0xaa64`)。
⚠️ 平台差异:`app.*` 与 `system.service.list` **仅在被控端为 Windows 时可用**,
非 Windows 被控端会返回 `E_UNSUPPORTED_PLATFORM`。控制台连接后会探测
`system.info.os` 并**事前禁用**这些入口,而不是发出注定失败的请求。
⚠️ 控制台**已自带** Node 运行时与被控端脚本,目标机器无需预装 Node.js,也无需先手工安装被控端;
但**开机自启与被控端的图形能力**要求运行在**登录用户会话**中(Windows 计划任务用
`Interactive` + 登录用户;SYSTEM 会话 0 没有交互桌面,截图/窗口类能力会失败)。
## 安全模型
三层防护,逐层收窄权限:
@@ -451,10 +480,28 @@ nodeagent keygen [--id mac_01] # 生成 Ed25519 密钥 + AC
```bash
nodeagent restart [--delay 2000] # 受控重启被控端(配置变更后让它生效)
nodeagent deploy <agent.mjs> [--path <远端路径>] # 一键升级(备份 → 上传 → 重启 → 复验)
nodeagent rollback [--dry-run] # 从 LKG 回滚(指纹比对 → 覆盖 → 重启 → 复核)
nodeagent daemon [start|stop|status] # 常驻连接池(批量操作省去每次握手)
nodeagent fanout <能力名> [--nodes a,b] [--args JSON] # 多设备并发下发并汇总
```
> **回滚的能力圈边界**:`rollback` 是 RPC 命令,**需被控端可达**——救的是「新版本能启动但需回退」。
> 新版本「完全起不来」(无监听可连)时:被控端看门狗会在启动连续失败 N 次后自动从 LKG 恢复
> (`scripts/startup-guard.mjs`);仍不行再走带外恢复(`docs/DELIVERY-PIPELINE.md` 附录 C)。
> 自动回滚刻意不做,理由见该文 §6。
**看门狗(被控端自启链上的一层)**:被控端由外层 supervisor 看守,启动连续失败 N 次(默认 3)自动从 LKG 恢复入口文件:
| 平台 | supervisor | 接入方式 |
|---|---|---|
| Windows | 计划任务 | `scripts/install.ps1` 把启动动作包一层 `scripts/startup-guard.mjs`(`RestartCount 10`) |
| Linux | systemd | `scripts/install-linux.sh` 的 unit `ExecStart` 包一层 |
| macOS | launchd | 无安装器,手工接法见 `docs/DEVELOPMENT.md` §7.4 |
安全边界:**无 LKG / 无指纹匹配的备份时只记日志放行,绝不擦除安装目录**;恢复目标恒为 LKG
(单一、已验证,不做「择优回滚」)。看门狗自身「裸环境可跑」——零项目依赖,只依赖
Node ≥22 内置模块 + 同目录纯函数核心,随安装包/便携包分发。
### 长任务与剪贴板
```bash
@@ -506,7 +553,7 @@ nodeagent mouse drag 300 200 700 500 # 拖拽(拖文件/框选
| `na_clip` | 读写剪贴板文本或图片 |
| `na_fs_list` · `na_fs_read` · `na_fs_write` · `na_fs_stat` | 读目录 / 读文件 / 写文件 / 元信息 |
| `na_restart` | 受控重启被控端 |
| `na_audit` · `na_discover` · `na_nodes` · `na_use` | 查审计、发现设备、多设备切换 |
| `na_audit` · `na_audit_l2` · `na_discover` · `na_nodes` · `na_use` | 查审计(`na_audit_l2` = 系统级 L2,仅 Windows 被控端)、发现设备、多设备切换 |
| `na_event_watch` · `na_event_poll` · `na_event_list` · `na_event_unwatch` | 事件订阅;`notify:true` 走 MCP 日志通知实时推送,否则用 `na_event_poll` 拉取 |
| `na_macro_run` | 回放 GUI 宏(步骤序列,逐步校验) |
| `na_metrics` | 成功指标(闭环率 / 装软件率 / P95 / 拦截率)+ 达标判定 |
@@ -605,46 +652,54 @@ nodeagent/
├── scripts/ # install.cmd / control.cmd(一键入口)/ install.ps1 / control.ps1 / verify.mjs
├── tests/e2e/ # 端到端测试
├── docs/ # 开发文档
├── PRD.md # 产品需求文档
├── docs/ # 项目文档(含 PRD)
└── README.md
```
## 路线图
| 阶段 | 目标 | 状态 |
> 下表「**批次**」是**开发阶段的内部代号**(一批相关改动的集合),**不是版本号** ——
> 项目的唯一版本号是 semver(见 `git tag` / `package.json`)。批次与发布版本的对应关系
> 及书写规范见 [`docs/VERSIONING.md`](docs/VERSIONING.md) §0。
| 阶段(开发批次) | 目标 | 状态 |
|---|---|---|
| **v1** | 命令级接管 + 装软件 + 查状态 + 基本鉴权 + CLI + MCP | ✅ |
| **v2** | 图形接管(`screen.*` 截屏 + `input.*` 键鼠)+ 输入控制开关 | ✅ |
| **v3** | 零信任:Ed25519 身份认证 + 能力级 ACL | ✅ |
| **v3+** | 审计日志(留痕 + 查询)+ 调用限速 | ✅ |
| **v4** | 无感体验:局域网自动发现 + 断线自动重连 | ✅ |
| **v5** | 多设备管理 + 文件传输(分块 / 大文件 / 原子写) | ✅ |
| **v6** | Hub 中转:跨网段 / 公网接入(被控端主动外连,穿 NAT) | ✅ |
| **v7** | **自持**:受控重启(配置变更后让自身生效,不再丢连接) | ✅ |
| **v8** | **GUI 语义**:`window.list` / `window.focus` / `screen.find`(UIA 按名取坐标) | ✅ |
| **v9** | `screen.find` **OCR 兜底**(自绘 UI 也能定位)+ MCP 工具同步 | ✅ |
| **v10** | **长任务异步化**(`system.task.*`)+ 剪贴板 + CLI 常驻连接池 daemon | ✅ |
| **v11** | **安全加固**:私钥入系统密钥库(Keychain/DPAPI)+ 审计链防篡改 + ACL 细化(IP/时段/按能力限速) | ✅ |
| **v11+** | **操作面补完**:鼠标拖拽 / 录屏 / 剪贴板图片 / 多设备并发 / 一键升级 | ✅ |
| **v12** | **事件订阅**(file/process/net 主动推送)+ **GUI 宏**(步骤序列回放)+ **Hub 并发多控制端** + 设备分组 | ✅ |
| **v12.1** | **Hub 侧按控制端授权**(多令牌 + 按设备白/黑名单 + 注册准入) | ✅ |
| **v12.2** | GUI **等待/重试语义**(`wait_ms`)—— 界面加载/动画不再假失败 | ✅ |
| **v12.3** | **macOS 端 GUI 对齐**:AppleScript 窗口枚举/聚焦 + Vision OCR 元素定位(含 Retina 坐标换算) | ✅ |
| **v12.4** | **事件直达 MCP 宿主**(实时推送 + 拉取双通路) | ✅ |
| **v13** | **成功指标聚合**(PRD 2.2 四项)+ 依真实数据修正口径(时延分层) | ✅ |
| **v14** | **PowerShell 常驻助手** —— GUI 能力提速 4~25 倍(预加载 C#/UIA,免每次现编译) | ✅ |
| **v15** | `screen.find` **图像模板匹配**(纯图标/无文字控件盲区)+ 一堆真机根因修复 | ✅ |
| **待办** | CI 单元测试步骤已就绪(分支 `ci/workflow-unit-tests`,**需 `gh auth refresh -s workflow` 授权后推送**);GUI 图像锚点匹配(自绘 UI 更稳) | 🚧 |
| **批次 1** | 命令级接管 + 装软件 + 查状态 + 基本鉴权 + CLI + MCP | ✅ |
| **批次 2** | 图形接管(`screen.*` 截屏 + `input.*` 键鼠)+ 输入控制开关 | ✅ |
| **批次 3** | 零信任:Ed25519 身份认证 + 能力级 ACL | ✅ |
| **批次 3+** | 审计日志(留痕 + 查询)+ 调用限速 | ✅ |
| **批次 4** | 无感体验:局域网自动发现 + 断线自动重连 | ✅ |
| **批次 5** | 多设备管理 + 文件传输(分块 / 大文件 / 原子写) | ✅ |
| **批次 6** | Hub 中转:跨网段 / 公网接入(被控端主动外连,穿 NAT) | ✅ |
| **批次 7** | **自持**:受控重启(配置变更后让自身生效,不再丢连接) | ✅ |
| **批次 8** | **GUI 语义**:`window.list` / `window.focus` / `screen.find`(UIA 按名取坐标) | ✅ |
| **批次 9** | `screen.find` **OCR 兜底**(自绘 UI 也能定位)+ MCP 工具同步 | ✅ |
| **批次 10** | **长任务异步化**(`system.task.*`)+ 剪贴板 + CLI 常驻连接池 daemon | ✅ |
| **批次 11** | **安全加固**:私钥入系统密钥库(Keychain/DPAPI)+ 审计链防篡改 + ACL 细化(IP/时段/按能力限速) | ✅ |
| **批次 11+** | **操作面补完**:鼠标拖拽 / 录屏 / 剪贴板图片 / 多设备并发 / 一键升级 | ✅ |
| **批次 12** | **事件订阅**(file/process/net 主动推送)+ **GUI 宏**(步骤序列回放)+ **Hub 并发多控制端** + 设备分组 | ✅ |
| **批次 12.1** | **Hub 侧按控制端授权**(多令牌 + 按设备白/黑名单 + 注册准入) | ✅ |
| **批次 12.2** | GUI **等待/重试语义**(`wait_ms`)—— 界面加载/动画不再假失败 | ✅ |
| **批次 12.3** | **macOS 端 GUI 对齐**:AppleScript 窗口枚举/聚焦 + Vision OCR 元素定位(含 Retina 坐标换算) | ✅ |
| **批次 12.4** | **事件直达 MCP 宿主**(实时推送 + 拉取双通路) | ✅ |
| **批次 13** | **成功指标聚合**(PRD 2.2 四项)+ 依真实数据修正口径(时延分层) | ✅ |
| **批次 14** | **PowerShell 常驻助手** —— GUI 能力提速 4~25 倍(预加载 C#/UIA,免每次现编译) | ✅ |
| **批次 15** | `screen.find` **图像模板匹配**(纯图标/无文字控件盲区)+ 一堆真机根因修复 | ✅ |
| **批次 16 ~ 26** | 网络两阶段提交 · 音频控制 · 构建指纹 · 自更新 · 证书钉住 · 每客户端 PSK · 连接层三防护 · 零信任安装 · 审计锚定 | ✅ |
| **批次 27 ~ 29** | **语义可断言**(`gui.await` + UIA 属性 + `where` 谓词 + `any_of`)· **状态可回看**(`log.query` + `monitor.*`) | ✅ |
| **发布** | **v2.0.2**(2026-10-11):51 项能力 · 274 单测 · Windows/Rust CI 全绿 —— 本版为**资产修复**(v2.0.1 的 `arm64` 包内实为 x64 node,详见 CHANGELOG) | ✅ 已发布 |
## 文档
- [`docs/SECURITY.md`](docs/SECURITY.md) —— **安全模型与加固指南**(钥匙=root、证书指纹钉住、网段白名单、应急处置)
- [`docs/VERSIONING.md`](docs/VERSIONING.md) —— **版本与兼容性契约**(升级顺序、什么算破坏性、发版流程、更新方式)
- [`docs/REMOTE-LIMITS.md`](docs/REMOTE-LIMITS.md) —— 远程能力边界与真实故障案例(含杀软误拦)
- [`docs/DEPLOY-LINUX.md`](docs/DEPLOY-LINUX.md) —— **Linux 被控端部署**(Debian/Ubuntu/NAS 系统;含实测能力清单与排查)
- [`docs/VM-VERIFICATION.md`](docs/VM-VERIFICATION.md) —— **无 Windows 真机时,用宿主虚拟化平台的 Win11 ARM 虚拟机补验证**(含逐条命令与结论标注规则)
- [`CHANGELOG.md`](CHANGELOG.md) —— **变更日志**(未发布版本的内容汇总 / 历史版本)
- [`docs/ROADMAP.md`](docs/ROADMAP.md) —— **当前进度盘点与下一步路线**(2026-10-07 基线:待办收尾 / 语义可断言 / 状态采样 / 安全残余)
- [产品需求文档(PRD)](./PRD.md)
- [产品需求文档(PRD)](./docs/PRD.md)
- [开发文档(DEVELOPMENT)](./docs/DEVELOPMENT.md) —— 协议细节、能力 schema、安全模型、各端实现指南
- [**跨机接管的边界与局限**](./docs/REMOTE-LIMITS.md) —— 哪些问题能远程修、哪些必须人工,附真实故障诊断案例
- [**实战复盘:短板与改进优先级**](./docs/RETROSPECTIVE.md) —— 基于真机使用的短板分析(GUI 语义 / 编码 / 自愈 / 可观测性)。**写于 2026-09-28(19 项能力时),多数 P0/P1 已解决 —— 最新状态见 [ROADMAP](./docs/ROADMAP.md) §3 对照表**
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/agent",
"version": "2.0.0",
"version": "2.1.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
@@ -20,7 +20,7 @@
},
"dependencies": {
"@nodeagent/protocol": "workspace:*",
"selfsigned": "^2.4.1",
"selfsigned": "^5.5.0",
"ws": "^8.18.0"
},
"devDependencies": {
+88 -64
View File
@@ -2,6 +2,13 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSy
import { createHash } from 'node:crypto';
import { join } from 'node:path';
import { agentDir } from './config.js';
import {
judgeAnchors,
parseAnchorsFromJsonl,
AUDIT_TYPES,
type AnchorComparison,
type AnchorRecord,
} from '@nodeagent/protocol';
export type AuditType =
| 'agent.start'
@@ -18,8 +25,44 @@ export type AuditType =
| 'net.deny'
/** v19:agent 自更新(拉取式) */
| 'agent.update'
/** v23:连接空闲被主动断开 */
| 'session.idle_close';
/**
* v2.1.0:**L2 系统级审计事件**(Windows Security 日志归一化副本)。
* 真源注释见 `packages/protocol/src/audit-types.ts`——不复用 `agent.start`:
* L2 策略操作与进程生命周期无关,混入会污染两边的统计与筛选。
*/
| 'agent.l2'
/** v2.0.0:连接空闲被主动断开 */
| 'session.idle_close'
/**
* v2.0.3:**被拒的请求**统一留痕。
*
* 为什么单独一个类型:原来「畸形报文 / 未知方法 / 未认证调用 / 参数非法 / 能力不存在」
* 这类拒绝**完全不留痕**(U-01)—— 被扫描、被爆破、被误用时事后无从复盘。
* 归到 `invoke` 会污染正常调用统计,归到 `acl.denied` 又不准确(很多拒绝与 ACL 无关)。
*/
| 'request.rejected'
/**
* v2.0.3:**进程级未捕获错误 / 未处理 Promise 拒绝**。
*
* 为什么单独一个类型:这类错误是「最后一次兜底拦截」(`index.ts` 的
* `unhandledRejection` / `uncaughtException`)。既然选择了**不退出进程**,
* 就必须留下证据 —— 归到 `request.rejected` 会让「被扫描」与「我们自己有 bug」
* 混在同一类里,事后无法区分。
*/
| 'agent.error';
/**
* 编译期守卫(v2.0.3,修 C-01):本文件这份联合类型必须与 `@nodeagent/protocol` 的
* `AUDIT_TYPES` **双向一致**。
*
* 少了 → 「写进日志但协议不认」(控制台筛不到,用户以为没发生);
* 多了 → 「协议认但从不写」(下拉框里是个永远为空的选项)。
* 两种漂移都**不报错**,所以必须让 `tsc` 来说话。真正的真源在
* `packages/protocol/src/audit-types.ts`,此处保留逐条注释只为记录「哪个版本引入的」。
*/
type AssertExact<A, B> = [A] extends [B] ? ([B] extends [A] ? true : never) : never;
const _auditTypeInSyncWithProtocol: AssertExact<AuditType, (typeof AUDIT_TYPES)[number]> = true;
void _auditTypeInSyncWithProtocol;
/** 一条审计记录(JSONL 单行)。 */
export interface AuditEntry {
@@ -35,6 +78,13 @@ export interface AuditEntry {
error?: string | null;
/** 参数摘要(sha256 前缀),用于比对而不泄露内容 */
args_digest?: string;
/**
* v2.1.0(A-P0-4):参数序列化后的**总字节数**。
* 与 `args_digest_truncated` 同时出现即表示「摘要只覆盖前 64KB」。
*/
args_bytes?: number;
/** v2.1.0(A-P0-4):摘要是否为截断哈希(序列化超过 64KB 时只哈希前 64KB)。 */
args_truncated?: boolean;
/** 参数预览(默认不记录;开启后自动脱敏) */
args_preview?: string;
reason?: string;
@@ -131,12 +181,34 @@ function sanitize(value: unknown, depth = 0): unknown {
return value;
}
/**
* 参数摘要阈值(字节):超过即只哈希前 64KB。
*
* ⚠️ A-P0-4(2026-10-25):原实现每次 invoke 都全量 `JSON.stringify` + sha256。
* `fs.write` 的 data 可达数 MB(协议单帧,ws 默认 maxPayload 隐式放行)——
* 同步序列化 + 哈希一个 8MB 字符串 ≈ 数十毫秒级 CPU,且**阻塞事件循环**:
* 期间 GUI 注入(`input.*`)、事件推送、保活 ping 全部延迟。
* 截断后:摘要口径从「全量哈希」变为「前 64KB 哈希」,**只影响审计摘要,
* 不影响任何安全属性**(摘要用途是比对与去泄密,从来不是完整性证明)。
* 总长记入 `args_bytes`,截断记入 `args_truncated`,语义不断代。
*/
const ARGS_DIGEST_MAX_BYTES = 64 * 1024;
/** 生成参数摘要与(可选)脱敏预览 —— 字段名与 AuditEntry 对齐,便于展开。 */
export function describeArgs(args: unknown): { args_digest: string; args_preview?: string } {
export function describeArgs(args: unknown): {
args_digest: string;
args_bytes?: number;
args_truncated?: boolean;
args_preview?: string;
} {
const json = JSON.stringify(args ?? {});
const digest = `sha256:${createHash('sha256').update(json).digest('hex').slice(0, 16)}`;
if (!opts.logArgs) return { args_digest: digest };
return { args_digest: digest, args_preview: JSON.stringify(sanitize(args)) };
const bytes = Buffer.byteLength(json, 'utf8');
// 截断按**字节**切(多字节字符下按字符切会得到非确定性的 64KB 边界)
const payload = bytes > ARGS_DIGEST_MAX_BYTES ? Buffer.from(json, 'utf8').subarray(0, ARGS_DIGEST_MAX_BYTES) : json;
const digest = `sha256:${createHash('sha256').update(payload).digest('hex').slice(0, 16)}`;
const base = bytes > ARGS_DIGEST_MAX_BYTES ? { args_bytes: bytes, args_truncated: true } : {};
if (!opts.logArgs) return { args_digest: digest, ...base };
return { args_digest: digest, ...base, args_preview: JSON.stringify(sanitize(args)) };
}
/** 写入一条审计记录。任何异常都被吞掉 —— 审计绝不阻断主流程。 */
@@ -336,7 +408,7 @@ export function readAudit(query: AuditQuery = {}): { entries: AuditEntry[]; tota
}
// ---------------- v25 审计链外部锚定 ----------------
// ---------------- v2.0.0 审计链外部锚定 ----------------
//
// ## 为什么需要
// 审计链的哈希使**链内**篡改可被发现(改一条就要重算其后全部)。但「有 root 的攻击者」
@@ -389,14 +461,9 @@ export function computeAuditHead(): AuditHead {
};
}
export interface AnchorRecord {
ts: number;
entries: number;
head_hash: string | null;
head_ts: number | null;
rotated_segments: number;
note?: string;
}
// 锚点记录与比对结果的定义**移到了 protocol**(唯一实现,控制端脚本也用它)——
// 见 packages/protocol/src/audit-anchor.ts 顶部的说明。此处 re-export 以保持既有导入路径可用。
export type { AnchorRecord, AnchorComparison } from '@nodeagent/protocol';
/** 锚点文件路径(默认与审计同目录)。 */
export function anchorFilePath(custom?: string): string {
@@ -429,13 +496,6 @@ export function anchorAudit(opts: { path?: string; note?: string } = {}): {
return { file, record: rec, total_lines: total };
}
export interface AnchorComparison {
ok: boolean;
anchors_checked: number;
latest?: AnchorRecord;
verdict: string;
detail?: Record<string, unknown>;
}
/**
* 与最近一条锚点比对,判断当前链是否与锚定时刻自洽。
@@ -450,52 +510,16 @@ export function compareWithAnchors(customPath?: string): AnchorComparison {
if (!existsSync(file)) {
return { ok: true, anchors_checked: 0, verdict: '尚无锚点:请先调用 system.audit.anchor 建立外部锚点' };
}
let anchors: AnchorRecord[] = [];
let anchors: AnchorRecord[] | null = null;
try {
anchors = readFileSync(file, 'utf8')
.split('\n')
.filter((l) => l.trim().length > 0)
.map((l) => JSON.parse(l) as AnchorRecord);
anchors = parseAnchorsFromJsonl(readFileSync(file, 'utf8'));
} catch {
return { ok: false, anchors_checked: 0, verdict: '锚点文件损坏,无法比对' };
}
if (anchors.length === 0) {
if (!anchors) {
return { ok: true, anchors_checked: 0, verdict: '锚点文件为空' };
}
const latest = anchors[anchors.length - 1]!;
const head = computeAuditHead();
if (head.entries < latest.entries && head.rotated_segments <= latest.rotated_segments) {
return {
ok: false,
anchors_checked: anchors.length,
latest,
verdict: '**疑似整链重写/回滚**:当前条目数少于锚点,且没有发生轮转来解释',
detail: { anchored_entries: latest.entries, current_entries: head.entries, anchored_hash: latest.head_hash, current_hash: head.head_hash },
};
}
if (head.rotated_segments === latest.rotated_segments && head.entries === latest.entries) {
if (head.head_hash !== latest.head_hash) {
return {
ok: false,
anchors_checked: anchors.length,
latest,
verdict: '**链头哈希与锚点不一致**(条目数相同却哈希不同 → 该段被重写)',
detail: { anchored_hash: latest.head_hash, current_hash: head.head_hash, entries: head.entries },
};
}
return { ok: true, anchors_checked: anchors.length, latest, verdict: '与锚点完全一致(条目数、轮转段数、链头哈希三者相符)' };
}
return {
ok: true,
anchors_checked: anchors.length,
latest,
// ⚠️ 注意:锚定动作**自身**也会被记入审计(server 层统一记录所有 invoke),
// 所以「刚锚定就比对」几乎必然看到"已增长"——这是正常现象,不是篡改。
// 想看到"完全一致",需在同一时刻不再产生新审计(实际很难,属预期)。
verdict:
'链已增长(或发生轮转)—— 锚点仍在链上,无需人工核对到该锚点为止的部分。' +
'(刚锚定就比对通常显示"已增长",因为锚定调用自身也会写一条审计。)',
detail: { anchored_entries: latest.entries, current_entries: head.entries, rotated_delta: head.rotated_segments - latest.rotated_segments, anchored_hash: latest.head_hash, current_hash: head.head_hash },
};
// 判定逻辑在 protocol(judgeAnchors)—— 与「控制端对云盘锚点」的比对共用同一份实现,
// 避免两处规则漂移(那会导致同一份日志两端结论不同)。
return judgeAnchors(computeAuditHead(), anchors);
}
+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;
+9 -1
View File
@@ -1,6 +1,8 @@
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { IS_WINDOWS, execCommand } from '../util/exec.js';
import { runPowerShellSmart } from '../util/ps-helper.js';
import { shLit } from '../util/quote.js';
import { assertGuiPlatform } from '../util/platform.js';
type Args = Record<string, unknown>;
@@ -132,7 +134,9 @@ async function runWindows(body: string, timeoutMs = 30_000): Promise<string> {
/** macOS:osascript 原生音量控制(静音用 `output muted`,音量用 `output volume`)。 */
async function runMac(script: string): Promise<string> {
const r = await execCommand({ command: `osascript -e ${JSON.stringify(script)}`, timeoutMs: 15_000 });
// ⚠️ AppleScript 源码里含用户可控文本 → 必须走 shell 单引号字面量。
// 曾用 JSON.stringify(双引号)→ bash 会在双引号内展开 `$(...)` 与反引号(注入面)。
const r = await execCommand({ command: `osascript -e ${shLit(script)}`, timeoutMs: 15_000 });
if (r.exit_code !== 0) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '音频控制失败(osascript)', {
detail: (r.stderr || r.stdout).slice(0, 300),
@@ -160,6 +164,8 @@ async function macState(): Promise<{ muted: boolean; volume: number }> {
const AUDIO_BACKEND_WIN = 'NAudio/CoreAudioAPI';
export async function audioGet(_args: Args): Promise<unknown> {
// 该能力仅在 Windows/macOS 有实现(Linux 端未实现)
assertGuiPlatform('audio.get');
if (IS_WINDOWS) {
const state = parseAudioState(await runWindows('[NAAudio]::Get()'));
return { ...state, platform: 'windows', backend: AUDIO_BACKEND_WIN };
@@ -175,6 +181,8 @@ export async function audioGet(_args: Args): Promise<unknown> {
// ---------------- system.audio.set ----------------
export async function audioSet(args: Args): Promise<unknown> {
// 该能力仅在 Windows/macOS 有实现(Linux 端未实现)
assertGuiPlatform('audio.set');
const muteArg = args['mute'];
const volumeArg = args['volume'];
const hasMute = typeof muteArg === 'boolean';
+364
View File
@@ -0,0 +1,364 @@
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { join } from 'node:path';
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { execCommand } from '../util/exec.js';
import { assertWindows } from '../util/platform.js';
import { agentDir } from '../config.js';
import { audit } from '../audit.js';
/**
* L2 系统级审计兜底(2026-10-17)。
*
* ## 它补的是 L1 结构上记不到的那块
* L1 是「agent 自己记的」:只看得见经过自己 RPC 的调用。
* 攻击者绕过 agent 直接跑系统命令、或干脆把 agent 停掉 —— L1 全都看不到。
* L2 采集**操作系统**的审计事件,补的正是这块。
*
* ## ⚠️ 信任边界(不要夸大它的作用)
* 采集器与被审计对象**同进程同权限** —— 攻击者能停 agent 就能停采集器。
* 故本地 `audit-l2.log` 只是一份**归一化副本**,防篡改价值有限。
* 真正的独立性来自**把记录转发出机**(与 SECURITY.md §4.8 的锚定同一思路)。
* 本模块不实现自动转发,只提供导出接口。
*
* ## 为什么 Windows 侧选内置 auditpol 而不是 Sysmon
* - Sysmon 是第三方驱动:需单独分发、需重启生效、版本行为有差异;
* - 内置高级审计策略是 Windows 自带的,零依赖即可立起来;
* - 策略:检测到装了 Sysmon 就并用,没装也不尝试下载(不在本仓库职责内)。
*
* ## 平台现状
* - Windows:**已落地**(status / enable / collect / verify)
* - Linux:仅设计(auditd,需预装)—— 见 docs/AUDIT-L2.md
* - macOS:仅设计,**ESF 需 Apple entitlement,个人自签无法启用** —— 不写假装能用的代码
*/
type Args = Record<string, unknown>;
/** 归一化后的事件类别(跨平台枚举,详见 docs/AUDIT-L2.md §4)。 */
export type L2Kind =
| 'process.create'
| 'process.exit'
| 'auth.success'
| 'auth.failure'
| 'file.access';
export interface L2Entry {
/** 事件发生时间(来自 OS,不是采集时刻) */
ts: number;
src: 'windows-security' | 'sysmon' | 'linux-auditd' | 'macos-esf';
/** OS 原始事件 ID —— 保留原始值以便人工核对 */
event_id: number | string;
kind: L2Kind;
subject?: string;
object?: string;
detail?: Record<string, unknown>;
/** 采集时刻。与 ts 相差过大说明期间离线或 agent 未运行 */
collected_at: number;
prev?: string;
hash?: string;
}
const GENESIS = 'genesis';
/** L2 独立落盘,不与 L1 混 —— 混了就无法区分「agent 记的」与「OS 记的」。 */
export function l2FilePath(): string {
return join(agentDir(), 'audit-l2.log');
}
function hashEntry(record: Record<string, unknown>, prev: string): string {
const keys = Object.keys(record).sort();
const body = JSON.stringify(record, keys);
return createHash('sha256').update(`${prev}|${body}`).digest('hex').slice(0, 32);
}
function lastHashOf(): string {
try {
const p = l2FilePath();
if (!existsSync(p)) return GENESIS;
const lines = readFileSync(p, 'utf8').split('\n').filter((l) => l.trim());
const last = lines[lines.length - 1];
if (!last) return GENESIS;
return (JSON.parse(last) as L2Entry).hash ?? GENESIS;
} catch {
return GENESIS;
}
}
/** 追加一批 L2 记录(带独立哈希链)。 */
function appendL2(entries: Omit<L2Entry, 'prev' | 'hash' | 'collected_at'>[]): number {
const p = l2FilePath();
mkdirSync(agentDir(), { recursive: true });
let prev = lastHashOf();
const now = Date.now();
let n = 0;
for (const e of entries) {
const full = { ...e, collected_at: now, prev } as unknown as Record<string, unknown>;
const hash = hashEntry(full, prev);
(full as Record<string, unknown>)['hash'] = hash;
appendFileSync(p, `${JSON.stringify(full)}\n`, 'utf8');
prev = hash;
n += 1;
}
return n;
}
// ---------------- Windows:Security 事件 → 归一化 ----------------
/** Windows 事件 ID → 归一化类别。 */
const WIN_EVENT_KIND: Record<number, L2Kind> = {
4688: 'process.create',
4689: 'process.exit',
4624: 'auth.success',
4625: 'auth.failure',
4663: 'file.access',
};
const WIN_WATCHED_IDS = Object.keys(WIN_EVENT_KIND).map(Number);
/**
* 采集 Windows Security 日志。
*
* 只用 PowerShell 内置 cmdlet(Get-WinEvent),不引外部依赖。
* @param sinceMs 起始时间(Unix ms);默认 24 小时内
* @param limit 上限,防止一次拉爆(Security 日志可能极多)
*/
async function collectWindows(sinceMs: number, limit: number): Promise<Omit<L2Entry, 'prev' | 'hash' | 'collected_at'>[]> {
// 用 ISO8601 传时间,避免区域设置导致的日期解析差异
const sinceIso = new Date(sinceMs).toISOString();
const idList = WIN_WATCHED_IDS.join(',');
const ps = [
`$ErrorActionPreference = 'Stop'`,
`$ids = @(${idList})`,
`$since = (Get-Date '${sinceIso}').ToUniversalTime()`,
`$evs = Get-WinEvent -FilterHashtable @{LogName='Security'; Id=$ids; StartTime=$since} -MaxEvents ${limit} -ErrorAction SilentlyContinue`,
`if ($null -eq $evs) { Write-Output '[]'; exit 0 }`,
`$out = foreach ($e in $evs) {`,
` $x = @{ id = $e.Id; t = $e.TimeCreated.ToUniversalTime().ToString('o'); msg = $e.Message }`,
` try { $x.sub = $e.Properties[0].Value.ToString() } catch { $x.sub = '' }`,
` $x | ConvertTo-Json -Compress -Depth 3`,
`}`,
`Write-Output ($out -join "\`n")`,
].join('\n');
const r = await execCommand({ command: ps, timeoutMs: 60_000 });
if (r.exit_code !== 0) {
// 常见:权限不足(非管理员读不了 Security 日志)
throw new CapabilityError(
ErrorCodes.EXECUTION_FAILED,
'读取 Windows Security 日志失败(常见原因:未以管理员运行,或审计策略未启用)',
{ detail: (r.stderr || r.stdout).slice(0, 300) },
);
}
const text = r.stdout.trim();
if (!text || text === '[]') return [];
const out: Omit<L2Entry, 'prev' | 'hash' | 'collected_at'>[] = [];
for (const line of text.split('\n')) {
if (!line.trim()) continue;
try {
const o = JSON.parse(line) as { id?: number; t?: string; msg?: string; sub?: string };
const id = Number(o.id);
const kind = WIN_EVENT_KIND[id];
if (!kind) continue;
const ts = o.t ? Date.parse(o.t) : Date.now();
// 从 Message 里抠进程名(4688 的「新进程名称」),拿不到就留空 —— 不猜
// 括号不可省:TS 不允许 ?? 与 || 无括号混用
const obj =
(/新进程名称:\s*(.+)/.exec(o.msg ?? '')?.[1]?.trim() ??
/进程名称:\s*(.+)/.exec(o.msg ?? '')?.[1]?.trim()) ||
undefined;
out.push({
ts: Number.isFinite(ts) ? ts : Date.now(),
src: 'windows-security',
event_id: id,
kind,
subject: o.sub || undefined,
object: obj,
detail: { raw_message_head: (o.msg ?? '').slice(0, 300) },
});
} catch {
/* 跳过解析失败的单条,不影响整体 */
}
}
return out;
}
// ---------------- 对外能力 ----------------
export async function auditL2(args: Args): Promise<unknown> {
const action = (args['action'] as string | undefined) ?? 'status';
// 平台门控走统一 assertWindows(与 app.* / system.service.list 同一形状):
// ① 错误信息格式统一(能力名 + 未实现平台 + 受支持平台);
// ② 让 console-contract 的平台门控守卫扫得到本能力(裸 throw 会漏检 → 控制台
// WINDOWS_ONLY_CAPS 少列一项 → 非 Windows 被控端上按钮照常显示,点了才报错)。
// 平台现状与 Linux/macOS 未实现的原因见文件头注释与 docs/AUDIT-L2.md。
assertWindows('system.audit.l2');
if (action === 'status') return l2Status();
if (action === 'enable') return await l2Enable();
if (action === 'collect') {
const since = typeof args['since'] === 'number' ? args['since'] : Date.now() - 24 * 3600 * 1000;
const limit = Math.max(1, Math.min(5000, (args['limit'] as number | undefined) ?? 1000));
const entries = await collectWindows(since, limit);
const written = appendL2(entries);
audit({ type: 'agent.l2', status: 'ok', reason: `l2.collect written=${written} since=${new Date(since).toISOString()}` });
return {
collected: entries.length,
written,
since,
limit,
file: l2FilePath(),
note:
'本地 audit-l2.log 只是归一化副本;真正防篡改依赖把记录转发出机(见 docs/AUDIT-L2.md §2)',
};
}
if (action === 'verify') return verifyL2();
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `action 只支持 status|enable|collect|verify,收到 ${action}`);
}
/** 检查 Windows 审计策略是否已启用(并提示命令行记录开关)。 */
async function l2Status(): Promise<unknown> {
const ps = [
`$ErrorActionPreference='Stop'`,
`$o = auditpol /get /category:* /r`,
// /r 输出 CSV:Machine Name,Policy Target,Subcategory,Subcategory GUID,Inclusion Setting,Exclusion Setting
`$rows = $o | ConvertFrom-Csv`,
`$want = @('Process Creation','Logon','Process Termination')`,
`$got = @()`,
`foreach ($w in $want) {`,
` $m = $rows | Where-Object { $_.Subcategory -eq $w } | Select-Object -First 1`,
` if ($m) { $got += ($w + '=' + $m.'Inclusion Setting') } else { $got += ($w + '=not found') }`,
`}`,
`Write-Output ($got -join '; ')`,
`$legacy = (Get-ItemProperty 'HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Lsa' -Name SCENoApplyLegacyAuditPolicy -ErrorAction SilentlyContinue).SCENoApplyLegacyAuditPolicy`,
`Write-Output ("SCENoApplyLegacyAuditPolicy=" + $(if ($null -eq $legacy) { 'not set' } else { $legacy }))`,
`$cmdline = (Get-ItemProperty 'HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Policies\\System\\Audit' -Name ProcessCreationIncludeCmdLine_Enabled -ErrorAction SilentlyContinue).ProcessCreationIncludeCmdLine_Enabled`,
`Write-Output ("ProcessCreationIncludeCmdLine_Enabled=" + $(if ($null -eq $cmdline) { 'not set' } else { $cmdline }))`,
].join('\n');
const r = await execCommand({ command: ps, timeoutMs: 60_000 });
const out = r.stdout || '';
const sysmon = await detectSysmon();
return {
platform: 'windows',
supported: true,
auditpol: out.trim(),
// 不设它,组策略的旧式类别策略会盖掉 subcategory 设置 ——
// 表现为「auditpol 显示已启用,但事件日志里没有 4688」
legacy_policy_disabled: /SCENoApplyLegacyAuditPolicy=1/.test(out),
cmdline_logging: /ProcessCreationIncludeCmdLine_Enabled=1/.test(out),
sysmon_installed: sysmon,
file: l2FilePath(),
file_exists: existsSync(l2FilePath()),
note: r.exit_code !== 0 ? 'auditpol 查询未成功返回(可能需要管理员权限)' : undefined,
};
}
/** 启用 Windows 高级审计策略(需管理员)。 */
async function l2Enable(): Promise<unknown> {
const ps = [
`$ErrorActionPreference='Stop'`,
// ① 让「高级审计策略」生效,覆盖旧式类别策略
`New-ItemProperty -Path 'HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Lsa' -Name SCENoApplyLegacyAuditPolicy -Value 1 -PropertyType DWORD -Force | Out-Null`,
// ② 命令行记录:不开的话 4688 只有进程名,看不到参数
`New-Item -Path 'HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Policies\\System\\Audit' -Force | Out-Null`,
`New-ItemProperty -Path 'HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Policies\\System\\Audit' -Name ProcessCreationIncludeCmdLine_Enabled -Value 1 -PropertyType DWORD -Force | Out-Null`,
// ③ 开启关注的 subcategory
`auditpol /set /subcategory:"进程创建" /success:enable /failure:enable`,
`auditpol /set /subcategory:"登录" /success:enable /failure:enable`,
`auditpol /set /subcategory:"进程终止" /success:enable`,
`Write-Output 'enabled'`,
].join('\n');
const r = await execCommand({ command: ps, timeoutMs: 90_000 });
if (r.exit_code !== 0) {
throw new CapabilityError(
ErrorCodes.EXECUTION_FAILED,
'启用审计策略失败(需要管理员权限)。未提权时请先用管理员身份运行。',
{ detail: (r.stderr || r.stdout).slice(0, 300) },
);
}
audit({ type: 'agent.l2', status: 'ok', reason: 'l2.enable windows audit policy' });
return {
enabled: true,
platform: 'windows',
policies: ['进程创建 4688', '登录 4624/4625', '进程终止 4689'],
cmdline_logging: true,
hint: '策略生效后需要一段时间才会产生事件;用 action=collect 拉取',
};
}
/** 检测 Sysmon 是否安装(装了就并用其日志,没装不尝试下载)。 */
async function detectSysmon(): Promise<boolean> {
const r = await execCommand({
command: `$ErrorActionPreference='SilentlyContinue'; (Get-Service Sysmon -ErrorAction SilentlyContinue).Name`,
timeoutMs: 20_000,
});
return r.stdout.trim() === 'Sysmon';
}
// ---------------- L2 链校验 ----------------
export interface L2VerifyResult {
ok: boolean;
checked: number;
broken_at?: { line: number; reason: string };
}
/** 校验 L2 链完整性(与 L1 同构算法,独立文件独立链)。 */
export function verifyL2(): L2VerifyResult {
const p = l2FilePath();
if (!existsSync(p)) return { ok: true, checked: 0 };
let expected: string | null = null;
let checked = 0;
let line = 0;
try {
for (const raw of readFileSync(p, 'utf8').split('\n')) {
if (!raw.trim()) continue;
line += 1;
let rec: L2Entry;
try {
rec = JSON.parse(raw) as L2Entry;
} catch {
return { ok: false, checked, broken_at: { line, reason: 'JSON 解析失败(条目被破坏)' } };
}
const { hash, ...body } = rec;
if (!hash) continue;
const recalc = hashEntry(body as unknown as Record<string, unknown>, rec.prev ?? GENESIS);
if (recalc !== hash) {
return { ok: false, checked, broken_at: { line, reason: '内容与哈希不符(条目被篡改)' } };
}
if (expected !== null && rec.prev !== expected) {
return { ok: false, checked, broken_at: { line, reason: '链断裂(prev 与上一条哈希不匹配)' } };
}
expected = hash;
checked += 1;
}
} catch {
return { ok: false, checked, broken_at: { line, reason: '读取 L2 文件失败' } };
}
return { ok: true, checked };
}
/** 读取 L2 记录(供导出转发 —— 真正的独立性来自这里,而非本地副本)。 */
export function readL2(limit = 200): L2Entry[] {
const p = l2FilePath();
if (!existsSync(p)) return [];
const out: L2Entry[] = [];
try {
const lines = readFileSync(p, 'utf8').split('\n').filter((l) => l.trim());
for (const l of lines.slice(-Math.max(1, limit))) {
try {
out.push(JSON.parse(l) as L2Entry);
} catch {
/* 跳过损坏行 */
}
}
} catch {
/* 读失败返回已有部分 */
}
return out;
}
+5 -5
View File
@@ -1,5 +1,5 @@
/**
* gui.await —— 等待条件成立(v1.6)。
* gui.await —— 等待条件成立(v1.6.0)。
*
* ## 为什么需要它(复盘三大短板之「语义」)
* 原来的 GUI 操作是**盲试**:按一下、截个图、看看有没有变化,不行再试。
@@ -63,7 +63,7 @@ function buildInnerArgs(condition: Condition, args: Args): Args {
for (const k of ['text', 'window', 'control_type', 'method', 'template', 'limit', 'timeout_ms']) {
if (args[k] !== undefined) inner[k] = args[k];
}
// v1.7:属性谓词(仅 UIA 引擎生效)。有 where 时允许不传 text ——
// v2.0.0:属性谓词(仅 UIA 引擎生效)。有 where 时允许不传 text ——
// 「等一个 enabled 的按钮」比「等一个叫某名字的按钮」更贴近真实意图。
const where = args['where'];
if (where !== undefined) {
@@ -117,7 +117,7 @@ function innerProbe(condition: Condition): (args: Args) => Promise<unknown> {
}
/**
* v1.7 组合条件:`any_of: [ {...}, {...} ]`,任一命中原句即算命中。
* v2.0.0 组合条件:`any_of: [ {...}, {...} ]`,任一命中原句即算命中。
*
* 为什么只做 any_of 不做 and/or/not generalize:真实场景里"弹窗出现 **且**
* 其中某按钮 enabled"用 any_of(弹窗) + where(按钮 enabled) 两步就能表达,
@@ -152,7 +152,7 @@ function buildAnyOf(args: Args): Array<{ condition: Condition; inner: Args; labe
}
export async function guiAwait(args: Args): Promise<unknown> {
// v1.7:有 any_of 时**不要求**顶层 condition(语义就是"这组条件任一命中"),
// v2.0.0:有 any_of 时**不要求**顶层 condition(语义就是"这组条件任一命中"),
// 校验顺序也因此调整为 any_of 先行 —— 否则会先撞上"condition 缺失"的报错。
const condition = args['condition'] as Condition | undefined;
if (!args['any_of'] && (!condition || !CONDITIONS.includes(condition))) {
@@ -175,7 +175,7 @@ export async function guiAwait(args: Args): Promise<unknown> {
let lastHit = false;
let lastError: string | null = null;
// v1.7:组合条件走单独路径(任一命中即算),失败语义与单条完全一致
// v2.0.0:组合条件走单独路径(任一命中即算),失败语义与单条完全一致
const anyOf = args['any_of'] !== undefined ? buildAnyOf(args) : null;
if (anyOf) {
for (;;) {
+5
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 { assertGuiPlatform } from '../util/platform.js';
type Args = Record<string, unknown>;
@@ -79,6 +80,8 @@ async function writeClipboardWindows(b64: string, expect: string): Promise<void>
// ---------------- clip.get ----------------
export async function clipGet(args: Args = {}): Promise<unknown> {
// 该能力仅在 Windows/macOS 有实现(Linux 端未实现)
assertGuiPlatform('clipboard.get');
const format = (args['format'] as string | undefined) ?? 'auto';
if (IS_WINDOWS) {
@@ -132,6 +135,8 @@ async function getImageWindows(): Promise<Record<string, unknown> | null> {
// ---------------- clip.set ----------------
export async function clipSet(args: Args): Promise<unknown> {
// 该能力仅在 Windows/macOS 有实现(Linux 端未实现)
assertGuiPlatform('clipboard.set');
const image = args['image_base64'] as string | undefined;
const text = args['text'] as string | undefined;
+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);
}
+15 -3
View File
@@ -1,6 +1,7 @@
import { CapabilityNames } from '@nodeagent/protocol';
import type { AgentConfig } from '../config.js';
import { systemInfo, systemStatus, processList, serviceList, shellExec, auditList, auditVerify, auditHead, auditAnchor, metricsReport, agentRestart } from './system.js';
import { auditL2 } from './audit-l2.js';
import { appList, appInstall } from './app.js';
import { screenInfo, screenCapture } from './screen.js';
import { windowList, windowFocus, screenFind } from './window.js';
@@ -19,6 +20,15 @@ import { logQuery, monitorStart, monitorReport, monitorStop, monitorList, monito
export interface HandlerContext {
/** 连接标识(事件订阅归属 / 断开清理) */
owner: string;
/**
* 调用方身份(`client_id`)。用于**跨控制端隔离**:
* 事件订阅的 list / unwatch / poll 只暴露给「创建它的那个 client_id」。
*
* 为什么按 client_id 而不是连接:同一次工作的多个连接(如 MCP 每次调用新建连接、
* CLI 的 watch 与 poll 分属两个进程)必须能互相看到自己的订阅,否则语义就断了。
* psk 模式下 client_id 由客户端自报(配置级约束);ed25519 + 每客户端独立 PSK 下身份才被真正绑定。
*/
clientId?: string;
/** 向该连接推送事件 */
emit: (evt: unknown) => void;
}
@@ -54,9 +64,9 @@ export function createCapabilityRegistry(cfg: AgentConfig): Record<string, Capab
[CapabilityNames.MouseDrag]: mouseDrag,
[CapabilityNames.KeyType]: keyType,
[CapabilityNames.KeyPress]: keyPress,
// v1.6 等待条件(把盲试变为可断言)
// v1.6.0 等待条件(把盲试变为可断言)
[CapabilityNames.GuiAwait]: guiAwait,
// v1.8 状态采样(方向二)
// v2.0.0 状态采样(方向二)
[CapabilityNames.LogQuery]: logQuery,
[CapabilityNames.MonitorStart]: monitorStart,
[CapabilityNames.MonitorReport]: monitorReport,
@@ -67,9 +77,11 @@ export function createCapabilityRegistry(cfg: AgentConfig): Record<string, Capab
[CapabilityNames.AuditList]: auditList,
// v11 审计防篡改
[CapabilityNames.AuditVerify]: auditVerify,
// v25 审计链外部锚定(防整链重写)
// v2.0.0 审计链外部锚定(防整链重写)
[CapabilityNames.AuditHead]: auditHead,
[CapabilityNames.AuditAnchor]: auditAnchor,
// L2 系统级审计兜底(见 docs/AUDIT-L2.md)
[CapabilityNames.AuditL2]: auditL2,
// v13 成功指标
[CapabilityNames.Metrics]: metricsReport,
// v5 文件传输
+4 -4
View File
@@ -45,13 +45,13 @@ export const VK_MAP: Record<string, number> = {
launch_mail: 0xb4, launch_media: 0xb5, launch_app1: 0xb6, launch_app2: 0xb7,
browser_favorites: 0xab, browser_home: 0xac,
// ---- 左右侧修饰键(v1.5)----
// ---- 左右侧修饰键(v1.5.0)----
// 游戏/部分应用区分左右(如游戏中把 LAlt 设为跑步、RAlt 设为瞄准),故不能只给一个 alt。
lctrl: 0xa2, rctrl: 0xa3, lshift: 0xa0, rshift: 0xa1,
lalt: 0xa4, ralt: 0xa5, lwin: 0x5b, rwin: 0x5c,
super: 0x5b, cmd: 0x5b, left_cmd: 0x5b, right_cmd: 0x5c,
// ---- 小键盘(v1.5)----
// ---- 小键盘(v1.5.0)----
// numpadN 与数字 N 分开:Excel 等程序靠 NumLock 状态区分二者行为,合成一个会坏掉快捷键。
numpad0: 0x60, numpad1: 0x61, numpad2: 0x62, numpad3: 0x63, numpad4: 0x64,
numpad5: 0x65, numpad6: 0x66, numpad7: 0x67, numpad8: 0x68, numpad9: 0x69,
@@ -59,7 +59,7 @@ export const VK_MAP: Record<string, number> = {
numlock: 0x90, scrolllock: 0x91,
numpad_enter: 0x0d,
// ---- OEM 符号键(v1.5,美式布局 VK 码)----
// ---- OEM 符号键(v1.5.0,美式布局 VK 码)----
// 必备理由:大量系统/应用快捷键就是"修饰键 + 符号键"(win+d 显示桌面、win+, 速览、
// ctrl++ 放大、ctrl+- 缩小、win+. 表情面板),此前只能靠字母数字,覆盖不到。
oem_1: 0xba, // : ;
@@ -75,7 +75,7 @@ export const VK_MAP: Record<string, number> = {
oem_comma: 0xbc, // , <
oem_period: 0xbe,// . >
// ---- 系统/IME 杂项(v1.5)----
// ---- 系统/IME 杂项(v1.5.0)----
sleep: 0x5f, help: 0x2f, select: 0x29, execute: 0x2b, clear: 0x0c,
separator: 0x6c, oem_clear: 0xfe,
kana: 0x15, hangul: 0x15, kanji: 0x19, hanja: 0x19,
+37 -13
View File
@@ -6,6 +6,8 @@ import {
buildPostBody,
} from './input-script.js';
import { IS_WINDOWS, execCommand } from '../util/exec.js';
import { PS_DPI_AWARE } from '../util/dpi.js';
import { assertGuiPlatform } from '../util/platform.js';
type Args = Record<string, unknown>;
@@ -183,7 +185,7 @@ public static class NAInput {
}
/// <summary>
/// v1.5:向目标进程的**全部顶层窗口** PostMessage 键盘消息(后端定向快捷键投放)。
/// v1.5.0:向目标进程的**全部顶层窗口** PostMessage 键盘消息(后端定向快捷键投放)。
///
/// 场景:目标窗口不在前台(被遮挡 / 最小化 / 聊天窗口在后台),又要给它发快捷键。
/// WM_KEYDOWN=0x0100,WM_KEYUP=0x0101;修饰键按顺序投递,调用方负责释放顺序。
@@ -213,15 +215,31 @@ public static class NAInput {
}
}`;
/** 组装:加类型定义 + 具体操作。 */
function psScript(body: string[]): string {
return [`$ErrorActionPreference = 'Stop'`, `$code = @'`, CS_CODE, `'@`, `Add-Type -TypeDefinition $code`, ...body].join(
'\n',
);
/**
* 组装:加 DPI 感知 + 类型定义 + 具体操作。
*
* 导出**仅供单测断言**(`tests/unit/dpi-awareness.test.mjs` 直接检查这里下发的
* PowerShell 是否带上了 DPI 感知、且位于坐标调用之前)——真机 2026-10-12 的
* 「点不准」缺陷就是这里漏了 DPI 感知,而源码扫描式守卫抓不到。
*/
export function buildInputPsScript(body: string[]): string {
return [
`$ErrorActionPreference = 'Stop'`,
// ⚠️ 必须最先执行,且**不可删**:不设 DPI 感知时 `SetCursorPos` / `mouse_event` 的坐标
// 会被系统按缩放比虚拟化(200% 下 ×2),而 `screen.find`(UIA)返回的是**物理**坐标 ——
// 「找得到、点不准」,且不报任何错。真机 2026-10-12 实证:
// 请求 (100,100) 实际落到 (200,200),请求 (552,745) 实际落到 (1104,1439)。
PS_DPI_AWARE,
`$code = @'`,
CS_CODE,
`'@`,
`Add-Type -TypeDefinition $code`,
...body,
].join('\n');
}
async function runPs(body: string[], timeoutMs = 30_000): Promise<string> {
const r = await execCommand({ command: psScript(body), timeoutMs });
const r = await execCommand({ command: buildInputPsScript(body), timeoutMs });
if (r.exit_code !== 0) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '输入注入失败', {
detail: (r.stderr || r.stdout).slice(0, 400),
@@ -239,6 +257,7 @@ const MOUSE_FLAGS: Record<string, { down: number; up: number }> = {
};
export async function mouseMove(args: Args): Promise<unknown> {
assertGuiPlatform('input.mouseMove');
assertAllowed('input.mouse.move');
requireWindows('input.mouse.move');
const x = args['x'] as number;
@@ -266,6 +285,7 @@ export async function mouseMove(args: Args): Promise<unknown> {
}
export async function mouseClick(args: Args): Promise<unknown> {
assertGuiPlatform('input.mouseClick');
assertAllowed('input.mouse.click');
requireWindows('input.mouse.click');
const button = (args['button'] as string) ?? 'left';
@@ -285,9 +305,11 @@ export async function mouseClick(args: Args): Promise<unknown> {
}
await runPs(body);
// 返回实际点击位置
// 返回实际点击位置。
// ⚠️ 读回光标位置同样要 DPI 感知,否则这里读到的是**逻辑**值(200% 下是真实值的一半),
// 于是「主叫方传物理坐标 → 返回被腰斩的坐标」,看起来像点错位置。真机 2026-10-12 排查。
const pos = await execCommand({
command: `Add-Type -AssemblyName System.Windows.Forms; $p = [System.Windows.Forms.Cursor]::Position; "{0},{1}" -f $p.X, $p.Y`,
command: `${PS_DPI_AWARE}; Add-Type -AssemblyName System.Windows.Forms; $p = [System.Windows.Forms.Cursor]::Position; "{0},{1}" -f $p.X, $p.Y`,
timeoutMs: 20_000,
});
const [cx, cy] = pos.stdout.trim().split(',').map(Number);
@@ -295,6 +317,7 @@ export async function mouseClick(args: Args): Promise<unknown> {
}
export async function mouseScroll(args: Args): Promise<unknown> {
assertGuiPlatform('input.mouseScroll');
assertAllowed('input.mouse.scroll');
requireWindows('input.mouse.scroll');
const delta = args['delta'] as number;
@@ -314,6 +337,7 @@ export async function mouseScroll(args: Args): Promise<unknown> {
* 用于拖文件、框选、拖动滑块/窗口。整段动作在一次进程内完成(避免多次 spawn 抖动)。
*/
export async function mouseDrag(args: Args): Promise<unknown> {
assertGuiPlatform('input.mouseDrag');
assertAllowed('input.mouse.drag');
requireWindows('input.mouse.drag');
const x1 = args['from_x'] as number;
@@ -364,7 +388,7 @@ export async function keyPress(args: Args): Promise<unknown> {
assertAllowed('input.key.press');
requireWindows('input.key.press');
// v1.5:入参统一经 protocol 的 expandChords 展开 —— 字符串热键 / 预设名 / 老数组形式
// v1.5.0:入参统一经 protocol 的 expandChords 展开 —— 字符串热键 / 预设名 / 老数组形式
// 全部收敛成「归一化和弦序列」。混用多种形式会直接报错,避免"以谁为准"的歧义。
let expanded;
try {
@@ -384,9 +408,9 @@ export async function keyPress(args: Args): Promise<unknown> {
const presets = expanded.presets;
const gap = Math.max(0, Math.min(2000, (args['interval_ms'] as number) ?? 40));
// v1.5:长按(按下到释放的保持时长),用于"长按音量/持续按住方向键"等场景
// v1.5.0:长按(按下到释放的保持时长),用于"长按音量/持续按住方向键"等场景
const holdMs = Math.max(0, Math.min(5000, Math.round((args['hold_ms'] as number | undefined) ?? 0)));
// v1.5:投放路由。foreground = SendInput 注入当前焦点(默认,旧行为);
// v1.5.0:投放路由。foreground = SendInput 注入当前焦点(默认,旧行为);
// post = 向目标进程全部顶层窗口 PostMessage(窗口在后台时可用,但兼容性差)
const route = (args['route'] as string | undefined) ?? 'foreground';
if (route !== 'foreground' && route !== 'post') {
@@ -423,7 +447,7 @@ export async function keyPress(args: Args): Promise<unknown> {
};
}
// v1.5:后端定向投递(route=post)—— 目标窗口不在前台时使用
// v1.5.0:后端定向投递(route=post)—— 目标窗口不在前台时使用
if (route === 'post') {
let sent = 0;
const out = await runPs(buildPostBody(chords, { gapMs: gap, holdMs, targetPid: pid, route }));
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* log.query(v1.8,方向二「状态」第一块)。
* log.query(v2.0.0,方向二「状态」第一块)。
*
* ## 为什么必须有
* 治间歇性问题(代理不通、杀软拦截、端口时开时闭)要看**历史**,而被控端日志常在
+69 -22
View File
@@ -1,5 +1,5 @@
/**
* monitor.*(v1.8,方向二「状态」第二块)—— 定时采样落盘与回看。
* monitor.*(v2.0.0,方向二「状态」第二块)—— 定时采样落盘与回看。
*
* ## 为什么需要(复盘 §四)
* 代理不通、杀软拦截、端口时开时闭都是**间歇性**的,而 `system.status` 只能看当下快照。
@@ -17,8 +17,8 @@
import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { connect } from 'node:net';
import { exec } from 'node:child_process';
import { join } from 'node:path';
import { exec, execFile } from 'node:child_process';
import { join, resolve as resolvePath, sep } from 'node:path';
import { homedir, totalmem, freemem, cpus } from 'node:os';
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { logQuery, type LogLine } from './log.js';
@@ -60,6 +60,35 @@ function monitorDir(): string {
return d;
}
/**
* 校验监控 id:**唯一入口**,四个命令(start/report/stop/delete)都必须先过它。
*
* ⚠️ M-01/M-02(真机实测):`monitor.report` / `monitor.delete` 曾经**不校验 id**
* 就直接 `join(monitorDir(), id + '.jsonl')` → `id:"../escape-target"` 可读/删
* 数据目录外的任意 `.jsonl` 文件(路径穿越)。
* 这里除了字符白名单,还做一次**解析后前缀比对**(`..` 与符号链接都拦得住)。
*/
function safeMonitorId(raw: unknown): string {
const id = typeof raw === 'string' ? raw.trim() : '';
if (!id) throw new CapabilityError(ErrorCodes.PARAM_INVALID, '需要 id');
if (!/^[\w.-]{1,64}$/.test(id) || id.includes('..')) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'id 只允许字母数字与 . _ -(≤64 字符,不含 ..)', {
got: id,
});
}
return id;
}
/** 监控样本文件路径 —— id 已在 safeMonitorId 校验过,这里再兜一次「必须在 monitors 目录内」。 */
function monitorFile(id: string): string {
const dir = resolvePath(monitorDir());
const file = resolvePath(dir, `${id}.jsonl`);
if (!file.startsWith(dir + sep)) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'id 解析后越出 monitors 目录', { id });
}
return file;
}
// ---------------- 采样器 ----------------
/** port:TCP 连通性 + 握手耗时(跨平台,无额外依赖)。 */
@@ -81,11 +110,11 @@ function samplePort(host: string, port: number, timeoutMs: number): Promise<Samp
/** process:按名判断存活(Windows tasklist / POSIX ps,与 events.ts 同策略)。 */
function sampleProcess(name: string): Promise<Sample> {
return new Promise((resolve) => {
const cmd =
process.platform === 'win32'
? `tasklist /FI "IMAGENAME eq ${name}" /NH`
: `ps -axo pid=,comm= | grep -i "${name}" | grep -v grep`;
exec(cmd, { timeout: 8000 }, (err, stdout) => {
// ⚠️ D-02(真机实测):原来把 `name` 拼进 `tasklist /FI "IMAGENAME eq ${name}"` 与
// `ps … | grep -i "${name}"`,payload `zzz"; touch /tmp/m…; #` 会被 shell 执行。
// 正解:**不经过 shell** —— Windows 用 execFile 传参数数组,POSIX 直接把 ps 输出
// 交给 JS 过滤(不再 grep)。
const collect = (stdout: string, err: Error | null): void => {
const lines = stdout
.split('\n')
.map((l) => l.trim())
@@ -96,7 +125,28 @@ function sampleProcess(name: string): Promise<Sample> {
return m ? Number(m[1]) : null;
})
.filter((x): x is number => x !== null);
resolve({ ts: Date.now(), alive: pids.length > 0, pids, ...(err && pids.length === 0 ? { error: String(err.message) } : {}) });
resolve({
ts: Date.now(),
alive: pids.length > 0,
pids,
...(err && pids.length === 0 ? { error: String(err.message) } : {}),
});
};
if (process.platform === 'win32') {
// 无 shell:name 作为单个参数传入,`"` `;` `&` 等都不再具特殊含义
execFile('tasklist', ['/FI', `IMAGENAME eq ${name}`, '/NH'], { timeout: 8000 }, (err, stdout) =>
collect(stdout ?? '', err),
);
return;
}
execFile('ps', ['-axo', 'pid=,comm='], { timeout: 8000 }, (err, stdout) => {
const needle = name.toLowerCase();
const filtered = (stdout ?? '')
.split('\n')
.filter((l) => l.toLowerCase().includes(needle))
.join('\n');
collect(filtered, err);
});
});
}
@@ -171,15 +221,15 @@ export async function monitorStart(args: Args): Promise<unknown> {
});
}
const id = (args['id'] as string | undefined)?.trim() || `m_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
const id =
(args['id'] as string | undefined)?.trim() ||
`m_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
safeMonitorId(id); // 与 report/delete 同一套校验(防路径穿越 + 统一口径)
if (monitors.has(id)) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `监控 id 已存在: ${id}`, { hint: '换个 id,或先 monitor.stop' });
}
if (!/^[\w.-]{1,64}$/.test(id)) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'id 只允许字母数字与 . _ -(≤64 字符)', { got: id });
}
const file = join(monitorDir(), `${id}.jsonl`);
const file = monitorFile(id);
const rec: MonitorRecord = {
id, source, target, intervalMs, createdAt: Date.now(), samples: 0, file,
timer: setInterval(() => { void tick(rec); }, intervalMs),
@@ -214,10 +264,9 @@ async function tick(rec: MonitorRecord): Promise<void> {
}
export async function monitorReport(args: Args): Promise<unknown> {
const id = args['id'] as string;
if (!id) throw new CapabilityError(ErrorCodes.PARAM_INVALID, '需要 id');
const id = safeMonitorId(args['id']);
const rec = monitors.get(id);
const file = join(monitorDir(), `${id}.jsonl`);
const file = monitorFile(id);
if (!rec && !existsSync(file)) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `没有这个监控: ${id}`, { running: [...monitors.keys()] });
}
@@ -313,8 +362,7 @@ function summarize(samples: Sample[], source: MonitorSource): Record<string, unk
}
export async function monitorStop(args: Args): Promise<unknown> {
const id = args['id'] as string;
if (!id) throw new CapabilityError(ErrorCodes.PARAM_INVALID, '需要 id');
const id = safeMonitorId(args['id']);
const rec = monitors.get(id);
if (!rec) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `没有正在运行的监控: ${id}`, { running: [...monitors.keys()] });
@@ -346,12 +394,11 @@ export async function monitorList(_args: Args): Promise<unknown> {
/** 供 monitor.delete:删除历史文件(不影响运行中的实例)。 */
export async function monitorDelete(args: Args): Promise<unknown> {
const id = args['id'] as string;
if (!id) throw new CapabilityError(ErrorCodes.PARAM_INVALID, '需要 id');
const id = safeMonitorId(args['id']);
if (monitors.has(id)) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, '该监控仍在运行,请先 stop', { hint: `monitor.stop {"id":"${id}"}` });
}
const file = join(monitorDir(), `${id}.jsonl`);
const file = monitorFile(id);
if (!existsSync(file)) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `没有历史文件: ${id}`);
}
+23 -22
View File
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFile
import { join } from 'node:path';
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { IS_WINDOWS, execCommand } from '../util/exec.js';
import { psLit as psLitShared, shLit } from '../util/quote.js';
import { agentDir } from '../config.js';
import { audit } from '../audit.js';
import { extractPsError } from '../util/ps-helper.js';
@@ -59,7 +60,7 @@ async function runPsFile(script: string, timeoutMs: number, tag = 'ps'): Promise
const path = join(dir, `${tag}-${Date.now()}.ps1`);
writeFileSync(path, withBom(script), 'utf8');
return execCommand({
command: `powershell -NoProfile -ExecutionPolicy Bypass -File ${JSON.stringify(path)}`,
command: `powershell -NoProfile -ExecutionPolicy Bypass -File ${psLit(path)}`,
timeoutMs,
});
}
@@ -68,7 +69,7 @@ async function runPsFile(script: string, timeoutMs: number, tag = 'ps'): Promise
* 生成的 .ps1 **必须带 UTF-8 BOM**。
*
* ⚠️ 真机踩过:PowerShell 5.1 读**无 BOM** 的 .ps1 会按系统 ANSI(GBK) 解析,
* 脚本里的中文(如网卡名「Ethernet」)会吃掉后面的闭合引号 →
* 脚本里的非 ASCII 字符(如中文网卡名)会吃掉后面的闭合引号 →
* 报「The string is missing the terminator」→ 计划任务退出码 1 且不留日志。
* 所有落盘的 PowerShell 脚本一律经此函数。
*/
@@ -83,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,
});
+7 -2
View File
@@ -3,6 +3,8 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { IS_WINDOWS, execCommand } from '../util/exec.js';
import { PS_DPI_AWARE } from '../util/dpi.js';
import { assertGuiPlatform } from '../util/platform.js';
type Args = Record<string, unknown>;
@@ -87,6 +89,8 @@ async function tryMuxMp4(dir: string, fps: number): Promise<string | null> {
* —— 避免把大体积视频塞进单条 RPC 响应。
*/
export async function screenRecord(args: Args): Promise<unknown> {
// 该能力仅在 Windows/macOS 有实现(Linux 端需 X11/Wayland 后端,尚未实现)
assertGuiPlatform('screen.record');
if (!IS_WINDOWS) {
throw new CapabilityError(ErrorCodes.UNSUPPORTED_PLATFORM, 'screen.record 当前仅支持 Windows 被控端', {
platform: process.platform,
@@ -109,9 +113,10 @@ export async function screenRecord(args: Args): Promise<unknown> {
let height = region?.height ?? 0;
if (!region) {
// ⚠️ 这里必须先设 DPI 感知,`VirtualScreen` 才是**物理**尺寸(见 util/dpi.ts)。
// 统一走 PS_DPI_AWARE,避免各能力各写一份 P/Invoke 壳而漂移。
const probe = await execCommand({
command:
'Add-Type -AssemblyName System.Windows.Forms; Add-Type -TypeDefinition \'using System.Runtime.InteropServices; public class NADPIR { [DllImport("user32.dll")] public static extern bool SetProcessDPIAware(); }\'; [void][NADPIR]::SetProcessDPIAware(); $vs = [System.Windows.Forms.SystemInformation]::VirtualScreen; Write-Output ("{0},{1},{2},{3}" -f $vs.X, $vs.Y, $vs.Width, $vs.Height)',
command: `${PS_DPI_AWARE}; Add-Type -AssemblyName System.Windows.Forms; $vs = [System.Windows.Forms.SystemInformation]::VirtualScreen; Write-Output ("{0},{1},{2},{3}" -f $vs.X, $vs.Y, $vs.Width, $vs.Height)`,
timeoutMs: 30_000,
});
const nums = probe.stdout.trim().split(',').map(Number);
+89 -24
View File
@@ -1,5 +1,8 @@
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { IS_WINDOWS, execCommand } from '../util/exec.js';
import { shLit } from '../util/quote.js';
import { PS_DPI_AWARE } from '../util/dpi.js';
import { assertGuiPlatform } from '../util/platform.js';
type Args = Record<string, unknown>;
@@ -25,22 +28,37 @@ const MAX_CAPTURE_BYTES = 40 * 1024 * 1024;
// ---------------- screen.info ----------------
/**
* listDisplays 下发给 PowerShell 的脚本。
*
* 导出**仅供单测断言**(检查 DPI 感知确实注入且位于坐标 API 之前)——
* 真机 2026-10-12 的「缺省整屏只截到左上 1/4」就是这里漏了 DPI 感知,
* 源码扫描式守卫抓不到(文件里别处也引用了 PS_DPI_AWARE)。
*/
export function buildListDisplaysScript(): string {
return [
// ⚠️ 必须最先执行:不设 DPI 感知时,`Screen.Bounds` 返回的是**逻辑**尺寸,
// 而 `CopyFromScreen` 又按**物理**像素裁剪 → 缺省整屏截图只截到左上 1/4
//(200% 缩放下 2560×1440 的桌面报成 1280×720)。真机 2026-10-12 实证。
PS_DPI_AWARE,
'Add-Type -AssemblyName System.Windows.Forms',
'$s = [System.Windows.Forms.Screen]::AllScreens',
// 读取 DPI 缩放(HKCU AppliedDPI;读不到则按 100% 处理)。
// 注意:设了 DPI 感知后 Bounds 已是**物理**像素,scale 仅作参考信息保留。
'$dpi = (Get-ItemProperty -Path "HKCU:\\Control Panel\\Desktop\\WindowMetrics" -Name AppliedDPI -ErrorAction SilentlyContinue).AppliedDPI',
'$scale = if ($dpi) { [math]::Round($dpi / 96.0, 2) } else { 1.0 }',
'$out = @()',
'for ($i = 0; $i -lt $s.Count; $i++) {',
' $d = $s[$i]',
' $out += [pscustomobject]@{ id = $i; name = $d.DeviceName; width = $d.Bounds.Width; height = $d.Bounds.Height; is_primary = $d.Primary; scale = $scale; x = $d.Bounds.X; y = $d.Bounds.Y }',
'}',
'$out | ConvertTo-Json -Compress',
].join('\n');
}
async function listDisplays(): Promise<Display[]> {
if (IS_WINDOWS) {
const script = [
'Add-Type -AssemblyName System.Windows.Forms',
'$s = [System.Windows.Forms.Screen]::AllScreens',
// 读取 DPI 缩放(HKCU AppliedDPI;读不到则按 100% 处理)
'$dpi = (Get-ItemProperty -Path "HKCU:\\Control Panel\\Desktop\\WindowMetrics" -Name AppliedDPI -ErrorAction SilentlyContinue).AppliedDPI',
'$scale = if ($dpi) { [math]::Round($dpi / 96.0, 2) } else { 1.0 }',
'$out = @()',
'for ($i = 0; $i -lt $s.Count; $i++) {',
' $d = $s[$i]',
' $out += [pscustomobject]@{ id = $i; name = $d.DeviceName; width = $d.Bounds.Width; height = $d.Bounds.Height; is_primary = $d.Primary; scale = $scale; x = $d.Bounds.X; y = $d.Bounds.Y }',
'}',
'$out | ConvertTo-Json -Compress',
].join('\n');
const r = await execCommand({ command: script, timeoutMs: 30_000 });
const r = await execCommand({ command: buildListDisplaysScript(), timeoutMs: 30_000 });
if (r.exit_code !== 0) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '读取显示器信息失败', { detail: r.stderr.slice(0, 500) });
}
@@ -65,6 +83,8 @@ async function listDisplays(): Promise<Display[]> {
}
export async function screenInfo(_args: Args): Promise<unknown> {
// 该能力仅在 Windows/macOS 有实现(Linux 端需 X11/Wayland 后端,尚未实现)
assertGuiPlatform('screen.info');
const displays = await listDisplays();
return {
displays: displays.map(({ id, name, width, height, is_primary, scale }) => ({
@@ -98,6 +118,8 @@ interface CaptureResult {
}
export async function screenCapture(args: Args): Promise<unknown> {
// 该能力仅在 Windows/macOS 有实现(Linux 端需 X11/Wayland 后端,尚未实现)
assertGuiPlatform('screen.capture');
const opts: CaptureOpts = {
display_id: (args['display_id'] as number) ?? 0,
format: (args['format'] as 'png' | 'jpeg') ?? 'jpeg',
@@ -106,27 +128,61 @@ export async function screenCapture(args: Args): Promise<unknown> {
region: args['region'] as CaptureOpts['region'],
};
/**
* ⚠️ S-01(v2.0.3):region 的合法性检查必须放在**枚举显示器之前**。
*
* 两道闸的由来:被控端会照 region 直接 `New-Object Bitmap $w,$h`(32bpp ≈ 4×w×h 字节),
* 一个 100000×100000 的请求足以吃光内存(≈40GB)→ 进程被杀。
* 收敛为「单边 ≤ 16384(常见位图/纹理上限)+ 总像素 ≤ 32M(≈128MB)」。
*
* 放在枚举之前有两个理由:① 校验不依赖显示器信息,没理由先付出枚举成本;
* ② 真要挡住的就是这种请求 —— 让它在任何副作用之前就失败(fail fast)。
*/
if (opts.region) assertRegionSane(opts.region);
const displays = await listDisplays();
const display = displays[opts.display_id] ?? displays[0];
if (!display) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, `显示器 ${opts.display_id} 不存在`);
}
// region 缺省 = 整个目标屏;带上多屏偏移
// region 缺省 = 整个目标屏;带上多屏偏移。
// 缺省值也要过同一道闸:显示器自报异常尺寸时同样不能放行。
const region = opts.region ?? { x: display.x, y: display.y, width: display.width, height: display.height };
if (region.width < 1 || region.height < 1) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'region 宽高必须 ≥ 1', { region });
}
assertRegionSane(region);
if (IS_WINDOWS) return captureWindows(opts, region);
return capturePosix(opts, region);
}
async function captureWindows(
opts: CaptureOpts,
/** region 上界校验(S-01)。缺省值与显式传入走**同一份**判据,避免两处漂移。 */
function assertRegionSane(region: NonNullable<CaptureOpts['region']>): void {
if (region.width < 1 || region.height < 1) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'region 宽高必须 ≥ 1', { region });
}
const MAX_EDGE = 16384;
const MAX_PIXELS = 33_554_432;
if (region.width > MAX_EDGE || region.height > MAX_EDGE || region.width * region.height > MAX_PIXELS) {
throw new CapabilityError(
ErrorCodes.PARAM_INVALID,
`region 过大:单边须 ≤ ${MAX_EDGE} 且总像素 ≤ ${MAX_PIXELS}(当前 ${region.width}×${region.height})`,
{ region, max_edge: MAX_EDGE, max_pixels: MAX_PIXELS },
);
}
if (![region.x, region.y, region.width, region.height].every((n) => Number.isFinite(n))) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'region 必须都是有限数字', { region });
}
}
/** captureWindows 下发给 PowerShell 的脚本(导出**仅供单测断言**,同 buildListDisplaysScript)。 */
export function buildCaptureScript(
region: { x: number; y: number; width: number; height: number },
): Promise<CaptureResult> {
const script = [
opts: { scale: number; format: string; quality: number },
): string {
return [
// ⚠️ 与 listDisplays 同理:region 是物理像素,进程也必须是 DPI 感知,
// 否则「region 语义(物理)」与「进程坐标观(逻辑)」两套口径并存。
PS_DPI_AWARE,
"$ErrorActionPreference = 'Stop'",
'Add-Type -AssemblyName System.Drawing',
`$x = ${region.x}; $y = ${region.y}; $w = ${region.width}; $h = ${region.height}`,
@@ -157,8 +213,17 @@ async function captureWindows(
'$ms.Dispose(); $bmp.Dispose()',
'Write-Output ("{0}|{1}|{2}" -f [Convert]::ToBase64String($bytes), $bmpW, $bmpH)',
].join('\n');
}
const r = await execCommand({ command: script, timeoutMs: 60_000, maxOutputBytes: MAX_CAPTURE_BYTES });
async function captureWindows(
opts: CaptureOpts,
region: { x: number; y: number; width: number; height: number },
): Promise<CaptureResult> {
const r = await execCommand({
command: buildCaptureScript(region, opts),
timeoutMs: 60_000,
maxOutputBytes: MAX_CAPTURE_BYTES,
});
if (r.exit_code !== 0) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '截屏失败', { detail: r.stderr.slice(0, 500) });
}
@@ -197,7 +262,7 @@ async function capturePosix(
// macOS 用 screencapture;区域用 -R x,y,w,h
const cmd =
`screencapture -x -t ${ext} -R ${region.x},${region.y},${region.width},${region.height} ${JSON.stringify(file)}`;
`screencapture -x -t ${ext} -R ${region.x},${region.y},${region.width},${region.height} ${shLit(file)}`;
const r = await execCommand({ command: cmd, timeoutMs: 30_000 });
if (r.exit_code !== 0) {
throw new CapabilityError(ErrorCodes.UNSUPPORTED_PLATFORM, '当前平台截屏不可用(需要 macOS 屏幕录制权限)', {
+222 -51
View File
@@ -1,11 +1,21 @@
import { createHash } from 'node:crypto';
import { copyFileSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import {
copyFileSync,
mkdirSync,
readFileSync,
readdirSync,
renameSync,
statSync,
unlinkSync,
writeFileSync,
} from 'node:fs';
import { join, dirname, basename } from 'node:path';
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { IS_WINDOWS } from '../util/exec.js';
import { agentDir } from '../config.js';
import { audit } from '../audit.js';
import { agentRestart } from './system.js';
import { httpGet, resolveProxy } from '../net/index.js';
type Args = Record<string, unknown>;
@@ -21,59 +31,204 @@ type Args = Record<string, unknown>;
* 1. **哈希必须钉死**:`sha256` 必填,且**逐字节校验下载内容**后才替换。
* 没有它,GitHub 账号被盗 / DNS 被污染 / 中间人 = 直接拿下被控端。
* 2. 只接受 http/https;拒绝 file:// 等其它 scheme。
* 3. 大小上限(默认 64MB)+ 下载超时,避免被喂垃圾撑爆磁盘。
* 4. **先备份再原子替换**(同目录 rename),失败可回滚。
* 3. 大小上限(默认 64MB,content-length 预检 + 流式中断双闸)+ 下载超时。
* 4. **先备份再替换**(同目录备份;Windows 上就地覆写,不用 rename —— 真机踩过),
* 失败可回滚;写后校验不一致自动回滚。
* 5. `dry_run` 只下载校验、不动现有文件 —— 用于「先确认能不能更新」。
* 6. 动作全程进审计(能力调用本身已记录 `invoke`,这里再加一条 `agent.update`)。
*
* ## 为什么不用 `fetch` 的代理能力
* Node 内置 `fetch` 不支持 HTTP 代理(需要 undici 的 ProxyAgent,而 undici 未对外暴露)。
* 国内访问 GitHub 不稳时,正确做法是**把 url 指向自建镜像 / 加速服务**(本能力接受任意
* http(s) 地址,天然支持),而不是在 agent 里再塞一套代理隧道。
* ## 出网:经 `net/proxy`(v2.1.0,已批准的方案 A)
* 历史立场(v19~v2.0.3)是「改用镜像地址,而不是在 agent 里塞代理隧道」——
* 真机教训证明该立场不成立:境内无透明代理的机器上,**即使配好了代理**,
* Node 内置 fetch 也不读代理环境变量、更读不到 Windows/macOS 系统代理,
* 「自更新」必然失败(与控制台 sidecar 2026-10-13 的同一缺陷)。
* 故 v2.1.0 起复用 sidecar 已验证的出网方案(`apps/console/sidecar/proxy.mjs`
* 的 TS 移植,见 `net/proxy.ts`):
* `NA_PROXY` → 目标协议环境变量 → 系统代理(reg query / scutil);
* PAC / socks **明确报错不静默降级**;整个重定向链共享一个超时预算。
*/
const MAX_BYTES = 64 * 1024 * 1024;
/** 下载到内存(带大小上限与超时)。 */
async function download(url: string, timeoutMs: number): Promise<Buffer> {
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), timeoutMs);
/** 出网诊断信息(返回结构与 CapabilityError.data 共用,保证成功/失败口径一致)。 */
interface OutboundInfo {
via: string;
proxy: string | null;
proxy_source: string | null;
notes: string[];
}
/** 组一条可操作的失败线索:走了哪条路、代理从哪来、为什么不走代理。 */
function outboundHint(o: OutboundInfo, timeoutMs: number): Record<string, unknown> {
return {
via: o.via,
proxy: o.proxy,
proxy_source: o.proxy_source,
timeout_ms: timeoutMs,
notes: o.notes,
hint: o.proxy
? '代理已生效但仍失败:请确认代理本身能访问更新源(浏览器里试同一个地址)'
: '未使用代理。若本机需要代理才能访问更新源,请在系统里开启「系统代理」,或设置环境变量 NA_PROXY=http://127.0.0.1:<端口> 后重启被控端',
};
}
/**
* 是否为「主动中止」类错误 —— **只认真正的 abort**。
*
* ⚠️ 别把 `ECONNRESET` 之类也塞进来(sidecar 第一版就这么写的,真机上立刻现形):
* 那次耗时 82.9s、预算 120s,连接是被对端**中途重置**的,却被报成
* 「请求超时(120000ms 内未完成)」—— 一句话把用户引到完全错误的方向上。
* 此处按 sidecar `main.mjs:146-151` **同款移植**,不许重新发明。
*/
export function isAbortLike(err: unknown): boolean {
const name = (err as { name?: string } | null)?.name ?? '';
const code = (err as { code?: string } | null)?.code ?? '';
const msg = String((err as { message?: unknown } | null)?.message ?? '');
return name === 'AbortError' || code === 'ABORT_ERR' || /\babort(ed)?\b/i.test(msg);
}
/**
* 把底层网络错误翻译成「用户能据以行动」的一句话。
*
* 按 sidecar `main.mjs:154-165` **同款移植**(含真机教训:ECONNRESET 不是超时)。
*/
export function describeNetError(err: unknown, timeoutMs: number): string {
const msg = err instanceof Error ? err.message : String(err);
const code = (err as { code?: string } | null)?.code ?? '';
if (isAbortLike(err)) return `请求超时(${timeoutMs}ms 内未完成)`;
if (code === 'ECONNRESET' || code === 'EPIPE') {
return `${msg}(连接被对端中途断开;若走了代理,多半是代理或线路的问题)`;
}
if (code === 'ENOTFOUND' || code === 'EAI_AGAIN' || code === 'ECONNREFUSED') {
return `${msg}(域名解析失败或目标拒绝连接;若配了代理,请核对代理地址与端口)`;
}
// 2026-10-27 真机补:境内直连 GitHub 的**最常见**失败形态就是 TCP 层 ETIMEDOUT
// (不是我们自己的 timeoutMs,也不是 ECONNRESET)。原先它落到下面兜底分支,
// 用户只看到一串 IP:443 超时,不知道该干什么 —— 而这时唯一有效的动作就是配代理。
if (code === 'ETIMEDOUT' || code === 'EHOSTUNREACH' || code === 'ENETUNREACH') {
return `${msg}(连接超时/网络不可达;若目标在境外或需要代理,请设置 NA_PROXY=<代理地址> 或配置系统代理后重试)`;
}
return msg;
}
/** 决议代理;任何异常都退化成「直连」—— 探测本身绝不该成为新的失败点。 */
async function resolveProxySafe(targetUrl: string): Promise<{ proxy: URL | null; info: OutboundInfo }> {
try {
const res = await fetch(url, { signal: ac.signal, redirect: 'follow' });
if (!res.ok) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, `下载失败: HTTP ${res.status} ${res.statusText}`, {
url,
});
}
const declared = Number(res.headers.get('content-length') ?? '0');
if (declared > MAX_BYTES) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `文件过大(${declared} 字节 > 上限 ${MAX_BYTES})`, { url });
}
const reader = res.body?.getReader();
if (!reader) {
return Buffer.from(await res.arrayBuffer());
}
const parts: Uint8Array[] = [];
let total = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
total += value.length;
if (total > MAX_BYTES) {
await reader.cancel();
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `下载超过上限 ${MAX_BYTES} 字节,已中断`, { url });
}
parts.push(value);
const r = await resolveProxy({ targetUrl });
return {
proxy: r.proxy,
info: {
via: r.proxy ? `经代理 ${r.proxy.href}` : '直连',
proxy: r.proxy ? r.proxy.href : null,
proxy_source: r.source,
notes: r.notes,
},
};
} catch (err) {
return {
proxy: null,
info: {
via: '直连',
proxy: null,
proxy_source: null,
notes: [`代理探测异常(${err instanceof Error ? err.message : String(err)}),按直连处理`],
},
};
}
}
/**
* 下载到内存(带大小上限与超时;经代理出网)。
*
* 超时语义:`timeoutMs` 是**整条重定向链的总预算**(与 sidecar 一致:链共享一个
* AbortController,防 5 跳 × 30s 穿过 RPC 超时)。
*
* @returns 下载内容 + 出网诊断(成功/失败都带,保证「代理没配 / 哈希不符 / 超时」三类可分)
*/
async function download(
url: string,
timeoutMs: number,
): Promise<{ buf: Buffer; outbound: OutboundInfo }> {
const { proxy, info } = await resolveProxySafe(url);
let res;
try {
res = await httpGet(url, { proxy, timeoutMs, maxBytes: MAX_BYTES });
} catch (err) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, `下载失败: ${describeNetError(err, timeoutMs)}`, {
url,
...outboundHint(info, timeoutMs),
});
}
if (res.status < 200 || res.status >= 300) {
throw new CapabilityError(
ErrorCodes.EXECUTION_FAILED,
`下载失败: HTTP ${res.status} ${res.statusText}`,
{ url, ...outboundHint(info, timeoutMs) },
);
}
const declared = Number(res.headers['content-length'] ?? '0');
if (declared > MAX_BYTES) {
throw new CapabilityError(
ErrorCodes.PARAM_INVALID,
`文件过大(${declared} 字节 > 上限 ${MAX_BYTES})`,
{ url, ...outboundHint(info, timeoutMs) },
);
}
if (res.body.length > MAX_BYTES) {
throw new CapabilityError(
ErrorCodes.PARAM_INVALID,
`下载超过上限 ${MAX_BYTES} 字节,已中断`,
{ url, ...outboundHint(info, timeoutMs) },
);
}
return { buf: res.body, outbound: info };
}
/**
* 备份清理计划(纯函数,供单测):
* 按 mtime **新→旧**排序,保留最近 `keep` 个,返回**应删除**的名单。
*
* 为什么需要:selfupdate 的备份是 `<script>.bak-<ts>`、永不清理 ——
* 推送式 deploy 踩过的坑(真机堆了 15+ 个 bak)在拉取式路径复发。
* 保留最近 3 个:再往前的版本可由更早的备份或重新发布获得,磁盘不无限堆积。
*/
export function planBackupCleanup(files: { name: string; mtime_ms: number }[], keep = 3): string[] {
const sorted = [...files].sort((a, b) => b.mtime_ms - a.mtime_ms);
// 反过来给「最旧先删」:万一删到一半失败,已删掉的也是价值最低的那些
return sorted.slice(Math.max(0, keep)).reverse().map((f) => f.name);
}
/**
* 清理入口同目录的旧 selfupdate 备份(保留最近 3 个)。
*
* 删除失败一律忽略(被杀软占位的 .bak 删不掉不影响正确性)。
* @returns 实际删除的文件名(供审计/返回值观察)
*/
function cleanupOldBackups(scriptPath: string, keep = 3): string[] {
try {
const dir = dirname(scriptPath);
const prefix = `${basename(scriptPath)}.bak-`;
const files: { name: string; mtime_ms: number }[] = [];
for (const name of readdirSync(dir)) {
if (!name.startsWith(prefix)) continue;
try {
files.push({ name, mtime_ms: statSync(join(dir, name)).mtimeMs });
} catch {
/* 文件可能已消失,跳过 */
}
}
return Buffer.concat(parts);
} catch (err) {
if (err instanceof CapabilityError) throw err;
const msg = err instanceof Error ? err.message : String(err);
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, `下载失败: ${msg}`, { url });
} finally {
clearTimeout(timer);
const removed: string[] = [];
for (const name of planBackupCleanup(files, keep)) {
try {
unlinkSync(join(dir, name));
removed.push(name);
} catch {
/* 删不掉不影响正确性 */
}
}
return removed;
} catch {
return [];
}
}
@@ -109,9 +264,9 @@ export async function agentUpdate(args: Args): Promise<unknown> {
const dir = join(agentDir(), 'update');
mkdirSync(dir, { recursive: true });
// ① 下载 + ② 校验哈希(先校验再落盘,避免半成品污染)
// ① 下载(经代理出网)+ ② 校验哈希(先校验再落盘,避免半成品污染)
const t0 = Date.now();
const buf = await download(url, timeoutMs);
const { buf, outbound } = await download(url, timeoutMs);
const got = createHash('sha256').update(buf).digest('hex');
if (!got.startsWith(expect) && got !== expect) {
audit({ type: 'agent.update', status: 'failed', reason: `hash mismatch url=${url} expect=${expect} got=${got.slice(0, 12)}` });
@@ -119,6 +274,7 @@ export async function agentUpdate(args: Args): Promise<unknown> {
expected: expect,
actual: got.slice(0, 12),
bytes: buf.length,
...outboundHint(outbound, timeoutMs),
});
}
@@ -146,7 +302,7 @@ export async function agentUpdate(args: Args): Promise<unknown> {
} catch {
/* 忽略 */
}
audit({ type: 'agent.update', status: 'ok', reason: `dry_run url=${url} hash=${got.slice(0, 12)}` });
audit({ type: 'agent.update', status: 'ok', reason: `dry_run url=${url} hash=${got.slice(0, 12)} via=${outbound.via}` });
return {
dry_run: true,
verified: true,
@@ -155,10 +311,15 @@ export async function agentUpdate(args: Args): Promise<unknown> {
current_hash: prevHash,
current_bytes: prevBytes,
note: '仅下载并校验,未改动任何文件',
via: outbound.via,
proxy: outbound.proxy,
proxy_source: outbound.proxy_source,
outbound_notes: outbound.notes,
};
}
// ③ 备份 + ④ 原子替换(同目录 rename,失败时旧文件不受影响)
// ③ 备份 + ④ 替换(同目录备份;就地覆写不用 rename —— Windows 不允许替换
// 正被运行进程打开的文件,真机踩过:rename 必失败)
const backup = `${scriptPath}.bak-${Date.now()}`;
try {
copyFileSync(scriptPath, backup);
@@ -200,13 +361,18 @@ export async function agentUpdate(args: Args): Promise<unknown> {
/* 清理失败不影响正确性 */
}
// ⑤ 备份清理:保留最近 3 个,其余删(A-P0-3:selfupdate 路径此前永不清理)
const removedBackups = cleanupOldBackups(scriptPath, 3);
audit({
type: 'agent.update',
status: 'ok',
reason: `url=${url} ${prevHash}->${got.slice(0, 12)} bytes=${buf.length} backup=${backup}`,
reason:
`url=${url} ${prevHash}->${got.slice(0, 12)} bytes=${buf.length} backup=${backup} via=${outbound.via}` +
(removedBackups.length > 0 ? ` cleaned=${removedBackups.length}` : ''),
});
// ⑤ 受控重启(复用 v7;Windows 走计划任务、POSIX 走 detached,均不依赖本进程存活)
// ⑥ 受控重启(复用 v7;Windows 走计划任务、POSIX 走 detached,均不依赖本进程存活)
let restarted = false;
if (doRestart) {
await agentRestart({ delay_ms: 1500, reason: 'self-update' });
@@ -227,5 +393,10 @@ export async function agentUpdate(args: Args): Promise<unknown> {
? '已替换并重启;请用 system.info 的 build.hash 复核(应等于 incoming_hash)。若异常,把 backup_path 覆盖回入口即可回滚'
: '已替换但未重启(restart=false);重启后才会生效',
platform: IS_WINDOWS ? 'windows' : 'posix',
// v2.1.0 新增(只增字段):出网诊断
via: outbound.via,
proxy: outbound.proxy,
proxy_source: outbound.proxy_source,
outbound_notes: outbound.notes,
};
}
+21 -6
View File
@@ -10,7 +10,9 @@ import { loadAgentConfig } from '../config.js';
import { readAudit, readAuditAll, verifyAudit, computeAuditHead, anchorAudit, compareWithAnchors } from '../audit.js';
import { computeMetrics } from '../metrics.js';
import { allPsShellStats } from '../util/ps-helper.js';
import { lkgStatus } from '../lkg.js';
import { startTask } from './task.js';
import { psLit } from '../util/quote.js';
type Args = Record<string, unknown>;
@@ -150,6 +152,10 @@ export async function systemInfo(args: Args): Promise<unknown> {
network: {
allow_from: configAllowFrom(),
},
// 交付流水线 §6:最后已知可用版本(回滚锚点)。
// matches=false 表示「当前版本还没被证明能启动」—— 刚更新完、尚未完成一次成功
// 启动时会是这种情况,控制端应据此暂缓向其余设备放量。
lkg: lkgStatus(),
};
if (fields && fields.length > 0) {
@@ -534,11 +540,20 @@ async function scheduleWindowsRestart(p: RestartParams): Promise<unknown> {
// (= 自杀,表现为"restart 成功但 agent 消失")。原实现只清非 Running,
// 代价是真机累积了 3 个 Running 僵尸(2026-10-09 实测)。
//
// 现策略:非 Running **或** LastRunTime 早于 1 小时 → 清。
// 1 小时余量远大于一次重启的耗时,因此"当前宿主"绝不会被误判(它刚刚才运行)。
`$cut = (Get-Date).AddHours(-1)`,
// 现策略:非 Running **或** LastRunTime **早于"本 agent 启动时刻"** → 清。
//
// ⚠️ 2026-10-11 真机(虚拟化平台上的 Win11 ARM)修正:原先用「早于 1 小时」作阈值,
// 实测发现**频繁 restart(间隔 < 1h)时旧任务照样累积**(v26 想治的僵尸只是从
// "永不清理"变成"1 小时后才清")。根因是阈值选得太粗:
// 阈值应表达「**这个任务是不是我(当前 agent)的宿主**」——
// 凡在**我启动之前**就运行的任务,必然不是我的宿主(我的宿主是刚刚才创建的),
// 因此可以安全删除。用 uptime 换算出的启动时刻正是这个边界。
`$agentStart = (Get-Date).AddMilliseconds(-${Math.max(0, Math.round(process.uptime() * 1000))})`,
`$cut = $agentStart`,
`Get-ScheduledTask -TaskName 'nodeagent-selfrestart*' -ErrorAction SilentlyContinue | Where-Object { if ($_.State -ne 'Running') { return $true }; $ti = ($_ | Get-ScheduledTaskInfo -ErrorAction SilentlyContinue); if ($ti -and $ti.LastRunTime -and $ti.LastRunTime -lt $cut) { return $true }; return $false } | Unregister-ScheduledTask -Confirm:$false -ErrorAction SilentlyContinue`,
`$a = New-ScheduledTaskAction -Execute 'cmd.exe' -Argument '${cmdLine.replace(/'/g, "''")}'`,
// ⚠️ 不要手写 `replace(/'/g,"''")` —— 那是「又发明一份 psLit」,两份实现必然漂移。
// PS 里 `\` 不是转义符、双引号串还会插值,故 cmdLine 唯一安全的落法就是单引号字面量。
`$a = New-ScheduledTaskAction -Execute 'cmd.exe' -Argument ${psLit(cmdLine)}`,
// 不限时(默认 72h 后强制结束),保证 node 能长期运行
`$s = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Seconds 0)`,
// ① 先试「最高权限」:agent 通常以管理员运行,普通权限的 taskkill 杀不掉它,
@@ -616,7 +631,7 @@ export async function auditVerify(_args: Args): Promise<unknown> {
}
/**
* v25:返回审计链的链头(只读)。
* v2.0.0:返回审计链的链头(只读)。
*
* 用途:控制端定期拉取并存到**链外**(Mac 本地 / 另一台机器)—— 这是"外部锚定"的一半。
* compare=true 时附带与最近一条锚点的比对结论(另一半)。
@@ -628,7 +643,7 @@ export async function auditHead(args: Args): Promise<unknown> {
}
/**
* v25:把当前链头**追加**写到锚点文件(默认 <数据目录>/audit-anchors.jsonl)。
* v2.0.0:把当前链头**追加**写到锚点文件(默认 <数据目录>/audit-anchors.jsonl)。
*
* 为什么是追加而不是覆盖:历史锚点一旦写成就不可被后续覆盖,否则攻击者重写链后
* 再"刷新"锚点即可抹掉痕迹。追加式让每次锚定都留痕。
+1 -1
View File
@@ -132,7 +132,7 @@ function getTask(id: string): TaskRecord {
// ---------------- system.task.list ----------------
/** v23:正在跑的任务数(连接层"有空闲断开但不动长任务"的判据)。 */
/** v2.0.0:正在跑的任务数(连接层"有空闲断开但不动长任务"的判据)。 */
export function runningTaskCount(): number {
let n = 0;
for (const t of tasks.values()) if (t.state === 'running') n += 1;
@@ -7,17 +7,19 @@
*/
/**
* OCR 引擎预加载段(v14):截图 / DPI / WinRT 类型与 Await 辅助函数。
* OCR 引擎预加载段(v14):截图 / WinRT 类型与 Await 辅助函数。
* 注意:WinRT 类型加载用 try/catch 包住 —— 个别环境缺组件时只应让 OCR 退化,
* 不该让整个助手(连带 window.list/focus/UIA)起不来。
*
* ⚠️ DPI 感知(`SetProcessDPIAware`)**已从本段移出**,改由 WIN_HELPER_PRELUDE 最前面的
* `PS_DPI_AWARE` 无条件执行:放在这个 try 里会被 catch 顺带吞掉,一旦吞掉
* 助手就退回 DPI 不感知,`GetWindowRect` 与 UIA / 截图坐标口径不一致(真机 2026-10-12 排查)。
*/
export const OCR_PRELUDE = `
Add-Type -AssemblyName System.Drawing
Add-Type -AssemblyName System.Windows.Forms
try {
Add-Type -AssemblyName System.Runtime.WindowsRuntime
Add-Type -TypeDefinition 'using System.Runtime.InteropServices; public class NADPI { [DllImport("user32.dll")] public static extern bool SetProcessDPIAware(); }'
[void][NADPI]::SetProcessDPIAware()
$null = [Windows.Media.Ocr.OcrEngine, Windows.Foundation, ContentType=WindowsRuntime]
$null = [Windows.Graphics.Imaging.BitmapDecoder, Windows.Foundation, ContentType=WindowsRuntime]
$null = [Windows.Storage.Streams.InMemoryRandomAccessStream, Windows.Foundation, ContentType=WindowsRuntime]
@@ -32,7 +32,7 @@ function Get-WinByTitle([string]$re) {
# v14:按子串在给定 scope 内遍历查找(FindAll + 子串过滤)。
# UIA 的 PropertyCondition 只能精确匹配,故必须遍历后过滤;遍历规模设上限防超大 UI 树卡死。
# v23:读取元素的 UIA 属性(v1.7 语义属性化)。
# v2.0.0:读取元素的 UIA 属性(v2.0.0 语义属性化)。
# 为什么逐个 try:ValuePattern / SelectionItemPattern / TogglePattern 并非所有控件
# 都支持(不支持时 GetSupportedPattern 返回 false 或抛异常),而且**部分应用
# (Electron / 游戏 UI)根本给不出值** —— 拿不到就留空,由上层判"未知"而非"false"。
+33 -15
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]{
@@ -300,7 +316,7 @@ export async function screenFind(args: Args): Promise<unknown> {
}
const text = (args['text'] as string | undefined) ?? '';
const wantImage = (args['method'] as string | undefined) === 'image';
// v1.8 修正:`where` 可单独使用(不传 text)——「找一个 enabled 的按钮」比
// v2.0.0 修正:`where` 可单独使用(不传 text)——「找一个 enabled 的按钮」比
// 「找一个叫某名字的按钮」更贴近真实意图。此前只在 gui.await 侧放开了,
// screen.find 自身的入口校验没同步,导致直连调用(以及 await 的 where-only)
// 被这里的"text 不能为空"拦下(真机 2026-10-09 验证时暴露)。
@@ -317,7 +333,7 @@ export async function screenFind(args: Args): Promise<unknown> {
}
const windowTitle = args['window'] as string | undefined;
const controlType = args['control_type'] as string | undefined;
// v1.7:属性谓词。仅 UIA 引擎生效;OCR / 图像模板忽略(无属性可读),
// v2.0.0:属性谓词。仅 UIA 引擎生效;OCR / 图像模板忽略(无属性可读),
// 此时在返回里带 note 说明,避免调用方误以为"按属性过滤过了"。
const whereArg = args['where'] as Record<string, unknown> | undefined;
const limit = (args['limit'] as number | undefined) ?? 20;
@@ -337,12 +353,13 @@ export async function screenFind(args: Args): Promise<unknown> {
// ---------- UIA 引擎 ----------
const uiaFind = async (): Promise<Record<string, unknown>[]> => {
const ct = controlType ?? '';
// ⚠️ 全部来自控制端,一律 psLit(详见文件顶部 windowFocus 处的 P0 说明)
const body = `
$text = ${JSON.stringify(text)}
$text = ${psLit(text)}
$limit = ${Number(limit)}
$ct = ${JSON.stringify(controlType ?? '')}
$winTitle = ${JSON.stringify(windowTitle ?? '')}
# v1.7 属性过滤:where 只含 enabled/selected/value/toggle 四类键(白名单式)。
$ct = ${psLit(controlType ?? '')}
$winTitle = ${psLit(windowTitle ?? '')}
# v2.0.0 属性过滤:where 只含 enabled/selected/value/toggle 四类键(白名单式)。
#
# ⚠️⚠️ 这里踩过**两次**同一个坑,真机 2026-10-09 连修两轮才通:
# ✗ 第一版:$where = {"enabled":true} → PS 不认 JSON 字面量,ParseError
@@ -352,7 +369,7 @@ $winTitle = ${JSON.stringify(windowTitle ?? '')}
# ✓ 第三版:**PS 单引号字符串字面量**,JSON 内的单引号翻倍成 ''(本行)
# 并在真机现场验证过:$j = '{"enabled":true}' → ConvertFrom-Json 摊平成哈希表 → OK。
# 摊平是因为 Test-UiaWhere 依赖 .Keys 遍历,而 PSCustomObject 没有 Keys。
$whereJson = '${(whereArg ? JSON.stringify(whereArg) : '').replace(/'/g, "''")}'
$whereJson = ${psLit(whereArg ? JSON.stringify(whereArg) : '')}
$where = $null
if ($whereJson -ne '') {
$where = @{}
@@ -402,7 +419,8 @@ if ($out.Count -eq 0) { Write-Output '[]' } else { $out | ConvertTo-Json -Compre
const shot = join(tmpDir, `match-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.png`);
// PowerShell 单引号字面量:不插值(避免路径里的 $ 被展开)、不处理反斜杠转义。
// 不能用 JSON.stringify —— 它会产出双引号串,而 PS 双引号会做变量插值且把 \ 当字面两个反斜杠。
const q = (v: string): string => `'${v.replace(/'/g, "''")}'`;
// (统一走 util/quote.ts 的 psLit,避免各文件各写一份实现而漏改)
const q = psLit;
// 截图区域:显式 region 优先,否则整块虚拟屏
const capX = regionArg ? regionArg.x : 'VSX';
@@ -473,7 +491,7 @@ $xv = ${regionArg ? regionArg.x : -1}
$yv = ${regionArg ? regionArg.y : -1}
$wv = ${regionArg ? regionArg.width : -1}
$hv = ${regionArg ? regionArg.height : -1}
$winRe = ${JSON.stringify(regionArg ? '' : windowTitle ?? '')}
$winRe = ${psLit(regionArg ? '' : windowTitle ?? '')}
$x = 0; $y = 0
$vs = [System.Windows.Forms.SystemInformation]::VirtualScreen
$w = $vs.Width; $h = $vs.Height
@@ -533,11 +551,11 @@ $engine = [Windows.Media.Ocr.OcrEngine]::TryCreateFromUserProfileLanguages()
if ($null -eq $engine) { Write-Output '[]'; return }
$result = Await ($engine.RecognizeAsync($soft)) ([Windows.Media.Ocr.OcrResult])
$text = ${JSON.stringify(text)}
$text = ${psLit(text)}
$limit = ${Number(limit)}
# Windows OCR 会在中文字符间插入空格(如「我的加速」识别为「我 的 加 速」),
# 因此匹配一律在「去除空白后」的文本上进行(两边同样处理)。
$textPlain = ${JSON.stringify(text)}.Replace(' ','')
$textPlain = (${psLit(text)}).Replace(' ','')
if ($textPlain -eq '') { $textPlain = ($text -replace '\\s','') }
$out = New-Object System.Collections.ArrayList
foreach ($line in $result.Lines) {
@@ -626,7 +644,7 @@ $out | ConvertTo-Json -Compress
await new Promise((r) => setTimeout(r, intervalMs));
last = await attempt();
}
// v1.8:where 只在 UIA 引擎下有属性可读 —— 若最终走的是 OCR/image,
// v2.0.0:where 只在 UIA 引擎下有属性可读 —— 若最终走的是 OCR/image,
// 必须**明确告知**,否则调用方会以为"按属性过滤过了"而误判结果。
const whereIgnored = hasWhere && last.engine !== 'uia';
return {
+11 -3
View File
@@ -13,7 +13,7 @@ export interface TlsMaterial {
* 确保自签证书存在(首次生成并缓存到 ~/.nodeagent/certs/)。
* v1 用自签证书做传输加密;控制端可校验证书指纹或临时跳过校验。
*/
export function ensureCert(): TlsMaterial {
export async function ensureCert(): Promise<TlsMaterial> {
const dir = join(agentDir(), 'certs');
const certPath = join(dir, 'cert.pem');
const keyPath = join(dir, 'key.pem');
@@ -23,8 +23,16 @@ export function ensureCert(): TlsMaterial {
}
mkdirSync(dir, { recursive: true });
const pems = selfsigned.generate([{ name: 'commonName', value: 'nodeagent' }], {
days: 3650,
// selfsigned 5.x:`generate` 改为**异步**(旧版同步返回)。
// 升级动因不是追新,而是安全:2.x 依赖 node-forge 1.4.0,
// 该版本有 high 级 advisory 且**上游无修复版本**(patched_versions: None);
// 5.x 已整体换成 @peculiar/x509 + pkijs,node-forge 被彻底移除。
// v5 把 `days` 换成了 `notAfterDate`(绝对时间),语义相同但必须换算 ——
// 这是升级时的**静默不兼容点**:老写法编译期报 TS2353,能拦住;
// 但若只改类型不改值,就会签出一个有效期不对的证书,运行期才暴露。
const notAfterDate = new Date(Date.now() + 3650 * 24 * 60 * 60 * 1000);
const pems = await selfsigned.generate([{ name: 'commonName', value: 'nodeagent' }], {
notAfterDate,
keySize: 2048,
algorithm: 'sha256',
});
+1 -1
View File
@@ -43,7 +43,7 @@ export interface AgentConfig {
allow_from?: string[];
/**
* v23 连接层防护(三项,默认值见 server.ts)。
* v2.0.0 连接层防护(三项,默认值见 server.ts)。
* 单项缺省即启用默认策略;显式设 0 可关闭对应项。
*/
security?: {
+55 -8
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,19 +280,35 @@ export async function eventList(_args: Args): Promise<unknown> {
};
}
export async function eventPoll(args: Args): Promise<unknown> {
/** 拉取事件:只回「属于本 client_id 的订阅」产生的事件。 */
export async function eventPoll(args: Args, ctx?: WatchContext): Promise<unknown> {
const limit = Math.max(1, Math.min(500, (args['limit'] as number | undefined) ?? 50));
const since = (args['since'] as number | undefined) ?? 0;
const watchId = args['watch_id'] as string | undefined;
const callerId = ctx?.clientId ?? '';
// watch_id 显式指定时先验归属,避免「猜 id 就能读别人事件」
if (watchId) {
const rec = watches.get(watchId);
if (rec && !ownedBy(rec, callerId)) {
throw new CapabilityError(ErrorCodes.ACL_DENIED, `订阅 ${watchId} 属于其他控制端`, { watch_id: watchId });
}
}
let list = buffer.filter((e) => e.seq > since);
if (watchId) list = list.filter((e) => e.watch_id === watchId);
if (watchId) {
list = list.filter((e) => e.watch_id === watchId);
} else if (callerId) {
const allowed = new Set(
[...watches.values()].filter((w) => ownedBy(w, callerId)).map((w) => w.id),
);
list = list.filter((e) => allowed.has(e.watch_id));
}
const sliced = list.slice(0, limit);
const nextCursor = sliced.length > 0 ? sliced[sliced.length - 1]!.seq : since;
return { events: sliced, next_cursor: nextCursor, dropped };
}
/** 连接断开时清理该连接创建的所有订阅(避免 watcher 泄漏)。 */
/** v23:当前事件订阅数(空闲断开的豁免判据之一)。 */
/** v2.0.0:当前事件订阅数(空闲断开的豁免判据之一)。 */
export function watchCount(): number {
return watches.size;
}
+150 -2
View File
@@ -1,12 +1,15 @@
import os from 'node:os';
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { warmupWindowHelper } from './capabilities/window.js';
import { CAPABILITY_MANIFEST } from '@nodeagent/protocol';
import { loadAgentConfig, agentConfigPath } from './config.js';
import { agentDir, loadAgentConfig, agentConfigPath } from './config.js';
import { ensureCert } from './certs.js';
import { createAgentServer } from './server.js';
import { initAudit, audit, auditFilePath } from './audit.js';
import { startBeacon, DEFAULT_DISCOVERY_PORT, type Beacon } from './discovery.js';
import { startHubClient, type HubClient } from './hub-mode.js';
import { recordLKG } from './lkg.js';
function listLocalIps(): string[] {
const out: string[] = [];
@@ -18,6 +21,143 @@ function listLocalIps(): string[] {
return out;
}
/**
* 看门狗失败计数归零(v2.1.0,决策 R-1;scripts/startup-guard.mjs)。
*
* 看门狗用 `<数据目录>/state/startup-state.json` 记录「连续启动失败次数」,
* 达到阈值即从 LKG 恢复入口。计数必须在**启动成功后**归零 —— 与 LKG 同一
* 铁律:不把「启动中」当成功,否则坏版本每崩一次都会被上一次的成功洗白,
* 阈值形同虚设。写入失败一律吞掉:重置计数不能影响启动(与 recordLKG 同哲学)。
*/
function resetStartupGuardState(): void {
try {
const dir = join(agentDir(), 'state');
mkdirSync(dir, { recursive: true });
writeFileSync(
join(dir, 'startup-state.json'),
`${JSON.stringify({ failures: 0, updated_at: new Date().toISOString() })}\n`,
'utf8',
);
} catch {
/* 重置失败不影响启动 */
}
}
// ---------------------------------------------------------------------------
// v2.0.3:进程级最后一道防线(崩溃链兜底)
// ---------------------------------------------------------------------------
//
// ## 为什么需要
// 真机缺陷链的共同根因:`ws.on('message')` 里用 `void handleMessage(...)` 启动异步处理,
// 未挂 `.catch`;而全仓**没有任何** `unhandledRejection` 处理器 → Node ≥22 的默认策略是
// **直接终止进程**。于是一条畸形报文就能把被控端打死(外部只见 `code=1` / 连接断开),
// 且因为 `verifyHmac`(psk 默认路径)没有 try/catch,连**未认证**的报文都能触发。
//
// 根治在源头(`server.ts` 已改 `.catch(...)` + 逐处报文类型校验),这里补的是
// 「将来某处再漏一个 await」时的兜底 —— 有证据地活着,胜过静默地死。
//
// ## 为什么**不**退出进程
// 被控端是常驻服务,「可用性」优先于「出错即死」。若这里改成 `process.exit(1)`,
// 就退化回「一条畸形报文打死服务」—— 正是本次要修的东西。
// 代价(可能带着不一致的状态继续跑)由两点对冲:
// ① 源头已有报文校验与 `.catch`;② 这里每次都写审计 + 日志,问题不会被掩盖。
//
// ## 为什么要限流审计
// 畸形报文可以高频重复(扫描/爆破)→ 无限写 `agent.error` 会把审计日志冲爆、把
// 正常记录挤掉。故日志每次都打(stdout 由控制台的环形缓冲兜底),但**审计按时间窗限流**。
let procErrCount = 0;
let lastProcErrAuditAt = 0;
/** 两条 `agent.error` 审计之间的最小间隔 */
const PROC_ERR_AUDIT_INTERVAL_MS = 10_000;
function reportProcessError(kind: string, detail: string): void {
procErrCount += 1;
console.error(
`[${new Date().toISOString()}] [ERROR] ${kind}(已拦截,进程继续运行;累计 ${procErrCount} 次):${detail}`,
);
const now = Date.now();
if (now - lastProcErrAuditAt < PROC_ERR_AUDIT_INTERVAL_MS) return;
lastProcErrAuditAt = now;
audit({
type: 'agent.error',
status: 'failed',
reason: `${kind}: ${detail}(累计 ${procErrCount} 次,审计按 ${PROC_ERR_AUDIT_INTERVAL_MS / 1000}s 限流)`,
});
}
process.on('unhandledRejection', (reason: unknown) => {
reportProcessError(
'未处理的 Promise 拒绝',
reason instanceof Error ? `${reason.name}: ${reason.message}` : String(reason),
);
});
// ---------------------------------------------------------------------------
// stdout / stderr 断管容错(2026-10-17 真机两次踩到)
// ---------------------------------------------------------------------------
//
// ## 现象
// 端口仍然 `OPEN`,但**所有** RPC 都超时(握手都不回)。持续数分钟不自愈,
// 只能靠重开控制台恢复。
//
// ## 根因
// 被控端由控制台 spawn 时,stdout/stderr 是**管道**而非终端。用户退出控制台
// (尤其选「保留被控端」)后,管道**读端消失**,而 agent 仍在往这个死管道写日志:
// - Node 对未监听 'error' 的 stream 会把 `EPIPE` **抛成未捕获异常** → 进程退出;
// - 更糟的是写端在无读者时可能**阻塞**,把事件循环一起卡住 ——
// 于是端口保住了(OS 层 socket 还在),但 JS 再也不执行了。
// 这解释了为什么「退出控制台后被控端还在,却谁都连不上」:它活着,但哑了。
//
// ## 做法
// ① 监听 'error' 吞掉 EPIPE,不让它升级成未捕获异常;
// ② 一旦判定通道已断,就**短路后续写**,不再往死管道里堆字节。
//
// 日志不会因此丢失:审计走文件(`audit.log`),控制台在时照常收到管道输出。
// 宁可少一行 stdout,也不能让常驻服务因为「没人看日志」而假死。
let stdoutDead = false;
let stderrDead = false;
function markDead(e: NodeJS.ErrnoException, isErr: boolean): void {
if (e.code === 'EPIPE' || e.code === 'ERR_STREAM_DESTROYED' || e.code === 'ERR_STREAM_WRITE_AFTER_END') {
if (isErr) stderrDead = true;
else stdoutDead = true;
return;
}
// 其他写错误也一律吞掉:日志通道的故障不该打死被控端
if (isErr) stderrDead = true;
else stdoutDead = true;
}
process.stdout.on('error', (e: NodeJS.ErrnoException) => markDead(e, false));
process.stderr.on('error', (e: NodeJS.ErrnoException) => markDead(e, true));
const rawLog = console.log.bind(console);
const rawErr = console.error.bind(console);
console.log = (...a: unknown[]): void => {
if (stdoutDead) return;
try {
rawLog(...a);
} catch {
stdoutDead = true;
}
};
console.error = (...a: unknown[]): void => {
if (stderrDead) return;
try {
rawErr(...a);
} catch {
stderrDead = true;
}
};
process.on('uncaughtException', (err: unknown) => {
reportProcessError(
'未捕获异常',
err instanceof Error ? `${err.name}: ${err.message}` : String(err),
);
});
async function main(): Promise<void> {
const { config, isNew } = loadAgentConfig();
initAudit({
@@ -28,7 +168,7 @@ async function main(): Promise<void> {
});
// v6:Hub 模式下不监听本地端口,改为主动外连 Hub(穿 NAT)
const useHub = config.hub?.enabled === true;
const tls = !useHub && config.tls ? ensureCert() : null;
const tls = !useHub && config.tls ? await ensureCert() : null;
const server = useHub ? null : await createAgentServer(config, tls);
let hubClient: HubClient | null = null;
@@ -76,6 +216,14 @@ async function main(): Promise<void> {
},
(msg) => console.log(`[WARN] ${msg}`),
);
// 交付流水线 §6:此刻脚本已加载、配置已读、证书已签、端口已绑、心跳已起 ——
// 走到这里说明「这个版本至少能启动」,故记为最后已知可用版本(LKG)。
// 注意**不在更新写完时就记**:那会把尚未验证的版本当成已知可用,LKG 就失去意义。
recordLKG();
// 同理(v2.1.0 看门狗):启动成功才把「连续启动失败」计数归零。
// 顺序在 recordLKG 之后、启动横幅之前 —— 两者都是「起来了」的注脚。
resetStartupGuardState();
console.log('');
console.log(' nodeagent 被控端已启动');
console.log(' ─────────────────────────────────────────────');
+179
View File
@@ -0,0 +1,179 @@
import { readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { join, basename } from 'node:path';
import { agentDir } from './config.js';
import { buildInfo } from './capabilities/system.js';
/**
* 最后已知可用版本(Last-Known-Good, LKG)—— 交付流水线 §6 的回滚锚点。
*
* ## 为什么需要(本项目特有风险,通用发布规范里没有)
* 对 Web 服务,发布失败可以回滚、可以摘流量。对 nodeagent:
* **回滚通道本身就是被更新的那个程序**。一次失败的更新会让一台物理不可达的
* 远程设备永久脱管 —— 这是本项目最严重的上线失败形态。
*
* ## 记什么、什么时候记
* 关键设计:**只在「本次启动确实起来了」之后才记**。
*
* 若在「更新刚写完」时就记 LKG,等于把还没验证的版本当成已知可用 ——
* 那 LKG 就失去了意义。故由 `index.ts` 在服务器 listen 成功、打印完启动信息后
* 调用 `recordLKG()`:此刻能走到这一行,说明脚本能被 Node 加载、配置能读、
* 证书能签、端口能绑。**能启动 ≠ 功能全对,但能启动 = 至少不是「打死」状态。**
*
* ## 明确不做的事:自动回滚
* 本文刻意**不实现**「启动失败自动回滚」。理由:
* - 远程设备上无法区分「更新本身坏了」与「网络抖动 / 端口被占 / 依赖服务没起」;
* - 自动回滚可能触发**回滚循环**(回滚→启动→又判定失败→再回滚),
* 在无人值守设备上比「停在坏版本」更难恢复;
* - 被控端一旦自作主张改自己的程序,控制端将失去对「当前跑的是什么」的掌控。
*
* 因此 LKG 的定位是**给回滚提供确定的目标**,而不是替人做回滚决策:
* 控制端 `nodeagent rollback` 读 LKG → 覆盖回入口文件 → 重启。
*/
export interface LkgRecord {
/** 入口脚本(agent.mjs)的 sha256 前 12 位 —— 与 system.info 的 build.hash 同口径 */
hash: string;
/**
* v2.1.0 新增(只增字段):入口脚本的 sha256 **全量 64 位**。
*
* 为什么加:回滚时控制端要用「枚举备份 + 指纹匹配」定位内容,12 位前缀存在
* 理论碰撞风险(1/16¹²,可接受但非零);全量比对消除该风险。
* 老记录(无此字段)回落 12 位前缀比对,见 `lkgHashMatches`。
*/
hash_full?: string;
bytes: number;
script_path: string;
/**
* v2.1.0 新增(只增字段):回滚内容候选的文件名模式(`<basename>.bak*`)。
* 覆盖推送式 deploy 的固定名 `.bak` 与拉取式 selfupdate 的时间戳名 `.bak-<ts>`
* 两套命名,供控制端枚举候选(`fs.list` + 过滤)。
*/
backup_glob?: string;
/** 记录时刻(Unix ms) */
recorded_at: number;
/** 当时的 node 版本,便于判断「是不是 Node 运行时升级导致的」 */
node_version: string;
platform: string;
}
export function lkgFilePath(): string {
return join(agentDir(), 'agent-lkg.json');
}
/**
* 计算当前入口脚本指纹。
* 与 `capabilities/system.ts` 的 buildInfo() 同口径(都是脚本内容的 sha256 前 12 位),
* 故可与 `nodeagent info` 里的 `build.hash` 直接比对。
*/
function currentScriptHash(): { hash: string; hash_full: string; bytes: number; path: string } | null {
const p = process.argv[1] ?? '';
if (!p) return null;
try {
const buf = readFileSync(p);
const full = createHash('sha256').update(buf).digest('hex');
return {
hash: full.slice(0, 12),
hash_full: full,
bytes: buf.length,
path: p,
};
} catch {
return null;
}
}
/**
* LKG 指纹比对(v2.1.0):有 `hash_full` 按**全量**比对,老记录回落 12 位前缀。
*
* 纯函数、不猜:两边都拿不出可比的值时返回 false。
* 供 rollback 的「备份指纹匹配」与任何需要判断「这份内容是不是 LKG」的场景共用,
* 避免各写一份比对规则(两份规则必然漂移)。
*/
export function lkgHashMatches(rec: LkgRecord | null, candidateFullHash: string | undefined | null): boolean {
if (!rec || !candidateFullHash) return false;
if (rec.hash_full) return rec.hash_full === candidateFullHash;
return rec.hash === candidateFullHash.slice(0, 12);
}
/**
* 记录当前版本为 LKG。
*
* 任何失败都被吞掉 —— 写入失败不能影响启动。
* @returns 写入的记录,失败返回 null
*/
export function recordLKG(): LkgRecord | null {
const cur = currentScriptHash();
if (!cur) return null;
const rec: LkgRecord = {
hash: cur.hash,
hash_full: cur.hash_full,
bytes: cur.bytes,
script_path: cur.path,
backup_glob: `${basename(cur.path)}.bak*`,
recorded_at: Date.now(),
node_version: process.version,
platform: process.platform,
};
try {
mkdirSync(agentDir(), { recursive: true });
writeFileSync(lkgFilePath(), `${JSON.stringify(rec, null, 2)}\n`, 'utf8');
return rec;
} catch {
return null;
}
}
/**
* 读取 LKG 记录;不存在或损坏返回 null(不作任何猜测)。
*
* 向后兼容:v2.1.0 之前写的记录没有 `hash_full` / `backup_glob` —— 原样读回,
* 由消费方按 `lkgHashMatches` 的 12 位前缀回落处理,不在这里猜测补齐。
*/
export function readLKG(): LkgRecord | null {
try {
const raw = readFileSync(lkgFilePath(), 'utf8');
const rec = JSON.parse(raw) as LkgRecord;
if (!rec || typeof rec.hash !== 'string') return null;
return rec;
} catch {
return null;
}
}
/**
* 当前版本与 LKG 是否一致。
*
* 用途:更新后复核 —— 若「当前 hash ≠ LKG」且刚做过更新,
* 说明新版本**尚未被证明能启动**,控制端应暂缓放量到其余设备(交付流水线 §6 分批策略)。
*/
export function lkgStatus(): {
lkg: LkgRecord | null;
current_hash: string | null;
matches: boolean;
/** LKG 与当前版本的字节差,便于快速判断是「升级」还是「回退」 */
byte_delta: number | null;
} {
const lkg = readLKG();
const cur = currentScriptHash();
const current_hash = cur?.hash ?? null;
return {
lkg,
current_hash,
matches: !!lkg && lkg.hash === current_hash,
byte_delta: lkg && cur ? cur.bytes - lkg.bytes : null,
};
}
/** 供审计/排障:LKG 文件是否存在及其大小(不读内容,避免大文件 IO)。 */
export function lkgFileInfo(): { path: string; exists: boolean; bytes: number } {
const p = lkgFilePath();
try {
return { path: p, exists: true, bytes: statSync(p).size };
} catch {
return { path: p, exists: false, bytes: 0 };
}
}
/** 让 system.info 能顺带暴露构建指纹(与 buildInfo 同源,避免两处口径漂移)。 */
export { buildInfo };
+28
View File
@@ -0,0 +1,28 @@
/**
* 被控端出网模块(桶文件)—— 导出面收敛。
*
* 目前只有代理出网(`proxy.ts`)。将来若新增其它出网能力(如镜像探测),
* 也从这里统一导出,调用方只依赖本文件,不直接深入子模块。
*
* 依赖纪律:本目录下的模块**只依赖 node 内置模块**,不 import 项目内任何模块 ——
* 保证可被单测独立加载、依赖方向严格单向(capabilities/* → net/)。
*/
export {
normalizeProxyUrl,
isBypassed,
isLoopbackTarget,
pickEnvProxy,
parseScutilProxy,
parseRegQueryProxy,
detectSystemProxy,
systemProxy,
__resetSystemProxyCache,
resolveProxy,
tunnelConnection,
httpGet,
type SystemProxyInfo,
type ProxyResolution,
type HttpResponse,
type CommandRunner,
} from './proxy.js';
+618
View File
@@ -0,0 +1,618 @@
/**
* 出站代理支持 —— 被控端「拉取式自更新」的出网模块。
*
* ## 为什么必须自己实现(这是缺陷,不是优化)
*
* Node 内置 `fetch`(undici)**默认不读任何代理环境变量** —— 实测:设了 `HTTPS_PROXY`
* 仍然 `fetch failed`。能开的只有 `NODE_USE_ENV_PROXY=1`(Node 22.x 实验性、24 正式),
* 而它**只能读环境变量**,读不到 Windows 系统代理(WinINET)或 macOS 系统代理
* —— 而中文用户用 Clash / v2rayN 时配置的恰恰是「系统代理」。
* 结果:境内用户即使代理一切正常,`system.agent.update` 也**必然**报 `fetch failed`。
* (真机取证见 apps/console/sidecar/proxy.mjs 顶部:VM 上 DNS 与 TCP 443 都通、
* 却 `fetch failed`;同机经代理取 `latest.json` 返回 302。)
*
* 另外 `fetch(url, { dispatcher })` 也不通:`dispatcher` 必须是 undici 的 Dispatcher 实例,
* 而 Node 没有把 undici 暴露成可导入的模块(只能整包 vendored 进来,会把 agent.mjs
* 撑胖一大截,且 undici 依赖 `.wasm` 解析器、esbuild 单文件打包有风险)。
*
* ## 做法
*
* 不用 `fetch`,改用 `node:http` / `node:https` 自己发请求:
* - **https 目标经代理**:先向代理发 `CONNECT host:443` 建隧道,再在隧道 socket 上做 TLS
* (通过 `createConnection` 交给 `http.request` —— 注意这里刻意用 `http` 而非 `https`,
* 因为我们交出去的**已经是 TLS socket**,用 `https.request` 会二次握手);
* - **http 目标经代理**:按 HTTP 代理规范发**绝对形态**请求行(`GET http://… HTTP/1.1`);
* - **无代理**:与改动前一致,直连(走 `https.request`)。
*
* ## 代理来源优先级
*
* ① `NA_PROXY`(显式覆盖,便于排查与将来做配置项)
* ② 目标协议对应的环境变量(`HTTPS_PROXY`/`https_proxy` → `ALL_PROXY`/`all_proxy` → …)
* ③ **系统代理**(Windows 读注册表 WinINET;macOS 读 `scutil --proxy`)
*
* `NO_PROXY` / `no_proxy` 命中的主机一律直连。只支持 **HTTP 代理**(`http(s)://`)——
* HTTPS 目标经 HTTP 代理走 CONNECT,是通用做法;`socks5://` 会**明确**记一条 note
* 而不是静默直连(静默正是本次缺陷最恶劣的地方)。
*
* ## 依赖纪律
*
* 本模块**只依赖 node 内置模块**(http/https/tls/child_process),不 import 项目内
* 任何模块 —— 保证可被单测独立加载、可被将来其它出网能力复用;依赖方向严格单向
* (selfupdate → net/proxy),杜绝环。
*
* ## 安全
*
* 只做「出站取数据」,不引入任何新依赖;TLS 证书默认校验,不提供跳过开关。
*/
import http from 'node:http';
import https from 'node:https';
import tls from 'node:tls';
import { execFile } from 'node:child_process';
/** 代理来源优先级(按目标协议分列)。`NA_PROXY` 恒为第一优先。 */
const ENV_ORDER: Record<string, string[]> = {
'https:': ['NA_PROXY', 'HTTPS_PROXY', 'https_proxy', 'ALL_PROXY', 'all_proxy', 'HTTP_PROXY', 'http_proxy'],
'http:': ['NA_PROXY', 'HTTP_PROXY', 'http_proxy', 'ALL_PROXY', 'all_proxy'],
};
const NO_PROXY_KEYS = ['NO_PROXY', 'no_proxy'];
const REDIRECT_STATUS = new Set([301, 302, 303, 307, 308]);
const UA = 'nodeagent-agent';
/** 系统代理探测结果(`url` 为 `host:port` 或完整 URL;`bypass` 为逗号分隔绕过列表)。 */
export interface SystemProxyInfo {
url: string | null;
bypass: string;
notes: string[];
}
/** 代理决议结果。 */
export interface ProxyResolution {
proxy: URL | null;
source: string | null;
notes: string[];
}
/** `httpGet` 的单次响应(跟随重定向后的最终响应)。 */
export interface HttpResponse {
status: number;
statusText: string;
headers: Record<string, unknown>;
body: Buffer;
url: string;
}
/** 可注入的只读命令 runner(单测用假 runner,不必真去读注册表)。 */
export type CommandRunner = (file: string, args: string[]) => Promise<string | null>;
/**
* 把环境里/配置里写的各种代理形态归一成可用的 URL。
*
* 接受 `http://127.0.0.1:7890`、`127.0.0.1:7890`(补 `http://`)、带账密的 URL;
* 其余(`socks5://`、空串、无法解析)返回 `null` —— 由调用方把「看到了但用不了」
* 明确报出来,**不许静默直连**。
*/
export function normalizeProxyUrl(raw: unknown): URL | null {
if (typeof raw !== 'string') return null;
const s = raw.trim();
if (!s) return null;
let u: URL;
try {
u = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(s) ? s : `http://${s}`);
} catch {
return null;
}
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
if (!u.hostname) return null;
return u;
}
/** 代理 URL 的主机端口(缺省按代理自身协议补 80/443)。 */
function proxyEndpoint(u: URL): { host: string; port: number } {
return { host: u.hostname, port: u.port ? Number(u.port) : u.protocol === 'https:' ? 443 : 80 };
}
/** 代理 URL 里的账密 → `Proxy-Authorization` 头(没有则 null)。 */
function proxyAuthHeader(u: URL): string | null {
if (!u.username) return null;
const raw = `${decodeURIComponent(u.username)}:${decodeURIComponent(u.password)}`;
return 'Basic ' + Buffer.from(raw).toString('base64');
}
/**
* `NO_PROXY` 命中判定。支持:精确主机、后缀(`example.com` 或 `.example.com` 都匹配
* `a.example.com`)、`host:port`、`*`。大小写不敏感。
*
* ⚠️ 刻意**不支持 CIDR**(如 `169.254/16`):本模块的目标只有公网域名,
* 而 CIDR 主机名判定极易写错、错了就是「该走代理的没走」。遇到疑似 CIDR 条目
* 由调用方记 note(见 `pickEnvProxy` 的 notes 语义)。
*/
export function isBypassed(targetUrl: string, noProxy: string): boolean {
if (!noProxy) return false;
let host: string;
let port: string;
try {
const u = new URL(targetUrl);
host = u.hostname.toLowerCase();
port = u.port || (u.protocol === 'https:' ? '443' : '80');
} catch {
return false;
}
for (const rawEntry of String(noProxy).split(',')) {
const entry = rawEntry.trim().toLowerCase();
if (!entry) continue;
if (entry === '*') return true;
let eHost = entry;
let ePort: string | null = null;
const i = entry.lastIndexOf(':');
// 排除 IPv6 字面量(`[::1]:8080`)里的冒号误判
if (i > 0 && !entry.slice(0, i).includes(':')) {
eHost = entry.slice(0, i);
ePort = entry.slice(i + 1);
}
if (ePort && ePort !== port) continue;
const bare = eHost.replace(/^\./, '');
if (!bare) continue;
if (host === bare || host.endsWith(`.${bare}`)) return true;
}
return false;
}
/** 从环境变量里挑代理(纯函数,便于单测)。 */
export function pickEnvProxy(
env: Record<string, string | undefined>,
targetUrl: string,
): { url: URL | null; source: string | null; notes: string[] } {
let proto = 'https:';
try {
proto = new URL(targetUrl).protocol;
} catch {
/* 保持默认 */
}
const keys = ENV_ORDER[proto] ?? ENV_ORDER['https:']!;
const notes: string[] = [];
for (const k of keys) {
const v = env?.[k];
if (!v) continue;
const u = normalizeProxyUrl(v);
if (u) return { url: u, source: k, notes };
notes.push(`${k}=${String(v).trim()} 不是受支持的代理协议(仅支持 http/https 代理),已忽略`);
}
return { url: null, source: null, notes };
}
/**
* 解析 macOS `scutil --proxy` 的输出。
*
* 形如:
* <dictionary> {
* HTTPSEnable : 1
* HTTPSPort : 7890
* HTTPSProxy : 127.0.0.1
* ExceptionsList : <array> { 0 : *.local 1 : 169.254/16 }
* }
*
* 注意 macOS 上 HTTPS 与 HTTP 是**分别开关**的;这里 https 优先,取不到再退 HTTP。
* `ProxyAutoConfigEnable : 1`(PAC 自动配置脚本)**无法解析** —— 返回 notes 说明,
* 让上层给出「请显式设 HTTPS_PROXY」这类可操作提示,而不是含糊的 fetch failed。
*/
export function parseScutilProxy(text: string): SystemProxyInfo | null {
if (typeof text !== 'string' || !text.includes('{')) return null;
const scalar = (key: string): string | null => {
const m = new RegExp(`^\\s*${key}\\s*:\\s*(.+?)\\s*$`, 'm').exec(text);
return m ? m[1]!.trim() : null;
};
const isOn = (v: string | null): boolean => v === '1' || v === 'true';
const notes: string[] = [];
// ExceptionsList 是 <array> 块里的 “序号 : 值” 行 —— 收集成逗号分隔的绕过列表
const bypass: string[] = [];
const arrIdx = text.indexOf('ExceptionsList');
if (arrIdx >= 0) {
const body = text.slice(arrIdx, text.indexOf('}', arrIdx) + 1);
const re = /^\s*\d+\s*:\s*(.+?)\s*$/gm;
let m: RegExpExecArray | null;
while ((m = re.exec(body)) !== null) bypass.push(m[1]!.trim());
}
for (const [flag, hostKey, portKey, dflt] of [
['HTTPSEnable', 'HTTPSProxy', 'HTTPSPort', '443'],
['HTTPEnable', 'HTTPProxy', 'HTTPPort', '80'],
] as const) {
if (!isOn(scalar(flag))) continue;
const host = scalar(hostKey);
if (!host) continue;
const port = scalar(portKey) ?? dflt;
return { url: `${host}:${port}`, bypass: bypass.join(','), notes };
}
if (isOn(scalar('ProxyAutoConfigEnable'))) {
notes.push('系统代理使用 PAC 自动配置脚本(ProxyAutoConfigEnable=1),本程序无法解析脚本内容,已按「无代理」处理');
}
return { url: null, bypass: bypass.join(','), notes };
}
/**
* 解析 Windows `reg query …\Internet Settings` 的输出。
*
* 形如(`REG_SZ` / `REG_DWORD` 是**类型名,不随系统语言变化**,可安全按 token 解析):
* ProxyEnable REG_DWORD 0x1
* ProxyOverride REG_SZ *.local;<local>
* ProxyServer REG_SZ 127.0.0.1:7890 (也可能是 http=…;https=… 分协议形态)
*
* `ProxyEnable=0x0` 视为**显式关闭** → 无代理(哪怕 ProxyServer 有值)。
* `<local>` 是「本机地址直连」的占位符,不是域名,从绕过列表里剔除。
*/
export function parseRegQueryProxy(text: string): SystemProxyInfo | null {
if (typeof text !== 'string' || !text.includes('REG_')) return null;
const value = (name: string): string | null => {
const m = new RegExp(`^\\s*${name}\\s+REG_(?:SZ|EXPAND_SZ|DWORD|BINARY)\\s+(.*?)\\s*$`, 'm').exec(text);
return m ? m[1]!.trim() : null;
};
const notes: string[] = [];
const enable = value('ProxyEnable');
if (enable !== null && /^0x0+$/i.test(enable)) return { url: null, bypass: '', notes };
const server = value('ProxyServer');
if (!server) return { url: null, bypass: '', notes };
const override = value('ProxyOverride');
const bypass = override
? override
.split(';')
.map((s) => s.trim())
.filter((s) => s && !/^<local>$/i.test(s))
.join(',')
: '';
// 分协议形态:http=127.0.0.1:7890;https=127.0.0.1:7891 —— https 优先
let url: string | null = server;
if (server.includes('=')) {
const map = new Map<string, string>();
for (const part of server.split(';')) {
const i = part.indexOf('=');
if (i > 0) map.set(part.slice(0, i).trim().toLowerCase(), part.slice(i + 1).trim());
}
url = map.get('https') ?? map.get('http') ?? null;
if (!url) notes.push(`系统代理仅配置了非 http(s) 协议(ProxyServer=${server})`);
}
return { url, bypass, notes };
}
/**
* 跑一个只读命令,失败/超时一律返回 null(探测系统代理**绝不允许**把自更新搞挂)。
*
* `windowsHide: true` —— 被控端由计划任务/服务拉起时是「无控制台」进程,
* 若再 spawn 控制台程序(reg.exe),Windows 会**新开一个控制台** → 黑窗闪一下。
*/
function runQuiet(file: string, args: string[]): Promise<string | null> {
return new Promise((resolve) => {
execFile(
file,
args,
{ windowsHide: true, timeout: 5000, encoding: 'utf8', maxBuffer: 1024 * 1024 },
(err, stdout) => resolve(err ? null : String(stdout)),
);
});
}
/**
* 探测系统代理。`run` 可注入(单测用假 runner,不必真去读注册表)。
*
* 任何异常都吞掉返回 null —— 探测失败绝不能成为新的失败点。
*/
export async function detectSystemProxy(
platform: NodeJS.Platform = process.platform,
run: CommandRunner = runQuiet,
): Promise<SystemProxyInfo | null> {
try {
if (platform === 'win32') {
const out = await run('reg', [
'query',
'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings',
]);
return out ? parseRegQueryProxy(out) : null;
}
if (platform === 'darwin') {
const out = await run('scutil', ['--proxy']);
return out ? parseScutilProxy(out) : null;
}
// Linux:桌面环境各异(gsettings / 环境变量 / PAC),不做猜测 —— 只认环境变量
return null;
} catch {
return null;
}
}
/** 系统代理只探测一次(同一进程内结果不变;用户改代理需重启被控端)。 */
let systemProxyPromise: Promise<SystemProxyInfo | null> | undefined;
export function systemProxy(): Promise<SystemProxyInfo | null> {
if (!systemProxyPromise) systemProxyPromise = detectSystemProxy().catch(() => null);
return systemProxyPromise;
}
/** 单测用:清掉系统代理缓存。 */
export function __resetSystemProxyCache(): void {
systemProxyPromise = undefined;
}
/**
* 目标是否为「本机回环 / 链路本地」地址。
*
* 为什么强制直连(对 sidecar 原版的有意加强):代理另一侧的 `localhost` **不是**
* 本机的 localhost —— 把发往 `127.0.0.1:<自建镜像/测试源站>` 的请求交给代理,
* 必然失败且报错形态费解(代理返回它自己那侧的 404 / 拒连)。
* CI 与开发机普遍设置 `HTTP_PROXY`(本仓库 e2e 就这样踩到过:测试源站在回环,
* agent 继承沙箱代理后拿到 404)。公开域名的行为**不变**(本函数只命中回环)。
*/
export function isLoopbackTarget(targetUrl: string): boolean {
let host: string;
try {
host = new URL(targetUrl).hostname.toLowerCase();
} catch {
return false;
}
if (host === 'localhost') return true;
// IPv6 回环:URL 会把 [::1] 规范化成 ::1
if (host === '::1' || host === '[::1]') return true;
// IPv4 回环段 127.0.0.0/8(只认 0-255 的合法字节,不把 127.999 也算进去)
const m = /^127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
if (m) {
const ok = m.slice(1).every((octet) => Number(octet) <= 255);
if (ok) return true;
}
return false;
}
/**
* 决议本次请求该用哪个代理。
*
* @param opts `system` 显式传入时不做探测(单测用);传 `null` 表示「确认没有系统代理」。
*/
export async function resolveProxy(
opts: {
targetUrl: string;
env?: Record<string, string | undefined>;
system?: SystemProxyInfo | null;
} = { targetUrl: '' },
): Promise<ProxyResolution> {
const { targetUrl } = opts;
const env = opts.env ?? process.env;
const notes: string[] = [];
const noProxy = NO_PROXY_KEYS.map((k) => env?.[k]).find(Boolean) ?? '';
if (noProxy && isBypassed(targetUrl, noProxy)) {
return { proxy: null, source: null, notes: [`目标命中 NO_PROXY=${noProxy},按直连处理`] };
}
// 回环/链路本地目标恒直连(理由见 isLoopbackTarget:代理的另一侧没有这台机器的 localhost)
if (isLoopbackTarget(targetUrl)) {
return { proxy: null, source: null, notes: ['目标为本机回环地址,按直连处理(代理另一侧的 localhost 不是本机)'] };
}
const fromEnv = pickEnvProxy(env ?? {}, targetUrl);
notes.push(...fromEnv.notes);
if (fromEnv.url) return { proxy: fromEnv.url, source: fromEnv.source, notes };
const sys = opts.system === undefined ? await systemProxy() : opts.system;
if (sys?.url) {
notes.push(...(sys.notes ?? []));
if (sys.bypass && isBypassed(targetUrl, sys.bypass)) {
notes.push(`目标命中系统代理的绕过列表(${sys.bypass}),按直连处理`);
return { proxy: null, source: null, notes };
}
const u = normalizeProxyUrl(String(sys.url));
if (u) return { proxy: u, source: 'system', notes };
notes.push(`系统代理 ${sys.url} 不受支持(仅支持 http/https 代理),已按直连处理`);
return { proxy: null, source: null, notes };
}
notes.push(...(sys?.notes ?? []));
return { proxy: null, source: null, notes };
}
/**
* 造一个 `createConnection`:向代理发 CONNECT 建隧道,https 目标再在隧道上叠 TLS。
*
* 返回的 socket 交给 `http.request` 使用(那已经是 TLS socket,故用 `http` 模块)。
*
* ## ⚠️ 为什么必须自己管 `signal`(真机踩过)
* `createConnection` 的回调**返回之前**,外层 `http.request` 还没建立 —— 此时
* 外层请求上的 `signal` / `timeout` 都**无人受理**。真机形态:代理 TCP 能连上、却对
* CONNECT 或随后的 TLS 握手**永不响应**,于是「75s 超时」形同虚设,实际挂了 109s。
* 所以:CONNECT 请求要带 signal,**拿到 socket 与 tlsSock 之后还要自己挂 abort**,
* 失败/成功时都要摘掉监听,避免泄漏与误伤。
*/
export function tunnelConnection(
proxy: URL,
target: { protocol: string; hostname: string; port: string },
signal: AbortSignal | undefined,
): (opts: http.RequestOptions, cb: (err: Error | null, socket?: tls.TLSSocket) => void) => void {
const pe = proxyEndpoint(proxy);
return (_opts, cb) => {
let settled = false;
let tunnelSock: import('node:net').Socket | null = null;
let tlsSock: tls.TLSSocket | null = null;
const detach = (): void => {
if (signal) signal.removeEventListener('abort', onAbort);
if (tunnelSock) tunnelSock.removeListener('error', onSockError);
};
const fail = (err: Error): void => {
if (settled) return;
settled = true;
detach();
// 半开的隧道必须销毁,否则这个悬挂连接会一直占着(点几次就攒一堆)
try {
tlsSock?.destroy();
tunnelSock?.destroy();
} catch {
/* 已经没了 */
}
cb(err);
};
const onSockError = (e: Error): void => fail(e);
const onAbort = (): void => fail(signal?.reason instanceof Error ? signal.reason : new Error('请求已中止'));
if (signal?.aborted) {
onAbort();
return;
}
signal?.addEventListener('abort', onAbort, { once: true });
const port = target.port || (target.protocol === 'https:' ? '443' : '80');
const headers: Record<string, string> = { host: `${target.hostname}:${port}` };
const auth = proxyAuthHeader(proxy);
if (auth) headers['proxy-authorization'] = auth;
const req = http.request({
host: pe.host,
port: pe.port,
method: 'CONNECT',
path: `${target.hostname}:${port}`,
headers,
agent: false,
signal,
});
req.once('connect', (res, socket, head) => {
tunnelSock = socket;
if (res.statusCode !== 200) {
fail(new Error(`代理拒绝 CONNECT(HTTP ${res.statusCode})`));
return;
}
if (head && head.length) socket.unshift(head);
if (target.protocol !== 'https:') {
settled = true;
detach();
cb(null, socket as unknown as tls.TLSSocket);
return;
}
socket.once('error', onSockError);
tlsSock = tls.connect({ socket, servername: target.hostname });
tlsSock.once('secureConnect', () => {
if (settled) return;
settled = true;
detach();
cb(null, tlsSock!);
});
tlsSock.once('error', onSockError);
});
req.once('error', onSockError);
req.end();
};
}
/** 单跳响应(`url` 由 httpGet 在跟随重定向后补上)。 */
type SingleResponse = Omit<HttpResponse, 'url'>;
/** 单跳请求(不跟随重定向)。 */
function once(
urlStr: string,
{ proxy, timeoutMs, signal, maxBytes }: { proxy: URL | null; timeoutMs: number; signal: AbortSignal; maxBytes?: number },
): Promise<SingleResponse> {
return new Promise((resolve, reject) => {
const u = new URL(urlStr);
const isHttps = u.protocol === 'https:';
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
reject(new Error(`不支持的协议:${u.protocol}`));
return;
}
// ⚠️ 手搭 options,**不要**把 URL 对象当第一参再覆盖字段 —— 传 URL 时 Node 会用
// URL 里的 host/port/path 覆盖后面的同名字段(原型实测踩过:代理形同无效、退化成直连)。
const opts: http.RequestOptions = {
method: 'GET',
signal,
timeout: timeoutMs,
host: u.hostname,
port: u.port || (isHttps ? 443 : 80),
path: `${u.pathname}${u.search}`,
headers: { accept: '*/*', 'user-agent': UA, host: u.host },
};
let req: http.ClientRequest;
if (proxy && !isHttps) {
// http 目标经代理 → 绝对形态请求行,且请求行交给代理
const pe = proxyEndpoint(proxy);
opts.host = pe.host;
opts.port = pe.port;
opts.path = u.href;
const auth = proxyAuthHeader(proxy);
if (auth) (opts.headers as Record<string, string>)['proxy-authorization'] = auth;
req = http.request(opts);
} else if (proxy) {
opts.createConnection = tunnelConnection(proxy, u, signal) as never;
req = http.request(opts);
} else {
req = (isHttps ? https : http).request(opts);
}
req.once('timeout', () => req.destroy(new Error(`请求超时(${timeoutMs}ms 无响应)`)));
req.once('response', (res) => {
// 上限第一闸:content-length 声明超限 → 根本不开始读 body
const declared = Number(res.headers['content-length'] ?? '0');
if (maxBytes !== undefined && declared > maxBytes) {
res.destroy(new Error(`响应体超过上限 ${maxBytes} 字节(content-length=${declared}),已中断`));
return;
}
const chunks: Buffer[] = [];
let total = 0;
res.on('data', (c: Buffer) => {
// 上限第二闸(流式):服务端谎报/不报 content-length 时,收到即累计,
// 超限立刻断,不让无界数据先把被控端内存吃爆
total += c.length;
if (maxBytes !== undefined && total > maxBytes) {
res.destroy(new Error(`响应体超过上限 ${maxBytes} 字节,已中断`));
return;
}
chunks.push(c);
});
res.once('error', reject);
res.once('end', () =>
resolve({
status: res.statusCode ?? 0,
statusText: res.statusMessage ?? '',
headers: res.headers as Record<string, unknown>,
body: Buffer.concat(chunks),
}),
);
});
req.once('error', reject);
req.end();
});
}
/**
* GET 一个 URL(跟随重定向)。
*
* 与 `fetch(url, {redirect:'follow'})` 的差异:不自动解压(不发 `accept-encoding`,
* 服务端会回 identity)、不解析 cookies —— 这两条对本用途都无影响。
*
* 整个重定向链共享**同一个**超时预算与 abort 信号(而不是每跳各自计时),
* 否则 5 跳 × 30s = 150s 会穿过上层的 RPC 超时。
*
* @param maxBytes 响应体流式上限(累计字节);超限立即中断。缺省不设限。
*/
export async function httpGet(
url: string,
{
proxy = null,
timeoutMs = 30_000,
maxRedirects = 5,
maxBytes,
}: { proxy?: URL | null; timeoutMs?: number; maxRedirects?: number; maxBytes?: number } = {},
): Promise<HttpResponse> {
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), timeoutMs);
try {
let current = String(url);
for (let hop = 0; ; hop += 1) {
const res = await once(current, { proxy, timeoutMs, signal: ac.signal, maxBytes });
const loc = res.headers?.['location'];
if (REDIRECT_STATUS.has(res.status) && typeof loc === 'string' && hop < maxRedirects) {
current = new URL(loc, current).href;
continue;
}
return { ...res, url: current };
}
} finally {
clearTimeout(timer);
}
}
+173 -39
View File
@@ -32,6 +32,7 @@ import { createCapabilityRegistry } from './capabilities/index.js';
import { audit, describeArgs } from './audit.js';
import { disposeWatchesByOwner, watchCount } from './events.js';
import { runningTaskCount } from './capabilities/task.js';
import { createRateLimiter } from './util/rate-limit.js';
import type { AgentConfig } from './config.js';
import type { TlsMaterial } from './certs.js';
@@ -48,7 +49,7 @@ interface ConnState {
remote: string;
/** v12:连接标识,用于清理该连接创建的事件订阅 */
connId: string;
/** v23:末次活跃时间(任何入站消息),空闲断开的依据 */
/** v2.0.0:末次活跃时间(任何入站消息),空闲断开的依据 */
lastActiveAt: number;
}
@@ -64,9 +65,9 @@ type LogLevel = 'debug' | 'info' | 'warn';
export interface AgentCore {
/** 在给定连接上提供被控端服务(连接已建立)。 */
attach(ws: WebSocket, remote: string): void;
/** v23:该来源封禁剩余毫秒(0 = 不挡)。连接入口在业务握手前调用。 */
/** v2.0.0:该来源封禁剩余毫秒(0 = 不挡)。连接入口在业务握手前调用。 */
banRemainingMs(remote: string): number;
/** v23:连接表(并发上限 / 空闲清扫判据;key 为 WebSocket)。 */
/** v2.0.0:连接表(并发上限 / 空闲清扫判据;key 为 WebSocket)。 */
states: Map<WebSocket, ConnState>;
}
@@ -74,25 +75,37 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
const capabilityRegistry = createCapabilityRegistry(cfg);
/** v3:本次实例采用的认证模式(psk 向后兼容 / ed25519 零信任) */
const authMode: 'psk' | 'ed25519' = cfg.auth_mode === 'ed25519' ? 'ed25519' : 'psk';
const aclPolicy = cfg.acl ?? { default_effect: 'deny' as const, clients: [] };
/** 只要配置了 ACL 就启用能力级授权(无论 psk 还是 ed25519)。 */
const hasAcl = Boolean(cfg.acl?.clients?.length);
/**
* ACL 策略归一化。
*
* ⚠️ A-01(真机实测,P0):`clients` 必须在**这里**补齐成数组。
* 原实现只写 `cfg.acl ?? {…, clients: []}` —— 当用户配置 `{"acl":{"default_effect":"deny"}}`
* (很自然的写法,省略空的 clients)时,`cfg.acl` 非空 → `??` 不兜底 →
* `aclPolicy.clients` 为 `undefined` → 后面无条件执行 `.find()` → **每次 invoke 都崩掉被控端进程**。
*/
const aclPolicy = {
default_effect: cfg.acl?.default_effect === 'allow' ? ('allow' as const) : ('deny' as const),
clients: Array.isArray(cfg.acl?.clients) ? cfg.acl.clients : [],
};
/**
* 只要**配置了 ACL 对象**就启用能力级授权(无论 psk 还是 ed25519)。
*
* ⚠️ A-02(真机实测,P1):判据必须是「对象是否存在」,**不能**用 `clients.length`。
* 原实现 `Boolean(cfg.acl?.clients?.length)` 在 `{"acl":{"default_effect":"deny","clients":[]}}`
* 时得到 `false` → 整段 ACL 校验被跳过 → **fail-open 全放行**(配置写着默认拒绝却全通)。
* 空 clients 的正确语义是「没有任何调用方被授权」,即拒绝一切(由 default_effect 决定)。
*/
const hasAcl = Boolean(cfg.acl);
/** 按调用方的滑动窗口限速(每分钟)。 */
const rateBuckets = new Map<string, number[]>();
function allowByRate(clientId: string, limitPerMin?: number, bucket?: string): boolean {
if (!limitPerMin || limitPerMin <= 0) return true;
const key = bucket ? `${clientId}|${bucket}` : clientId;
const now = Date.now();
const recent = (rateBuckets.get(key) ?? []).filter((t) => now - t < 60_000);
if (recent.length >= limitPerMin) {
rateBuckets.set(key, recent);
return false;
}
recent.push(now);
rateBuckets.set(key, recent);
return true;
}
/**
* 按调用方的滑动窗口限速(每分钟)。
*
* ⚠️ A-P0-2(2026-10-25):原实现 `allowByRate(clientId, limit, String(limit))`
* 把**限速值**当桶名 —— 同限速值的不同能力共用一个桶(互相挤占/漏过),
* 且 `rateBuckets` 只增不删(psk 模式 client_id 自报 → 可枚举造键 → 内存无界增长)。
* 现抽到 `util/rate-limit.ts`:桶名 = 能力名;过期键顺手删 + 容量上限淘汰最旧。
*/
const rateLimiter = createRateLimiter();
const levelOrder: Record<LogLevel, number> = { debug: 0, info: 1, warn: 2 };
const log = (level: LogLevel, msg: string): void => {
if (levelOrder[level] >= levelOrder[cfg.log_level]) {
@@ -100,7 +113,7 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
}
};
// v23:用 Map 而非 WeakMap —— 连接层需要 size(并发上限)与遍历(空闲清扫)。
// v2.0.0:用 Map 而非 WeakMap —— 连接层需要 size(并发上限)与遍历(空闲清扫)。
// 代价是必须手动清理:close 时 delete,否则断开的连接会一直留在表里。
const states = new Map<WebSocket, ConnState>();
@@ -114,14 +127,37 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
function handleHello(ws: WebSocket, state: ConnState, req: RpcRequest): void {
const params = req.params as HelloParams | undefined;
const proto = params?.protocol ?? '';
// ⚠️ B-01(真机实测,P1):`protocol` 必须先做**类型**校验再 `.split()`。
// 原实现 `params?.protocol ?? ''` 对 `protocol: 123` 得到数字 → `proto.split` 抛
// TypeError → 未捕获的 rejection → Node ≥22 直接终止被控端进程(**免认证即可打崩**)。
const rawProto = params?.protocol;
if (typeof rawProto !== 'string' || rawProto === '') {
log('warn', `握手失败:protocol 非法(${typeof rawProto})`);
audit({
type: 'request.rejected',
remote: state.remote,
status: 'denied',
reason: `hello.protocol 类型非法: ${typeof rawProto}`,
});
sendError(ws, req.id, ErrorCodes.PARAM_INVALID, 'hello 的 protocol 必须是形如 "1.0" 的字符串', {
got: typeof rawProto,
});
return;
}
const proto = rawProto;
if (proto.split('.')[0] !== PROTOCOL_VERSION.split('.')[0]) {
audit({
type: 'request.rejected',
remote: state.remote,
status: 'denied',
reason: `协议版本不兼容: 客户端 ${proto}`,
});
sendError(ws, req.id, ErrorCodes.PROTOCOL_MISMATCH, `协议版本不兼容: 客户端 ${proto},服务端 ${PROTOCOL_VERSION}`);
return;
}
state.nonce = generateNonce();
state.nonceExpiresAt = Date.now() + NONCE_TTL_MS;
state.clientId = params?.client_id ?? null;
state.clientId = typeof params?.client_id === 'string' ? params.client_id : null;
// 新一轮握手开始:清除上一轮的认证态与授权
// (Hub 模式下同一连接会被不同控制端先后复用,必须重置,否则存在越权风险)
state.authenticated = false;
@@ -147,12 +183,16 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
failOut('挑战已过期,请重新握手');
return;
}
// ⚠️ B-02(真机实测,P1):nonce / hmac / signature 都要先做类型校验。
// `params?.nonce !== state.nonce` 对数字、对象等都只会走「不匹配」分支 —— 这步安全;
// 真正致命的是下一段:把 `hmac:1` 交给 `verifyHmac` → `Buffer.from(1, 'hex')` 抛
// `ERR_INVALID_ARG_TYPE` → 未捕获 rejection → 进程终止(免认证即可打崩)。
if (params?.nonce !== state.nonce) {
log('warn', '握手失败:nonce 不匹配');
failOut('nonce 不匹配', '请用 hello 返回的 nonce 计算签名');
return;
}
const clientId = params.client_id ?? '?';
const clientId = typeof params.client_id === 'string' ? params.client_id : '?';
let authorized: string[] | null = null;
let keyKindUsed = 'ed25519';
@@ -163,7 +203,7 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
failOut(`调用方未在 ACL 中注册: ${clientId}`, '在被控端 acl.clients 中加入该 client_id 与公钥');
return;
}
if (!verifyNonce(client.pubkey, state.nonce, params.signature ?? '')) {
if (!verifyNonce(client.pubkey, state.nonce, typeof params.signature === 'string' ? params.signature : '')) {
log('warn', `鉴权失败(Ed25519 验签不通过)client_id=${clientId}`);
failOut('Ed25519 签名校验失败', '确认用的是该 client_id 对应的私钥');
return;
@@ -176,10 +216,24 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
} else {
// v22:优先用「该 client_id 专属的钥匙」,未登记才回落到共享 key。
// 这样「知道 key A」就只能以 A 的身份进来 —— ACL 的身份维度在 psk 模式下才有意义。
const scopedKey = cfg.keys?.[clientId];
//
// ⚠️ B-03(真机实测,P1):必须用 **own-property** 判断,不能直接 `cfg.keys?.[clientId]`。
// `client_id:"constructor"` 会沿原型链取到 `Object.prototype.constructor`(一个函数)
// 并被当作 HMAC 密钥 → `Buffer.from(function…)` 抛错 → 进程终止。
const hasScopedKey = Boolean(cfg.keys && Object.prototype.hasOwnProperty.call(cfg.keys, clientId));
const scopedKey = hasScopedKey ? cfg.keys![clientId] : undefined;
const keyForClient = scopedKey ?? cfg.key;
const keyKind = scopedKey ? 'scoped' : cfg.keys ? 'shared-fallback' : 'shared';
if (!keyForClient || !verifyHmac(keyForClient, state.nonce, params.hmac ?? '')) {
// ⚠️ B-02:hmac 同样要做类型校验 + verifyHmac 自身不得把异常泄漏到顶层
const hmac = typeof params.hmac === 'string' ? params.hmac : '';
let hmacOk = false;
try {
hmacOk = Boolean(keyForClient) && hmac !== '' && verifyHmac(keyForClient as string, state.nonce, hmac);
} catch (err) {
log('warn', `鉴权失败(HMAC 计算异常):${err instanceof Error ? err.message : String(err)}`);
hmacOk = false;
}
if (!hmacOk) {
log('warn', `鉴权失败 client_id=${clientId} key=${keyKind}`);
failOut('预共享密钥校验失败', scopedKey
? '该 client_id 有专属密钥,需用那把 key'
@@ -191,7 +245,7 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
state.nonce = null; // 一次性,用后即废
state.nonceExpiresAt = 0;
recordAuthSuccess(state.remote); // v23:握手成功即清零失败计数(避免误伤手滑的控制端)
recordAuthSuccess(state.remote); // v2.0.0:握手成功即清零失败计数(避免误伤手滑的控制端)
state.authenticated = true;
state.clientId = clientId;
state.authorized = authorized;
@@ -220,11 +274,17 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
async function handleInvoke(ws: WebSocket, state: ConnState, req: RpcRequest): Promise<void> {
if (!state.authenticated) {
audit({
type: 'request.rejected',
remote: state.remote,
status: 'denied',
reason: '未完成认证即调用能力',
});
sendError(ws, req.id, ErrorCodes.AUTH_REQUIRED, '未完成认证,请先握手');
return;
}
const params = req.params as InvokeParams | undefined;
const name = params?.capability ?? '';
const name = typeof params?.capability === 'string' ? params.capability : '';
// 能力级 ACL:只要被控端配置了 ACL 即执行(deny 优先 → allow → 默认拒绝)。
// 注:psk 模式下 client_id 由客户端自报,此为「配置级约束」;对抗恶意调用方仍需 ed25519 绑定身份。
@@ -252,6 +312,7 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
}
// v3+:按调用方滑动窗口限速(ACL 的 rate_limits 按能力覆盖,未命中回退 max_calls_per_min)
// 注:aclPolicy.clients 已在 createAgentCore 里归一成数组(A-01),这里取不到就是 undefined。
const clientRule = aclPolicy.clients.find((c) => c.client_id === state.clientId);
// v11:来源 IP 白/黑名单 + 生效时段(仅在已配置该 client 规则时生效)
@@ -294,8 +355,11 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
}
}
// 限速键 = `${认证身份 ?? 远端地址}:${能力名}`:
// ① 不同能力各占一桶(同限速值不再互挤/漏过);
// ② 未认证身份(psk 自报缺失)时退化按远端地址,不用空串把所有人混为一桶。
const rateLimit = clientRule ? rateLimitFor(clientRule, name) : undefined;
if (!allowByRate(state.clientId ?? '', rateLimit, String(rateLimit ?? ''))) {
if (!rateLimiter.allow(state.clientId ?? state.remote, rateLimit, name)) {
log('warn', `限速拒绝 client_id=${state.clientId} capability=${name}`);
audit({
type: 'rate.limited',
@@ -315,6 +379,14 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
const cap = findCapability(name);
const handler = capabilityRegistry[name];
if (!cap || !handler) {
audit({
type: 'request.rejected',
client_id: state.clientId ?? undefined,
remote: state.remote,
capability: name,
status: 'denied',
reason: `不支持的能力: ${name}`,
});
sendError(ws, req.id, ErrorCodes.CAPABILITY_NOT_FOUND, `不支持的能力: ${name}`, { capability: name });
return;
}
@@ -322,6 +394,15 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
const args = (params?.args ?? {}) as Record<string, unknown>;
const errors = validate(args, cap.params_schema);
if (errors.length > 0) {
// ⚠️ U-01:拒绝必须留痕(探测行为若不留审计,事后无法复盘被谁、用什么参数试过)
audit({
type: 'request.rejected',
client_id: state.clientId ?? undefined,
remote: state.remote,
capability: name,
status: 'denied',
reason: `参数校验失败: ${errors.slice(0, 3).join('; ')}`,
});
sendError(ws, req.id, ErrorCodes.PARAM_INVALID, '参数校验失败', { errors });
return;
}
@@ -333,6 +414,7 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
// v12:把连接上下文交给能力处理器(事件订阅需要「谁订阅的」与「往哪推」)
const data = await handler(finalArgs, {
owner: state.connId,
clientId: state.clientId ?? '',
emit: (evt: unknown) => send(ws, { jsonrpc: '2.0', method: 'event', params: evt }),
});
const duration = Date.now() - started;
@@ -379,10 +461,18 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
try {
req = JSON.parse(text) as RpcRequest;
} catch {
audit({ type: 'request.rejected', remote: states.get(ws)?.remote, status: 'denied', reason: '无法解析 JSON' });
sendError(ws, null, ErrorCodes.PARSE_ERROR, '无法解析 JSON');
return;
}
if (req?.jsonrpc !== '2.0' || typeof req.id !== 'string' || typeof req.method !== 'string') {
// 信封非法:可能是扫描器/协议错误,也留一条痕(原实现完全静默)
audit({
type: 'request.rejected',
remote: states.get(ws)?.remote,
status: 'denied',
reason: `请求信封无效(jsonrpc=${typeof req?.jsonrpc} id=${typeof req?.id} method=${typeof req?.method})`,
});
sendError(ws, typeof req?.id === 'string' ? req.id : null, ErrorCodes.INVALID_REQUEST, '请求信封无效');
return;
}
@@ -401,6 +491,12 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
return;
case Methods.Capabilities:
if (!state.authenticated) {
audit({
type: 'request.rejected',
remote: state.remote,
status: 'denied',
reason: '未认证即拉取能力清单',
});
sendError(ws, req.id, ErrorCodes.AUTH_REQUIRED, '未完成认证');
return;
}
@@ -410,11 +506,18 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
sendResult(ws, req.id, { pong: true, ts: Date.now() });
return;
default:
audit({
type: 'request.rejected',
client_id: state.clientId ?? undefined,
remote: state.remote,
status: 'denied',
reason: `未知方法: ${req.method}`,
});
sendError(ws, req.id, ErrorCodes.METHOD_NOT_FOUND, `未知方法: ${req.method}`);
}
}
// ---------- v23 连接层防护:握手失败封禁 ----------
// ---------- v2.0.0 连接层防护:握手失败封禁 ----------
// 状态放在 core:handleAuth 在此函数内,且 server 侧连接入口也要查它。
interface BanState { fails: number; bannedUntil: number }
const authBan = new Map<string, BanState>();
@@ -470,27 +573,46 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
connId,
lastActiveAt: Date.now(),
});
ws.on('message', (raw: RawData) => {
// v23:任何入站消息都算活跃(空闲断开以"完全静默"为准,不是"没有调用能力")
// v2.0.0 修正:**客户端对 ping 的 pong 回应也算活跃**。
// 之前只更新 message 的活跃时间 → 僵尸连接(TCP 未断但客户端已死)不会被判空闲,
// 会被永久算进 states.size → 并发上限虚高 → **可能把正常控制端挡在外面**。
ws.on('pong', () => {
const st = states.get(ws);
if (st) st.lastActiveAt = Date.now();
void handleMessage(ws, raw.toString());
});
ws.on('message', (raw: RawData) => {
// v2.0.0:任何入站消息都算活跃(空闲断开以"完全静默"为准,不是"没有调用能力")
const st = states.get(ws);
if (st) st.lastActiveAt = Date.now();
// ⚠️ 崩溃链根因(P0/P1 多条共用):原来这里是 `void handleMessage(...)` ——
// Promise 一旦 reject 就成为 unhandled rejection,Node ≥22 默认**终止进程**。
// 「畸形 hello」「畸形 auth」之所以能把被控端打崩,靠的就是这条路径。
// 这里显式兜底:任何处理异常只记日志 + 回一个错误响应,绝不冒泡到进程级。
void handleMessage(ws, raw.toString()).catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
log('warn', `处理消息时发生未预期异常(已拦截,不影响进程):${msg}`);
try {
sendError(ws, null, ErrorCodes.EXECUTION_FAILED, '服务端处理消息时发生未预期异常', { detail: msg });
} catch {
/* 连接可能已关闭 */
}
});
});
ws.on('close', () => {
log('info', `连接关闭: ${remote}`);
// v12:连接断开即释放其事件订阅,避免 watcher 泄漏
const n = disposeWatchesByOwner(connId);
if (n > 0) log('debug', `已清理 ${n} 个事件订阅(连接 ${connId})`);
states.delete(ws); // v23:Map 需手动清理(WeakMap 时代靠 GC)
states.delete(ws); // v2.0.0:Map 需手动清理(WeakMap 时代靠 GC)
});
ws.on('error', (err: Error) => log('warn', `连接错误: ${err.message}`));
}
return {
attach: attachConnection,
/** v23:连接入口用 —— 该来源是否在封禁期(返回剩余毫秒,0=不挡)。 */
/** v2.0.0:连接入口用 —— 该来源是否在封禁期(返回剩余毫秒,0=不挡)。 */
banRemainingMs,
/** v23:连接表(空闲清扫与并发上限判据)。 */
/** v2.0.0:连接表(空闲清扫与并发上限判据)。 */
states,
};
}
@@ -548,7 +670,19 @@ export function createAgentServer(cfg: AgentConfig, tls: TlsMaterial | null): Pr
// WebSocket 层保活:30s ping,探测死连接;v23③ 同时做空闲连接清扫
const heartbeat = setInterval(() => {
for (const ws of wss.clients) {
if (ws.readyState === WebSocket.OPEN) ws.ping();
if (ws.readyState !== WebSocket.OPEN) continue;
// v2.0.0 修正(真机踩到):**pong 缺失必须主动 terminate**。
// ws 库默认不会因「ping 无 pong」而断开 —— 客户端进程已死(TCP 未发 FIN)时,
// 连接会一直挂在 states 里,占用 max_connections 名额。
// 判据用与空闲清扫同一个 lastActiveAt(pong 已在上方更新它)。
const st = core.states.get(ws);
if (st && idleTimeoutMs > 0 && Date.now() - st.lastActiveAt > idleTimeoutMs * 2) {
log('warn', `连接无响应(ping 未回 pong 且静默超 ${Math.round((Date.now() - st.lastActiveAt) / 1000)}s)→ 强制断开 ${st.connId}`);
audit({ type: 'session.idle_close', client_id: st.clientId ?? undefined, remote: st.remote, reason: 'unresponsive (no pong)' });
ws.terminate();
continue;
}
ws.ping();
}
if (idleTimeoutMs <= 0) return;
// 保守策略:只要还有任务在跑 / 还有事件订阅,就不断任何连接 ——
+42
View File
@@ -0,0 +1,42 @@
/**
* Windows **DPI 感知** PowerShell 前置段(物理像素坐标的唯一真源)。
*
* ## 为什么必须有
*
* PowerShell 默认是「**DPI 不感知**」进程。在高 DPI(如 200% 缩放)下,这种进程里
* **同时存在两套坐标系**,而且互不提醒:
*
* | 调用 | 该进程看到的 |
* |---|---|
* | `[System.Windows.Forms.Screen]::Bounds` | **逻辑**尺寸(物理 2560×1440 → 报 1280×720) |
* | `[System.Windows.Forms.SystemInformation]::VirtualScreen` | **逻辑**(同上) |
* | `CopyFromScreen(0,0,1280,720)` | **物理**裁剪 → 只截到屏幕左上 1/4 |
* | `SetCursorPos(552,745)` | 落点实际在物理 (1104, 1490)(被系统 ×2) |
* | UIA(`screen.find`) | **物理**(与进程 DPI 感知无关) |
*
* 于是最要命的一条链路会静默断掉:`screen.find` 返回物理坐标 → 直接喂给
* `input.mouse.click` → **点到两倍远的地方**(还可能被屏幕边界截断)。
* 「找得到、点不准」,而且不报任何错。
*
* ## 用法
*
* 在 **任何** `System.Windows.Forms` / 截屏 / 光标调用**之前**执行本段;
* 同一进程内只需一次(`SetProcessDPIAware` 幂等)。新起的 PowerShell 每次都要带上。
*
* ## 真机实证(2026-10-12)
*
* 某虚拟化平台上的 Win11 ARM 虚拟机,宿主缩放 200%,物理 2560×1440:
* - 缺省 `screen.capture` 只拿到 1280×720(= 屏幕左上 1/4),且 `screen.info` 报 1280×720
* - `input.mouse.move 100,100` → DPI 感知读回 **(200,200)**
* - `input.mouse.move 552,745` → 实际落点 **(1104, 1439)**(截断到屏幕底部)
* - 补上本段后:`screen.capture` 拿到完整 2560×1440;`screen.find` 的物理坐标
* 可直接用于 `input.mouse.click`(同一坐标一次点中)
*
* ⚠️ 不要删。这不是「锦上添花」的兼容代码,而是坐标口径统一的**前提**。
* 守卫见 `tests/unit/dpi-awareness.test.mjs`。
*/
export const PS_DPI_AWARE = [
// Add-Type 现编译一个小 P/Invoke 壳;类名固定,每进程只应出现一次。
`Add-Type -TypeDefinition 'using System.Runtime.InteropServices; public class NADpiAware { [DllImport("user32.dll")] public static extern bool SetProcessDPIAware(); }'`,
`[void][NADpiAware]::SetProcessDPIAware()`,
].join('\n');
+80
View File
@@ -0,0 +1,80 @@
/**
* 平台能力守卫(跨平台最小实现)。
*
* ## 为什么要统一
* 各能力原本各自判断平台,风格不一:
* - `darwin.ts` 有 `assertDarwin()`
* - `window.ts` 写 `if (platform === 'darwin') return macXxx()`,其余平台**静默走到 Windows 实现**,
* 于是在 Linux 上表现为 `spawn /bin/bash ENOENT` 这类**误导性错误**(像是环境缺依赖,
* 实际是"本平台根本没实现")。
*
* 统一后的语义:**没实现就明确说"不支持当前平台"**(`E_UNSUPPORTED_PLATFORM`),
* 并带上「支持哪些平台」,让调用方一眼看懂,也让能力清单的"声明可用"不至于撒谎。
*
* ## 用法
* ```ts
* assertPlatform('screen.capture', 'win32', 'darwin');
* ```
*/
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
/** 人类可读的平台名(错误信息里用)。 */
const PLATFORM_LABEL: Record<string, string> = {
win32: 'Windows',
darwin: 'macOS',
linux: 'Linux',
};
export function platformLabel(p: NodeJS.Platform): string {
return PLATFORM_LABEL[p] ?? p;
}
/**
* 当前平台是否在允许集合内。
*
* 与 `assertPlatform` 的分工:这个用于**条件注册/条件分支**,那个用于**进入能力时兜底拒绝**。
*/
export function isPlatform(...allowed: NodeJS.Platform[]): boolean {
return allowed.includes(process.platform);
}
/**
* 断言当前平台受支持;否则抛 `E_UNSUPPORTED_PLATFORM`。
*
* @param capability 能力名(写进错误信息,便于定位)
* @param allowed 支持该能力的平台(如 `'win32', 'darwin'`)
*/
export function assertPlatform(capability: string, ...allowed: NodeJS.Platform[]): void {
if (isPlatform(...allowed)) return;
const supported = allowed.map(platformLabel).join(' / ');
throw new CapabilityError(
ErrorCodes.UNSUPPORTED_PLATFORM,
`${capability} 未在 ${platformLabel(process.platform)} 上实现(仅支持 ${supported})`,
{
platform: process.platform,
supported,
},
);
}
/**
* 仅 Windows 的能力(PowerShell / Win32 实现)。
*
* 覆盖:截屏与录屏、键鼠注入、剪贴板、音量、软件装卸(winget)、窗口与 UIA 语义。
* 注意 **不含** `system.info` / `system.status` / `system.process.list` / `shell.exec` / `fs.*` ——
* 这些有分平台实现,在 Linux 上可用。
*/
export function assertWindows(capability: string): void {
assertPlatform(capability, 'win32');
}
/**
* 图形界面类能力(Windows 或 macOS 有实现,Linux 目前没有)。
*
* 用于 `screen.*` / `window.*` / `input.*` 这类:在 Windows 走 Win32/UIA、
* 在 macOS 走 AppleScript/Vision,**Linux 端尚未实现**(需 X11/Wayland 后端)。
*/
export function assertGuiPlatform(capability: string): void {
assertPlatform(capability, 'win32', 'darwin');
}
+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);
}
+83
View File
@@ -0,0 +1,83 @@
/**
* 按调用方的滑动窗口限速器(每分钟)。
*
* ## 为什么从 server.ts 抽出来(A-P0-2,2026-10-25)
* 原实现有两处缺陷,且逻辑埋在 `createAgentCore` 闭包里无法单测:
*
* 1. **桶 key 用错值**:原 `allowByRate(clientId, limit, String(limit))` 第三参
* (bucket)传的是**限速值本身**。于是「screen.capture ≤ 5/min」与
* 「app.install ≤ 5/min」两条同值规则共用 `clientId|5` 一个桶:
* 截图 5 次后装软件也被限速(误伤),且两者总量仍按 5/min(漏过)。
* 正确口径:key = `${身份}:${能力名}`。
* 2. **桶只增不删**:`rateBuckets` Map 无任何清扫,key 永久留存。而 psk 模式下
* `client_id` 由客户端**自报**(配置级约束)→ 外部可枚举任意 client_id 造键
* → 长期运行内存无界增长。现在:每次允许判定顺手删过期键 + 容量上限淘汰最旧。
*
* ## 语义
* - 窗口 60s 滑动:只数「最近 60 秒内」的调用;
* - `limitPerMin` 为空/≤0 表示不限速(直接放行,**不建桶** —— 与旧行为一致);
* - 身份用「认证身份 ?? 远端地址」:psk 自报 client_id 不可信,但限速键仍以它为纲
* (配了限速就说明该 client_id 已在 ACL 里登记),无身份的调用方退化按远端地址。
*/
/** 桶容量上限:超出淘汰最旧(Map 保持插入顺序,首键即最旧)。 */
const DEFAULT_MAX_KEYS = 10_000;
const WINDOW_MS = 60_000;
export interface RateLimiter {
/**
* 请求一次调用配额。
*
* @param identity 调用方身份(认证身份 ?? 远端地址)
* @param limitPerMin 每分钟上限;空/≤0 = 不限速
* @param bucket 桶名(能力名)—— 不同能力各占一桶,互不挤占
*/
allow(identity: string, limitPerMin: number | undefined, bucket: string): boolean;
/** 当前桶数(供测试与观测)。 */
size(): number;
}
export interface RateLimiterOptions {
/** 桶容量上限(超出淘汰最旧)。默认 10_000。 */
maxKeys?: number;
/** 时钟注入(单测用假时钟推进 60s 窗口,不必真等)。 */
now?: () => number;
}
export function createRateLimiter(opts: RateLimiterOptions = {}): RateLimiter {
const maxKeys = opts.maxKeys ?? DEFAULT_MAX_KEYS;
const now = opts.now ?? Date.now;
const buckets = new Map<string, number[]>();
return {
allow(identity: string, limitPerMin: number | undefined, bucket: string): boolean {
if (!limitPerMin || limitPerMin <= 0) return true;
const key = `${identity}|${bucket}`;
const t = now();
// 清扫①:过期键直接删(只增不删 = 内存无界增长;自报 client_id 可枚举造键)
for (const [k, times] of buckets) {
if (times.length === 0 || t - times[times.length - 1]! >= WINDOW_MS) buckets.delete(k);
}
// 清扫②:容量上限,超出淘汰最旧(插入序 = 时间序,首键最旧)
while (buckets.size >= maxKeys) {
const oldest = buckets.keys().next();
if (oldest.done) break;
buckets.delete(oldest.value);
}
const recent = (buckets.get(key) ?? []).filter((ts) => t - ts < WINDOW_MS);
if (recent.length >= limitPerMin) {
buckets.set(key, recent);
return false;
}
recent.push(t);
buckets.set(key, recent);
return true;
},
size(): number {
return buckets.size;
},
};
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/cli",
"version": "2.0.0",
"version": "2.1.0",
"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,
+4 -4
View File
@@ -172,7 +172,7 @@ export async function cmdKey(action: string | undefined, positionals: string[],
return;
}
case 'press': {
// v1.5:三种用法(一次一种)
// v1.5.0:三种用法(一次一种)
// nodeagent key press "ctrl+shift+esc" 字符串热键
// nodeagent key press copy 预设名
// nodeagent key press ctrl c 老形式(空格分隔的各键)
@@ -193,7 +193,7 @@ export async function cmdKey(action: string | undefined, positionals: string[],
}
// 老形式说明:位置参数以空格分隔 → 兼容"ctrl c"(无 + 号)
if (opts.interval) args['interval_ms'] = Number(opts.interval);
// v1.5 新增可选参数
// v1.5.0 新增可选参数
if (opts.hold) args['hold_ms'] = Number(opts.hold);
if (opts.route) args['route'] = opts.route;
// 媒体键可定向到指定进程(播放器常建 30+ 辅助窗口,只投主窗口往往无效)
@@ -232,7 +232,7 @@ export async function cmdKey(action: string | undefined, positionals: string[],
}
}
// ---------- v1.6 等待条件(gui.await) ----------
// ---------- v1.6.0 等待条件(gui.await) ----------
/**
* 等待一个条件成立再返回(只读低危)。
@@ -251,7 +251,7 @@ export async function cmdAwait(opts: Options): Promise<void> {
fail('用法: nodeagent await --condition <window|control|process|file> --timeout <ms> [--text/--title/--process/--path] [--state absent]');
}
const args: Record<string, unknown> = { condition };
// v1.7:where / any_of 是 JSON 字符串(属性谓词与组合条件不便用命令行开关表达)
// v2.0.0:where / any_of 是 JSON 字符串(属性谓词与组合条件不便用命令行开关表达)
for (const k of ['where', 'any_of']) {
const raw = (opts as unknown as Record<string, string | undefined>)[k];
if (raw !== undefined) {
+3 -3
View File
@@ -395,7 +395,7 @@ export async function cmdDeploy(localFile: string, opts: Options): Promise<void>
console.error(` 回滚方式:把备份复制回目标即可 ——`);
console.error(` nodeagent exec "Copy-Item '${uploadInfo.backupPath}' '${target}' -Force"`);
}
console.error(' 排查:被控端杀软(主流杀软/杀软)可能处置了被覆盖的 agent 文件');
console.error(' 排查:被控端杀软可能处置了被覆盖的 agent 文件');
process.exitCode = 1;
}
}
@@ -641,7 +641,7 @@ export async function cmdClip(opts: Options): Promise<void> {
);
}
// ---------- v1.8 状态采样(monitor / log) ----------
// ---------- v2.0.0 状态采样(monitor / log) ----------
/**
* 状态采样与回看。
@@ -732,7 +732,7 @@ export async function cmdMonitor(action: string | undefined, positional: string[
}
}
/** 在被控端侧过滤日志(v1.8):日志常几万行,过滤必须发生在被控端。 */
/** 在被控端侧过滤日志(v2.0.0):日志常几万行,过滤必须发生在被控端。 */
export async function cmdLog(opts: Options): Promise<void> {
const logPath = opts.path;
if (!logPath) fail('用法: nodeagent log --path <文件> [--pattern 正则] [--level ERROR] [--limit n] [--tail]');
+576
View File
@@ -0,0 +1,576 @@
import { createHash } from 'node:crypto';
import { CapabilityNames, type InvokeResult } from '@nodeagent/protocol';
import { printJson, probeOnce, withClient } from '../core.js';
/**
* `nodeagent rollback` —— 从 LKG(最后已知可用版本)恢复被控端(ARCH T03 / PRD P0-2)。
*
* ## 能力圈边界(必须向用户讲清楚,见 DELIVERY-PIPELINE §6 与 A-P0-6)
* 本命令本质是 **RPC 命令,要求被控端可达**。它覆盖的场景是
* 「新版本能启动但需要回退(行为异常 / 主动避险)」;
* 「新版本进程完全起不来」时没有监听可连,CLI 无能为力 —— 那种场景走:
* ① 看门狗自动恢复(scripts/startup-guard.mjs:启动连续失败 N 次自动从 LKG 恢复);
* ② 物理/带外恢复(DELIVERY-PIPELINE 附录 C:备份文件手工还原)。
* 本命令**不提供自动回滚**:无法归因、回滚循环、失控三问见 DELIVERY-PIPELINE §6。
*
* ## 执行流程(对齐 deploy 的成熟姿势,见 cmdDeploy)
* ① system.info → agent_script / build.hash / lkg
* ② 无 LKG → 报错退出(exit 2),零改动
* ③ current == LKG.hash → 「当前即 LKG」提示退出(提示 --backup 逃生门)
* ④ fs.list 枚举备份候选(`<basename>.bak` / `<basename>.bak-<ts>`),mtime 新→早
* ⑤ 流式读 + sha256 指纹匹配(全量 hash_full 优先,老记录回落 12 位前缀)
* —— 无匹配 → 列候选指纹报错退出(exit 2,不动任何文件)
* ⑥ 覆盖写入(同 deploy:首块 append:false + 续块 append:true)→ 回读校验
* ├─ 一致 → ⑦
* └─ 不一致 → 用回滚源再覆盖一次(还原到回滚源内容=原状)→ 报错退出(exit 1)
* ⑦ system.agent.restart(独立连接,吞连接中断)
* ⑧ probeOnce 轮询直到 build.hash == 期望指纹(90s 墙钟)
*
* ## --dry-run
* 只做 ①~⑤(含匹配结论),**零写入**:无 fs.write / 无 restart。
*/
// ---------- 公开类型 ----------
/** LKG 记录(system.info.lkg.lkg)。hash_full/backup_glob 为 v2.1.0 只增字段,老记录没有。 */
export interface LkgInfo {
/** 入口脚本 sha256 前 12 位(与 system.info 的 build.hash 同口径) */
hash: string;
/** sha256 全量 64 位(v2.1.0 起记录;老记录无此字段,回落 12 位前缀比对) */
hash_full?: string;
bytes: number;
script_path: string;
/** 回滚内容候选的文件名模式(`<basename>.bak*`,v2.1.0 起记录) */
backup_glob?: string;
recorded_at: number;
node_version: string;
platform: string;
}
/** system.info 里 rollback 关心的字段(其余字段忽略)。 */
export interface SystemInfoLike {
agent_script?: string;
build?: { hash?: string };
lkg?: {
lkg: LkgInfo | null;
current_hash: string | null;
matches: boolean;
byte_delta: number | null;
};
}
/** rollback 可注入的最小客户端面(NodeAgentClient 结构上满足它)。 */
export interface RollbackClient {
invoke<T = unknown>(
capability: string,
args: Record<string, unknown>,
timeoutMs?: number,
): Promise<InvokeResult<T>>;
}
/** rollback 命令的选项面(Options 的子集,便于单测构造)。 */
export interface RollbackOptions {
/** 高级逃生门:直接把该远端路径当回滚源(跳过 LKG 与指纹匹配) */
backup?: string;
/** 只读比对,零写入 */
dryRun?: boolean;
/** 原始 JSON 输出 */
json?: boolean;
}
/** planRollback 的判定结果。 */
export type RollbackAction = 'no-lkg' | 'already-lkg' | 'explicit' | 'match';
export interface RollbackPlan {
action: RollbackAction;
/** 期望的回滚后指纹:LKG 指纹(match)/ 备份指纹(explicit,调用方算好后回填)/ 空(no-lkg) */
targetHash: string;
/** 被控端入口脚本路径(回滚写入目标) */
scriptPath: string;
}
/** 备份候选(fs.list 的一条)。 */
export interface BackupCandidate {
path: string;
name: string;
type: string;
size: number;
mtime: number;
}
/** 指纹比对条目(候选 + 其远端内容指纹)。 */
export interface HashedCandidate {
path: string;
hash: string;
mtime: number;
}
/** runRollback 的结构化结果(不直接碰进程退出码,便于单测断言)。 */
export interface RollbackResult {
/** 0 成功/已是 LKG;1 执行失败;2 前提不满足 */
code: 0 | 1 | 2;
plan: RollbackPlan;
/** 命中/指定的回滚源(未找到为 null) */
source: { path: string; hash: string } | null;
/** 候选指纹清单(无匹配时供排查) */
candidates: HashedCandidate[];
/** 写后校验失败后是否已执行还原写 */
restored: boolean;
/** 是否需要进入「重启 + 复核」阶段(dry-run 恒为 false) */
needsRestart: boolean;
/** 失败原因(成功为 undefined) */
error?: string;
}
// ---------- 纯函数 ----------
/**
* 由入口文件名生成备份候选的本地过滤正则。
*
* 覆盖两套命名(ARCH A-P0-3):
* - deploy 推送式:固定名 `<basename>.bak`(覆盖写)
* - selfupdate 拉取式:时间戳名 `<basename>.bak-<ts>`
*
* 平台相关字符串(Windows 反斜杠路径、文件名)全部收敛进本函数:
* basename 的切分兼容 `\` 与 `/`,正则元字符一律转义。
*/
export function backupNameRegex(basename: string): RegExp {
const base = remoteBasename(basename);
const esc = base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`^${esc}\\.bak(-.*)?$`);
}
/** 取远端路径的 basename(兼容 Windows 反斜杠)。 */
export function remoteBasename(p: string): string {
return p.split(/[\\/]/).pop() ?? '';
}
/** 取远端路径的目录部分(兼容 Windows 反斜杠);无目录时返回空串。 */
export function remoteDirname(p: string): string {
const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'));
return i > 0 ? p.slice(0, i) : '';
}
/**
* 指纹比对:全量相等,或老记录(12 位)按前缀比。
*
* 为什么允许前缀:LkgRecord 老数据只有 12 位 hash(A-P0-3/R-2),
* 控制端不得要求被控端先升级才能回滚 —— 向后兼容,不猜。
*/
export function hashMatches(got: string, want: string): boolean {
if (!got || !want) return false;
if (got === want) return true;
return want.length < got.length && got.startsWith(want);
}
/**
* 双向前缀匹配:短的一方是长的一方的前缀即算命中。
*
* 为什么需要它(v2.1.0 QA 缺陷单):指纹有两个口径同时在场 ——
* · `build.hash` / 老 LKG 的 `hash` 是 **12 位**前缀;
* · `hash_full` / 流式 sha256 结果是 **64 位**全量。
* 写后校验与重启复核是「拿 64 位实测值去比期望值」,而期望值可能是 12 位(老
* 记录)或 64 位(新记录)。用严格 === 会让其中一种口径必然失败:
* · 12 位期望 ↔ 64 位实测 → 老记录回滚永远走「写后校验失败」;
* · 64 位期望 ↔ 12 位实测 → 新记录回滚成功后被误报复核失败(退出码 1)。
* 因此校验方向必须容忍两种口径,而「挑回滚源」方向仍用单向 hashMatches
* (hash_full 在手时不许前缀糊弄,见 tests/unit/qa-v210-supplement.test.mjs C⑥)。
*/
export function hashMatchesEither(a: string, b: string): boolean {
if (!a || !b) return false;
if (a === b) return true;
return a.length < b.length ? b.startsWith(a) : a.startsWith(b);
}
/**
* 回滚计划(纯函数):决定 action 与期望指纹。
*
* - `no-lkg`:无 LKG 记录且未指定 --backup → 前提不满足
* - `already-lkg`:当前指纹已是 LKG → 零写入退出(提示 --backup 逃生门)
* - `explicit`:给了 --backup → 以备份自身为源与期望指纹(targetHash 由调用方回填)
* - `match`:枚举备份 + 指纹匹配定位 LKG 内容
*/
export function planRollback(info: SystemInfoLike, backupPath?: string): RollbackPlan {
const lkg = info.lkg?.lkg ?? null;
const scriptPath = info.agent_script ?? lkg?.script_path ?? '';
if (backupPath) {
return { action: 'explicit', targetHash: '', scriptPath };
}
if (!lkg) {
return { action: 'no-lkg', targetHash: '', scriptPath };
}
const current = info.build?.hash ?? '';
if (current && lkg.hash && current === lkg.hash) {
return { action: 'already-lkg', targetHash: lkg.hash, scriptPath };
}
return { action: 'match', targetHash: lkg.hash_full ?? lkg.hash, scriptPath };
}
/**
* 从「已算好指纹」的候选里挑回滚源(纯函数;mtime 新→早的排序在外层做)。
*
* 为什么排序在外层:本函数只负责「第一个指纹匹配者」,与时间序解耦,可独立测。
*/
export function pickBackupByHash(
cands: HashedCandidate[],
wantHash: string,
): { path: string; hash: string } | null {
if (!wantHash) return null;
for (const c of cands) {
if (hashMatches(c.hash, wantHash)) return { path: c.path, hash: c.hash };
}
return null;
}
// ---------- 远端 IO(client 注入 → 可假测) ----------
/** 分块大小:与 cmdDeploy 一致(512KB),单条 RPC 的安全分块 */
const CHUNK = 512 * 1024;
/**
* 流式计算远端文件指纹(fs.read 分块 + sha256)。
*
* ⚠️ offset 前进量必须用**本次返回字节数**,不能用返回记录里的起始 offset
* (拿它前进会原地打转 —— cmdDeploy 的真机教训:备份 33B→268KB 且卡死 200s)。
*/
export async function hashRemoteFile(
client: RollbackClient,
path: string,
): Promise<{ hash: string; bytes: number }> {
const hash = createHash('sha256');
let offset = 0;
for (;;) {
const r = await client.invoke<{ data?: string; bytes?: number; eof?: boolean }>(
CapabilityNames.FsRead,
{ path, offset, max_bytes: CHUNK, encoding: 'base64' },
);
if (r.status !== 'ok') {
throw new Error(`读取失败: ${r.error?.name ?? 'E_EXECUTION_FAILED'}: ${r.error?.message ?? '未知错误'}`);
}
const d = r.data ?? {};
const chunk = d.data ?? '';
if (chunk) hash.update(Buffer.from(chunk, 'base64'));
const n = d.bytes ?? 0;
offset += n;
if (d.eof || n === 0) break;
}
return { hash: hash.digest('hex'), bytes: offset };
}
/**
* 流式复制远端文件(src → dst),返回复制内容的指纹与字节数。
*
* 写姿势与 cmdDeploy 的上传段完全一致:首块 `append:false`(截断写),
* 续块 `append:true`。base64 必须显式声明 —— 默认 utf8 会把二进制当文本读写坏
* (真机踩过:备份 33 字节的垃圾)。
*/
async function copyRemoteFile(
client: RollbackClient,
srcPath: string,
dstPath: string,
): Promise<{ hash: string; bytes: number }> {
const hash = createHash('sha256');
let offset = 0;
let first = true;
for (;;) {
const r = await client.invoke<{ data?: string; bytes?: number; eof?: boolean }>(
CapabilityNames.FsRead,
{ path: srcPath, offset, max_bytes: CHUNK, encoding: 'base64' },
);
if (r.status !== 'ok') {
throw new Error(`读取失败: ${r.error?.name ?? 'E_EXECUTION_FAILED'}: ${r.error?.message ?? '未知错误'}`);
}
const d = r.data ?? {};
const chunk = d.data ?? '';
const n = d.bytes ?? 0;
if (chunk) {
hash.update(Buffer.from(chunk, 'base64'));
const w = await client.invoke(CapabilityNames.FsWrite, {
path: dstPath,
data: chunk,
encoding: 'base64',
append: !first,
});
if (w.status !== 'ok') {
throw new Error(`写入失败: ${w.error?.name ?? 'E_EXECUTION_FAILED'}: ${w.error?.message ?? '未知错误'}`);
}
first = false;
}
offset += n;
if (d.eof || n === 0) break;
}
// 空文件也要落到目标(否则目标保留旧内容,与「复制」语义相反)
if (first) {
const w = await client.invoke(CapabilityNames.FsWrite, { path: dstPath, data: '' });
if (w.status !== 'ok') {
throw new Error(`写入失败: ${w.error?.name ?? 'E_EXECUTION_FAILED'}: ${w.error?.message ?? '未知错误'}`);
}
}
return { hash: hash.digest('hex'), bytes: offset };
}
// ---------- 主编排(①~⑥,client 注入 → 可假测) ----------
/**
* 执行 rollback 的 ①~⑥ 阶段(不含重启与复核)。
*
* 为什么与 cmdRollback 分开:重启/复核要真实网络(probeOnce 反复重连),
* 单测只覆盖「决策 + 写入 + 校验」这段编排 —— 故 client 注入、
* 进程退出码用返回值表达(code),由 cmdRollback 翻译成 process.exitCode。
*/
export async function runRollback(
client: RollbackClient,
opts: RollbackOptions,
): Promise<RollbackResult> {
const say = (line: string): void => {
if (!opts.json) console.log(line);
};
// ① system.info
const infoRes = await client.invoke<SystemInfoLike>(CapabilityNames.SystemInfo, {});
if (infoRes.status !== 'ok') {
return {
code: 2,
plan: { action: 'no-lkg', targetHash: '', scriptPath: '' },
source: null,
candidates: [],
restored: false,
needsRestart: false,
error: `无法读取被控端信息: ${infoRes.error?.message ?? '未知错误'}`,
};
}
const info = infoRes.data ?? {};
const plan = planRollback(info, opts.backup);
const lkg = info.lkg?.lkg ?? null;
if (lkg) {
say(`LKG 记录 : ${lkg.hash}${lkg.hash_full ? `(全量 ${lkg.hash_full.slice(0, 16)}…)` : ''} ${lkg.bytes} 字节`);
say(` 记录于 ${new Date(lkg.recorded_at).toLocaleString('zh-CN')} · node ${lkg.node_version} · ${lkg.platform}`);
}
const current = info.build?.hash ?? 'unknown';
say(`当前指纹 : ${current}${info.agent_script ? `\n入口路径 : ${info.agent_script}` : ''}`);
// ② 无 LKG(且未给 --backup)
if (plan.action === 'no-lkg') {
return {
code: 2,
plan,
source: null,
candidates: [],
restored: false,
needsRestart: false,
error: '被控端没有 LKG 记录(尚未成功启动过或记录已丢失)—— 无回滚目标,零改动',
};
}
// ③ 当前即 LKG
if (plan.action === 'already-lkg') {
say('✓ 当前版本就是 LKG,无需回滚');
say(' 如需回退到更早的备份,用逃生门: nodeagent rollback --backup <远端备份路径>');
return { code: 0, plan, source: null, candidates: [], restored: false, needsRestart: false };
}
if (!plan.scriptPath) {
return {
code: 2,
plan,
source: null,
candidates: [],
restored: false,
needsRestart: false,
error: '无法确定被控端入口路径(system.info 未上报 agent_script,LKG 也无 script_path)',
};
}
// ④ 枚举备份候选(fs.list + 本地正则过滤;正则才是权威判据)
const dir = remoteDirname(plan.scriptPath);
const base = remoteBasename(plan.scriptPath);
const glob = lkg?.backup_glob ?? `${base}.bak*`;
const listRes = await client.invoke<{ entries?: BackupCandidate[] }>(
CapabilityNames.FsList,
{ path: dir, pattern: glob, max_entries: 500 },
);
if (listRes.status !== 'ok') {
return {
code: 2,
plan,
source: null,
candidates: [],
restored: false,
needsRestart: false,
error: `枚举备份失败: ${listRes.error?.message ?? '未知错误'}`,
};
}
const re = backupNameRegex(base);
const cands: BackupCandidate[] = (listRes.data?.entries ?? [])
.filter((e) => e.type === 'file' && re.test(e.name ?? ''))
.sort((a, b) => (b.mtime ?? 0) - (a.mtime ?? 0)); // mtime 新→早
if (cands.length === 0) {
return {
code: 2,
plan,
source: null,
candidates: [],
restored: false,
needsRestart: false,
error: `未找到备份候选(${dir} 下无 ${glob})—— 回滚内容不存在,零改动`,
};
}
// ⑤ 指纹匹配(流式读 + sha256)
const hashed: HashedCandidate[] = [];
for (const c of cands) {
try {
const h = await hashRemoteFile(client, c.path);
hashed.push({ path: c.path, hash: h.hash, mtime: c.mtime });
} catch (err) {
say(` ⚠️ 跳过无法读取的候选 ${c.path}: ${err instanceof Error ? err.message : String(err)}`);
}
}
say(`备份候选 ${hashed.length} 个(mtime 新→早),指纹比对中…`);
let source: { path: string; hash: string } | null;
let expectHash: string;
if (plan.action === 'explicit') {
// 逃生门:--backup 指定的路径即回滚源;期望指纹 = 它自身的内容指纹
const backupPath = opts.backup as string;
try {
const h = await hashRemoteFile(client, backupPath);
source = { path: backupPath, hash: h.hash };
expectHash = h.hash;
say(`逃生门 : 指定备份 ${backupPath}(指纹 ${h.hash.slice(0, 12)})`);
} catch (err) {
return {
code: 2,
plan,
source: null,
candidates: hashed,
restored: false,
needsRestart: false,
error: `无法读取指定备份 ${backupPath}: ${err instanceof Error ? err.message : String(err)}`,
};
}
} else {
source = pickBackupByHash(hashed, plan.targetHash);
if (!source) {
// 无匹配:列候选指纹供排查,零改动退出
for (const h of hashed) say(` · ${h.path} ${h.hash.slice(0, 12)}`);
return {
code: 2,
plan,
source: null,
candidates: hashed,
restored: false,
needsRestart: false,
error: '没有备份的指纹与 LKG 匹配 —— 已列出各候选指纹,未改动任何文件',
};
}
expectHash = plan.targetHash;
say(`✓ 命中回滚源: ${source.path}(指纹 ${source.hash.slice(0, 12)})`);
}
if (opts.dryRun) {
say('--dry-run:只读比对完成,未写入、未重启');
return { code: 0, plan, source, candidates: hashed, restored: false, needsRestart: false };
}
// ⑥ 覆盖写入 + 写后校验(不一致 → 用回滚源还原 → 报错)
try {
const written = await copyRemoteFile(client, source.path, plan.scriptPath);
const back = await hashRemoteFile(client, plan.scriptPath);
if (back.hash !== written.hash || !hashMatchesEither(back.hash, expectHash)) {
say('✗ 写后校验失败,正在用回滚源还原…');
await copyRemoteFile(client, source.path, plan.scriptPath);
return {
code: 1,
plan,
source,
candidates: hashed,
restored: true,
needsRestart: false,
error:
`写后校验失败(期望 ${expectHash.slice(0, 12)},实得 ${back.hash.slice(0, 12)})—— ` +
`已用回滚源还原 ${plan.scriptPath};请检查磁盘/杀软/路径权限后重试`,
};
}
say(`✓ 已写入 ${plan.scriptPath} 并通过写后校验(指纹 ${back.hash.slice(0, 12)})`);
return { code: 0, plan, source, candidates: hashed, restored: false, needsRestart: true };
} catch (err) {
return {
code: 1,
plan,
source,
candidates: hashed,
restored: false,
needsRestart: false,
error: `回滚写入失败: ${err instanceof Error ? err.message : String(err)}`,
};
}
}
// ---------- 重启与复核(⑦⑧,真实网络) ----------
/** 触发受控重启 + 轮询复核指纹上线(90s 墙钟)。返回是否复核通过。 */
export async function restartAndVerify(expectHash: string): Promise<boolean> {
// ⑦ 独立连接触发重启,吞掉连接中断(重启会切断本连接,属预期行为;
// cmdDeploy 真机教训:当成失败抛出会把成功的部署误判为失败)
try {
const r = await withClient((c) =>
c.invoke(CapabilityNames.AgentRestart, { delay_ms: 1500, reason: 'rollback' }),
);
if (r.status === 'failed') {
console.error(`✗ 重启失败: ${r.error?.message ?? '未知错误'}`);
return false;
}
} catch (err) {
console.log(`(重启切断了连接,属预期:${err instanceof Error ? err.message : String(err)})`);
}
console.log('✓ 已触发重启,复核回滚版本上线…');
// ⑧ probeOnce 轮询(永不抛出的探测;withClient 连不上会 fail 退出,不能用它)
let lastSeen = '连不上';
const deadline = Date.now() + 90_000;
while (Date.now() < deadline) {
await new Promise((t) => setTimeout(t, 1500));
const p = await probeOnce();
if (!p.ok) continue;
lastSeen = p.buildHash ?? 'unknown';
if (hashMatchesEither(p.buildHash ?? '', expectHash)) {
console.log(`✓ 回滚完成并复核通过:指纹 ${p.buildHash},PID ${p.pid ?? '?'},能力 ${p.caps} 项`);
return true;
}
}
console.error(`✗ 90s 内未复核到回滚版本(期望 ${expectHash.slice(0, 12)},最后见到 ${lastSeen})`);
console.error(' 排查:被控端可能没起来 —— 看 <数据目录>/logs/startup-guard.log;');
console.error(' 仍不行则按 docs/DELIVERY-PIPELINE.md 附录 C 走带外恢复');
return false;
}
// ---------- CLI 入口 ----------
/** CLI 主编排:连接被控端 → 执行回滚 →(非 dry-run)重启复核。 */
export async function cmdRollback(opts: RollbackOptions): Promise<void> {
const result = await withClient((c) => runRollback(c, opts));
if (opts.json) {
printJson({
code: result.code,
action: result.plan.action,
target_hash: result.plan.targetHash,
script_path: result.plan.scriptPath,
source: result.source,
candidates: result.candidates.map((c) => ({ path: c.path, hash: c.hash })),
restored: result.restored,
error: result.error,
});
} else if (result.error) {
console.error(`✗ ${result.error}`);
}
if (result.code === 0 && result.needsRestart && result.source) {
const ok = await restartAndVerify(result.plan.targetHash || result.source.hash);
if (!ok) process.exitCode = 1;
return;
}
process.exitCode = result.code;
}
+46 -1
View File
@@ -183,7 +183,7 @@ export async function cmdRestart(opts: Options): Promise<void> {
);
}
// ---------- v25 审计链外部锚定 ----------
// ---------- v2.0.0 审计链外部锚定 ----------
/**
* 打印审计链头(可选与最近锚点比对)。
@@ -231,6 +231,51 @@ export async function cmdAuditAnchor(opts: Options): Promise<void> {
);
}
/**
* L2 系统级审计兜底(见 docs/AUDIT-L2.md)。
* nodeagent audit l2 [status|enable|collect|verify] [--since <ms>] [--limit N]
*/
export async function cmdAuditL2(action: string | undefined, opts: Options): Promise<void> {
const args: Record<string, unknown> = { action: action ?? 'status' };
if (opts.since !== undefined) args['since'] = Number(opts.since);
if (opts.limit !== undefined) args['limit'] = Number(opts.limit);
await withClient((c) =>
callAndPrint(c, CapabilityNames.AuditL2, args, opts.json, (data) => {
const d = data as Record<string, unknown>;
if (d['collected'] !== undefined) {
console.log(`✓ 已采集 ${d['collected']} 条,写入 ${d['written']} 条`);
console.log(` 文件:${d['file']}`);
if (typeof d['note'] === 'string') console.log(` ⚠️ ${d['note']}`);
return;
}
if (d['ok'] !== undefined) {
const ok = d['ok'] === true;
const broken = d['broken_at'] as { line: number; reason: string } | undefined;
console.log(
ok
? `✓ L2 链完整(校验 ${d['checked']} 条)`
: `✗ L2 链异常:第 ${broken?.line} 行 —— ${broken?.reason}`,
);
return;
}
if (d['enabled'] === true) {
console.log(`✓ 已启用 Windows 审计策略:${(d['policies'] as string[]).join(' / ')}`);
if (typeof d['hint'] === 'string') console.log(` ${d['hint']}`);
return;
}
// status
console.log(`平台 : ${d['platform']}`);
console.log(`审计策略 : ${d['auditpol']}`);
console.log(`已禁用旧式策略覆盖 : ${d['legacy_policy_disabled'] ? '是' : '否(⚠️ 会导致 4688 不产生)'}`);
console.log(`记录命令行 : ${d['cmdline_logging'] ? '是' : '否(只能看到进程名)'}`);
console.log(`Sysmon : ${d['sysmon_installed'] ? '已安装(可并用)' : '未安装(不自动下载)'}`);
console.log(`L2 文件 : ${d['file']}${d['file_exists'] ? '' : '(尚未生成)'}`);
if (typeof d['note'] === 'string') console.log(`⚠️ ${d['note']}`);
}),
);
}
export async function cmdAuditVerify(opts: Options): Promise<void> {
await withClient((c) =>
callAndPrint(c, CapabilityNames.AuditVerify, {}, opts.json, (data) => {
+16 -3
View File
@@ -14,6 +14,11 @@ export const HELP = `nodeagent —— 跨机 AI 接管框架(控制端 CLI)
nodeagent audit [--limit 20] [--type invoke|auth|acl|agent] [--client-id X] [--since <ms>]
查询被控端审计日志 (v3+)
nodeagent audit verify 校验审计链完整性(防篡改检测)(v11)
nodeagent audit l2 [status|enable|collect|verify] [--since <ms>] [--limit N]
L2 系统级审计兜底:采集 OS 自身审计事件,
补 L1 记不到的那块(agent 被停/被绕过时)(见 docs/AUDIT-L2.md)
仅 Windows 已落地;Linux 需预装 auditd;
macOS ESF 受 Apple entitlement 限制不可用
nodeagent metrics [--since <ms>] 成功指标:闭环率/装软件率/P95/拦截率 (v13)
nodeagent audio [get] 查看被控端静音状态与主音量 (v17)
nodeagent audio set [--mute on|off] [--volume 0-100] 设置静音 / 音量
@@ -49,12 +54,12 @@ export const HELP = `nodeagent —— 跨机 AI 接管框架(控制端 CLI)
nodeagent events [--kind file|process|net] [--path <路径>] [--pattern G] [--seconds 15]
订阅事件并实时接收推送(无 --kind 则列出订阅)(v12)
nodeagent monitor start --source <port|process|command|metric> [--target X] [--interval ms] [--id 名]
启动定时采样并落盘 (v1.8):port 验连通性/process 验存活/
启动定时采样并落盘 (v2.0.0):port 验连通性/process 验存活/
command 取退出码/metric 取 CPU·内存
nodeagent monitor report <id> 回看样本 + 摘要(断几次、最长断多久、值域)(v1.8)
nodeagent monitor report <id> 回看样本 + 摘要(断几次、最长断多久、值域)(v2.0.0)
nodeagent monitor stop|list|delete <id>
nodeagent log --path <文件> [--pattern 正则] [--level ERROR] [--limit n] [--tail]
在被控端侧过滤日志,只回匹配行(日志几万行也不拖网)(v1.8)
在被控端侧过滤日志,只回匹配行(日志几万行也不拖网)(v2.0.0)
nodeagent record [--duration 5000] [--fps 2] [--scale 0.5] [--region x,y,w,h]
录屏为帧序列(有 ffmpeg 则封装 mp4)(v11)
nodeagent update --url <地址> --sha256 <哈希> [--dry-run]
@@ -62,6 +67,14 @@ export const HELP = `nodeagent —— 跨机 AI 接管框架(控制端 CLI)
nodeagent deploy <agent.mjs> [--path <远端路径>] [--check] [--force]
一键升级被控端 (v11/v18):比指纹 → 备份 → 上传 → 重启
→ 轮询校验;--check 只比对不部署
nodeagent rollback [--dry-run] [--backup <远端备份路径>]
从 LKG(最后已知可用版本)回滚被控端 (v2.1.0):
指纹比对定位备份 → 覆盖回入口 → 受控重启 → 复核上线
⚠️ 需被控端可达(RPC 命令):新版本「完全起不来」时
没有监听可连,请依赖看门狗自动恢复(启动连续失败 N 次,
见 docs/DELIVERY-PIPELINE.md §6)或带外恢复(附录 C)
自动回滚刻意不做(无法归因/回滚循环/失控)
--dry-run 只读比对零写入;--backup 为高级逃生门
nodeagent group [add <组名> <设备...> | remove <组名>] 设备分组管理 (v12)
nodeagent fanout <能力名> [--nodes a,b|@组名] [--args JSON]
多设备并发调用并汇总 (v11)
+16 -1
View File
@@ -34,12 +34,13 @@ import { HELP } from './help.js';
import { parseOptions } from './options.js';
import { cmdConnect, cmdDiscover, cmdKeygen, cmdNodes, cmdRemove, cmdUse } from './cmd/conn.js';
import {
cmdApps, cmdAudio, cmdAudit, cmdAuditAnchor, cmdAuditHead, cmdAuditVerify, cmdExec, cmdInfo, cmdInstall, cmdInvoke, cmdList,
cmdApps, cmdAudio, cmdAudit, cmdAuditAnchor, cmdAuditHead, cmdAuditL2, cmdAuditVerify, cmdExec, cmdInfo, cmdInstall, cmdInvoke, cmdList,
cmdMetrics, cmdPs, cmdRestart, cmdServices, cmdStatus, cmdUpdate,
} from './cmd/system.js';
import {
cmdBg, cmdClip, cmdDeploy, cmdEvents, cmdFanout, cmdLog, cmdMacro, cmdMonitor, cmdRecord, cmdTask, cmdTasks,
} from './cmd/ops.js';
import { cmdRollback } from './cmd/rollback.js';
import { cmdAwait, cmdKey, cmdMouse, cmdScreen, cmdScreenshot } from './cmd/gui.js';
import { cmdCat, cmdLs, cmdPull, cmdPush, cmdStat } from './cmd/fs.js';
import { cmdNet } from './cmd/net.js';
@@ -60,6 +61,16 @@ async function main(): Promise<void> {
return;
}
// ⚠️ `--help` 是**选项**、不是位置参数。
// parseArgs 开了 `strict: false`,`--help` / `-h` 会被收进 `values`(作 boolean),
// `positionals` 里压根没有它 —— 原来靠「`positionals[0] === undefined` 时打印帮助」
// **偶然**蒙对,一旦前面多一个位置参数就会掉进「未知命令」。
// L-01 总闸守卫(tests/unit/cli-options.test.mjs)扫出这类「承诺了却没接上」的选项。
if (opts.help) {
console.log(HELP);
return;
}
switch (command) {
case 'connect': {
const host = positionals[0];
@@ -250,6 +261,9 @@ async function main(): Promise<void> {
await cmdDeploy(f, opts);
return;
}
case 'rollback':
await cmdRollback(opts);
return;
case 'install': {
const pkg = positionals[0];
if (!pkg) fail('用法: nodeagent install <包名|ID>');
@@ -278,6 +292,7 @@ async function main(): Promise<void> {
if (positionals[0] === 'verify') await cmdAuditVerify(opts);
else if (positionals[0] === 'head') await cmdAuditHead(opts);
else if (positionals[0] === 'anchor') await cmdAuditAnchor(opts);
else if (positionals[0] === 'l2') await cmdAuditL2(positionals[1], opts);
else await cmdAudit(opts);
return;
case 'discover':
+19 -1
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' },
@@ -116,6 +127,8 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
'forget-cert': { type: 'boolean', default: false },
check: { type: 'boolean', default: false },
force: { type: 'boolean', default: false },
// v2.1.0 rollback 逃生门(--backup <远端备份路径>)
backup: { type: 'string' },
mute: { type: 'string' },
volume: { type: 'string' },
seconds: { type: 'string' },
@@ -126,6 +139,8 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
});
return {
opts: {
help: Boolean(values['help']),
noTls: Boolean(values['no-tls']),
json: Boolean(values['json']),
insecure: Boolean(values['insecure']),
port: values['port'] as string | undefined,
@@ -165,7 +180,9 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
discoveryPort: values['discovery-port'] as string | undefined,
node: values['node'] as string | undefined,
delay: values['delay'] as string | undefined,
timeoutMs: values['timeout-ms'] as string | undefined,
// 两个写法都接受:`--timeout` 是帮助文本/`await` 命令里的正式写法,
// `--timeout-ms` 是早期在 `bg` 命令里引入的别名。二者同时给时以 `--timeout` 为准。
timeoutMs: (values['timeout'] ?? values['timeout-ms']) as string | undefined,
kill: Boolean(values['kill']),
offset: values['offset'] as string | undefined,
set: values['set'] as string | undefined,
@@ -193,6 +210,7 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
forgetCert: Boolean(values['forget-cert']),
check: Boolean(values['check']),
force: Boolean(values['force']),
backup: values['backup'] as string | undefined,
mute: values['mute'] as string | undefined,
volume: values['volume'] as string | undefined,
seconds: values['seconds'] as string | undefined,
+12 -6
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;
@@ -20,11 +24,11 @@ export interface Options {
/** 通用选项:文本 / 标题 */
text?: string;
title?: string;
/** v25 审计锚定 */
/** v2.0.0 审计锚定 */
compare?: boolean;
anchorPath?: string;
note?: string;
/** v1.8 状态采样 */
/** v2.0.0 状态采样 */
source?: string;
target?: string;
pattern?: string;
@@ -32,17 +36,17 @@ export interface Options {
since?: string;
offset?: string;
tail?: boolean;
/** v1.7 gui.await 扩展 */
/** v2.0.0 gui.await 扩展 */
where?: string;
any_of?: string;
/** v1.6 gui.await */
/** v1.6.0 gui.await */
condition?: string;
state?: string;
path?: string;
process?: string;
/** v1.5 快捷键扩展:长按毫秒 */
/** v1.5.0 快捷键扩展:长按毫秒 */
hold?: string;
/** v1.5 快捷键扩展:投递路由 foreground|post */
/** v1.5.0 快捷键扩展:投递路由 foreground|post */
route?: string;
pid?: string;
/** v3:认证模式 psk | ed25519 */
@@ -92,6 +96,8 @@ export interface Options {
/** v18 部署校验 */
check?: boolean;
force?: boolean;
/** v2.1.0 rollback 逃生门:直接指定远端备份路径为回滚源 */
backup?: string;
/** v17 音频 */
mute?: string;
volume?: string;
+16 -2
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>
+11 -4
View File
@@ -1,13 +1,18 @@
{
"name": "nodeagent-console",
"private": true,
"version": "2.0.0",
"version": "2.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"tauri": "tauri"
"tauri": "tauri",
"sidecar:dev": "node sidecar/main.mjs",
"sidecar:build": "node sidecar/build.mjs",
"prepare:node": "node ../../scripts/prepare-console.mjs",
"verify:bundle": "node scripts/verify-bundle.mjs",
"bundle": "pnpm sidecar:build && pnpm --filter @nodeagent/agent build && pnpm prepare:node && tauri build --config src-tauri/tauri.bundle.gen.json && pnpm verify:bundle"
},
"dependencies": {
"@tauri-apps/api": "^2",
@@ -16,13 +21,15 @@
"naive-ui": "^2.45.3",
"pinia": "^4.0.3",
"vue": "^3.5.13",
"vue-router": "^5.3.1"
"vue-router": "^5.3.1",
"@nodeagent/client": "workspace:*",
"@nodeagent/protocol": "workspace:*"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@vitejs/plugin-vue": "^6.0.7",
"typescript": "~6.0.3",
"vite": "^8.0.16",
"vite": "^8.3.5",
"vue-tsc": "^3.3.5"
}
}
-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);
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env node
/**
* 把 sidecar 打成**单文件**(供 Tauri 打包进应用)。
*
* 用法:node apps/console/sidecar/build.mjs [--out <dir>]
*
* 为什么用 esbuild 的 **JS API** 而不是 spawn `node_modules/.bin/esbuild`:
* pnpm 的 `.bin` 布局下 spawn 该路径会 ENOENT(scripts/pack.mjs 真机踩过)。
* 用 API 则从模块解析走,与包管理器布局无关。
*
* 依赖处理:`@nodeagent/client` 与 `@nodeagent/protocol` 是 workspace 包,
* **一并 bundle 进去**(sidecar 要作为独立文件被 Tauri 拉起,不能依赖仓库 node_modules)。
* `ws` 亦被打入(它是纯 JS)。Node 内置模块(readline/process)保持 external。
*/
import { build } from 'esbuild';
import { mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const argv = process.argv.slice(2);
const outIdx = argv.indexOf('--out');
const outFile = join(outIdx >= 0 ? argv[outIdx + 1] : join(here, 'dist'), 'sidecar.mjs');
mkdirSync(dirname(outFile), { recursive: true });
const result = await build({
entryPoints: [join(here, 'main.mjs')],
outfile: outFile,
bundle: true,
platform: 'node',
format: 'esm',
target: 'node20',
// Node 内置模块保持外部引用;其余(含 ws / workspace 包)全部内联
external: ['node:*'],
// ⚠️ CJS 依赖(如 ws)里有**动态 require**(`require('events')`),esbuild 打成 ESM 后
// 无法转换 → 运行时报 "Dynamic require of X is not supported"。注入 createRequire shim
// 是标准解法:让 bundle 内的 `require(...)` 走真实的 CJS 解析。
banner: {
js:
'// nodeagent console sidecar(由 apps/console/sidecar/build.mjs 生成,请勿手工编辑)\n' +
"import { createRequire as __naCreateRequire } from 'node:module';\n" +
'const require = __naCreateRequire(import.meta.url);',
},
legalComments: 'none',
minify: false, // 保持可读:出问题时能直接看堆栈
metafile: true,
});
const bytes = Object.values(result.metafile.outputs)[0]?.bytes ?? 0;
console.log(`✓ sidecar 已打包: ${outFile} ${(bytes / 1024).toFixed(0)} KB`);
+425
View File
@@ -0,0 +1,425 @@
#!/usr/bin/env node
/**
* console sidecar —— 把 `@nodeagent/client`(TS 客户端)暴露成 **JSON-RPC 2.0 over stdio**。
*
* ## 为什么需要它(选项 D:Tauri + Node sidecar)
* console 的 Rust 壳若自己实现一份 client,就出现「Rust client 与 TS client 两套协议实现」——
* 二者会静默漂移(改了一处漏另一处,且构建、单测都不报错)。sidecar 让 Rust 壳退化为
* **进程管理者 + 消息转发者**,协议实现只剩 TS 一份。
*
* ## 协议(行分隔 JSON,每条消息一行)
* 请求(Rust → 本进程):
* {"jsonrpc":"2.0","id":1,"method":"connect","params":{url,key,client_id,insecure,...}}
* {"jsonrpc":"2.0","id":2,"method":"disconnect"}
* {"jsonrpc":"2.0","id":3,"method":"invoke","params":{capability,args,timeout_ms}}
* {"jsonrpc":"2.0","id":4,"method":"state"}
* {"jsonrpc":"2.0","id":5,"method":"check_update","params":{console_version,url?,timeout_ms?}}
* {"jsonrpc":"2.0","id":6,"method":"download","params":{url,sha256,dest,timeout_ms?}}
* ⚠️ `timeout_ms` 由 Rust 侧下发,**必须小于**它在 `sidecar.rs::request` 上的 RPC 超时,
* 否则永远看到的是「RPC 超时」这种假失败(拿不到真正的原因)。两侧口径在
* apps/console/src-tauri/src/lib.rs 的 `UPDATE_CHECK_TIMEOUT_MS` / `UPDATE_RPC_TIMEOUT_SECS` 里成对定义。
* 响应(本进程 → Rust):{"jsonrpc":"2.0","id":N,"result":{...}} 或 {"...","error":{code,message,data}}
* 通知(无 id,本进程主动推):
* {"jsonrpc":"2.0","method":"event","params":{"kind":"state","state":"connected"}}
* {"jsonrpc":"2.0","method":"event","params":{"kind":"log","message":"..."}}
* {"jsonrpc":"2.0","method":"event","params":{"kind":"agent_event","event":{...}}}
*
* ## 纪律
* - **stdout 只写协议消息**(一行一条);日志一律走 stderr —— 混入任何非 JSON 行都会让 Rust 侧解析失败。
* - 任何异常都转成 error 响应,**绝不因单个请求崩掉进程**(Rust 侧会当成"连接断了")。
*/
import { createInterface } from 'node:readline';
import { createHash } from 'node:crypto';
import { mkdir, readFile, writeFile, unlink } from 'node:fs/promises';
import { dirname } from 'node:path';
import { NodeAgentClient } from '@nodeagent/client';
import { httpGet, resolveProxy } from './proxy.mjs';
/** @type {NodeAgentClient | null} */
let client = null;
/** 最近一次连接参数(重连/状态查询用)。 */
let lastOptions = null;
// ---------------- 输出(唯一写 stdout 的地方) ----------------
function send(msg) {
process.stdout.write(JSON.stringify(msg) + '\n');
}
function ok(id, result) {
send({ jsonrpc: '2.0', id, result: result ?? null });
}
function fail(id, code, message, data) {
send({ jsonrpc: '2.0', id, error: { code, message, ...(data !== undefined ? { data } : {}) } });
}
function notify(kind, payload) {
send({ jsonrpc: '2.0', method: 'event', params: { kind, ...payload } });
}
/** 日志走 stderr(stdout 归协议专用)。 */
function log(level, message) {
process.stderr.write(`[sidecar] ${level} ${message}\n`);
}
// ---------------- 状态快照 ----------------
function stateSnapshot() {
if (!client) return { connected: false };
return {
connected: true,
capabilities: client.listCapabilities(),
authorized: client.listAuthorized(),
agent_version: client.getAgentVersion() ?? null,
agent_build: client.getAgentBuild() ?? null,
peer_cert_fp: client.getPeerCertFingerprint(),
client_id: lastOptions?.clientId ?? null,
auth_mode: lastOptions?.authMode ?? 'psk',
};
}
// ---------------- 方法实现 ----------------
/**
* 比较两个 `x.y.z` 版本号:a > b 返回正、相等返回 0、a < b 返回负。
*
* 按**数值**逐段比较,不做字符串比较 —— 否则 `"2.0.10" < "2.0.9"`(字符串序),
* 到两位数的补丁号时会误判「无更新」。
*/
function cmpVersion(a, b) {
const pa = String(a).split('.').map((x) => parseInt(x, 10) || 0);
const pb = String(b).split('.').map((x) => parseInt(x, 10) || 0);
for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
if (d !== 0) return d > 0 ? 1 : -1;
}
return 0;
}
/**
* 出网超时(毫秒)的裁剪。参数由 Rust 侧下发,**默认值只作兜底** —— 真实口径以
* `lib.rs` 的 `UPDATE_CHECK_TIMEOUT_MS` / `UPDATE_DOWNLOAD_TIMEOUT_MS` 为准。
*/
function clampTimeoutMs(raw, dflt, min = 5_000, max = 1_800_000) {
const n = typeof raw === 'number' && Number.isFinite(raw) ? Math.floor(raw) : dflt;
return Math.max(min, Math.min(max, n));
}
/**
* 决议代理是否可用;**任何异常都退化成「直连」**。
*
* 代理只是为了「本来连不上时能连上」,它本身绝不该成为新的失败点:
* 探测系统代理要跑 `reg`/`scutil`,被安全软件拦、被超时都是可能的,
* 那种情况下退回直连(与改动前行为一致)比让「检查更新」直接报错要好。
*/
async function resolveProxyOrDirect(targetUrl) {
try {
return await resolveProxy({ targetUrl });
} catch (err) {
return { proxy: null, source: null, notes: [`代理探测异常(${err instanceof Error ? err.message : String(err)}),按直连处理`] };
}
}
/** 组一条可操作的失败线索:走了哪条路、代理从哪来、为什么不走代理。 */
function outboundHint({ proxy, source, notes, timeoutMs }) {
return {
via: proxy ? `经代理 ${proxy.href}` : '直连',
proxy: proxy ? proxy.href : null,
proxy_source: source,
timeout_ms: timeoutMs,
notes,
hint: proxy
? '代理已生效但仍失败:请确认代理本身能访问 GitHub(浏览器里试同一个地址)'
: '未使用代理。若本机需要代理才能访问 GitHub,请在系统里开启「系统代理」,或设置环境变量 HTTPS_PROXY=http://127.0.0.1:<端口> 后重启控制台',
};
}
/**
* 是否为「主动中止」类错误 —— **只认真正的 abort**。
*
* ⚠️ 别把 `ECONNRESET` 之类也塞进来(第一版就这么写的,真机上立刻现形):
* 那次耗时 82.9s、预算 120s,连接是被对端**中途重置**的,却被报成
* 「请求超时(120000ms 内未完成)」—— 一句话把用户引到完全错误的方向上。
*/
function isAbortLike(err) {
const name = err?.name ?? '';
const code = err?.code ?? '';
const msg = String(err?.message ?? '');
return name === 'AbortError' || code === 'ABORT_ERR' || /\babort(ed)?\b/i.test(msg);
}
/** 把底层网络错误翻译成「用户能据以行动」的一句话。 */
function describeNetError(err, timeoutMs) {
const msg = err instanceof Error ? err.message : String(err);
const code = err?.code ?? '';
if (isAbortLike(err)) return `请求超时(${timeoutMs}ms 内未完成)`;
if (code === 'ECONNRESET' || code === 'EPIPE') {
return `${msg}(连接被对端中途断开;若走了代理,多半是代理或线路的问题)`;
}
if (code === 'ENOTFOUND' || code === 'EAI_AGAIN' || code === 'ECONNREFUSED') {
return `${msg}(域名解析失败或目标拒绝连接;若配了代理,请核对代理地址与端口)`;
}
return msg;
}
const methods = {
/** 建立连接并完成握手。返回与旧 Rust client 的 ConnectInfo 同构的元信息。 */
async connect(params) {
if (!params?.url || !params?.key) {
throw Object.assign(new Error('connect 需要 url 与 key'), { code: -32602 });
}
// 已有连接先干净关闭,避免两条 ws 并存
if (client) {
try {
client.close();
} catch {
/* 关不掉也无妨,下面会覆盖 */
}
}
lastOptions = {
url: String(params.url),
clientId: String(params.client_id ?? 'console'),
authMode: params.auth_mode === 'ed25519' ? 'ed25519' : 'psk',
};
client = new NodeAgentClient({
url: lastOptions.url,
key: String(params.key),
clientId: lastOptions.clientId,
insecure: params.insecure !== false,
...(params.cert_sha256 ? { certSha256: String(params.cert_sha256) } : {}),
...(params.auth_mode === 'ed25519' ? { authMode: 'ed25519' } : {}),
...(params.private_key ? { privateKey: String(params.private_key) } : {}),
...(params.hub ? { hub: params.hub } : {}),
autoReconnect: params.auto_reconnect !== false,
// 三个回调 → 三条通知(Rust 侧再桥接成 Tauri event)
onStateChange: (state) => notify('state', { state }),
onLog: (message) => notify('log', { message }),
onEvent: (event) => notify('agent_event', { event }),
});
await client.connect();
log('info', `已连接 ${lastOptions.url}(${lastOptions.clientId})`);
return stateSnapshot();
},
disconnect() {
if (client) {
try {
client.close();
} catch (err) {
log('warn', `关闭连接时异常:${String(err)}`);
}
client = null;
}
return { connected: false };
},
async invoke(params) {
// 「调用方式不对」(未连接 / 缺参数)→ 协议错误,Rust 侧能看到明确的 code
if (!client) throw Object.assign(new Error('尚未连接(先调 connect)'), { code: -32005 });
if (!params?.capability) {
throw Object.assign(new Error('invoke 需要 capability'), { code: -32602 });
}
try {
// 正常路径:被控端返回 InvokeResult(status: ok|failed)
return await client.invoke(
String(params.capability),
(params.args ?? {}),
typeof params.timeout_ms === 'number' ? params.timeout_ms : undefined,
);
} catch (err) {
// ⚠️ 被控端对**未知能力 / ACL 拒绝 / 超时**是回 JSON-RPC error,client 会**抛异常**。
// 若原样往上扔,前端就得处理两种形态(result.status 与 error),调用面不统一。
// 故归一成 InvokeResult 的 failed 形态 —— 前端只判 status 即可。
return {
status: 'failed',
error: {
name: err?.rpcName ?? err?.name ?? 'E_INVOKE_FAILED',
message: err instanceof Error ? err.message : String(err),
...(err?.data !== undefined ? { data: err.data } : {}),
},
};
}
},
/**
* 检查更新:拉项目最新 Release 的 `latest.json`,与本应用版本比对。
*
* ## 为什么放在 sidecar 而不是 Rust 壳
* 本壳的既定分工是「**进程管理 + 消息转发**,协议与 IO 逻辑集中在 Node 一侧」
* (见文件头注释)。更新检查要做 HTTPS + JSON + 后续大概率还要下载文件校验哈希,
* 在 Rust 侧等于再引一套 HTTP 栈;而 Node 有内置 `fetch`,一行到位。**单一实现**优先。
*
* `latest.json` 由发版脚本产出(含 version / commit / agent.url / agent.sha256),
* 走 `releases/latest/download/` 这个**稳定别名**,无需调 GitHub API、不占速率配额。
*/
async check_update(params) {
const consoleVersion = String(params?.console_version ?? '0.0.0');
const url = String(
params?.url ?? 'https://github.com/jkooe/nodeagent/releases/latest/download/latest.json',
);
const timeoutMs = clampTimeoutMs(params?.timeout_ms, 120_000);
const out = await resolveProxyOrDirect(url);
log('info', `检查更新:${out.proxy ? `经代理 ${out.proxy.href}(来源 ${out.source})` : '直连'}`);
for (const n of out.notes) log('warn', `检查更新:${n}`);
const t0 = Date.now();
let meta;
try {
const res = await httpGet(url, { proxy: out.proxy, timeoutMs });
if (res.status < 200 || res.status >= 300) {
throw new Error(`拉取 latest.json 失败:HTTP ${res.status} ${res.statusText}`);
}
meta = JSON.parse(res.body.toString('utf8'));
} catch (err) {
const msg = describeNetError(err, timeoutMs);
const where = out.proxy ? `经代理 ${out.proxy.href}` : '直连';
throw Object.assign(
new Error(`检查更新失败:${msg}(${where},耗时 ${Date.now() - t0}ms)`),
{ data: { url, ...outboundHint({ ...out, timeoutMs }) } },
);
}
const latest = String(meta?.version ?? '');
if (!latest) {
throw new Error('latest.json 缺少 version 字段(发版产物异常)');
}
const agent = meta?.agent ?? {};
return {
console_version: consoleVersion,
latest_version: latest,
has_update: cmpVersion(latest, consoleVersion) > 0,
published_at: meta?.built_at ?? null,
commit: meta?.commit ?? null,
capabilities: meta?.capabilities ?? null,
agent: {
url: agent.url ?? null,
sha256: agent.sha256 ?? null,
bytes: agent.bytes ?? null,
},
release_url: `https://github.com/jkooe/nodeagent/releases/tag/v${latest}`,
source: url,
// 让界面/日志能一眼看出这次是直连还是走了代理(真机复验与用户自查都需要)
via_proxy: out.source,
via: out.proxy ? `经代理 ${out.proxy.href}` : '直连',
checked_at: Date.now(),
};
},
/**
* 下载文件并做 sha256 校验(校验不通过**不落盘**,避免半成品污染数据目录)。
* 供「更新本机被控端」使用。
*/
async download(params) {
const url = String(params?.url ?? '');
const expect = String(params?.sha256 ?? '').toLowerCase();
const dest = String(params?.dest ?? '');
if (!url || !dest) {
throw Object.assign(new Error('download 需要 url 与 dest'), { code: -32602 });
}
if (!/^[0-9a-f]{12,64}$/.test(expect)) {
throw Object.assign(
new Error('download 需要 sha256(12~64 位十六进制)—— 不校验哈希等于接受任意内容'),
{ code: -32602 },
);
}
const timeoutMs = clampTimeoutMs(params?.timeout_ms, 300_000);
const out = await resolveProxyOrDirect(url);
log('info', `下载更新包:${out.proxy ? `经代理 ${out.proxy.href}(来源 ${out.source})` : '直连'}`);
const t0 = Date.now();
let buf;
try {
const res = await httpGet(url, { proxy: out.proxy, timeoutMs });
if (res.status < 200 || res.status >= 300) {
throw new Error(`HTTP ${res.status} ${res.statusText}`);
}
buf = res.body;
} catch (err) {
const msg = describeNetError(err, timeoutMs);
const where = out.proxy ? `经代理 ${out.proxy.href}` : '直连';
throw Object.assign(new Error(`下载失败:${msg}(${where},耗时 ${Date.now() - t0}ms)`), {
data: { url, ...outboundHint({ ...out, timeoutMs }) },
});
}
const got = createHash('sha256').update(buf).digest('hex');
if (got !== expect && !got.startsWith(expect)) {
throw Object.assign(new Error('哈希校验失败,已丢弃(未写入任何文件)'), {
data: { expected: expect.slice(0, 12), actual: got.slice(0, 12), bytes: buf.length },
});
}
await mkdir(dirname(dest), { recursive: true });
await writeFile(dest, buf);
// 写后复核:防止截断/半写(磁盘满等)
const written = createHash('sha256')
.update(await readFile(dest))
.digest('hex');
if (written !== got) {
await unlink(dest).catch(() => {});
throw new Error('写入后校验不一致,已删除(请检查磁盘空间)');
}
return { path: dest, sha256: got, bytes: buf.length };
},
state: () => stateSnapshot(),
};
// ---------------- 主循环 ----------------
const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
rl.on('line', (line) => {
const text = line.trim();
if (!text) return;
let req;
try {
req = JSON.parse(text);
} catch {
fail(null, -32700, 'JSON 解析失败');
return;
}
const { id = null, method, params } = req ?? {};
const fn = methods[method];
if (typeof fn !== 'function') {
fail(id, -32601, `未知方法: ${String(method)}`);
return;
}
// 统一包裹:任何异常都变成 error 响应,绝不冒泡崩进程
Promise.resolve()
.then(() => fn(params ?? {}))
.then((result) => ok(id, result))
.catch((err) => {
const code = typeof err?.code === 'number' ? err.code : -32603;
const msg = err instanceof Error ? err.message : String(err);
log('error', `${String(method)} 失败:${msg}`);
fail(id, code, msg, err?.data);
});
});
rl.on('close', () => {
// Rust 侧关闭 stdin(进程退出/窗口关闭)→ 干净收尾后退出
methods.disconnect();
log('info', 'stdin 关闭,sidecar 退出');
process.exit(0);
});
process.on('uncaughtException', (err) => {
log('error', `未捕获异常:${err?.stack ?? String(err)}`);
});
process.on('unhandledRejection', (err) => {
log('error', `未处理的 rejection:${String(err)}`);
});
log('info', `sidecar 就绪(pid=${process.pid},node=${process.version})`);
+523
View File
@@ -0,0 +1,523 @@
/**
* 出站代理支持 —— 只为「检查更新 / 下载更新包」这两条 HTTPS 出网路径服务。
*
* ## 为什么必须自己实现(这是缺陷,不是优化)
*
* Node 内置 `fetch`(undici)**默认不读任何代理环境变量** —— 实测:设了 `HTTPS_PROXY`
* 仍然 `fetch failed`。能开的只有 `NODE_USE_ENV_PROXY=1`(Node 22.x 实验性、24 正式),
* 而它**只能读环境变量**,读不到 Windows 系统代理(WinINET)或 macOS 系统代理
* —— 而中文用户用 Clash / v2rayN 时配置的恰恰是「系统代理」,不是环境变量。
* 结果:境内用户即使代理一切正常,「检查更新」也**必然**报 `fetch failed`。
* (真机取证 2026-10-13:VM 上 DNS 与 TCP 443 都通、但 `fetch failed`;同一台机器
* 直连 GitHub 被阻断、经代理取 `latest.json` 返回 302。)
*
* 另外 `fetch(url, { dispatcher })` 也不通:`dispatcher` 必须是 undici 的 Dispatcher 实例,
* 而 Node 没有把 undici 暴露成可导入的模块(只能整包 vendored 进来,那会把 sidecar
* 从 227KB 撑胖一大截,且 undici 依赖 `.wasm` 解析器、esbuild 单文件打包有风险)。
*
* ## 做法
*
* 不用 `fetch`,改用 `node:http` / `node:https` 自己发请求:
* - **https 目标经代理**:先向代理发 `CONNECT host:443` 建隧道,再在隧道 socket 上做 TLS
* (通过 `createConnection` 交给 `http.request` —— 注意这里刻意用 `http` 而非 `https`,
* 因为我们交出去的**已经是 TLS socket**,用 `https.request` 会二次握手);
* - **http 目标经代理**:按 HTTP 代理规范发**绝对形态**请求行(`GET http://… HTTP/1.1`);
* - **无代理**:与改动前一致,直连(走 `https.request`)。
*
* ## 代理来源优先级
*
* ① `NA_PROXY`(显式覆盖,便于排查与将来做配置项)
* ② 目标协议对应的环境变量(`HTTPS_PROXY`/`https_proxy` → `ALL_PROXY`/`all_proxy` → …)
* ③ **系统代理**(Windows 读注册表 WinINET;macOS 读 `scutil --proxy`)
*
* `NO_PROXY` / `no_proxy` 命中的主机一律直连。只支持 **HTTP 代理**(`http(s)://`)——
* HTTPS 目标经 HTTP 代理走 CONNECT,是通用做法;`socks5://` 会**明确**记一条 note
* 而不是静默直连(静默正是本次缺陷最恶劣的地方)。
*
* ## 安全
*
* 只做「出站取数据」,不引入任何新依赖;TLS 证书默认校验,不提供跳过开关。
*/
import http from 'node:http';
import https from 'node:https';
import tls from 'node:tls';
import { execFile } from 'node:child_process';
/** 代理来源优先级(按目标协议分列)。`NA_PROXY` 恒为第一优先。 */
const ENV_ORDER = {
'https:': ['NA_PROXY', 'HTTPS_PROXY', 'https_proxy', 'ALL_PROXY', 'all_proxy', 'HTTP_PROXY', 'http_proxy'],
'http:': ['NA_PROXY', 'HTTP_PROXY', 'http_proxy', 'ALL_PROXY', 'all_proxy'],
};
const NO_PROXY_KEYS = ['NO_PROXY', 'no_proxy'];
const REDIRECT_STATUS = new Set([301, 302, 303, 307, 308]);
const UA = 'nodeagent-console-sidecar';
/**
* 把环境里/配置里写的各种代理形态归一成可用的 URL。
*
* 接受 `http://127.0.0.1:7890`、`127.0.0.1:7890`(补 `http://`)、带账密的 URL;
* 其余(`socks5://`、空串、无法解析)返回 `null` —— 由调用方把「看到了但用不了」
* 明确报出来,**不许静默直连**。
*
* @param {unknown} raw
* @returns {URL | null}
*/
export function normalizeProxyUrl(raw) {
if (typeof raw !== 'string') return null;
const s = raw.trim();
if (!s) return null;
let u;
try {
u = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(s) ? s : `http://${s}`);
} catch {
return null;
}
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
if (!u.hostname) return null;
return u;
}
/** 代理 URL 的主机端口(缺省按代理自身协议补 80/443)。 */
function proxyEndpoint(u) {
return { host: u.hostname, port: u.port ? Number(u.port) : u.protocol === 'https:' ? 443 : 80 };
}
/** 代理 URL 里的账密 → `Proxy-Authorization` 头(没有则 null)。 */
function proxyAuthHeader(u) {
if (!u.username) return null;
const raw = `${decodeURIComponent(u.username)}:${decodeURIComponent(u.password)}`;
return 'Basic ' + Buffer.from(raw).toString('base64');
}
/**
* `NO_PROXY` 命中判定。支持:精确主机、后缀(`example.com` 或 `.example.com` 都匹配
* `a.example.com`)、`host:port`、`*`。大小写不敏感。
*
* ⚠️ 刻意**不支持 CIDR**(如 `169.254/16`):本模块的目标只有公网域名,
* 而 CIDR 主机名判定极易写错、错了就是「该走代理的没走」。遇到疑似 CIDR 条目
* 由调用方记 note(见 `pickEnvProxy` 的 notes 语义)。
*
* @param {string} targetUrl
* @param {string} noProxy
* @returns {boolean}
*/
export function isBypassed(targetUrl, noProxy) {
if (!noProxy) return false;
let host;
let port;
try {
const u = new URL(targetUrl);
host = u.hostname.toLowerCase();
port = u.port || (u.protocol === 'https:' ? '443' : '80');
} catch {
return false;
}
for (const rawEntry of String(noProxy).split(',')) {
const entry = rawEntry.trim().toLowerCase();
if (!entry) continue;
if (entry === '*') return true;
let eHost = entry;
let ePort = null;
const i = entry.lastIndexOf(':');
// 排除 IPv6 字面量(`[::1]:8080`)里的冒号误判
if (i > 0 && !entry.slice(0, i).includes(':')) {
eHost = entry.slice(0, i);
ePort = entry.slice(i + 1);
}
if (ePort && ePort !== port) continue;
const bare = eHost.replace(/^\./, '');
if (!bare) continue;
if (host === bare || host.endsWith(`.${bare}`)) return true;
}
return false;
}
/**
* 从环境变量里挑代理(纯函数,便于单测)。
*
* @param {Record<string, string | undefined>} env
* @param {string} targetUrl
* @returns {{ url: URL | null, source: string | null, notes: string[] }}
*/
export function pickEnvProxy(env, targetUrl) {
let proto = 'https:';
try {
proto = new URL(targetUrl).protocol;
} catch {
/* 保持默认 */
}
const keys = ENV_ORDER[proto] ?? ENV_ORDER['https:'];
const notes = [];
for (const k of keys) {
const v = env?.[k];
if (!v) continue;
const u = normalizeProxyUrl(v);
if (u) return { url: u, source: k, notes };
notes.push(`${k}=${String(v).trim()} 不是受支持的代理协议(仅支持 http/https 代理),已忽略`);
}
return { url: null, source: null, notes };
}
/**
* 解析 macOS `scutil --proxy` 的输出。
*
* 形如:
* <dictionary> {
* HTTPSEnable : 1
* HTTPSPort : 7890
* HTTPSProxy : 127.0.0.1
* ExceptionsList : <array> { 0 : *.local 1 : 169.254/16 }
* }
*
* 注意 macOS 上 HTTPS 与 HTTP 是**分别开关**的;这里 https 优先,取不到再退 HTTP。
* `ProxyAutoConfigEnable : 1`(PAC 自动配置脚本)**无法解析** —— 返回 notes 说明,
* 让上层给出「请显式设 HTTPS_PROXY」这类可操作提示,而不是含糊的 fetch failed。
*
* @param {string} text
* @returns {{ url: string | null, bypass: string, notes: string[] } | null}
*/
export function parseScutilProxy(text) {
if (typeof text !== 'string' || !text.includes('{')) return null;
const scalar = (key) => {
const m = new RegExp(`^\\s*${key}\\s*:\\s*(.+?)\\s*$`, 'm').exec(text);
return m ? m[1].trim() : null;
};
const isOn = (v) => v === '1' || v === 'true';
const notes = [];
// ExceptionsList 是 <array> 块里的 “序号 : 值” 行 —— 收集成逗号分隔的绕过列表
const bypass = [];
const arrIdx = text.indexOf('ExceptionsList');
if (arrIdx >= 0) {
const body = text.slice(arrIdx, text.indexOf('}', arrIdx) + 1);
const re = /^\s*\d+\s*:\s*(.+?)\s*$/gm;
let m;
while ((m = re.exec(body)) !== null) bypass.push(m[1].trim());
}
for (const [flag, hostKey, portKey, dflt] of [
['HTTPSEnable', 'HTTPSProxy', 'HTTPSPort', '443'],
['HTTPEnable', 'HTTPProxy', 'HTTPPort', '80'],
]) {
if (!isOn(scalar(flag))) continue;
const host = scalar(hostKey);
if (!host) continue;
const port = scalar(portKey) ?? dflt;
return { url: `${host}:${port}`, bypass: bypass.join(','), notes };
}
if (isOn(scalar('ProxyAutoConfigEnable'))) {
notes.push('系统代理使用 PAC 自动配置脚本(ProxyAutoConfigEnable=1),本程序无法解析脚本内容,已按「无代理」处理');
}
return { url: null, bypass: bypass.join(','), notes };
}
/**
* 解析 Windows `reg query …\Internet Settings` 的输出。
*
* 形如(`REG_SZ` / `REG_DWORD` 是**类型名,不随系统语言变化**,可安全按 token 解析):
* ProxyEnable REG_DWORD 0x1
* ProxyOverride REG_SZ *.local;<local>
* ProxyServer REG_SZ 127.0.0.1:7890 (也可能是 http=…;https=… 分协议形态)
*
* `ProxyEnable=0x0` 视为**显式关闭** → 无代理(哪怕 ProxyServer 有值)。
* `<local>` 是「本机地址直连」的占位符,不是域名,从绕过列表里剔除。
*
* @param {string} text
* @returns {{ url: string | null, bypass: string, notes: string[] } | null}
*/
export function parseRegQueryProxy(text) {
if (typeof text !== 'string' || !text.includes('REG_')) return null;
const value = (name) => {
const m = new RegExp(`^\\s*${name}\\s+REG_(?:SZ|EXPAND_SZ|DWORD|BINARY)\\s+(.*?)\\s*$`, 'm').exec(text);
return m ? m[1].trim() : null;
};
const notes = [];
const enable = value('ProxyEnable');
if (enable !== null && /^0x0+$/i.test(enable)) return { url: null, bypass: '', notes };
const server = value('ProxyServer');
if (!server) return { url: null, bypass: '', notes };
const override = value('ProxyOverride');
const bypass = override
? override
.split(';')
.map((s) => s.trim())
.filter((s) => s && !/^<local>$/i.test(s))
.join(',')
: '';
// 分协议形态:http=127.0.0.1:7890;https=127.0.0.1:7891 —— https 优先
let url = server;
if (server.includes('=')) {
const map = new Map();
for (const part of server.split(';')) {
const i = part.indexOf('=');
if (i > 0) map.set(part.slice(0, i).trim().toLowerCase(), part.slice(i + 1).trim());
}
url = map.get('https') ?? map.get('http') ?? null;
if (!url) notes.push(`系统代理仅配置了非 http(s) 协议(ProxyServer=${server})`);
}
return { url, bypass, notes };
}
/** 跑一个只读命令,失败/超时一律返回 null(探测系统代理**绝不允许**把检查更新搞挂)。 */
function runQuiet(file, args) {
return new Promise((resolve) => {
execFile(
file,
args,
// windowsHide:本进程是被 CREATE_NO_WINDOW 拉起的「无控制台」进程,
// 若再 spawn 控制台程序(reg.exe),Windows 会**新开一个控制台** → 黑窗闪一下。
{ windowsHide: true, timeout: 5000, encoding: 'utf8', maxBuffer: 1024 * 1024 },
(err, stdout) => resolve(err ? null : String(stdout)),
);
});
}
/**
* 探测系统代理。`run` 可注入(单测用假 runner,不必真去读注册表)。
*
* @param {NodeJS.Platform} [platform]
* @param {(file: string, args: string[]) => Promise<string | null>} [run]
* @returns {Promise<{ url: string | null, bypass: string, notes: string[] } | null>}
*/
export async function detectSystemProxy(platform = process.platform, run = runQuiet) {
try {
if (platform === 'win32') {
const out = await run('reg', [
'query',
'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings',
]);
return out ? parseRegQueryProxy(out) : null;
}
if (platform === 'darwin') {
const out = await run('scutil', ['--proxy']);
return out ? parseScutilProxy(out) : null;
}
// Linux:桌面环境各异(gsettings / 环境变量 / PAC),不做猜测 —— 只认环境变量
return null;
} catch {
return null;
}
}
/** 系统代理只探测一次(同一进程内结果不变;用户改代理需重启控制台)。 */
let systemProxyPromise;
export function systemProxy() {
if (!systemProxyPromise) systemProxyPromise = detectSystemProxy().catch(() => null);
return systemProxyPromise;
}
/** 单测用:清掉系统代理缓存。 */
export function __resetSystemProxyCache() {
systemProxyPromise = undefined;
}
/**
* 决议本次请求该用哪个代理。
*
* @param {{ targetUrl: string, env?: Record<string, string | undefined>, system?: unknown }} opts
* `system` 显式传入时不做探测(单测用);传 `null` 表示「确认没有系统代理」。
* @returns {Promise<{ proxy: URL | null, source: string | null, notes: string[] }>}
*/
export async function resolveProxy({ targetUrl, env = process.env, system } = {}) {
const notes = [];
const noProxy = NO_PROXY_KEYS.map((k) => env?.[k]).find(Boolean) ?? '';
if (noProxy && isBypassed(targetUrl, noProxy)) {
return { proxy: null, source: null, notes: [`目标命中 NO_PROXY=${noProxy},按直连处理`] };
}
const fromEnv = pickEnvProxy(env ?? {}, targetUrl);
notes.push(...fromEnv.notes);
if (fromEnv.url) return { proxy: fromEnv.url, source: fromEnv.source, notes };
const sys = system === undefined ? await systemProxy() : system;
if (sys?.url) {
notes.push(...(sys.notes ?? []));
if (sys.bypass && isBypassed(targetUrl, sys.bypass)) {
notes.push(`目标命中系统代理的绕过列表(${sys.bypass}),按直连处理`);
return { proxy: null, source: null, notes };
}
const u = normalizeProxyUrl(String(sys.url));
if (u) return { proxy: u, source: 'system', notes };
notes.push(`系统代理 ${sys.url} 不受支持(仅支持 http/https 代理),已按直连处理`);
return { proxy: null, source: null, notes };
}
notes.push(...(sys?.notes ?? []));
return { proxy: null, source: null, notes };
}
/**
* 造一个 `createConnection`:向代理发 CONNECT 建隧道,https 目标再在隧道上叠 TLS。
*
* 返回的 socket 交给 `http.request` 使用(那已经是 TLS socket,故用 `http` 模块)。
*
* ## ⚠️ 为什么必须自己管 `signal`(真机踩过)
* `createConnection` 的回调**返回之前**,外层 `http.request` 还没建立 —— 此时
* 外层请求上的 `signal` / `timeout` 都**无人受理**。真机形态:代理 TCP 能连上、却对
* CONNECT 或随后的 TLS 握手**永不响应**,于是「75s 超时」形同虚设,实际挂了 109s。
* 所以:CONNECT 请求要带 signal,**拿到 socket 与 tlsSock 之后还要自己挂 abort**,
* 失败/成功时都要摘掉监听,避免泄漏与误伤。
*/
export function tunnelConnection(proxy, target, signal) {
const pe = proxyEndpoint(proxy);
return (_opts, cb) => {
let settled = false;
let tunnelSock = null;
let tlsSock = null;
const detach = () => {
if (signal) signal.removeEventListener('abort', onAbort);
if (tunnelSock) tunnelSock.removeListener('error', onSockError);
};
const fail = (err) => {
if (settled) return;
settled = true;
detach();
// 半开的隧道必须销毁,否则这个悬挂连接会一直占着(用户点几次就攒一堆)
try {
tlsSock?.destroy();
tunnelSock?.destroy();
} catch {
/* 已经没了 */
}
cb(err);
};
const onSockError = (e) => fail(e);
const onAbort = () => fail(signal?.reason instanceof Error ? signal.reason : new Error('请求已中止'));
if (signal?.aborted) {
onAbort();
return;
}
signal?.addEventListener('abort', onAbort, { once: true });
const port = target.port || (target.protocol === 'https:' ? 443 : 80);
const headers = { host: `${target.hostname}:${port}` };
const auth = proxyAuthHeader(proxy);
if (auth) headers['proxy-authorization'] = auth;
const req = http.request({
host: pe.host,
port: pe.port,
method: 'CONNECT',
path: `${target.hostname}:${port}`,
headers,
agent: false,
signal,
});
req.once('connect', (res, socket, head) => {
tunnelSock = socket;
if (res.statusCode !== 200) {
fail(new Error(`代理拒绝 CONNECT(HTTP ${res.statusCode})`));
return;
}
if (head && head.length) socket.unshift(head);
if (target.protocol !== 'https:') {
settled = true;
detach();
cb(null, socket);
return;
}
socket.once('error', onSockError);
tlsSock = tls.connect({ socket, servername: target.hostname });
tlsSock.once('secureConnect', () => {
if (settled) return;
settled = true;
detach();
cb(null, tlsSock);
});
tlsSock.once('error', onSockError);
});
req.once('error', onSockError);
req.end();
};
}
/** 单跳请求(不跟随重定向)。 */
function once(urlStr, { proxy, timeoutMs, signal }) {
return new Promise((resolve, reject) => {
const u = new URL(urlStr);
const isHttps = u.protocol === 'https:';
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
reject(new Error(`不支持的协议:${u.protocol}`));
return;
}
// ⚠️ 手搭 options,**不要**把 URL 对象当第一参再覆盖字段 —— 传 URL 时 Node 会用
// URL 里的 host/port/path 覆盖后面的同名字段(原型实测踩过:代理形同无效、退化成直连)。
const opts = {
method: 'GET',
signal,
timeout: timeoutMs,
host: u.hostname,
port: u.port || (isHttps ? 443 : 80),
path: `${u.pathname}${u.search}`,
headers: { accept: '*/*', 'user-agent': UA, host: u.host },
};
let req;
if (proxy && !isHttps) {
// http 目标经代理 → 绝对形态请求行,且请求行交给代理
const pe = proxyEndpoint(proxy);
opts.host = pe.host;
opts.port = pe.port;
opts.path = u.href;
const auth = proxyAuthHeader(proxy);
if (auth) opts.headers['proxy-authorization'] = auth;
req = http.request(opts);
} else if (proxy) {
opts.createConnection = tunnelConnection(proxy, u, signal);
req = http.request(opts);
} else {
req = (isHttps ? https : http).request(opts);
}
req.once('timeout', () => req.destroy(new Error(`请求超时(${timeoutMs}ms 无响应)`)));
req.once('response', (res) => {
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.once('error', reject);
res.once('end', () =>
resolve({ status: res.statusCode, statusText: res.statusMessage, headers: res.headers, body: Buffer.concat(chunks) }),
);
});
req.once('error', reject);
req.end();
});
}
/**
* GET 一个 URL(跟随重定向)。
*
* 与 `fetch(url, {redirect:'follow'})` 的差异:不自动解压(不发 `accept-encoding`,
* 服务端会回 identity)、不解析 cookies —— 这两条对本用途都无影响。
*
* 整个重定向链共享**同一个**超时预算与 abort 信号(而不是每跳各自计时),
* 否则 5 跳 × 30s = 150s 会穿过上层的 RPC 超时。
*
* @param {string} url
* @param {{ proxy?: URL | null, timeoutMs?: number, maxRedirects?: number }} [opts]
* @returns {Promise<{ status: number, statusText: string, headers: Record<string, unknown>, body: Buffer, url: string }>}
*/
export async function httpGet(url, { proxy = null, timeoutMs = 30_000, maxRedirects = 5 } = {}) {
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), timeoutMs);
try {
let current = String(url);
for (let hop = 0; ; hop += 1) {
const res = await once(current, { proxy, timeoutMs, signal: ac.signal });
const loc = res.headers?.location;
if (REDIRECT_STATUS.has(res.status) && typeof loc === 'string' && hop < maxRedirects) {
current = new URL(loc, current).href;
continue;
}
return { ...res, url: current };
}
} finally {
clearTimeout(timer);
}
}
+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
+34 -545
View File
@@ -213,29 +213,6 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "aws-lc-rs"
version = "1.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
dependencies = [
"aws-lc-sys",
"zeroize",
]
[[package]]
name = "aws-lc-sys"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
"pkg-config",
]
[[package]]
name = "base64"
version = "0.21.7"
@@ -254,12 +231,6 @@ version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bit-set"
version = "0.8.0"
@@ -299,15 +270,6 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-buffer"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"hybrid-array",
]
[[package]]
name = "block2"
version = "0.6.2"
@@ -462,8 +424,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
@@ -500,17 +460,6 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "chacha20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
"rand_core",
]
[[package]]
name = "chrono"
version = "0.4.45"
@@ -523,21 +472,6 @@ dependencies = [
"windows-link",
]
[[package]]
name = "cmake"
version = "0.1.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
dependencies = [
"cc",
]
[[package]]
name = "cmov"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
[[package]]
name = "combine"
version = "4.6.8"
@@ -557,12 +491,6 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "cookie"
version = "0.18.2"
@@ -622,15 +550,6 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
@@ -665,15 +584,6 @@ dependencies = [
"typenum",
]
[[package]]
name = "crypto-common"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
]
[[package]]
name = "cssparser"
version = "0.37.0"
@@ -703,42 +613,6 @@ version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d"
[[package]]
name = "ctutils"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574"
dependencies = [
"cmov",
]
[[package]]
name = "curve25519-dalek"
version = "5.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
"curve25519-dalek-derive",
"digest 0.11.3",
"fiat-crypto",
"rustc_version",
"subtle",
"zeroize",
]
[[package]]
name = "curve25519-dalek-derive"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "darling"
version = "0.24.1"
@@ -773,12 +647,6 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "data-encoding"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "dbus"
version = "0.9.12"
@@ -821,16 +689,6 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "der"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a"
dependencies = [
"const-oid",
"zeroize",
]
[[package]]
name = "deranged"
version = "0.5.8"
@@ -867,20 +725,8 @@ version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer 0.10.4",
"crypto-common 0.1.7",
]
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.1",
"const-oid",
"crypto-common 0.2.2",
"ctutils",
"block-buffer",
"crypto-common",
]
[[package]]
@@ -1001,31 +847,6 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "ed25519"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a"
dependencies = [
"pkcs8",
"signature",
]
[[package]]
name = "ed25519-dalek"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de"
dependencies = [
"curve25519-dalek",
"ed25519",
"serde",
"sha2 0.11.0",
"signature",
"subtle",
"zeroize",
]
[[package]]
name = "embed-resource"
version = "3.0.12"
@@ -1135,12 +956,6 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "fiat-crypto"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24"
[[package]]
name = "field-offset"
version = "0.3.6"
@@ -1216,12 +1031,6 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futures-channel"
version = "0.3.34"
@@ -1415,17 +1224,6 @@ dependencies = [
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "getrandom"
version = "0.3.4"
@@ -1447,7 +1245,6 @@ dependencies = [
"cfg-if",
"libc",
"r-efi 6.0.0",
"rand_core",
]
[[package]]
@@ -1634,15 +1431,6 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
dependencies = [
"digest 0.11.3",
]
[[package]]
name = "html5ever"
version = "0.39.0"
@@ -1692,15 +1480,6 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "hybrid-array"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
dependencies = [
"typenum",
]
[[package]]
name = "hyper"
version = "1.12.0"
@@ -2072,16 +1851,6 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "jobserver"
version = "0.1.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
dependencies = [
"getrandom 0.4.3",
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.106"
@@ -2330,37 +2099,17 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nodeagent-client"
version = "0.1.0"
dependencies = [
"base64 0.23.1",
"ed25519-dalek",
"futures-util",
"hmac",
"rand",
"rustls",
"rustls-pemfile",
"serde",
"serde_json",
"sha2 0.11.0",
"thiserror 2.0.21",
"tokio",
"tokio-tungstenite",
"url",
"webpki-roots 1.0.9",
]
[[package]]
name = "nodeagent-console"
version = "0.1.0"
version = "2.1.0"
dependencies = [
"nodeagent-client",
"serde",
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-opener",
"tauri-plugin-single-instance",
"tokio",
]
[[package]]
@@ -2759,16 +2508,6 @@ dependencies = [
"futures-io",
]
[[package]]
name = "pkcs8"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7"
dependencies = [
"der",
"spki",
]
[[package]]
name = "pkg-config"
version = "0.3.34"
@@ -2956,23 +2695,6 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
dependencies = [
"chacha20",
"getrandom 0.4.3",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "raw-window-handle"
version = "0.6.2"
@@ -3081,20 +2803,6 @@ dependencies = [
"web-sys",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rustc-hash"
version = "2.1.3"
@@ -3123,51 +2831,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"aws-lc-rs",
"log",
"once_cell",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pemfile"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"aws-lc-rs",
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.23"
@@ -3428,17 +3091,6 @@ dependencies = [
"stable_deref_trait",
]
[[package]]
name = "sha1"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
"digest 0.11.3",
]
[[package]]
name = "sha2"
version = "0.10.9"
@@ -3446,19 +3098,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest 0.10.7",
]
[[package]]
name = "sha2"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
"digest 0.11.3",
"cpufeatures",
"digest",
]
[[package]]
@@ -3477,12 +3118,6 @@ dependencies = [
"libc",
]
[[package]]
name = "signature"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5"
[[package]]
name = "simd-adler32"
version = "0.3.10"
@@ -3565,16 +3200,6 @@ dependencies = [
"system-deps",
]
[[package]]
name = "spki"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9"
dependencies = [
"base64ct",
"der",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -3611,12 +3236,6 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "swift-rs"
version = "1.0.8"
@@ -3839,7 +3458,7 @@ dependencies = [
"semver",
"serde",
"serde_json",
"sha2 0.10.9",
"sha2",
"syn 2.0.119",
"tauri-utils",
"thiserror 2.0.21",
@@ -3901,6 +3520,22 @@ dependencies = [
"zbus",
]
[[package]]
name = "tauri-plugin-single-instance"
version = "2.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c142ed88deee222bd2d979269d35c73b6c1c0f6ebd5b79b4ff80066fcad6af1"
dependencies = [
"serde",
"serde_json",
"tauri",
"thiserror 2.0.21",
"tokio",
"tracing",
"windows-sys 0.61.2",
"zbus",
]
[[package]]
name = "tauri-runtime"
version = "2.12.1"
@@ -4119,6 +3754,7 @@ dependencies = [
"libc",
"mio",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
@@ -4135,32 +3771,6 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "tokio-rustls"
version = "0.26.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-tungstenite"
version = "0.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71"
dependencies = [
"futures-util",
"log",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tungstenite",
"webpki-roots 0.26.11",
]
[[package]]
name = "tokio-util"
version = "0.7.19"
@@ -4374,24 +3984,6 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tungstenite"
version = "0.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22"
dependencies = [
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand",
"rustls",
"rustls-pki-types",
"sha1",
"thiserror 2.0.21",
]
[[package]]
name = "typeid"
version = "1.0.3"
@@ -4427,12 +4019,6 @@ version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.8"
@@ -4687,24 +4273,6 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.9",
]
[[package]]
name = "webpki-roots"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.39.1"
@@ -4893,16 +4461,7 @@ version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
"windows-targets",
]
[[package]]
@@ -4920,29 +4479,13 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
dependencies = [
"windows_aarch64_gnullvm 0.42.2",
"windows_aarch64_msvc 0.42.2",
"windows_i686_gnu 0.42.2",
"windows_i686_msvc 0.42.2",
"windows_x86_64_gnu 0.42.2",
"windows_x86_64_gnullvm 0.42.2",
"windows_x86_64_msvc 0.42.2",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
@@ -4969,90 +4512,42 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winnow"
version = "0.5.40"
@@ -5123,7 +4618,7 @@ dependencies = [
"once_cell",
"percent-encoding",
"raw-window-handle",
"sha2 0.10.9",
"sha2",
"soup3",
"tao-macros",
"thiserror 2.0.21",
@@ -5272,12 +4767,6 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
[[package]]
name = "zerotrie"
version = "0.2.5"
+11 -4
View File
@@ -1,7 +1,7 @@
[package]
name = "nodeagent-console"
version = "0.1.0"
description = "nodeagent 桌面控制台(主控 + 被控一体)"
version = "2.1.0"
description = "nodeagent 桌面控制台(主控 + 被控一体,含本机服务托管)"
authors = ["jkooe"]
edition = "2021"
@@ -18,11 +18,18 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
# `tray-icon`:托盘常驻(后台运行 / 开机自启的载体)必需。
# 不给这个 feature,`TrayIconBuilder` 根本不存在(编译期就找不到符号)。
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-opener = "2"
# ⚠️ 单实例保护(v2.0.3 修):缺了它,「开机自启的隐藏实例 + 用户手开的实例」会并存,
# 各自托管一个被控端 → 同机两个被控端抢同一个 8765。详见 src/lib.rs 顶部「单实例」一节。
tauri-plugin-single-instance = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
nodeagent-client = { path = "../../../crates/nodeagent-client" }
# ⚠️ 2026-10:不再依赖 crates/nodeagent-client —— 协议实现改由 Node sidecar 承担
# (见 src/sidecar.rs 的说明:避免 Rust/TS 两套协议实现静默漂移)
tokio = { version = "1", features = ["process", "io-util", "sync", "time", "macros", "rt-multi-thread"] }
# Read the optimization guideline for more details: https://tauri.app/concept/size/#cargo-configuration
File diff suppressed because it is too large. Load diff
+299
View File
@@ -0,0 +1,299 @@
//! 开机自启管理 —— 「随系统启动」开关的落地实现。
//!
//! ## 设计:自启的是**控制台的仅服务模式**,不是裸 node
//!
//! 两个候选方案,各有硬伤:
//! - **直接自启 `node agent.mjs`**:Windows 上会弹一个黑色控制台窗口(schtasks 无法指定
//! `CREATE_NO_WINDOW`),且进程无人托管 —— 崩了不会拉起、也无法从界面停止。
//! - **自启本控制台(`--agent-only`)**:本应用是 GUI 子系统程序,**不会有控制台窗口**;
//! 且它本来就负责「托管被控端」(启动/停止/自动重启/日志),语义完全对齐。
//!
//! 故选后者。`--agent-only` 模式下应用不显示主窗口,直接进托盘并拉起被控端。
//!
//! ## 平台差异
//! | 平台 | 机制 | 位置 |
//! |---|---|---|
//! | macOS | LaunchAgent(用户级,无需提权) | `~/Library/LaunchAgents/<label>.plist` |
//! | Windows | 计划任务(登录时触发,Interactive 登录类型) | 任务名 `<label>` |
//!
//! Windows 用**登录用户 + Interactive** 而非 SYSTEM:被控端的图形能力(截图 / 键鼠 / 窗口)
//! 必须跑在交互式会话里,SYSTEM(会话 0)没有桌面 —— 这一点在真机验证时已实证。
use serde_json::{json, Value};
use std::path::{Path, PathBuf};
use std::process::Command;
/// 自启项标识(macOS 的 launchd label / Windows 的计划任务名)。
const LABEL: &str = "cn.jkooe.nodeagent-console";
/// 仅服务模式命令行参数(无窗口、直接进托盘并拉起被控端)。
pub const AGENT_ONLY_FLAG: &str = "--agent-only";
/// 当前可执行文件路径。
///
/// ⚠️ 出口经 `unverbatim`:Windows 上 `current_exe()` 可能带 `\\?\` 前缀,
/// 而这条路径会**被写进计划任务的执行路径**(`New-ScheduledTaskAction -Execute`)。
/// 带前缀的路径不是「一定不能用」,但它会以原样进入 PowerShell 字符串与任务定义 ——
/// 与 2026-10-13「verbatim 路径交给外部程序」那类真机缺陷同源,统一在出口净化。
fn app_exe() -> Result<PathBuf, String> {
std::env::current_exe()
.map(crate::sidecar::unverbatim)
.map_err(|e| format!("取当前程序路径失败:{e}"))
}
/// macOS 上 LaunchAgent plist 的位置。
fn macos_plist_path() -> Result<PathBuf, String> {
let home = std::env::var_os("HOME")
.map(PathBuf::from)
.ok_or("拿不到 HOME")?;
Ok(home.join("Library").join("LaunchAgents").join(format!("{LABEL}.plist")))
}
/// 生成 macOS LaunchAgent plist。
///
/// `RunAtLoad` 保证登录即拉起;**不用** `KeepAlive` —— 让「退出」按钮能真正退出,
/// 用户想恢复就重新登录或再开应用。进程级别的崩溃自愈由控制台的 `AgentManager` 负责,
/// 不该在这里再叠一层(两层自愈会互相打架)。
fn macos_plist(exe: &Path) -> String {
let exe = xml_escape(&exe.to_string_lossy());
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!-- 由 nodeagent 控制台生成 —— 请勿手工编辑(界面上的「开机自启」开关会覆写本文件) -->
<plist version="1.0">
<dict>
<key>Label</key>
<string>{LABEL}</string>
<key>ProgramArguments</key>
<array>
<string>{exe}</string>
<string>{AGENT_ONLY_FLAG}</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>ProcessType</key>
<string>Interactive</string>
</dict>
</plist>
"#
)
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&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 })
}
}
/// 自启是否已启用。
///
/// ## 为什么要缓存(2026-10-17 真机)
/// Windows 上 `schtasks /Query` 实测**约 2 秒**(`Get-ScheduledTask` 同样 2s),
/// 而这个函数每次 `agent_status` 都会被调用 —— 于是每刷新一次界面就白等 2 秒,
/// 叠加多次刷新后表现为「按钮转圈不结束、PID 不再更新」。
///
/// 自启开关是低频状态(用户手动切换才会变),故缓存 5 秒即可:
/// 切换自启后紧接着的那次 `status()` 可能读到旧值,但 `set_autostart` 本身
/// 会把结果写回缓存(见 `invalidate_cache`),界面不会看到错的状态。
fn is_enabled_uncached() -> bool {
if cfg!(target_os = "macos") {
macos_plist_path().map(|p| p.exists()).unwrap_or(false)
} else if cfg!(windows) {
Command::new("schtasks")
.args(["/Query", "/TN", LABEL])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
} else {
false
}
}
/// 自启状态缓存:`(取值时刻 ms, 值)`。0 表示从未查过。
static AUTOSTART_CACHE: std::sync::Mutex<(u64, bool)> = std::sync::Mutex::new((0, false));
const AUTOSTART_TTL_MS: u64 = 5_000;
fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
pub fn is_enabled() -> bool {
{
let c = AUTOSTART_CACHE.lock().unwrap();
if c.0 != 0 && now_ms().saturating_sub(c.0) < AUTOSTART_TTL_MS {
return c.1;
}
}
let v = is_enabled_uncached();
*AUTOSTART_CACHE.lock().unwrap() = (now_ms(), v);
v
}
/// 切换自启后立刻让缓存失效 —— 否则用户点完开关,界面会显示 5 秒前的旧值。
pub fn invalidate_cache() {
*AUTOSTART_CACHE.lock().unwrap() = (0, false);
}
/// 读取自启状态的完整快照(供界面展示)。
pub fn status() -> Value {
let exe = app_exe();
let supported = cfg!(any(target_os = "macos", windows));
let where_ = if cfg!(target_os = "macos") {
macos_plist_path().map(|p| p.to_string_lossy().to_string()).unwrap_or_default()
} else if cfg!(windows) {
format!("计划任务 {LABEL}")
} else {
String::new()
};
json!({
"enabled": is_enabled(),
"supported": supported,
"location": where_,
"app_exe": exe.map(|p| p.to_string_lossy().to_string()).unwrap_or_default(),
"mode": AGENT_ONLY_FLAG,
"note": if supported {
"开机自启将以「仅服务模式」拉起本控制台:不显示窗口、直接进托盘并托管本机被控端。"
} else {
"当前平台暂不支持——请在系统设置里自行添加登录项。"
},
})
}
/// 开启 / 关闭开机自启。
pub fn set(enabled: bool) -> Result<Value, String> {
if !cfg!(any(target_os = "macos", windows)) {
return Err("当前平台暂不支持自动配置开机自启".to_string());
}
let exe = app_exe()?;
let detail = if cfg!(target_os = "macos") {
let plist = macos_plist_path()?;
if enabled {
if let Some(dir) = plist.parent() {
std::fs::create_dir_all(dir).map_err(|e| format!("创建 LaunchAgents 目录失败:{e}"))?;
}
std::fs::write(&plist, macos_plist(&exe)).map_err(|e| format!("写入 plist 失败:{e}"))?;
macos_launchctl(&plist, true)?
} else {
if plist.exists() {
std::fs::remove_file(&plist).map_err(|e| format!("删除 plist 失败:{e}"))?;
}
macos_launchctl(&plist, false)?
}
} else {
windows_schtasks(&exe, enabled)?
};
// 刚改过自启状态,缓存必然过期 —— 不清就会把 5 秒前的旧值回给界面
invalidate_cache();
let mut snap = status();
snap["detail"] = json!(detail);
Ok(snap)
}
+756 -76
View File
@@ -1,42 +1,164 @@
//! nodeagent 桌面控制台(主控 + 被控一体)——Tauri 后端。
//!
//! command 层把 `nodeagent-client`(Rust 控制端)接到前端:
//! ## 结构(2026-10 起:sidecar 方案)
//! 本层只做**进程管理 + 消息转发**,真正的协议实现只有 TS 一份(`@nodeagent/client`),
//! 由 `sidecar.rs` 拉起的 Node 进程承载。原先「Rust 版 client 与 TS 版并存」的漂移风险就此消除。
//!
//! `agent.rs` 是同一思路的延伸:本机**被控端**也由本层托管(启动 / 停止 / 重启 / 日志 /
//! 崩溃自愈),用户在界面上点按钮即可,全程不必碰命令行。
//!
//! ## command 面
//! 连接类(签名与语义从未变过):
//! - `connect` / `disconnect` / `invoke_capability` / `get_capabilities` / `get_state`
//! - client 的日志/状态/被控端事件经 Tauri `emit` 推给前端(`na-log`/`na-state`/`na-event`)。
//!
//! 本机服务类(2026-10-11 新增):
//! - `agent_status` / `agent_start` / `agent_stop` / `agent_restart`
//! - `agent_logs` / `agent_logs_clear`
//! - `autostart_status` / `autostart_set`
//! - `check_update` / `apply_update`
//! - `settings_get` / `settings_set` / `system_info`
//!
//! ## 事件面
//! - 连接:`na-state` / `na-log` / `na-event`
//! - 本机服务:`na-agent-state` / `na-agent-log`
//!
//! ## 启动模式
//! - 默认:显示主窗口
//! - `--agent-only`:**不显示窗口**,直接进托盘并托管本机被控端(开机自启用它,
//! 这样登录自启也不会有任何窗口弹出来)
//!
//! ## 单实例(v2.0.3)
//! 见下方「单实例」一节 —— 真机缺陷:无保护时同机会跑起两个被控端。
mod agent;
mod autostart;
mod settings;
mod sidecar;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use nodeagent_client::{
CapabilityDescriptor, Client, ClientEvent, ClientOptions, ConnState, InvokeResult,
};
use serde::Serialize;
use serde_json::Value;
use tauri::{AppHandle, Emitter, State};
use serde_json::{json, Value};
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
use tauri::{AppHandle, Emitter, Manager, State};
/// 全局状态:持有当前连接(`None` = 未连接)。
use agent::{AgentEvent, AgentManager};
use sidecar::{Sidecar, SidecarEvent};
/// 全局状态。
///
/// `connect` 成功后缓存「能力清单 / 版本 / 连接态」——这样 `get_capabilities`、
/// `get_state` 能保持**同步**(无需每次往返 sidecar),签名与改造前一致。
struct AppState {
client: Mutex<Option<Client>>,
sidecar: tokio::sync::Mutex<Option<Arc<Sidecar>>>,
capabilities: Mutex<Value>,
agent_version: Mutex<Option<String>>,
conn_state: Mutex<String>,
/// 本机被控端管理器(在 `setup` 里建,因为需要 AppHandle 来桥接事件)
agent: Mutex<Option<Arc<AgentManager>>>,
settings: Mutex<settings::Settings>,
/// 控制台自身配置目录(设置存这里)
config_dir: PathBuf,
/// 控制台版本(由 `package_info` 注入,用于更新比对;不在代码里写死)
app_version: String,
}
/// 连接成功后回传给前端的元信息。
#[derive(Serialize)]
impl AppState {
fn agent(&self) -> Result<Arc<AgentManager>, String> {
self.agent
.lock()
.unwrap()
.as_ref()
.map(Arc::clone)
.ok_or_else(|| "被控端管理器尚未初始化".to_string())
}
fn settings(&self) -> settings::Settings {
self.settings.lock().unwrap().clone()
}
}
/// 连接成功后回传给前端的元信息(字段名与改造前一致)。
#[derive(serde::Serialize)]
struct ConnectInfo {
capabilities: Vec<CapabilityDescriptor>,
capabilities: Value,
agent_version: Option<String>,
authorized: Option<Vec<String>>,
authorized: Option<Value>,
peer_cert_fp: Option<String>,
auth_mode: String,
client_id: String,
}
fn conn_state_str(s: ConnState) -> &'static str {
match s {
ConnState::Connected => "connected",
ConnState::Reconnecting => "reconnecting",
ConnState::Closed => "closed",
}
/// 本机被控端的三条路径。
struct AgentPaths {
node: PathBuf,
script: PathBuf,
data_dir: PathBuf,
}
/// 解析(并在需要时播种)本机被控端所需路径。
fn resolve_agent_paths(app: &AppHandle, state: &AppState) -> Result<AgentPaths, String> {
let resource_dir = app.path().resource_dir().ok();
let node = sidecar::resolve_node(resource_dir.as_deref())?;
let bundled = agent::resolve_bundled_agent(resource_dir.as_deref())?;
let data_dir = agent::agent_data_dir();
let script = agent::seed_agent_script(&bundled, &data_dir, &state.app_version)?;
Ok(AgentPaths { node, script, data_dir })
}
/// 懒启动 sidecar(首次调用时拉起,之后复用同一进程)。
async fn ensure_sidecar(app: &AppHandle, state: &AppState) -> Result<Arc<Sidecar>, String> {
let mut guard = state.sidecar.lock().await;
if let Some(sc) = guard.as_ref() {
if sc.is_alive() {
return Ok(Arc::clone(sc));
}
// ⚠️ 缓存里那个 sidecar **已经退出**,绝不能继续复用。
//
// 为什么以前没发现:往已退出进程的管道里写,在 Windows 上未必立刻报错
// (管道缓冲区吞下就返回成功),于是请求会一路干等到 `request` 的超时。
// 真机实证(2026-10-13,Windows 11 ARM64):控制台从 10-12 起一直开着,
// 其 sidecar 早已退出;用户点「检查更新」等满 45 秒,界面报
// 「检查更新失败:check_update 超时」—— 而同一台机器上**手工拉起 sidecar
// 跑同一个请求只要 7 秒**(两相对照即锁定「复用死实例」)。
// 丢掉缓存往下走,重新拉起一个。
eprintln!("[sidecar] 检测到已退出的实例,正在重建");
*guard = None;
}
// 打包后 node 与 sidecar.mjs 随 resource 分发;开发期用系统 node + 仓库内脚本
let resource_dir = app.path().resource_dir().ok();
let node = sidecar::resolve_node(resource_dir.as_deref())?;
let script = sidecar::resolve_sidecar(resource_dir.as_deref())?;
// 事件桥接:sidecar 通知 → Tauri event(名字与改造前一致)
let app_events = app.clone();
let on_event = Arc::new(move |evt: SidecarEvent| match evt {
SidecarEvent::State(s) => {
let _ = app_events.emit("na-state", s);
}
SidecarEvent::Log(msg) => {
let _ = app_events.emit("na-log", msg);
}
SidecarEvent::Event(params) => {
let _ = app_events.emit("na-event", params);
}
SidecarEvent::Other(raw) => {
let _ = app_events.emit("na-log", format!("[sidecar] 未知通知: {raw}"));
}
});
let sc = Sidecar::spawn(&node, &script, on_event)?;
*state.conn_state.lock().unwrap() = "未连接".to_string();
*guard = Some(Arc::clone(&sc));
Ok(sc)
}
// ---------------------------------------------------------------------------
// 连接类 command(签名与语义保持不变)
// ---------------------------------------------------------------------------
/// 建立到被控端的连接并完成握手(默认 psk 模式、接受自签证书、开启自动重连)。
#[tauri::command]
async fn connect(
@@ -47,51 +169,51 @@ async fn connect(
client_id: Option<String>,
insecure: Option<bool>,
) -> Result<ConnectInfo, String> {
let sc = ensure_sidecar(&app, &state).await?;
let client_id = client_id.unwrap_or_else(|| "console".to_string());
let client = Client::new(ClientOptions {
url,
key,
client_id: client_id.clone(),
insecure: insecure.unwrap_or(true),
auto_reconnect: true,
..Default::default()
});
// 把 client 的事件桥接成 Tauri 事件,推给前端。
let app_events = app.clone();
client.set_handler(Arc::new(move |evt| match evt {
ClientEvent::State(s) => {
let _ = app_events.emit("na-state", conn_state_str(s));
}
ClientEvent::Log(msg) => {
let _ = app_events.emit("na-log", msg);
}
ClientEvent::Event(params) => {
let _ = app_events.emit("na-event", params);
}
}));
let snap = sc
.request(
"connect",
json!({
"url": url,
"key": key,
"client_id": client_id,
"insecure": insecure.unwrap_or(true),
}),
30,
)
.await?;
let caps = client.connect().await.map_err(|e| e.to_string())?;
let caps = snap.get("capabilities").cloned().unwrap_or_else(|| json!([]));
let ver = snap.get("agent_version").and_then(Value::as_str).map(str::to_string);
*state.capabilities.lock().unwrap() = caps.clone();
*state.agent_version.lock().unwrap() = ver.clone();
*state.conn_state.lock().unwrap() = "connected".to_string();
let info = ConnectInfo {
agent_version: client.agent_version(),
authorized: client.list_authorized(),
peer_cert_fp: client.peer_cert_fingerprint(),
auth_mode: "psk".to_string(),
Ok(ConnectInfo {
capabilities: caps,
agent_version: ver,
authorized: snap.get("authorized").cloned(),
peer_cert_fp: snap.get("peer_cert_fp").and_then(Value::as_str).map(str::to_string),
auth_mode: snap
.get("auth_mode")
.and_then(Value::as_str)
.unwrap_or("psk")
.to_string(),
client_id,
};
*state.client.lock().unwrap() = Some(client);
Ok(info)
})
}
/// 断开连接(停止自动重连)。
#[tauri::command]
fn disconnect(state: State<'_, AppState>) -> Result<(), String> {
if let Some(client) = state.client.lock().unwrap().take() {
client.close();
async fn disconnect(state: State<'_, AppState>) -> Result<(), String> {
let sc = { state.sidecar.lock().await.as_ref().map(Arc::clone) };
if let Some(sc) = sc {
sc.request("disconnect", json!({}), 10).await?;
}
*state.conn_state.lock().unwrap() = "未连接".to_string();
*state.capabilities.lock().unwrap() = json!([]);
Ok(())
}
@@ -102,50 +224,608 @@ async fn invoke_capability(
capability: String,
args: Option<Value>,
timeout_ms: Option<u64>,
) -> Result<InvokeResult, String> {
let client = {
let guard = state.client.lock().unwrap();
guard.as_ref().cloned().ok_or_else(|| "未连接".to_string())?
) -> Result<Value, String> {
let sc = {
state
.sidecar
.lock()
.await
.as_ref()
.map(Arc::clone)
.ok_or_else(|| "未连接".to_string())?
};
let args = args.unwrap_or(Value::Object(Default::default()));
match timeout_ms {
Some(ms) if ms > 0 => client.invoke_with_timeout(&capability, args, ms).await,
_ => client.invoke(&capability, args).await,
let mut params = json!({ "capability": capability, "args": args.unwrap_or_else(|| json!({})) });
if let Some(ms) = timeout_ms.filter(|m| *m > 0) {
params["timeout_ms"] = json!(ms);
}
.map_err(|e| e.to_string())
// sidecar 侧已把「未知能力 / ACL 拒绝 / 超时」归一成 {status:"failed",error:{...}},
// 故这里拿到的是稳定的 InvokeResult 形态(前端只需判 status)。
sc.request("invoke", params, timeout_ms.map(|m| (m / 1000) + 10).unwrap_or(70)).await
}
/// 当前连接的能力清单。
/// 当前连接的能力清单(读缓存,保持同步签名)。
#[tauri::command]
fn get_capabilities(state: State<'_, AppState>) -> Result<Vec<CapabilityDescriptor>, String> {
let guard = state.client.lock().unwrap();
let client = guard.as_ref().ok_or_else(|| "未连接".to_string())?;
Ok(client.list_capabilities())
fn get_capabilities(state: State<'_, AppState>) -> Result<Value, String> {
Ok(state.capabilities.lock().unwrap().clone())
}
/// 当前连接状态字符串(connected / reconnecting / closed / 未连接)。
#[tauri::command]
fn get_state(state: State<'_, AppState>) -> String {
match state.client.lock().unwrap().as_ref() {
Some(client) => conn_state_str(client.state()).to_string(),
None => "未连接".to_string(),
state.conn_state.lock().unwrap().clone()
}
// ---------------------------------------------------------------------------
// 本机服务(被控端)command
// ---------------------------------------------------------------------------
/// 本机服务总览:进程状态 + 配置 + 自启 + 设置 + 版本。
#[tauri::command]
async fn agent_status(app: AppHandle, state: State<'_, AppState>) -> Result<Value, String> {
// 管理器可能尚未建(极早的调用);此时给一个纯配置视图,而不是报错
let mgr = state.agent.lock().unwrap().clone();
let snap = match &mgr {
Some(m) => m.snapshot().await,
None => json!({ "state": agent::state::STOPPED }),
};
// 数据目录以「实际使用的」为准;管理器还没跑过就用默认值
let data_dir = snap
.get("data_dir")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(PathBuf::from)
.unwrap_or_else(agent::agent_data_dir);
let cfg = agent::read_agent_config(&data_dir);
let resource_dir = app.path().resource_dir().ok();
Ok(json!({
"manager": snap,
"config": cfg,
"autostart": autostart::status(),
"settings": settings::to_json(&state.settings()),
"app_version": state.app_version,
"config_dir": state.config_dir.to_string_lossy(),
"installed_agent_version": agent::installed_agent_version(&data_dir),
"bundled_agent": agent::resolve_bundled_agent(resource_dir.as_deref())
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default(),
}))
}
/// 启动本机被控端。`auto_restart` 缺省时取设置里的值。
#[tauri::command]
async fn agent_start(
app: AppHandle,
state: State<'_, AppState>,
auto_restart: Option<bool>,
) -> Result<Value, String> {
let paths = resolve_agent_paths(&app, &state)?;
let ar = auto_restart.unwrap_or_else(|| state.settings().auto_restart);
state
.agent()?
.start(&paths.node, &paths.script, &paths.data_dir, ar)
.await
}
/// 停止本机被控端(幂等)。
#[tauri::command]
async fn agent_stop(state: State<'_, AppState>) -> Result<Value, String> {
state.agent()?.stop().await
}
/// 退出控制台 —— 由前端在用户确认后调用。
///
/// `keep_agent`:
/// - `true` 保留被控端继续运行(它将成为孤儿进程;下次启动会被「复用」逻辑接管)
/// - `false` 先停掉被控端再退出(退出后不留任何后台进程)
///
/// 为什么不直接在托盘菜单里 exit:退出是有**副作用**的决定(留不留下一个
/// 正在监听端口的后台进程),必须让用户明确选择,而不是替用户猜。
#[tauri::command]
async fn quit_app(app: AppHandle, state: State<'_, AppState>, keep_agent: bool) -> Result<(), String> {
if !keep_agent {
// 尽力而为:停不掉也要让用户能退出,否则控制台会卡在确认框后面出不来
if let Ok(mgr) = state.agent() {
let _ = mgr.stop().await;
}
}
app.exit(0);
Ok(())
}
/// 重启本机被控端。
#[tauri::command]
async fn agent_restart(
app: AppHandle,
state: State<'_, AppState>,
auto_restart: Option<bool>,
) -> Result<Value, String> {
let paths = resolve_agent_paths(&app, &state)?;
let ar = auto_restart.unwrap_or_else(|| state.settings().auto_restart);
state
.agent()?
.restart(&paths.node, &paths.script, &paths.data_dir, ar)
.await
}
/// 取本机服务日志(增量:传上次拿到的最大 `seq`)。
#[tauri::command]
fn agent_logs(state: State<'_, AppState>, since: Option<u64>) -> Result<Value, String> {
Ok(state.agent()?.logs_since(since.unwrap_or(0)))
}
/// 清空日志缓冲。
#[tauri::command]
fn agent_logs_clear(state: State<'_, AppState>) -> Result<(), String> {
state.agent()?.clear_logs();
Ok(())
}
/// 开机自启状态。
#[tauri::command]
fn autostart_status() -> Value {
autostart::status()
}
/// 开关开机自启。
#[tauri::command]
fn autostart_set(enabled: bool) -> Result<Value, String> {
autostart::set(enabled)
}
// ---------------------------------------------------------------------------
// 更新
// ---------------------------------------------------------------------------
/// 检查更新:比对最新 Release 与本控制台版本,并附上本机被控端已安装版本。
#[tauri::command]
async fn check_update(app: AppHandle, state: State<'_, AppState>) -> Result<Value, String> {
let sc = ensure_sidecar(&app, &state).await?;
// 超时**成对**下发:内层(sidecar 出网)必须小于外层(RPC),
// 否则用户只会看到「check_update 超时」这种丢掉真实原因的假失败。
// 见 sidecar.rs 的 UPDATE_CHECK_TIMEOUT_MS / UPDATE_CHECK_RPC_TIMEOUT_SECS。
let mut snap = sc
.request(
"check_update",
json!({
"console_version": state.app_version,
"timeout_ms": sidecar::UPDATE_CHECK_TIMEOUT_MS,
}),
sidecar::UPDATE_CHECK_RPC_TIMEOUT_SECS,
)
.await?;
// 本机被控端的版本(数据目录里那份),供界面区分「应用要更新」与「被控端要更新」
let data_dir = agent::agent_data_dir();
let installed_version = agent::installed_agent_version(&data_dir).unwrap_or_default();
let latest = snap
.get("latest_version")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let agent_has_update =
!installed_version.is_empty() && agent::cmp_version(&latest, &installed_version) > 0;
snap["installed_agent_version"] = json!(installed_version);
snap["agent_has_update"] = json!(agent_has_update);
Ok(snap)
}
/// 应用更新:下载 → 校验 → 替换本机被控端脚本 → 重启。
#[tauri::command]
async fn apply_update(
app: AppHandle,
state: State<'_, AppState>,
url: String,
sha256: String,
version: String,
) -> Result<Value, String> {
let sc = ensure_sidecar(&app, &state).await?;
let paths = resolve_agent_paths(&app, &state)?;
let mgr = state.agent()?;
// ① 下载到暂存文件(**校验通过才落盘**;不直接覆盖在跑的脚本)
let staged = paths
.data_dir
.join("runtime")
.join(format!("agent.mjs.staged-{}", std::process::id()));
let dl = sc
.request(
"download",
json!({
"url": url,
"sha256": sha256,
"dest": staged.to_string_lossy(),
"timeout_ms": sidecar::UPDATE_DOWNLOAD_TIMEOUT_MS,
}),
sidecar::UPDATE_DOWNLOAD_RPC_TIMEOUT_SECS,
)
.await?;
// ② 停被控端(Windows 上正被 node 打开的文件不能重命名/替换)
let was_running = mgr.snapshot().await["state"] == json!(agent::state::RUNNING);
if was_running {
mgr.stop().await?;
}
// ③ 原子替换:先改名旧文件做备份,再落新文件
let backup = paths.script.with_extension(format!("mjs.bak-{}", now_secs()));
let replace = (|| -> Result<(), String> {
if paths.script.exists() {
std::fs::rename(&paths.script, &backup)
.map_err(|e| format!("备份现有脚本失败:{e}"))?;
}
std::fs::rename(&staged, &paths.script).map_err(|e| {
// 失败则回滚
let _ = std::fs::rename(&backup, &paths.script);
format!("替换脚本失败(已回滚):{e}")
})
})();
if let Err(e) = replace {
let _ = std::fs::remove_file(&staged);
return Err(e);
}
// ④ 记版本:这是「控制台启动时不要把已更新的被控端降级」的依据
agent::mark_installed_version(&paths.data_dir, &version, "console")?;
mgr.push_log(
"system",
format!("已更新本机被控端到 v{version}(备份:{})", backup.display()),
);
// ⑤ 按需重新拉起
let mut restarted = false;
if was_running || state.settings().auto_start_agent {
match mgr
.start(&paths.node, &paths.script, &paths.data_dir, state.settings().auto_restart)
.await
{
Ok(_) => restarted = true,
Err(e) => mgr.push_log("system", format!("更新后启动失败:{e}")),
}
}
Ok(json!({
"updated": true,
"version": version,
"bytes": dl.get("bytes").cloned().unwrap_or(json!(0)),
"sha256": dl.get("sha256").cloned().unwrap_or(json!("")),
"backup": backup.to_string_lossy(),
"restarted": restarted,
}))
}
fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
// ---------------------------------------------------------------------------
// 设置与系统信息
// ---------------------------------------------------------------------------
/// 读设置。
#[tauri::command]
fn settings_get(state: State<'_, AppState>) -> Value {
settings::to_json(&state.settings())
}
/// 合并式写设置(只传要改的字段)。
#[tauri::command]
fn settings_set(state: State<'_, AppState>, patch: Value) -> Result<Value, String> {
let next = settings::merge(&state.config_dir, &patch)?;
*state.settings.lock().unwrap() = next.clone();
Ok(settings::to_json(&next))
}
/// 控制台自身信息(版本、数据目录、平台),供「关于」与故障上报使用。
#[tauri::command]
fn system_info(app: AppHandle, state: State<'_, AppState>) -> Value {
let resource_dir = app.path().resource_dir().ok();
// 先取 node 路径,再把 resource_dir 消费成字符串 —— 顺序反了会「借用已移动的值」
let node = sidecar::resolve_node(resource_dir.as_deref())
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
json!({
"app_version": state.app_version,
"config_dir": state.config_dir.to_string_lossy(),
"agent_data_dir": agent::agent_data_dir().to_string_lossy(),
"resource_dir": resource_dir.map(|p| p.to_string_lossy().to_string()).unwrap_or_default(),
"platform": std::env::consts::OS,
"arch": std::env::consts::ARCH,
"node": node,
"agent_only": std::env::args().any(|a| a == autostart::AGENT_ONLY_FLAG),
})
}
// ---------------------------------------------------------------------------
// 托盘与启动模式
// ---------------------------------------------------------------------------
/// 显示主窗口(托盘菜单与第二次启动都用它)。
fn show_main(app: &AppHandle) {
if let Some(w) = app.get_webview_window("main") {
let _ = w.show();
let _ = w.unminimize();
let _ = w.set_focus();
}
}
/// 在后台拉起被控端(忽略错误 —— 失败详情在日志里)。
fn spawn_start_agent(app: &AppHandle, auto_restart: bool) {
let app2 = app.clone();
tauri::async_runtime::spawn(async move {
let state = app2.state::<AppState>();
let mgr = match state.agent() {
Ok(m) => m,
Err(e) => {
eprintln!("[console] 被控端管理器不可用:{e}");
return;
}
};
if mgr.snapshot().await["state"] == json!(agent::state::RUNNING) {
return;
}
match resolve_agent_paths(&app2, &state) {
Ok(p) => {
if let Err(e) = mgr.start(&p.node, &p.script, &p.data_dir, auto_restart).await {
mgr.push_log("system", format!("自动启动失败:{e}"));
}
}
Err(e) => mgr.push_log("system", format!("自动启动失败(路径解析):{e}")),
}
});
}
fn build_tray(app: &AppHandle) -> tauri::Result<()> {
let show = MenuItem::with_id(app, "show", "显示主窗口", true, None::<&str>)?;
let start = MenuItem::with_id(app, "agent_start", "启动本机被控端", true, None::<&str>)?;
let stop = MenuItem::with_id(app, "agent_stop", "停止本机被控端", true, None::<&str>)?;
let restart = MenuItem::with_id(app, "agent_restart", "重启本机被控端", true, None::<&str>)?;
let sep1 = PredefinedMenuItem::separator(app)?;
let sep2 = PredefinedMenuItem::separator(app)?;
let quit = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show, &sep1, &start, &stop, &restart, &sep2, &quit])?;
let mut builder = TrayIconBuilder::with_id("na-tray")
.menu(&menu)
.show_menu_on_left_click(false)
.tooltip("nodeagent 控制台")
.on_menu_event(|app, event| {
let id = event.id().as_ref().to_string();
match id.as_str() {
"show" => show_main(app),
"agent_start" => {
let ar = app.state::<AppState>().settings().auto_restart;
spawn_start_agent(app, ar);
}
"agent_stop" => {
let app2 = app.clone();
tauri::async_runtime::spawn(async move {
let state = app2.state::<AppState>();
if let Ok(mgr) = state.agent() {
let _ = mgr.stop().await;
}
});
}
"agent_restart" => {
let app2 = app.clone();
tauri::async_runtime::spawn(async move {
let state = app2.state::<AppState>();
if let Ok(mgr) = state.agent() {
if let Ok(p) = resolve_agent_paths(&app2, &state) {
let ar = state.settings().auto_restart;
let _ = mgr.restart(&p.node, &p.script, &p.data_dir, ar).await;
}
}
});
}
// 2026-10-17「退出前确认」需求:
// 原实现直接 `app.exit(0)`,而 `kill_on_drop(false)` 会让被控端**成为孤儿
// 继续监听** —— 用户以为关了控制台就干净了,实际还有个看不见的进程在跑。
//
// 现在改为**先问一句**:发事件给前端,由它弹三选一
// (保留被控端退出 / 停止被控端退出 / 取消),再回调 `quit_app`。
// 之所以走前端而不是加 tauri-plugin-dialog:前端已有 n-dialog-provider,
// 不加依赖、风格统一,且能复用既有的 message 视觉。
"quit" => {
let _ = tauri::Emitter::emit(app, "na-quit-requested", ());
// 把窗口唤出来,否则用户在最小化状态下看不到确认框
if let Some(w) = tauri::Manager::get_webview_window(app, "main") {
let _ = w.show();
let _ = w.set_focus();
}
}
_ => {}
}
})
.on_tray_icon_event(|tray, event| {
// 左键单击托盘图标 → 唤出主窗口(符合习惯)
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
show_main(tray.app_handle());
}
});
if let Some(icon) = app.default_window_icon() {
builder = builder.icon(icon.clone());
}
builder.build(app)?;
Ok(())
}
// ---------------------------------------------------------------------------
// 单实例
// ---------------------------------------------------------------------------
//
// ## 为什么必须有(真机缺陷,2026-10-13)
// 控制台此前**完全没有单实例保护**。开机自启会拉起一个 `--agent-only` 实例(无窗口、驻托盘),
// 用户再手动双击图标 → **第二个进程**。而「本机被控端」是**进程内状态**
// (`AgentManager` 各持自己的 `child: Option<Child>` / `pid`),两个进程互相看不见 →
// 同一台机器上跑起**两个被控端**,抢同一个 8765 端口。
//
// 后果按先后顺序分两种,正是用户报的两个症状:
// - **A(两个被控端)**:两边都抢到了不同时机 → 同机并存两个被控端进程;
// - **B(后开的起不来)**:先占住端口的那一侧赢了,后启动的一侧在 `AgentManager::start`
// 的端口检查处失败,界面表现为「应用端无法启动」(本机服务起不来)。
//
// ## 为什么不能简单「已有实例就退出」
// 自启实例是**无窗口**的(`--agent-only`)。若第二次启动只是静默退出,用户双击图标会
// **什么都不发生** —— 比缺陷本身更难排查。正确语义是:
// 第二次启动 → 把「用户想看到界面」这个意图**交给已有实例**(让它显示并聚焦窗口)→ 新进程再退出。
//
// `tauri-plugin-single-instance` 正好是这个语义:回调在**已有实例**里执行,新进程由插件负责收尾退出。
// 它必须注册为**第一个**插件,才能在其他插件与本项目的 `setup`(会拉起被控端)之前完成抢占判定。
/// 第二次启动时执行(在**已有实例**的进程里)。
fn on_second_instance(app: &AppHandle, argv: Vec<String>) {
// 第二次启动若自己就带 `--agent-only`(例如自启被重复触发),**不要弹窗** ——
// 那正是「仅服务模式」要避免的行为。只提示已有实例,不做别的。
if argv.iter().any(|a| a == autostart::AGENT_ONLY_FLAG) {
return;
}
// 已有实例可能是「自启的隐藏实例」或「窗口已关到托盘」→ 两种都要能唤出来。
show_main(app);
// 顺带通知前端刷一次状态(第二次启动往往发生在「用户发现没反应、想确认一下」的时刻)。
// 前端没监听也无害。
let _ = app.emit("na-second-instance", json!({ "argv": argv }));
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let agent_only = std::env::args().any(|a| a == autostart::AGENT_ONLY_FLAG);
tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| {
on_second_instance(app, argv);
}))
.plugin(tauri_plugin_opener::init())
.manage(AppState {
client: Mutex::new(None),
.setup(move |app| {
let handle = app.handle().clone();
let config_dir = handle
.path()
.app_config_dir()
.unwrap_or_else(|_| PathBuf::from("."));
let app_version = handle.package_info().version.to_string();
let loaded = settings::load(&config_dir);
// 事件桥接:被控端管理器 → Tauri event
let app_events = handle.clone();
let emit: agent::AgentEmit = Arc::new(move |evt: AgentEvent| match evt {
AgentEvent::Log(l) => {
let _ = app_events.emit("na-agent-log", l);
}
AgentEvent::State(s) => {
let _ = app_events.emit("na-agent-state", s);
}
});
let manager = AgentManager::new(emit);
manager.push_log(
"system",
format!(
"控制台 v{app_version} 就绪({} / {})",
std::env::consts::OS,
std::env::consts::ARCH
),
);
app.manage(AppState {
sidecar: tokio::sync::Mutex::new(None),
capabilities: Mutex::new(json!([])),
agent_version: Mutex::new(None),
conn_state: Mutex::new("未连接".to_string()),
agent: Mutex::new(Some(Arc::clone(&manager))),
settings: Mutex::new(loaded.clone()),
config_dir,
app_version,
});
build_tray(&handle)?;
// 仅服务模式:不显示窗口(开机自启时不该弹任何窗口出来)
if agent_only {
if let Some(w) = handle.get_webview_window("main") {
let _ = w.hide();
}
manager.push_log("system", "以「仅服务模式」启动(无窗口,常驻托盘)");
}
// 自动拉起被控端(默认开)——「运行应用之后就有服务」的期望
if loaded.auto_start_agent {
spawn_start_agent(&handle, loaded.auto_restart);
}
Ok(())
})
.on_window_event(|window, event| {
// 关窗 → 最小化到托盘(可在设置里关掉,改为直接退出)
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
let app = window.app_handle();
let state = app.state::<AppState>();
let close_to_tray = state.settings().close_to_tray;
if close_to_tray {
api.prevent_close();
let _ = window.hide();
if let Ok(mgr) = state.agent() {
mgr.push_log("system", "窗口已隐藏到托盘(被控端继续运行;从托盘菜单可退出)");
}
} else {
// 用户关掉了「最小化到托盘」→ 点 X 等于退出应用。
// 这条路同样会留下孤儿被控端,故**也必须问一句**,
// 否则「退出确认」只在托盘菜单生效,从 X 走就绕过去了。
api.prevent_close();
let _ = tauri::Emitter::emit(window.app_handle(), "na-quit-requested", ());
}
}
})
.invoke_handler(tauri::generate_handler![
// 连接
connect,
disconnect,
invoke_capability,
get_capabilities,
get_state
get_state,
// 本机服务
agent_status,
agent_start,
agent_stop,
quit_app,
agent_restart,
agent_logs,
agent_logs_clear,
autostart_status,
autostart_set,
// 更新
check_update,
apply_update,
// 设置与信息
settings_get,
settings_set,
system_info,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app, event| {
// app 退出时优雅收尾:先让 sidecar 主动 disconnect,再杀进程
//(Sidecar 设了 kill_on_drop,即使这里失败也不会留孤儿进程)
if let tauri::RunEvent::Exit = event {
let state = app.state::<AppState>();
let sc = tauri::async_runtime::block_on(async {
state.sidecar.lock().await.as_ref().map(Arc::clone)
});
if let Some(sc) = sc {
tauri::async_runtime::block_on(async {
sc.shutdown().await;
});
}
}
});
}
+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!({}))
}
+733
View File
@@ -0,0 +1,733 @@
//! Node sidecar 管理器 —— 启动 `apps/console/sidecar/main.mjs` 并做 JSON-RPC 转发。
//!
//! ## 为什么是 sidecar(而不是 Rust 版 client)
//! 原设计让本壳自己实现一份 client,与 TS 版(`@nodeagent/client`)**两套协议实现并存** ——
//! 改了一处漏另一处时,构建过、单测过,只在真机暴露。sidecar 让本壳只做
//! **进程管理 + 消息转发**,协议实现只剩 TS 一份。
//!
//! ## 协议(行分隔 JSON,见 apps/console/sidecar/main.mjs 顶部注释)
//! 出(本壳 → sidecar):`{"jsonrpc":"2.0","id":N,"method":...,"params":{...}}`
//! 入(sidecar → 本壳):
//! - 响应:`{"jsonrpc":"2.0","id":N,"result"|"error":...}`
//! - 通知:`{"jsonrpc":"2.0","method":"event","params":{"kind":...,...}}`
//!
//! ## 可测试性(本项目纪律:不能只 `cargo check`)
//! 本模块的 `spawn` / `request` / 事件回调**都能在 `cargo test` 里真跑** ——
//! 单测直接拉起真实的 node 进程,不依赖 Tauri GUI。GUI 那层才需要人工验证。
use serde_json::{json, Value};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, ChildStdin, Command};
use tokio::sync::oneshot;
// ---------------------------------------------------------------------------
// 更新链路的超时口径(「检查更新 / 下载更新包」)
// ---------------------------------------------------------------------------
//
// 两层超时必须**成对**看:内层是 sidecar 自己的出网超时(下发给它),
// 外层是本壳等 RPC 的超时。**内层必须小于外层**,否则本壳先放弃,
// 用户只会看到「check_update 超时」——一条**丢掉真实原因**的假失败
// (2026-10-13 真机排查就被这种假失败带偏过一轮)。
//
// 下面那条单测断言的就是这层关系:改一侧忘了另一侧,测试直接红。
/// 「检查更新」下发给 sidecar 的**出网超时**(毫秒)。
///
/// ## 为什么从 20s 抬到 120s(2026-10-13 实测)
/// 取 `latest.json` 要**跟随两次重定向**(`releases/latest/download/…` → 版本化 URL →
/// 资产 CDN)。整条链的耗时波动极大,真机连续多次实测:
/// **2.0s / 10.0s / 39.7s / 68.6s / >109s**(同一台机器、同一个代理)。
/// 原来的 20s 是「假定能直连 GitHub」时定的值 —— 对真实链路**必然**超时,
/// 于是用户看到的失败原因永远是「超时」,而不是「到底为什么连不上」。
///
/// 为什么敢给到 2 分钟:这是**用户手动点**的动作,界面上按钮有 loading 态
/// (`LocalServiceView.vue` 的 `:loading="busy"`),等待是可见的;
/// 而「假失败」会让用户以为功能坏了。宁可慢,不要假失败。
pub(crate) const UPDATE_CHECK_TIMEOUT_MS: u64 = 120_000;
/// `check_update` 的 RPC 超时(秒)。必须 > `UPDATE_CHECK_TIMEOUT_MS`(120s)。
pub(crate) const UPDATE_CHECK_RPC_TIMEOUT_SECS: u64 = 150;
/// 「下载更新包」下发给 sidecar 的出网超时(毫秒)。被控端约 1MB,300s 足够。
pub(crate) const UPDATE_DOWNLOAD_TIMEOUT_MS: u64 = 300_000;
/// `download` 的 RPC 超时(秒)。必须 > `UPDATE_DOWNLOAD_TIMEOUT_MS`(300s)。
pub(crate) const UPDATE_DOWNLOAD_RPC_TIMEOUT_SECS: u64 = 330;
/// **编译期不变式**:内层出网超时必须小于外层 RPC 超时。
///
/// ## 为什么放在 `const _` 里而不是写成 `#[test]`
/// ① 这层关系一旦破坏,症状是「用户看到假失败、且失败原因指向别处」——
/// 让**构建**直接失败,比等某条单测被跑到更早、更不可能被绕过
/// (CI 的 `cargo clippy` / `cargo build` 都是闸门,而单测可能被 filter 掉)。
/// ② 两侧都是常量,写成断言函数会被 `clippy::assertions_on_constants` 拦下
/// (`-D warnings` 下即失败)—— clippy 给的正是本写法。
///
/// 注入验证(2026-10-13):把 `UPDATE_CHECK_RPC_TIMEOUT_SECS` 改成 50、
/// 或把 `UPDATE_CHECK_TIMEOUT_MS` 改回 20_000,**构建**即失败并打印下面的文案。
const _: () = {
assert!(
UPDATE_CHECK_TIMEOUT_MS < UPDATE_CHECK_RPC_TIMEOUT_SECS * 1000,
"check_update:出网超时必须小于 RPC 超时(否则用户只会看到「check_update 超时」这种丢掉真实原因的假失败)"
);
assert!(
UPDATE_DOWNLOAD_TIMEOUT_MS < UPDATE_DOWNLOAD_RPC_TIMEOUT_SECS * 1000,
"download:出网超时必须小于 RPC 超时"
);
// 60s 下限:真机上取 latest.json 的重定向链实测出现过 68.6s(三次为 2.0 / 10.0 / 68.6s),
// 回落到「假定能直连」时代的 20s 必然超时。
assert!(
UPDATE_CHECK_TIMEOUT_MS >= 60_000,
"检查更新的出网超时不得低于 60s"
);
};
// ---------------------------------------------------------------------------
// 路径净化:**交给外部进程**(node)的路径必须先去掉 Windows verbatim 前缀
// ---------------------------------------------------------------------------
/// 去掉 Windows 的 verbatim 前缀(`\\?\` 与 `\\?\UNC\`)。
///
/// ## 为什么非做不可(2026-10-13 真机缺陷的根因)
/// Tauri 的 `resource_dir()` 返回的是**规范化之后的 verbatim 路径**
/// (`\\?\C:\Users\…\nodeagent-console`)。把它当**参数**交给 node 时,
/// node 启动流程会在入口模块的 `realpathSync` 里把它拆成 `C:` 与剩余部分:
///
/// ```text
/// Error: EISDIR: illegal operation on a directory, lstat 'C:'
/// at Object.realpathSync (node:fs:27xx)
/// ```
///
/// → **exit 1,且一行 JS 都来不及执行**(连 sidecar 自己的启动日志都写不出来)。
///
/// ## 真机 A/B —— 二维四格,精确到是「哪一维」出问题
/// Windows 11 ARM64;同一个 `node.exe`、同一份最小脚本 `mini.mjs`;用
/// `ProcessStartInfo` 下发,**精确对应 Rust `Command::new(prog).arg(script)` 的形态**:
///
/// | # | `program` | 脚本参数 | 结果 |
/// |---|---|---|---|
/// | G1 | `C:\…\node.exe` | `C:\…\mini.mjs` | exit 0,正常输出(**= 修复后的形态**) |
/// | G2 | `C:\…\node.exe` | `\\?\C:\…\mini.mjs` | **exit 1**,`EISDIR … lstat 'C:'` |
/// | G3 | `\\?\C:\…\node.exe` | `C:\…\mini.mjs` | exit 0,正常输出 |
/// | G4 | `\\?\C:\…\node.exe` | `\\?\C:\…\mini.mjs` | **exit 1**,`EISDIR … lstat 'C:'` |
///
/// **结论**:炸的是「**脚本参数**带 verbatim」,**不是**「可执行文件带 verbatim」——
/// G3 证明 `program` 位置带前缀 node 照常启动;真机上也有旁证:被控端与 sidecar 进程的
/// `program` 都带 `\\?\`(见 `Get-CimInstance Win32_Process` 的 CommandLine)却长期存活。
/// 因此 `sidecar_command` 里**净化 `script` 才是关键修复**,净化 `node` 属防御性
/// (幂等、无副作用;且 `\\?\` 路径不适合出现在计划任务 `-Execute` 之类场景)。
///
/// 另有一次更贴近原症状的观测(同一台机器,直接用真 `sidecar.mjs`):
/// 普通路径 **2.5 秒**返回 `check_update` 结果并常驻;
/// `\\?\` 路径 **0.5 秒**即退出、exit 1、**零 JS 执行**(连自带启动日志都没写出来)。
///
/// 控制台侧前后两种表现都由这一条派生:
/// - 修存活检测**之前**:请求写进已死进程的管道 → 干等到 RPC 超时(当时 45 秒) → 「check_update 超时」;
/// - 修存活检测**之后**:stdout 立即 EOF → 「sidecar 已退出(stdout 关闭),请重试」。
///
/// ## 为什么单测 / CI / 开发模式全都测不出来
/// 开发期 `resolve_sidecar` 走的是 `CARGO_MANIFEST_DIR` 拼出来的**普通路径**;
/// 只有**打包之后**才会从 `resource_dir()` 拿到 verbatim 路径。
/// 于是「本地全绿 + CI 全绿 + 真机必挂」。
///
/// 纯字符串处理,**任何平台都可安全调用**(非 Windows 上一般原样返回),
/// 因此可以用 Windows 形态的字符串在 macOS / Linux 上直接验证 Windows 分支。
///
/// ## 为什么用 `to_str()` 而不是 `to_string_lossy()`
/// 后者会把**非法字节替换成 U+FFFD** 再用替换后的串重建路径 —— 那是**损坏路径**,
/// 比不净化更糟:原本只是 node 会报 EISDIR,改完会变成「指向一个不存在的目录」,
/// 更难排查。故非 UTF-8 一律**原样返回**(Windows 上是含未配对代理项的路径;
/// Unix 上是含非法字节的文件名 —— 两者都不该被我们的「净化」悄悄改写)。
pub(crate) fn unverbatim(path: PathBuf) -> PathBuf {
let fixed: Option<PathBuf> = path.to_str().and_then(|s| {
let rest = s.strip_prefix(r"\\?\")?;
Some(match rest.strip_prefix("UNC\\") {
// \\?\UNC\server\share\x → \\server\share\x(UNC 必须还原成双反斜杠)
Some(unc) => PathBuf::from(format!(r"\\{unc}")),
// \\?\C:\x → C:\x
None => PathBuf::from(rest),
})
});
fixed.unwrap_or(path)
}
/// 构造「把 sidecar 拉起来」的那条命令 —— **唯一**把路径交给 node 的地方。
///
/// 「必须去掉 verbatim 前缀」这条纪律只在这里守一次;单测直接断言本函数产出的
/// `program` / `args`,因此**在 macOS / Linux 上也守得住 Windows 分支**(守的是
/// 「最终下发文本」,不是「源码里出现过某个常量」)。
/// `CREATE_NO_WINDOW`(`0x0800_0000`):进程创建时不新开控制台窗口。
///
/// ## 为什么必须显式加(2026-10-13 真机发现的既有缺陷)
/// 控制台自身是 **GUI 子系统**程序(不占控制台)。GUI 父进程 spawn 一个**控制台子系统**
/// 程序(`node.exe`)时,Windows 会**新建一个控制台**;Windows 11 上它由
/// **WindowsTerminal.exe** 承载 → 用户屏幕上多出一个常驻黑窗。
///
/// 修复 verbatim 缺陷**之前**,sidecar 被拉起后 0.5 秒即退,黑窗一闪而过(所以一直没被发现);
/// 修复**之后** sidecar 常驻,黑窗就留在屏幕上不走了 —— 真机截图实证(窗口标题即
/// `C:\Users\…\nodeagent-console\node.exe`,进程名 `WindowsTerminal`)。
///
/// 这与「消灭命令行窗口」那条既有目标直接冲突(`AgentManager::start` 早已加了此标志),
/// 故 `sidecar_command` 必须同样带上,两边**共用本常量**以免值漂移。
///
/// ⚠️ **本项没有单测守卫**,原因如实记档:`creation_flags` 只有 setter、**没有 getter**
/// (`std::process::Command` 与 `tokio::process::Command` 都如此),断言不到「到底带没带」;
/// 而任何「扫源码看有没有写过这个常量」的做法都违反铁律 1(守卫要落在最终下发值上)。
/// 因此这条纪律靠**真机截图对照**把关(修复前有黑窗 / 修复后无黑窗)。
#[cfg(windows)]
pub(crate) const CREATE_NO_WINDOW: u32 = 0x0800_0000;
fn sidecar_command(node: &Path, script: &Path) -> Command {
let mut cmd = Command::new(unverbatim(node.to_path_buf()));
cmd.arg(unverbatim(script.to_path_buf()))
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true); // app 退出时不留孤儿进程
// ⚠️ 消灭黑窗:不加这一行,用户每点一次「检查更新」都会多出一个常驻黑窗
// (详见 `CREATE_NO_WINDOW` 的说明)。
#[cfg(windows)]
{
cmd.creation_flags(CREATE_NO_WINDOW);
}
cmd
}
/// sidecar 主动推上来的事件(与 `main.mjs` 的 `params.kind` 一一对应)。
#[derive(Debug, Clone)]
pub enum SidecarEvent {
/// 连接状态变化:connected / reconnecting / closed
State(String),
/// 日志一行
Log(String),
/// 被控端主动推送的事件(v12 `event.*`)
Event(Value),
/// sidecar 上来的未知通知(保留原始形态,便于排查)
Other(Value),
}
type Pending = Arc<Mutex<HashMap<u64, oneshot::Sender<Result<Value, String>>>>>;
/// 一个已启动的 sidecar 进程。
pub struct Sidecar {
child: Mutex<Option<Child>>,
stdin: tokio::sync::Mutex<ChildStdin>,
pending: Pending,
next_id: AtomicU64,
/// 「本实例已不可用」的标记(stdout 关闭 / 写入失败)。
///
/// ⚠️ 为什么不能只靠 `child.try_wait()`:往**已退出进程的管道**里写在 Windows 上
/// 未必立刻报错(管道缓冲区吞下就返回成功),于是请求会一路干等到 `request` 的超时。
/// 真机实证(2026-10-13,Windows 11 ARM64):sidecar 早已退出,控制台却仍复用缓存里的
/// `Arc<Sidecar>`,点「检查更新」等满 RPC 超时(当时 45 秒) → 界面显示「检查更新失败:check_update 超时」;
/// 而同一台机器上**手工拉起 sidecar 跑同一个请求只要 7 秒**。
/// 所以判定「它死了」要多路取证:进程状态 ∨ stdout EOF ∨ 写入失败。
dead: Arc<AtomicBool>,
}
impl Sidecar {
/// 启动 sidecar 并开始读取其 stdout。
///
/// `on_event`:sidecar 的通知回调(本壳里用于桥接成 Tauri event)。
pub fn spawn(
node: &Path,
script: &Path,
on_event: Arc<dyn Fn(SidecarEvent) + Send + Sync>,
) -> Result<Arc<Self>, String> {
// 进程边界:先净化一次(用于报错信息与实际下发文本一致)。
// 真正「不得带 verbatim 前缀」这道纪律由 `sidecar_command` 兜底 ——
// 去掉它会复现真机缺陷(node 启动即 EISDIR 退出),单测会红。
let node = unverbatim(node.to_path_buf());
let script = unverbatim(script.to_path_buf());
let mut child = sidecar_command(&node, &script)
.spawn()
.map_err(|e| format!("启动 sidecar 失败({} {}):{e}", node.display(), script.display()))?;
let stdin = child.stdin.take().ok_or("sidecar stdin 不可用")?;
let stdout = child.stdout.take().ok_or("sidecar stdout 不可用")?;
let stderr = child.stderr.take().ok_or("sidecar stderr 不可用")?;
let pending: Pending = Arc::new(Mutex::new(HashMap::new()));
let dead = Arc::new(AtomicBool::new(false));
// stdout:逐行读,分派「响应」与「通知」
{
let pending = Arc::clone(&pending);
let dead = Arc::clone(&dead);
tokio::spawn(async move {
let mut lines = BufReader::new(stdout).lines();
while let Ok(Some(line)) = lines.next_line().await {
let text = line.trim();
if text.is_empty() {
continue;
}
let Ok(msg) = serde_json::from_str::<Value>(text) else {
// 混入非 JSON 行说明协议被破坏 —— 记日志但不崩
eprintln!("[sidecar] 非 JSON 输出:{}", &text[..text.len().min(200)]);
continue;
};
// 响应:有 id
if let Some(id) = msg.get("id").and_then(Value::as_u64) {
if let Some(tx) = pending.lock().unwrap().remove(&id) {
let out = if let Some(err) = msg.get("error") {
Err(err
.get("message")
.and_then(Value::as_str)
.unwrap_or("sidecar 返回错误")
.to_string())
} else {
Ok(msg.get("result").cloned().unwrap_or(Value::Null))
};
let _ = tx.send(out);
}
continue;
}
// 通知:method == "event"
if msg.get("method").and_then(Value::as_str) == Some("event") {
let p = msg.get("params").cloned().unwrap_or(Value::Null);
let kind = p.get("kind").and_then(Value::as_str).unwrap_or("");
let evt = match kind {
"state" => SidecarEvent::State(
p.get("state").and_then(Value::as_str).unwrap_or("closed").to_string(),
),
"log" => SidecarEvent::Log(
p.get("message").and_then(Value::as_str).unwrap_or("").to_string(),
),
"agent_event" => {
SidecarEvent::Event(p.get("event").cloned().unwrap_or(Value::Null))
}
_ => SidecarEvent::Other(p),
};
on_event(evt);
}
}
// 走到这里 = stdout 关闭(EOF 或读取出错)= sidecar 进程已结束。
dead.store(true, Ordering::SeqCst);
// 顺手唤醒**所有还在等响应**的请求,让它们立刻失败,
// 而不是干等各自的 timeout(见 UPDATE_CHECK_RPC_TIMEOUT_SECS / UPDATE_DOWNLOAD_RPC_TIMEOUT_SECS)。
// 没有这一步,sidecar 死掉的那一刻就会有请求白等好几分钟。
let mut waiting = pending.lock().unwrap();
for (_, tx) in waiting.drain() {
let _ = tx.send(Err("sidecar 已退出(stdout 关闭),请重试".to_string()));
}
});
}
// stderr:sidecar 的日志 → 本壳 stderr(保持 stdout 干净)
tokio::spawn(async move {
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
eprintln!("{}", line);
}
});
Ok(Arc::new(Self {
child: Mutex::new(Some(child)),
stdin: tokio::sync::Mutex::new(stdin),
pending,
next_id: AtomicU64::new(1),
dead,
}))
}
/// 本实例是否仍然可用(子进程还在跑,且未被判定失效)。
///
/// `try_wait()` 返回 `Ok(None)` = 仍在运行;返回 `Some(_)` = 已退出。
/// 任何拿不准的情况一律按「不可用」处理 —— 宁可付一次 sidecar 冷启动的代价,
/// 也不要让用户的每个操作都干等到 timeout(那正是真机上报「更新失败」的形态)。
pub fn is_alive(&self) -> bool {
if self.dead.load(Ordering::SeqCst) {
return false;
}
let mut guard = self.child.lock().unwrap();
match guard.as_mut() {
Some(child) => matches!(child.try_wait(), Ok(None)),
None => false, // shutdown() 已经把 child 取走了
}
}
/// 发一个请求并等响应(`timeout_secs` 兜底,避免 sidecar 卡住时本壳一起挂)。
pub async fn request(&self, method: &str, params: Value, timeout_secs: u64) -> Result<Value, String> {
// 入口先判存活 —— 这是「秒失败」的那一道。
// 只靠写入报错是不够的:Windows 上往已关闭的管道写可能被缓冲区吞掉而"成功",
// 之后就会一路干等到 timeout(真机形态:check_update 的 RPC 超时,当时 45s)。宁可在入口直接拒绝。
if !self.is_alive() {
return Err(format!("sidecar 不可用(进程已退出),{method} 已放弃"));
}
let id = self.next_id.fetch_add(1, Ordering::SeqCst);
let (tx, rx) = oneshot::channel();
self.pending.lock().unwrap().insert(id, tx);
let msg = json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params });
{
let mut stdin = self.stdin.lock().await;
// 写入/刷新失败 = 管道对面没人了 → 立刻置 dead,让下一次 ensure_sidecar 重建。
// 否则后续每个请求都会重演「写进去、永远没响应、干等到超时」。
if let Err(e) = stdin.write_all(format!("{msg}\n").as_bytes()).await {
self.dead.store(true, Ordering::SeqCst);
return Err(format!(
"写入 sidecar 失败:{e}(sidecar 可能已退出,下次操作会自动重建)"
));
}
if let Err(e) = stdin.flush().await {
self.dead.store(true, Ordering::SeqCst);
return Err(format!(
"flush sidecar 失败:{e}(sidecar 可能已退出,下次操作会自动重建)"
));
}
}
match tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), rx).await {
Ok(Ok(r)) => r,
Ok(Err(_)) => Err("sidecar 通道已关闭".to_string()),
Err(_) => {
self.pending.lock().unwrap().remove(&id);
Err(format!("{method} 超时({timeout_secs}s)"))
}
}
}
/// 关掉 sidecar(app 退出或显式 disconnect 时调用)。
pub async fn shutdown(&self) {
let _ = self.request("disconnect", json!({}), 5).await;
// ⚠️ 必须**先把 child 取出来、让锁离开作用域**,再 await kill()。
// 若写成 `if let Some(child) = self.child.lock().unwrap().take() { child.kill().await }`,
// MutexGuard 会存活到 if 块结束 → 跨 await 持有 std Mutex(clippy::await_holding_lock,
// 且真会阻塞 executor)。
let child = self.child.lock().unwrap().take();
self.dead.store(true, Ordering::SeqCst); // 主动关闭同样视为不可用
if let Some(mut child) = child {
let _ = child.kill().await;
}
}
}
// ---------------------------------------------------------------------------
// 路径解析:开发期用系统 node + 仓库内脚本;打包后从 Tauri resource 目录取
// ---------------------------------------------------------------------------
/// 解析 node 可执行文件。
///
/// 优先级:环境变量 `NODEAGENT_CONSOLE_NODE` → 随包 resource 的 `node` → 系统 `node`。
///
/// ⚠️ 出口一律经 `unverbatim`:`resource_dir()` 在 Windows 上给的是 verbatim 路径。
/// 对本函数而言这是**防御性**净化 —— 真机四格实验(见 `unverbatim`)证明 `program`
/// 位置带 `\\?\` 本身无害(G3 照常启动)。之所以仍然净化,是为了守住「凡从本模块
/// 出去的路径都干净」这一条**单一纪律**:靠「记住哪些位置安全」来省这一步,
/// 迟早会有人把它挪到参数位置而不自知(那才是 G2/G4 的必炸形态)。
pub fn resolve_node(resource_dir: Option<&Path>) -> Result<PathBuf, String> {
if let Ok(p) = std::env::var("NODEAGENT_CONSOLE_NODE") {
if !p.is_empty() {
return Ok(unverbatim(PathBuf::from(p)));
}
}
if let Some(dir) = resource_dir {
let bundled = unverbatim(dir.join(if cfg!(windows) { "node.exe" } else { "node" }));
if bundled.exists() {
return Ok(bundled);
}
}
// 系统 node(开发期)
for cand in ["node", "node.exe"] {
if which(cand).is_some() {
return Ok(PathBuf::from(cand));
}
}
Err("找不到 node 可执行文件(可用 NODEAGENT_CONSOLE_NODE 指定)".to_string())
}
/// 极简 which(避免为一个函数引依赖)。
fn which(cmd: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path) {
let full = dir.join(cmd);
if full.is_file() {
return Some(full);
}
}
None
}
/// 解析 sidecar 脚本。
///
/// 优先级:环境变量 `NODEAGENT_CONSOLE_SIDECAR` → 随包 resource 的 `sidecar.mjs` →
/// 开发期的 `apps/console/sidecar/dist/sidecar.mjs`(相对本 crate)。
///
/// ⚠️ 出口一律经 `unverbatim` —— **这一条就是 2026-10-13 真机缺陷的修复点**:
/// 打包后 `resource_dir()` 给的是 `\\?\C:\…`,交给 node 当参数会让 node 在启动期
/// `realpathSync` 里炸成 `EISDIR ... lstat 'C:'` 并 exit 1(sidecar 秒退)。
pub fn resolve_sidecar(resource_dir: Option<&Path>) -> Result<PathBuf, String> {
if let Ok(p) = std::env::var("NODEAGENT_CONSOLE_SIDECAR") {
if !p.is_empty() && Path::new(&p).exists() {
return Ok(unverbatim(PathBuf::from(p)));
}
}
if let Some(dir) = resource_dir {
let bundled = unverbatim(dir.join("sidecar.mjs"));
if bundled.exists() {
return Ok(bundled);
}
}
// 开发期:src-tauri → apps/console/sidecar/dist/sidecar.mjs
let dev = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../sidecar/dist/sidecar.mjs");
if dev.exists() {
return Ok(dev);
}
Err("找不到 sidecar.mjs(先跑 `pnpm --filter nodeagent-console sidecar:build`,\
或用 NODEAGENT_CONSOLE_SIDECAR 指定)"
.to_string())
}
// ---------------------------------------------------------------------------
// 单测:真跑 sidecar 进程(不依赖 Tauri GUI)
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::AtomicUsize;
/// 开发期的 sidecar 源码(不依赖打包产物,pnpm 的 node_modules 已能解析其依赖)。
fn dev_sidecar_src() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../sidecar/main.mjs")
}
fn node_bin() -> PathBuf {
resolve_node(None).expect("需要 node 才能跑 sidecar 单测")
}
// -----------------------------------------------------------------------
// 「交给 node 的路径不得带 Windows verbatim 前缀」—— 2026-10-13 真机缺陷
//
// 现象(真机):控制台点「检查更新」,sidecar 被拉起后**存活约 0.5 秒即消失**、
// 退出码 1,且一行 JS 都没执行(连自带的启动日志都写不出来);界面报
// 「sidecar 已退出(stdout 关闭),请重试」(修存活检测之前报「等满 RPC 超时」,当时 45 秒)。
// 根因:`resource_dir()` 给的是 verbatim 路径(`\\?\C:\…`),当参数交给 node 后,
// node 启动期的 `realpathSync` 把 `\\?\C:` 拆成 `C:` → `EISDIR` → exit 1。
// 为什么本地/CI 全绿:开发模式走的是仓库相对路径(普通路径),只有打包后才暴露。
// 因此这里的守卫**必须落在「最终下发给 node 的 program / args」上**,
// 而不是「源码里有没有调用过某个函数」—— 这样在 macOS / Linux 上也能守住 Windows 分支。
// -----------------------------------------------------------------------
#[test]
fn unverbatim_去掉各类_windows_前缀() {
// 常见的盘符形态
assert_eq!(
unverbatim(PathBuf::from(r"\\?\C:\Users\u\AppData\Local\x\node.exe")),
PathBuf::from(r"C:\Users\u\AppData\Local\x\node.exe")
);
// UNC 形态:\\?\UNC\server\share\x → \\server\share\x
assert_eq!(
unverbatim(PathBuf::from(r"\\?\UNC\srv\share\sidecar.mjs")),
PathBuf::from(r"\\srv\share\sidecar.mjs")
);
// 普通路径原样返回(含 POSIX)
assert_eq!(
unverbatim(PathBuf::from(r"C:\a\b.mjs")),
PathBuf::from(r"C:\a\b.mjs")
);
assert_eq!(
unverbatim(PathBuf::from("/Users/x/y.mjs")),
PathBuf::from("/Users/x/y.mjs")
);
// 幂等:净化两次 == 净化一次(spawn 与 sidecar_command 都会调)
let once = unverbatim(PathBuf::from(r"\\?\C:\a\b.mjs"));
assert_eq!(unverbatim(once.clone()), once);
}
/// 反例守卫:**非 UTF-8 路径必须原样返回,不得被 lossy 重建损坏**。
///
/// ⚠️ 构造必须**同时**含 `\\?\` 前缀与非法字节,否则测不出差异 ——
/// 不带前缀时新旧实现都会落到「原样返回」那一步(`to_string_lossy` 也安全),
/// 只有在「剥前缀 → `PathBuf::from(rest)`」这一步才会把 U+FFFD 写进路径。
/// 若把实现退回 `to_string_lossy()`,本用例立刻变红:那是把「node 报 EISDIR」
/// 换成「指向一个不存在的路径」,更难排查 —— 宁可原样返回。
#[cfg(unix)]
#[test]
fn unverbatim_对非_utf8_的_verbatim_路径必须原样返回不得损坏() {
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
// 0xff / 0xfe 永不出现在合法 UTF-8 序列里;前缀形态按 Windows 写法给全
let raw = OsStr::from_bytes(b"\\\\?\\C:\\na-\xff\xfe-weird.mjs");
let p = PathBuf::from(raw);
assert!(p.to_str().is_none(), "构造出来的路径应确实是非 UTF-8");
assert_eq!(
unverbatim(p.clone()),
p,
"非 UTF-8 路径被改写了(很可能退回了 to_string_lossy)"
);
}
#[test]
fn unverbatim_对_resource_dir_拼接后的路径同样生效() {
// 真实形态就是「resource_dir().join("sidecar.mjs")」——前缀在目录上,拼完仍在最前
let joined = unverbatim(Path::new(r"\\?\C:\app").join("sidecar.mjs"));
let s = joined.to_string_lossy().into_owned();
assert!(!s.contains(r"\\?\"), "拼接后仍带 verbatim 前缀:{s}");
assert!(s.starts_with(r"C:\app"), "应剥成普通路径,实际:{s}");
assert!(s.ends_with("sidecar.mjs"), "文件名不该被动:{s}");
}
#[test]
fn 交给_node_的路径必须去掉_verbatim_前缀() {
// 直接断言**最终下发给 node 的文本**(program 与 args),而不是源码长相。
// 去掉 `sidecar_command` 里的 unverbatim → 本用例立刻变红。
let cmd = sidecar_command(
Path::new(r"\\?\C:\Users\u\AppData\Local\nodeagent-console\node.exe"),
Path::new(r"\\?\C:\Users\u\AppData\Local\nodeagent-console\sidecar.mjs"),
);
let std_cmd = cmd.as_std();
let prog = std_cmd.get_program().to_string_lossy().into_owned();
assert!(
!prog.contains(r"\\?\"),
"program 仍带 verbatim 前缀(示例:{prog})"
);
assert_eq!(
prog, r"C:\Users\u\AppData\Local\nodeagent-console\node.exe",
"program 应剥成普通路径"
);
let args: Vec<String> = std_cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert!(
!args.iter().any(|a| a.contains(r"\\?\")),
"脚本参数仍带 verbatim 前缀 → node 启动即 EISDIR 退出(真机表现为「sidecar 秒退」):{args:?}"
);
assert_eq!(
args,
vec![r"C:\Users\u\AppData\Local\nodeagent-console\sidecar.mjs".to_string()],
"脚本参数应为唯一一个、且已净化的路径"
);
}
#[tokio::test]
async fn 启动后_state_请求应返回未连接() {
let seen: Arc<AtomicUsize> = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&seen);
let sc = Sidecar::spawn(
&node_bin(),
&dev_sidecar_src(),
Arc::new(move |_e| {
counter.fetch_add(1, Ordering::SeqCst);
}),
)
.expect("sidecar 应能启动");
let r = sc.request("state", json!({}), 15).await.expect("state 应成功");
assert_eq!(r["connected"], json!(false), "未连接时 connected 应为 false");
sc.shutdown().await;
}
#[tokio::test]
async fn 未知方法应返回错误而不是崩溃() {
let sc = Sidecar::spawn(&node_bin(), &dev_sidecar_src(), Arc::new(|_| {}))
.expect("sidecar 应能启动");
let err = sc.request("no_such_method", json!({}), 15).await;
assert!(err.is_err(), "未知方法应返回错误");
// 关键:进程没崩,后续请求仍可用
let ok = sc.request("state", json!({}), 15).await.expect("sidecar 不应因错误退出");
assert_eq!(ok["connected"], json!(false));
sc.shutdown().await;
}
#[tokio::test]
async fn 未连接时_invoke_应返回明确错误() {
let sc = Sidecar::spawn(&node_bin(), &dev_sidecar_src(), Arc::new(|_| {}))
.expect("sidecar 应能启动");
let err = sc.request("invoke", json!({"capability": "system.info"}), 15).await;
match err {
Err(msg) => assert!(msg.contains("尚未连接"), "错误信息应提示未连接,实际:{msg}"),
Ok(v) => panic!("未连接时 invoke 应失败,实际返回:{v}"),
}
sc.shutdown().await;
}
// -----------------------------------------------------------------------
// 「sidecar 退出后不得被复用」—— 2026-10-13 真机缺陷
//
// 现象:控制台点「检查更新」等满 RPC 超时(当时 45 秒),界面报「check_update 超时」;
// 而同一台机器上手工拉起 sidecar 跑同一个请求只要 7 秒。
// 根因:控制台缓存里的 Arc<Sidecar> 指向早已退出的子进程,
// ensure_sidecar 只判「缓存里有没有」,不判「那进程还活着没」。
// -----------------------------------------------------------------------
/// 把 sidecar 的子进程直接杀掉,模拟真机上它意外退出。
fn kill_child(sc: &Sidecar) {
let mut guard = sc.child.lock().unwrap();
if let Some(child) = guard.as_mut() {
let _ = child.start_kill();
}
}
/// 等 is_alive 变为 false(stdout 任务观察到 EOF 会置 dead)。
async fn wait_dead(sc: &Sidecar) {
for _ in 0..150 {
if !sc.is_alive() {
return;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
}
#[tokio::test]
async fn 启动后_is_alive_应为真_shutdown_后应为假() {
let sc = Sidecar::spawn(&node_bin(), &dev_sidecar_src(), Arc::new(|_| {}))
.expect("sidecar 应能启动");
assert!(sc.is_alive(), "刚启动的 sidecar 应判为存活");
sc.shutdown().await;
assert!(!sc.is_alive(), "shutdown 之后必须判为不存活");
}
#[tokio::test]
async fn 子进程被杀后_is_alive_必须变假() {
let sc = Sidecar::spawn(&node_bin(), &dev_sidecar_src(), Arc::new(|_| {}))
.expect("sidecar 应能启动");
assert!(sc.is_alive());
kill_child(&sc);
wait_dead(&sc).await;
assert!(
!sc.is_alive(),
"子进程已退出,is_alive 仍为真 → ensure_sidecar 会继续复用死实例(就是真机那个缺陷)"
);
}
#[tokio::test]
async fn 对已退出的_sidecar_发请求必须秒失败而不是干等超时() {
let sc = Sidecar::spawn(&node_bin(), &dev_sidecar_src(), Arc::new(|_| {}))
.expect("sidecar 应能启动");
kill_child(&sc);
wait_dead(&sc).await;
// 故意给 30 秒超时:正确实现应当在入口就拒绝(毫秒级)。
// 首版缺陷的表现恰恰是「干等到超时」,所以这里**断言耗时**而不只是断言出错。
let t0 = std::time::Instant::now();
let r = sc.request("state", json!({}), 30).await;
let cost = t0.elapsed();
assert!(r.is_err(), "对已退出的 sidecar 发请求必须失败");
assert!(
cost < std::time::Duration::from_secs(5),
"应快速失败,实际耗时 {cost:?} —— 若接近 30s 说明仍在干等超时"
);
}
}
+6 -3
View File
@@ -1,12 +1,12 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "nodeagent-console",
"version": "2.0.0",
"version": "2.1.0",
"identifier": "cn.jkooe.nodeagent-console",
"build": {
"beforeDevCommand": "pnpm dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "pnpm build",
"beforeBuildCommand": "pnpm build && pnpm sidecar:build",
"frontendDist": "../dist"
},
"app": {
@@ -30,6 +30,9 @@
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
],
"resources": {
"../sidecar/dist/sidecar.mjs": "sidecar.mjs"
}
}
}
+35 -9
View File
@@ -1,19 +1,45 @@
<script setup lang="ts">
import {
NConfigProvider,
NMessageProvider,
NDialogProvider,
darkTheme,
zhCN,
dateZhCN,
} from "naive-ui";
import { watchEffect } from "vue";
import { NConfigProvider, NMessageProvider, NDialogProvider, zhCN, dateZhCN } from "naive-ui";
import AppShell from "./components/AppShell.vue";
import QuitGuard from "./components/QuitGuard.vue";
import { cssVars } from "./theme";
import { useThemeStore } from "./stores/theme";
const theme = useThemeStore();
/**
* 把调色板写成 `--na-*` CSS 变量挂到根元素。
*
* 色值只在 `theme/index.ts` 定义一份,此处在运行期注入给自定义样式用 ——
* 免得「TS 调色板」与「CSS 硬编码」各写一份后静默漂移(换主题时一半新一半旧,
* 不报错、类型也抓不到)。
*
* 同时设 `color-scheme`:不设的话滚动条、`<input>` 等原生控件会一直是浅色,
* 在「高级灰」下白得刺眼。
*/
watchEffect(() => {
const { palette, name } = theme.preset;
const root = document.documentElement;
for (const [k, v] of Object.entries(cssVars(palette))) {
root.style.setProperty(k, v);
}
root.style.colorScheme = palette.scheme;
root.dataset.theme = name;
});
</script>
<template>
<n-config-provider :theme="darkTheme" :locale="zhCN" :date-locale="dateZhCN">
<n-config-provider
:theme="theme.preset.base"
:theme-overrides="theme.preset.overrides"
:locale="zhCN"
:date-locale="dateZhCN"
>
<n-message-provider>
<n-dialog-provider>
<!-- 必须在 dialog-provider 内部:它要用 useDialog() 弹退出确认 -->
<quit-guard />
<app-shell />
</n-dialog-provider>
</n-message-provider>
+93 -2
View File
@@ -1,6 +1,18 @@
/** Tauri command 调用封装(前端 → Rust 壳 → nodeagent-client)。 */
/** Tauri command 调用封装(前端 → Rust 壳 → Node sidecar → 被控端)。 */
import { invoke } from "@tauri-apps/api/core";
import type { CapabilityDescriptor, ConnectInfo, InvokeResult } from "../types";
import type {
AgentActionResult,
AgentLogPage,
AgentStatus,
ApplyUpdateResult,
AutostartStatus,
CapabilityDescriptor,
ConnectInfo,
ConsoleSettings,
ConsoleSystemInfo,
InvokeResult,
UpdateInfo,
} from "../types";
/** 建立连接并握手。 */
export function connect(url: string, key: string, clientId = "console", insecure = true) {
@@ -86,3 +98,82 @@ export async function ok<T = unknown>(
}
return res.data as T;
}
// ---------------------------------------------------------------------------
// 本机服务(本机被控端)—— 全部走 Tauri command,不经被控端协议
// ---------------------------------------------------------------------------
/** 本机服务总览:进程状态 + 配置 + 自启 + 设置 + 版本。 */
export function agentStatus() {
return invoke<AgentStatus>("agent_status");
}
/** 启动本机被控端。`autoRestart` 省略时取设置里的值。 */
export function agentStart(autoRestart?: boolean) {
return invoke<AgentActionResult>("agent_start", { autoRestart });
}
/** 停止本机被控端(幂等)。 */
export function agentStop() {
return invoke<AgentActionResult>("agent_stop");
}
/** 重启本机被控端。 */
export function agentRestart(autoRestart?: boolean) {
return invoke<AgentActionResult>("agent_restart", { autoRestart });
}
/**
* 退出控制台(由前端的确认框调用)。
*
* `keepAgent = true` 保留被控端继续运行 —— 它将成为孤儿进程,
* 下次启动控制台时会被「复用」逻辑接管;`false` 则先停掉再退出。
*/
export function quitApp(keepAgent: boolean) {
return invoke<void>("quit_app", { keepAgent });
}
/** 增量取日志(传上次拿到的最大 seq)。 */
export function agentLogs(since = 0) {
return invoke<AgentLogPage>("agent_logs", { since });
}
/** 清空日志缓冲。 */
export function agentLogsClear() {
return invoke<void>("agent_logs_clear");
}
/** 开机自启状态。 */
export function autostartStatus() {
return invoke<AutostartStatus>("autostart_status");
}
/** 开关开机自启。 */
export function autostartSet(enabled: boolean) {
return invoke<AutostartStatus>("autostart_set", { enabled });
}
/** 检查更新(比对最新 Release)。 */
export function checkUpdate() {
return invoke<UpdateInfo>("check_update");
}
/** 应用更新:下载 → 校验 → 替换本机被控端脚本 → 重启。 */
export function applyUpdate(url: string, sha256: string, version: string) {
return invoke<ApplyUpdateResult>("apply_update", { url, sha256, version });
}
/** 读设置。 */
export function settingsGet() {
return invoke<ConsoleSettings>("settings_get");
}
/** 合并式写设置(只传要改的字段)。 */
export function settingsSet(patch: Partial<ConsoleSettings>) {
return invoke<ConsoleSettings>("settings_set", { patch });
}
/** 控制台自身信息。 */
export function systemInfo() {
return invoke<ConsoleSystemInfo>("system_info");
}
-1
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>
+71
View File
@@ -0,0 +1,71 @@
<script setup lang="ts">
/**
* 退出确认(2026-10-17 需求方提出)。
*
* ## 它解决的误解
* 旧行为:点托盘「退出」→ 控制台立刻消失,但被控端因 `kill_on_drop(false)`
* **成为孤儿继续监听端口**。用户以为「关了窗口就干净了」,实际还有个看不见的
* 进程在跑 —— 下次启动还会撞上「端口已被占用」。
*
* ## 为什么做成三选一
* 「留不留一个正在监听端口的后台进程」是有副作用的决定,必须由用户明确选择:
* - 保留被控端 → 远程仍可接管(下次启动控制台会自动复用它)
* - 停止被控端 → 彻底清净,但本机不再可被远程接管
* - 关闭弹窗 → 取消退出
*
* ## 为什么不在 Rust 里弹系统对话框
* 前端已有 `n-dialog-provider`,复用它就不必新增 `tauri-plugin-dialog` 依赖,
* 视觉也与全应用一致。Rust 侧只负责「发一个事件 + 提供 quit_app 命令」。
*/
import { onMounted, onUnmounted } from "vue";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { useDialog } from "naive-ui";
import { h } from "vue";
import { quitApp } from "../api";
const dialog = useDialog();
let unlisten: UnlistenFn | null = null;
onMounted(async () => {
try {
unlisten = await listen("na-quit-requested", () => {
const d = dialog.warning({
title: "退出控制台",
closable: true,
maskClosable: true,
content: () =>
h("div", { style: "line-height:1.7" }, [
h("div", "退出后本机被控端要怎么处理?"),
h(
"div",
{ style: "margin-top:6px;font-size:12px;opacity:.72" },
"保留:仍然可以被远程接管(下次打开控制台会自动复用它);停止:彻底结束,退出后不再有后台进程。",
),
]),
positiveText: "保留被控端,退出",
negativeText: "停止被控端,退出",
onPositiveClick: () => {
d.loading = true;
void quitApp(true);
},
onNegativeClick: () => {
d.loading = true;
void quitApp(false);
},
});
});
} catch {
/* 事件注册失败不应阻断应用启动 */
}
});
onUnmounted(() => {
unlisten?.();
unlisten = null;
});
</script>
<template>
<!-- 纯逻辑组件,不渲染任何可见内容 -->
<span style="display: none" />
</template>
+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,
},
];
+281
View File
@@ -0,0 +1,281 @@
/**
* 本机服务 store —— 托管**本机**被控端(启动 / 停止 / 重启 / 自启 / 更新 / 设置)。
*
* 与 `conn` store 的分工:`conn` 管「连到某台被控端」(远程控制),
* 本 store 管「本机这台被控端本身的进程与配置」。二者都连本机时是同一个实例,
* 但关注点不同 —— 本 store 不依赖连接是否成功(进程没起也能启动它)。
*/
import { defineStore } from "pinia";
import { computed, ref } from "vue";
import { listen } from "@tauri-apps/api/event";
import * as api from "../api";
import { useConnStore } from "./conn";
import type {
AgentLogLine,
AgentState,
AgentStatus,
ApplyUpdateResult,
ConsoleSettings,
ConsoleSystemInfo,
UpdateInfo,
} from "../types";
/** 状态 → 展示文案与配色。 */
export const STATE_META: Record<AgentState, { label: string; type: "success" | "warning" | "error" | "default" }> = {
running: { label: "运行中", type: "success" },
starting: { label: "启动中", type: "warning" },
stopping: { label: "停止中", type: "warning" },
stopped: { label: "已停止", type: "default" },
crashed: { label: "已异常退出", type: "error" },
};
export const useAgentStore = defineStore("agent", () => {
const status = ref<AgentStatus | null>(null);
const sysInfo = ref<ConsoleSystemInfo | null>(null);
const update = ref<UpdateInfo | null>(null);
/** 启停/设置等毫秒级操作的全局忙标志(**不要**给网络类操作复用,见 checkUpdate 注释) */
const busy = ref(false);
/** 更新检查/下载专用忙标志 —— 与 `busy` 隔离,避免慢操作锁死整页按钮 */
const updateBusy = ref(false);
const error = ref<string | null>(null);
const bound = ref(false);
const process = computed(() => status.value?.manager ?? null);
const state = computed<AgentState>(() => process.value?.state ?? "stopped");
const meta = computed(() => STATE_META[state.value] ?? STATE_META.stopped);
const settings = computed<ConsoleSettings>(
() => status.value?.settings ?? { auto_start_agent: true, auto_restart: true, close_to_tray: true, insecure: true },
);
const running = computed(() => state.value === "running");
/** 端口被占但本管理器没在跑 → 已有「非托管」的实例 */
const externalInstance = computed(
() => !running.value && (status.value?.config.port_in_use ?? false),
);
/** 包一层:统一置 busy / 收集错误,避免每个动作都写一遍 try-finally。 */
async function run<T>(fn: () => Promise<T>): Promise<T | null> {
busy.value = true;
error.value = null;
try {
const r = await fn();
return r;
} catch (e) {
error.value = e instanceof Error ? e.message : String(e);
return null;
} finally {
// ⚠️ 刷新必须放在 finally(2026-10-17 真机缺陷):
// 原来 `await refresh()` 写在 try 里,一旦动作**抛错**就被跳过,
// 界面状态会永远停在动作发生前的旧值。
//
// 真机症状:点「启动」→ 后端答「被控端已在运行」(说明它其实是 running)
// → 界面却一直显示「启动中」,「停止 / 重启」永久灰着,用户无从操作。
// 根因就是那次 start 抛了错,refresh 被跳过;而 `na-agent-state` 推送
// 在 WebView 就绪前发出,前端没收到 —— 两条更新通道同时失效。
// refresh() 自身已 try/catch,放这里不会掩盖原始错误。
await refresh();
busy.value = false;
}
}
/** 刷新总览(状态 + 配置 + 自启 + 设置)。 */
async function refresh() {
try {
status.value = await api.agentStatus();
error.value = null;
} catch (e) {
error.value = e instanceof Error ? e.message : String(e);
}
return status.value;
}
async function loadSystemInfo() {
sysInfo.value = await api.systemInfo();
return sysInfo.value;
}
/** 绑定后端事件(应用启动时调用一次):进程状态变化由推送驱动,不轮询。 */
async function bind() {
if (bound.value) return;
bound.value = true;
await listen<string>("na-agent-state", () => {
void refresh();
});
// v2.0.3 单实例:用户再次双击图标时,由**已存在实例**接管(把窗口唤出来)。
// 顺手刷新一次 —— 触发它的场景往往正是「点了没反应,想看看怎么回事」。
await listen<{ argv?: string[] }>("na-second-instance", () => {
void refresh();
});
await refresh();
void loadSystemInfo();
}
const start = () => run(() => api.agentStart());
const stop = () => run(() => api.agentStop());
const restart = () => run(() => api.agentRestart());
const clearLogs = () => api.agentLogsClear();
async function setAutostart(enabled: boolean) {
return run(() => api.autostartSet(enabled));
}
async function patchSettings(patch: Partial<ConsoleSettings>) {
return run(() => api.settingsSet(patch));
}
/**
* 检查更新。
*
* ⚠️ **刻意不用全局 `busy`**(2026-10-17 真机缺陷):
* `check_update` 要访问网络,境内可能一路等到超时(原 120s / RPC 150s)。
* 而 `busy` 是**全页共享**的(所有按钮 `:loading="busy"`),于是用户点一下
* 「检查更新」,整页按钮跟着转两分半 —— 看起来像「点什么都卡死」。
*
* 更新检查是**只读、可等待、与启停无关**的操作,本就不该锁住其他按钮。
* 故它自成一套 `updateBusy`;全局 `busy` 只服务毫秒级的启停与设置。
*/
async function checkUpdate() {
updateBusy.value = true;
error.value = null;
try {
update.value = await api.checkUpdate();
return update.value;
} catch (e) {
error.value = e instanceof Error ? e.message : String(e);
return null;
} finally {
updateBusy.value = false;
}
}
/**
* 应用更新(仅更新本机被控端;控制台自身需重新下载安装包)。
*
* 同样走 `updateBusy` 而非全局 `busy`:下载更新的 RPC 超时是 330s,
* 若占用全局 busy,用户会看到「下载更新时整页按钮全部转圈五分钟」。
*/
async function applyUpdate(): Promise<ApplyUpdateResult | null> {
const u = update.value;
const url = u?.agent?.url;
const sha = u?.agent?.sha256;
if (!u || !url || !sha) {
error.value = "最新版本信息不完整(缺少下载地址或哈希),无法更新";
return null;
}
updateBusy.value = true;
error.value = null;
try {
const r = await api.applyUpdate(url, sha, u.latest_version);
await refresh();
if (r) await checkUpdate();
return r;
} catch (e) {
error.value = e instanceof Error ? e.message : String(e);
return null;
} finally {
updateBusy.value = false;
}
}
/** 一键连接本机被控端(用配置里的地址与密钥)。 */
async function connectLocal() {
const cfg = status.value?.config;
if (!cfg) return null;
const conn = useConnStore();
return conn.connect(cfg.url, cfg.key);
}
return {
status,
sysInfo,
update,
busy,
updateBusy,
error,
process,
state,
meta,
settings,
running,
externalInstance,
bind,
refresh,
loadSystemInfo,
start,
stop,
restart,
clearLogs,
setAutostart,
patchSettings,
checkUpdate,
applyUpdate,
connectLocal,
};
});
/**
* 日志 store —— 本机服务日志的实时流 + 增量拉取。
*
* 设计要点:**事件推 + 序号去重**。若只靠事件,页面重新挂载后会丢掉历史;
* 若只靠轮询,则看不到实时的那一行。故两条路并用,用 `seq` 去重。
*/
export const useLogsStore = defineStore("logs", () => {
const lines = ref<AgentLogLine[]>([]);
const maxSeq = ref(0);
const paused = ref(false);
const bound = ref(false);
/** 内存里保留的最大行数(与 Rust 侧环形缓冲上限对齐,避免界面无限膨胀) */
const CAP = 4000;
function append(rows: AgentLogLine[]) {
const fresh = rows.filter((r) => r.seq > maxSeq.value);
if (fresh.length === 0) return;
lines.value.push(...fresh);
maxSeq.value = Math.max(maxSeq.value, ...fresh.map((r) => r.seq));
if (lines.value.length > CAP) lines.value.splice(0, lines.value.length - CAP);
}
/** 拉取历史(首次进页面 / 清空后重建)。 */
async function load() {
const page = await api.agentLogs(0);
lines.value = [];
maxSeq.value = 0;
append(page.lines);
return page;
}
/** 绑定实时日志事件。 */
async function bind() {
if (bound.value) return;
bound.value = true;
await listen<AgentLogLine>("na-agent-log", (e) => {
if (paused.value) return;
append([e.payload]);
});
await load();
}
async function clear() {
await api.agentLogsClear();
lines.value = [];
maxSeq.value = 0;
}
/** 导出为文本(在浏览器侧生成,不额外调后端)。 */
function toText(filtered?: AgentLogLine[]): string {
const src = filtered ?? lines.value;
return src
.map((l) => `${new Date(l.ts).toISOString()} [${l.stream}] ${l.line}`)
.join("\n");
}
function downloadText(text: string, filename: string) {
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = filename;
a.click();
URL.revokeObjectURL(a.href);
}
return { lines, maxSeq, paused, bind, load, clear, append, toText, downloadText };
});
+3 -2
View File
@@ -14,11 +14,12 @@ export interface AgentEvent {
* 仅在被控端为 Windows 时可用(其余平台返回 E_UNSUPPORTED_PLATFORM)。
* 前端据此**事前门控**,而不是发出注定失败的请求再解释错误。
*/
export const WINDOWS_ONLY_CAPS = ["app.list", "app.install", "system.service.list"] as const;
export const WINDOWS_ONLY_CAPS = ["app.list", "app.install", "system.service.list", "system.audit.l2"] as const;
export const useConnStore = defineStore("conn", () => {
const state = ref<string>("closed");
const url = ref(localStorage.getItem("na.url") || "ws://127.0.0.1:8765");
// 默认 wss:被控端默认 tls=true(明文 ws 连 TLS 端口必然握手失败,2026-10-17 真机踩过)
const url = ref(localStorage.getItem("na.url") || "wss://127.0.0.1:8765");
const key = ref(localStorage.getItem("na.key") || "");
const info = ref<ConnectInfo | null>(null);
const logs = ref<string[]>([]);
+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";
}
+206 -2
View File
@@ -1,4 +1,4 @@
/** 协议类型镜像(对齐 Rust 侧 `crates/nodeagent-client` 与 Tauri command 的序列化形状)。 */
/** 协议类型镜像(对齐 `@nodeagent/protocol`,亦即 sidecar 与 Tauri command 的序列化形状)。 */
/** 能力描述(被控端声明的能力清单条目)。 */
export interface CapabilityDescriptor {
@@ -109,6 +109,29 @@ export interface SystemInfo {
ps_helper?: PsHelperInfo;
network?: NetworkInfo;
build?: BuildInfo;
/** 最后已知可用版本 LKG(交付流水线 §6 回滚锚点) */
lkg?: LkgInfo;
}
/**
* 最后已知可用版本(Last-Known-Good)。
*
* 只在「本次启动真正起来了」之后才由被控端写入 ——
* 故 `matches=false` 意味着当前版本**尚未被证明能启动**(刚更新完、还没完成一次成功启动),
* 界面应据此提示「暂缓向其余设备放量」。
*/
export interface LkgInfo {
lkg?: {
hash?: string;
bytes?: number;
script_path?: string;
recorded_at?: number;
node_version?: string;
platform?: string;
};
current_hash?: string;
matches?: boolean;
byte_delta?: number;
}
/**
@@ -204,7 +227,12 @@ export type AuditType =
| "net.change"
| "net.deny"
| "agent.update"
| "session.idle_close";
// v2.1.0:L2 系统级审计事件(Windows Security 日志归一化副本)
| "agent.l2"
| "session.idle_close"
// v2.0.3:被拒请求统一留痕(U-01)+ 进程级兜底错误
| "request.rejected"
| "agent.error";
export interface AuditEntry {
ts?: number;
@@ -217,6 +245,10 @@ export interface AuditEntry {
error?: string | null;
/** 参数摘要(sha256 前缀)——默认只记摘要,不记内容 */
args_digest?: string;
/** v2.1.0(A-P0-4):参数序列化总字节数;与 args_truncated 同时出现表示摘要只覆盖前 64KB */
args_bytes?: number;
/** v2.1.0(A-P0-4):摘要是否为截断哈希(序列化超过 64KB 时只哈希前 64KB) */
args_truncated?: boolean;
/** 参数预览(仅 `log_args:true` 时记录,键含敏感词自动脱敏) */
args_preview?: string;
reason?: string;
@@ -247,3 +279,175 @@ export interface AuditVerifyResult {
/** 是**对象**而非数字 */
broken_at?: AuditBrokenAt | null;
}
// ---------------------------------------------------------------------------
// L2 系统级审计(system.audit.l2,仅 Windows)—— 契约来源:manifest system.ts
// AuditL2 returns_schema(四种 action 的顶层属性并集;未出现的属性即本次不返回)
// ---------------------------------------------------------------------------
/** L2 策略状态 / 采集 / 启用 / 校验的返回(按 action 只出现对应字段)。 */
export interface AuditL2Result {
// status
platform?: string;
supported?: boolean;
auditpol?: string;
legacy_policy_disabled?: boolean;
cmdline_logging?: boolean;
sysmon_installed?: boolean;
file?: string;
file_exists?: boolean;
note?: string;
// enable
enabled?: boolean;
policies?: string[];
hint?: string;
// collect
collected?: number;
written?: number;
since?: number;
limit?: number;
// verify
ok?: boolean;
checked?: number;
broken_at?: AuditBrokenAt | null;
}
// ---------------------------------------------------------------------------
// 本机服务(本机被控端托管)—— 契约来源:src-tauri/src/agent.rs
// ---------------------------------------------------------------------------
/** 被控端进程状态机取值(与 Rust `agent::state` 一一对应)。 */
export type AgentState = "stopped" | "starting" | "running" | "stopping" | "crashed";
/** 本机被控端进程快照。 */
export interface AgentProcess {
state: AgentState;
pid?: number | null;
started_at?: number | null;
uptime_ms?: number | null;
/** 自动重启已尝试次数 */
restarts?: number;
last_exit?: string | null;
/** 实际使用的 node 可执行文件 */
node?: string;
/** 实际运行的那份 agent.mjs(在数据目录里,不在 app 包内) */
script?: string;
data_dir?: string;
/**
* true = 复用了**非本控制台启动**的被控端(退出控制台时选择「保留」而留下的孤儿)。
*
* 此时它已在监听、可直接「连接本机」使用;但拿不到子进程句柄,
* 故「重启」会先按 PID 收掉再自己拉起(即把控制权收回本控制台)。
*/
external?: boolean;
}
/** 被控端配置(读自 `<数据目录>/agent.json`)。 */
export interface AgentConfigInfo {
data_dir: string;
config_path: string;
config_exists: boolean;
node_id: string;
port: number;
tls: boolean;
auth_mode: "psk" | "ed25519" | string;
/** PSK 明文(本机展示用;它本就在用户自己的 600 权限文件里) */
key: string;
/** 端口是否已被监听(可能是一个非本控制台托管的实例) */
port_in_use: boolean;
url: string;
}
export interface AutostartStatus {
enabled: boolean;
supported: boolean;
/** 落地位置(plist 路径 / 计划任务名) */
location: string;
app_exe: string;
/** 自启时传给应用的模式参数 */
mode: string;
note: string;
/** 本次操作详情(开关后返回) */
detail?: string;
}
export interface ConsoleSettings {
auto_start_agent: boolean;
auto_restart: boolean;
close_to_tray: boolean;
insecure: boolean;
}
export interface AgentStatus {
manager: AgentProcess;
config: AgentConfigInfo;
autostart: AutostartStatus;
settings: ConsoleSettings;
app_version: string;
config_dir: string;
/** 数据目录里那份脚本的版本(被控端自更新或控制台更新后写入) */
installed_agent_version?: string | null;
/** 随包内置的 agent.mjs 路径 */
bundled_agent?: string;
}
export interface AgentActionResult {
state?: AgentState;
pid?: number | null;
note?: string;
port_released?: boolean;
}
export interface AgentLogLine {
seq: number;
ts: number;
/** stdout / stderr / system */
stream: string;
line: string;
}
export interface AgentLogPage {
lines: AgentLogLine[];
total: number;
}
export interface UpdateInfo {
console_version: string;
latest_version: string;
has_update: boolean;
agent_has_update?: boolean;
installed_agent_version?: string;
published_at?: string | null;
commit?: string | null;
capabilities?: number | null;
agent: { url?: string | null; sha256?: string | null; bytes?: number | null };
release_url: string;
source: string;
/** 本次出网走的代理来源(`HTTPS_PROXY` / `system` / `NA_PROXY`…),直连为 null。
* 2026-10-13 加:境内「检查更新」失败的排查线索,必须能在界面上看到。 */
via_proxy?: string | null;
/** 人类可读的出网路径描述(「直连」/「经代理 http://…」)。 */
via?: string | null;
checked_at: number;
}
export interface ApplyUpdateResult {
updated: boolean;
version: string;
bytes: number;
sha256: string;
backup: string;
restarted: boolean;
}
export interface ConsoleSystemInfo {
app_version: string;
config_dir: string;
agent_data_dir: string;
resource_dir: string;
platform: string;
arch: string;
node: string;
/** 是否以「仅服务模式」启动(开机自启时为 true) */
agent_only: boolean;
}
+184 -17
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { h, onMounted, ref } from "vue";
import { computed, h, onMounted, ref } from "vue";
import {
NCard,
NDataTable,
@@ -11,11 +11,14 @@ import {
NTag,
NEmpty,
NAlert,
NPopconfirm,
NDescriptions,
NDescriptionsItem,
useMessage,
type DataTableColumns,
} from "naive-ui";
import { useConnStore } from "../stores/conn";
import type { AuditEntry, AuditListResult, AuditVerifyResult } from "../types";
import type { AuditEntry, AuditListResult, AuditVerifyResult, AuditL2Result } from "../types";
import { formatTime } from "../utils/format";
const conn = useConnStore();
@@ -28,19 +31,29 @@ const typeFilter = ref<string | null>(null);
const loading = ref(false);
const verify = ref<AuditVerifyResult | null>(null);
/**
* 审计筛选项(取值必须是 `@nodeagent/protocol` 里 `AUDIT_FILTER_TYPES` 的一员)。
*
* ⚠️ C-01(v2.0.3 修):这里原本列了 11 个**细粒度**取值(`auth.success`、`acl.denied`…),
* 而协议 manifest 的 enum 只有 `['invoke','auth','acl','agent']` → 被控端 schema 校验把
* 除 `invoke` 外的**全部**取值判成 `E_PARAM_INVALID`:下拉框形同虚设,且**不报类型错**。
*
* 改用**粗粒度前缀**:被控端按 `entry.type.startsWith(query)` 过滤,所以
* `agent` 一次覆盖 `agent.start/stop/update/error`,`request` 覆盖新增的 `request.rejected`。
* 覆盖面更全(每个审计类型都能被至少一项筛到),下拉框也不必膨胀成二十几项。
* 防漂移由 `tests/unit/audit-filter.test.mjs` 钉死(正反双向都比对真源)。
*/
const typeOptions = [
{ label: "全部", value: "" },
{ label: "invoke", value: "invoke" },
{ label: "auth.success", value: "auth.success" },
{ label: "auth.failure", value: "auth.failure" },
{ label: "acl.denied", value: "acl.denied" },
{ label: "capability.disabled", value: "capability.disabled" },
{ label: "rate.limited", value: "rate.limited" },
{ label: "net.deny", value: "net.deny" },
{ label: "net.change", value: "net.change" },
{ label: "agent.update", value: "agent.update" },
{ label: "agent.start", value: "agent.start" },
{ label: "agent.stop", value: "agent.stop" },
{ label: "调用 invoke", value: "invoke" },
{ label: "认证 auth", value: "auth" },
{ label: "ACL 拒绝 acl", value: "acl" },
{ label: "能力被禁 capability", value: "capability" },
{ label: "限速 rate", value: "rate" },
{ label: "网络 net", value: "net" },
{ label: "agent 生命周期 agent", value: "agent" },
{ label: "会话 session", value: "session" },
{ label: "被拒请求 request", value: "request" },
];
const cols: DataTableColumns<AuditEntry> = [
@@ -117,6 +130,80 @@ async function exportJsonl() {
}
}
// ---------- L2 系统级审计(system.audit.l2,仅 Windows)----------
/**
* L2 补的是 L1 结构上记不到的那块:攻击者绕过 agent 直接操作、或把 agent 停掉时,
* 上面的审计表全盲。L2 采集操作系统自己的审计事件(Windows Security 日志)归一化落盘。
*
* ⚠️ 信任边界:采集器与 agent 同权限,本地 audit-l2.log 只是归一化副本,
* 防篡改依赖把记录转发出机(界面文案已如实说明,不夸大)。
*/
const l2Loading = ref(false);
const l2Result = ref<AuditL2Result | null>(null);
const l2SinceHours = ref(24);
const l2Limit = ref(1000);
async function runL2(action: "status" | "enable" | "collect" | "verify") {
l2Loading.value = true;
try {
const args: Record<string, unknown> = { action };
if (action === "collect") {
args.since = Date.now() - l2SinceHours.value * 3600 * 1000;
args.limit = l2Limit.value;
}
l2Result.value = await conn.ok<AuditL2Result>("system.audit.l2", args);
} catch (e) {
// 平台不支持时错误信息里已带「仅支持 Windows」—— 原样透出,不包装
msg.error(`L2 ${action} 失败:${e}`);
l2Result.value = null;
} finally {
l2Loading.value = false;
}
}
/** 把 L2 返回(四 action 属性并集,只出现本次相关字段)摊成描述列表。 */
const l2Rows = computed(() => {
const r = l2Result.value;
if (!r) return [];
const rows: { label: string; text: string }[] = [];
const push = (label: string, v: string | number | boolean | undefined | null) => {
if (v === undefined || v === null) return;
rows.push({ label, text: typeof v === "boolean" ? (v ? "是" : "否") : String(v) });
};
// status
push("平台", r.platform);
push("受支持", r.supported);
push("禁用旧版策略", r.legacy_policy_disabled);
push("命令行记录", r.cmdline_logging);
push("已装 Sysmon", r.sysmon_installed);
push("副本文件", r.file);
push("副本存在", r.file_exists);
// enable
push("已启用", r.enabled);
push("已开启子类", r.policies?.join("、"));
push("提示", r.hint);
// collect
push("采集到", r.collected);
push("已落盘", r.written);
push("起始时间", r.since != null ? formatTime(r.since) : undefined);
push("条数上限", r.limit);
// verify
push("校验通过", r.ok);
push("已校验", r.checked);
if (r.broken_at) {
push(
"首个断点",
r.broken_at.file
? `${r.broken_at.file} 第 ${r.broken_at.line ?? "?"} 行${r.broken_at.reason ? `(${r.broken_at.reason})` : ""}`
: `第 ${r.broken_at.line ?? "?"} 行${r.broken_at.reason ? `(${r.broken_at.reason})` : ""}`,
);
}
// 说明类放最后
push("说明", r.note);
return rows;
});
onMounted(() => {
if (conn.connected) load();
});
@@ -124,7 +211,7 @@ onMounted(() => {
<template>
<div v-if="!conn.connected">
<n-empty description="请先在「设备管理」页连接被控端" style="margin-top: 80px" />
<n-empty :show-icon="false" description="请先在「设备管理」页连接被控端" style="margin-top: 80px" />
</div>
<div v-else class="wrap">
@@ -141,7 +228,7 @@ onMounted(() => {
v-model:value="typeFilter"
:options="typeOptions"
size="small"
style="width: 140px"
style="width: 190px"
@update:value="load"
/>
<n-input-number v-model:value="limit" :min="10" :max="2000" :step="50" size="small" style="width: 110px" />
@@ -154,7 +241,6 @@ onMounted(() => {
<n-alert
v-if="verify"
:type="verify.ok ? 'success' : 'error'"
:show-icon="true"
style="margin-bottom: 12px"
>
完整性校验:{{ verify.ok ? "通过" : "发现问题" }} · 已校验 {{ verify.checked }} 条
@@ -165,9 +251,90 @@ onMounted(() => {
</template>
</n-alert>
<n-empty v-if="!entries.length" description="暂无审计记录" size="small" />
<n-empty :show-icon="false" v-if="!entries.length" description="暂无审计记录" size="small" />
<n-data-table v-else :columns="cols" :data="entries" :bordered="false" size="small" :max-height="560" />
</n-card>
<n-card size="small" style="margin-top: 12px">
<template #header>
<n-space align="center">
<span>L2 系统级审计</span>
<n-text depth="3" style="font-size: 12px">操作系统自己的审计事件(Windows Security 日志)</n-text>
<n-tag size="small" :type="conn.isWindows ? 'info' : 'warning'">
{{ conn.isWindows ? "当前被控端受支持" : "仅 Windows" }}
</n-tag>
</n-space>
</template>
<template #header-extra>
<n-space align="center">
<n-button size="small" :loading="l2Loading" :disabled="!conn.platformReady" @click="runL2('status')">
查状态
</n-button>
<n-button size="small" :loading="l2Loading" :disabled="!conn.platformReady" @click="runL2('collect')">
拉取采集
</n-button>
<n-button size="small" :loading="l2Loading" :disabled="!conn.platformReady" @click="runL2('verify')">
校验 L2 链
</n-button>
<n-popconfirm :show-icon="false" @positive-click="runL2('enable')">
<template #trigger>
<n-button size="small" type="warning" :loading="l2Loading" :disabled="!conn.platformReady">
启用策略
</n-button>
</template>
将开启 Windows 高级审计策略(进程创建 4688 / 登录 4624/4625 / 进程终止 4689)并记录命令行。
需要<strong>管理员权限</strong>,未提权会被拒。确定?
</n-popconfirm>
</n-space>
</template>
<n-alert v-if="conn.nodeOs && !conn.isWindows" type="warning" style="margin-bottom: 12px">
被控端当前为 {{ conn.nodeOs }} —— L2 采集仅落地 Windows(Linux auditd / macOS ESF 未实现,原因见
docs/AUDIT-L2.md)。按钮已禁用,避免点了才报错。
</n-alert>
<n-alert v-else type="default" style="margin-bottom: 12px">
L1 只记录经过 agent 的调用;攻击者绕过 agent、或把 agent 停掉时 L1 全盲,L2 补的正是这块。
本地副本防篡改价值有限,真正的独立性依赖把记录<strong>转发出机</strong>。
</n-alert>
<!-- 采集参数始终可见:早前它在 `v-if="l2Rows.length"` 里面,
必须先出一次结果才出现输入框 —— 用户想「先设窗口 72h 再采集」做不到,
只能先点一次默认参数再改。参数与结果的生命周期无关,故拆开。 -->
<n-space align="center" style="margin-bottom: 8px">
<n-text depth="3" style="font-size: 12px">采集窗口(小时)</n-text>
<n-input-number
v-model:value="l2SinceHours"
:min="1"
:max="720"
:step="1"
size="small"
style="width: 110px"
/>
<n-text depth="3" style="font-size: 12px">条数上限</n-text>
<n-input-number
v-model:value="l2Limit"
:min="1"
:max="5000"
:step="100"
size="small"
style="width: 120px"
/>
</n-space>
<n-space v-if="l2Rows.length" vertical size="small">
<n-descriptions :column="2" size="small" label-placement="left" bordered>
<n-descriptions-item v-for="row in l2Rows" :key="row.label" :label="row.label">
{{ row.text }}
</n-descriptions-item>
</n-descriptions>
</n-space>
<n-empty
v-else-if="!l2Loading"
:show-icon="false"
description="点「查状态」查看被控端审计策略是否已启用"
size="small"
/>
</n-card>
</div>
</template>
+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;
+451
View File
@@ -0,0 +1,451 @@
<script setup lang="ts">
/**
* 本机服务 —— 控制台托管的**本机被控端**(默认首页)。
*
* 目标:用户**完全不碰命令行** —— 打开应用即可启动 / 停止 / 重启被控端、
* 开关开机自启、查日志、检查更新。
*/
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import {
NAlert,
NButton,
NCard,
NDivider,
NGrid,
NGridItem,
NPopconfirm,
NSpace,
NSpin,
NSwitch,
NTag,
NText,
NTooltip,
useMessage,
} from "naive-ui";
import { useAgentStore, useLogsStore } from "../stores/agent";
import { formatDuration } from "../utils/format";
const agent = useAgentStore();
const logs = useLogsStore();
const msg = useMessage();
const router = useRouter();
const revealKey = ref(false);
const copied = ref<string | null>(null);
const cfg = computed(() => agent.status?.config ?? null);
const proc = computed(() => agent.process);
const busy = computed(() => agent.busy);
/** 更新检查/下载独立忙标志 —— 网络类操作不该锁住启停按钮(见 stores/agent.ts 注释) */
const updateBusy = computed(() => agent.updateBusy);
/** 最近 6 行日志(完整日志在「服务日志」页)。 */
const recent = computed(() => logs.lines.slice(-6));
async function copy(text: string, what: string) {
try {
await navigator.clipboard.writeText(text);
copied.value = what;
msg.success(`已复制${what}`);
setTimeout(() => (copied.value = null), 1500);
} catch {
msg.error("复制失败(系统剪贴板不可用,可手工选中复制)");
}
}
async function doAction(fn: () => Promise<unknown>, name: string) {
const r = await fn();
if (agent.error) msg.error(`${name}失败:${agent.error}`);
else if (r) msg.success(`${name}完成`);
}
async function doAutostart(v: boolean) {
const r = await agent.setAutostart(v);
if (agent.error) msg.error(`设置开机自启失败:${agent.error}`);
else msg.success(v ? `已开启开机自启(${agent.status?.autostart.location})` : "已关闭开机自启");
return r;
}
async function doSetting(key: "auto_restart" | "close_to_tray" | "auto_start_agent", v: boolean) {
await agent.patchSettings({ [key]: v } as never);
if (agent.error) msg.error(`保存设置失败:${agent.error}`);
}
async function doCheckUpdate() {
const u = await agent.checkUpdate();
if (agent.error) msg.error(prefixOnce(agent.error, "检查更新失败:"));
else if (u) msg.success(u.has_update ? `有新版本 v${u.latest_version}` : "已是最新版本");
}
async function doApplyUpdate() {
const r = await agent.applyUpdate();
if (agent.error) msg.error(prefixOnce(agent.error, "更新失败:"));
else if (r) msg.success(`已更新到 v${r.version}(${r.restarted ? "并已重启" : "未自动启动"})`);
}
/**
* 错误信息里若已带同一前缀就不再叠加。
*
* ⚠️ 2026-10-13:sidecar 侧把失败原因写成「检查更新失败:<真实原因>(经代理 …, 耗时 …)」,
* 而这里原本又拼一层 → 用户看到「检查更新失败:检查更新失败:请求超时…」。
* 但 `ensure_sidecar` 之类来自 Rust 壳的错误**没有**前缀,所以前缀本身要保留,只是不重复。
*/
function prefixOnce(text: string, prefix: string) {
return text.startsWith(prefix) ? text : prefix + text;
}
async function doConnectLocal() {
try {
await agent.connectLocal();
msg.success("已连接本机被控端");
} catch (e) {
msg.error(`连接失败:${e}`);
}
}
onMounted(async () => {
await agent.bind();
// `bind()` 内部有 `bound` 守卫:第二次进入本页会直接 return,**不会刷新**。
// 而 `na-agent-state` 推送可能在前端监听就绪前就发出(WebView 尚未 ready),
// 于是状态会一直停在旧值(真机症状:后端已是 running,界面永远「启动中」)。
// 故每次进入本页都显式拉一次 —— 这是最便宜的状态自愈。
await agent.refresh();
await logs.bind();
});
function fmtTime(ts?: number | null) {
return ts ? new Date(ts).toLocaleString() : "-";
}
/** 打开 Release 页面(模板里不能直接用 `window`,故走 setup 作用域的函数)。 */
function openRelease() {
const url = agent.update?.release_url;
if (url) window.open(url, "_blank");
}
</script>
<template>
<n-spin :show="busy && !agent.status">
<div class="wrap">
<n-alert v-if="agent.error" type="error" style="margin-bottom: 14px">
{{ agent.error }}
</n-alert>
<!-- 非托管实例提示:端口被占但不是本控制台起的 -->
<n-alert
v-if="agent.externalInstance"
type="warning"
style="margin-bottom: 14px"
title="检测到已有一个被控端在运行(非本控制台托管)"
>
端口 {{ cfg?.port }} 已被监听。它可能是手工安装的(`install.cmd` / 计划任务)。
本控制台无法停止它;如要用本控制台管理,请先停掉它,或直接点「连接本机」复用它。
</n-alert>
<n-grid :cols="4" :x-gap="16" :y-gap="16" item-responsive responsive="screen">
<!-- ① 服务状态与操作 -->
<n-grid-item span="4 s:4 m:2">
<n-card title="本机被控端" size="small">
<n-space align="center" style="margin-bottom: 12px">
<n-tag :type="agent.meta.type" size="medium" round>{{ agent.meta.label }}</n-tag>
<n-text v-if="proc?.pid" depth="3" style="font-size: 12px">PID {{ proc.pid }}</n-text>
<n-text v-if="proc?.uptime_ms" depth="3" style="font-size: 12px">
· 已运行 {{ formatDuration(Math.round((proc.uptime_ms ?? 0) / 1000)) }}
</n-text>
<n-text v-if="proc?.restarts" depth="3" style="font-size: 12px">
· 自动重启 {{ proc.restarts }} 次
</n-text>
<!-- 复用来的实例(退出控制台时选择「保留」留下的孤儿):可直接用,
但控制权不在本控制台 —— 点「重启」即收归托管 -->
<n-tag v-if="proc?.external" type="warning" size="small" round>
已复用外部实例
</n-tag>
</n-space>
<n-space style="margin-bottom: 14px">
<n-button
type="primary"
:loading="busy"
:disabled="agent.running || agent.externalInstance"
@click="doAction(() => agent.start(), '启动')"
>
启动
</n-button>
<n-button :loading="busy" :disabled="!agent.running" @click="doAction(() => agent.stop(), '停止')">
停止
</n-button>
<n-button :loading="busy" :disabled="!agent.running" @click="doAction(() => agent.restart(), '重启')">
重启
</n-button>
<n-button :disabled="!cfg" quaternary @click="doConnectLocal">连接本机</n-button>
</n-space>
<n-alert v-if="proc?.last_exit" type="default" style="margin-bottom: 12px">
上次退出:{{ proc.last_exit }}
</n-alert>
<div class="kv"><span>节点 ID</span><b>{{ cfg?.node_id || "-" }}</b></div>
<div class="kv"><span>监听地址</span><b>{{ cfg?.url || "-" }}</b></div>
<div class="kv">
<span>预共享密钥</span>
<b class="mono">
{{ revealKey ? cfg?.key || "-" : "•".repeat(Math.min(cfg?.key?.length ?? 0, 16)) || "-" }}
<n-button text size="tiny" style="margin-left: 6px" @click="revealKey = !revealKey">
{{ revealKey ? "隐藏" : "显示" }}
</n-button>
<n-button
text
size="tiny"
style="margin-left: 6px"
:disabled="!cfg?.key"
@click="copy(cfg?.key ?? '', '密钥')"
>
复制
</n-button>
</b>
</div>
<div class="kv"><span>认证模式</span><b>{{ cfg?.auth_mode || "-" }}</b></div>
<div class="kv">
<span>数据目录</span>
<b class="mono ellipsis" :title="cfg?.data_dir">{{ cfg?.data_dir || "-" }}</b>
</div>
<div class="kv">
<span>运行脚本</span>
<b class="mono ellipsis" :title="proc?.script">{{ proc?.script || "-" }}</b>
</div>
<div class="kv">
<span>被控端版本</span>
<b>{{ agent.status?.installed_agent_version || "(未播种)" }}</b>
</div>
</n-card>
</n-grid-item>
<!-- ② 行为设置 -->
<n-grid-item span="4 s:4 m:2">
<n-card title="运行方式" size="small">
<div class="switch-row">
<div>
<div>开机自启</div>
<div class="hint">
{{ agent.status?.autostart.note }}
</div>
</div>
<n-switch
:value="agent.status?.autostart.enabled ?? false"
:disabled="!agent.status?.autostart.supported || busy"
:loading="busy"
@update:value="doAutostart"
/>
</div>
<n-divider style="margin: 12px 0" />
<div class="switch-row">
<div>
<div>后台运行(关闭窗口时最小化到托盘)</div>
<div class="hint">关闭后,点窗口的关闭按钮将直接退出应用并停止被控端</div>
</div>
<n-switch
:value="agent.settings.close_to_tray"
:disabled="busy"
@update:value="(v: boolean) => doSetting('close_to_tray', v)"
/>
</div>
<n-divider style="margin: 12px 0" />
<div class="switch-row">
<div>
<div>启动应用时自动拉起被控端</div>
<div class="hint">关闭后需手工点「启动」</div>
</div>
<n-switch
:value="agent.settings.auto_start_agent"
:disabled="busy"
@update:value="(v: boolean) => doSetting('auto_start_agent', v)"
/>
</div>
<n-divider style="margin: 12px 0" />
<div class="switch-row">
<div>
<div>被控端异常退出时自动重启</div>
<div class="hint">连续失败 5 次后停止重试并保留现场日志</div>
</div>
<n-switch
:value="agent.settings.auto_restart"
:disabled="busy"
@update:value="(v: boolean) => doSetting('auto_restart', v)"
/>
</div>
<n-divider style="margin: 12px 0" />
<div class="kv"><span>自启落地位置</span><b class="mono ellipsis">{{ agent.status?.autostart.location || "-" }}</b></div>
<div class="kv"><span>自启方式</span><b class="mono">仅服务模式(无窗口)</b></div>
</n-card>
</n-grid-item>
<!-- ③ 更新 -->
<n-grid-item span="4 s:4 m:2">
<n-card title="更新" size="small">
<div class="kv"><span>控制台版本</span><b>v{{ agent.status?.app_version || "-" }}</b></div>
<div class="kv">
<span>被控端版本</span>
<b>{{ agent.status?.installed_agent_version || "(未播种)" }}</b>
</div>
<div class="kv">
<span>最新版本</span>
<b>
{{ agent.update ? `v${agent.update.latest_version}` : "(未检查)" }}
<n-tag
v-if="agent.update?.has_update"
type="warning"
size="tiny"
style="margin-left: 6px"
>
有更新
</n-tag>
<n-tag
v-else-if="agent.update"
type="success"
size="tiny"
style="margin-left: 6px"
>
已是最新
</n-tag>
</b>
</div>
<div v-if="agent.update?.published_at" class="kv">
<span>发布时间</span><b>{{ fmtTime(Date.parse(agent.update.published_at)) }}</b>
</div>
<!-- 出网路径:境内「检查更新」失败时最关键的一条排查线索(直连 / 哪个代理) -->
<div v-if="agent.update?.via" class="kv">
<span>出网</span>
<b class="mono ellipsis">{{ agent.update.via }}</b>
</div>
<n-space style="margin-top: 12px">
<n-button size="small" :loading="updateBusy" @click="doCheckUpdate">检查更新</n-button>
<n-popconfirm :show-icon="false" @positive-click="doApplyUpdate">
<template #trigger>
<n-button
size="small"
type="primary"
:disabled="!agent.update?.agent_has_update || updateBusy"
>
更新被控端
</n-button>
</template>
将下载最新被控端并替换本机正在使用的那份(自动备份旧文件),随后重启服务。确定?
</n-popconfirm>
</n-space>
<div class="hint" style="margin-top: 10px">
「更新被控端」只替换本机被控端脚本(架构无关的 JS)。控制台自身(桌面应用)请从
<n-button
text
size="tiny"
:disabled="!agent.update"
@click="openRelease"
>
Release 页面
</n-button>
下载安装包。
</div>
</n-card>
</n-grid-item>
<!-- ④ 日志预览 -->
<n-grid-item span="4 s:4 m:2">
<n-card title="最近日志" size="small">
<template #header-extra>
<n-space>
<n-button text size="tiny" @click="logs.load()">刷新</n-button>
<n-button text size="tiny" @click="router.push({ name: 'logs' })">查看全部</n-button>
</n-space>
</template>
<div v-if="recent.length" class="logs">
<div v-for="l in recent" :key="l.seq" class="log-line" :class="l.stream">
<span class="ts">{{ new Date(l.ts).toLocaleTimeString() }}</span>
<span class="txt">{{ l.line }}</span>
</div>
</div>
<n-text v-else depth="3" style="font-size: 13px">暂无日志</n-text>
</n-card>
</n-grid-item>
</n-grid>
<n-tooltip trigger="hover">
<template #trigger>
<n-text depth="3" style="font-size: 12px; display: block; margin-top: 18px">
提示:关闭窗口后控制台会缩到托盘继续运行 —— 从托盘菜单可显示窗口或退出。
</n-text>
</template>
若不想后台常驻,可在「运行方式」里关闭「后台运行」
</n-tooltip>
</div>
</n-spin>
</template>
<style scoped>
.wrap {
max-width: 1100px;
}
.kv {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 13px;
padding: 4px 0;
}
.kv > span {
opacity: 0.6;
flex: 0 0 auto;
}
.kv > b {
font-weight: 500;
text-align: right;
min-width: 0;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
}
.ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-block;
max-width: 100%;
}
.switch-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
font-size: 13px;
}
.hint {
font-size: 12px;
opacity: 0.55;
margin-top: 3px;
line-height: 1.5;
}
.logs {
max-height: 190px;
overflow: auto;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
}
.log-line {
display: flex;
gap: 8px;
padding: 1px 0;
white-space: pre-wrap;
word-break: break-all;
}
.log-line .ts {
opacity: 0.45;
flex: 0 0 auto;
}
.log-line.stderr .txt {
color: var(--na-err);
}
.log-line.system .txt {
color: var(--na-info);
}
</style>
+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>
+9 -9
View File
@@ -22,7 +22,7 @@ const nodeStore = useNodeStore();
const msg = useMessage();
const newName = ref("");
const newUrl = ref("ws://127.0.0.1:8765");
const newUrl = ref("wss://127.0.0.1:8765");
const newKey = ref("");
async function doConnect() {
@@ -73,7 +73,7 @@ const capGroups = computed(() => {
<n-card title="连接被控端" size="small">
<n-space vertical>
<n-input-group>
<n-input v-model:value="conn.url" placeholder="ws://127.0.0.1:8765" style="flex: 3" />
<n-input v-model:value="conn.url" placeholder="wss://127.0.0.1:8765" style="flex: 3" />
<n-input
v-model:value="conn.key"
type="password"
@@ -93,7 +93,7 @@ const capGroups = computed(() => {
指纹 {{ (conn.info.peer_cert_fp ?? "-").slice(0, 16) }}…
</n-text>
</div>
<n-empty v-else description="尚未连接" size="small" />
<n-empty :show-icon="false" v-else description="尚未连接" size="small" />
</n-space>
</n-card>
@@ -114,7 +114,7 @@ const capGroups = computed(() => {
</div>
<n-space>
<n-button size="tiny" type="primary" @click="connectTo(n.id)">连接</n-button>
<n-popconfirm @positive-click="nodeStore.remove(n.id)">
<n-popconfirm :show-icon="false" @positive-click="nodeStore.remove(n.id)">
<template #trigger>
<n-button size="tiny" quaternary>删除</n-button>
</template>
@@ -123,7 +123,7 @@ const capGroups = computed(() => {
</n-space>
</div>
</div>
<n-empty v-else description="暂无保存的设备" size="small" />
<n-empty :show-icon="false" v-else description="暂无保存的设备" size="small" />
<div class="add-row">
<n-input v-model:value="newName" placeholder="名称" size="small" style="flex: 1" />
@@ -149,7 +149,7 @@ const capGroups = computed(() => {
</div>
</n-collapse-item>
</n-collapse>
<n-empty v-else description="连接后展示" size="small" />
<n-empty :show-icon="false" v-else description="连接后展示" size="small" />
</n-card>
<n-card title="日志" size="small">
@@ -195,11 +195,11 @@ const capGroups = computed(() => {
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border: 1px solid rgba(255, 255, 255, 0.09);
border: 1px solid var(--na-border);
border-radius: 8px;
}
.node.active {
border-color: #63e2b7;
border-color: var(--na-accent);
}
.node-name {
font-size: 14px;
@@ -219,7 +219,7 @@ const capGroups = computed(() => {
font-size: 12px;
padding: 2px 8px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.06);
background: var(--na-sunken);
}
.logs {
font-family: ui-monospace, Menlo, monospace;
+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.0",
"version": "2.1.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/mcp",
"version": "2.0.0",
"version": "2.1.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
@@ -14,7 +14,7 @@
"start": "node dist/index.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.0",
"@modelcontextprotocol/sdk": "^1.32.1",
"@nodeagent/client": "workspace:*",
"@nodeagent/protocol": "workspace:*"
}
+2 -1
View File
@@ -147,6 +147,7 @@ const TOOL_TO_CAPABILITY: Record<string, string> = {
na_service_list: CapabilityNames.ServiceList,
na_screenshot: CapabilityNames.ScreenCapture,
na_audit: CapabilityNames.AuditList,
na_audit_l2: CapabilityNames.AuditL2,
na_fs_list: CapabilityNames.FsList,
na_fs_read: CapabilityNames.FsRead,
na_fs_write: CapabilityNames.FsWrite,
@@ -262,7 +263,7 @@ function resolveToolCall(toolName: string, input: Record<string, unknown>): Reso
return { capability: CapabilityNames.KeyType, args: { text: input['text'] } };
}
if (action === 'press') {
// v1.5:四种形式一次只用一种(hotkeys > hotkey > presets > preset > keys),
// v1.5.0:四种形式一次只用一种(hotkeys > hotkey > presets > preset > keys),
// 其余各端会做同样的校验与展开,这里只做薄校验后原样透传。
const has = (k: string): boolean =>
input[k] !== undefined && !(Array.isArray(input[k]) && (input[k] as unknown[]).length === 0);
+26 -5
View File
@@ -165,6 +165,27 @@ export const TOOLS = [
additionalProperties: false,
},
},
{
name: 'na_audit_l2',
description:
'L2 系统级审计兜底(仅 Windows):采集操作系统自己的审计事件(Windows Security 日志),补 na_audit 结构上记不到的那块 —— ' +
'攻击者绕过 agent 直接操作、或把 agent 停掉时 na_audit 全盲。' +
'action: status=查策略是否启用 / enable=开启(需管理员)/ collect=拉取并归一化落盘 / verify=校验 L2 链。' +
'⚠️ 本地副本防篡改价值有限,真正的独立性依赖把记录转发出机。',
inputSchema: {
type: 'object',
properties: {
action: {
type: 'string',
enum: ['status', 'enable', 'collect', 'verify'],
description: 'status(默认)/ enable / collect / verify',
},
since: { type: 'integer', description: 'collect 的起始时间(Unix ms),默认 24 小时内' },
limit: { type: 'integer', minimum: 1, maximum: 5000, description: 'collect 条数上限,默认 1000' },
},
additionalProperties: false,
},
},
{
name: 'na_discover',
description:
@@ -257,7 +278,7 @@ export const TOOLS = [
{
name: 'na_await',
description:
'等待一个条件成立再返回(只读低危,v1.6)。\n' +
'等待一个条件成立再返回(只读低危,v1.6.0)。\n' +
'四类条件:window=等窗口出现/消失;control=等界面元素出现/消失(复用 screen.find);' +
'process=等进程出现/消失;file=等文件出现/消失。\n' +
'state=present(默认,等出现)或 absent(等消失)。\n' +
@@ -275,8 +296,8 @@ export const TOOLS = [
title: { type: 'string', description: 'window 条件:窗口标题(正则)' },
process: { type: 'string', description: 'process 条件:进程名(正则)' },
path: { type: 'string', description: 'file 条件:文件路径' },
where: { type: 'object', description: 'v1.7 属性谓词(仅 UIA):{ enabled?, selected?, value?, toggle? }' },
any_of: { type: 'array', maxItems: 8, description: 'v1.7 组合条件:每项一个条件对象,任一命中即算' },
where: { type: 'object', description: 'v2.0.0 属性谓词(仅 UIA):{ enabled?, selected?, value?, toggle? }' },
any_of: { type: 'array', maxItems: 8, description: 'v2.0.0 组合条件:每项一个条件对象,任一命中即算' },
},
required: ['condition'],
additionalProperties: false,
@@ -285,7 +306,7 @@ export const TOOLS = [
{
name: 'na_monitor',
description:
'定时采样并落盘,事后回看(v1.8,治间歇性问题)。\n' +
'定时采样并落盘,事后回看(v2.0.0,治间歇性问题)。\n' +
'action=start:起一个采样。source=port(target="host:port",测连通性与延迟)/' +
'process(target=进程名,测存活)/command(target=命令,取退出码)/metric(CPU·内存,无需 target)。\n' +
'action=report:读回样本序列与**摘要**(port/process 给「断了几次 + 最长连续中断」;' +
@@ -309,7 +330,7 @@ export const TOOLS = [
{
name: 'na_log',
description:
'在被控端侧过滤日志,只回匹配行(v1.8,只读)。日志常几万行,整份拉回既慢又占带宽 —— ' +
'在被控端侧过滤日志,只回匹配行(v2.0.0,只读)。日志常几万行,整份拉回既慢又占带宽 —— ' +
'本工具把过滤下推到被控端(流式逐行读)。支持 pattern(正则)/level(ERROR|WARN|INFO|DEBUG)/\n' +
'since(Unix ms)/offset/limit/tail(取末尾 N 条)。',
inputSchema: {
-1185
View File
File diff suppressed because it is too large. Load diff
-5
View File
@@ -1,5 +0,0 @@
[workspace]
resolver = "2"
members = ["nodeagent-client"]
[workspace.dependencies]
-23
View File
@@ -1,23 +0,0 @@
[package]
name = "nodeagent-client"
version = "0.1.0"
edition = "2021"
description = "nodeagent 控制端 Rust 客户端(行为对齐 @nodeagent/client)"
license = "MIT"
[dependencies]
base64 = "0.23.1"
ed25519-dalek = { version = "3.0.0", features = ["pkcs8", "alloc"] }
futures-util = "0.3.34"
hmac = "0.13.0"
rand = "0.10.3"
rustls = "0.23.45"
rustls-pemfile = "2.2.0"
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
sha2 = "0.11.0"
thiserror = "2.0.21"
tokio = { version = "1.53.2", features = ["rt-multi-thread", "net", "time", "sync", "macros", "io-util"] }
tokio-tungstenite = { version = "0.30.0", features = ["rustls-tls-webpki-roots"] }
url = "2.5.8"
webpki-roots = "1.0.9"
@@ -1,53 +0,0 @@
//! 连接一个真实 nodeagent 被控端的最小示例。
//!
//! 用法:`cargo run --example connect -- <url> <key> [client_id]`
//! 例如:`cargo run --example connect -- ws://127.0.0.1:8765 test-psk-key`
use nodeagent_client::{Client, ClientEvent, ClientOptions};
use serde_json::json;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<String> = std::env::args().collect();
let url = args
.get(1)
.cloned()
.unwrap_or_else(|| "ws://127.0.0.1:8765".to_string());
let key = args.get(2).cloned().unwrap_or_default();
let client_id = args
.get(3)
.cloned()
.unwrap_or_else(|| "rust-example".to_string());
let client = Client::new(ClientOptions {
url: url.clone(),
key,
client_id,
insecure: true,
auto_reconnect: false,
..Default::default()
});
client.set_handler(std::sync::Arc::new(|evt| {
if let ClientEvent::Log(msg) = evt {
println!("[log] {msg}");
}
}));
let caps = client.connect().await?;
println!("握手成功,{} 项能力", caps.len());
println!("agent 版本: {:?}", client.agent_version());
println!("证书指纹: {:?}", client.peer_cert_fingerprint());
for c in caps.iter().take(12) {
println!(" - {} (risk={}, v{})", c.name, c.risk, c.version);
}
let res = client.invoke("system.info", json!({})).await?;
println!("\nsystem.info → status={}", res.status);
if let Some(data) = &res.data {
println!("{}", serde_json::to_string_pretty(data)?);
}
client.close();
Ok(())
}
Loaded 100 of 196 files, more files were not shown because too many files have changed in this diff. Show more