196 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
jkooe a515eb2e63 chore(release): v2.0.0 2026-10-10 12:14:02 +08:00
jkooe 3fcfc7d12d chore: CHANGELOG 定版 v2.0.0 + 忽略 .workbuddy/(本地 agent 数据)
- CHANGELOG:「未发布」→ v2.0.0(2026-10-10),补兼容性声明(完全向后兼容)
  与**已知限制**(发布时被控端离线 → v1.5 的四项与 v26 清理策略尚未真机验证)
- .gitignore:忽略 .workbuddy/(memory/automations 属本机状态,不应入库;
  此前未被忽略,会干扰 release.mjs 的干净工作区检查)
2026-10-10 12:12:47 +08:00
jkooe 865862ee7c refactor: 拆 mcp/index.ts(1139 → 530 行),工具清单独立成 tools/defs.ts
## 拆法
`TOOLS` 数组(610 行,占原文件过半)抽到 `apps/mcp/src/tools/defs.ts` 并 export。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## 剩余待办(SECURITY.md §5)
握手失败封禁 / 审计链签名与外部锚定 / 会话上限与空闲断开 / 安装期密钥改用文件传递 /
ed25519 仍非默认(v22 的 `keys{}` 已是更轻量的等价物)
2026-10-05 02:42:12 +08:00
jkooe 159ae2bf6f chore(release): v1.3.0 2026-10-05 02:26:21 +08:00
jkooe 0d59d6032b feat(v21): 第一批安全加固 —— 证书指纹钉住 + 来源网段白名单
对应审查建议的 P0 两条:这两项是**真实可利用的攻击路径**,其余(发现最小化/失败封禁等)
留待后续批次。

## ① 证书指纹钉住(TOFU)—— 挡同网段中间人
自签证书没有 CA 链,历史上客户端只能 `insecure` 接受任意证书 → 攻击者可**透明中继**:
拿不到钥匙(挑战-应答挡得住冒名),但能读到截屏/剪贴板/键入/exec 输出。

- 被控端:`install.ps1` 打印证书指纹(sha256 of DER)供**带外核对**;
  `system.info.build.cert_sha256` 与 `auth_ok.build.cert_sha256` 上报
- 控制端:首次连接自动钉进 `nodes.<名>.cert_sha256`;之后**严格比对**,
  不一致即 `E_CERT_MISMATCH`(-32422) 拒连,并提示 `--forget-cert` 重钉
- 额外做**交叉确认**:实际握手证书 vs auth_ok 自报,不一致同样拒绝(防自报被篡改)
- 指纹基于 **DER**(不是 PEM 字节),保证与 Windows .NET `GetRawCertData()` 同值

**踩坑记录(写进代码注释,避免后人重走)**:
1. `ws` 客户端的 `upgrade` 事件**只传 IncomingMessage**,拿不到 socket
2. socket 要在 `open` 之后从 `ws._socket` 取(TLSSocket)
3. Node ≥15 的 `getPeerCertificate()` 返回**对象**,DER 在 `.raw` 上(老版本才是 Buffer)
   —— 现已双兼容

## ② 来源网段白名单 `allow_from` —— 收敛暴露面
被控端默认监听 `0.0.0.0`(远程接入所需),但**换到不可信网络就自动暴露**。

- 支持 CIDR / 单 IP / `*`;**未配置 = 放行全部**(保持向后兼容,不打断现有部署)
- 拒绝发生在**业务握手之前**(不做 TLS/挑战应答,省资源),并记 `net.deny` 审计
- 判定按**前缀比特**而非字符串相等:Node 在 IPv6 监听上把 IPv4 客户端报成
  `::ffff:a.b.c.d`,已归一化(单测覆盖)
- `system.info.network.allow_from` 上报状态;`nodeagent info` 一眼看出是否已收敛

## 验证(本地真跑,非纸面)
- 白名单不含本机 → 连接被拒(agent 侧 WARN + 审计留痕)✓
- 白名单含本机 → 连上,并**自动钉住**指纹 ✓
- **换掉被控端证书后重连 → `E_CERT_MISMATCH` 拒绝**(期望 2ed4957b… / 实际 ca8dc20c…)✓
- 新增 e2e 用例(自起 TLS agent)覆盖上述四种情形 + agent 自报与实际证书一致性 ✓
- 新增单测 9 例(CIDR 前缀匹配 / IPv4-mapped 归一 / IPv6 zone / PEM≡DER / 篡改即变)
- 全量:单测 **159**(150→159)· e2e **29**(28→29)全绿

## 文档
新增 `docs/SECURITY.md`:信任模型(钥匙=root)、认证现状与**已知限制**(ACL 身份维度仅在
ed25519 有效)、本次两项加固的用法、尚未收敛的 6 项、应急处置表。
2026-10-05 02:25:19 +08:00
jkooe 3cbac53308 chore(release): v1.2.1 2026-10-05 00:26:36 +08:00
jkooe 045e3b08b1 fix(sec): 安装包不再打包 PSK.txt(该 zip 会作为公开 Release 资产发布)
## 问题
`nodeagent-win-x64.zip` 里含 `PSK.txt`(64 位预共享密钥)。而这个 zip 正是 GitHub
Release 的公开资产 —— 等于**把被控端的预共享密钥公开**。若谁用该 Release 包安装,
其密钥即为公开值,同网段的任何人都能凭此接入。

## 修复
- `pack.mjs` 打 zip 时用 `-x "PSK.txt"` 排除(并注明原因,防日后被加回)
- 不影响安装:`install.cmd` 在 PSK.txt 缺失时**本地自动生成**密钥(只留在被控端安装目录),
  这正是它头注释里既有的行为
- 新增单测守卫:断言打 zip 命令必须带 `-x PSK.txt`(防止回归)

## 兼容性
PATCH 级:仅打包内容变化,不涉及协议/能力/参数。已安装的机器不受影响。
2026-10-05 00:25:44 +08:00
jkooe 303cbc7436 chore(release): v1.2.0 2026-10-05 00:19:29 +08:00
jkooe 1c26dc4e3f fix(release): sh() 容错 stdio:'inherit' 的 null 返回(发版首跑即炸) 2026-10-05 00:13:45 +08:00
jkooe 6faf807108 chore(release): fetch/push 统一走重试(本机 GitHub 连接间歇抖动) 2026-10-05 00:02:48 +08:00
jkooe 21d568d90d chore(release): 发版脚本加 push 重试(本机到 GitHub 连接会间歇抖动) 2026-10-04 23:57:35 +08:00
jkooe cedc46d090 feat(v20): 版本纪律 + 发版自动化(对齐「做好版本功能 + GitHub 同步资产」)
## 一、版本功能(防新旧混用)
1. **单一版本源 + 构建注入**:根 package.json 的 version、git sha、构建时间经 esbuild
   `--define` 注入 agent;`system.info.build` 新增 `version/commit/built_at`(原有 `hash` 指纹不变)。
   开发态用 typeof 探测(未注入也不崩)。
2. **握手携带版本**:`auth_ok` 新增可选字段 `agent_version` / `build` / `protocol`(只增不减 → 旧控制端忽略即可)。
3. **能力级兼容比对**:新增纯函数 `capabilityDiff(local, remote)` ——
   - `missingOnRemote`:本地有远端没有 → **远端较旧**,提示更新被控端
   - `unknownLocally`:远端有本地没有 → **本控制端较旧**(不影响既有功能)
   控制端不比版本号而比**能力集合**:版本号可能相同而内容不同,能力集合不会骗人。
4. **CLI 体检**:`nodeagent info` / `connect` 直接显示被控端版本与能力对齐情况,
   把"调用时才炸"变成一句人话提示。

## 二、发版自动化(GitHub 资产同步)
`pnpm release <x.y.z|patch|minor|major>`:前置检查(干净/在 main/与远端同步)→ 改 7 个 package.json
→ 构建+打包 → 生成资产 → 提交打标签推送 → `gh release create` 上传。
- 资产:`agent.mjs`(**自更新载荷**)、`nodeagent-win-x64.zip`(人工安装包)、
  `latest.json`(version/commit/能力数 + 两个资产的 url 与 sha256)、`SHA256SUMS`
- `--dry-run` 无副作用演练;`--no-publish` 只到本地标签
- 能力数从 dist 动态取(避免写死漂移);刻意排除 `.github/`(缺 workflow 权限推不了)

## 三、兼容性契约文档
新增 `docs/VERSIONING.md`:semver 各位的含义、**只增不改**六条硬规则、
升级顺序建议(**先被控端**:新被控端向后兼容旧 CLI)、两条更新路径、发版流程、排查手册。

## 验证
- 单测 147 → **149**(新增 capabilityDiff 用例:远端过旧 / 本机过旧 / 完全一致 / 真实清单自比无差异)
- e2e 28 全绿;`node scripts/release.mjs 1.0.0 --dry-run` 演练通过且工作区无副作用
2026-10-04 23:54:51 +08:00
jkooe aa72241080 fix: 真机全量测试修 5 个 bug(剪贴板假失败 / 音频后端误标 / CLI info 打印 / key press 崩溃 / Unicode 代理对)
在 192.168.1.100 真机逐项跑通 42 项能力,暴露并修复 5 个真 bug:

1. clip.set 假失败(间歇 ~20%)
   Set-Clipboard 抛「所请求的剪贴板操作失败」时 exit_code 仍为 0(只写 stderr),
   且内容其实已写入 —— 造成「成功却报错」。改为重试 3 次 + 退避 + 读回校验,
   不再凭 exit_code 下结论。验证:修前 2/5 失败 → 修后 0/8 失败。

2. Windows 音频 backend 误标 'CoreAudio'
   CoreAudio 是 macOS 的后端名;Windows 实际走 C# NAudio(CoreAudioAPI)。
   抽常量并修正 manifest 描述,避免排查时误判平台。

3. CLI info 把嵌套对象打成 [object Object]
   ps_helper / build 是嵌套对象,String(v) 丢失全部内容。新增 renderInfoValue():
   null→—、空串→—(未设置)、对象→key=value 紧凑摘要(180 字截断)。

4. nodeagent key press 必崩(100% 复现)
   字段契约不一致:被控端返回 {pressed,chords,times}(无 keys),
   CLI 读 r.keys.join() 抛 TypeError。双端对齐 —— agent 补回 keys 字段,
   CLI 改为 keys ?? chords 展开 ?? 参数回显。

5. Unicode 注入拆坏代理对(emoji / 生僻字)
   UnicodeChar(char) 逐字符注入,BMP 外字符被拆成两个孤立代理项,
   目标应用收到非法序列渲染 U+FFFD。新增 C# UnicodeString() 按 UTF-16 码元注入。
   注:KEYEVENTF_UNICODE 扫描码位仅 16 位,代理对必须各自单独发(API 固有限制)。

验证:单测 147 全绿;真机验收 15 通过 / 0 失败 / 1 跳过;双端已部署上线。
另确认中文偶发错字为 IME 上屏竞争,非注入逻辑问题(30 次对照实验全对)。
2026-10-04 23:40:25 +08:00
jkooe e95a0248da feat(v19): 拉取式自更新 system.agent.update(42 项)—— 回答「能否直接用 GitHub 做更新服务器」
## 结论:能,而且这是「控制端与被控端不可达」场景的正解
`deploy`(推送式)要求两者可达;本能力只要求**被控端能上网**,因此跨网段/NAT/异地都能更新。
GitHub Release / raw / 自建镜像 / 任意 http(s) 地址均可 —— 地址是参数,不写死。

## 实现(apps/agent/src/capabilities/selfupdate.ts)
下载 → **校验 sha256** → 备份 → **原子替换**(同目录 rename)→ 受控重启(复用 v7)→ CLI 复核指纹
- 出口:CLI `nodeagent update --url <u> --sha256 <h> [--dry-run]` · MCP `na_agent_update`
- 审计新增 `agent.update` 事件(记录 url / 前后指纹 / 字节数 / 备份路径)

## 安全底线(否则等于开放远程代码执行)
1. **sha256 必填**,且逐字节校验通过才替换;不符即丢弃并明确报错(未改动任何文件)
2. 只接受 http/https,拒绝 file:// 等其它 scheme
3. 大小上限 64MB + 下载超时(默认 120s)
4. **先备份再原子替换**,失败可回滚(返回 backup_path)
5. `dry_run` 只下载校验、不动文件 —— 用于「先确认能不能更」
6. 不用 fetch 的内置代理(Node 未暴露 undici):国内访问 GitHub 不稳时,
   把 url 指向**自建镜像/加速地址**即可,能力本身接受任意 http(s)

## 真机/本地验证(全程真跑,非纸面)
- e2e 新增:本地 HTTP 服务真跑下载 —— 正确哈希通过 / **错误哈希被拒** / 404 明确报错 /
  非 http(s) scheme 被拒 / 缺 sha256 协议层拒绝 ✓
- 完整流程本地实测:`dry-run 预检` → `真更新`(966KB)→ 备份逐字节一致 → 重启 →
  **复核到新指纹** → 能力 42 项 ✓
- 顺带修:返回值漏 `incoming_hash` 导致 CLI 复核被跳过(真机踩过)
- 单测 126 / e2e 28 全绿

## 已知约束(写进文档)
1. **首次仍要一次性安装**(install.ps1)—— 自更新能力本身得先在机器上,鸡生蛋无解
2. `node.exe`(Node 运行时)不在自更新范围,只换 agent.mjs
2026-10-04 23:03:37 +08:00
jkooe e6a9a3d7ea feat(v18): 更新链路可靠化 —— 构建指纹 + 备份 + 轮询校验(附 6 个真机 bug 修复)
## 皇上的问题
「这个版本如果有更新,可以在客户端上便捷的更新吗?」
答案:本来就有 `nodeagent deploy`(比最初的一键升级还早),但**它不可信**:
部署后只看「PID 变了 / 能力数对了」,证明不了真的换成了新代码(旧版可能能力数一样),
而且备份与失败判定都存在问题。本轮把这条链路做成**可信**的。

## 新增
1. **构建指纹**:agent 上报 `build.hash`(运行中脚本的 sha256 前 12 位)+ bytes/mtime/node/uptime。
   部署后按指纹校验「远端真的是新代码」,而非靠 PID/能力数猜。
2. **`deploy --check`**:只比对指纹不动文件(退出码 2 = 需要更新),适合脚本/巡检。
3. **`deploy --force`**:指纹相同也强制覆盖。
4. **`pnpm update:win [设备名]`**:一条命令完成「打包 → 校验产物确实更新 → 部署」
   (脚本刻意防呆:pack 后产物时间戳没变就拒绝部署,避免部署旧包 —— 真机踩过)。
5. **NODEAGENT_DEBUG=1**:CLI 打印异常堆栈(异步链问题必备)。

## 修掉的 6 个真机 bug(本轮逐个实测暴露)
1. **备份用 `Copy-Item`**(Windows 专用 shell 命令)→ macOS 被控端静默不备份。
   改为协议自带 fs.read/fs.write,跨平台。
2. **备份读取漏 `encoding: 'base64'`** → 二进制被按 utf8 读(损坏),再当 base64 写回
   → 备份文件只有 33 字节垃圾(实测:`fs.read` 返回 524288 字符而非 base64)。
3. **备份循环用 fs.read 返回的 `offset` 当前进量** → 那是「本次读取的起始位置」,
   于是原地打转 → 死循环 + 文件暴涨 + 部署卡死 200s。
   改为自算 len(对齐 cmdPush 成熟写法)+ 无进展即跳出。
4. **轮询用 withClient → 直接退出进程**:`withClientDirect` 在连接失败且是 ClientError 时
   调 `fail()` 退出 —— 轮询第一次连接必失败(agent 正在重启)→ CLI 立刻退出,
   报 E_NODE_OFFLINE,而**部署其实已成功**(本轮 3 次「失败」全是这个假象)。
   新增 `probeOnce()`:永不抛出的探测函数,专用于「等待上线/重启」类重试。
5. **盲等 9 秒**判定部署结果 → 改为**按墙钟**限时轮询指纹(90s),失败时报真实耗时。
6. **重启会切断连接**:该中断属预期,此前未处理会把成功误判为失败 → 显式吞掉并说明。

## 验证(本地全程真跑,非纸面)
用「起本地 agent → 造一个改动过的 bundle → 部署」的方式把整条链路跑通:
- 同版本 `--check` → 「已是最新」退出码 0 ✓
- 不同版本 `--check` → 报不一致退出码 2 ✓
- 完整部署 → 备份(979578B,`cmp` 逐字节一致)→ 上传 → 重启 → **指纹校验通过** ✓
- 防呆:文件过小/不存在被拒 ✓
- 单测 125 / e2e 27 全绿
2026-10-04 22:54:45 +08:00
jkooe 9853ee8cdb docs: 增补「杀软误拦(主流杀软 / 杀软 / 360)」排查章节
真机实证:远程接管类工具的特征(未知 exe + 建计划任务 + 开防火墙 + 常驻监听 + 注入键鼠)
必然触发杀软拦截。主流杀软会拦 install.ps1 执行 / 计划任务注册,或直接处置 node.exe,
表现为「下载成功但装不上」或「装上后 agent 起不来」。

新增内容:
- 逐项放行清单(安装目录 / node.exe / agent.mjs / nodeagent 计划任务 / 入站 8765 / PowerShell 执行 / 键鼠截屏)
- 自检命令(任务状态 + 进程 + 端口三连查)
- 排查顺序:任务与进程 → 端口 → **确认两台机器在同一网络**
- 特别标注最后一个坑:控制端换 Wi-Fi(酒店/热点)后仍在扫旧网段,会误判为「失联」
2026-10-03 03:07:23 +08:00
jkooe aaf0175e94 feat(v17): 音频控制能力 system.audio.get / set(40 -> 41 项)
## 动机
「远程把那台机器静音」是接管场景里的高频小事,此前只能靠 exec 拼脚本,
容易踩本项目已记录的各种坑(C# 转义 / 编码 / 引号)。做成正式能力后一条命令即可。

## 实现
- **Windows**:Core Audio COM(IMMDeviceEnumerator → IAudioEndpointVolume)
  —— 可**读回真实状态**,所以是确定性的「设为静音」而不是「切换」
  (切换在未知状态下可能反而打开声音)。走项目既有的常驻助手基础设施(label=audio)。
- **macOS**:osascript 原生 `output muted` / `output volume`,零互操作。

## C# 互操作的两个硬约束(已写进文件头注释)
1. **槽位顺序即 vtable 顺序**:接口方法必须按 COM 定义顺序声明,不调用的也要占位。
   顺序错了不报编译错,而是调用到错误的内存地址 → 进程崩溃。
   (本次:m1..m4 → SetMasterVolumeLevelScalar(5) → m6 → GetMasterVolumeLevelScalar(7)
     → m8..m11 → SetMute(12) → GetMute(13))
2. **C# 源码必须纯 ASCII**:Add-Type 按系统 ANSI(GBK) 落盘,非 ASCII 会乱码。

## 出口
- CLI:`nodeagent audio [get]` / `nodeagent audio set [--mute on|off] [--volume 0-100]`
- MCP:na_audio_get / na_audio_set
- 参数校验:mute 必须布尔、volume 必须 0-100 整数(越界由 schema 在协议层拒绝)

## 验证
- 单测 124 → 125(新增音频 schema 用例:范围/类型/未知参数)
- e2e 26 → 27:新增音频用例,**macOS 路径已实测**(读回真实 state、写回原值走通写入路径不产生可感知变化)
- ⚠️ **Windows 侧(Core Audio COM)尚未真机验证** —— 被控端重装系统后当前离线,上线后补验
- 顺带修正 e2e 一处断言:空参数属**能力层**失败(返回 failed),越界属**协议层**(抛异常)——
  正好把本项目那条契约固化进用例
2026-10-02 19:53:46 +08:00
jkooe edc184037b perf(v16): 常驻助手启动后台预热 —— 首调用不再承担 1-3s 启动成本
## 动机(由指标发现)
真机指标显示 window.list 的 P95 被抬到 2775ms,与实测稳态 42~75ms 矛盾。
排查:助手是**懒启动**的,每个 agent 进程的「第一次调用」要付启动 + 类型预加载的固定
成本(~1-3s);今天为验证反复重启了数十次,这笔成本就落进了 P95 的统计里。
这既是测量噪声,也是真实体验问题 —— 首个用户调用不该等 3 秒。

## 做法
agent 启动、打印完横幅后,后台预热一次助手(延时 1.5s、不阻塞、不 throw、失败无副作用):
把固定成本挪到无人等待的时刻。

## 真机验证
部署(agent 重启)→ 等 6s → **重启后首次** window.list:**0.57s**(此前 1.5~2.5s);
system.info 的 ps_helper 显示 hits=2(预热 1 + 调用 1)、spawned=1、failures=0 ✓

## 顺带说明(指标口径)
本轮 P95 3140ms 的另一部分噪声来自本人当日的开发流量(数十次重启 + 图像匹配调试的
失败样本,screen.find 成功率 77.6% 就是调试期数据)。测稳态请用 --since 限定窗口。
2026-10-01 21:16:11 +08:00
jkooe 4c6190285c refactor(protocol): 拆分 1281 行的 capabilities.ts(纯搬移,公共 API 不变)
## 结果
    capabilities.ts                     1281 → 12 行(仅门面:re-export + findCapability)
    capabilities/names.ts                 55 行  标准能力名 CapabilityNames + 类型
    capabilities/schemas.ts               11 行  通用 schema 片段(filterSchema)
    capabilities/manifest/system.ts      303 行  系统面 9 项
    capabilities/manifest/graphics.ts    413 行  图形 12 项(screen/window/input)
    capabilities/manifest/files.ts       121 行  文件 4 项
    capabilities/manifest/software.ts     62 行  软件 2 项
    capabilities/manifest/automation.ts  329 行  自动化 12 项(task/clip/event/net/macro/group)
    capabilities/manifest/index.ts        20 行  聚合为单一 CAPABILITY_MANIFEST

## 做法(可复用的纯搬移流程)
1. 先扫「清单数组区间内是否夹带顶层声明」→ 无 ✓(否则按函数切分会漏)
2. 用花括号配对切出 39 个描述符(断言数量必须一致,避免静默丢项)
3. 按**能力名字符串前缀**分组(不是枚举成员名——成员名与字符串前缀并不一致,
   例如 AuditList→system.audit.list、AgentRestart→system.agent.restart)
4. 聚合文件保持对外单一 CAPABILITY_MANIFEST;facade 保持全部导出不变

## 副作用(正面)
清单顺序由「历史上陆续追加的混杂顺序」变为**按域分组**(系统→软件→图形→文件→自动化),
CLI/MCP 的能力展示更易读。已真机确认无功能影响。

## 验证
- 构建 6 包全过;单测 124/124(含清单守护:数量=39、逐项 name/schema 校验);e2e 26/26
- 真机:部署后能力 39 项 ✓、CLI 能力矩阵正常打印(含风险图标)✓
2026-10-01 21:11:59 +08:00
jkooe caa5b8a2c1 refactor(cli): 拆分 1977 行的 index.ts 为 11 个模块(纯搬移,零逻辑变更)
## 动机(审查报告 §5.5 / 阿特拉斯报告)
单文件 1977 行难维护、难 review、难以定位改动影响面。本次按职责拆分:

    src/index.ts   315 行  入口:参数解析后按子命令调度
    src/options.ts 155 行  命令行参数解析(parseArgs)
    src/help.ts     75 行  帮助文本
    src/types.ts    74 行  共享类型与常量
    src/core.ts    236 行  运行时:客户端/守护进程代理/输出工具/--node 覆盖
    src/cmd/conn.ts   217 行  连接与设备(connect/keygen/nodes/use/remove/discover)
    src/cmd/system.ts 278 行  系统面(info/status/ps/services/exec/restart/audit/metrics/invoke/apps/install)
    src/cmd/ops.ts    499 行  自动化(bg/tasks/task/fanout/record/deploy/events/macro/clip)
    src/cmd/gui.ts    173 行  图形(screen/screenshot/mouse/key)
    src/cmd/fs.ts     172 行  文件(ls/stat/cat/pull/push)
    src/cmd/net.ts    116 行  网络两阶段提交

## 拆分中处理的真实工程问题(都不是「机械搬移」能躲过的)
1. **跨模块可变状态**:`--node` 的全局覆盖 `currentNodeOverride` 原来是模块级 let。
   ESM 的 import 绑定是只读的,直接 import 后赋值编译不过 → 改为访问器
   setNodeOverride()/getNodeOverride()(并把「为什么不能用变量」写进注释)。
2. **散落在函数之间的顶层常量**:CHUNK_BYTES 恰好位于两个函数之间,按函数切分会
   误入别的模块 → 归入 types.ts 统一导出。DAEMON_SOCK 同理并去重。
3. 导入按模块职责补齐(parseArgs/getClientConfig 等)。

## 验证
- 构建 6 包全过;单测 124/124;e2e 26/26
- **真机 CLI 冒烟 25 条命令全通过**(CLI 不在 e2e 覆盖内,必须实测):
  info/status/ps/services/ls/metrics/audit/nodes/net status/screen/window.list/tasks/clip/apps/exec
  + push/stat/cat/pull(校验回读内容一致)/screenshot/key type/bg/record/mouse/discover
2026-10-01 21:04:24 +08:00
jkooe 411a483c76 fix(v16): 修复网络两阶段提交的 4 个真机缺陷(事故复盘后全部实测通过)
真机实测暴露并修复:
1. **生成的 .ps1 缺 UTF-8 BOM** → PowerShell 5.1 按 ANSI(GBK) 解析,脚本里的中文
   (网卡名「Ethernet」)会吃掉后面的闭合引号,报 "The string is missing the terminator"
   → 计划任务退出码 1 且不留任何日志。现已统一 withBom() 落盘(对照实验:纯 ASCII 正常、
   含中文失败,定位明确)。
2. **路径字面量被多余转义**:单引号内本是字面量,之前却把反斜杠翻倍 → 生成
   `C:\\Users\\...` 非法路径 → Out-File 失败。新增 psLit() 统一处理。
3. **账号名拼法错误**:`"$env:USERDOMAIN\$env:USERNAME"` 在 TS 模板串里 `\$` 被当转义吃掉
   反斜杠 → 拼出「机器名<user>」→ Register-ScheduledTask 报 HRESULT 0x80070534
   「No mapping between account names and security IDs」。改用 WindowsIdentity 权威名称。
4. **注册脚本重复实现导致漏改**:回滚/延时两处各写一份,改了一处漏另一处(本轮回滚改好了、
   apply 没改 → 继续报错)。抽出唯一实现 buildTaskRegistration(),并把注册类调用统一改为
   **临时脚本 + -File**(真机实测 -EncodedCommand 下进度流会污染 stderr 且 stdout 拿不到
   预期输出,导致「明明成功却被判失败」)。

顺带:
- 错误详情统一剥离 CLIXML 噪声(extractPsError),并带上 exit code 与 stdout —— 本轮就是靠它
  才看到真实报错(此前只有「启动失败(code=1)」)
- net confirm 顺带清理已执行完的 netapply 任务,保持任务计划程序干净

真机验证(全部通过):
- apply(command) → 返回 apply_scheduled + 两个任务名 ✓
- apply 脚本执行 → 日志出现 "apply ok" ✓
- 不确认 → 自动回滚 → 日志 "restored: static" ✓(事故路径已修复)且网络完好 ✓
- confirm → 任务被取消(netrollback 计数 0)✓
- net status 剩余秒数正确 ✓
2026-10-01 20:58:19 +08:00
jkooe 9ef8978b92 feat(scripts): 网络救援脚本 rescue-network.ps1(网络变更失联后的本地恢复)
## 为什么需要它
`system.net.apply` 是两阶段提交(未确认自动回滚),但**万一回滚本身也失败**
(本次真机事故就是:变更脚本把地址删了却没加上),机器会落到 APIPA(169.254.x.x) 而彻底失联。
那时远程已无路可走,只能本地恢复 —— 这个脚本就是为那一天准备的。

## 能力
- `-ListOnly`:先看清各网卡状态/地址/默认路由(含「已连接但无地址」的失联特征标注)
- 自动识别失联网卡(已连接但无有效 IPv4),也可 -Interface 指定
- 带 -Ip/-PrefixLength/-Gateway/-Dns 恢复静态;不带 -Ip 则切回 DHCP
- **安全顺序**(与 agent 内实现一致):先加新地址 → 清旧地址与残留默认路由 → 补默认路由
- 自检并给出下一步命令(含可直接复制的 Mac 侧 connect 命令)

## 用法
    powershell -ExecutionPolicy Bypass -File rescue-network.ps1 -ListOnly
    powershell -ExecutionPolicy Bypass -File rescue-network.ps1 -Interface 'Ethernet' `
        -Ip 192.168.1.100 -PrefixLength 24 -Gateway 192.168.0.1
    powershell -ExecutionPolicy Bypass -File rescue-network.ps1 -Interface 'Ethernet'   # 切回 DHCP
2026-09-30 11:10:23 +08:00
jkooe e955b6a2f7 test(v16): 网络脚本回归断言 8 条(把事故根因变成防线)
针对本次真机事故(机器掉 APIPA 失联)逐条设防:
- apply 必须先 New-NetIPAddress 再 Remove-NetIPAddress(顺序颠倒 → already exists → 无地址)
- 建默认路由前必须先 Remove-NetRoute(否则 New-NetRoute 报实例已存在)
- 断言脚本里**不得出现** netsh set address / set dnsservers(Win11 上会挂起)
- 自检片段必须校验目标地址并在失败时执行回滚(自愈)
- 回滚脚本同样受「先加后清 / 先清路由」约束,且必须恢复原地址/网关/DNS
- DHCP 快照的回滚走 -Dhcp Enabled,不得写静态地址
- 掩码→前缀换算边界(含非法输入回退)

单测 116 -> 124
2026-09-30 10:20:02 +08:00
jkooe 6afec163f3 feat(v16): 网络变更两阶段提交(commit-confirm)—— 解决审查报告 §5.4「自断生路」
## 能力(39 项)
- system.net.apply   备份 → 快照 → 武装回滚任务 → **延时执行变更** → 自检失败即自愈
- system.net.confirm 取消回滚任务 = 提交(可指定 task_name 或取消全部)
- system.net.status  网络现状 + 待确认变更(含剩余秒数)+ 历史备份
- 出口:CLI `nodeagent net [status|apply|confirm]` + MCP na_net_apply/confirm/status
- 审计新增 net.change 事件类型(含 mode/接口/时间点/备份路径/快照)

## 设计要点
1. **OS 级回滚**:用 S4U 计划任务触发回滚(注销/重启/agent 崩溃都照样回滚),
   不依赖 agent 进程存活 —— 这是与「先删后加」类操作并存的前提
2. **先武装后开火**:回滚任务注册成功才执行变更;注册失败直接中止(宁可不改)
3. **延时执行 + 先应答**:改地址必然切断控制端连接,同步执行调用方永远收不到返回
   (真机实测 200s 超时,而变更其实已生效)。改为排程 3s 后执行,先交出凭据
4. **结构化快照**:不用 `netsh -f 备份` 回滚(备份里是 `set address`,该命令在 Win11 会挂起)
   —— 按 JSON 快照用原生 cmdlet 重建

## 真机踩坑(含一次真实事故,全部已修 + 注释固化)
- **netsh set address 在 Win11 挂起**(>60s 无响应),原生 Set/New-NetIPAddress <1s
- **netsh dump 经 PowerShell 重定向会被转码破坏** → 备份不可用(改走 cmd 原始重定向)
- **嵌套 -Command "..." 时内层 $_ 被外层展开** → 静默返回空(统一改 -EncodedCommand)
- **杀手机制**:先 Remove 旧地址再 New 新地址 → Windows 残留默认路由使 New-NetIPAddress
  报 already exists → 新地址没加上 → **机器回落到 APIPA 彻底失联(真机事故)**。
  修法:① 先加新地址再清旧地址(过渡期链路不断)② 先 Remove-NetRoute 再建地址
  ③ 变更后自检,未生效则立即执行回滚脚本自愈
- 单网卡机器 ConvertTo-Json 输出对象而非数组 → 解析要兼容(否则网卡列表永远为空)
- 任务名应编码「回滚时间」而非创建时间(否则剩余秒数算不对)
2026-09-30 10:19:20 +08:00
jkooe 553b16f595 docs: 口径更新到 v15(图像模板匹配 / 助手提速)+ 三条腿定位说明 2026-09-30 01:27:55 +08:00
jkooe 8ee8dc7f41 feat(v15): screen.find 图像模板匹配(补齐自绘 UI 盲区)+ 一串真机根因修复
## 新能力:method=image 图像模板匹配
审查报告 §5.2 指出「screen.find(image) 图像模板匹配未做,纯图标/无文字的控件是定位盲区」。
UIA 靠控件树、OCR 靠文字,两者对纯图标都无能为力;本版补上第三条腿。

实现(随常驻助手预加载,C# + System.Drawing,零新依赖):
- 灰度化(LockBits + ITU-R BT.601 整数近似)
- 零均值归一化互相关 ZNCC(对亮度/对比度变化不敏感)
- **粗到精**:1/4 分辨率全图扫描 → 候选在全分辨率 ±8px 精修
  (全分辨率逐像素在 3440x1440 上约 190 亿次,不可行)
- 非极大值抑制去重;返回中心坐标 + score,可直接喂 input.mouse.click
- 参数:template(被控端图片路径)、threshold(默认 0.85);支持 region 限定搜索区
- 出口:screen.find(method=image) + CLI/MCP 同步暴露

真机实测:3440x1440 全屏搜索 **~580ms**,score 0.9745 命中可用坐标;
对「模板纯色/低对比」会显式报错(E_PARAM_INVALID + variance),而不是静默返回空。

## 本轮为打通它而修掉的 7 个真机根因(都值得记档)
1. **预加载段拼接静默失败**:WIN_HELPER_PRELUDE 是多行模板串,替换串写成单行 \n 不匹配且
   无 assert → OCR/图像两段从未拼入 → 被 esbuild tree-shake → 运行时才报「找不到类型」。
   现已导出并加回归断言(四段齐全 + 顺序)。
2. **助手启动超命令行上限**:bootstrap 含内嵌 C#(编码后 ~36KB)超过 Windows ~32KB 限制,
   spawn 直接 ENAMETOOLONG → 每次静默降级到慢路径。改为落地临时脚本 + -File。
3. **一次性降级路径同样超限**:ENAMETOOLONG。同样改为临时脚本 + -File(UTF-8 BOM)。
4. **统计记账掩盖根因**:ensureStarted 在 try 之外,spawn 失败不计入 failures,
   表现为「spawned 一直涨、calls 恒为 0」。现已纳入 try,并暴露 ps_helper 状态
   (enabled/calls/hits/failures/spawned/avg_ms/lastError)到 system.info。
5. **Add-Type 在中文系统上乱码**:C# 源码按系统 ANSI(GBK) 落盘,中文注释必乱码 → 编译失败。
   已把内嵌 C# 全部改为纯 ASCII,并加断言「C# 块不得含非 ASCII」。
6. **Add-Type 缺 -ReferencedAssemblies**:只 Add-Type -AssemblyName System.Drawing 不够,
   编译期仍报「命名空间 System.Drawing 中不存在 Imaging」。已补 -ReferencedAssemblies。
7. **TS 模板串吃掉 \" 转义**:在模板字符串里写 \" 会得到裸引号 → C# 编译失败(踩了两次)。
   统一改用 C# 字符常量 Q 拼 JSON,并加断言(不得残留裸引号 JSON 形态 / Q 不得重复声明)。
另外把「粗层预筛」从 threshold-0.05 放宽到 threshold-0.30 并始终保留最优候选 ——
降采样会拉低真位置的分数,过严的预筛会让正确答案在精修前就被丢弃(这曾让所有匹配返回空)。

## 单测 105 -> 114
新增 win-prelude 回归 9 例:四段齐全与顺序 / C# 纯 ASCII / 无裸引号 JSON / Q 不重复声明 /
Win32+UIA+OCR+图像 各段关键标记(含 -ReferencedAssemblies)。
这些断言的价值在真机验证过:3 个 bug 都是先被断言抓住、才避免了再次上线静默坏掉。
2026-09-30 01:27:37 +08:00
jkooe 92df08eb20 test(v7~v13): 新能力补齐 e2e 覆盖(21 -> 26 用例)—— 审查报告 §5.1-3
## 背景
报告指出「v7/v8 新能力零自动化测试,只有提交说明里的真机手验」。此前 e2e 覆盖止于 v6。

## 新增 5 个用例(tests/e2e/run.mjs)
1. **v8 GUI 语义契约** —— 不断言「屏幕上一定有什么」(CI 无桌面会话时不可靠),
   而断言对外契约:window.list 结构 + 前台窗口排最前(否则会被 limit 截掉);
   window.focus 目标不存在时是干净错误;screen.find 无命中返回空数组 + engine + waited_ms;
   参数越界必须被拒
2. **v10 剪贴板与后台任务** —— clip.set/get 往返;异步 exec → task_id →
   续读输出 → 任务列表 → 终止长任务并断言 state=killed / exit_code=124
3. **v12 事件订阅** —— 文件监控:制造变动后**同时验证两条通路**
   (onEvent 推送 + event.poll 增量游标),并验证 unwatch 生效
4. **v12 GUI 宏引擎** —— 不依赖 GUI 的步骤组合(exec+clip+assert+sleep)回放;
   断言失败必须中止并给出 failed_at 定位;optional 失败不中断
5. **v13 成功指标与审计链** —— 指标字段齐全、分层时延存在、拦截率恒 1、
   无样本时装软件成功率为 null(而非 0);audit.verify 链完整

## 写用例时踩到并固化的契约(重要,已写入测试注释)
**协议层错误会抛异常,能力层失败才返回 {status:'failed'}**:
  - 能力层失败(输入未开启 / 窗口未找到)→ 返回 { status:'failed', error:{name} }
  - 协议层失败(参数校验 / 鉴权 / ACL / 限速)→ invoke() **抛 ClientError**(err.name = 错误码)
新增 expectProtocolError() 辅助函数统一断言后者,避免后续再写错。
另修正:任务终态是 `done`(不是 exited),运行中为 `running`。

单测 105/105、e2e 26/26 全绿
2026-09-30 00:56:49 +08:00
jkooe 0dcf01ad4e perf(v14): PowerShell 常驻助手 —— GUI 能力提速 4~25 倍(审查报告 §5.5 P1)
## 问题(审查报告 5.5 指出,真机实测确认)
window.list / window.focus / screen.find 每次调用都起一个 powershell.exe 现编译 C#
(Add-Type @"..."@)+ 重新加载 UIA 程序集。实测 agent 侧中位:
  window.list 1267ms · window.focus 1631ms · screen.find 3140~3736ms
固定成本(起进程 + 编译类型)与「本次要做的活」无关,纯浪费。

## 方案(apps/agent/src/util/ps-helper.ts)
常驻 PowerShell 进程:启动时把 prelude(Win32 C# 类型 + UIA 程序集 + WinRT/OCR 段)
**只加载一次**,之后每请求只发「本次逻辑」(base64 一行),进程内直接执行回传。
- 协议:stdin 收 base64 脚本行;stdout 回 `@@R <b64>` / `@@E <b64>`;stderr 只走就绪信号
- 请求 FIFO 串行(单进程单 stdin),逐请求超时
- **可用性不倒退**:助手不可用/超时/进程退出 → 自动回退到原一次性脚本路径;
  连续失败 3 次熔断,本进程不再重试(避免每次白等一次超时)
- 自愈:进程退出后在下次请求重建;空闲 10 分钟自动回收;agent 退出统一回收
- 预加载段带 sha256 指纹:**prelude 变化自动换新助手**(否则会用到旧进程里已加载的定义)
- 逃生开关:环境变量 NODEAGENT_NO_PS_HELPER=1 可强制走一次性路径

## 关键工程细节:exit
脚本体会被送进常驻进程执行,**其中的 exit 会杀掉助手进程**。
已将 focusOnce 的 `exit 0`、UIA 的 `exit 0`、OCR 的 2 处 `exit 0` 全部改为 return。

## 顺带优化 UIA 扫描策略(本轮第二个瓶颈)
UIA 只能「FindAll(Descendants) + 子串过滤」(PropertyCondition 是精确匹配)。
原来**每次都遍历整棵桌面 UI 树**。改为:
  指定 window → 该窗口子树;否则**先在前台窗口子树内找**,未命中再退回全桌面兜底。
遍历逻辑收敛为预加载函数 Find-UiaByText,两处复用不再重复。

## 真机实测(agent 侧耗时,同一台机器同一会话)
| 能力 | 改造前 | 改造后 |
|---|---|---|
| window.list  | 1267 / 1216 / 1260 / 980 ms | **52 / 52 / 52 / 73 / 42 ms**(约 25x) |
| window.focus | 1576 / 1568 / 1600 ms        | **411~443 ms**(约 4x;余量为刻意的等待与激活校验) |
| screen.find  | 3140 / 3736 / 1302 / 1393 ms | **19 / 26 / 72 / 103 / 176 ms**(约 20x,前台命中路径) |
端到端(CLI 视角)window.list 由 1.34s → **0.16s**。
另注:全桌面无命中路径的耗时随 UI 树规模变化,非固定值。

## 单测
101 -> 105(新增助手协议 4 例:UTF-16LE 编码可逆 / bootstrap 含 prelude+READY+循环+exit 协议
且必须 try-catch 兜异常 / 协议行解析(含 Add-Type 噪声必须忽略)/ 中文多行往返无损)
2026-09-30 00:51:56 +08:00
jkooe 87d7f3fbcc docs: 口径更新到 v13(36 能力 / 32 MCP 工具 / 101 单测)+ 指标命令 2026-09-30 00:23:16 +08:00
jkooe 8743bd1237 feat(v13): 成功指标聚合(对齐 PRD 2.2 四项验收指标)+ 依据真实数据修正口径
## 背景(审查报告 P0-2)
审查报告指出:PRD 2.2 的成功指标「无任何采集与统计落点,指标空转」,并给出
已验证原型(30 行脚本在 598 条真实审计日志上算出三项指标)。本版将其产品化。

## 实现(apps/agent/src/metrics.ts,纯函数 + 可单测)
数据源就是审计日志,无需额外埋点:
- 闭环成功率 = invoke ok / 总数
- 装软件成功率 = app.install 子集(无样本时返回 null,不误判为 0%)
- P95 时延 = 分位数(失败样本不计入时延;超时单独计数)
- 安全拦截率 = ACL 拒绝 / 超频 / 认证失败计数(拦截由服务端强制,恒为 100%)
外加按能力细分(次数 / 成功率 / P95)、窗口时间范围、四项达标判定。

出口:能力 system.metrics(36 项)+ CLI `nodeagent metrics [--since ms]` + MCP `na_metrics`。
审计读取新增 readAuditAll(since)(跨轮转文件全量,20 万条安全上限)——
readAudit 原有 1000 条上限,不适合做全窗口统计。

## 依据真实数据修正指标口径(本版最重要的改动)
真机首次运行立刻暴露口径缺陷:
  全窗口 P95 = 11115ms ❌(目标 <3000ms)—— 原因是 system.shell.exec P95 27.5s
  近 6 小时窗口 P95 = 9575ms ❌ —— 8 个样本里仅 1 次「故意重启」9.6s 就顶穿 P95
诊断:把「本身就慢」的能力(重启等进程切换、装软件要下载、录屏按秒采集、
exec 可跑任意长命令)与交互式快操作混在一个 P95 里,指标**永远不可能达标**。

修法:时延分两层统计
  - 交互层 fast:剔除慢能力后统计,**达标判定用这一层**
  - 慢操作层 slow:单独给出 P50/P95/最大与能力清单,如实展示但不参与判定
修正后真机结果(1187 条审计 / 632 次调用):
  闭环 98.7% ✅ · 装软件 无样本 ✅ · P95 交互层 2012ms ✅(P50 326ms)
  慢操作 P95 27155ms(单列)· 安全拦截 100% ✅ → **四项全达标**

## 单测
89 -> 101(新增指标 12 例:空样本不误判 / 成功率边界 90% / P95 分位 /
分层口径(慢能力不拖累、真机场景复现)/ 超时单列 / 自定义阈值 / 窗口 / 摘要格式)
2026-09-30 00:19:10 +08:00
jkooe 0f8b878de0 docs: 路线图补 v12.4(事件直达 MCP)+ 验证脚本说明 2026-09-29 15:47:49 +08:00
jkooe 4d1aeada07 feat(v12.4): 事件直达 MCP 宿主(实时推送)+ 修两个真 bug
## 背景
v12 的事件订阅对 MCP 只走「被控端缓冲 + na_event_poll 拉取」。MCP 协议本身支持
服务端 -> 客户端通知,故补上实时推送:na_event_watch 的 notify=true 开启,
事件经 MCP 标准日志通知(notifications/message)即时送达宿主。

## 两条通路并存(互相兜底)
1) 推送:onEvent -> server.notification(notifications/message, logger=nodeagent.event)
   —— 宿主若不展示日志通知,则自动退化为拉取,无副作用
2) 拉取:na_event_poll(增量游标)
na_event_list 额外返回推送开关与已转发条数,便于判断该用哪条通路。

## 真机修掉的两个 bug
1. **notify 参数泄漏到能力侧**:notify 是 MCP 层开关,原样透传被被控端
   additionalProperties:false 拒绝(E_PARAM_INVALID)。改为在 toArgs 里显式构造,
   只带能力真正接受的字段。
2. **通知未声明 logging 能力导致 MCP 进程崩溃**:SDK 的 notification() 在未声明
   logging 能力时抛错,且因返回 Promise 未 catch -> 未处理拒绝直接终止进程
   (真机实测:watch 成功后一有事件,整个 MCP 服务就退出)。
   修法:capabilities 声明 logging + 通知调用补 catch(失败静默降级为拉取)。

## 端到端验证(scripts/verify-mcp-events.mjs,已收进仓库)
真实 MCP stdio 客户端驱动,对生产被控端实测:
- initialize ok;tools/list 31 个工具、事件类 4 个
- na_event_watch(notify=true) -> watch_id
- 制造 创建/修改/删除 -> **收到 3 条推送通知**(file created_or_removed / modified / ...)
- na_event_poll 返回缓冲事件(seq/watch_id/ts/action/target)
- na_event_list 显示「推送已开启(已转发 3 条)」
结论:推送 ✅ 生效;拉取 ✅ 生效

单测 89/89、e2e 21/21 全绿
2026-09-29 15:47:34 +08:00
jkooe 82a44bb13f docs: 路线图补 v12.1~v12.3(Hub 授权 / 等待语义 / macOS GUI 对齐) 2026-09-29 14:56:35 +08:00
jkooe d56556d3c3 feat(v12.3): macOS 端 GUI 能力对齐 —— window.list / window.focus / screen.find
## 动机
此前 window.* 与 screen.find 只在 Windows 可用(UNSUPPORTED_PLATFORM)。本版让
macOS 也能作为被控端被 GUI 操作,兑现「Mac 与 Windows 双向接管」。

## 实现(apps/agent/src/capabilities/darwin.ts)
| 能力        | Windows                 | macOS                                     |
|-------------|-------------------------|-------------------------------------------|
| window.list | Win32 EnumWindows       | AppleScript System Events(需辅助功能权限) |
| window.focus| SetForegroundWindow+兜底 | AppleScript frontmost + AXRaise           |
| screen.find | UIA → OCR(WinRT)        | 无 UIA 等价物 → 截图 + **Vision OCR**      |

要点:
- AppleScript 经 stdin 传入(base64 解码),彻底规避引号/换行转义
- Vision OCR 用 Swift 源码 + `swiftc -O` 按需编译,二进制缓存在数据目录 bin/naocr;
  源码变化时自动重编(首编约 15s,之后毫秒级)
- 权限错误翻译成可执行指引:实测 -10004「发生权限违例」→ 提示去「辅助功能」授权;
  -1743 → 「自动化」授权

## 真机踩坑(本轮最重要的一条)
**Retina 屏上 screencapture 产出 2x 像素图**,而窗口坐标为逻辑点 —— 直接返回会让
点击偏移一倍。修法:解析 PNG 的 IHDR 取像素尺寸,除以请求区域点数得到缩放比,
换算后再叠加区域偏移(返回体注明已换算)。

## 顺带修掉一个参数缺口
screen.find 的实现读 region 但 schema 未声明 → 带 region 的调用被参数校验拒绝
(E_PARAM_INVALID)。已补 region 到 manifest,并让 Windows 的 OCR 路径同样支持
「显式区域优先 > 指定窗口 > 整屏」(区域越小 OCR 越快越准)。

## 真机验证(本机 macOS 27.2,M3 Max)
- window.list:AppleScript 生效,但本机未授予辅助功能权限 -> 返回可执行提示(预期路径)
- screen.capture:984KB 有效 JPEG(3024x1964)
- Vision OCR 独立验证:对含中文/英文的真实截图识别出「WorkBuddy 编辑 查看 窗口 帮助」等
- screen.find(全屏):命中 3 处,坐标稳定
- screen.find(区域 0,0,1600,120):命中且坐标全部落在区域内 —— 证明 Retina 换算正确
- 等待语义(wait_ms)在 macOS 路径同样生效

单测 89/89、e2e 21/21 全绿
2026-09-29 14:55:13 +08:00
jkooe a892b113ca feat(v12.2): GUI 等待/重试语义 —— 结束「点下去就不管」的假失败
## 动机
GUI 自动化最常见的失败不是「找不到」,而是「还没出来」:应用启动、页面切换、动画
都有延迟,一次性查找必然假失败。之前调用方只能自己在外面 sleep+重试(浪费往返)。

## 实现(agent 侧内联轮询,避免多次往返)
- screen.find 新增 wait_ms(0~30000,默认 0)与 interval_ms(100~2000,默认 400):
  轮询到命中或超时;无论成败都返回 waited_ms,让调用方知道是否命中等待窗口
- window.focus 新增 wait_ms:窗口未出现时每 500ms 重试,直到超时(仅 title 模式)
- 宏步骤 find/assert 透传 wait_ms/interval_ms(写宏时可直接声明等待)
- MCP na_screen_find 暴露 wait_ms

## 真机验证(决定性)
调用 window.focus(title=Notepad, wait_ms=15000) 时**记事本尚未运行**;
4 秒后才启动 -> 等待轮询捕获并成功聚焦:
  { title: "*install.ps1 - Notepad", focused: true, activated_by: "click" }
注意 activated_by=click:本次 SetForegroundWindow 又被系统静默否决,
点击兜底自动接管 —— 两个加固协同生效(等待 + 激活兜底)。

单测 89/89、e2e 21/21 全绿
2026-09-29 14:43:27 +08:00
jkooe cd774ae0a6 feat(E3c): Hub 侧按控制端授权(多令牌 + 按设备白/黑名单 + 注册准入)
## 背景
Hub 原先只有**一个**令牌:任何持有者都能接入任意设备,无法区分「谁在控制哪台机器」。
E3 的权限隔离因此只做到能力级(被控端 ACL),Hub 侧仍是全通。

## 实现(apps/hub/src/authz.ts,纯函数便于单测)
- 多令牌:每个令牌 = 一个控制端身份,可独立指定 allow_nodes / deny_nodes(glob)
- 判定顺序 deny -> allow -> 默认拒绝;未配置 allow_nodes 时默认放行(兼容)
- 顶层 token 视为主令牌(放行全部),旧配置零改动
- 前缀式滚动令牌:value 以 * 结尾时按前缀匹配(轮换期间可灰度)
- 注册准入:node_allowlist 非空时只有匹配的 node_id 能注册,防止他人借令牌挂靠节点
- 列表按身份过滤:控制端只看得到自己被授权的设备
- 越权接入返回新错误码 E_NODE_FORBIDDEN

## 职责边界(重要)
Hub 只做「能否连到某设备」的接入过滤;连上之后的**能力级**授权仍由被控端 ACL
按 client_id 裁决。两层各自独立,Hub 不参与能力决策 —— 保持 Hub 不可信假设。

## 测试
- 单测 79 -> 89(新增 Hub 授权 10 例:主令牌兼容 / 未知令牌 / 前缀轮换 /
  deny 优先 / glob 白名单 / 默认放行 / 注册准入 / 旧配置兼容 / 列表过滤 / glob 转义)
- e2e:Hub 用例新增受限令牌访问未授权设备 -> 断言 E_NODE_FORBIDDEN 且错误信息含授权原因
2026-09-29 14:38:53 +08:00
jkooe 66c44f6822 feat(E3): Hub 并发多控制端 + 设备分组(阶段 E 全部完成)
## E3a 并发多控制端(Hub 槽位池)
过去:一个被控端只有一条 Hub 连接 -> 同时只能一个控制端(否则 E_NODE_BUSY)。
现在:
- Hub 侧 agents 表改为「node_id -> 槽位池」,每个槽位服务一个控制端:
  有空闲槽位直接配对;不足且未达上限则向被控端请求扩容(need_slot,3s 超时);
  仍不满足才返回 E_NODE_BUSY。新增配置 max_slots_per_node(默认 3,设 1 即退回独占语义)
- 被控端侧 hub-mode 重写为槽位池:常备 2 条连接(warm_slots 可调),
  并在某槽位被占用(收到 hello)时预热下一条直到 max_slots
  —— 复用既有协议信号,不新增控制消息;每条槽位独立重连与退避
- Hub 列表视图聚合为「一节点一行」并带 slots / paired_slots
- e2e 新增并发用例:两个控制端同时握手 + 交替并发调用,均成功(旧版会失败)

## E3b 设备分组
- 客户端配置新增 groups(组名 -> 设备名数组;成员可写 @其它组名,最多展开 3 层防环)
- CLI:nodeagent group add|remove|list;fanout --nodes @组名 按组并发下发
- 真机验证:group add 本机 win -> fanout system.info --nodes @本机 正常

## E3c 权限隔离(部分完成)
- 能力级隔离沿用被控端 ACL(按 client_id,已含 allow/deny/glob/生效时段/IP 白黑名单/按能力限速)
- Hub 侧「按设备/按控制端授权」尚未实现(当前 Hub 视角为单一令牌),已记入路线图待办

## 测试
- 单测 73 -> 79(新增设备分组 6 例:展开 / 嵌套 / 去重 / 未知 / 兼容旧配置 / 环引用收敛)
- e2e 21/21(含扩展后的 Hub 并发双控制端用例)
2026-09-29 14:33:01 +08:00
jkooe 3ed117770b feat(E4): GUI 宏(步骤序列回放)+ 窗口聚焦加固(能力 35 稳定)
## E4 宏引擎(packages/client/src/macro.ts,CLI 与 MCP 共用)
- 步骤类型:focus / find / click / type / key / drag / sleep / exec / clip / assert / capture
- 通用能力:retry(失败重试)、interval_ms、optional|continue_on_error、${VAR} 与 ${VAR:-默认值} 替换
- 断言内置在步骤里:exec 的 expect_exit/expect_stdout、clip 的 expect、
  assert 支持 kind=screen|window|clip(屏幕断言自动走 UIA→OCR)
- 返回逐步结果(含 ms 与失败定位 failed_at),失败即中止并给出可读原因
- CLI: macro run <file> [--var k=v] [--out 目录] / macro validate / macro init(生成示例)
- MCP: na_macro_run(inline steps + vars)

## 真机验证(记事本全流程)
宏 init 生成示例 -> 启动记事本 -> 回放 9 步:
focus ✓ / key ctrl+a ✓ / delete ✓ / type ✓ / sleep ✓ / key ctrl+a ✓ / key ctrl+c ✓ /
clip 断言「宏回放演示 回放测试」✓ / window 断言 ✓ -> 全部通过(10.1s)

## 顺带修复两个真机踩出的问题
1. **window.focus 可能被静默否决**:SetForegroundWindow 受 Windows 前台锁定限制,
   调用后前台窗口可能仍是别人(实测被系统防火墙对话框抢焦点,导致宏的输入全进了对话框)。
   现改为:调用后**校验当前前台窗口**;未生效则在该窗口标题栏做一次真实点击兜底,
   并返回 activated_by=api|click 让调用方知情。
2. **window.list 会被 limit 截掉真正的前台窗口**:现按 is_foreground 排序置顶并返回 total。
3. 宏引擎:变量缺失原先会在 try 之外抛出、导致整个宏崩掉;现改为「记为该步失败」,
   optional 时继续执行(单测覆盖)。

单测 63 -> 73(新增宏引擎 10 个用例:变量/默认值/缺失、retry、optional、失败中止、
expect_exit/stdout、clip 断言、未知步骤、find→click 坐标透传、坏文件拒绝);e2e 21/21
2026-09-29 14:25:35 +08:00
jkooe 0a47c5a92d feat(E1+E2): 事件订阅与监控 —— agent 主动推送(能力 31 -> 35)
## 协议
- 新增 Methods.Event = 'event':协议中唯一的**无 id 通知**(JSON-RPC notification),
  被控端 → 控制端主动推送,控制端不回复
- 4 项能力:event.watch / event.unwatch / event.list / event.poll
- 能力处理器签名扩展为 (args, ctx?):ctx 携带 owner(连接标识)与 emit(推送出口)

## 被控端(apps/agent/src/events.ts)
三类 watcher:
- file    —— fs.watch(支持 recursive + glob 过滤),报 created_or_removed / modified
- process —— 轮询 tasklist(win)/ps(posix) 做差分,报 process_started / process_exited
- net     —— 轮询 netstat 提取 LISTEN 端口做差分,报 port_opened / port_closed
两条出口并存:
1) **推送**:通过 event 通知实时发给创建订阅的连接
2) **环形缓冲 + 游标**:500 条上限、seq 游标增量拉取(适配无法收推送的 MCP)
其它:首次采样只建基线(避免开订阅就刷出几百条);连接断开自动清理该连接的订阅(防泄漏)

## 控制端
- NodeAgentClient 新增 onEvent 回调;handleMessage 把无 id 的 event 通知分发过去
- CLI: nodeagent events [--kind file|process|net] [--path/--pattern/--recursive/--seconds]
       无 --kind 则列出当前订阅
- MCP: na_event_watch / na_event_poll / na_event_list / na_event_unwatch

## 真机验证(三类全部实测)
- 文件:创建/修改/删除 t1.txt -> 3 个事件实时推送(14:16:33/34/35)
- 进程:启动并关闭 Notepad.exe -> process_started / process_exited
- 端口:开-关 8899 -> port_opened / port_closed(基线 45 个监听端口)
全部**零轮询**到达控制端(由被控端主动推)。

单测 61 -> 63(补 event.watch 枚举/边界与 event.poll 游标校验)、e2e 21/21 全绿
2026-09-29 14:19:09 +08:00
jkooe 7efe717cf8 fix(gui): window.list 进程名错乱 + screen.find UIA 只支持精确匹配
## bug 1:所有窗口的进程名都报成 powershell
根因:C# 回调里用 $pid 接 GetWindowThreadProcessId 的输出 —— 但 $pid 是 PowerShell
**自动变量**(当前进程 ID),赋值被静默忽略,于是每个窗口都取到 powershell 的 PID。
改为 $procId。真机验证:cmd / hexin(同花顺) / SearchPictureTool / TextInputHost / explorer
均正确归属。

## bug 2:UIA 路径实际是精确匹配,与文档「子串匹配」不符
根因:用 PropertyCondition(NameProperty, text, IgnoreCase) —— IgnoreCase 只影响大小写,
仍是精确相等。实测「管理员: C:\WINDOWS\system32\cmd.exe」用 '管理员' 永远匹配不到。
改为:FindAll(Descendants, [控制类型条件]) 后按 IndexOf(OrdinalIgnoreCase) 子串过滤,
并加 20000 元素扫描上限防止超大 UI 树卡住。
真机验证:
  '管理员' -> 命中 '管理员: C:\WINDOWS\system32\cmd.exe' @ (548,312)
  '同花顺' -> 命中窗口标题 + 内部文本元素

## 顺带明确一个语义(写入文档)
is_foreground = 「持有键盘焦点」,不等于「视觉上在最上层」。
实测 cmd 持有焦点(is_foreground=true)但画面被全屏的「同花顺」覆盖 ——
此时按 UIA 返回的坐标点击会打到覆盖它的窗口上。
因此:**几何信息可信,视觉遮挡需另行确认**(点击前截屏复核)。

单测 61/61、e2e 21/21 全绿
2026-09-29 13:30:23 +08:00
247 changed files with 49964 additions and 3332 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 }}
+120
View File
@@ -0,0 +1,120 @@
name: Rust Shell
# 2026-10:原 crates/nodeagent-client(Rust 版协议客户端)已退役 ——
# 协议实现改由 Node sidecar 承担(apps/console/sidecar/),避免两套实现静默漂移。
# 本工作流随之改为验证 **console 的 Rust 壳**(sidecar 管理 + command 转发)。
#
# 它的单测是**真跑** Node sidecar 子进程的(见 apps/console/src-tauri/src/sidecar.rs),
# 不依赖 Tauri GUI,因此能在 Linux 快车道上跑。
on:
push:
branches: [main]
paths:
- 'apps/console/src-tauri/**'
- 'apps/console/sidecar/**'
- 'scripts/prepare-console.mjs'
- 'scripts/lib/bundle-agent.mjs'
- 'apps/agent/**'
- '.github/workflows/rust.yml'
pull_request:
paths:
- 'apps/console/src-tauri/**'
- 'apps/console/sidecar/**'
- 'scripts/prepare-console.mjs'
- 'scripts/lib/bundle-agent.mjs'
- 'apps/agent/**'
- '.github/workflows/rust.yml'
workflow_dispatch:
env:
CARGO_TERM_COLOR: always
jobs:
test:
name: Rust 壳单测(真跑 sidecar)
runs-on: ubuntu-latest
steps:
- name: 检出代码
uses: actions/checkout@v5
# 版本**锁死**而不跟 stable 漂 —— 否则「本地全绿」与「CI 绿」会因 lint 集合随工具链
# 变化而分叉(本仓库已因同类原因吃过 8 轮 Windows CI 的苦头)。
- name: 安装 Rust 工具链(含 clippy)
uses: dtolnay/rust-toolchain@master
with:
toolchain: stable
components: clippy
# Tauri 壳在 Linux 上编译需要这些系统库(webkit2gtk 等)。sidecar 单测本身
# 不需要 GUI,但整个 crate 要能编过。
- name: 安装 Tauri 系统依赖
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev \
librsvg2-dev patchelf libgtk-3-dev
- name: 安装 Node(sidecar 单测要真跑它)
uses: actions/setup-node@v4
with:
node-version: '22'
- name: 安装 pnpm 与依赖(sidecar 需 @nodeagent/client)
uses: pnpm/action-setup@v4
with:
version: 9
- run: pnpm install --frozen-lockfile
# ⚠️ 顺序有依赖:prepare:node 现在**还要 esbuild 打包被控端**(控制台随包分发
# agent.mjs,用于托起本机被控端)。故 @nodeagent/agent 的 dist 必须先构建出来,
# 否则 prepare 阶段会以「未找到被控端构建产物」失败。
# (本项目已因「新步骤漏排前置产物」在 Windows CI 上红过一次,此处显式写全。)
- name: 构建 workspace 包(sidecar 与 agent 的 dist 都是前置产物)
run: pnpm --filter "./packages/*" --filter "@nodeagent/agent" build
# ⚠️ tauri-build 在**编译期**校验 tauri.conf.json 里 resources 的文件存在性,
# 故必须先产出 sidecar.mjs,否则 clippy/test 会在 build.rs 阶段就失败。
- name: 构建 sidecar(resources 引用的产物)
run: pnpm --filter nodeagent-console sidecar:build
# 随包分发的 Node 运行时 + 被控端 agent.mjs 由该脚本准备;它同时产出
# 「构建期注入片段」。在此真跑一遍:既验证脚本本身可用(含两份产物自检:
# node 必须真能执行、agent.mjs 必须含被控端标识),也验证注入片段声明就绪。
# 注意 Linux 上不会走 strip 分支(那是 macOS 专属,见脚本内注释),
# 故「strip 后必须 adhoc 重签」这条由 macOS 本机实测覆盖,CI 不重复验证。
- name: 准备 Node 运行时 + 被控端(验证 prepare-console.mjs)
run: pnpm --filter nodeagent-console prepare:node
- name: 注入片段应声明三份资源(node + sidecar + agent)
run: |
node -e '
const g = require("./apps/console/src-tauri/tauri.bundle.gen.json");
const r = g.bundle.resources;
const keys = Object.keys(r);
if (keys.length !== 3) { console.error("资源项应为 3,实际", keys); process.exit(1); }
const vals = Object.values(r);
for (const need of ["sidecar.mjs", "agent.mjs"]) {
if (!vals.includes(need)) { console.error("缺少资源", need, vals); process.exit(1); }
}
if (!vals.includes("node") && !vals.includes("node.exe")) {
console.error("缺少 node 运行时", vals); process.exit(1);
}
console.log("✓", JSON.stringify(r));
'
# 更强的一道:不是「文件在不在」,而是「产物能不能自举」——
# 真 spawn 打包内的 node 跑 sidecar 并走一次 JSON-RPC 握手,
# 并用打包内的 node 解析一次 agent.mjs(截断/损坏会在此暴露)。
# 这类问题只在产物中暴露(源码与单测都正常)。
- name: 验证运行时 + sidecar + agent 可自举
run: node apps/console/scripts/verify-bundle.mjs --runtime
- name: 缓存 cargo 构建产物
uses: Swatinem/rust-cache@v2
with:
workspaces: apps/console/src-tauri
- name: Clippy(告警即失败)
run: cargo clippy --manifest-path apps/console/src-tauri/Cargo.toml --all-targets -- -D warnings
- name: 单测(真跑 Node sidecar 进程)
run: cargo test --manifest-path apps/console/src-tauri/Cargo.toml
+109 -9
View File
@@ -27,7 +27,7 @@ jobs:
steps:
- name: 检出代码
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: 安装 pnpm
uses: pnpm/action-setup@v4
@@ -49,31 +49,129 @@ jobs:
- name: 类型检查
run: pnpm typecheck
# 控制台「随包分发的 Node 运行时 + 被控端 agent.mjs」在 Windows 上的分支
# 此前零覆盖(准备脚本本机只在 macOS 上跑过)。此处真跑:复制 node.exe(无 strip
# 分支)→ 自检可执行 → 再验证它与 sidecar 能否走通一次 JSON-RPC 自举,
# 并用同一个 node 解析 agent.mjs。
# ⚠️ 必须先 sidecar:build —— prepare-console.mjs 的前置检查要求 sidecar.mjs 存在
# (首次推送时漏了这步,CI 在 Windows 上当场报「未找到 sidecar/dist/sidecar.mjs」)。
# agent.mjs 的前置是 `pnpm -r build`(上面的「构建」步骤已覆盖)。
- name: 控制台 Node 运行时 + 被控端(Windows 分支)与自举验证
run: |
pnpm --filter nodeagent-console sidecar:build
pnpm --filter nodeagent-console prepare:node
node apps/console/scripts/verify-bundle.mjs --runtime
- name: 单元测试 — 协议纯函数 / 清单守护 / 脚本生成
# 来自 ci/workflow-unit-tests 分支(4d73d6e)的实质贡献;该分支基于老 main,
# 直接 merge 会回退上万行,故只取其提交内容手工并入。
run: node --test "tests/unit/**/*.test.mjs"
- name: E2E — 协议与跨平台能力
run: node tests/e2e/run.mjs
- name: E2E — Windows 专属能力(服务 / 软件 / winget)
run: node tests/e2e/windows.mjs
# ⚠️ 这一步刻意用 **powershell(Windows PowerShell 5.1)** 而非 pwsh。
# 5.1 读无 BOM 的 .ps1 会按 ANSI/GBK 解析,中文注释会破坏字符串边界 →
# 整份脚本 ParseError(真机 2026-10-04 踩过:install.ps1 报「","后面缺少表达式」)。
# 之前的冒烟全用 pwsh(7+ 认无 BOM UTF-8),所以 CI 绿、真机炸。
# 这里只做「解析 + 首行 help」检查:5.1 能否读懂,就是真机的核心约束。
# ⚠️ 必须用 shell: powershell(runner 上的 powershell 即 Windows PowerShell 5.1)。
# 不要用 shell: cmd 再套 powershell -Command "..." —— 多行带引号经 cmd 转义会全乱
# (实测:Missing closing '}' / '$errs' is not recognized,是步骤自身坏,不是脚本坏)。
- name: 冒烟 — 脚本可被 Windows PowerShell 5.1 解析(BOM 守卫)
shell: powershell
run: |
$ErrorActionPreference = 'Stop'
foreach ($f in @('scripts\install.ps1', 'scripts\uninstall.ps1', 'scripts\rescue-network.ps1')) {
$errs = $null
[void][System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path $f).Path, [ref]$null, [ref]$errs)
if ($errs -and $errs.Count -gt 0) { throw ('5.1 parse FAILED: ' + $f + ' -> ' + $errs[0].Message) }
Write-Host ('OK 5.1 parse: ' + $f)
}
- name: 冒烟 — install.ps1 安装链路
shell: pwsh
shell: powershell
run: |
$ErrorActionPreference = "Stop"
./scripts/install.ps1 -Port 18769 -NodeId ci_win
$task = Get-ScheduledTask -TaskName "nodeagent" -ErrorAction Stop
Write-Host "计划任务状态: $($task.State)"
Write-Host "Scheduled task state: $($task.State)"
$cfg = Get-Content "$env:USERPROFILE\.nodeagent\agent.json" -Raw | ConvertFrom-Json
if ($cfg.port -ne 18769) { throw "端口配置错误: $($cfg.port)" }
if (-not $cfg.key) { throw "预共享密钥缺失" }
if (-not $cfg.tls) { throw "TLS 默认应开启" }
if ($cfg.port -ne 18769) { throw "port mismatch: $($cfg.port)" }
if (-not $cfg.key) { throw "PSK missing in agent.json" }
if (-not $cfg.tls) { throw "TLS should be on by default" }
$rule = Get-NetFirewallRule -DisplayName "nodeagent (TCP 18769)" -ErrorAction Stop
if (-not $rule) { throw "防火墙规则未创建" }
if (-not $rule) { throw "Firewall rule" }
Write-Host "OK — 计划任务 / 配置 / 防火墙均已就位"
Write-Host "OK scheduled task / config / firewallall in place"
# 一键入口冒烟:真跑 install.cmd / control.cmd。
# 目的不是「再装一遍」,而是证明双击路径可用 —— 这些脚本是用户唯一的
# 上手入口,语法或编码一旦坏了,只有真机 cmd.exe 才测得出来。
# 端口用 18770 避开前一步的 18770 之外的安装,便于分辨是哪一步装的。
- name: 冒烟 — install.cmd 双击链路(含自提权与 PSK 读取)
shell: cmd
run: call scripts\install.cmd 18770
env:
CI: "true"
- name: 冒烟 — control.cmd 状态查询
shell: cmd
run: scripts\control.cmd 18770 < nul
env:
CI: "true"
- name: 冒烟 — 产物包结构与编码
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
pnpm pack:win
$zip = "release/nodeagent-win-x64.zip"
if (-not (Test-Path $zip)) { throw "zip not produced: $zip" }
Add-Type -AssemblyName System.IO.Compression.FileSystem
$z = [IO.Compression.ZipFile]::OpenRead((Resolve-Path $zip))
try {
$names = $z.Entries.Name
# 必须随包分发
foreach ($need in @('install.cmd','control.cmd','install.ps1','control.ps1','README.txt','agent.mjs','node.exe')) {
if ($names -notcontains $need) { throw "missing in package: $need" }
}
# 必须**不**在包内:PSK.txt 是现生成给本机的,打进公开资产等于泄露密钥
# (eaca9e0 起已从 zip 剔除;此处反向断言守住该性质)
foreach ($forbidden in @('PSK.txt','*.key','*.pem','certs')) {
if ($names -contains $forbidden) { throw "package must NOT contain: $forbidden" }
}
if ($names -match 'PSK\.txt') { throw "package leaks PSK.txt" }
foreach ($e in $z.Entries) {
$s = $e.Open(); $buf = New-Object byte[] 3
[void]$s.Read($buf, 0, 3); $s.Close()
$hasBom = ($buf[0] -eq 0xEF -and $buf[1] -eq 0xBB -and $buf[2] -eq 0xBF)
if ($e.Name -like '*.ps1' -and -not $hasBom) { throw "$($e.Name) lost its BOM in package" }
if ($e.Name -like '*.cmd' -and $hasBom) { throw "$($e.Name) must not have a BOM" }
}
Write-Host "OK - package complete; .ps1 has BOM / .cmd has none; no key shipped"
} finally { $z.Dispose() }
- name: 冒烟 — 双架构包(node.exe PE 架构校验)
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
# 真机教训(虚拟化平台 + Win11 ARM):曾出现 arm64 包里装的其实是 x64 的 node.exe
# —— 两份包的 node.exe 字节完全相同。x64 node 在 ARM Windows 上**能 listen,
# 但 TLS 握手不完成**(客户端只见超时)→ 被控端实际不可用,且 macOS 侧 100% 测不出。
# 根因是 pack.mjs 的运行时缓存文件名不含架构。这里直接验**最终产物**的 PE 架构,
# 比只验脚本内部逻辑更靠近交付物(缓存串包 / 手工塞错文件都会被这一步拦下)。
# 校验实现与发版脚本共用(scripts/lib/zip-pe-arch.mjs)—— 避免两套实现各自漂移。
foreach ($arch in @('x64','arm64')) {
node scripts/pack.mjs --arch $arch
}
foreach ($arch in @('x64','arm64')) {
node scripts/lib/zip-pe-arch.mjs "release/nodeagent-win-$arch.zip" $arch
if ($LASTEXITCODE -ne 0) { throw "node.exe 架构校验失败($arch)—— 严禁发布该包" }
}
- name: 冒烟 — 被控端监听
shell: pwsh
run: |
@@ -108,7 +206,9 @@ jobs:
- name: 清理
if: always()
shell: pwsh
# 清理失败不掩盖真实结论(前置挂了会连带这里报错)
continue-on-error: true
shell: powershell
run: |
Get-Process node -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
./scripts/uninstall.ps1 -Port 18769 -Purge
+22
View File
@@ -18,6 +18,10 @@ pnpm-debug.log*
.env.*
!.env.example
# 预共享密钥:install.cmd 在包内生成,绝不入库
PSK.txt
**/PSK.txt
# OS
.DS_Store
Thumbs.db
@@ -36,3 +40,21 @@ release/
# 重装备份数据(含设备软件清单,不入库)
../win-backup/
win-backup/
# Rust 构建产物
# ⚠️ 2026-10-07:crates/ 此前整体被忽略,target/ 是「顺带」没进库的;crates/ 正式入库后
# 必须显式忽略,否则 4GB 级的 cargo target 会被 add -A 卷进提交。
/crates/target/
# 密钥材料(路线图 §6.2):被控端数据目录 keys/、ed25519/TLS 私钥
keys/
*.key
*.pem
!*.pub
# 本地 agent 工作数据(memory / automations 等,属本机状态,不入库)
.workbuddy/
# 被控端 / 控制端本机运行配置(含真实密钥与节点地址,绝不入库)
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
+1149
View File
File diff suppressed because it is too large. Load diff
+356 -40
View File
@@ -20,17 +20,155 @@ nodeagent 把一台 Windows 机器的能力**标准化成一组可授权、可
| 分组 | 能力 | 说明 |
|---|---|---|
| **系统** | `system.info` · `system.status` · `system.process.list` · `system.service.list` · `system.shell.exec` · `system.audit.list` · `system.audit.verify` | 信息 / 资源 / 进程 / 服务 / 命令 / 审计 / **审计防篡改校验** |
| **系统** | `system.info` · `system.status` · `system.process.list` · `system.service.list` · `system.shell.exec` · `system.audit.list` · `system.audit.verify` · `system.metrics` | 信息 / 资源 / 进程 / 服务 / 命令 / 审计 / **防篡改校验** / **成功指标(PRD 2.2)** |
| **自持** | `system.agent.restart` | **受控重启自身**(配置变更后让它生效,不会失联) |
| **异步任务** | `system.task.list` · `system.task.get` · `system.task.kill` | 长命令后台执行 + 增量续读 + 终止(`exec` 加 `async:true`) |
| **软件** | `app.list` · `app.install` | 已装软件(注册表 + winget)、winget 静默安装 |
| **屏幕** | `screen.info` · `screen.capture` · `screen.record` · `screen.find` | 显示器 / 截屏 / **录屏为帧序列** / **按名字找元素取坐标** |
| **窗口** | `window.list` · `window.focus` | 枚举可见窗口(精确矩形)/ 置前聚焦 |
| **屏幕** | `screen.info` · `screen.capture` · `screen.record` · `screen.find` | 显示器 / 截屏 / **录屏为帧序列** / **元素定位三引擎**(UIA 控件树 → OCR 文字 → **图像模板**) |
| **窗口** | `window.list` · `window.focus` | 枚举可见窗口(精确矩形)/ 置前聚焦(**Windows + macOS**) |
| **文件** | `fs.list` · `fs.stat` · `fs.read` · `fs.write` | 列目录 / 元信息 / 分块读 / 原子写 |
| **输入** | `input.mouse.move` · `input.mouse.click` · `input.mouse.scroll` · `input.mouse.drag` · `input.key.type` · `input.key.press` | 键鼠控制(🔒 **默认禁用**),含**拖拽**与**按键序列/连按** |
| **输入** | `input.mouse.move` · `input.mouse.click` · `input.mouse.scroll` · `input.mouse.drag` · `input.key.type` · `input.key.press` | 键鼠控制(🔒 **默认禁用**),含**拖拽**、**字符串热键 / 预设 / 长按 / 后台定向投递** |
| **剪贴板** | `clip.get` · `clip.set` | 读写文本**或图片**(PNG Base64) |
| **事件订阅** | `event.watch` · `event.unwatch` · `event.list` · `event.poll` | 文件变动 / 进程启停 / 端口开闭,**主动推送**(无需轮询) |
| **GUI 等待** | `gui.await` | 等条件成立再返回(窗口/元素/进程/文件 × 出现/消失);支持**属性谓词 `where`** 与 **`any_of` 组合** |
| **状态采样** | `log.query` · `monitor.start` · `monitor.report` · `monitor.stop` · `monitor.list` · `monitor.delete` | 日志**在被控端侧过滤**(不上传整份)/ 定时采样落盘 + **摘要回看**(断几次、最长断多久、CPU/内存值域) |
| **审计锚定** | `system.audit.head` · `system.audit.anchor` | 链头可读 + 锚点**追加**落盘,让「有 root 者整链重写」可被发现 |
**31 项能力** · **26 个 MCP 工具** · **61 项单元测试** · **21 项端到端用例**(CI 在真实 Windows 上验证)
## 快捷键(hotkey)控制 —— v1.5.0
`input.key.press` 支持 **4 种输入形式**(一次只用一种):
```bash
# ① 字符串热键(最常用;分隔符支持 + - 空格,大小写不敏感)
nodeagent --node win key press "ctrl+shift+esc"
nodeagent --node win key press "win+d" # 显示桌面
nodeagent --node win key press "alt+tab" # 切换应用
# ② 热键序列
nodeagent --node win key press "ctrl+c" # 老形式也兼容:key press ctrl c
nodeagent --node win invoke input.key.press --args '{"hotkeys":["ctrl+c","ctrl+v"]}'
# ③ 预设语义名(约 50 个,免记键位)
nodeagent --node win key press copy # = ctrl+c
nodeagent --node win key press task_manager # = ctrl+shift+esc
nodeagent --node win key press volume_mute_toggle # 媒体键(走 WM_APPCOMMAND)
# ④ 老数组形式
nodeagent --node win key press ctrl c
```
**按键覆盖**:字母/数字 · F1–F24 · 左右侧修饰键(`lalt`/`rctrl`/`lwin`…)·
小键盘(`numpad7`、`numpad_add`…,**与数字键分开**——Excel 靠 NumLock 区分)·
OEM 符号键(`oem_plus`/`oem_comma`/`oem_period`… → 支持 `win+d`、`ctrl++`、`win+.`)·
媒体键 · 浏览器键 · IME 键(`convert`/`kana`…)· 系统键(`sleep`/`help`…)
**进阶参数**:
| 参数 | 说明 |
|---|---|
| `hold_ms` | 长按:按下后保持 N 毫秒再释放(≤5000) |
| `route=post` + `target_pid` | **后台定向投递**:向目标进程全部顶层窗口 PostMessage(目标窗口被遮挡/最小化时用)。⚠️ 游戏与部分输入型程序不响应后台键消息 |
| `target_pid`(默认路由) | 媒体键定向(播放器常建 30+ 辅助窗口,指定 PID 投给该进程全部顶层窗口) |
**预设速查**(完整表见 `packages/protocol/src/hotkeys.ts` 的 `HOTKEY_PRESETS`):
| 类别 | 预设名 |
|---|---|
| 编辑 | `copy` `cut` `paste` `paste_plain` `undo` `redo` `select_all` |
| 文件 | `save` `save_as` `new` `open` `close_tab` `print` |
| 窗口 | `switch_app` `task_view` `task_manager` `show_desktop` `minimize_all` `maximize` `snap_left` `snap_right` `rename` `properties` |
| 系统 | `lock_screen` `run` `explorer` `search` `settings` `clipboard_history` `emoji_picker` `screenshot` |
| 虚拟桌面 | `new_desktop` `desktop_left` `desktop_right` |
| 标签/视图 | `new_tab` `next_tab` `reopen_tab` `zoom_in` `zoom_out` `fullscreen` |
| 媒体 | `music_play_pause` `music_next` `music_prev` `volume_add` `volume_mute_toggle` |
> MCP 侧同名工具为 `na_key`(`action: "press"`),支持同一套参数。
## 等待条件 gui.await —— v1.6.0
GUI 操作从「按一下→睡几秒→截图碰运气」变成**可断言**流程:
```bash
# 等界面元素出现(最多 30s)
nodeagent --node win invoke gui.await --args '{"condition":"control","text":"完成","timeout_ms":30000}'
# 等它消失
nodeagent --node win invoke gui.await --args '{"condition":"control","text":"安装中","state":"absent"}'
# 等窗口 / 进程 / 文件
nodeagent --node win invoke gui.await --args '{"condition":"window","title":"安装程序","timeout_ms":10000}'
nodeagent --node win invoke gui.await --args '{"condition":"process","process":"setup.exe"}'
nodeagent --node win invoke gui.await --args '{"condition":"file","path":"C:\\log.txt"}'
```
四类条件各自复用 `window.list` / `screen.find` / `process.list` / `fs.stat`,
平台行为(Windows UIA/OCR、macOS Vision OCR)与那些能力完全一致,**只读低危**。
轮询期单次异常不视为失败(启动中的程序常短暂报错),超时未命中才带回 `last_error`。
**典型闭环**:`await control(text:"立即安装")` → `mouse.click(x, y)` → `await control(text:"完成", timeout_ms:60000)` → `click` → `await absent(text:"安装中")`。
### 属性谓词与组合(v2.0.0)
`screen.find` 现在返回 UIA 属性,`gui.await` 可据此等**状态**而不只是「出现」:
```bash
# 等一个「可用」的按钮(不指定文字)
nodeagent --node win await --condition control --where '{"enabled":true}'
# 等值里含「已」的元素
nodeagent --node win await --condition control --where '{"value":"*已*"}' --timeout 15000
# 组合:装成功 或 报错,先出现的算
nodeagent --node win await --any_of '[{"condition":"window","title":"安装完成"},{"condition":"control","text":"错误"}]'
```
| 属性 | 来源 | 拿不到时 |
|---|---|---|
| `enabled` | `IsEnabled` | — |
| `value` | `ValuePattern` | `null`(**不猜**) |
| `selected` | `SelectionItemPattern` | `null` |
| `toggle` | `TogglePattern` | `null` |
> 属性只在 **UIA 引擎**下可读(Windows)。走 OCR/图像模板时 `where` 被忽略并**带回 `note`**,
> 不会让调用方误以为「按属性过滤过了」。macOS 无 UIA 等价物,`where` 不可用(会明确报错提示用 `text`)。
---
## 状态采样:日志过滤与定时采样 —— v2.0.0
治**间歇性**问题(代理不通、杀软拦截、端口时开时闭)—— `system.status` 只能看当下快照,
而 `event.watch` 只解决「有事件时通知」,没有「**持续记录并回看**」。
```bash
# 日志在被控端侧过滤,只回匹配行(几万行日志也不整份拉回)
nodeagent --node win log --path "C:\app\app.log" --level ERROR --limit 20
nodeagent --node win log --path "C:\app\app.log" --pattern "timeout|refused" --tail
# 连续采样 5 分钟,事后回看「断过几次、最长断多久」
nodeagent --node win invoke monitor.start --args '{"source":"port","target":"127.0.0.1:8765","interval_ms":2000,"id":"probe"}'
nodeagent --node win monitor report probe
nodeagent --node win monitor stop probe
```
采样源四类:`port`(连通性 + 延迟)/ `process`(存活 + PID)/ `command`(退出码 + 首行输出)/
`metric`(CPU·内存)。样本落 `<数据目录>/monitors/<id>.jsonl`,**摘要直接给结论**。
---
## 审计链外部锚定 —— v2.0.0
链内哈希只能发现「改一条」。**有 root 的攻击者可以整链重写** —— 从零重建一份自洽的假日志,
`audit.verify` 照样通过。外部锚定是唯一能戳破它的手段:把链头记到**链外**。
```bash
nodeagent --node win audit head --compare # 看链头 + 与最近锚点比对
nodeagent --node win audit anchor --note "每日例行" # 追加一个锚点
```
**锚点记四元组** `{entries, head_hash, head_ts, rotated_segments}`,不能只记哈希 ——
日志轮转会丢弃最旧段使条目数下降,只比哈希会把**轮转误判成篡改**。
锚点文件**只追加**(可覆盖的话,攻击者重写链后再刷新锚点即可抹痕)。
> 真正发挥价值的前提:把锚点文件指向**另一台机器 / 网盘同步目录**。
> 本机文件被整盘重写时,锚点也随之消失 —— 那就等于没锚。
**52 项能力** · **42 个 MCP 工具** · **483 项单元测试** · **50 项端到端用例**(34 通用 + 16 Windows 专属) · **23 项 Rust 壳单测**(真跑 Node sidecar)(CI 在真实 Windows / Linux runner 上验证)
> 关键里程碑:**GUI 语义**(`window.list` + `screen.find`,UIA 找不到自动降级 OCR)
> 让 AI 从「看得到画面但读不懂界面」变成「按名字取坐标点下去」。
@@ -41,9 +179,15 @@ nodeagent 把一台 Windows 机器的能力**标准化成一组可授权、可
```bash
pnpm install
pnpm build
pnpm build # 全部 workspace(protocol/client/agent/cli/mcp/hub + 桌面控制台前端)
pnpm test:unit # 单元测试(协议纯函数 / 清单守护 / 编码脚本守卫 / 契约守卫)
pnpm test:e2e # 端到端(起真实被控端跑全链路)
pnpm test:rust # Rust 控制端客户端(需 cargo)
```
> 桌面控制台是 Tauri 应用,**打包**需另进目录:`cd apps/console && pnpm tauri build`。
> 仅前端产物则 `pnpm build` 已覆盖。
### 1. Windows 侧(被控端)
**方式 A:安装包(推荐,免装 Node.js)**
@@ -51,7 +195,7 @@ pnpm build
1. 在控制端构建安装包(**需在项目目录执行**):
```bash
cd <项目目录> # 例如 ~/WorkBuddy/NodeAgent/nodeagent
cd <项目目录> # 例如 ~/projects/nodeagent
pnpm pack:win
# 或从任意目录调用:
@@ -60,16 +204,35 @@ pnpm build
产物:`release/nodeagent-win-x64.zip`(约 32MB)
2. 把 zip 拷到 Windows 并解压
3. 右键以**管理员**运行其中的 `install.ps1`
2. 把 zip 拷到 Windows 并解压到**固定目录**(如 `D:\nodeagent`,装完不要移动或改名)
3. **双击 `install.cmd`** —— 会自动弹 UAC 提权,然后一路装完并打印连接信息
安装脚本会使用**包内自带的 Node.js 运行时**,自动:生成预共享密钥 → 写配置 → 放行防火墙 → 注册开机自启 → 启动 Agent,并打印**密钥**与**连接命令**。
> 不需要记任何命令行。`install.cmd` 会自己提权、自己读包内 `PSK.txt` 的密钥(没有就生成一把),
> 再调用底层的 `install.ps1`。日后管理双击 **`control.cmd`**(启动 / 停止 / 重启 / 状态 / 日志 / 卸载)。
安装脚本会使用**包内自带的 Node.js 运行时**,自动:读/生成预共享密钥 → 写配置 → 放行防火墙 → 注册开机自启 → 启动 Agent,并打印**密钥**与**连接命令**。
<details>
<summary>需要指定端口 / 无人值守模式?展开命令行用法</summary>
```powershell
# 指定端口(默认 8765)
install.cmd 18770
# 等价的底层调用(install.cmd 就是这行 + 提权 + 读 PSK)
powershell -ExecutionPolicy Bypass -File .\install.ps1 -NodeId my-pc -Key <PSK> -AllowInput
# 无人值守:注销/重启后仍运行,但**没有 GUI**(Session 0 无桌面)
powershell -ExecutionPolicy Bypass -File .\install.ps1 -Unattended -AtStartup
```
</details>
**两种运行模式(重要)**
| 模式 | 命令 | 登录/注销 | 能力范围 |
|---|---|---|---|
| 交互(默认) | `install.ps1` | 登录时启动;**注销即停** | 全部能力,**含 GUI**(截屏 / UIA 找元素 / 键鼠注入) |
| 交互(默认) | `install.cmd` | 登录时启动;**注销即停** | 全部能力,**含 GUI**(截屏 / UIA 找元素 / 键鼠注入) |
| 无人值守 | `install.ps1 -Unattended -AtStartup` | **注销、重启后仍运行**(S4U,无需存密码) | 无 GUI —— 截屏/输入类能力不可用(进程在非交互会话,没有桌面) |
> 这是 Windows 的固有限制:**图形操作必须有交互桌面**。需要 GUI 就保持登录(可锁屏);
@@ -148,11 +311,17 @@ nodeagent pull "C:\big.iso" --out ./big.iso # 下载(大文件自动
## 架构
```
┌────────────────────┐ ┌────────────────────┐
│ 控制端 (macOS) │ ◄──── JSON-RPC 2.0 ────► │ 被控端 (Windows) │
│ CLI · MCP Server │ over WebSocket │ Agent(开机自启) │
└────────────────────┘ └────────────────────┘
│ │
┌──────────────────────────────────────────┐
│ 控制端 (macOS / Windows) │
│ ① 桌面控制台(Tauri 2) ② CLI ③ MCP │
└──────────────────────────────────────────┘
│
JSON-RPC 2.0 over WebSocket
│
┌──────────────────────────────────────────┐
│ 被控端 Agent(Windows 常驻 / 开机自启) │
│ 同时也是「本地被控端」,供本机控制台直连 │
└──────────────────────────────────────────┘
└──────────── 三种链路可选 ──────────────────────┘
① 局域网直连 ② UDP 广播自动发现 ③ Hub 中转(跨网段/公网)
```
@@ -165,6 +334,64 @@ nodeagent pull "C:\big.iso" --out ./big.iso # 下载(大文件自动
| 审计 | JSONL 落盘 + 轮转 + 参数脱敏 + 查询能力 |
| 传输 | TLS(自签证书可用 `--insecure` 跳过校验,鉴权另有保障) |
### 桌面控制台(Tauri 2)
`apps/console` —— 跨端图形控制台,**主控与被控一体**:本机也可作为被控端,
由控制台经 loopback 直连(复用同一个 Agent,不做第二套被控端实现)。
技术栈 Tauri 2(Rust 壳 + 系统 WebView)+ Vue 3 + TypeScript + Pinia + Naive UI。
协议客户端由 **Node sidecar**(`apps/console/sidecar/`)承担(复用 `@nodeagent/client`,
Tauri 侧经 Cargo path 依赖),**不引入第二套协议实现**。
**打开即用、全程不碰命令行**:应用自带 Node 运行时与被控端脚本,
默认页面就是「本机服务」——点按钮即可启动 / 停止 / 重启本机被控端,
并支持检查更新、查看日志、后台常驻(托盘)、开机自启。
**界面**:导航为**纯文字**(不使用图标);配色为两套中性灰阶主题 ——
「简约白」与「高级灰」,侧栏底部一键切换。
| 页面 | 能力 |
|---|---|
| **本机服务**(默认) | 本机被控端启停/重启、连接本机、节点 ID 与密钥、开机自启、后台运行、异常自动重启、检查并应用更新 |
| **服务日志** | 被控端 stdout/stderr 实时推送、关键字过滤、暂停接收、导出、清空 |
| 设备管理 | 连接/断开、保存多台设备、能力清单与握手元信息、实时日志 |
| 状态总览 | CPU / 内存 / 磁盘 / 网卡,3s 轮询 |
| 终端 | `system.shell.exec`(可设超时)、历史回放 |
| 进程与服务 | 进程表(CPU/内存/已运行)+ Windows 服务表 |
| 软件 | `app.list` / `app.install`(winget 静默安装,超时放大到 10 分钟) |
| 文件 | 浏览 / 预览 / 写入(走分块能力,天然支持大文件) |
| 审计 | `system.audit.list` 筛选 + `system.audit.verify` 链完整性校验 + 导出 JSONL |
```bash
pnpm -r build # 含 console 前端
cd apps/console && pnpm tauri dev # 开发态(起 Vite + Tauri 窗口)
pnpm --filter nodeagent-console bundle # 打包(含 sidecar + Node 运行时 + 被控端脚本)
# macOS 实测:.app 92.2 MiB / .dmg 33.78 MiB(分发件,其中 Node 运行时 86MB)
```
Windows 版由 CI 产出(`.github/workflows/console-windows.yml`),**双架构各一套**:
| 架构 | runner | 安装包 | 实测体积 |
|---|---|---|---|
| x64 | `windows-latest` | `.msi`(企业批量部署)+ `-setup.exe`(NSIS,双击安装) | 34.2 MiB / 23.2 MiB |
| arm64 | `windows-11-arm` | `-setup.exe`(NSIS) | 19.5 MiB |
> 为什么必须两套:控制台内含 Node 运行时,且要用它**托起本机被控端**(即自己就是 TLS 服务端)。
> 若在 ARM Windows 上装到 x64 的运行时,会**能启动、能监听,但 TLS 握手不完成** ——
> 客户端只见超时,且开发机上 100% 复现不出。CI 里为此加了三道断言:
> 「构建机架构 ⇄ 目标架构」、「随包 `node.exe` 的 PE Machine」、
> 以及**解开安装包查内容**(三样随包资源齐全 + 包内 `node.exe` 的 PE Machine 对得上 ——
> 前两条查的是打包**前**的中间产物,这一条查的才是**最终交付物**;
> 实测解出 x64 `node.exe` 82.9 MB / `0x8664`、arm64 73.5 MB / `0xaa64`)。
⚠️ 平台差异:`app.*` 与 `system.service.list` **仅在被控端为 Windows 时可用**,
非 Windows 被控端会返回 `E_UNSUPPORTED_PLATFORM`。控制台连接后会探测
`system.info.os` 并**事前禁用**这些入口,而不是发出注定失败的请求。
⚠️ 控制台**已自带** Node 运行时与被控端脚本,目标机器无需预装 Node.js,也无需先手工安装被控端;
但**开机自启与被控端的图形能力**要求运行在**登录用户会话**中(Windows 计划任务用
`Interactive` + 登录用户;SYSTEM 会话 0 没有交互桌面,截图/窗口类能力会失败)。
## 安全模型
三层防护,逐层收窄权限:
@@ -244,6 +471,7 @@ nodeagent push <本地文件> <远端路径> [--create-dirs] # 上传(自动
```bash
nodeagent audit [--limit 20] [--type invoke|auth|acl|agent] [--client-id X] [--since <ms>]
nodeagent audit verify # 校验审计链完整性(防篡改检测)
nodeagent metrics [--since <ms>] # 成功指标:闭环率/装软件率/P95/拦截率
nodeagent keygen [--id mac_01] # 生成 Ed25519 密钥 + ACL 配置片段(私钥入系统钥匙串)
```
@@ -252,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
@@ -265,7 +511,27 @@ nodeagent clip [--set "文本"] [--out <路径>] [--image-file <路径>] # 剪
nodeagent record [--duration 5000] [--fps 2] [--region x,y,w,h] # 录屏为帧序列
```
### GUI 语义(找到元素再点,不猜坐标)
### 事件订阅与宏
```bash
nodeagent events --kind file --path C:\logs --seconds 20 # 实时接收文件变动推送(CLI 直连推送)
nodeagent events --kind process --pattern chrome* # 进程启停
nodeagent events # 列出当前订阅
nodeagent macro init demo.json # 生成示例宏
nodeagent macro run demo.json --var STAMP=hello # 回放(逐步校验)
nodeagent macro validate demo.json # 仅校验文件
nodeagent group add 办公机 win_a,win_b # 设备分组
nodeagent fanout system.info --nodes @办公机 # 按组并发下发
```
### GUI 语义(三条腿:控件树 / 文字 / 图像)
```bash
nodeagent screen find "确定" # UIA → OCR 自动降级
nodeagent screen find "登录" --method ocr --wait-ms 5000 # 等界面加载出来
nodeagent screen find "" --method image --template C:\icons\save.png --threshold 0.9
# 纯图标控件:按图找坐标
```
```bash
nodeagent invoke window.list --args '{"title_pattern":"记事本"}'
@@ -287,7 +553,10 @@ 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 / 拦截率)+ 达标判定 |
## Hub 中转(跨网段 / 公网)
@@ -310,6 +579,30 @@ node apps/cli/dist/index.js connect hub.example.com --port 443 --hub-token <Hub
--hub-node win_01 --key <设备密钥> --name win_01
```
**并发多控制端**:Hub 为每个被控端维护**槽位池**(`max_slots_per_node`,默认 3),
被控端侧常备 2 条连接(`hub.warm_slots`)并在被占用时自动扩容 —— 因此
「AI + 人同时在线」不再互斥(旧版是独占,会返回 `E_NODE_BUSY`)。
**Hub 侧访问控制(v12)**:可配置多令牌,每个令牌独立指定能访问哪些设备:
```json
{
"token": "<主令牌,放行全部>",
"node_allowlist": ["win_*"],
"tokens": [
{ "value": "ci-token", "name": "ci", "allow_nodes": ["win_build_*"] },
{ "value": "guest-token", "name": "guest", "allow_nodes": ["*"], "deny_nodes": ["win_prod"] },
{ "value": "roll-*", "name": "rolling", "allow_nodes": ["win_a"] }
]
}
```
- 判定顺序 **deny → allow → 默认拒绝**;`node_allowlist` 非空时只有匹配的 node_id 能注册
- `value` 以 `*` 结尾表示**前缀匹配**(便于令牌轮换期间灰度)
- 设备列表按身份过滤(控制端只看得到自己被授权的设备),越权接入返回 `E_NODE_FORBIDDEN`
- **职责边界**:Hub 只做「能否连到某设备」的接入过滤;连上之后的**能力级**授权
仍由被控端 ACL 按 client_id 裁决 —— 两层独立,Hub 不参与能力决策
**安全边界**:Hub **只做字节透传**,不解析内容、不持有设备密钥 —— 控制端与被控端之间仍执行端到端握手,因此 **Hub 无法窃听也无法伪造**(Hub 被攻陷也不等于设备被接管)。生产环境建议在 Hub 前挂 Caddy / Nginx 终止 TLS。
## 开发
@@ -317,10 +610,11 @@ node apps/cli/dist/index.js connect hub.example.com --port 443 --hub-token <Hub
```bash
pnpm build # 构建全部包
pnpm typecheck # 类型检查
pnpm test:unit # 单元测试(61 项:协议纯函数 / 清单守护 / 审计链 / ACL v11)
pnpm test:unit # 单元测试(114 项:协议纯函数 / 清单守护 / 审计链 / ACL / 宏引擎 / 分组 / Hub 授权 / 指标 / 助手与预加载段)
pnpm test:e2e # 端到端测试(21 项,含 TLS / 零信任 / 发现 / 文件 / Hub)
pnpm test:windows # Windows 专属能力(服务 / 软件 / winget 真实装软件)
pnpm verify # 对已配置的被控端跑全套验收并输出报告
node scripts/verify-mcp-events.mjs # 验证 MCP 事件双通路(推送 + 拉取)
```
### 三层自动化真机验证
@@ -329,7 +623,7 @@ pnpm verify # 对已配置的被控端跑全套验收并输出报告
| 层次 | 方式 | 覆盖 |
|---|---|---|
| **CI(推荐)** | GitHub Actions `windows-latest` | 真实 Windows 上跑协议 E2E、专属能力(`Get-Service` / 注册表 / winget 装软件)、`install.ps1` 链路、CLI 连入、MCP 工具列表 |
| **CI(推荐)** | GitHub Actions `windows-latest` | 真实 Windows 上跑协议 E2E、专属能力(`Get-Service` / 注册表 / winget 装软件)、`install.ps1` 链路、`install.cmd`/`control.cmd` 双击链路、**Windows PowerShell 5.1 解析守卫**、CLI 连入、MCP 工具列表 |
| **远程验收** | `node scripts/verify.mjs --host <IP> --key <密钥> [--insecure]` | 逐条验收并输出终端报告(`--report report.md`) |
| **本地测试** | `pnpm test:e2e` / `pnpm test:windows` | 单机自举:本机起 Agent 当被控端自测 |
@@ -355,38 +649,60 @@ nodeagent/
│ ├── hub/ # 中转节点:设备注册、控制端配对、字节透传(跨网段/公网)
│ ├── cli/ # macOS 控制端 CLI
│ └── mcp/ # MCP server(接入 WorkBuddy)
├── scripts/ # install.ps1 / uninstall.ps1 / verify.mjs
├── 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+** | **操作面补完**:鼠标拖拽 / 录屏 / 剪贴板图片 / 多设备并发 / 一键升级 | ✅ |
| **待办** | 事件订阅(push)、v7~v11 能力入 CI、macOS 端 GUI 能力对齐 | 🚧 |
| **批次 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) | ✅ 已发布 |
## 文档
- [产品需求文档(PRD)](./PRD.md)
- [`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)](./docs/PRD.md)
- [开发文档(DEVELOPMENT)](./docs/DEVELOPMENT.md) —— 协议细节、能力 schema、安全模型、各端实现指南
- [**跨机接管的边界与局限**](./docs/REMOTE-LIMITS.md) —— 哪些问题能远程修、哪些必须人工,附真实故障诊断案例
- [**实战复盘:短板与改进优先级**](./docs/RETROSPECTIVE.md) —— 基于真机使用的短板分析(GUI 语义 / 编码 / 自愈 / 可观测性)
- [**实战复盘:短板与改进优先级**](./docs/RETROSPECTIVE.md) —— 基于真机使用的短板分析(GUI 语义 / 编码 / 自愈 / 可观测性)。**写于 2026-09-28(19 项能力时),多数 P0/P1 已解决 —— 最新状态见 [ROADMAP](./docs/ROADMAP.md) §3 对照表**
## 关键决策
@@ -396,5 +712,5 @@ nodeagent/
| 安全演进 | 先简化(预共享密钥 + HMAC)→ 后强化(Ed25519 + 能力级 ACL + 审计) |
| AI 接入 | 核心能力 → CLI(调试底座)→ MCP(AI 落点) |
| 装软件 | 优先 winget 静默安装 |
| Windows 部署 | 一键 `install.ps1` + 开机自启 |
| Windows 部署 | 一键 `install.cmd`(双击即装,自提权)+ 开机自启;`control.cmd` 管理 |
| 依赖策略 | 被控端零原生编译;能力全部基于系统原生 API 实现 |
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/agent",
"version": "0.1.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": {
+229 -5
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'
@@ -11,7 +18,51 @@ export type AuditType =
| 'invoke'
| 'acl.denied'
| 'capability.disabled'
| 'rate.limited';
| 'rate.limited'
/** v16:网络配置变更(两阶段提交) */
| 'net.change'
/** v21:来源不在 allow_from 被拒 */
| 'net.deny'
/** v19:agent 自更新(拉取式) */
| 'agent.update'
/**
* v2.1.0:**L2 系统级审计事件**(Windows Security 日志归一化副本)。
* 真源注释见 `packages/protocol/src/audit-types.ts`——不复用 `agent.start`:
* L2 策略操作与进程生命周期无关,混入会污染两边的统计与筛选。
*/
| 'agent.l2'
/** v2.0.0:连接空闲被主动断开 */
| 'session.idle_close'
/**
* 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 {
@@ -27,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;
@@ -123,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)) };
}
/** 写入一条审计记录。任何异常都被吞掉 —— 审计绝不阻断主流程。 */
@@ -271,6 +351,33 @@ function readEntriesFromFile(file: string): AuditEntry[] {
* 读取审计记录(取最新 limit 条,按时间升序返回)。
* 会同时读取轮转文件(audit.log.1/.2/…),避免历史记录被遗漏。
*/
/**
* 读取全部审计条目(跨轮转文件,按时间升序)。
* 供指标聚合等「需要全窗口样本」的场景使用 —— readAudit 有 limit 上限(最多 1000 条)。
* 安全上限 20 万条,防止极端情况下把内存吃爆。
*/
export function readAuditAll(since?: number): AuditEntry[] {
const p = auditFilePath();
const files: string[] = [];
for (let i = 20; i >= 1; i -= 1) {
const f = `${p}.${i}`;
if (existsSync(f)) files.push(f);
}
files.push(p);
const out: AuditEntry[] = [];
for (const f of files) {
for (const e of readEntriesFromFile(f)) {
if (since !== undefined && e.ts <= since) continue;
out.push(e);
if (out.length >= 200_000) break;
}
if (out.length >= 200_000) break;
}
out.sort((a, b) => a.ts - b.ts);
return out;
}
export function readAudit(query: AuditQuery = {}): { entries: AuditEntry[]; total: number; file: string } {
const p = auditFilePath();
if (!existsSync(p)) return { entries: [], total: 0, file: p };
@@ -299,3 +406,120 @@ export function readAudit(query: AuditQuery = {}): { entries: AuditEntry[]; tota
collected.sort((a, b) => a.ts - b.ts);
return { entries: collected.slice(-limit), total, file: p };
}
// ---------------- v2.0.0 审计链外部锚定 ----------------
//
// ## 为什么需要
// 审计链的哈希使**链内**篡改可被发现(改一条就要重算其后全部)。但「有 root 的攻击者」
// 可以**整链重写**:从零重建一份自洽的假日志,链校验照样通过。
// 外部锚定就是打破这一点:定期把**链头哈希 + 条目数**记到链外的独立位置
// (另一台机器 / 网盘 / U 盘)。事后比对时,若「当前链在某锚点之后的部分对不上」,
// 说明这段被重写过 —— 攻击者无法回到过去改掉已经落到别处的锚点。
//
// ## 记什么(关键:不能只记哈希)
// 轮转会丢弃最旧的段,所以条目数会**下降**。只记 head_hash 会导致轮转后被误判为篡改。
// 故锚点记 {entries, head_hash, head_ts, rotated_segments} 四元组,
// 比对时先按"轮转了几段"对齐,再判断尾部是否一致。
export interface AuditHead {
/** 当前链可见条目数(含轮转段) */
entries: number;
/** 链头哈希(末条的 hash)—— 这是要外部留存的核心值 */
head_hash: string | null;
/** 末条时间戳 */
head_ts: number | null;
/** 存在的轮转段数(.log.1 … .log.20) */
rotated_segments: number;
file: string;
file_bytes: number;
computed_at: number;
}
/** 计算当前审计链的头部信息(只读)。 */
export function computeAuditHead(): AuditHead {
const p = auditFilePath();
let rotated = 0;
for (let i = 1; i <= 20; i += 1) if (existsSync(`${p}.${i}`)) rotated += 1;
const entries = readAuditAll();
const last = entries.length > 0 ? entries[entries.length - 1]! : null;
let fileBytes = 0;
try {
fileBytes = statSync(p).size;
} catch {
/* 文件可能尚不存在(还没写过审计) */
}
return {
entries: entries.length,
head_hash: last?.hash ?? null,
head_ts: last?.ts ?? null,
rotated_segments: rotated,
file: p,
file_bytes: fileBytes,
computed_at: Date.now(),
};
}
// 锚点记录与比对结果的定义**移到了 protocol**(唯一实现,控制端脚本也用它)——
// 见 packages/protocol/src/audit-anchor.ts 顶部的说明。此处 re-export 以保持既有导入路径可用。
export type { AnchorRecord, AnchorComparison } from '@nodeagent/protocol';
/** 锚点文件路径(默认与审计同目录)。 */
export function anchorFilePath(custom?: string): string {
return custom && custom.trim().length > 0 ? custom.trim() : join(agentDir(), 'audit-anchors.jsonl');
}
/** 把当前链头**追加**到锚点文件(追加式:历史锚点不可被后续覆盖)。 */
export function anchorAudit(opts: { path?: string; note?: string } = {}): {
file: string;
record: AnchorRecord;
total_lines: number;
} {
const head = computeAuditHead();
const file = anchorFilePath(opts.path);
const rec: AnchorRecord = {
ts: Date.now(),
entries: head.entries,
head_hash: head.head_hash,
head_ts: head.head_ts,
rotated_segments: head.rotated_segments,
...(opts.note ? { note: opts.note } : {}),
};
appendFileSync(file, `${JSON.stringify(rec)}\n`, 'utf8');
let total = 0;
try {
total = readFileSync(file, 'utf8').split('\n').filter((l) => l.trim().length > 0).length;
} catch {
total = 1;
}
return { file, record: rec, total_lines: total };
}
/**
* 与最近一条锚点比对,判断当前链是否与锚定时刻自洽。
*
* 判定逻辑(三条):
* - 条目数应 **≥** 锚点记录(只增不减;减少只能由轮转解释,轮转会让 rotated_segments 变大)
* - 若条目数与轮转段数都没变 → 链头哈希必须**完全一致**
* - 若已轮转(段数变大)→ 只校验"当前 entries ≥ 锚点 entries",并提示需人工核对
*/
export function compareWithAnchors(customPath?: string): AnchorComparison {
const file = anchorFilePath(customPath);
if (!existsSync(file)) {
return { ok: true, anchors_checked: 0, verdict: '尚无锚点:请先调用 system.audit.anchor 建立外部锚点' };
}
let anchors: AnchorRecord[] | null = null;
try {
anchors = parseAnchorsFromJsonl(readFileSync(file, 'utf8'));
} catch {
return { ok: false, anchors_checked: 0, verdict: '锚点文件损坏,无法比对' };
}
if (!anchors) {
return { ok: true, anchors_checked: 0, verdict: '锚点文件为空' };
}
// 判定逻辑在 protocol(judgeAnchors)—— 与「控制端对云盘锚点」的比对共用同一份实现,
// 避免两处规则漂移(那会导致同一份日志两端结论不同)。
return judgeAnchors(computeAuditHead(), anchors);
}
+4 -5
View File
@@ -1,5 +1,6 @@
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { IS_WINDOWS, execCommand } from '../util/exec.js';
import { psLit } from '../util/quote.js';
type Args = Record<string, unknown>;
@@ -108,7 +109,9 @@ export async function appInstall(args: Args): Promise<unknown> {
);
}
const target = id ? `--id ${quote(id)} --exact` : `${quote(pkg)}`;
// ⚠️ package / id 来自控制端,且命令经 `powershell.exe -Command` 执行
// → 必须用 PS 单引号字面量(旧的 `"…"` + `\"` 写法可被 `;` / `$(…)` 逃逸,同类 P0)。
const target = id ? `--id ${psLit(id)} --exact` : `${psLit(pkg)}`;
const flags = [
silent ? '--silent' : '',
'--accept-package-agreements',
@@ -134,10 +137,6 @@ export async function appInstall(args: Args): Promise<unknown> {
};
}
function quote(s: string): string {
return `"${s.replace(/"/g, '\\"')}"`;
}
function extractVersion(output: string): string | null {
const m = /(\d+\.\d+(?:\.\d+)*(?:-\w+)?)/.exec(output);
return m ? m[1]! : null;
+212
View File
@@ -0,0 +1,212 @@
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>;
/**
* 音频控制(v17):静音开关与主音量。
*
* ## 为什么需要
* 远程接管一台机器时,「把它静音」是高频且刚需的小事(夜里吵、放视频要音量、
* 排障时确认音频设备)。此前只能靠 exec 拼一段脚本,容易踩本项目已记录的
* 各种坑(C# 转义 / 编码 / 引号),故做成正式能力。
*
* ## 平台实现
* - Windows:**Core Audio COM**(IMMDeviceEnumerator → IAudioEndpointVolume)
* —— 可**读回真实状态**,因此是确定性的「设为静音」而非「切换」
* (切换在状态未知时可能反而打开声音)。不用 Add-Type -AssemblyName 那套,
* 是因为音量不在 .NET BCL 里,只能用 COM 互操作。
* - macOS:`osascript` 的 volume 设置(原生,无需互操作)。
*
* ## C# 互操作的两个硬约束(本项目踩过的坑,勿改)
* 1. **槽位顺序即 vtable 顺序**:接口方法必须按 COM 定义顺序声明,
* 不调用的方法也要占位(占位方法签名无所谓,但不能漏)。
* 顺序错了不会编译报错,而是调用到错误的内存地址 → 进程崩溃。
* 2. **C# 源码必须纯 ASCII**:Add-Type 按系统 ANSI(GBK) 落盘,非 ASCII 会乱码。
*/
// 不调用的方法只占槽位,签名随意;**顺序不可调整**(见文件头注释)
const AUDIO_PRELUDE = `
Add-Type @"
using System;
using System.Runtime.InteropServices;
public class NAAudio {
[ComImport, Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")]
private class MMDeviceEnumerator { }
[Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IMMDeviceEnumerator {
int m1();
int GetDefaultAudioEndpoint(int dataFlow, int role, out IMMDevice ppDevice);
}
[Guid("D666063F-1587-4E43-81F1-B948E807363F"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IMMDevice {
int Activate(ref Guid iid, int dwClsCtx, IntPtr pActivationParams, [MarshalAs(UnmanagedType.IUnknown)] out object ppInterface);
}
[Guid("5CDF2C82-841E-4546-9722-0CF74078229A"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IAudioEndpointVolume {
int m1(); int m2(); int m3(); int m4();
int SetMasterVolumeLevelScalar(float fLevel, IntPtr pguidEventContext);
int m6();
int GetMasterVolumeLevelScalar(out float pfLevel);
int m8(); int m9(); int m10(); int m11();
int SetMute([MarshalAs(UnmanagedType.Bool)] bool bMute, IntPtr pguidEventContext);
int GetMute([MarshalAs(UnmanagedType.Bool)] out bool pbMute);
}
private const char SEP = (char)124;
private static IAudioEndpointVolume Default() {
IMMDeviceEnumerator en = (IMMDeviceEnumerator)(new MMDeviceEnumerator());
IMMDevice dev;
Marshal.ThrowExceptionForHR(en.GetDefaultAudioEndpoint(0, 0, out dev));
Guid iid = typeof(IAudioEndpointVolume).GUID;
object o;
Marshal.ThrowExceptionForHR(dev.Activate(ref iid, 23, IntPtr.Zero, out o));
return (IAudioEndpointVolume)o;
}
private static string Snapshot(IAudioEndpointVolume v) {
bool muted;
Marshal.ThrowExceptionForHR(v.GetMute(out muted));
float lvl;
Marshal.ThrowExceptionForHR(v.GetMasterVolumeLevelScalar(out lvl));
return (muted ? "1" : "0") + SEP + ((int)Math.Round(lvl * 100));
}
public static string Get() {
return Snapshot(Default());
}
public static string Mute(string flag) {
IAudioEndpointVolume v = Default();
bool want = flag == "1";
Marshal.ThrowExceptionForHR(v.SetMute(want, IntPtr.Zero));
return Snapshot(v);
}
public static string Volume(int percent) {
if (percent < 0) percent = 0;
if (percent > 100) percent = 100;
IAudioEndpointVolume v = Default();
Marshal.ThrowExceptionForHR(v.SetMasterVolumeLevelScalar(percent / 100f, IntPtr.Zero));
return Snapshot(v);
}
public static string Apply(string flag, int percent) {
IAudioEndpointVolume v = Default();
if (percent >= 0) Marshal.ThrowExceptionForHR(v.SetMasterVolumeLevelScalar(percent / 100f, IntPtr.Zero));
if (flag == "0" || flag == "1") Marshal.ThrowExceptionForHR(v.SetMute(flag == "1", IntPtr.Zero));
return Snapshot(v);
}
}
"@
`;
/** 解析 C# 回传的 `静音|音量` 紧凑串。 */
export function parseAudioState(raw: string): { muted: boolean; volume: number } {
const line = raw.trim().split('\n').pop() ?? '';
const m = line.match(/([01])\s*\|\s*(\d{1,3})/);
if (!m) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '音频状态解析失败', { detail: raw.slice(0, 200) });
}
return { muted: m[1] === '1', volume: Number(m[2]) };
}
async function runWindows(body: string, timeoutMs = 30_000): Promise<string> {
const { stdout } = await runPowerShellSmart({
body,
prelude: AUDIO_PRELUDE,
timeoutMs,
label: 'audio',
log: (level, msg) => {
if (level === 'warn') console.error(`[audio] ${msg}`);
},
});
return stdout;
}
/** macOS:osascript 原生音量控制(静音用 `output muted`,音量用 `output volume`)。 */
async function runMac(script: string): Promise<string> {
// ⚠️ 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),
});
}
return r.stdout.trim();
}
async function macState(): Promise<{ muted: boolean; volume: number }> {
const out = await runMac('get volume settings');
// 形如: output volume:42, input volume:100, alert volume:100, output muted:false
const vol = /output volume:(\d+)/.exec(out);
const muted = /output muted:(true|false)/.exec(out);
return { muted: muted?.[1] === 'true', volume: vol ? Number(vol[1]) : 0 };
}
// ---------------- system.audio.get ----------------
/**
* Windows 音频后端标识。
*
* 修正:此前误写为 'CoreAudio'(那是 macOS 的后端名),导致 Windows 被控端也报
* 「CoreAudio」,排查时极易误导。真机 2026-10-04 发现。实现走的是 C# NAudio(CoreAudioAPI)。
*/
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 };
}
if (process.platform === 'darwin') {
return { ...(await macState()), platform: 'macos', backend: 'osascript' };
}
throw new CapabilityError(ErrorCodes.UNSUPPORTED_PLATFORM, '音频控制仅支持 Windows / macOS 被控端', {
platform: process.platform,
});
}
// ---------------- 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';
const hasVolume = typeof volumeArg === 'number';
if (!hasMute && !hasVolume) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, '至少提供 mute(布尔)或 volume(0-100)之一');
}
const volume = hasVolume ? Math.max(0, Math.min(100, Math.round(volumeArg as number))) : -1;
if (IS_WINDOWS) {
const flag = hasMute ? ((muteArg as boolean) ? '1' : '0') : '';
const body = `[NAAudio]::Apply('${flag}', ${volume})`;
const state = parseAudioState(await runWindows(body));
return { ...state, platform: 'windows', backend: AUDIO_BACKEND_WIN, applied: { mute: hasMute ? muteArg : null, volume: hasVolume ? volume : null } };
}
if (process.platform === 'darwin') {
if (hasMute) await runMac(`set volume output muted ${muteArg === true ? 'true' : 'false'}`);
if (hasVolume) await runMac(`set volume output volume ${volume}`);
const state = await macState();
return { ...state, platform: 'macos', backend: 'osascript', applied: { mute: hasMute ? muteArg : null, volume: hasVolume ? volume : null } };
}
throw new CapabilityError(ErrorCodes.UNSUPPORTED_PLATFORM, '音频控制仅支持 Windows / macOS 被控端', {
platform: process.platform,
});
}
+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;
}
+271
View File
@@ -0,0 +1,271 @@
/**
* gui.await —— 等待条件成立(v1.6.0)。
*
* ## 为什么需要它(复盘三大短板之「语义」)
* 原来的 GUI 操作是**盲试**:按一下、截个图、看看有没有变化,不行再试。
* 问题不是"找不到元素"(screen.find 已解决),而是**操作之后无法断言界面到了预期状态**。
* 有了本能力,"点了安装 → 等待『完成』按钮出现" 从"睡 3 秒碰运气"变成**可验证的等待**:
*
* await control(text:"完成", timeout:30s) → click(found) → await absent(text:"安装中")
*
* ## 实现要点
* 不写任何新的平台探测代码 —— 四个条件全部**复用既有能力**(window.list /
* screen.find / process.list / fs.stat),因此 Windows/macOS 行为与它们完全一致。
* 轮询期间异常一律视为"尚未命中"并记录 last_error,避免单次抖动整句失败;
* 但超时未命中时会把它带回,方便判断是"真的没有"还是"每次都在报错"。
*/
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { windowList } from './window.js';
import { screenFind } from './window.js';
import { processList } from './system.js';
import { fsStat } from './fs.js';
type Args = Record<string, unknown>;
type Condition = 'window' | 'control' | 'process' | 'file';
const CONDITIONS: readonly Condition[] = ['window', 'control', 'process', 'file'];
/** 判断一次底层调用是否"命中"(兼容各能力不同的返回形态)。 */
function isHit(result: unknown): boolean {
if (result === null || result === undefined) return false;
if (typeof result === 'boolean') return result;
const r = result as Record<string, unknown>;
if (r['found'] === true) return true;
if (r['exists'] === true) return true;
for (const key of ['windows', 'processes', 'matches', 'elements']) {
const arr = r[key];
if (Array.isArray(arr) && arr.length > 0) return true;
}
if (Array.isArray(result) && result.length > 0) return true;
return false;
}
/** 按条件组装底层调用的参数(只透传该条件支持的字段)。 */
function buildInnerArgs(condition: Condition, args: Args): Args {
const num = (k: string): number | undefined => {
const v = args[k];
return typeof v === 'number' && Number.isFinite(v) ? v : undefined;
};
switch (condition) {
case 'window': {
const inner: Args = {};
const title = (args['title'] as string | undefined) ?? (args['pattern'] as string | undefined);
if (title) inner['title_pattern'] = title;
const limit = num('limit');
if (limit !== undefined) inner['limit'] = limit;
return inner;
}
case 'control': {
// 透传 screen.find 的全部相关参数(method=image 时也一样走轮询)
const inner: Args = {};
for (const k of ['text', 'window', 'control_type', 'method', 'template', 'limit', 'timeout_ms']) {
if (args[k] !== undefined) inner[k] = args[k];
}
// v2.0.0:属性谓词(仅 UIA 引擎生效)。有 where 时允许不传 text ——
// 「等一个 enabled 的按钮」比「等一个叫某名字的按钮」更贴近真实意图。
const where = args['where'];
if (where !== undefined) {
if (typeof where !== 'object' || where === null || Array.isArray(where)) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'where 必须是对象,如 {"enabled":true}');
}
const allowed = ['enabled', 'selected', 'value', 'toggle'];
const bad = Object.keys(where as Record<string, unknown>).filter((k) => !allowed.includes(k));
if (bad.length > 0) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `where 只支持 ${allowed.join('/')}`, { got: bad });
}
inner['where'] = where;
}
if (inner['text'] === undefined && where === undefined) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'control 条件需要 text 或 where 之一');
}
return inner;
}
case 'process': {
const inner: Args = {};
const name = args['process'] as string | undefined;
if (name) inner['filter'] = { name_pattern: name };
const limit = num('limit');
if (limit !== undefined) inner['limit'] = limit;
return inner;
}
case 'file': {
const p = args['path'] as string | undefined;
if (!p) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'condition=file 需要 path', {
hint: '如 {"condition":"file","path":"C:\\\\Windows\\\\Temp\\\\setup.log"}',
});
}
return { path: p };
}
}
}
/** 条件 → 底层能力(本进程内直接调用,省去 RPC 往返开销)。 */
function innerProbe(condition: Condition): (args: Args) => Promise<unknown> {
switch (condition) {
case 'window':
return (a) => windowList(a);
case 'control':
return (a) => screenFind(a);
case 'process':
return (a) => processList(a);
case 'file':
return (a) => fsStat(a);
}
}
/**
* v2.0.0 组合条件:`any_of: [ {...}, {...} ]`,任一命中原句即算命中。
*
* 为什么只做 any_of 不做 and/or/not generalize:真实场景里"弹窗出现 **且**
* 其中某按钮 enabled"用 any_of(弹窗) + where(按钮 enabled) 两步就能表达,
* 而通用布尔表达式会把 schema 与求值都复杂化,收益不成比例。
* 若真需要"两个都必须成立",上层连调两次 await 更直白。
*/
function buildAnyOf(args: Args): Array<{ condition: Condition; inner: Args; label: string }> {
const raw = args['any_of'];
if (!Array.isArray(raw) || raw.length === 0) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'any_of 需为非空数组,每项为一个条件对象');
}
if (raw.length > 8) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'any_of 最多 8 个条件', { got: raw.length });
}
return raw.map((item, i) => {
const obj = item as Record<string, unknown>;
const condition = obj['condition'] as Condition | undefined;
if (!condition || !CONDITIONS.includes(condition)) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `any_of[${i}].condition 不支持: ${String(condition)}`, {
allowed: CONDITIONS,
});
}
// 每项复用主条件的构造逻辑(同等校验),顶层键名直接透传
const inner = buildInnerArgs(condition, obj);
const label =
condition === 'control' ? String(obj['text'] ?? JSON.stringify(obj['where'] ?? ''))
: condition === 'window' ? String(obj['title'] ?? obj['pattern'] ?? '')
: condition === 'process' ? String(obj['process'] ?? '')
: String(obj['path'] ?? '');
return { condition, inner, label };
});
}
export async function guiAwait(args: Args): Promise<unknown> {
// v2.0.0:有 any_of 时**不要求**顶层 condition(语义就是"这组条件任一命中"),
// 校验顺序也因此调整为 any_of 先行 —— 否则会先撞上"condition 缺失"的报错。
const condition = args['condition'] as Condition | undefined;
if (!args['any_of'] && (!condition || !CONDITIONS.includes(condition))) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `不支持的 condition: ${String(condition)}`, {
allowed: CONDITIONS,
hint: 'window=等窗口出现/消失;control=等界面元素;process=等进程;file=等文件',
});
}
const state = (args['state'] as string | undefined) ?? 'present';
if (state !== 'present' && state !== 'absent') {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `不支持的 state: ${state}`, {
allowed: ['present', 'absent'],
});
}
const timeoutMs = Math.max(0, Math.min(60_000, (args['timeout_ms'] as number | undefined) ?? 5000));
const intervalMs = Math.max(50, Math.min(5000, (args['interval_ms'] as number | undefined) ?? 400));
const startedAt = Date.now();
let attempts = 0;
let lastHit = false;
let lastError: string | null = null;
// v2.0.0:组合条件走单独路径(任一命中即算),失败语义与单条完全一致
const anyOf = args['any_of'] !== undefined ? buildAnyOf(args) : null;
if (anyOf) {
for (;;) {
attempts += 1;
let hitLabel: string | null = null;
lastError = null;
for (const c of anyOf) {
try {
const r = await innerProbe(c.condition)(c.inner);
if (isHit(r)) { hitLabel = c.label; break; }
} catch (err) {
lastError = err instanceof Error ? err.message : String(err);
}
}
const satisfied = (state === 'present' ? hitLabel !== null : hitLabel === null) as boolean;
if (satisfied) {
return {
satisfied: true,
condition: 'any_of',
state,
elapsed_ms: Date.now() - startedAt,
attempts,
last_seen: state,
hit: hitLabel ?? undefined,
note: hitLabel ? `命中:${hitLabel}` : '所有子条件均已消失',
};
}
if (Date.now() - startedAt >= timeoutMs) {
return {
satisfied: false,
condition: 'any_of',
state,
elapsed_ms: Date.now() - startedAt,
attempts,
last_seen: lastHit ? 'present' : 'absent',
last_error: lastError ?? undefined,
note: `等待 ${timeoutMs}ms,${anyOf.length} 个条件均未${state === 'present' ? '命中' : '消失'}`,
};
}
await new Promise((r) => setTimeout(r, intervalMs));
}
}
// 能走到这里说明没走 any_of 分支,而进入函数时的校验已保证 condition 合法;
// TS 无法跨分支收窄,这里显式断言一次(buildInnerArgs / innerProbe 内部还会各自校验)。
const single = condition as Condition;
const innerArgs = buildInnerArgs(single, args);
const probe = innerProbe(single);
for (;;) {
attempts += 1;
try {
const result = await probe(innerArgs);
lastHit = isHit(result);
lastError = null;
} catch (err) {
// 轮询期异常不代表条件不成立(可能正忙于启动)——记下再试
lastError = err instanceof Error ? err.message : String(err);
lastHit = false;
}
const satisfied = state === 'present' ? lastHit : !lastHit;
if (satisfied) {
return {
satisfied: true,
condition,
state,
elapsed_ms: Date.now() - startedAt,
attempts,
last_seen: state,
note:
state === 'absent'
? '条件已消失(连续一次未命中即视为消失;如需更稳可加大 interval_ms)'
: undefined,
};
}
if (Date.now() - startedAt >= timeoutMs) {
return {
satisfied: false,
condition,
state,
elapsed_ms: Date.now() - startedAt,
attempts,
last_seen: lastHit ? 'present' : 'absent',
last_error: lastError ?? undefined,
note:
state === 'present'
? `等待 ${timeoutMs}ms 仍未出现${lastError ? `,期间持续报错:${lastError.slice(0, 160)}` : ''}`
: `等待 ${timeoutMs}ms 仍未消失${lastError ? `,期间持续报错:${lastError.slice(0, 160)}` : ''}`,
};
}
await new Promise((r) => setTimeout(r, intervalMs));
}
}
+55 -9
View File
@@ -3,9 +3,13 @@ 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>;
/** Set-Clipboard 遇剪贴板被瞬时占用时的重试次数(实测约 20% 概率偶发)。 */
const CLIP_SET_RETRIES = 3;
/**
* 图片类剪贴板操作必须走**临时文件**:
* 命令行参数在 Windows 上有 ~32KB 上限,而截图 base64 常达数 MB;
@@ -27,9 +31,57 @@ function psPath(p: string): string {
return p.replace(/\\/g, '\\\\');
}
/**
* PowerShell 写 Windows 剪贴板,**带重试**。
*
* 背景(真机 2026-10-04 实测):`Set-Clipboard` 偶发抛「所请求的剪贴板操作失败」
* —— 剪贴板是全系统共享资源,被瞬时占用(其他程序刚写/读、其他 RDP 会话切换)即会失败。
* 更坑的是**该错误只写 stderr 而 exit_code 仍为 0**,故 `exit_code !== 0` 判据抓不到它。
*
* 策略:重试若干次;仍失败则以**读回校验**为准 —— 剪贴板可能已被前一次成功写入,
* 读到目标内容即视为成功(避免「成功却报错」的假失败)。
*
* @param b64 UTF-8 文本的 base64(避免命令行 ANSI 编码问题)
* @param expect 期望读回的内容(用于校验)
*/
async function writeClipboardWindows(b64: string, expect: string): Promise<void> {
const setPs = `Set-Clipboard -Value ([System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${b64}')))`;
const getPs = 'Get-Clipboard -Raw -ErrorAction SilentlyContinue';
let lastErr = '';
for (let attempt = 1; attempt <= CLIP_SET_RETRIES; attempt++) {
const r = await execCommand({ command: setPs, timeoutMs: 10_000 });
// exit_code 非 0 是硬失败;124 是被超时强杀
if (r.exit_code !== 0 && r.exit_code !== 124) {
lastErr = r.stderr.slice(0, 300) || `exit_code=${r.exit_code}`;
} else if (r.stderr) {
// exit_code 为 0 但 stderr 有内容 = 上面说的「软失败」,记录后重试
lastErr = r.stderr.slice(0, 300);
} else {
return; // 干净成功
}
if (attempt < CLIP_SET_RETRIES) {
// 退避后再试:给占用方释放剪贴板的时间
await new Promise((res) => setTimeout(res, 150 * attempt));
continue;
}
// 最后一次尝试后:读回校验。也许前一次其实写成功了(只是报了软失败)
const back = await execCommand({ command: getPs, timeoutMs: 10_000 });
if (back.stdout.trim() === expect.trim()) return;
}
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '写入剪贴板失败', {
detail: `${lastErr}(已重试 ${CLIP_SET_RETRIES} 次,并经读回校验仍不匹配)`,
});
}
// ---------------- 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) {
@@ -83,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;
@@ -100,15 +154,7 @@ export async function clipSet(args: Args): Promise<unknown> {
if (IS_WINDOWS) {
// 文本经 base64 传输,避免命令行参数编码问题(PS 对非 ASCII 参数不可靠)
const b64 = Buffer.from(text, 'utf8').toString('base64');
const r = await execCommand({
command: `Set-Clipboard -Value ([System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${b64}')))`,
timeoutMs: 10_000,
});
if (r.exit_code !== 0) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '写入剪贴板失败', {
detail: r.stderr.slice(0, 300),
});
}
await writeClipboardWindows(b64, text);
} else {
const b64 = Buffer.from(text, 'utf8').toString('base64');
const r = await execCommand({
+438
View File
@@ -0,0 +1,438 @@
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>;
/**
* macOS 端 GUI 语义(v12.3 / 跨平台对齐)。
*
* 与 Windows 的对照:
* | 能力 | Windows | macOS |
* |-----------------|--------------------------|-----------------------------------------|
* | window.list | Win32 EnumWindows | AppleScript System Events(需辅助功能权限)|
* | window.focus | SetForegroundWindow+兜底 | AppleScript frontmost + AXRaise |
* | screen.find | UIA → OCR(WinRT) | 无 UIA 等价物 → 截图 + **Vision OCR** |
*
* 权限说明(macOS 的硬门槛,无法绕过):
* - 枚举/聚焦窗口:系统设置 → 隐私与安全性 → **辅助功能**里勾选运行 agent 的程序
* - 截屏做 OCR:**屏幕录制**权限
* 未授权时给出明确指引,而不是笼统失败。
*/
const OSA = 'osascript';
function assertDarwin(capability: string): void {
if (IS_WINDOWS) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `${capability} 的 macOS 实现不应在 Windows 上调用`);
}
if (process.platform !== 'darwin') {
throw new CapabilityError(ErrorCodes.UNSUPPORTED_PLATFORM, `${capability} 暂不支持 ${process.platform}`, {
platform: process.platform,
});
}
}
/** 跑 AppleScript:脚本经 stdin 传入,避免引号转义问题。 */
async function runOsa(script: string, timeoutMs = 30_000): Promise<{ ok: boolean; stdout: string; stderr: string }> {
const b64 = Buffer.from(script, 'utf8').toString('base64');
const r = await execCommand({
// -e 接受多行;用 base64 传入再还原,彻底规避引号/换行问题
command: `printf %s '${b64}' | base64 -d | ${OSA} -l AppleScript -`,
timeoutMs,
});
return { ok: r.exit_code === 0, stdout: r.stdout.trim(), stderr: (r.stderr || '').trim() };
}
/**
* 把 macOS 的权限类错误翻译成可执行指引。
* 实测错误码/文案(真机):
* -10004「发生权限违例 / privilege violation」→ 辅助功能(Accessibility)未授权
* -1743「Not authorized to send Apple events」→ 自动化授权
* -25211 / 「not allowed assistive access」→ 辅助功能
* 未授权时 screencapture 通常仍成功但内容为纯黑/仅桌面 —— 由 OCR 结果为空体现。
*/
function permissionHint(raw: string): string {
if (/10004|权限违例|privilege violation|not allowed assistive|不允许辅助访问|-25211|-1719/i.test(raw)) {
return '(请到「系统设置 → 隐私与安全性 → 辅助功能」勾选运行 agent 的程序,然后重启它)';
}
if (/not authorized to send Apple events|-1743/i.test(raw)) {
return '(请到「系统设置 → 隐私与安全性 → 自动化」允许控制 System Events)';
}
return '';
}
// ---------------- window.list ----------------
const LIST_SCRIPT = `
set out to ""
tell application "System Events"
set fgProc to name of first application process whose frontmost is true
repeat with p in (every application process whose visible is true)
try
set pname to name of p
set ppid to unix id of p
repeat with w in (every window of p)
try
set wname to name of w
set wpos to position of w
set wsz to size of w
set isFg to (pname is fgProc)
set out to out & pname & "\\t" & ppid & "\\t" & wname & "\\t" & (item 1 of wpos) & "\\t" & (item 2 of wpos) & "\\t" & (item 1 of wsz) & "\\t" & (item 2 of wsz) & "\\t" & isFg & "\\n"
end try
end repeat
end try
end repeat
end tell
return out
`;
interface MacWindow {
title: string;
process: string;
pid: number;
x: number;
y: number;
width: number;
height: number;
is_foreground: boolean;
is_minimized: boolean;
}
export async function macWindowList(args: Args): Promise<unknown> {
assertDarwin('window.list');
const r = await runOsa(LIST_SCRIPT, 40_000);
if (!r.ok) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, `枚举窗口失败${permissionHint(r.stderr)}`, {
detail: r.stderr.slice(0, 300),
});
}
const all: MacWindow[] = [];
for (const line of r.stdout.split('\n')) {
const f = line.split('\t');
if (f.length < 8) continue;
const [process, pidRaw, title, x, y, w, h, fg] = f as [
string, string, string, string, string, string, string, string,
];
const width = Number(w);
const height = Number(h);
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 40 || height <= 40) continue;
all.push({
title: title || process,
process,
pid: Number(pidRaw) || 0,
x: Number(x),
y: Number(y),
width,
height,
is_foreground: fg.trim().toLowerCase() === 'true',
is_minimized: false, // AppleScript 取「every window」通常已排除最小化窗口
});
}
const pattern = args['title_pattern'] as string | undefined;
let filtered = all;
if (pattern) {
const re = new RegExp(pattern, 'i');
filtered = all.filter((w) => re.test(w.title) || re.test(w.process));
}
// 与 Windows 侧保持一致:前台窗口置顶,避免被 limit 截掉
filtered.sort((a, b) => Number(b.is_foreground) - Number(a.is_foreground));
const limit = (args['limit'] as number | undefined) ?? 50;
return { windows: filtered.slice(0, limit), total: filtered.length };
}
// ---------------- window.focus ----------------
export async function macWindowFocus(args: Args): Promise<unknown> {
assertDarwin('window.focus');
const title = args['title'] as string | undefined;
if (!title) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'macOS 端目前需提供 title(正则);暂不支持句柄');
}
const waitMs = Math.max(0, Math.min(30_000, (args['wait_ms'] as number | undefined) ?? 0));
const startedAt = Date.now();
const script = `
set re to "${title.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"
set targetProc to ""
set targetTitle to ""
tell application "System Events"
repeat with p in (every application process whose visible is true)
try
set pname to name of p
repeat with w in (every window of p)
try
set wname to name of w
if wname contains re or pname contains re then
set targetProc to pname
set targetTitle to wname
exit repeat
end if
end try
end repeat
if targetProc is not "" then exit repeat
end try
end repeat
if targetProc is "" then return "NOTFOUND"
set frontmost of (first application process whose name is targetProc) to true
try
perform action "AXRaise" of (first window of (first application process whose name is targetProc))
end try
delay 0.3
end tell
set out to ""
tell application "System Events"
set fg to name of first application process whose frontmost is true
set p to first application process whose name is targetProc
set wpos to position of first window of p
set wsz to size of first window of p
set out to targetTitle & "\\t" & (item 1 of wpos) & "\\t" & (item 2 of wpos) & "\\t" & (item 1 of wsz) & "\\t" & (item 2 of wsz) & "\\t" & (fg is targetProc)
end tell
return out
`;
let lastErr = '';
for (;;) {
const r = await runOsa(script, 40_000);
if (r.ok && r.stdout && r.stdout !== 'NOTFOUND') {
const f = r.stdout.split('\t');
if (f.length >= 6) {
return {
title: f[0],
x: Number(f[1]),
y: Number(f[2]),
width: Number(f[3]),
height: Number(f[4]),
focused: (f[5] ?? '').trim().toLowerCase() === 'true',
activated_by: 'applescript',
waited_ms: Date.now() - startedAt,
};
}
}
lastErr = r.ok ? '未找到匹配的窗口' : r.stderr;
if (Date.now() - startedAt >= waitMs) break;
await new Promise((res) => setTimeout(res, 500));
}
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, `聚焦失败:${lastErr}${permissionHint(lastErr)}`, {
detail: lastErr.slice(0, 300),
});
}
// ---------------- screen.find(截图 + Vision OCR) ----------------
/** Vision OCR 助手:编译一次后缓存到数据目录,后续秒级复用。 */
const OCR_SWIFT = `
import Foundation
import Vision
import AppKit
// 用法: naocr <image.png> [searchText]
// 输出: JSON 数组 [{text,x,y,w,h,confidence}](像素坐标,原点左上)
let args = CommandLine.arguments
guard args.count >= 2 else { FileHandle.standardError.write("usage: naocr <image> [text]\\n".data(using: .utf8)!); exit(2) }
let imagePath = args[1]
let search = args.count >= 3 ? args[2] : ""
guard let img = NSImage(contentsOfFile: imagePath),
let tiff = img.tiffRepresentation,
let bitmap = NSBitmapImageRep(data: tiff),
let cg = bitmap.cgImage else {
FileHandle.standardError.write("cannot load image\\n".data(using: .utf8)!); exit(3)
}
let width = Double(cg.width), height = Double(cg.height)
let request = VNRecognizeTextRequest()
request.recognitionLevel = .accurate
request.usesLanguageCorrection = false
request.recognitionLanguages = ["zh-Hans", "en-US"]
let handler = VNImageRequestHandler(cgImage: cg, options: [:])
do { try handler.perform([request]) } catch {
FileHandle.standardError.write("ocr failed: \\(error)\\n".data(using: .utf8)!); exit(4)
}
let norm: (String) -> String = { $0.replacingOccurrences(of: " ", with: "") }
let want = norm(search)
var out: [[String: Any]] = []
for obs in (request.results ?? []) {
guard let top = obs.topCandidates(1).first else { continue }
let text = top.string
if !want.isEmpty && !norm(text).lowercased().contains(want.lowercased()) { continue }
let bb = obs.boundingBox
let x = bb.minX * width
let y = (1.0 - bb.maxY) * height
let w = bb.width * width
let h = bb.height * height
out.append(["text": text, "x": x, "y": y, "w": w, "h": h, "confidence": Double(top.confidence)])
}
let data = try JSONSerialization.data(withJSONObject: out, options: [])
FileHandle.standardOutput.write(data)
`;
/**
* 读取 PNG 的像素尺寸(解析 IHDR 头,不启动额外进程)。
* 用途:Retina 屏上 `screencapture` 产出的是 **2x 像素**图,而窗口坐标是**逻辑点**,
* 若不做换算,返回的坐标会让点击偏移一倍(真机踩到)。
*/
function pngSize(file: string): { width: number; height: number } | null {
try {
const buf = readFileSync(file);
// IHDR 数据从第 16 字节开始:width(4) height(4)
if (buf.length < 24 || buf.readUInt32BE(12) !== 0x49484452) return null;
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
} catch {
return null;
}
}
function ocrBinPath(): string {
return join(agentDir(), 'bin', 'naocr');
}
/** 确保 OCR 助手已编译(源码变化或二进制缺失时重编)。 */
async function ensureOcrBin(): Promise<string> {
const dir = join(agentDir(), 'bin');
mkdirSync(dir, { recursive: true });
const src = join(dir, 'naocr.swift');
const bin = ocrBinPath();
const needSrc = !existsSync(src) || readFileSync(src, 'utf8') !== OCR_SWIFT;
if (needSrc) writeFileSync(src, OCR_SWIFT);
const needBuild = needSrc || !existsSync(bin);
if (needBuild) {
const r = await execCommand({
command: `swiftc -O -o '${bin}' '${src}'`,
timeoutMs: 180_000,
});
if (r.exit_code !== 0) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '编译 OCR 助手失败(需要 Xcode 命令行工具)', {
detail: (r.stderr || r.stdout).slice(0, 300),
});
}
}
return bin;
}
export async function macScreenFind(args: Args): Promise<unknown> {
assertDarwin('screen.find');
const text = args['text'] as string;
if (!text) {
// macOS 走 Vision OCR,**没有控件属性可读** —— where-only 无法满足,
// 明确说清原因,别让调用方以为是参数写错。
throw new CapabilityError(
ErrorCodes.UNSUPPORTED_PLATFORM,
'macOS 无 UIA 等价物:where 属性过滤不可用,请提供 text(OCR 按文字查找)',
{ platform: 'darwin', engine: 'ocr' },
);
}
const limit = (args['limit'] as number | undefined) ?? 20;
const waitMs = Math.max(0, Math.min(30_000, (args['wait_ms'] as number | undefined) ?? 0));
const intervalMs = Math.max(100, Math.min(2000, (args['interval_ms'] as number | undefined) ?? 400));
const region = args['region'] as { x: number; y: number; width: number; height: number } | undefined;
const bin = await ensureOcrBin();
const { mkdtempSync, rmSync } = await import('node:fs');
const { tmpdir } = await import('node:os');
const dir = mkdtempSync(join(tmpdir(), 'na-ocr-'));
const shot = join(dir, 'shot.png');
const captureRegion = region ?? (await fullVirtualScreen());
const startedAt = Date.now();
let matches: Array<Record<string, unknown>> = [];
try {
for (;;) {
const cap = await execCommand({
command: `screencapture -x -t png -R ${captureRegion.x},${captureRegion.y},${captureRegion.width},${captureRegion.height} ${shLit(shot)}`,
timeoutMs: 30_000,
});
if (cap.exit_code !== 0) {
throw new CapabilityError(
ErrorCodes.EXECUTION_FAILED,
'截屏失败(需「屏幕录制」权限)',
{ detail: (cap.stderr || '').slice(0, 200) },
);
}
// Retina 换算:图片像素 / 请求区域点数 = 缩放比(通常 2)
const img = pngSize(shot);
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: `${shLit(bin)} ${shLit(shot)} ${shLit(text)}`,
timeoutMs: 60_000,
maxOutputBytes: 4 * 1024 * 1024,
});
if (r.exit_code !== 0) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, 'OCR 失败', {
detail: (r.stderr || r.stdout).slice(0, 300),
});
}
const start = r.stdout.indexOf('[');
const end = r.stdout.lastIndexOf(']');
const raw =
start >= 0 && end > start
? (JSON.parse(r.stdout.slice(start, end + 1)) as Array<{
text: string;
x: number;
y: number;
w: number;
h: number;
confidence: number;
}>)
: [];
matches = raw.slice(0, limit).map((m) => {
// 先由像素换算回逻辑点,再叠加区域偏移 -> 得到可直接用于点击的屏幕坐标
const px = m.x / scale;
const py = m.y / scale;
const pw = m.w / scale;
const ph = m.h / scale;
return {
name: m.text,
control_type: 'Text(ocr)',
x: Math.round(px + pw / 2 + captureRegion.x),
y: Math.round(py + ph / 2 + captureRegion.y),
left: Math.round(px + captureRegion.x),
top: Math.round(py + captureRegion.y),
width: Math.round(pw),
height: Math.round(ph),
};
});
if (matches.length > 0 || Date.now() - startedAt >= waitMs) break;
await new Promise((res) => setTimeout(res, intervalMs));
}
} finally {
try {
rmSync(dir, { recursive: true, force: true });
} catch {
/* 忽略清理失败 */
}
}
return {
matches,
engine: 'ocr',
waited_ms: Date.now() - startedAt,
note: 'macOS 无 UIA 等价物,统一走 Vision OCR(只识别可见内容);坐标已由 Retina 像素换算为逻辑点',
};
}
/** 取整块虚拟屏的尺寸(多屏取并集)。 */
async function fullVirtualScreen(): Promise<{ x: number; y: number; width: number; height: number }> {
const r = await runOsa(
`tell application "Finder" to get bounds of window of desktop`,
10_000,
);
if (r.ok) {
const m = /(-?\d+),\s*(-?\d+),\s*(-?\d+),\s*(-?\d+)/.exec(r.stdout);
if (m) {
const [x1, y1, x2, y2] = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])];
return { x: x1, y: y1, width: x2 - x1, height: y2 - y1 };
}
}
return { x: 0, y: 0, width: 1920, height: 1080 };
}
+57 -8
View File
@@ -1,4 +1,4 @@
import { createHash } from 'node:crypto';
import { createHash, randomBytes } from 'node:crypto';
import {
appendFileSync,
closeSync,
@@ -7,6 +7,7 @@ import {
openSync,
readSync,
readdirSync,
realpathSync,
renameSync,
statSync,
writeFileSync,
@@ -26,24 +27,70 @@ const HASH_LIMIT_BYTES = 8 * 1024 * 1024;
let allowedRoots: string[] = [];
export function setFsRoots(roots: string[] | undefined): void {
allowedRoots = (roots ?? []).map((r) => resolve(r));
// 白名单根也做一次 realpath 归一:否则「配置里写的是软链」会与后面 realpath 后的路径对不上
allowedRoots = (roots ?? []).map((r) => realPathBestEffort(resolve(r)));
}
function guardPath(input: string): string {
/**
* 尽力取真实路径(解析符号链接)。
*
* - 路径存在 → 直接 `realpathSync`
* - 不存在(写入场景很常见)→ 向上找到**最近的已存在祖先**做 realpath,再把剩余段拼回去
*
* 这样即便目标文件本身还不存在,也能正确判断「它最终会落在哪里」,
* 从而拦住「用软链接把访问点引到白名单之外」这一类绕过。
*/
function realPathBestEffort(abs: string): string {
try {
return realpathSync.native(abs);
} catch {
/* 继续向上找 */
}
const parts: string[] = [];
let cur = abs;
for (;;) {
const parent = dirname(cur);
if (parent === cur) return abs; // 到根了仍不存在 → 原样返回
parts.unshift(cur.slice(parent.length + (parent.endsWith(sep) ? 0 : 1)));
try {
const base = realpathSync.native(parent);
return parts.length > 0 ? join(base, ...parts) : base;
} catch {
cur = parent;
}
}
}
/**
* 路径白名单守卫(v5)。
*
* ⚠️ 两道防线,缺一不可:
* 1. **解析符号链接后的真实路径**再做包含判断 —— 只比较字面路径时,
* `白名单/a_软链 → /etc` 这样的路径能直接穿出去(绕过 `fs_roots`)。
* 2. 前缀比较必须带上分隔符(`/root` 不得匹配 `/rootX`),且大小写按平台归一
* (Windows 文件系统不区分大小写)。
*/
export function guardPath(input: string): string {
if (typeof input !== 'string' || input.length === 0) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'path 不能为空');
}
const abs = resolve(input);
if (allowedRoots.length === 0) return abs;
const ok = allowedRoots.some((root) => {
if (abs === root) return true;
const real = realPathBestEffort(abs);
const norm = (p: string): string => (process.platform === 'win32' ? p.toLowerCase() : p);
const target = norm(real);
const ok = allowedRoots.some((rootRaw) => {
const root = norm(rootRaw);
if (target === root) return true;
const prefix = root.endsWith(sep) ? root : root + sep;
return abs.startsWith(prefix);
return target.startsWith(prefix);
});
if (!ok) {
throw new CapabilityError(ErrorCodes.ACL_DENIED, `路径不在允许范围内: ${abs}`, {
allowed_roots: allowedRoots,
resolved: real,
});
}
return abs;
@@ -207,8 +254,10 @@ export async function fsWrite(args: Args): Promise<unknown> {
if (append) {
appendFileSync(p, buf);
} else {
// 原子写:先写临时文件再重命名,避免中途失败留下半截文件
const tmp = `${p}.nodeagent-tmp-${process.pid}`;
// 原子写:先写临时文件再重命名,避免中途失败留下半截文件。
// ⚠️ 临时名必须**唯一**:原来只用 PID,同一进程内并发写同一路径(或两个会话同时写)
// 会互相踩对方的 tmp(先完成的 rename 走后,后一个 renameSync 会 ENOENT 失败)。
const tmp = `${p}.nodeagent-tmp-${process.pid}-${randomBytes(4).toString('hex')}`;
writeFileSync(tmp, buf);
renameSync(tmp, p);
}
+59 -2
View File
@@ -1,16 +1,42 @@
import { CapabilityNames } from '@nodeagent/protocol';
import type { AgentConfig } from '../config.js';
import { systemInfo, systemStatus, processList, serviceList, shellExec, auditList, auditVerify, agentRestart } from './system.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';
import { screenRecord } from './record.js';
import { taskList, taskGet, taskKill } from './task.js';
import { clipGet, clipSet } from './clipboard.js';
import { eventWatch, eventUnwatch, eventList, eventPoll } from '../events.js';
import { netApply, netConfirm, netStatus } from './network.js';
import { audioGet, audioSet } from './audio.js';
import { agentUpdate } from './selfupdate.js';
import { mouseMove, mouseClick, mouseScroll, mouseDrag, keyType, keyPress, setInputPolicy } from './input.js';
import { fsList, fsStat, fsRead, fsWrite, setFsRoots } from './fs.js';
import { guiAwait } from './await.js';
import { logQuery, monitorStart, monitorReport, monitorStop, monitorList, monitorDelete } from './monitor.js';
export type CapabilityHandler = (args: Record<string, unknown>) => Promise<unknown>;
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;
}
export type CapabilityHandler = (
args: Record<string, unknown>,
ctx?: HandlerContext,
) => Promise<unknown>;
/**
* 装配能力注册表。
@@ -38,10 +64,26 @@ export function createCapabilityRegistry(cfg: AgentConfig): Record<string, Capab
[CapabilityNames.MouseDrag]: mouseDrag,
[CapabilityNames.KeyType]: keyType,
[CapabilityNames.KeyPress]: keyPress,
// v1.6.0 等待条件(把盲试变为可断言)
[CapabilityNames.GuiAwait]: guiAwait,
// v2.0.0 状态采样(方向二)
[CapabilityNames.LogQuery]: logQuery,
[CapabilityNames.MonitorStart]: monitorStart,
[CapabilityNames.MonitorReport]: monitorReport,
[CapabilityNames.MonitorStop]: monitorStop,
[CapabilityNames.MonitorList]: monitorList,
[CapabilityNames.MonitorDelete]: monitorDelete,
// v3+ 审计
[CapabilityNames.AuditList]: auditList,
// v11 审计防篡改
[CapabilityNames.AuditVerify]: auditVerify,
// v2.0.0 审计链外部锚定(防整链重写)
[CapabilityNames.AuditHead]: auditHead,
[CapabilityNames.AuditAnchor]: auditAnchor,
// L2 系统级审计兜底(见 docs/AUDIT-L2.md)
[CapabilityNames.AuditL2]: auditL2,
// v13 成功指标
[CapabilityNames.Metrics]: metricsReport,
// v5 文件传输
[CapabilityNames.FsList]: fsList,
[CapabilityNames.FsStat]: fsStat,
@@ -60,5 +102,20 @@ export function createCapabilityRegistry(cfg: AgentConfig): Record<string, Capab
[CapabilityNames.TaskKill]: taskKill,
[CapabilityNames.ClipGet]: clipGet,
[CapabilityNames.ClipSet]: clipSet,
// v19 拉取式自更新
[CapabilityNames.AgentUpdate]: agentUpdate,
// v17 音频控制
[CapabilityNames.AudioGet]: audioGet,
[CapabilityNames.AudioSet]: audioSet,
// v16 网络变更两阶段提交
[CapabilityNames.NetApply]: netApply,
[CapabilityNames.NetConfirm]: netConfirm,
[CapabilityNames.NetStatus]: netStatus,
// v12 事件订阅
[CapabilityNames.EventWatch]: (args, ctx) =>
eventWatch(args, ctx ?? { owner: 'unknown', emit: () => undefined }),
[CapabilityNames.EventUnwatch]: eventUnwatch,
[CapabilityNames.EventList]: eventList,
[CapabilityNames.EventPoll]: eventPoll,
};
}
+200
View File
@@ -0,0 +1,200 @@
/**
* 输入注入的 **PowerShell 脚本生成**(纯函数,无副作用 —— 便于单测)。
*
* 独立成模块的原因:脚本生成逻辑(顺序、逆序释放、hold 插入、PostMessage 路由)
* 以往只能靠真机验证;而它恰好是最容易写错(一行顺序错 → 组合键失效)的部分。
* 现在由单测逐行断言,真机只需验证"最终是否生效"。
*/
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
/** 键名 → 虚拟键码(字母/数字走 ASCII 映射,其余查表白名单)。 */
export const VK_MAP: Record<string, number> = {
ctrl: 0x11, control: 0x11, shift: 0x10, alt: 0x12, win: 0x5b, meta: 0x5b,
enter: 0x0d, return: 0x0d, tab: 0x09, esc: 0x1b, escape: 0x1b, space: 0x20,
backspace: 0x08, delete: 0x2e, del: 0x2e, insert: 0x2d, ins: 0x2d,
up: 0x26, down: 0x28, left: 0x25, right: 0x27,
home: 0x24, end: 0x23, pageup: 0x21, pagedown: 0x22,
capslock: 0x14, printscreen: 0x2c,
// 系统/编辑键补充
pause: 0x13, break: 0x13,
apps: 0x5d, menu: 0x5d,
f1: 0x70, f2: 0x71, f3: 0x72, f4: 0x73, f5: 0x74, f6: 0x75,
f7: 0x76, f8: 0x77, f9: 0x78, f10: 0x79, f11: 0x7a, f12: 0x7b,
f13: 0x7c, f14: 0x7d, f15: 0x7e, f16: 0x7f, f17: 0x80, f18: 0x81,
f19: 0x82, f20: 0x83, f21: 0x84, f22: 0x85, f23: 0x86, f24: 0x87,
// 媒体键(0xA6-0xB7 区段)—— 2026-10-05 补
//
// 为何必要:远程控媒体播放器(音乐 / 视频 / 浏览器内视频)时,
// 若软件热键不可用(精简模式常不注册全局热键),系统媒体键是唯一
// 与「窗口是否可见/被遮挡」无关的通道 —— 窗口最小化、被游戏全屏盖住时
// 依然有效。实测:精简模式的播放器被全屏游戏遮挡时,媒体键仍能控制。
media_play: 0xb3, media_pause: 0xb3, media_play_pause: 0xb3, play_pause: 0xb3,
media_stop: 0xb2, stop: 0xb2,
media_prev: 0xb1, media_previous: 0xb1, prev_track: 0xb1, prev: 0xb1,
media_next: 0xb0, next_track: 0xb0, next: 0xb0,
media_fast_forward: 0xb4, fast_forward: 0xb4,
media_rewind: 0xb7, rewind: 0xb7,
media_eject: 0xb2,
volume_mute: 0xad, mute: 0xad,
volume_down: 0xae,
volume_up: 0xaf,
browser_back: 0xa6, browser_forward: 0xa7, browser_refresh: 0xa8, browser_stop: 0xa9, browser_search: 0xaa,
launch_mail: 0xb4, launch_media: 0xb5, launch_app1: 0xb6, launch_app2: 0xb7,
browser_favorites: 0xab, browser_home: 0xac,
// ---- 左右侧修饰键(v1.5.0)----
// 游戏/部分应用区分左右(如游戏中把 LAlt 设为跑步、RAlt 设为瞄准),故不能只给一个 alt。
lctrl: 0xa2, rctrl: 0xa3, lshift: 0xa0, rshift: 0xa1,
lalt: 0xa4, ralt: 0xa5, lwin: 0x5b, rwin: 0x5c,
super: 0x5b, cmd: 0x5b, left_cmd: 0x5b, right_cmd: 0x5c,
// ---- 小键盘(v1.5.0)----
// numpadN 与数字 N 分开:Excel 等程序靠 NumLock 状态区分二者行为,合成一个会坏掉快捷键。
numpad0: 0x60, numpad1: 0x61, numpad2: 0x62, numpad3: 0x63, numpad4: 0x64,
numpad5: 0x65, numpad6: 0x66, numpad7: 0x67, numpad8: 0x68, numpad9: 0x69,
numpad_multiply: 0x6a, numpad_add: 0x6b, numpad_subtract: 0x6d, numpad_decimal: 0x6e, numpad_divide: 0x6f,
numlock: 0x90, scrolllock: 0x91,
numpad_enter: 0x0d,
// ---- OEM 符号键(v1.5.0,美式布局 VK 码)----
// 必备理由:大量系统/应用快捷键就是"修饰键 + 符号键"(win+d 显示桌面、win+, 速览、
// ctrl++ 放大、ctrl+- 缩小、win+. 表情面板),此前只能靠字母数字,覆盖不到。
oem_1: 0xba, // : ;
oem_2: 0xbf, // / ?
oem_3: 0xc0, // ` ~
oem_4: 0xdb, // [ {
oem_5: 0xdc, // \ |
oem_6: 0xdd, // ] }
oem_7: 0xde, // ' "
oem_8: 0xdf,
oem_minus: 0xbd, // - _
oem_plus: 0xbb, // + =
oem_comma: 0xbc, // , <
oem_period: 0xbe,// . >
// ---- 系统/IME 杂项(v1.5.0)----
sleep: 0x5f, help: 0x2f, select: 0x29, execute: 0x2b, clear: 0x0c,
separator: 0x6c, oem_clear: 0xfe,
kana: 0x15, hangul: 0x15, kanji: 0x19, hanja: 0x19,
convert: 0x1c, nonconvert: 0x1d, accept: 0x1e, modechange: 0x1f,
};
/** 键名 → 虚拟键码。 */
export function toVk(key: string): number {
const k = key.trim().toLowerCase();
if (VK_MAP[k] !== undefined) return VK_MAP[k]!;
if (/^[a-z]$/.test(k)) return k.toUpperCase().charCodeAt(0);
if (/^[0-9]$/.test(k)) return k.charCodeAt(0);
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `不支持的按键: ${key}`, {
supported: [...Object.keys(VK_MAP), 'a-z', '0-9'],
});
}
/**
* 媒体键名 → APPCOMMAND 取值。
*
* 取值见 Windows `APPCOMMAND_*` 常量。整条序列都是媒体键时改走 WM_APPCOMMAND
* 投递(而非 SendInput 注入 VK)—— 前者能定向到指定进程且不受窗口遮挡影响。
*
* ⚠️ 音量键(volume_mute / volume_up / volume_down)**不在此表**:
* WM_APPCOMMAND 的 lParam 低 16 位按 device 段解释,VOLUME_DOWN 与 PAUSE 的裸值相同(都是 1),
* 放进表里会与 PAUSE 撞车。音量键走 VK 注入(0xAD-0xAF)更可靠 —— 系统会自行映射为
* APPCOMMAND_VOLUME_*,故此处刻意不收录音量键,避免 device-bit 歧义。
*/
export const MEDIA_COMMANDS: Record<string, number> = {
media_play: 14, play: 14,
media_pause: 1, pause: 1,
media_play_pause: 12, play_pause: 12,
media_stop: 15, stop: 15,
media_prev: 6, media_previous: 6, prev_track: 6, prev: 6,
media_next: 7, next_track: 7, next: 7,
media_fast_forward: 3, fast_forward: 3,
media_rewind: 4, rewind: 4,
media_close: 10, close: 10,
};
export interface PressBodyOptions {
/** 序列中相邻和弦之间的间隔毫秒 */
gapMs: number;
/** 按下后保持毫秒数(长按) */
holdMs: number;
/** route=post 时的目标进程 PID */
targetPid: number;
/** 投放路由 */
route: 'foreground' | 'post';
}
/** 判断整条序列是否都是媒体键。 */
export function isAllMedia(chords: string[][]): boolean {
return (
chords.length > 0 &&
chords.every((ch) => ch.length === 1 && MEDIA_COMMANDS[ch[0]!.trim().toLowerCase()] !== undefined)
);
}
/**
* 生成媒体键(WM_APPCOMMAND)投放脚本。
* targetPid=0 → HWND_BROADCAST 广播;否则定向该进程全部顶层窗口。
*/
export function buildMediaBody(chords: string[][], opts: PressBodyOptions): string[] {
const body: string[] = [];
const pid = Math.max(0, Math.trunc(opts.targetPid));
for (let i = 0; i < chords.length; i += 1) {
const cmd = MEDIA_COMMANDS[chords[i]![0]!.trim().toLowerCase()]!;
body.push(`[NAInput]::MediaCommand(${cmd}, ${pid})`);
if (i < chords.length - 1 && opts.gapMs > 0) {
body.push(`Start-Sleep -Milliseconds ${opts.gapMs}`);
}
}
return body;
}
/**
* 生成前台注入(SendInput Vk)脚本。
*
* 顺序至关重要:按下按给定顺序、**释放逆序** —— 否则 Windows 组合键状态机会淆
* (例如 ctrl+c 若先释放 ctrl,绝大多数程序收不到"带 ctrl 的 c")。
*/
export function buildForegroundBody(chords: string[][], opts: PressBodyOptions): string[] {
const body: string[] = [];
for (let i = 0; i < chords.length; i += 1) {
const chord = chords[i]!;
if (chord.length === 0) continue;
const vks = chord.map(toVk);
for (const vk of vks) body.push(`[NAInput]::Vk(${vk}, $false)`);
if (opts.holdMs > 0) body.push(`Start-Sleep -Milliseconds ${opts.holdMs}`);
// 逆序释放,保证组合键正确
for (const vk of [...vks].reverse()) body.push(`[NAInput]::Vk(${vk}, $true)`);
if (i < chords.length - 1 && opts.gapMs > 0) {
body.push(`Start-Sleep -Milliseconds ${opts.gapMs}`);
}
}
return body;
}
/**
* 生成后台定向投递(PostMessage)脚本。
*
* 与前台注入同样遵守"按下顺序 / 释放逆序";每和弦投递给目标进程全部顶层窗口。
* C# 侧 PostChord(vks, targetPid, up):up=false → WM_KEYDOWN,up=true → WM_KEYUP。
*/
export function buildPostBody(chords: string[][], opts: PressBodyOptions): string[] {
const body: string[] = [];
const pid = Math.max(1, Math.trunc(opts.targetPid));
for (let i = 0; i < chords.length; i += 1) {
const vks = chords[i]!.map(toVk);
if (vks.length === 0) continue;
body.push(`[NAInput]::PostChord(@(${vks.join(', ')}), ${pid}, $false)`);
if (opts.holdMs > 0) body.push(`Start-Sleep -Milliseconds ${opts.holdMs}`);
// 逆序释放
const rel = [...vks].reverse().join(', ');
body.push(`[NAInput]::PostChord(@(${rel}), ${pid}, $true)`);
if (i < chords.length - 1 && opts.gapMs > 0) {
body.push(`Start-Sleep -Milliseconds ${opts.gapMs}`);
}
}
return body;
}
+243 -67
View File
@@ -1,5 +1,13 @@
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { CapabilityError, ErrorCodes, expandChords } from '@nodeagent/protocol';
import {
isAllMedia,
buildMediaBody,
buildForegroundBody,
buildPostBody,
} from './input-script.js';
import { IS_WINDOWS, execCommand } from '../util/exec.js';
import { PS_DPI_AWARE } from '../util/dpi.js';
import { assertGuiPlatform } from '../util/platform.js';
type Args = Record<string, unknown>;
@@ -79,13 +87,32 @@ public static class NAInput {
SendInput(1, a, SIZE);
}
/// <summary>
/// 注入单个 Unicode 码元(KEYEVENTF_UNICODE)。
///
/// 修正 2026-10-04:原实现用 char 逐字符注入,**BMP 外字符(代理对,如 emoji、
/// 部分生僻字)被拆成两个孤立代理项**,目标应用收到的是非法序列 → 渲染为
/// U+FFFD 或乱码。现改为按 UTF-16 码元成对注入。
///
/// 注意:KEYEVENTF_UNICODE 的扫描码位只有 16 位,故代理对(两个 16 位码元)
/// 必须**各自单独**发送,无法合并 —— 这是 Windows 输入 API 的固有限制。
/// </summary>
public static void UnicodeChar(char c) {
var a = new INPUT[2];
a[0].type = 1; a[0].U.ki.wScan = c; a[0].U.ki.dwFlags = 0x0004;
a[1].type = 1; a[1].U.ki.wScan = c; a[1].U.ki.dwFlags = 0x0006;
a[0].type = 1; a[0].U.ki.wScan = c; a[0].U.ki.dwFlags = 0x0004; // KEYEVENTF_UNICODE
a[1].type = 1; a[1].U.ki.wScan = c; a[1].U.ki.dwFlags = 0x0006; // + KEYUP
SendInput(2, a, SIZE);
}
/// <summary>注入完整字符串(按 UTF-16 码元,代理对自动成对处理)。</summary>
public static void UnicodeString(string s, int intervalMs) {
// 直接遍历 UTF-16 码元:代理对的两个码元会连续注入,效果等同输入该字符
for (int i = 0; i < s.Length; i++) {
UnicodeChar(s[i]);
if (intervalMs > 0) System.Threading.Thread.Sleep(intervalMs);
}
}
public static void Vk(ushort vk, bool up) {
var a = new INPUT[1];
a[0].type = 1; a[0].U.ki.wVk = vk; a[0].U.ki.dwFlags = up ? 2u : 0u;
@@ -109,22 +136,116 @@ public static class NAInput {
var u = new INPUT[1]; u[0].type = 0; u[0].U.mi.dwFlags = upFlag;
SendInput(1, u, SIZE);
}
/// <summary>
/// 媒体控制(2026-10-05 补):经 WM_APPCOMMAND 广播到目标窗口。
///
/// 为何不只靠 VK 注入:媒体键用 SendInput 打进去后,**并非所有播放器都接收**
/// —— 只有当前获得媒体会话焦点的窗口才会响应。而 WM_APPCOMMAND 可直接
/// 投递给指定 hwnd,绕开「谁是当前媒体会话焦点」这层不确定性。
///
/// 用途:远程控制主流媒体播放器(音乐 / 视频)或浏览器视频,且**窗口最小化
/// 或被全屏独占应用遮挡时依然有效**(实测:精简模式的播放器被全屏游戏压住时该通道可用)。
/// </summary>
[DllImport("user32.dll", CharSet = CharSet.Auto)]
private static extern bool PostMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
[DllImport("user32.dll")]
private static extern bool EnumWindows(EnumProc cb, IntPtr p);
private delegate bool EnumProc(IntPtr h, IntPtr p);
[DllImport("user32.dll")]
private static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
/// <summary>
/// 向目标窗口(或其全部顶层窗口)发媒体控制命令。
/// appCommand 取值:14=PLAY 15=STOP 12=PLAY_PAUSE 6=PREV 7=NEXT
/// 3=FFWD 4=REWIND 10=CLOSE 0=PLAY 1=PAUSE
/// targetPid 非 0 时,自动找到该进程的所有顶层窗口并逐个投递
/// (部分播放器会创建数十个辅助窗口,只投主窗口常常无效)。
/// </summary>
public static int MediaCommand(int appCommand, int targetPid) {
int sent = 0;
if (targetPid == 0) {
// HWND_BROADCAST:广播给所有顶层窗口
if (PostMessage((IntPtr)0xFFFF, 0x0319, IntPtr.Zero, (IntPtr)appCommand)) sent++;
return sent;
}
var handles = new System.Collections.Generic.List<IntPtr>();
EnumWindows(delegate(IntPtr h, IntPtr p) {
uint wpid;
GetWindowThreadProcessId(h, out wpid);
if (wpid == (uint)targetPid) handles.Add(h);
return true;
}, IntPtr.Zero);
foreach (var h in handles) {
if (PostMessage(h, 0x0319, IntPtr.Zero, (IntPtr)appCommand)) sent++;
}
return sent;
}
/// <summary>
/// v1.5.0:向目标进程的**全部顶层窗口** PostMessage 键盘消息(后端定向快捷键投放)。
///
/// 场景:目标窗口不在前台(被遮挡 / 最小化 / 聊天窗口在后台),又要给它发快捷键。
/// WM_KEYDOWN=0x0100,WM_KEYUP=0x0101;修饰键按顺序投递,调用方负责释放顺序。
///
/// ⚠️ 已知局限(务必告知调用方):
/// - 部分程序不处理**后台**键盘消息(游戏、部分浏览器与输入型应用直接忽略);
/// - PostMessage 不带 scancode,依赖 lParam 解码按键的程序可能取不到键。
/// 该类目标请改用默认 route=foreground(前台注入,需目标可见)。
/// </summary>
public static int PostChord(ushort[] vks, int targetPid, bool up) {
int sent = 0;
if (vks == null || vks.Length == 0) return 0;
var handles = new System.Collections.Generic.List<IntPtr>();
EnumWindows(delegate(IntPtr h, IntPtr p) {
uint wpid;
GetWindowThreadProcessId(h, out wpid);
if (wpid == (uint)targetPid) handles.Add(h);
return true;
}, IntPtr.Zero);
uint msg = up ? 0x0101u : 0x0100u;
foreach (var h in handles) {
foreach (var vk in vks) {
if (PostMessage(h, msg, (IntPtr)vk, IntPtr.Zero)) sent++;
}
}
return sent;
}
}`;
/** 组装:加类型定义 + 具体操作。 */
function psScript(body: string[]): string {
return [`$ErrorActionPreference = 'Stop'`, `$code = @'`, CS_CODE, `'@`, `Add-Type -TypeDefinition $code`, ...body].join(
'\n',
);
/**
* 组装:加 DPI 感知 + 类型定义 + 具体操作。
*
* 导出**仅供单测断言**(`tests/unit/dpi-awareness.test.mjs` 直接检查这里下发的
* PowerShell 是否带上了 DPI 感知、且位于坐标调用之前)——真机 2026-10-12 的
* 「点不准」缺陷就是这里漏了 DPI 感知,而源码扫描式守卫抓不到。
*/
export function buildInputPsScript(body: string[]): string {
return [
`$ErrorActionPreference = 'Stop'`,
// ⚠️ 必须最先执行,且**不可删**:不设 DPI 感知时 `SetCursorPos` / `mouse_event` 的坐标
// 会被系统按缩放比虚拟化(200% 下 ×2),而 `screen.find`(UIA)返回的是**物理**坐标 ——
// 「找得到、点不准」,且不报任何错。真机 2026-10-12 实证:
// 请求 (100,100) 实际落到 (200,200),请求 (552,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<void> {
const r = await execCommand({ command: psScript(body), timeoutMs });
async function runPs(body: string[], timeoutMs = 30_000): Promise<string> {
const r = await execCommand({ command: buildInputPsScript(body), timeoutMs });
if (r.exit_code !== 0) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '输入注入失败', {
detail: (r.stderr || r.stdout).slice(0, 400),
});
}
return r.stdout ?? '';
}
// ---------------- 鼠标 ----------------
@@ -136,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;
@@ -163,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';
@@ -182,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);
@@ -192,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;
@@ -211,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;
@@ -237,29 +364,6 @@ export async function mouseDrag(args: Args): Promise<unknown> {
return { dragged: true, from: { x: x1, y: y1 }, to: { x: x2, y: y2 }, button, steps };
}
// ---------------- 键盘 ----------------
const VK_MAP: Record<string, number> = {
ctrl: 0x11, control: 0x11, shift: 0x10, alt: 0x12, win: 0x5b, meta: 0x5b,
enter: 0x0d, return: 0x0d, tab: 0x09, esc: 0x1b, escape: 0x1b, space: 0x20,
backspace: 0x08, delete: 0x2e, del: 0x2e, insert: 0x2d, ins: 0x2d,
up: 0x26, down: 0x28, left: 0x25, right: 0x27,
home: 0x24, end: 0x23, pageup: 0x21, pagedown: 0x22,
capslock: 0x14, printscreen: 0x2c,
f1: 0x70, f2: 0x71, f3: 0x72, f4: 0x73, f5: 0x74, f6: 0x75,
f7: 0x76, f8: 0x77, f9: 0x78, f10: 0x79, f11: 0x7a, f12: 0x7b,
};
/** 键名 → 虚拟键码(字母/数字走 ASCII 映射,其余查表白名单)。 */
function toVk(key: string): number {
const k = key.trim().toLowerCase();
if (VK_MAP[k] !== undefined) return VK_MAP[k]!;
if (/^[a-z]$/.test(k)) return k.toUpperCase().charCodeAt(0);
if (/^[0-9]$/.test(k)) return k.charCodeAt(0);
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `不支持的按键: ${key}`, {
supported: [...Object.keys(VK_MAP), 'a-z', '0-9'],
});
}
export async function keyType(args: Args): Promise<unknown> {
assertAllowed('input.key.type');
requireWindows('input.key.type');
@@ -268,50 +372,122 @@ export async function keyType(args: Args): Promise<unknown> {
// 文本经 Base64 传入,避免任何形式的内容注入
const b64 = Buffer.from(text, 'utf8').toString('base64');
// 走 C# 的 UnicodeString:按 UTF-16 码元成对注入,代理对(emoji 等)不再被拆坏
const body = [
`$bytes = [Convert]::FromBase64String('${b64}')`,
`$text = [System.Text.Encoding]::UTF8.GetString($bytes)`,
'foreach ($ch in $text.ToCharArray()) {',
' [NAInput]::UnicodeChar($ch)',
interval > 0 ? ` Start-Sleep -Milliseconds ${interval}` : '',
'}',
].filter(Boolean);
`[NAInput]::UnicodeString($text, ${Math.max(0, Math.round(interval))})`,
];
await runPs(body, 30_000 + text.length * (interval + 5));
return { typed: true, length: text.length };
// 回显注入长度与码元数:中文 1 字 = 1 码元,emoji = 2 码元
// (若将来发现错字,可用 codepoints 辅助定位是 IME 抖动还是注入截断)
return { typed: true, length: text.length, code_points: [...text].length, utf16_units: text.length };
}
export async function keyPress(args: Args): Promise<unknown> {
assertAllowed('input.key.press');
requireWindows('input.key.press');
// v10.2:支持三种语义
// 1) keys —— 单个和弦(如 ["ctrl","c"])
// 2) repeat —— 和弦重复 N 次(解决「连按上键 3 次」这类需求)
// 3) sequence —— 任意和弦序列(如 [["ctrl","c"], ["ctrl","v"]])
const sequence = args['sequence'] as string[][] | undefined;
const repeat = Math.max(1, Math.min(50, (args['repeat'] as number | undefined) ?? 1));
const keys = args['keys'] as string[] | undefined;
// v1.5.0:入参统一经 protocol 的 expandChords 展开 —— 字符串热键 / 预设名 / 老数组形式
// 全部收敛成「归一化和弦序列」。混用多种形式会直接报错,避免"以谁为准"的歧义。
let expanded;
try {
expanded = expandChords({
hotkey: args['hotkey'] as string | undefined,
hotkeys: args['hotkeys'] as string[] | undefined,
preset: args['preset'] as string | undefined,
presets: args['presets'] as string[] | undefined,
keys: args['keys'] as string[] | undefined,
sequence: args['sequence'] as string[][] | undefined,
repeat: args['repeat'] as number | undefined,
});
} catch (err) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `快捷键参数错误: ${err instanceof Error ? err.message : String(err)}`);
}
const chords = expanded.chords;
const presets = expanded.presets;
const chords: string[][] = sequence?.length
? sequence
: keys?.length
? Array.from({ length: repeat }, () => keys)
: [];
if (chords.length === 0) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, '需提供 keys(可配 repeat)或 sequence');
const gap = Math.max(0, Math.min(2000, (args['interval_ms'] as number) ?? 40));
// v1.5.0:长按(按下到释放的保持时长),用于"长按音量/持续按住方向键"等场景
const holdMs = Math.max(0, Math.min(5000, Math.round((args['hold_ms'] as number | undefined) ?? 0)));
// v1.5.0:投放路由。foreground = SendInput 注入当前焦点(默认,旧行为);
// post = 向目标进程全部顶层窗口 PostMessage(窗口在后台时可用,但兼容性差)
const route = (args['route'] as string | undefined) ?? 'foreground';
if (route !== 'foreground' && route !== 'post') {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `不支持的 route: ${route}`, { allowed: ['foreground', 'post'] });
}
const pid = (args['target_pid'] as number) ?? 0;
if (route === 'post' && pid <= 0) {
throw new CapabilityError(
ErrorCodes.PARAM_INVALID,
'route=post 需要提供 target_pid(≥1)—— 后台投递必须知道投给哪个进程',
{ hint: '用 window.list 取目标进程 PID;无目标时请用默认 route=foreground' },
);
}
const body: string[] = [];
const gap = Math.max(0, Math.min(2000, (args['interval_ms'] as number | undefined) ?? 40));
for (let i = 0; i < chords.length; i += 1) {
const chord = chords[i]!;
if (chord.length === 0) continue;
const vks = chord.map(toVk);
for (const vk of vks) body.push(`[NAInput]::Vk(${vk}, $false)`);
// 逆序释放,保证组合键正确
for (const vk of [...vks].reverse()) body.push(`[NAInput]::Vk(${vk}, $true)`);
if (i < chords.length - 1 && gap > 0) body.push(`Start-Sleep -Milliseconds ${gap}`);
// 媒体键走 WM_APPCOMMAND 通道(2026-10-05 补)
//
// 仅当**整条序列都是媒体键**时启用。若混在普通按键里(如 ["ctrl","media_play_pause"])
// 则回退到 VK 注入 —— 那种组合场景本就罕见,SendInput 足够。
const allMedia = isAllMedia(chords);
if (allMedia) {
// target_pid 非 0 时定向投递到该进程的全部顶层窗口(部分播放器会建数十个辅助窗口,
// 只投主窗口常常无效 —— 实测踩过);否则广播。
const mediaBody = buildMediaBody(chords, { gapMs: gap, holdMs, targetPid: pid, route });
await runPs(mediaBody);
return {
pressed: true,
keys: chords.length === 1 ? chords[0] : undefined,
chords,
times: chords.length,
presets,
via: expanded.via,
channel: 'appcommand',
target_pid: pid || null,
};
}
await runPs(body);
return { pressed: true, chords, times: chords.length };
// v1.5.0:后端定向投递(route=post)—— 目标窗口不在前台时使用
if (route === 'post') {
let sent = 0;
const out = await runPs(buildPostBody(chords, { gapMs: gap, holdMs, targetPid: pid, route }));
// 累计投递成功的窗口数(C# 侧每次 PostMessage 成功计数)
let total = 0;
for (const line of out.split('\n')) {
const m = /(\d+)/.exec(line.trim());
if (m) total += Number(m[1]);
}
sent = total;
return {
pressed: true,
keys: chords.length === 1 ? chords[0] : undefined,
chords,
times: chords.length,
presets,
via: expanded.via,
channel: 'postmessage',
route,
target_pid: pid,
sent_windows: sent,
note:
sent === 0
? '未投递到任何窗口(进程可能已退出或没有任何顶层窗口)'
: '后台投递兼容性有限:游戏/部分浏览器与输入型程序不响应后台键盘消息,' +
'无效时改用 route=foreground 并确保目标窗口可见',
};
}
await runPs(buildForegroundBody(chords, { gapMs: gap, holdMs, targetPid: pid, route }));
// 同时回传 keys(单和弦时的回显)与 chords(完整序列)。
// 修正 2026-10-04:此前只回 chords,控制端 CLI 读 keys.join() 直接崩溃。
return {
pressed: true,
keys: chords.length === 1 ? chords[0] : undefined,
chords,
times: chords.length,
presets,
via: expanded.via,
hold_ms: holdMs || undefined,
route,
};
}
+151
View File
@@ -0,0 +1,151 @@
/**
* log.query(v2.0.0,方向二「状态」第一块)。
*
* ## 为什么必须有
* 治间歇性问题(代理不通、杀软拦截、端口时开时闭)要看**历史**,而被控端日志常在
* 几万行以上。`fs.read` 虽有 8MB 上限,但"整份拉回再筛"既慢又占带宽 ——
* 过滤必须发生在 **Windows 侧**。
*
* ## 实现要点
* - 流式逐行读(readline),**不全量载入内存**;扫到 limit 条即停
* - 三级过滤:`pattern`(正则,大小写不敏感)/ `level`(ERROR|WARN|INFO|DEBUG,
* 命中行内含 `[LEVEL]` 或 `LEVEL:` 即算)/ `since`(Unix ms,按行首时间戳或
* 文件 mtime 粗筛)
* - 路径仍走 `fs.guardPath` 的白名单(fs_roots 非空即受限)—— 不新开安全面
* - 单行超长保护:超过 4KB 的行截断后再匹配,避免一行巨型 JSON 拖垮内存
*/
import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';
import { statSync } from 'node:fs';
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { guardPath } from './fs.js';
type Args = Record<string, unknown>;
const MAX_LINE = 4096;
const MAX_SCAN = 2_000_000; // 单次查询最多扫 200 万行,防失控文件拖死 agent
export interface LogLine {
/** 行号(从 1 开始)—— 回看时便于对照原文件 */
n: number;
text: string;
}
export interface LogQueryResult {
path: string;
matched: LogLine[];
/** 实际扫描过的行数(用于判断"是扫完了还是被 limit 截住") */
scanned: number;
/** 是否因达到 MAX_SCAN 提前结束 */
truncated: boolean;
note?: string;
}
/** 从行里推断日志级别(找不到返回 null,不猜)。 */
function detectLevel(line: string): string | null {
const m = /\[(ERROR|WARN|WARNING|INFO|DEBUG|TRACE)\]|\b(ERROR|WARN|INFO|DEBUG|TRACE):/i.exec(line);
if (!m) return null;
const raw = (m[1] ?? m[2] ?? '').toUpperCase();
return raw === 'WARNING' ? 'WARN' : raw;
}
export async function logQuery(args: Args): Promise<LogQueryResult> {
const p = guardPath(args['path'] as string);
const limit = Math.max(1, Math.min(5000, (args['limit'] as number | undefined) ?? 100));
const offset = Math.max(0, (args['offset'] as number | undefined) ?? 0);
const patternStr = args['pattern'] as string | undefined;
const level = (args['level'] as string | undefined)?.toUpperCase();
const since = args['since'] as number | undefined;
const tail = (args['tail'] as boolean | undefined) ?? false;
let re: RegExp | null = null;
if (patternStr) {
try {
re = new RegExp(patternStr, 'i');
} catch (err) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `pattern 不是合法正则: ${patternStr}`, {
detail: err instanceof Error ? err.message : String(err),
});
}
}
if (level && !['ERROR', 'WARN', 'INFO', 'DEBUG', 'TRACE'].includes(level)) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `不支持的 level: ${level}`, {
allowed: ['ERROR', 'WARN', 'INFO', 'DEBUG', 'TRACE'],
});
}
let st;
try {
st = statSync(p);
} catch {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `文件不存在或不可读: ${p}`);
}
if (!st.isFile()) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `不是普通文件: ${p}`);
}
// since 粗筛:文件在 since 之前就没改过,必然没有更新的行 —— 省一次全扫
if (since !== undefined && st.mtimeMs < since) {
return { path: p, matched: [], scanned: 0, truncated: false, note: '文件 mtime 早于 since,未扫描' };
}
// tail 模式:保留最后 limit 条命中的环形缓冲(边扫边丢旧的)
const ring: LogLine[] = [];
let matched = 0;
let scanned = 0;
let truncated = false;
const rl = createInterface({
input: createReadStream(p, { encoding: 'utf8' }),
crlfDelay: Number.POSITIVE_INFINITY,
});
for await (const rawLine of rl) {
scanned += 1;
if (scanned > MAX_SCAN) {
truncated = true;
break;
}
let line = rawLine;
if (line.length > MAX_LINE) line = `${line.slice(0, MAX_LINE)}…[截断]`;
if (re && !re.test(line)) continue;
if (level && detectLevel(line) !== level) continue;
// since 精确筛:只在行首能解出时间戳时才按时间比(解不出就放行,交由调用方判断)
if (since !== undefined) {
const ts = parseLeadingTs(line);
if (ts !== null && ts < since) continue;
}
matched += 1;
if (tail) {
ring.push({ n: scanned, text: line });
if (ring.length > limit) ring.shift();
} else {
if (matched > offset && matched <= offset + limit) ring.push({ n: scanned, text: line });
if (matched > offset + limit) break; // 已取够,提前收工
}
}
return {
path: p,
matched: ring,
scanned,
truncated,
...(truncated ? { note: `扫描超过 ${MAX_SCAN} 行上限,结果可能不完整` } : {}),
};
}
/**
* 行首时间戳 → Unix ms(支持 ISO 与常见 `[2026-10-07 12:34:56]` 形态;解不出返回 null)。
*
* ⚠️ 必须**连同时区后缀一起解析**:只截取到秒会丢掉 `Z` / `+08:00`,
* 让 UTC 时间被当本地时间读 —— 实测会整体偏 8 小时(真机日志跨时区时判断"何时断的"就错了)。
*/
export function parseLeadingTs(line: string): number | null {
const m = /^\[?(\d{4}[-/]\d{2}[-/]\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:?\d{2})?)/.exec(line);
if (!m) return null;
// 无时区信息时按本地时间解析(与 `[2026-10-07 10:00:12]` 这类本地日志的语义一致);
// 带 Z / ±hh:mm 时 Date.parse 自行按该时区处理 —— 关键是**别把后缀截掉**。
const iso = m[1]!.replace(/\//g, '-').replace(' ', 'T');
const t = Date.parse(iso);
return Number.isFinite(t) ? t : null;
}
+411
View File
@@ -0,0 +1,411 @@
/**
* monitor.*(v2.0.0,方向二「状态」第二块)—— 定时采样落盘与回看。
*
* ## 为什么需要(复盘 §四)
* 代理不通、杀软拦截、端口时开时闭都是**间歇性**的,而 `system.status` 只能看当下快照。
* `event.watch` 解决「有事件时通知」,没解决「**持续记录,事后能查**」。
*
* ## 设计
* - 四类采样源:`port`(连通性 + 延迟)/ `process`(存活 + PID 列表)/
* `command`(退出码 + 首行输出)/ `metric`(CPU/内存)
* - 落盘 `<数据目录>/monitors/<id>.jsonl`(一行一样本,追加写 + 立即 flush 语义)
* - `monitor.report` 读回并给摘要:min/max/均值、断线次数、最长中断 —— 直接回答
* 「5 分钟里断过几次、最长断了多久」
* - 并发上限 8 个;单文件 10 万行封顶(防写满磁盘)
* - 采样循环 `unref()`,不阻止 agent 退出;`monitor.stop` 或进程退出即停
*/
import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { connect } from 'node:net';
import { exec, execFile } from 'node:child_process';
import { join, resolve as resolvePath, sep } from 'node:path';
import { homedir, totalmem, freemem, cpus } from 'node:os';
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { logQuery, type LogLine } from './log.js';
type Args = Record<string, unknown>;
export type MonitorSource = 'port' | 'process' | 'command' | 'metric';
interface Sample {
ts: number;
/** 各源自定义字段 */
[k: string]: unknown;
}
interface MonitorRecord {
id: string;
source: MonitorSource;
target: string;
intervalMs: number;
createdAt: number;
samples: number;
file: string;
timer: ReturnType<typeof setInterval>;
/** 首次采样失败时记下,避免每次 tick 都刷错误 */
lastError: string | null;
}
const monitors = new Map<string, MonitorRecord>();
const MAX_MONITORS = 8;
const MAX_SAMPLES_PER_FILE = 100_000;
function dataDir(): string {
return process.env['NODEAGENT_HOME'] ?? join(homedir(), '.nodeagent');
}
function monitorDir(): string {
const d = join(dataDir(), 'monitors');
if (!existsSync(d)) mkdirSync(d, { recursive: true });
return d;
}
/**
* 校验监控 id:**唯一入口**,四个命令(start/report/stop/delete)都必须先过它。
*
* ⚠️ M-01/M-02(真机实测):`monitor.report` / `monitor.delete` 曾经**不校验 id**
* 就直接 `join(monitorDir(), id + '.jsonl')` → `id:"../escape-target"` 可读/删
* 数据目录外的任意 `.jsonl` 文件(路径穿越)。
* 这里除了字符白名单,还做一次**解析后前缀比对**(`..` 与符号链接都拦得住)。
*/
function safeMonitorId(raw: unknown): string {
const id = typeof raw === 'string' ? raw.trim() : '';
if (!id) throw new CapabilityError(ErrorCodes.PARAM_INVALID, '需要 id');
if (!/^[\w.-]{1,64}$/.test(id) || id.includes('..')) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'id 只允许字母数字与 . _ -(≤64 字符,不含 ..)', {
got: id,
});
}
return id;
}
/** 监控样本文件路径 —— id 已在 safeMonitorId 校验过,这里再兜一次「必须在 monitors 目录内」。 */
function monitorFile(id: string): string {
const dir = resolvePath(monitorDir());
const file = resolvePath(dir, `${id}.jsonl`);
if (!file.startsWith(dir + sep)) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'id 解析后越出 monitors 目录', { id });
}
return file;
}
// ---------------- 采样器 ----------------
/** port:TCP 连通性 + 握手耗时(跨平台,无额外依赖)。 */
function samplePort(host: string, port: number, timeoutMs: number): Promise<Sample> {
return new Promise((resolve) => {
const started = Date.now();
const sock = connect({ host, port });
const done = (up: boolean, err?: string): void => {
sock.destroy();
resolve({ ts: Date.now(), up, ms: Date.now() - started, ...(err ? { error: err } : {}) });
};
sock.setTimeout(timeoutMs);
sock.once('connect', () => done(true));
sock.once('timeout', () => done(false, 'timeout'));
sock.once('error', (e) => done(false, String(e.message ?? e)));
});
}
/** process:按名判断存活(Windows tasklist / POSIX ps,与 events.ts 同策略)。 */
function sampleProcess(name: string): Promise<Sample> {
return new Promise((resolve) => {
// ⚠️ D-02(真机实测):原来把 `name` 拼进 `tasklist /FI "IMAGENAME eq ${name}"` 与
// `ps … | grep -i "${name}"`,payload `zzz"; touch /tmp/m…; #` 会被 shell 执行。
// 正解:**不经过 shell** —— Windows 用 execFile 传参数数组,POSIX 直接把 ps 输出
// 交给 JS 过滤(不再 grep)。
const collect = (stdout: string, err: Error | null): void => {
const lines = stdout
.split('\n')
.map((l) => l.trim())
.filter((l) => l.length > 0 && !/^INFO:/i.test(l));
const pids = lines
.map((l) => {
const m = /^(\d+)/.exec(l);
return m ? Number(m[1]) : null;
})
.filter((x): x is number => x !== null);
resolve({
ts: Date.now(),
alive: pids.length > 0,
pids,
...(err && pids.length === 0 ? { error: String(err.message) } : {}),
});
};
if (process.platform === 'win32') {
// 无 shell:name 作为单个参数传入,`"` `;` `&` 等都不再具特殊含义
execFile('tasklist', ['/FI', `IMAGENAME eq ${name}`, '/NH'], { timeout: 8000 }, (err, stdout) =>
collect(stdout ?? '', err),
);
return;
}
execFile('ps', ['-axo', 'pid=,comm='], { timeout: 8000 }, (err, stdout) => {
const needle = name.toLowerCase();
const filtered = (stdout ?? '')
.split('\n')
.filter((l) => l.toLowerCase().includes(needle))
.join('\n');
collect(filtered, err);
});
});
}
/** command:退出码 + 输出首行(用于"跑个探针命令"这类自定义采样)。 */
function sampleCommand(command: string, timeoutMs: number): Promise<Sample> {
return new Promise((resolve) => {
exec(command, { timeout: timeoutMs }, (err, stdout, stderr) => {
const first = (stdout || stderr || '').split('\n')[0]?.slice(0, 500) ?? '';
resolve({
ts: Date.now(),
exit_code: err ? (typeof err.code === 'number' ? err.code : 1) : 0,
first_line: first,
});
});
});
}
/** metric:CPU(两次采样差分)+ 内存。 */
async function sampleMetric(): Promise<Sample> {
const a = cpus();
await new Promise((r) => setTimeout(r, 200));
const b = cpus();
const idle = (c: typeof a): number =>
c.reduce((s, x) => s + x.times.idle + x.times.irq, 0) / c.length;
const total = (c: typeof a): number =>
c.reduce((s, x) => s + Object.values(x.times).reduce((p, q) => p + q, 0), 0) / c.length;
const idleDiff = idle(b) - idle(a);
const totalDiff = total(b) - total(a);
const cpu = totalDiff > 0 ? Math.round((1 - idleDiff / totalDiff) * 10000) / 100 : 0;
const mem = totalmem();
const free = freemem();
return { ts: Date.now(), cpu_pct: cpu, mem_pct: Math.round(((mem - free) / mem) * 10000) / 100 };
}
async function takeSample(rec: MonitorRecord): Promise<Sample> {
switch (rec.source) {
case 'port': {
const [host, portStr] = rec.target.split(':');
return samplePort(host || '127.0.0.1', Number(portStr), Math.min(rec.intervalMs, 5000));
}
case 'process':
return sampleProcess(rec.target);
case 'command':
return sampleCommand(rec.target, Math.min(rec.intervalMs * 2, 20_000));
case 'metric':
return sampleMetric();
}
}
// ---------------- 能力实现 ----------------
export async function monitorStart(args: Args): Promise<unknown> {
const source = args['source'] as MonitorSource | undefined;
if (!source || !['port', 'process', 'command', 'metric'].includes(source)) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `不支持的 source: ${String(source)}`, {
allowed: ['port', 'process', 'command', 'metric'],
hint: 'port 需 target="host:port";process 需 target=进程名;command 需 target=命令;metric 不需要 target',
});
}
const intervalMs = Math.max(500, Math.min(600_000, (args['interval_ms'] as number | undefined) ?? 2000));
const target = (args['target'] as string | undefined) ?? '';
if (source !== 'metric' && !target) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `source=${source} 需要 target`);
}
if (source === 'port' && !/^.+:\d+$/.test(target)) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'port 采样的 target 应为 "host:port"', { got: target });
}
if (monitors.size >= MAX_MONITORS) {
throw new CapabilityError(ErrorCodes.RATE_LIMITED, `并发监控上限 ${MAX_MONITORS} 个`, {
running: [...monitors.keys()],
});
}
const id =
(args['id'] as string | undefined)?.trim() ||
`m_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
safeMonitorId(id); // 与 report/delete 同一套校验(防路径穿越 + 统一口径)
if (monitors.has(id)) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `监控 id 已存在: ${id}`, { hint: '换个 id,或先 monitor.stop' });
}
const file = monitorFile(id);
const rec: MonitorRecord = {
id, source, target, intervalMs, createdAt: Date.now(), samples: 0, file,
timer: setInterval(() => { void tick(rec); }, intervalMs),
lastError: null,
};
monitors.set(id, rec);
// 立即采第一针(调用方往往想马上知道"现在通不通"),不额外等一个 interval
void tick(rec);
return { id, source, target, interval_ms: intervalMs, file, started_at: rec.createdAt };
}
async function tick(rec: MonitorRecord): Promise<void> {
try {
const s = await takeSample(rec);
rec.lastError = null;
appendFileSync(rec.file, `${JSON.stringify(s)}\n`, 'utf8');
rec.samples += 1;
// 行数封顶:超过就从头截断(保留后半),防止磁盘写满
if (rec.samples > MAX_SAMPLES_PER_FILE) {
const lines = readFileSync(rec.file, 'utf8').trim().split('\n');
writeFileSync(rec.file, `${lines.slice(-MAX_SAMPLES_PER_FILE).join('\n')}\n`, 'utf8');
rec.samples = MAX_SAMPLES_PER_FILE;
}
} catch (err) {
rec.lastError = err instanceof Error ? err.message : String(err);
// 采样失败也记一行("查不到"本身也是信息),但不无限刷盘
if (rec.samples < MAX_SAMPLES_PER_FILE) {
appendFileSync(rec.file, `${JSON.stringify({ ts: Date.now(), error: rec.lastError })}\n`, 'utf8');
rec.samples += 1;
}
}
}
export async function monitorReport(args: Args): Promise<unknown> {
const id = safeMonitorId(args['id']);
const rec = monitors.get(id);
const file = monitorFile(id);
if (!rec && !existsSync(file)) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `没有这个监控: ${id}`, { running: [...monitors.keys()] });
}
const since = args['since'] as number | undefined;
const limit = Math.max(1, Math.min(20_000, (args['limit'] as number | undefined) ?? 5000));
const samples: Sample[] = [];
let truncated = false;
if (existsSync(file)) {
const lines = readFileSync(file, 'utf8').split('\n');
for (const line of lines) {
if (!line.trim()) continue;
try {
const s = JSON.parse(line) as Sample;
if (since !== undefined && s.ts < since) continue;
samples.push(s);
} catch {
/* 半行(写入中断)跳过 */
}
}
if (samples.length > limit) {
samples.splice(0, samples.length - limit);
truncated = true;
}
}
return {
id,
running: rec !== undefined,
source: rec?.source ?? 'unknown',
target: rec?.target ?? '',
interval_ms: rec?.intervalMs,
file,
samples: samples.length,
truncated,
last_error: rec?.lastError ?? undefined,
summary: summarize(samples, rec?.source ?? 'port'),
series: samples,
};
}
/** 摘要:把样本压成"人话"—— 断几次、最长多久、值域。 */
function summarize(samples: Sample[], source: MonitorSource): Record<string, unknown> {
if (samples.length === 0) return { n: 0 };
const base: Record<string, unknown> = { n: samples.length };
const ts = samples.map((s) => s.ts);
base.window_ms = Math.max(...ts) - Math.min(...ts);
if (source === 'port') {
const ups = samples.filter((s) => s.up === true);
base.up_count = ups.length;
base.down_count = samples.length - ups.length;
const lat = samples.map((s) => Number(s.ms ?? 0)).filter((x) => Number.isFinite(x));
if (lat.length) {
base.ms_min = Math.min(...lat);
base.ms_max = Math.max(...lat);
base.ms_avg = Math.round((lat.reduce((a, b) => a + b, 0) / lat.length) * 100) / 100;
}
// 最长连续中断
let run = 0;
let worst = 0;
for (const s of samples) {
if (s.up === true) { run = 0; } else { run += 1; worst = Math.max(worst, run); }
}
base.longest_outage_samples = worst;
} else if (source === 'process') {
base.alive_count = samples.filter((s) => s.alive === true).length;
base.dead_count = samples.filter((s) => s.alive !== true).length;
let run = 0;
let worst = 0;
for (const s of samples) {
if (s.alive === true) { run = 0; } else { run += 1; worst = Math.max(worst, run); }
}
base.longest_dead_samples = worst;
} else if (source === 'command') {
const codes = samples.map((s) => Number(s.exit_code ?? -1));
base.ok_count = codes.filter((c) => c === 0).length;
base.fail_count = codes.filter((c) => c !== 0).length;
base.exit_codes_seen = [...new Set(codes)];
} else {
const cpu = samples.map((s) => Number(s.cpu_pct ?? 0));
const mem = samples.map((s) => Number(s.mem_pct ?? 0));
if (cpu.length) {
base.cpu_min = Math.min(...cpu); base.cpu_max = Math.max(...cpu);
base.cpu_avg = Math.round((cpu.reduce((a, b) => a + b, 0) / cpu.length) * 100) / 100;
}
if (mem.length) {
base.mem_min = Math.min(...mem); base.mem_max = Math.max(...mem);
base.mem_avg = Math.round((mem.reduce((a, b) => a + b, 0) / mem.length) * 100) / 100;
}
}
return base;
}
export async function monitorStop(args: Args): Promise<unknown> {
const id = safeMonitorId(args['id']);
const rec = monitors.get(id);
if (!rec) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `没有正在运行的监控: ${id}`, { running: [...monitors.keys()] });
}
clearInterval(rec.timer);
monitors.delete(id);
const report = (await monitorReport({ id })) as { samples: number; summary: Record<string, unknown> };
return { id, stopped: true, samples: report.samples, summary: report.summary, file: rec.file };
}
export async function monitorList(_args: Args): Promise<unknown> {
const running = [...monitors.values()].map((r) => ({
id: r.id, source: r.source, target: r.target, interval_ms: r.intervalMs,
samples: r.samples, started_at: r.createdAt, file: r.file, last_error: r.lastError ?? undefined,
}));
const dir = monitorDir();
let files: string[] = [];
try {
files = (await import('node:fs')).readdirSync(dir).filter((f) => f.endsWith('.jsonl'));
} catch {
/* 目录不存在即无历史 */
}
return {
running,
running_count: running.length,
history_files: files.map((f) => f.replace(/\.jsonl$/, '')),
};
}
/** 供 monitor.delete:删除历史文件(不影响运行中的实例)。 */
export async function monitorDelete(args: Args): Promise<unknown> {
const id = safeMonitorId(args['id']);
if (monitors.has(id)) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, '该监控仍在运行,请先 stop', { hint: `monitor.stop {"id":"${id}"}` });
}
const file = monitorFile(id);
if (!existsSync(file)) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `没有历史文件: ${id}`);
}
rmSync(file, { force: true });
return { id, deleted: true, file };
}
// log.query 与 monitor 同属"状态"方向,放在同一能力文件里导出
export { logQuery };
export type { LogLine };
+748
View File
@@ -0,0 +1,748 @@
import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
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';
type Args = Record<string, unknown>;
/**
* 网络变更两阶段提交(v16 / commit-confirm)。
*
* ## 解决的痛点(审查报告 §5.4「自断生路」悖论)
* 远程改 IP / 切 DHCP 是**最容易把自己关在门外**的操作:改完若新地址连不上,
* 控制端就再也进不来了,而现场又没人能救 —— 只能物理接触。
*
* ## 方案:仿网络设备的 commit-confirm(同 Cisco `reload in 5` 的思路)
* ① 备份当前网络配置(netsh dump / ip addr 快照)
* ② 应用变更
* ③ **注册一个 OS 级的一次性计划任务**,到点自动执行回滚
* —— 关键:任务由 OS 触发,**不依赖 agent 进程存活**(agent 被网络变更
* 影响/重启甚至崩溃都照样回滚)
* ④ 控制端(可能已在新地址上)调用 system.net.confirm 取消该任务 = 提交
* 未确认 → 到点自动回滚 → 链路恢复原样,不会失联。
*
* ## 为什么用 S4U 计划任务
* 网络变更最常发生在「无人值守」场景(无人登录)。S4U 无需保存密码且注销后仍可运行,
* 正是这里需要的语义。
*/
const NET_DIR = (): string => join(agentDir(), 'net');
const TASK_PREFIX = 'nodeagent-netrollback-';
/** 延时执行变更的秒数:留出时间让本次响应先回到调用方(连接随后会被变更切断) */
const APPLY_TASK_PREFIX = 'nodeagent-netapply-';
const APPLY_DELAY_MS = 3000;
export interface PendingRollback {
task_name: string;
created_at: number;
rollback_at: number;
seconds_left: number;
interface: string;
mode: string;
backup_path: string;
requested_by?: string;
}
/**
* 执行一段 PowerShell:**落地临时脚本 + -File**。
*
* ⚠️ 为什么不用 `-EncodedCommand`:真机实测同一段注册计划任务的脚本,
* `-File` 稳定成功并回显 `Scheduled`;`-EncodedCommand` 下 PowerShell 把进度流
* 以 CLIXML 写 stderr、且 stdout 拿不到预期输出,导致「明明成功却被判失败」,
* 个别场景还直接报账号映射错误。计划任务类操作一律走 -File。
*/
async function runPsFile(script: string, timeoutMs: number, tag = 'ps'): Promise<{ exit_code: number; stdout: string; stderr: string }> {
const dir = ensureDir();
const path = join(dir, `${tag}-${Date.now()}.ps1`);
writeFileSync(path, withBom(script), 'utf8');
return execCommand({
command: `powershell -NoProfile -ExecutionPolicy Bypass -File ${psLit(path)}`,
timeoutMs,
});
}
/**
* 生成的 .ps1 **必须带 UTF-8 BOM**。
*
* ⚠️ 真机踩过:PowerShell 5.1 读**无 BOM** 的 .ps1 会按系统 ANSI(GBK) 解析,
* 脚本里的非 ASCII 字符(如中文网卡名)会吃掉后面的闭合引号 →
* 报「The string is missing the terminator」→ 计划任务退出码 1 且不留日志。
* 所有落盘的 PowerShell 脚本一律经此函数。
*/
export function withBom(content: string): string {
return `\uFEFF${content}`;
}
/**
* 把字符串包成 PowerShell **单引号字面量**。
*
* ⚠️ 单引号内一切都是字面量:**反斜杠不需要任何转义**。
* 曾经用 JSON.stringify(双引号)或手动把 \ 翻倍当转义,结果生成 `C:\\Users\\...`
* 这种非法路径 → Out-File 失败 → 计划任务退出码 1 且不留日志(真机踩过)。
* 需要字面量时一律走本函数;需要插值时用双引号但避免反斜杠。
*
* 注:实现已收敛到 `util/quote.ts`(单一真源),此处保留同名导出以兼容既有引用与测试。
*/
export const psLit = psLitShared;
/**
* 生成「注册一次性计划任务」的 PowerShell 脚本(唯一实现,两处复用)。
*
* ⚠️ 两个真机教训都固化在这里:
* 1) **账号名必须用 WindowsIdentity 取**,不要用 "$env:USERDOMAIN\$env:USERNAME" 拼 ——
* 在 TS 模板串里 `\$` 会被当转义吃掉反斜杠,拼出「机器名+纯数字」这种不可解析的账号,
* Register-ScheduledTask 报 HRESULT 0x80070534「No mapping between account names...」。
* 2) 复用同一实现:此前回滚/延时两处各写一份,改了一处漏了另一处(真机踩过)。
*/
export function buildTaskRegistration(taskName: string, scriptPath: string, atIso: string): string {
return [
`$ErrorActionPreference='Stop'`,
`$a = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument '-NoProfile -ExecutionPolicy Bypass -File "${scriptPath}"'`,
`$t = New-ScheduledTaskTrigger -Once -At ([datetime]::Parse('${atIso}'))`,
`$s = New-ScheduledTaskSettingsSet -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Minutes 5)`,
// 用权威身份,绝不拼环境变量(见函数注释)
`$me = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name`,
`$p = New-ScheduledTaskPrincipal -UserId $me -RunLevel Highest -LogonType S4U`,
`Register-ScheduledTask -TaskName '${taskName}' -Action $a -Trigger $t -Settings $s -Principal $p -Force | Out-Null`,
`Write-Output 'Scheduled'`,
].join('\n');
}
function ensureDir(): string {
const d = NET_DIR();
mkdirSync(d, { recursive: true });
return d;
}
/** 从计划任务名解析待确认项(任务名自带时间戳)。 */
function pendingFromTasks(taskNames: string[]): Array<{ task_name: string; rollback_at: number }> {
return taskNames
.filter((t) => t.startsWith(TASK_PREFIX))
.map((t) => {
const ms = Number(t.slice(TASK_PREFIX.length));
return { task_name: t, rollback_at: Number.isFinite(ms) ? ms : 0 };
});
}
// ---------------- 平台实现 ----------------
/** 找出承载默认路由的网卡名(未显式指定 interface 时使用)。 */
async function defaultInterfaceWindows(): Promise<string> {
const ifaceScript =
`$c = Get-NetIPConfiguration | Where-Object { $_.IPv4DefaultGateway } | Select-Object -First 1; ` +
`if ($c) { $c.InterfaceAlias } else { '' }`;
const r = await execCommand({
command: `powershell -NoProfile -EncodedCommand ${Buffer.from(ifaceScript, 'utf16le').toString('base64')}`,
timeoutMs: 30_000,
});
const name = r.stdout.trim();
if (!name) {
throw new CapabilityError(
ErrorCodes.EXECUTION_FAILED,
'无法自动识别默认网卡(没有带默认网关的接口),请显式传 interface',
);
}
return name;
}
/** 掩码 → 前缀长度(255.255.255.0 → 24)。 */
export function maskToPrefix(mask: string): number {
const parts = mask.split('.');
if (parts.length !== 4) return 24;
let bits = 0;
for (const p of parts) {
const n = Number(p);
if (!Number.isInteger(n) || n < 0 || n > 255) return 24;
bits += n.toString(2).split('').filter((c) => c === '1').length;
}
return bits;
}
/** 结构化快照(回滚脚本据此用**原生 cmdlet** 重建配置)。 */
export interface IfaceSnapshot {
alias: string;
dhcp: boolean;
ipv4: Array<{ ip: string; prefix: number }>;
gateway: string[];
dns: string[];
}
async function snapshotIface(iface: string): Promise<IfaceSnapshot> {
const script = `
$ErrorActionPreference='Continue'
$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') }
$o.ipv4 = @(Get-NetIPAddress -InterfaceAlias $a -AddressFamily IPv4 -ErrorAction SilentlyContinue |
Where-Object { $_.PrefixOrigin -ne 'WellKnown' } |
ForEach-Object { [ordered]@{ ip = $_.IPAddress; prefix = [int]$_.PrefixLength } })
$o.gateway = @(Get-NetRoute -InterfaceAlias $a -AddressFamily IPv4 -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue |
ForEach-Object { $_.NextHop })
$o.dns = @(Get-DnsClientServerAddress -InterfaceAlias $a -AddressFamily IPv4 -ErrorAction SilentlyContinue |
ForEach-Object { $_.ServerAddresses })
$o | ConvertTo-Json -Compress -Depth 4
`;
const r = await execCommand({
command: `powershell -NoProfile -EncodedCommand ${Buffer.from(script, 'utf16le').toString('base64')}`,
timeoutMs: 30_000,
});
const start = r.stdout.indexOf('{');
const end = r.stdout.lastIndexOf('}');
const fallback: IfaceSnapshot = { alias: iface, dhcp: false, ipv4: [], gateway: [], dns: [] };
if (start < 0 || end <= start) return fallback;
try {
const j = JSON.parse(r.stdout.slice(start, end + 1)) as Record<string, unknown>;
return {
alias: iface,
dhcp: j['dhcp'] === true,
ipv4: (j['ipv4'] as Array<{ ip: string; prefix: number }>) ?? [],
gateway: (j['gateway'] as string[]) ?? [],
dns: (j['dns'] as string[]) ?? [],
};
} catch {
return fallback;
}
}
/** 由结构化快照生成**原生 cmdlet** 回滚脚本(不用 netsh set —— 它在 Win11 上会挂起)。 */
export function buildRollbackScript(snap: IfaceSnapshot, backupTxt: string, logPath: string): string {
const a = psLit(snap.alias);
const L = psLit(logPath);
const lines = [
`# nodeagent 自动回滚(生成于 ${new Date().toISOString()})`,
`$ErrorActionPreference = 'Continue'`,
`$log = ${L}`,
`function W($m) { ("[" + (Get-Date -Format o) + "] " + $m) | Out-File -Append -Encoding utf8 $log }`,
`W 'rollback begin'`,
`try {`,
];
if (snap.dhcp) {
lines.push(` Set-NetIPInterface -InterfaceAlias ${a} -Dhcp Enabled -ErrorAction SilentlyContinue`);
lines.push(` Set-DnsClientServerAddress -InterfaceAlias ${a} -ResetServerAddresses -ErrorAction SilentlyContinue`);
lines.push(` W 'restored: dhcp'`);
} else {
lines.push(` Set-NetIPInterface -InterfaceAlias ${a} -Dhcp Disabled -ErrorAction SilentlyContinue`);
// 同样「先加后清 + 先清默认路由」——否则回滚本身也会踩 already exists(真机事故根因)
for (const addr of snap.ipv4) {
lines.push(
` 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) => 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 ${psLit(snap.gateway[0])} -ErrorAction SilentlyContinue | Out-Null`,
);
}
if (snap.dns.length > 0) {
lines.push(
` Set-DnsClientServerAddress -InterfaceAlias ${a} -ServerAddresses ${snap.dns.map((d) => psLit(d)).join(',')} -ErrorAction SilentlyContinue`,
);
}
lines.push(` W 'restored: static'`);
}
lines.push(`} catch { W ("rollback failed: " + $_) }`);
lines.push(`W ('netsh dump for manual recovery: ' + ${psLit(backupTxt)})`);
lines.push(`W 'rollback done'`);
return lines.join('\n');
}
/** 读取网卡当前地址(用于回报与审计)。 */
async function readIfaceWindows(iface: string): Promise<Record<string, unknown>> {
const script = `
$ErrorActionPreference='Continue'
$c = Get-NetIPConfiguration -InterfaceAlias ${psLit(iface)} -ErrorAction SilentlyContinue
if (-not $c) { Write-Output '{}'; return }
$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 ${psLit(iface)} -AddressFamily IPv4 -ErrorAction SilentlyContinue
$o.dhcp = ($dhcp.Dhcp -eq 'Enabled')
$o | ConvertTo-Json -Compress
`;
const r = await execCommand({
command: `powershell -NoProfile -EncodedCommand ${Buffer.from(script, 'utf16le').toString('base64')}`,
timeoutMs: 30_000,
});
const start = r.stdout.indexOf('{');
const end = r.stdout.lastIndexOf('}');
if (start < 0 || end <= start) return { interface: iface };
try {
return JSON.parse(r.stdout.slice(start, end + 1)) as Record<string, unknown>;
} catch {
return { interface: iface };
}
}
// ---------------- system.net.apply ----------------
export async function netApply(args: Args): Promise<unknown> {
const mode = (args['mode'] as string | undefined) ?? 'static';
const confirmWithinMs = Math.max(
15_000,
Math.min(600_000, (args['confirm_within_ms'] as number | undefined) ?? 60_000),
);
const explicitIface = args['interface'] as string | undefined;
const customCommand = args['command'] as string | undefined;
const dir = ensureDir();
const ts = Date.now();
if (!IS_WINDOWS) {
// POSIX:只支持「自定义命令 + 分离式回滚」——静态/DHCP 自动化涉及 nmcli/netplan
// 差异过大,未做(避免给出不可靠的自动化承诺)。
if (!customCommand) {
throw new CapabilityError(
ErrorCodes.UNSUPPORTED_PLATFORM,
'macOS/Linux 被控端仅支持 mode=command(自定义命令);静态/DHCP 自动化当前仅 Windows',
{ platform: process.platform },
);
}
return posixCommandApply(customCommand, confirmWithinMs, dir, ts);
}
const iface = explicitIface ?? (await defaultInterfaceWindows());
const before = await readIfaceWindows(iface);
// ① 备份:netsh 导出的脚本可用 `netsh -f` 原样恢复
const backupPath = join(dir, `backup-${ts}.txt`);
// ⚠️ 关键:netsh 输出的是 **OEM 代码页(中文系统=GBK)字节**。经 PowerShell 重定向会被
// 转码破坏,导致回滚时 `netsh -f` 解析失败(报「找不到下列命令」)—— 也就是说
// 回滚看似执行、实际什么都没恢复(真机踩过)。必须走 cmd 的原始重定向保字节。
const dump = await execCommand({
command: `cmd.exe /c netsh -c interface dump > "${backupPath}"`,
timeoutMs: 30_000,
});
if (dump.exit_code !== 0 || !existsSync(backupPath)) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '网络配置备份失败,已中止(不做无备份的变更)', {
detail: (dump.stderr || dump.stdout).slice(0, 300),
});
}
// ② 快照 + 生成回滚脚本(与 agent 进程解耦,由计划任务触发)
// ⚠️ 不用「netsh -f 备份」回滚:备份里是 `set address`,而该命令在 Win11 上会**挂起**
// (真机实测 >60s 无响应)—— 那样回滚等于没有。改为按结构化快照用原生 cmdlet 重建。
const snapshot = await snapshotIface(iface);
const rollbackLog = join(dir, `rollback-${ts}.log`);
const rollbackPath = join(dir, `rollback-${ts}.ps1`);
writeFileSync(rollbackPath, withBom(buildRollbackScript(snapshot, backupPath, rollbackLog)), 'utf8');
// ③ 先注册一次性回滚任务(OS 级,S4U —— 不依赖登录会话与 agent 进程)
// **先武装后开火**:即使 apply 慢或挂起,保护也已经就位。
// 任务名编码的是**回滚时间戳**(不是创建时间),status 可直接算出剩余秒数。
// 回滚时间 = 现在 + 延时执行 + 确认窗口(窗口从变更真正生效时算起)
const at = new Date(Date.now() + APPLY_DELAY_MS + confirmWithinMs);
const taskName = `${TASK_PREFIX}${at.getTime()}`;
const register = buildTaskRegistration(taskName, rollbackPath, at.toISOString());
const reg = await runPsFile(register, 60_000, 'reg-rollback');
if (!reg.stdout.includes('Scheduled')) {
throw new CapabilityError(
ErrorCodes.EXECUTION_FAILED,
'**回滚任务注册失败,已中止变更**(宁可不变更也不做无保护的网络改动)',
{
detail: `exit=${reg.exit_code} ` + (extractPsError(reg.stderr) || reg.stdout.slice(0, 400) || '(无输出)'),
backup_path: backupPath,
},
);
}
// ④ 应用变更 —— **异步执行**,先应答再动手。
//
// 为什么必须异步:改地址/切 DHCP 必然导致本控制端与被控端的连接中断(旧地址被移除),
// 同步执行的话调用方永远收不到返回值(真机实测:客户端 200s 超时,而变更其实已生效)。
// 与网络设备的 commit-confirm 一致:先排程,交出凭据,由调用方在新地址上确认。
const applyCmd = buildApplyCommand(mode, iface, args, snapshot);
const applyScriptPath = join(dir, `apply-${ts}.ps1`);
writeFileSync(
applyScriptPath,
withBom([
`$ErrorActionPreference='Stop'`,
`$log = ${psLit(join(dir, `apply-${ts}.log`))}`,
`try {`,
...applyCmd.split('\n').map((l) => ` ${l}`),
` "apply ok" | Out-File -Append -Encoding utf8 $log`,
`} catch { ("apply failed: " + $_) | Out-File -Append -Encoding utf8 $log }`,
// 自检 + 失败自愈(没有它就会静默把机器留在无地址状态 —— 真机事故根因之一)
buildSelfCheck(iface, mode === 'static' ? (args['ip'] as string) : null, rollbackPath),
].join('\n')),
'utf8',
);
const applyAt = new Date(Date.now() + APPLY_DELAY_MS);
const applyTask = `${APPLY_TASK_PREFIX}${applyAt.getTime()}`;
const regApply = buildTaskRegistration(applyTask, applyScriptPath, applyAt.toISOString());
const regA = await runPsFile(regApply, 60_000, 'reg-apply');
if (!regA.stdout.includes('Scheduled')) {
// 排程失败:撤销已注册的回滚任务,保证状态干净
await runPsFile(
`Unregister-ScheduledTask -TaskName '${taskName}' -Confirm:$false -ErrorAction SilentlyContinue`,
30_000,
'unreg',
);
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '变更排程失败,已撤销回滚任务(配置未改动)', {
detail:
`exit=${regA.exit_code} ` +
(extractPsError(regA.stderr) || '(无 stderr) ') +
` | out=${regA.stdout.replace(/\s+/g, ' ').slice(0, 200)}`,
});
}
audit({
type: 'net.change',
capability: 'system.net.apply',
status: 'ok',
reason:
`mode=${mode} interface=${iface} apply_at=${applyAt.getTime()} rollback_at=${at.getTime()} ` +
`backup=${backupPath} snapshot_dhcp=${snapshot.dhcp} snapshot_ip=${JSON.stringify(snapshot.ipv4)}`,
});
return {
apply_scheduled: true,
apply_at: applyAt.getTime(),
apply_task: applyTask,
mode,
interface: iface,
backup_path: backupPath,
rollback_script: rollbackPath,
rollback_scheduled: true,
task_name: taskName,
rollback_at: at.getTime(),
confirm_within_ms: confirmWithinMs,
before: { ...snapshot, raw: before },
hint:
`变更将于约 ${APPLY_DELAY_MS / 1000}s 后执行(先放行本次响应);连接会随地址变更中断。` +
`请在 ${new Date(at.getTime()).toLocaleTimeString('zh-CN')} 之前(若改了地址,用 nodeagent connect <新地址> --port ${'<端口>'} 重连后)` +
`调用 system.net.confirm 确认提交;逾期自动回滚到变更前配置(${JSON.stringify(snapshot.ipv4)})。`,
snapshot,
};
}
/** 生成应用命令(PowerShell 片段)。 */
/**
* 生成应用脚本(PowerShell,**原生 NetTCPIP cmdlet**)。
*
* ⚠️ 不用 `netsh interface ipv4 set address`:真机实测它在 Windows 11 上会挂起(>60s 无返回),
* 而 `Set-NetIPAddress` / `Set-NetIPInterface` / `Set-DnsClientServerAddress` 同场景 <1s。
* netsh 仅保留用于「读取」与人工恢复用的 dump 备份(对操作者友好)。
*/
export function buildApplyCommand(mode: string, iface: string, args: Args, snap: IfaceSnapshot): string {
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 参数');
return cmd;
}
if (mode === 'dhcp') {
return [
`$ErrorActionPreference='Stop'`,
`Set-NetIPInterface -InterfaceAlias ${a} -Dhcp Enabled -ErrorAction Stop`,
`Set-DnsClientServerAddress -InterfaceAlias ${a} -ResetServerAddresses -ErrorAction SilentlyContinue`,
`Write-Output 'dhcp applied'`,
].join('\n');
}
// static
const ip = args['ip'] as string | undefined;
const mask = args['mask'] as string | undefined;
const gateway = args['gateway'] as string | undefined;
if (!ip || !mask) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'mode=static 需要 ip 与 mask');
}
const prefix = maskToPrefix(mask);
const dns = (args['dns'] as string[] | undefined) ?? [];
const ipLit = psLit(ip);
const lines = [
`$ErrorActionPreference='Continue'`,
// 关掉 DHCP(否则设静态地址会与 DHCP 租约打架)
`Set-NetIPInterface -InterfaceAlias ${a} -Dhcp Disabled -ErrorAction SilentlyContinue`,
// ⚠️ 顺序很关键:**先加新地址**(此时旧地址还在,链路不断)→ 再清旧地址与默认路由 → 最后补路由。
// 曾经写成「先删后加」,结果 Windows 残留的默认路由让 New-NetIPAddress 报 already exists
// → 新地址没加上 → 机器回落到 APIPA 彻底失联(真机事故,务必保留本顺序注释)。
`New-NetIPAddress -InterfaceAlias ${a} -IPAddress ${ipLit} -PrefixLength ${prefix} -ErrorAction SilentlyContinue | Out-Null`,
// 清掉旧的默认路由(不先清,后面 New-NetRoute 会因「实例已存在」失败)
`Get-NetRoute -InterfaceAlias ${a} -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue | Remove-NetRoute -Confirm:$false -ErrorAction SilentlyContinue`,
// 清掉非目标地址(保留 APIPA/回环)
`Get-NetIPAddress -InterfaceAlias ${a} -AddressFamily IPv4 -ErrorAction SilentlyContinue |`,
` Where-Object { $_.IPAddress -ne ${ipLit} -and $_.PrefixOrigin -ne 'WellKnown' } |`,
` ForEach-Object { Remove-NetIPAddress -IPAddress $_.IPAddress -InterfaceAlias ${a} -Confirm:$false -ErrorAction SilentlyContinue }`,
];
if (gateway) {
lines.push(
`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) => psLit(d)).join(',')} -ErrorAction SilentlyContinue`,
);
}
lines.push(`Write-Output 'static applied'`);
return lines.join('\n');
}
/**
* 变更后的自检 + 失败自愈片段。
* ⚠️ 这段是**事故换来的**:没有自检时,变更失败会静默把机器留在「无地址」状态,
* 而调用方还以为变更成功了(真机事故:机器掉到 APIPA 彻底失联)。
*/
export function buildSelfCheck(iface: string, expectIp: string | null, rollbackScriptPath: string): string {
const a = psLit(iface);
const rb = psLit(rollbackScriptPath);
const cond = expectIp
? `$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 [
``,
`# ---- 自检:变更是否真的生效,失败立即回滚(自愈)----`,
`Start-Sleep -Seconds 3`,
cond,
`if ($ok) {`,
` Write-Output 'selfcheck ok'`,
`} else {`,
` Write-Output 'selfcheck FAILED -> auto rollback'`,
` & ${rb}`,
`}`,
].join('\n');
}
/** POSIX:自定义命令 + 分离式回滚(sleep N 后执行回滚命令)。 */
async function posixCommandApply(
applyCommand: string,
confirmWithinMs: number,
dir: string,
ts: number,
): Promise<unknown> {
const backupPath = join(dir, `backup-${ts}.txt`);
const snap = await execCommand({ command: 'ip -o addr 2>/dev/null; echo ---; ip route 2>/dev/null', timeoutMs: 15_000 });
writeFileSync(backupPath, snap.stdout, 'utf8');
// POSIX 恢复:用快照重放地址与路由(尽力而为 —— 各发行版差异大,故提示谨慎使用)
// 注意:不要用 sed 反向引用(\1 在 TS 模板串里是八进制转义,编译期报错)—— 统一用 awk
const rollbackCmd = [
`# 尽力恢复:从快照重放 IPv4 地址与默认路由`,
`awk '/inet /{dev=""; for(i=1;i<=NF;i++) if($i=="dev") dev=$(i+1); if(dev && $2!="127.0.0.1/8") print $2, dev}' '${backupPath}' | while read -r addr dev; do`,
` ip addr add "$addr" dev "$dev" 2>/dev/null`,
`done`,
`awk '/^default/{via=""; dev=""; for(i=1;i<=NF;i++){ if($i=="via") via=$(i+1); if($i=="dev") dev=$(i+1) } if(via && dev) print via, dev}' '${backupPath}' | while read -r via dev; do`,
` ip route add default via "$via" dev "$dev" 2>/dev/null`,
`done`,
].join('\n');
const rollbackPath = join(dir, `rollback-${ts}.sh`);
writeFileSync(
rollbackPath,
withBom([
'#!/bin/sh',
`# nodeagent 自动回滚(${new Date(ts).toISOString()})`,
`sleep ${Math.round(confirmWithinMs / 1000)}`,
`# 若确认文件存在则跳过回滚`,
`[ -f '${join(dir, `confirmed-${ts}`)}' ] && exit 0`,
rollbackCmd,
].join('\n')),
'utf8',
);
const launched = await execCommand({
command: `chmod +x ${shLit(rollbackPath)} && setsid nohup sh ${shLit(rollbackPath)} > ${shLit(join(dir, `rollback-${ts}.log`))} 2>&1 & echo started`,
timeoutMs: 15_000,
});
const applied = await execCommand({ command: applyCommand, timeoutMs: 60_000 });
audit({
type: 'net.change',
capability: 'system.net.apply',
status: applied.exit_code === 0 ? 'ok' : 'failed',
reason: `mode=command confirm_within=${confirmWithinMs}ms backup=${backupPath}`,
});
return {
applied: applied.exit_code === 0,
mode: 'command',
backup_path: backupPath,
rollback_script: rollbackPath,
rollback_scheduled: launched.stdout.includes('started'),
task_name: `posix-${ts}`,
rollback_at: Date.now() + confirmWithinMs,
confirm_within_ms: confirmWithinMs,
hint:
`请在 ${Math.round(confirmWithinMs / 1000)} 秒内调用 system.net.confirm(task_name="posix-${ts}") 确认;` +
`逾期由分离进程执行回滚(依赖备份脚本可恢复,网络类变更的 POSIX 恢复能力弱于 Windows,请谨慎)`,
apply_output: (applied.stdout || applied.stderr).trim().slice(0, 300),
};
}
// ---------------- system.net.confirm ----------------
export async function netConfirm(args: Args): Promise<unknown> {
const taskName = args['task_name'] as string | undefined;
const dir = NET_DIR();
if (!IS_WINDOWS) {
// POSIX:写确认标记文件,回滚脚本会自行跳过
if (!taskName) return { confirmed: 0, note: 'POSIX 需指定 task_name' };
const ts = taskName.replace('posix-', '');
writeFileSync(join(dir, `confirmed-${ts}`), String(Date.now()), 'utf8');
audit({ type: 'net.change', capability: 'system.net.confirm', status: 'ok', reason: `confirmed ${taskName}` });
return { confirmed: 1, cancelled: [taskName] };
}
const listScript = `Get-ScheduledTask -TaskName '${TASK_PREFIX}*' -ErrorAction SilentlyContinue | Select-Object -ExpandProperty TaskName`;
let targets: string[] = [];
if (taskName) {
targets = [taskName];
} else {
const r = await execCommand({
command: `powershell -NoProfile -EncodedCommand ${Buffer.from(listScript, 'utf16le').toString('base64')}`,
timeoutMs: 30_000,
});
targets = r.stdout
.split('\n')
.map((l) => l.trim())
.filter((l) => l.startsWith(TASK_PREFIX));
}
if (targets.length === 0) {
return { confirmed: 0, cancelled: [], note: '没有待确认的网络变更(可能已确认或已回滚)' };
}
const unreg = [
...targets.map((t) => `Unregister-ScheduledTask -TaskName '${t}' -Confirm:$false -ErrorAction SilentlyContinue`),
// 顺带清理已执行过的 netapply 任务(一次性、已完成,留着只会让任务计划程序变乱)
`Get-ScheduledTask -TaskName '${APPLY_TASK_PREFIX}*' -ErrorAction SilentlyContinue | Unregister-ScheduledTask -Confirm:$false -ErrorAction SilentlyContinue`,
].join('\n');
const r = await runPsFile(unreg, 60_000, 'unreg-all');
const stillThere = await execCommand({
command: `powershell -NoProfile -EncodedCommand ${Buffer.from(listScript, 'utf16le').toString('base64')}`,
timeoutMs: 30_000,
});
const remaining = stillThere.stdout
.split('\n')
.map((l) => l.trim())
.filter((l) => l.startsWith(TASK_PREFIX));
audit({
type: 'net.change',
capability: 'system.net.confirm',
status: remaining.length === 0 ? 'ok' : 'failed',
reason: `confirmed=[${targets.join(',')}]`,
});
return {
confirmed: targets.length - remaining.length,
cancelled: targets.filter((t) => !remaining.includes(t)),
remaining,
detail: (r.stderr || '').slice(0, 200),
};
}
// ---------------- system.net.status ----------------
export async function netStatus(_args: Args): Promise<unknown> {
const dir = NET_DIR();
let backups: Array<{ file: string; bytes: number; mtime: number }> = [];
try {
backups = readdirSync(dir)
.filter((f) => f.startsWith('backup-'))
.map((f) => {
const p = join(dir, f);
const st = readFileSync(p);
return { file: p, bytes: st.length, mtime: 0 };
});
} catch {
backups = [];
}
if (!IS_WINDOWS) {
const r = await execCommand({ command: 'ip -o addr 2>/dev/null | head -20', timeoutMs: 15_000 });
return { platform: process.platform, addresses: r.stdout.trim().split('\n'), backups, pending: [] };
}
const listScript = `Get-ScheduledTask -TaskName '${TASK_PREFIX}*' -ErrorAction SilentlyContinue | ForEach-Object { $_.TaskName + '|' + $_.State }`;
const r = await execCommand({
command: `powershell -NoProfile -EncodedCommand ${Buffer.from(listScript, 'utf16le').toString('base64')}`,
timeoutMs: 30_000,
});
const rows = r.stdout
.split('\n')
.map((l) => l.trim())
.filter((l) => l.startsWith(TASK_PREFIX));
const now = Date.now();
const pending: PendingRollback[] = rows.map((row) => {
const [task_name, state] = row.split('|') as [string, string];
const rollbackAt = Number(task_name.slice(TASK_PREFIX.length));
return {
task_name,
created_at: rollbackAt,
rollback_at: rollbackAt,
seconds_left: Math.max(0, Math.round((rollbackAt - now) / 1000)),
interface: '(见 task)',
mode: '(见 task)',
backup_path: '',
requested_by: state,
};
});
// ⚠️ 必须用 -EncodedCommand:用 -Command "..." 时内层的 $_ 会被**外层 PowerShell** 展开,
// 命令静默失效并返回空(真机踩过)。
const addrScript =
`Get-NetIPConfiguration | Where-Object { $_.NetAdapter.Status -eq 'Up' } | ` +
`Select-Object InterfaceAlias, @{n='ip';e={$_.IPv4Address.IPAddress}}, @{n='gw';e={$_.IPv4DefaultGateway.NextHop}} | ` +
`ConvertTo-Json -Compress`;
const addr = await execCommand({
command: `powershell -NoProfile -EncodedCommand ${Buffer.from(addrScript, 'utf16le').toString('base64')}`,
timeoutMs: 30_000,
});
// ⚠️ 只有一块网卡时 ConvertTo-Json 输出的是**对象**而不是数组 —— 只按 [...] 解析会
// 在单网卡机器上永远返回空(真机踩过)。这里统一用首 { 到末 } 截取后按需包成数组。
const s = addr.stdout.indexOf('{');
const e = addr.stdout.lastIndexOf('}');
let interfaces: unknown[] = [];
if (s >= 0 && e > s) {
try {
const parsed = JSON.parse(addr.stdout.slice(s, e + 1)) as unknown;
interfaces = Array.isArray(parsed) ? parsed : [parsed];
} catch {
interfaces = [];
}
}
return {
platform: process.platform,
interfaces,
pending,
pending_count: pending.length,
backups,
note: pending.length > 0
? '存在待确认的网络变更:请在逾期前调用 system.net.confirm;逾期会自动回滚'
: '没有待确认的网络变更',
};
}
/** 清理旧的备份/回滚脚本(保留最近 N 份)。 */
export function pruneNetArtifacts(keep = 10): number {
const dir = NET_DIR();
let removed = 0;
try {
const files = readdirSync(dir)
.filter((f) => f.startsWith('backup-') || f.startsWith('rollback-'))
.sort()
.reverse();
for (const f of files.slice(keep * 2)) {
try {
unlinkSync(join(dir, f));
removed += 1;
} catch {
/* 忽略 */
}
}
} catch {
/* 目录不存在 */
}
return removed;
}
+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 屏幕录制权限)', {
+402
View File
@@ -0,0 +1,402 @@
import { createHash } from 'node:crypto';
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>;
/**
* 拉取式自更新(v19):被控端**自己**从 URL 下载新版本并替换自身。
*
* ## 与 `nodeagent deploy`(推送式)的分工
* - 推送式:控制端与被控端可达 → 逐块上传(简单、无需被控端能上网)
* - 拉取式(本能力):两者不可达,但被控端能上网 → 从 GitHub Release / 自建镜像拉取
* 典型场景:被控端在老家、控制端在出差地;或跨网段/穿 NAT 失败。
*
* ## 安全(必须严格遵守,否则等于开放远程代码执行)
* 1. **哈希必须钉死**:`sha256` 必填,且**逐字节校验下载内容**后才替换。
* 没有它,GitHub 账号被盗 / DNS 被污染 / 中间人 = 直接拿下被控端。
* 2. 只接受 http/https;拒绝 file:// 等其它 scheme。
* 3. 大小上限(默认 64MB,content-length 预检 + 流式中断双闸)+ 下载超时。
* 4. **先备份再替换**(同目录备份;Windows 上就地覆写,不用 rename —— 真机踩过),
* 失败可回滚;写后校验不一致自动回滚。
* 5. `dry_run` 只下载校验、不动现有文件 —— 用于「先确认能不能更新」。
* 6. 动作全程进审计(能力调用本身已记录 `invoke`,这里再加一条 `agent.update`)。
*
* ## 出网:经 `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;
/** 出网诊断信息(返回结构与 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 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 {
/* 文件可能已消失,跳过 */
}
}
const removed: string[] = [];
for (const name of planBackupCleanup(files, keep)) {
try {
unlinkSync(join(dir, name));
removed.push(name);
} catch {
/* 删不掉不影响正确性 */
}
}
return removed;
} catch {
return [];
}
}
export async function agentUpdate(args: Args): Promise<unknown> {
const url = args['url'] as string | undefined;
const expect = (args['sha256'] as string | undefined)?.toLowerCase();
const dryRun = args['dry_run'] === true;
const doRestart = args['restart'] !== false;
const timeoutMs = Math.max(5_000, Math.min(600_000, (args['timeout_ms'] as number | undefined) ?? 120_000));
if (!url) throw new CapabilityError(ErrorCodes.PARAM_INVALID, '需要 url(更新包地址)');
if (!expect || !/^[0-9a-f]{12,64}$/.test(expect)) {
throw new CapabilityError(
ErrorCodes.PARAM_INVALID,
'需要 sha256(12~64 位十六进制)。**这是安全底线**:不校验哈希等于开放远程代码执行',
);
}
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `url 不是合法地址: ${url}`);
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `只支持 http/https,收到 ${parsed.protocol}`);
}
const scriptPath = process.argv[1] ?? '';
if (!scriptPath) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '无法定位自身入口路径,拒绝自更新');
}
const dir = join(agentDir(), 'update');
mkdirSync(dir, { recursive: true });
// ① 下载(经代理出网)+ ② 校验哈希(先校验再落盘,避免半成品污染)
const t0 = Date.now();
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)}` });
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '哈希校验失败,已丢弃下载内容(未改动任何文件)', {
expected: expect,
actual: got.slice(0, 12),
bytes: buf.length,
...outboundHint(outbound, timeoutMs),
});
}
const staged = join(dir, `agent-${Date.now()}.mjs`);
writeFileSync(staged, buf);
const prevBytes = (() => {
try {
return statSync(scriptPath).size;
} catch {
return 0;
}
})();
const prevHash = (() => {
try {
return createHash('sha256').update(readFileSync(scriptPath)).digest('hex').slice(0, 12);
} catch {
return 'unknown';
}
})();
if (dryRun) {
try {
unlinkSync(staged);
} catch {
/* 忽略 */
}
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,
bytes: buf.length,
incoming_hash: got.slice(0, 12),
current_hash: prevHash,
current_bytes: prevBytes,
note: '仅下载并校验,未改动任何文件',
via: outbound.via,
proxy: outbound.proxy,
proxy_source: outbound.proxy_source,
outbound_notes: outbound.notes,
};
}
// ③ 备份 + ④ 替换(同目录备份;就地覆写不用 rename —— Windows 不允许替换
// 正被运行进程打开的文件,真机踩过:rename 必失败)
const backup = `${scriptPath}.bak-${Date.now()}`;
try {
copyFileSync(scriptPath, backup);
} catch (err) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '备份现有脚本失败,已中止(不做无备份的替换)', {
detail: err instanceof Error ? err.message : String(err),
});
}
// ⚠️ 必须**就地覆写**,不能用 rename 覆盖 —— Windows 不允许重命名/替换「正被运行进程
// 打开的文件」(EPERM/EBUSY),而 node.exe 正持有 agent.mjs。真机踩过:rename 必失败。
// 覆写打开中的文件是允许的(只有删除/重命名被禁),这也正是推送式 deploy 能工作的原因。
try {
writeFileSync(scriptPath, buf);
} catch (err) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '写入脚本失败(已保留备份,可回滚)', {
detail: err instanceof Error ? err.message : String(err),
backup_path: backup,
staged_path: staged,
});
}
// 写后校验:内容必须与刚校验过的下载一致,否则立即回滚(防止截断/半写)
const written = createHash('sha256').update(readFileSync(scriptPath)).digest('hex');
if (written !== got) {
try {
copyFileSync(backup, scriptPath); // 回滚
} catch {
/* 回滚失败时靠 backup_path 人工兜底 */
}
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '写入后校验不一致,已自动回滚', {
expected: got.slice(0, 12),
actual: written.slice(0, 12),
backup_path: backup,
});
}
try {
unlinkSync(staged); // 暂存文件已并入入口文件,及时清理
} catch {
/* 清理失败不影响正确性 */
}
// ⑤ 备份清理:保留最近 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} via=${outbound.via}` +
(removedBackups.length > 0 ? ` cleaned=${removedBackups.length}` : ''),
});
// ⑥ 受控重启(复用 v7;Windows 走计划任务、POSIX 走 detached,均不依赖本进程存活)
let restarted = false;
if (doRestart) {
await agentRestart({ delay_ms: 1500, reason: 'self-update' });
restarted = true;
}
return {
updated: true,
bytes: buf.length,
previous: { hash: prevHash, bytes: prevBytes },
current_hash: got.slice(0, 12),
// CLI 靠它做「重启后复核」——漏了这个字段会导致复核被跳过(真机踩过)
incoming_hash: got.slice(0, 12),
backup_path: backup,
restarted,
elapsed_ms: Date.now() - t0,
hint: doRestart
? '已替换并重启;请用 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,
};
}
+157 -5
View File
@@ -1,9 +1,18 @@
import { createHash } from 'node:crypto';
import { guardPath } from './fs.js';
import { readFileSync, statSync } from 'node:fs';
import { spawn } from 'node:child_process';
import os from 'node:os';
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { IS_WINDOWS, execCommand } from '../util/exec.js';
import { readAudit, verifyAudit } from '../audit.js';
import { currentCertFingerprint } from '../certs.js';
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>;
@@ -42,6 +51,82 @@ async function isAdmin(): Promise<boolean> {
// ---------------- system.info ----------------
// ---------- 构建指纹(v18 / v20)----------
// 构建时由 esbuild --define 注入(见 scripts/pack.mjs);开发态(直接跑 dist)下未定义,
// 故一律用 typeof 探测,绝不能直接引用(否则 dev 启动就 ReferenceError)。
declare const __AGENT_VERSION__: string | undefined;
declare const __BUILD_COMMIT__: string | undefined;
/** v21:读取当前生效的来源白名单(用于 system.info 上报)。 */
function configAllowFrom(): string[] | null {
try {
const cfg = loadAgentConfig().config;
return cfg.allow_from && cfg.allow_from.length > 0 ? cfg.allow_from : null;
} catch {
return null;
}
}
function injected(name: 'version' | 'commit'): string {
if (name === 'version') return typeof __AGENT_VERSION__ === 'undefined' ? 'dev' : __AGENT_VERSION__;
return typeof __BUILD_COMMIT__ === 'undefined' ? 'dev' : __BUILD_COMMIT__;
}
const STARTED_AT = Date.now();
let buildCache: { hash: string; bytes: number; mtime_ms: number } | null = null;
/**
* 计算「正在运行的这一份 agent 脚本」的指纹(sha256 前 12 位)。
*
* 用途:部署后**校验远端真的换成了新代码**。仅靠「PID 变了 / 能力数对了」证明不了这点
* (旧版可能恰好也是同样的能力数);内容哈希是唯一可靠的判据。
* 懒计算 + 缓存:文件约 1MB,只在首次 system.info 时读一次。
*/
export function buildInfo(): {
hash: string;
bytes: number;
mtime_ms: number;
node: string;
started_at: number;
uptime_ms: number;
version: string;
commit: string;
built_at: string;
cert_sha256: string | null;
} {
if (!buildCache) {
let hash = 'unknown';
let bytes = 0;
let mtime = 0;
try {
const scriptPath = process.argv[1] ?? '';
const buf = readFileSync(scriptPath);
bytes = buf.length;
mtime = statSync(scriptPath).mtimeMs;
hash = createHash('sha256').update(buf).digest('hex').slice(0, 12);
} catch {
/* 读不到脚本(如被内联启动)时保持 unknown,不影响其他功能 */
}
buildCache = { hash, bytes, mtime_ms: Math.round(mtime) };
}
return {
...buildCache,
node: process.version,
started_at: STARTED_AT,
uptime_ms: Date.now() - STARTED_AT,
// v20:语义化版本 + 提交 + 构建时间(来自构建期注入)
version: injected('version'),
commit: injected('commit'),
// 构建时间取入口文件的 mtime(而非构建期注入)—— 注入会让每次打包字节不同,
// 破坏「指纹 = 构建身份」的确定性(v22 真机踩过)
built_at: buildCache.mtime_ms ? new Date(buildCache.mtime_ms).toISOString() : 'unknown',
// v21:TLS 证书指纹(控制端据此钉住被控端身份)
cert_sha256: currentCertFingerprint(),
};
}
export async function systemInfo(args: Args): Promise<unknown> {
const fields = args['fields'] as string[] | undefined;
const info: Record<string, unknown> = {
@@ -59,6 +144,18 @@ export async function systemInfo(args: Args): Promise<unknown> {
agent_script: process.argv[1] ?? '',
agent_home: process.env['NODEAGENT_HOME'] ?? '',
node_path: process.execPath,
// v15:PowerShell 常驻助手状态(排查 GUI 能力性能/降级时用)
ps_helper: allPsShellStats(),
// v18/v21:构建指纹(部署校验用)+ 证书指纹(身份钉定用)
build: buildInfo(),
// v21:来源网段访问控制(null = 未配置 = 放行全部,属于应尽快收敛的风险)
network: {
allow_from: configAllowFrom(),
},
// 交付流水线 §6:最后已知可用版本(回滚锚点)。
// matches=false 表示「当前版本还没被证明能启动」—— 刚更新完、尚未完成一次成功
// 启动时会是这种情况,控制端应据此暂缓向其余设备放量。
lkg: lkgStatus(),
};
if (fields && fields.length > 0) {
@@ -436,15 +533,38 @@ async function scheduleWindowsRestart(p: RestartParams): Promise<unknown> {
const TASK = `nodeagent-selfrestart-${Date.now()}`;
const ps = [
`$ErrorActionPreference='SilentlyContinue'`,
// 清理历史 selfrestart 任务(保留 Running 的 —— 那正是当前 agent 的宿主)
`Get-ScheduledTask -TaskName 'nodeagent-selfrestart*' | Where-Object { $_.State -ne 'Running' } | Unregister-ScheduledTask -Confirm:$false`,
`$a = New-ScheduledTaskAction -Execute 'cmd.exe' -Argument '${cmdLine.replace(/'/g, "''")}'`,
// 清理历史 selfrestart 任务。
//
// ⚠️ 为什么不能简单清所有非 Running 的:重启任务的 cmd 会**一直持有新 agent 进程**,
// 所以任务长期处于 Running;若把 Running 的全清掉,会连当前 agent 的宿主一起杀
// (= 自杀,表现为"restart 成功但 agent 消失")。原实现只清非 Running,
// 代价是真机累积了 3 个 Running 僵尸(2026-10-09 实测)。
//
// 现策略:非 Running **或** LastRunTime **早于"本 agent 启动时刻"** → 清。
//
// ⚠️ 2026-10-11 真机(虚拟化平台上的 Win11 ARM)修正:原先用「早于 1 小时」作阈值,
// 实测发现**频繁 restart(间隔 < 1h)时旧任务照样累积**(v26 想治的僵尸只是从
// "永不清理"变成"1 小时后才清")。根因是阈值选得太粗:
// 阈值应表达「**这个任务是不是我(当前 agent)的宿主**」——
// 凡在**我启动之前**就运行的任务,必然不是我的宿主(我的宿主是刚刚才创建的),
// 因此可以安全删除。用 uptime 换算出的启动时刻正是这个边界。
`$agentStart = (Get-Date).AddMilliseconds(-${Math.max(0, Math.round(process.uptime() * 1000))})`,
`$cut = $agentStart`,
`Get-ScheduledTask -TaskName 'nodeagent-selfrestart*' -ErrorAction SilentlyContinue | Where-Object { if ($_.State -ne 'Running') { return $true }; $ti = ($_ | Get-ScheduledTaskInfo -ErrorAction SilentlyContinue); if ($ti -and $ti.LastRunTime -and $ti.LastRunTime -lt $cut) { return $true }; return $false } | Unregister-ScheduledTask -Confirm:$false -ErrorAction SilentlyContinue`,
// ⚠️ 不要手写 `replace(/'/g,"''")` —— 那是「又发明一份 psLit」,两份实现必然漂移。
// PS 里 `\` 不是转义符、双引号串还会插值,故 cmdLine 唯一安全的落法就是单引号字面量。
`$a = New-ScheduledTaskAction -Execute 'cmd.exe' -Argument ${psLit(cmdLine)}`,
// 不限时(默认 72h 后强制结束),保证 node 能长期运行
`$s = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Seconds 0)`,
// ⚠️ 必须用最高权限:agent 通常以管理员运行,普通权限的 taskkill 杀不掉它,
// ① 先试「最高权限」:agent 通常以管理员运行,普通权限的 taskkill 杀不掉它,
// 否则会出现「新进程起来了但端口被占(EADDRINUSE)」。
`$p = New-ScheduledTaskPrincipal -UserId $env:USERNAME -RunLevel Highest -LogonType Interactive`,
`Register-ScheduledTask -TaskName '${TASK}' -Action $a -Settings $s -Principal $p -Force | Out-Null`,
// ② 降级:**非管理员身份下 Highest 会被系统拒绝**(真机 2026-10-09 坐实:
// agent 以普通用户运行时,deploy/restart 一律报"注册重启任务失败 exit=1")。
// 此时不指定 -Principal —— 默认继承当前用户权限级别(Limited),
// 注册能成功,且同权限下 taskkill 自己完全够用。
`if ((Get-ScheduledTask -TaskName '${TASK}' -ErrorAction SilentlyContinue) -eq $null) { Register-ScheduledTask -TaskName '${TASK}' -Action $a -Settings $s -Force | Out-Null }`,
// 校验注册真的成功(SilentlyContinue 会吞错,必须显式确认)
`if ((Get-ScheduledTask -TaskName '${TASK}' -ErrorAction SilentlyContinue) -eq $null) { Write-Output 'register-failed'; exit 1 }`,
`Start-ScheduledTask -TaskName '${TASK}'`,
@@ -509,3 +629,35 @@ export async function auditList(args: Args): Promise<unknown> {
export async function auditVerify(_args: Args): Promise<unknown> {
return verifyAudit();
}
/**
* v2.0.0:返回审计链的链头(只读)。
*
* 用途:控制端定期拉取并存到**链外**(Mac 本地 / 另一台机器)—— 这是"外部锚定"的一半。
* compare=true 时附带与最近一条锚点的比对结论(另一半)。
*/
export async function auditHead(args: Args): Promise<unknown> {
const head = computeAuditHead();
if (args['compare'] !== true) return head;
return { ...head, comparison: compareWithAnchors(args['anchor_path'] as string | undefined) };
}
/**
* v2.0.0:把当前链头**追加**写到锚点文件(默认 <数据目录>/audit-anchors.jsonl)。
*
* 为什么是追加而不是覆盖:历史锚点一旦写成就不可被后续覆盖,否则攻击者重写链后
* 再"刷新"锚点即可抹掉痕迹。追加式让每次锚定都留痕。
* path 若指定,仍受 fs_roots 白名单约束(不新开写入面)。
*/
export async function auditAnchor(args: Args): Promise<unknown> {
const raw = args['path'] as string | undefined;
const path = raw ? guardPath(raw) : undefined;
return anchorAudit({ ...(path ? { path } : {}), ...(args['note'] ? { note: String(args['note']) } : {}) });
}
/** v13:成功指标聚合(对齐 PRD 2.2)—— 数据源为审计日志,无需额外埋点。 */
export async function metricsReport(args: Args): Promise<unknown> {
const since = args['since'] as number | undefined;
const entries = readAuditAll(since);
return computeMetrics(entries);
}
+7
View File
@@ -132,6 +132,13 @@ function getTask(id: string): TaskRecord {
// ---------------- system.task.list ----------------
/** v2.0.0:正在跑的任务数(连接层"有空闲断开但不动长任务"的判据)。 */
export function runningTaskCount(): number {
let n = 0;
for (const t of tasks.values()) if (t.state === 'running') n += 1;
return n;
}
export async function taskList(_args: Args): Promise<unknown> {
return {
tasks: [...tasks.values()].map((t) => ({
@@ -0,0 +1,42 @@
/**
* 枚举可见顶层窗口 → JSON(**不含 prelude**,供常驻助手与一次性路径复用)
*
* 2026-10 从 window.ts 抽出(结构拆分,**内容逐字节不变**)。
* ⚠️ WIN_HELPER_PRELUDE 是本段与另外几段的拼接 —— 改动会直接影响常驻 PS 助手。
* 拆分以 tests/unit/win-prelude.test.mjs + prelude sha256 比对双重把关。
*/
/**
* 枚举可见顶层窗口 → JSON(**不含 prelude**,供常驻助手与一次性路径复用)。
* 注意:本段会被送进常驻进程执行,**严禁出现 exit**(会杀掉助手进程)。
*/
export const LIST_BODY = `
$fg = [NAWin32]::GetForegroundWindow()
$list = New-Object System.Collections.ArrayList
$cb = [NAWin32+EnumProc]{
param($h, $l)
if (-not [NAWin32]::IsWindowVisible($h)) { return $true }
$sb = New-Object System.Text.StringBuilder 512
[void][NAWin32]::GetWindowTextW($h, $sb, 512)
$title = $sb.ToString()
if ([string]::IsNullOrWhiteSpace($title)) { return $true }
$r = New-Object NAWin32+RECT
if (-not [NAWin32]::GetWindowRect($h, [ref]$r)) { return $true }
$w = $r.Right - $r.Left; $ht = $r.Bottom - $r.Top
if ($w -le 0 -or $ht -le 0) { return $true }
# ⚠️ 变量名不能叫 $pid —— 它是 PowerShell 的自动变量(当前进程 ID),赋值会被忽略,
# 导致所有窗口都报成 powershell 进程(真机踩过)。
$procId = 0
[void][NAWin32]::GetWindowThreadProcessId($h, [ref]$procId)
$pn = ''
try { $pn = (Get-Process -Id $procId -ErrorAction Stop).ProcessName } catch {}
[void]$list.Add([pscustomobject]@{
hwnd = ('0x{0:X}' -f $h.ToInt64()); title = $title; process = $pn; pid = $procId
x = $r.Left; y = $r.Top; width = $w; height = $ht
is_foreground = ($h -eq $fg); is_minimized = [NAWin32]::IsIconic($h)
})
return $true
}
[void][NAWin32]::EnumWindows($cb, [IntPtr]::Zero)
$list | ConvertTo-Json -Compress
`;
@@ -0,0 +1,226 @@
/**
* 图像模板匹配预加载段(ZNCC 粗到精)
*
* 2026-10 从 window.ts 抽出(结构拆分,**内容逐字节不变**)。
* ⚠️ WIN_HELPER_PRELUDE 是本段与另外几段的拼接 —— 改动会直接影响常驻 PS 助手。
* 拆分以 tests/unit/win-prelude.test.mjs + prelude sha256 比对双重把关。
*/
/**
* 图像模板匹配预加载段(v15)。
*
* 动机:UIA 靠控件树、OCR 靠文字 —— **纯图标/无文字的控件**两者都抓不到(审查报告 §5.2 指出的盲区)。
* 做法:把模板图与屏幕区域都转灰度,做**零均值归一化互相关(ZNCC)**;
* 全分辨率逐像素太慢(3440×1440 屏 × 64×64 模板 ≈ 190 亿次),故**粗到精**:
* ① 1/4 分辨率全图扫描(约 8000 万次)取候选
* ② 对候选在全分辨率 ±10px 邻域精修
* 编译一次常驻(随助手预加载),运行时纯 native 循环。
* 亮度和对比度无关(ZNCC 归一化),故对主题/亮度变化不敏感。
*/
export const IMAGE_MATCH_PRELUDE = `
Add-Type -AssemblyName System.Drawing
# ⚠️ Add-Type 的老坑:-AssemblyName 只把程序集加载进 PowerShell 会话,
# 编译 C# 时还必须显式 -ReferencedAssemblies,否则报「命名空间 System.Drawing
# 中不存在 Imaging」。真机踩过:编译失败会让整个助手起不来,并伴随误导性的
# 「找不到类型 System.Windows.Forms.SystemInformation」二次错误。
Add-Type @"
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Imaging;
using System.Runtime.InteropServices;
public class NAImageMatch {
public class Hit { public int x; public int y; public int w; public int h; public double score; }
private static byte[] Gray(Bitmap src, out int w, out int h) {
w = src.Width; h = src.Height;
var dst = new byte[w * h];
var rect = new Rectangle(0, 0, w, h);
Bitmap bmp = src.PixelFormat == PixelFormat.Format24bppRgb ? src : src.Clone(rect, PixelFormat.Format24bppRgb);
var data = bmp.LockBits(rect, ImageLockMode.ReadOnly, PixelFormat.Format24bppRgb);
try {
int stride = data.Stride;
var buf = new byte[stride * h];
Marshal.Copy(data.Scan0, buf, 0, buf.Length);
for (int y = 0; y < h; y++) {
int row = y * stride;
for (int x = 0; x < w; x++) {
int i = row + x * 3;
// BGR -> luma (integer approximation of ITU-R BT.601)
dst[y * w + x] = (byte)((buf[i + 2] * 77 + buf[i + 1] * 150 + buf[i] * 29) >> 8);
}
}
} finally { bmp.UnlockBits(data); if (bmp != src) bmp.Dispose(); }
return dst;
}
// Area-average downscale (coarse search layer)
private static byte[] Downscale(byte[] src, int w, int h, int f, out int ow, out int oh) {
ow = w / f; oh = h / f;
var dst = new byte[ow * oh];
for (int y = 0; y < oh; y++) {
for (int x = 0; x < ow; x++) {
int sum = 0;
for (int dy = 0; dy < f; dy++) {
int row = (y * f + dy) * w;
for (int dx = 0; dx < f; dx++) sum += src[row + x * f + dx];
}
dst[y * ow + x] = (byte)(sum / (f * f));
}
}
return dst;
}
// Zero-mean normalized cross correlation; 1.0 = identical
private static double Zncc(byte[] S, int sw, int sx, int sy, byte[] T, int tw, int th) {
int n = tw * th;
double sumT = 0;
for (int i = 0; i < n; i++) sumT += T[i];
double meanT = sumT / n;
double sumS = 0;
for (int y = 0; y < th; y++) {
int row = (sy + y) * sw + sx;
for (int x = 0; x < tw; x++) sumS += S[row + x];
}
double meanS = sumS / n;
double num = 0, dS = 0, dT = 0;
for (int y = 0; y < th; y++) {
int row = (sy + y) * sw + sx;
int trow = y * tw;
for (int x = 0; x < tw; x++) {
double a = S[row + x] - meanS;
double b = T[trow + x] - meanT;
num += a * b; dS += a * a; dT += b * b;
}
}
double den = Math.Sqrt(dS * dT);
if (den < 1e-9) return 0;
return num / den;
}
/**
// (comment removed: ASCII only)
// (comment removed: ASCII only)
*/
public static string Match(string screenPath, string templatePath, int maxResults, double threshold, int searchX, int searchY, int searchW, int searchH) {
var hits = new List<Hit>();
using (var screenBmp = new Bitmap(screenPath))
using (var tmplBmp = new Bitmap(templatePath)) {
int sw, sh, tw, th;
var S0 = Gray(screenBmp, out sw, out sh);
var T0 = Gray(tmplBmp, out tw, out th);
if (tw >= sw || th >= sh) return "[]";
// Guard: a flat template carries no gradient information, so correlation is
// undefined (ZNCC denominator ~ 0). Report it explicitly instead of silently
// returning no hits - a uniform crop usually means the wrong region was picked.
//
// NOTE: build all JSON/string literals with the char constant Q.
// Writing a backslash-quote inside a TS template literal collapses it to a bare
// quote and breaks the C# compile - this trap bit this file twice.
const char Q = '"';
var ci0 = System.Globalization.CultureInfo.InvariantCulture;
double tSum = 0, tSum2 = 0;
for (int i = 0; i < T0.Length; i++) { tSum += T0[i]; tSum2 += (double)T0[i] * T0[i]; }
double tMean = tSum / T0.Length;
double tVar = (tSum2 / T0.Length) - (tMean * tMean);
if (tVar < 4.0) {
return "[" + "{" + Q + "__error" + Q + ":" + Q + "template_has_no_contrast" + Q
+ "," + Q + "variance" + Q + ":" + tVar.ToString("F2", ci0) + "}" + "]";
}
// Optional search window: crop a sub-image, then add the offset back to hit coords
int ox = 0, oy = 0;
if (searchW > 0 && searchH > 0) {
ox = Math.Max(0, Math.Min(searchX, sw - 1));
oy = Math.Max(0, Math.Min(searchY, sh - 1));
int w = Math.Min(searchW, sw - ox), h = Math.Min(searchH, sh - oy);
var sub = new byte[w * h];
for (int y = 0; y < h; y++) Array.Copy(S0, (oy + y) * sw + ox, sub, y * w, w);
S0 = sub; sw = w; sh = h;
}
const int F = 4;
int sw4, sh4, tw4, th4;
var S4 = Downscale(S0, sw, sh, F, out sw4, out sh4);
var T4 = Downscale(T0, tw, th, F, out tw4, out th4);
// (1) coarse scan: collect candidates.
// NOTE: the pre-filter must be MUCH looser than the final threshold.
// Downsampling lowers the correlation of the true position (typically 0.7-0.85
// for a 0.9+ full-res match), so a tight pre-filter discards the right answer
// before refinement ever runs (this exact bug made every match return empty).
var cands = new List<Hit>();
if (tw4 > 0 && th4 > 0 && tw4 <= sw4 && th4 <= sh4) {
double preFilter = Math.Max(0.30, threshold - 0.30);
double bestCoarse = -1; int bx4 = 0, by4 = 0;
for (int y = 0; y <= sh4 - th4; y++) {
for (int x = 0; x <= sw4 - tw4; x++) {
double sc = Zncc(S4, sw4, x, y, T4, tw4, th4);
if (sc > bestCoarse) { bestCoarse = sc; bx4 = x; by4 = y; }
if (sc >= preFilter) cands.Add(new Hit { x = x * F, y = y * F, w = tw, h = th, score = sc });
}
}
// Belt and braces: always keep the coarse best, even if it missed the pre-filter
if (bestCoarse > 0) {
bool seen = false;
foreach (var c in cands) { if (Math.Abs(c.x - bx4 * F) < F && Math.Abs(c.y - by4 * F) < F) { seen = true; break; } }
if (!seen) cands.Add(new Hit { x = bx4 * F, y = by4 * F, w = tw, h = th, score = bestCoarse });
}
}
cands.Sort((a, b) => b.score.CompareTo(a.score));
if (cands.Count > 40) cands.RemoveRange(40, cands.Count - 40);
// (2) refine at full resolution within a +/-F*2 neighborhood
foreach (var c in cands) {
double best = -1; int bx = c.x, by = c.y;
for (int dy = -F * 2; dy <= F * 2; dy++) {
for (int dx = -F * 2; dx <= F * 2; dx++) {
int x = c.x + dx, y = c.y + dy;
if (x < 0 || y < 0 || x + tw > sw || y + th > sh) continue;
double sc = Zncc(S0, sw, x, y, T0, tw, th);
if (sc > best) { best = sc; bx = x; by = y; }
}
}
if (best >= threshold) hits.Add(new Hit { x = bx + ox, y = by + oy, w = tw, h = th, score = best });
}
// (3) de-dup: non-maximum suppression (keep best within 8px)
hits.Sort((a, b) => b.score.CompareTo(a.score));
var final = new List<Hit>();
foreach (var h in hits) {
bool dup = false;
foreach (var f in final) {
if (Math.Abs(f.x - h.x) < 8 && Math.Abs(f.y - h.y) < 8) { dup = true; break; }
}
if (!dup) final.Add(h);
if (final.Count >= maxResults) break;
}
var parts = new List<string>();
foreach (var h in final) {
// Build JSON by concatenation; reuse the shared Q / ci0 declared above.
var ci = ci0;
parts.Add("{" + Q + "x" + Q + ":" + h.x.ToString(ci) + "," + Q + "y" + Q + ":" + h.y.ToString(ci)
+ "," + Q + "width" + Q + ":" + h.w.ToString(ci) + "," + Q + "height" + Q + ":" + h.h.ToString(ci)
+ "," + Q + "score" + Q + ":" + h.score.ToString("F4", ci) + "}");
}
return "[" + String.Join(",", parts.ToArray()) + "]";
}
}
// Capture a screen region to a file (avoids extra process round-trips)
public static string Capture(string outPath, int x, int y, int w, int h) {
using (var bmp = new Bitmap(w, h))
using (var g = Graphics.FromImage(bmp)) {
g.CopyFromScreen(x, y, 0, 0, new Size(w, h));
bmp.Save(outPath, ImageFormat.Png);
}
return outPath;
}
}
"@ -ReferencedAssemblies System.Drawing
`;
@@ -0,0 +1,39 @@
/**
* OCR 引擎预加载段:截图 / DPI / WinRT 类型与 Await 辅助函数
*
* 2026-10 从 window.ts 抽出(结构拆分,**内容逐字节不变**)。
* ⚠️ WIN_HELPER_PRELUDE 是本段与另外几段的拼接 —— 改动会直接影响常驻 PS 助手。
* 拆分以 tests/unit/win-prelude.test.mjs + prelude sha256 比对双重把关。
*/
/**
* OCR 引擎预加载段(v14):截图 / WinRT 类型与 Await 辅助函数。
* 注意:WinRT 类型加载用 try/catch 包住 —— 个别环境缺组件时只应让 OCR 退化,
* 不该让整个助手(连带 window.list/focus/UIA)起不来。
*
* ⚠️ DPI 感知(`SetProcessDPIAware`)**已从本段移出**,改由 WIN_HELPER_PRELUDE 最前面的
* `PS_DPI_AWARE` 无条件执行:放在这个 try 里会被 catch 顺带吞掉,一旦吞掉
* 助手就退回 DPI 不感知,`GetWindowRect` 与 UIA / 截图坐标口径不一致(真机 2026-10-12 排查)。
*/
export const OCR_PRELUDE = `
Add-Type -AssemblyName System.Drawing
Add-Type -AssemblyName System.Windows.Forms
try {
Add-Type -AssemblyName System.Runtime.WindowsRuntime
$null = [Windows.Media.Ocr.OcrEngine, Windows.Foundation, ContentType=WindowsRuntime]
$null = [Windows.Graphics.Imaging.BitmapDecoder, Windows.Foundation, ContentType=WindowsRuntime]
$null = [Windows.Storage.Streams.InMemoryRandomAccessStream, Windows.Foundation, ContentType=WindowsRuntime]
$null = [Windows.Storage.Streams.DataWriter, Windows.Foundation, ContentType=WindowsRuntime]
$null = [Windows.Graphics.Imaging.SoftwareBitmap, Windows.Foundation, ContentType=WindowsRuntime]
$script:asTaskGeneric = ([System.WindowsRuntimeSystemExtensions].GetMethods() | Where-Object { $_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation\`1' })[0]
function global:Await($WinRtTask, $ResultType) {
$asTask = $script:asTaskGeneric.MakeGenericMethod($ResultType)
$netTask = $asTask.Invoke($null, @($WinRtTask))
$netTask.Wait(-1) | Out-Null
$netTask.Result
}
$script:ocrReady = $true
} catch {
$script:ocrReady = $false
}
`;
@@ -0,0 +1,117 @@
/**
* UIA 控件树预加载段(含 Where 谓词与属性读取)
*
* 2026-10 从 window.ts 抽出(结构拆分,**内容逐字节不变**)。
* ⚠️ WIN_HELPER_PRELUDE 是本段与另外几段的拼接 —— 改动会直接影响常驻 PS 助手。
* 拆分以 tests/unit/win-prelude.test.mjs + prelude sha256 比对双重把关。
*/
/**
* v14:常驻助手预加载段 —— 这些内容**只在助手启动时执行一次**。
* 过去每次调用都重新编译 C# / 加载程序集,真机实测占单次耗时的绝大部分
* (window.list 1267ms / window.focus 1631ms,其中「起进程+编译」是固定成本)。
*/
export const UIA_ASSEMBLY_PRELUDE = `
Add-Type -AssemblyName UIAutomationClient
Add-Type -AssemblyName UIAutomationTypes
Add-Type -AssemblyName WindowsBase
function Get-WinByTitle([string]$re) {
$root = [System.Windows.Automation.AutomationElement]::RootElement
$cond = New-Object System.Windows.Automation.PropertyCondition(
[System.Windows.Automation.AutomationElement]::ControlTypeProperty,
[System.Windows.Automation.ControlType]::Window)
$wins = $root.FindAll([System.Windows.Automation.TreeScope]::Children, $cond)
foreach ($w in $wins) {
try {
$t = $w.Current.Name
if ($t -and $t -match $re) { return $w }
} catch {}
}
return $null
}
# v14:按子串在给定 scope 内遍历查找(FindAll + 子串过滤)。
# UIA 的 PropertyCondition 只能精确匹配,故必须遍历后过滤;遍历规模设上限防超大 UI 树卡死。
# v2.0.0:读取元素的 UIA 属性(v2.0.0 语义属性化)。
# 为什么逐个 try:ValuePattern / SelectionItemPattern / TogglePattern 并非所有控件
# 都支持(不支持时 GetSupportedPattern 返回 false 或抛异常),而且**部分应用
# (Electron / 游戏 UI)根本给不出值** —— 拿不到就留空,由上层判"未知"而非"false"。
function Get-UiaProps($e) {
$enabled = $true
try { $enabled = [bool]$e.Current.IsEnabled } catch {}
$value = $null
try {
$vp = $e.GetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern)
if ($vp -ne $null) { $value = $vp.Current.Value }
} catch {}
$selected = $null
try {
$sp = $e.GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern)
if ($sp -ne $null) { $selected = [bool]$sp.Current.IsSelected }
} catch {}
$toggle = $null
try {
$tp = $e.GetCurrentPattern([System.Windows.Automation.TogglePattern]::Pattern)
if ($tp -ne $null) { $toggle = "$($tp.Current.ToggleState)" }
} catch {}
return @{ enabled = $enabled; value = $value; selected = $selected; toggle = $toggle }
}
function Test-UiaWhere($props, $where) {
if ($where -eq $null) { return $true }
foreach ($k in $where.Keys) {
$want = $where[$k]
switch ($k) {
'enabled' { if ($props.enabled -ne [bool]$want) { return $false } }
'selected' { if ($null -eq $props.selected -or $props.selected -ne [bool]$want) { return $false } }
'value' { if ($props.value -eq $null -or $props.value -notlike [string]$want) { return $false } }
default { } # 未知键忽略(Node 侧已白名单校验)
'toggle' { if ($props.toggle -eq $null -or $props.toggle -notlike [string]$want) { return $false } }
}
}
return $true
}
function Find-UiaByText($scope, [string]$text, [int]$limit, [string]$ct, $where) {
$cond = [System.Windows.Automation.Condition]::TrueCondition
if ($ct -ne '') {
$ctObj = $null
try { $ctObj = [System.Windows.Automation.ControlType]::$ct } catch {}
if ($ctObj -ne $null) {
$cond = New-Object System.Windows.Automation.PropertyCondition(
[System.Windows.Automation.AutomitionElement]::ControlTypeProperty, $ctObj)
}
}
$found = $scope.FindAll([System.Windows.Automation.TreeScope]::Descendants, $cond)
$out = New-Object System.Collections.ArrayList
$scanned = 0
foreach ($e in $found) {
if ($out.Count -ge $limit) { break }
$scanned++
if ($scanned -gt 20000) { break }
try {
$nm = $e.Current.Name
if ([string]::IsNullOrEmpty($nm)) { continue }
if ($text -ne '' -and $nm.IndexOf($text, [System.StringComparison]::OrdinalIgnoreCase) -lt 0) { continue }
$r = $e.Current.BoundingRectangle
if ($r.Width -le 0 -or $r.Height -le 0) { continue }
$props = Get-UiaProps $e
if (-not (Test-UiaWhere $props $where)) { continue }
[void]$out.Add([pscustomobject]@{
name = $nm
control_type = ($e.Current.ControlType.ProgrammaticName -replace 'ControlType\\.','')
automation_id = $e.Current.AutomationId
x = [int]($r.Left + $r.Width / 2)
y = [int]($r.Top + $r.Height / 2)
left = [int]$r.Left; top = [int]$r.Top
width = [int]$r.Width; height = [int]$r.Height
enabled = $props.enabled
value = $props.value
selected = $props.selected
toggle = $props.toggle
})
} catch {}
}
return ,$out
}
`;
@@ -0,0 +1,30 @@
/**
* Win32 窗口枚举与矩形读取(供 window.list / window.focus 复用)
*
* 2026-10 从 window.ts 抽出(结构拆分,**内容逐字节不变**)。
* ⚠️ WIN_HELPER_PRELUDE 是本段与另外几段的拼接 —— 改动会直接影响常驻 PS 助手。
* 拆分以 tests/unit/win-prelude.test.mjs + prelude sha256 比对双重把关。
*/
export const WIN32_PRELUDE = `
Add-Type @"
using System;
using System.Text;
using System.Runtime.InteropServices;
public class NAWin32 {
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left, Top, Right, Bottom; }
public delegate bool EnumProc(IntPtr hWnd, IntPtr lParam);
[DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr p);
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);
[DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetWindowTextW(IntPtr h, StringBuilder s, int n);
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
[DllImport("user32.dll")] public static extern void mouse_event(uint f, int dx, int dy, uint d, UIntPtr e);
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd);
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
}
"@
`;
+357 -165
View File
@@ -1,5 +1,18 @@
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { IS_WINDOWS, execCommand } from '../util/exec.js';
import { runPowerShellSmart } from '../util/ps-helper.js';
import { psLit } from '../util/quote.js';
import { agentDir } from '../config.js';
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { macScreenFind, macWindowFocus, macWindowList } from './darwin.js';
import { PS_DPI_AWARE } from '../util/dpi.js';
import { WIN32_PRELUDE } from './win-ps/prelude-win32.js';
import { LIST_BODY } from './win-ps/body-list.js';
import { UIA_ASSEMBLY_PRELUDE } from './win-ps/prelude-uia.js';
import { OCR_PRELUDE } from './win-ps/prelude-ocr.js';
import { IMAGE_MATCH_PRELUDE } from './win-ps/prelude-image.js';
type Args = Record<string, unknown>;
@@ -16,7 +29,31 @@ function encodePS(script: string): string {
return Buffer.from(script, 'utf16le').toString('base64');
}
async function runPS(script: string, timeoutMs = 30_000): Promise<string> {
/**
* 执行「不含 prelude」的脚本主体:**优先常驻助手**(类型已预加载),
* 助手不可用时自动回退到一次性脚本(自带 prelude),保证可用性不倒退。
*/
async function runPS(body: string, timeoutMs = 30_000): Promise<string> {
try {
const { stdout } = await runPowerShellSmart({
body,
prelude: WIN_HELPER_PRELUDE,
timeoutMs,
label: 'win',
log: (level, msg) => {
if (level === 'warn') console.error(`[ps-helper] ${msg}`);
},
});
return stdout;
} catch (err) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '窗口操作失败', {
detail: (err instanceof Error ? err.message : String(err)).slice(0, 600),
});
}
}
/** 一次性执行完整脚本(含 prelude)—— 尚未迁移到助手的路径暂用。 */
async function runPSRaw(script: string, timeoutMs = 30_000): Promise<string> {
const r = await execCommand({
command: `powershell.exe -NoProfile -NonInteractive -EncodedCommand ${encodePS(script)}`,
timeoutMs,
@@ -51,56 +88,53 @@ function extractJson<T>(raw: string): T | null {
}
/** Win32 窗口枚举与矩形读取(供 window.list / window.focus 复用)。 */
const WIN32_PRELUDE = `
Add-Type @"
using System;
using System.Text;
using System.Runtime.InteropServices;
public class NAWin32 {
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left, Top, Right, Bottom; }
public delegate bool EnumProc(IntPtr hWnd, IntPtr lParam);
[DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr p);
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);
[DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetWindowTextW(IntPtr h, StringBuilder s, int n);
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd);
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
}
"@
`;
/** 枚举可见顶层窗口 → JSON 字符串。 */
const LIST_SCRIPT = `${WIN32_PRELUDE}
$fg = [NAWin32]::GetForegroundWindow()
$list = New-Object System.Collections.ArrayList
$cb = [NAWin32+EnumProc]{
param($h, $l)
if (-not [NAWin32]::IsWindowVisible($h)) { return $true }
$sb = New-Object System.Text.StringBuilder 512
[void][NAWin32]::GetWindowTextW($h, $sb, 512)
$title = $sb.ToString()
if ([string]::IsNullOrWhiteSpace($title)) { return $true }
$r = New-Object NAWin32+RECT
if (-not [NAWin32]::GetWindowRect($h, [ref]$r)) { return $true }
$w = $r.Right - $r.Left; $ht = $r.Bottom - $r.Top
if ($w -le 0 -or $ht -le 0) { return $true }
$pid = 0
[void][NAWin32]::GetWindowThreadProcessId($h, [ref]$pid)
$pn = ''
try { $pn = (Get-Process -Id $pid -ErrorAction Stop).ProcessName } catch {}
[void]$list.Add([pscustomobject]@{
hwnd = ('0x{0:X}' -f $h.ToInt64()); title = $title; process = $pn; pid = $pid
x = $r.Left; y = $r.Top; width = $w; height = $ht
is_foreground = ($h -eq $fg); is_minimized = [NAWin32]::IsIconic($h)
})
return $true
/** 助手统一预加载段(window.list / window.focus / screen.find 共用同一个进程)。 */
/**
* 助手统一预加载段(window.list / window.focus / screen.find 的 UIA/OCR/图像三引擎共用)。
* ⚠️ 必须定义在四段之后(块级作用域),且**四段缺一不可** ——
* 漏拼会被 esbuild tree-shake,直到运行时才报「找不到类型」(真机踩过)。
* tests/unit/win-prelude.test.mjs 对此有回归断言。
*/
/**
* 后台预热常驻助手(agent 启动后调用一次,不阻塞、失败无副作用)。
*
* 动机:助手的「首次调用」要付启动 + 类型预加载的固定成本(~1-3s)。
* 若不预热,这笔成本会算在**第一个真实用户调用**头上(真机指标里能明显看到
* window.list 的 P95 被抬高)。启动后主动预热,把成本挪到无人等待的时刻。
*/
export function warmupWindowHelper(): void {
if (!IS_WINDOWS || process.env['NODEAGENT_NO_PS_HELPER']) return;
setTimeout(() => {
void runPowerShellSmart({
body: "Write-Output 'warmup'",
prelude: WIN_HELPER_PRELUDE,
timeoutMs: 90_000,
label: 'win',
log: () => undefined,
}).catch(() => {
/* 预热失败无所谓:真实调用时仍会按原路径重试 */
});
}, 1500).unref?.();
}
[void][NAWin32]::EnumWindows($cb, [IntPtr]::Zero)
$list | ConvertTo-Json -Compress
`;
/**
* 常驻助手的预加载段。
*
* ⚠️ `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}`;
interface WinInfo {
hwnd: string;
@@ -119,13 +153,15 @@ interface WinInfo {
export async function windowList(args: Args): Promise<unknown> {
if (!IS_WINDOWS) {
// v12.3:macOS 走 AppleScript 实现(需辅助功能权限),其余平台明确不支持
if (process.platform === 'darwin') return macWindowList(args);
throw new CapabilityError(
ErrorCodes.UNSUPPORTED_PLATFORM,
'window.list 仅在被控端为 Windows 时可用',
'window.list 仅支持 Windows / macOS 被控端',
{ platform: process.platform },
);
}
const out = await runPS(LIST_SCRIPT, 40_000);
const out = await runPS(LIST_BODY, 40_000);
let all: WinInfo[] = toArray<WinInfo>(extractJson<WinInfo[]>(out) ?? []);
// 过滤掉 UWP 的隐形壳窗口(尺寸异常大且进程为 ApplicationFrameHost 的重复项保留)
all = all.filter((w) => w.width > 40 && w.height > 40);
@@ -136,16 +172,19 @@ export async function windowList(args: Args): Promise<unknown> {
all = all.filter((w) => re.test(w.title));
}
const limit = (args['limit'] as number | undefined) ?? 50;
return { windows: all.slice(0, limit) };
// 前台窗口排最前:低 limit 时也不会把它截掉(真机踩过:被系统对话框抢占焦点却查不到)
all.sort((a, b) => Number(b.is_foreground) - Number(a.is_foreground));
return { windows: all.slice(0, limit), total: all.length };
}
// ---------------- window.focus ----------------
export async function windowFocus(args: Args): Promise<unknown> {
if (!IS_WINDOWS) {
if (process.platform === 'darwin') return macWindowFocus(args);
throw new CapabilityError(
ErrorCodes.UNSUPPORTED_PLATFORM,
'window.focus 仅在被控端为 Windows 时可用',
'window.focus 仅支持 Windows / macOS 被控端',
{ platform: process.platform },
);
}
@@ -154,13 +193,43 @@ export async function windowFocus(args: Args): Promise<unknown> {
if (!title && !hwnd) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, '需提供 title(正则)或 hwnd');
}
// v12.2:等窗口出现(应用启动有延迟,一次性查找极易假失败)
const waitMs = Math.max(0, Math.min(30_000, (args['wait_ms'] as number | undefined) ?? 0));
if (waitMs > 0 && !hwnd) {
const t0 = Date.now();
let lastErr: unknown = null;
while (Date.now() - t0 < waitMs) {
try {
return await focusOnce(title, hwnd);
} catch (err) {
lastErr = err;
await new Promise((r) => setTimeout(r, 500));
}
}
throw lastErr instanceof Error
? lastErr
: new CapabilityError(ErrorCodes.EXECUTION_FAILED, `等待 ${waitMs}ms 仍未找到窗口`, { title });
}
const script = `${WIN32_PRELUDE}
return focusOnce(title, hwnd);
}
/** 单次聚焦(供等待重试复用)。 */
/**
* 聚焦脚本主体(不含 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]{
@@ -176,10 +245,28 @@ else {
[void][NAWin32]::EnumWindows($cb, [IntPtr]::Zero)
$h = $script:found
}
if ($h -eq [IntPtr]::Zero) { Write-Output '{"found":false}'; exit 0 }
if ($h -eq [IntPtr]::Zero) { Write-Output '{"found":false}'; return }
[void][NAWin32]::ShowWindow($h, 9) # SW_RESTORE
[void][NAWin32]::SetForegroundWindow($h)
Start-Sleep -Milliseconds 400
# ⚠️ SetForegroundWindow 会被 Windows 的前台锁定规则**静默否决**(真机踩过:
# 返回后 GetForegroundWindow 仍是别人,后续键鼠全打进错误窗口)。
# 故校验一次;未生效则在标题栏做一次真实点击 —— 点击比 API 更能说服系统切换前台。
$activatedBy = 'api'
if ([NAWin32]::GetForegroundWindow() -ne $h) {
$rr = New-Object NAWin32+RECT
[void][NAWin32]::GetWindowRect($h, [ref]$rr)
$cx = [int](($rr.Left + $rr.Right) / 2)
$cy = [int]($rr.Top + 8) # 标题栏(避开内容区,避免误触按钮)
[NAWin32]::SetCursorPos($cx, $cy) | Out-Null
Start-Sleep -Milliseconds 120
[NAWin32]::mouse_event(0x0002, 0, 0, 0, [UIntPtr]::Zero) # LEFTDOWN
Start-Sleep -Milliseconds 60
[NAWin32]::mouse_event(0x0004, 0, 0, 0, [UIntPtr]::Zero) # LEFTUP
Start-Sleep -Milliseconds 350
if ([NAWin32]::GetForegroundWindow() -eq $h) { $activatedBy = 'click' }
}
$r = New-Object NAWin32+RECT
[void][NAWin32]::GetWindowRect($h, [ref]$r)
$sb2 = New-Object System.Text.StringBuilder 512
@@ -188,9 +275,14 @@ $sb2 = New-Object System.Text.StringBuilder 512
found = $true; hwnd = ('0x{0:X}' -f $h.ToInt64()); title = $sb2.ToString()
x = $r.Left; y = $r.Top; width = ($r.Right - $r.Left); height = ($r.Bottom - $r.Top)
focused = ([NAWin32]::GetForegroundWindow() -eq $h)
activated_by = $activatedBy
} | ConvertTo-Json -Compress
`;
const out = await runPS(script, 40_000);
}
/** 单次聚焦(供等待重试复用)。 */
async function focusOnce(title: string | undefined, hwnd: string | undefined): Promise<unknown> {
const out = await runPS(focusBody(title, hwnd), 40_000);
const obj = extractJson<Record<string, unknown>>(out) ?? {};
if (obj['found'] === false) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '未找到匹配的窗口', { title, hwnd });
@@ -201,25 +293,7 @@ $sb2 = New-Object System.Text.StringBuilder 512
// ---------------- screen.find ----------------
const FIND_SCRIPT_PRELUDE = `
Add-Type -AssemblyName UIAutomationClient
Add-Type -AssemblyName UIAutomationTypes
Add-Type -AssemblyName WindowsBase
function Get-WinByTitle([string]$re) {
$root = [System.Windows.Automation.AutomationElement]::RootElement
$cond = New-Object System.Windows.Automation.PropertyCondition(
[System.Windows.Automation.AutomationElement]::ControlTypeProperty,
[System.Windows.Automation.ControlType]::Window)
$wins = $root.FindAll([System.Windows.Automation.TreeScope]::Children, $cond)
foreach ($w in $wins) {
try {
$t = $w.Current.Name
if ($t -and $t -match $re) { return $w }
} catch {}
}
return $null
}
`;
// v14:原 FIND_SCRIPT_PRELUDE 已并入 WIN_HELPER_PRELUDE(助手启动时预加载一次)
/**
* 在界面中查找 UI 元素并返回屏幕坐标。
@@ -232,103 +306,198 @@ function Get-WinByTitle([string]$re) {
*/
export async function screenFind(args: Args): Promise<unknown> {
if (!IS_WINDOWS) {
// macOS:无 UIA 等价物 → 截图 + Vision OCR(见 darwin.ts 的权限说明)
if (process.platform === 'darwin') return macScreenFind(args);
throw new CapabilityError(
ErrorCodes.UNSUPPORTED_PLATFORM,
'screen.find 仅在被控端为 Windows 时可用',
'screen.find 仅支持 Windows / macOS 被控端',
{ platform: process.platform },
);
}
const text = args['text'] as string;
if (typeof text !== 'string' || text.length === 0) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'text 不能为空');
const text = (args['text'] as string | undefined) ?? '';
const wantImage = (args['method'] as string | undefined) === 'image';
// v2.0.0 修正:`where` 可单独使用(不传 text)——「找一个 enabled 的按钮」比
// 「找一个叫某名字的按钮」更贴近真实意图。此前只在 gui.await 侧放开了,
// screen.find 自身的入口校验没同步,导致直连调用(以及 await 的 where-only)
// 被这里的"text 不能为空"拦下(真机 2026-10-09 验证时暴露)。
const hasWhere = (() => {
const w = args['where'];
return typeof w === 'object' && w !== null && !Array.isArray(w) && Object.keys(w as object).length > 0;
})();
if (!wantImage && text.length === 0 && !hasWhere) {
throw new CapabilityError(
ErrorCodes.PARAM_INVALID,
'text 与 where 至少给一个(method=image 时用 template)',
{ hint: 'where 仅 UIA 引擎生效,如 {"enabled":true}' },
);
}
const windowTitle = args['window'] as string | undefined;
const controlType = args['control_type'] as string | undefined;
// v2.0.0:属性谓词。仅 UIA 引擎生效;OCR / 图像模板忽略(无属性可读),
// 此时在返回里带 note 说明,避免调用方误以为"按属性过滤过了"。
const whereArg = args['where'] as Record<string, unknown> | undefined;
const limit = (args['limit'] as number | undefined) ?? 20;
const method = (args['method'] as string | undefined) ?? 'auto'; // auto | uia | ocr
// v12.2:等待语义 —— 界面常有动画/加载延迟,一次性查找极易假失败。
// wait_ms > 0 时轮询直到命中或超时(返回 waited_ms 让调用方知情)。
const waitMs = Math.max(0, Math.min(30_000, (args['wait_ms'] as number | undefined) ?? 0));
const intervalMs = Math.max(100, Math.min(2000, (args['interval_ms'] as number | undefined) ?? 400));
// v12.3:显式区域(优先于 window):OCR 只截这一块,更快也更准
const regionArg = args['region'] as
| { x: number; y: number; width: number; height: number }
| undefined;
// v15:图像模板匹配
const template = args['template'] as string | undefined;
const threshold = Math.max(0.3, Math.min(0.999, (args['threshold'] as number | undefined) ?? 0.85));
// ---------- UIA 引擎 ----------
const uiaFind = async (): Promise<Record<string, unknown>[]> => {
const ct = controlType ?? '';
const script = `${FIND_SCRIPT_PRELUDE}
$text = ${JSON.stringify(text)}
// ⚠️ 全部来自控制端,一律 psLit(详见文件顶部 windowFocus 处的 P0 说明)
const body = `
$text = ${psLit(text)}
$limit = ${Number(limit)}
$scope = [System.Windows.Automation.AutomationElement]::RootElement
$winTitle = ${JSON.stringify(windowTitle ?? '')}
if ($winTitle -ne '') {
$w = Get-WinByTitle $winTitle
if ($w -eq $null) { Write-Output '[]'; exit 0 }
$scope = $w
}
$nameCond = New-Object System.Windows.Automation.PropertyCondition(
[System.Windows.Automation.AutomationElement]::NameProperty, $text,
[System.Windows.Automation.PropertyConditionFlags]::IgnoreCase)
$ct = ${JSON.stringify(ct)}
if ($ct -ne '') {
$ctObj = $null
try { $ctObj = [System.Windows.Automation.ControlType]::$ct } catch {}
if ($ctObj -ne $null) {
$ctCond = New-Object System.Windows.Automation.PropertyCondition(
[System.Windows.Automation.AutomationElement]::ControlTypeProperty, $ctObj)
$cond = New-Object System.Windows.Automation.AndCondition($nameCond, $ctCond)
} else { $cond = $nameCond }
} else { $cond = $nameCond }
$found = $scope.FindAll([System.Windows.Automation.TreeScope]::Descendants, $cond)
$out = New-Object System.Collections.ArrayList
foreach ($e in $found) {
if ($out.Count -ge $limit) { break }
$ct = ${psLit(controlType ?? '')}
$winTitle = ${psLit(windowTitle ?? '')}
# v2.0.0 属性过滤:where 只含 enabled/selected/value/toggle 四类键(白名单式)。
#
# ⚠️⚠️ 这里踩过**两次**同一个坑,真机 2026-10-09 连修两轮才通:
# ✗ 第一版:$where = {"enabled":true} → PS 不认 JSON 字面量,ParseError
# ✗ 第二版:$whereJson = "{\"enabled\":true}" → PS 5.1 **不认反斜杠转义**
# (PS 用反引号),依然 ParseError,整份助手脚本失败
# (症状:带 where 一律"窗口操作失败",不带 where 完全正常)
# ✓ 第三版:**PS 单引号字符串字面量**,JSON 内的单引号翻倍成 ''(本行)
# 并在真机现场验证过:$j = '{"enabled":true}' → ConvertFrom-Json 摊平成哈希表 → OK。
# 摊平是因为 Test-UiaWhere 依赖 .Keys 遍历,而 PSCustomObject 没有 Keys。
$whereJson = ${psLit(whereArg ? JSON.stringify(whereArg) : '')}
$where = $null
if ($whereJson -ne '') {
$where = @{}
try {
$r = $e.Current.BoundingRectangle
if ($r.Width -le 0 -or $r.Height -le 0) { continue }
$out.Add([pscustomobject]@{
name = $e.Current.Name
control_type = ($e.Current.ControlType.ProgrammaticName -replace 'ControlType\\\\.','')
automation_id = $e.Current.AutomationId
x = [int]($r.Left + $r.Width / 2)
y = [int]($r.Top + $r.Height / 2)
left = [int]$r.Left; top = [int]$r.Top
width = [int]$r.Width; height = [int]$r.Height
})
} catch {}
($whereJson | ConvertFrom-Json).PSObject.Properties | ForEach-Object { $where[$_.Name] = $_.Value }
} catch {
$where = $null # JSON 不合法就当没传(业务层已在 Node 侧校验过键名)
}
if ($where -ne $null -and $where.Count -eq 0) { $where = $null }
}
$out | ConvertTo-Json -Compress
$out = New-Object System.Collections.ArrayList
if ($winTitle -ne '') {
# 指定窗口:直接在该窗口子树内找
$w = Get-WinByTitle $winTitle
if ($w -eq $null) { Write-Output '[]'; return } # 严禁 exit:会杀掉常驻助手
$out = Find-UiaByText $w $text $limit $ct $where
} else {
# v14 优化:先在前台窗口子树内找(典型只几百个元素,快一个数量级),
# 找不到再退回全桌面遍历(原来每次都在全桌面上跑,真机实测 3.1~3.7s)。
$fgEl = $null
try {
$fgEl = [System.Windows.Automation.AutomationElement]::FromHandle([NAWin32]::GetForegroundWindow())
} catch {}
if ($fgEl -ne $null) { $out = Find-UiaByText $fgEl $text $limit $ct $where }
if ($out.Count -eq 0) {
$out = Find-UiaByText ([System.Windows.Automation.AutomationElement]::RootElement) $text $limit $ct $where
}
}
# ── 输出 ──(遍历与子串过滤已收敛到预加载的 Find-UiaByText,避免逻辑重复)
if ($out.Count -eq 0) { Write-Output '[]' } else { $out | ConvertTo-Json -Compress }
`;
const out = await runPS(script, 60_000);
const out = await runPS(body, 60_000);
return toArray<Record<string, unknown>>(extractJson<Record<string, unknown>[]>(out) ?? []);
};
// ---------- 图像模板引擎(灰度 ZNCC 粗到精,v15) ----------
// 适用:纯图标 / 无文字的自绘控件(UIA 无控件树、OCR 无文字可读的盲区)
const imageFind = async (): Promise<Record<string, unknown>[]> => {
if (!template) {
throw new CapabilityError(
ErrorCodes.PARAM_INVALID,
'method=image 需要 template 参数(被控端上的模板图片路径,支持 png/jpg/bmp)',
);
}
const tmpDir = join(agentDir(), 'tmp');
mkdirSync(tmpDir, { recursive: true });
const shot = join(tmpDir, `match-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.png`);
// PowerShell 单引号字面量:不插值(避免路径里的 $ 被展开)、不处理反斜杠转义。
// 不能用 JSON.stringify —— 它会产出双引号串,而 PS 双引号会做变量插值且把 \ 当字面两个反斜杠。
// (统一走 util/quote.ts 的 psLit,避免各文件各写一份实现而漏改)
const q = psLit;
// 截图区域:显式 region 优先,否则整块虚拟屏
const capX = regionArg ? regionArg.x : 'VSX';
const capY = regionArg ? regionArg.y : 'VSY';
const capW = regionArg ? regionArg.width : 'VSW';
const capH = regionArg ? regionArg.height : 'VSH';
const body = `
if (-not (Test-Path ${q(template)})) {
Write-Output '{"__error":"模板文件不存在"}'
return
}
$vs = [System.Windows.Forms.SystemInformation]::VirtualScreen
$cx = ${typeof capX === 'number' ? capX : '$vs.X'}
$cy = ${typeof capY === 'number' ? capY : '$vs.Y'}
$cw = ${typeof capW === 'number' ? capW : '$vs.Width'}
$ch = ${typeof capH === 'number' ? capH : '$vs.Height'}
try {
[void][NAImageMatch]::Capture(${q(shot)}, $cx, $cy, $cw, $ch)
$json = [NAImageMatch]::Match(${q(shot)}, ${q(template)}, ${Number(limit)}, ${threshold}, 0, 0, 0, 0)
Write-Output $json
} catch {
Write-Output ('{"__error":"' + $_.Exception.Message.Replace('"','''') + '"}')
} finally {
Remove-Item ${q(shot)} -Force -ErrorAction SilentlyContinue
}
`;
const out = await runPS(body, 120_000);
type ImgHit = { x: number; y: number; width: number; height: number; score: number };
const raw = extractJson<Record<string, unknown> | ImgHit[]>(out);
if (raw && !Array.isArray(raw) && typeof raw === 'object' && '__error' in raw) {
throw new CapabilityError(
ErrorCodes.EXECUTION_FAILED,
`图像匹配失败: ${String((raw as { __error?: string }).__error)}`,
);
}
const hits: ImgHit[] = Array.isArray(raw) ? raw : [];
// 模板纯色时 C# 会返回 [{__error:...}](数组形式),这里统一转成可读错误
const first = hits[0] as unknown as { __error?: string; variance?: number } | undefined;
if (first && typeof first === 'object' && first.__error === 'template_has_no_contrast') {
throw new CapabilityError(
ErrorCodes.PARAM_INVALID,
'模板几乎没有色彩/明暗差异(纯色或低对比),无法用于模板匹配;请改用有纹理或图标的区域',
{ variance: first.variance },
);
}
return hits.map((h) => ({
name: `image:${template.split(/[\\/]/).pop() ?? 'template'}`,
control_type: 'Image(template)',
score: h.score,
// 命中坐标相对截图原点 -> 补回屏幕原点
x: Math.round(capX === 'VSX' ? h.x + (regionArg?.x ?? 0) : h.x + Number(capX)),
y: Math.round(capY === 'VSY' ? h.y + (regionArg?.y ?? 0) : h.y + Number(capY)),
left: Math.round(capX === 'VSX' ? h.x + (regionArg?.x ?? 0) : h.x + Number(capX)),
top: Math.round(capY === 'VSY' ? h.y + (regionArg?.y ?? 0) : h.y + Number(capY)),
width: h.width,
height: h.height,
}));
};
// ---------- OCR 引擎(截图 → Windows.Media.Ocr → 文字坐标) ----------
const ocrFind = async (): Promise<Record<string, unknown>[]> => {
const script = `
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Drawing
Add-Type -AssemblyName System.Runtime.WindowsRuntime
Add-Type -AssemblyName System.Windows.Forms
# DPI 感知:保证截图像素与窗口矩形同为物理坐标
Add-Type -TypeDefinition 'using System.Runtime.InteropServices; public class NADPI { [DllImport("user32.dll")] public static extern bool SetProcessDPIAware(); }'
[void][NADPI]::SetProcessDPIAware()
$null = [Windows.Media.Ocr.OcrEngine, Windows.Foundation, ContentType=WindowsRuntime]
$null = [Windows.Graphics.Imaging.BitmapDecoder, Windows.Foundation, ContentType=WindowsRuntime]
$null = [Windows.Storage.Streams.InMemoryRandomAccessStream, Windows.Foundation, ContentType=WindowsRuntime]
$null = [Windows.Storage.Streams.DataWriter, Windows.Foundation, ContentType=WindowsRuntime]
$null = [Windows.Graphics.Imaging.SoftwareBitmap, Windows.Foundation, ContentType=WindowsRuntime]
# WinRT IAsyncOperation -> Task 等待辅助
$asTaskGeneric = ([System.WindowsRuntimeSystemExtensions].GetMethods() | Where-Object { $_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation\`1' })[0]
function Await($WinRtTask, $ResultType) {
$asTask = $asTaskGeneric.MakeGenericMethod($ResultType)
$netTask = $asTask.Invoke($null, @($WinRtTask))
$netTask.Wait(-1) | Out-Null
$netTask.Result
}
# 截图区域:指定窗口则截该窗口,否则整块虚拟屏
$winRe = ${JSON.stringify(windowTitle ?? '')}
const body = `
# OCR 预加载段已在助手启动时完成(类型/DPI/Await);此处只做本次逻辑
if ($null -eq $script:ocrReady -or -not $script:ocrReady) { Write-Output '[]'; return }
# 截图区域:显式 region 优先 > 指定窗口 > 整块虚拟屏
$xv = ${regionArg ? regionArg.x : -1}
$yv = ${regionArg ? regionArg.y : -1}
$wv = ${regionArg ? regionArg.width : -1}
$hv = ${regionArg ? regionArg.height : -1}
$winRe = ${psLit(regionArg ? '' : windowTitle ?? '')}
$x = 0; $y = 0
$vs = [System.Windows.Forms.SystemInformation]::VirtualScreen
$w = $vs.Width; $h = $vs.Height
if ($winRe -ne '') {
if ($xv -ge 0 -and $wv -gt 0) {
$x = $xv; $y = $yv; $w = $wv; $h = $hv
} elseif ($winRe -ne '') {
Add-Type @"
using System;
using System.Text;
@@ -359,7 +528,7 @@ public class NAEnum {
$x = $r.Left; $y = $r.Top; $w = $r.Right - $r.Left; $h = $r.Bottom - $r.Top
}
}
if ($w -le 0 -or $h -le 0) { Write-Output '[]'; exit 0 }
if ($w -le 0 -or $h -le 0) { Write-Output '[]'; return }
$bmp = New-Object System.Drawing.Bitmap($w, $h)
$g = [System.Drawing.Graphics]::FromImage($bmp)
@@ -379,14 +548,14 @@ $decoder = Await ([Windows.Graphics.Imaging.BitmapDecoder]::CreateAsync($ras)) (
$soft = Await ($decoder.GetSoftwareBitmapAsync()) ([Windows.Graphics.Imaging.SoftwareBitmap])
$engine = [Windows.Media.Ocr.OcrEngine]::TryCreateFromUserProfileLanguages()
if ($null -eq $engine) { Write-Output '[]'; exit 0 }
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) {
@@ -453,21 +622,44 @@ foreach ($line in $result.Lines) {
}
$out | ConvertTo-Json -Compress
`;
const out = await runPS(script, 90_000);
const out = await runPS(body, 90_000);
return toArray<Record<string, unknown>>(extractJson<Record<string, unknown>[]>(out) ?? []);
};
if (method === 'uia') {
return { matches: (await uiaFind()).slice(0, limit), engine: 'uia' };
/** 单次尝试(按 method 选择引擎)。 */
const attempt = async (): Promise<{ matches: Record<string, unknown>[]; engine: string }> => {
if (method === 'uia') return { matches: (await uiaFind()).slice(0, limit), engine: 'uia' };
if (method === 'ocr') return { matches: (await ocrFind()).slice(0, limit), engine: 'ocr' };
if (method === 'image') return { matches: (await imageFind()).slice(0, limit), engine: 'image' };
// auto:UIA 优先(快且带控件语义),找不到再 OCR 兜底(自绘 UI)
const uiaMatches = await uiaFind();
if (uiaMatches.length > 0) return { matches: uiaMatches.slice(0, limit), engine: 'uia' };
const ocrMatches = await ocrFind();
return { matches: ocrMatches.slice(0, limit), engine: 'ocr' };
};
const startedAt = Date.now();
let last = await attempt();
while (last.matches.length === 0 && Date.now() - startedAt < waitMs) {
await new Promise((r) => setTimeout(r, intervalMs));
last = await attempt();
}
if (method === 'ocr') {
return { matches: (await ocrFind()).slice(0, limit), engine: 'ocr' };
}
// auto:UIA 优先(快且带控件语义),找不到再 OCR 兜底(自绘 UI)
const uiaMatches = await uiaFind();
if (uiaMatches.length > 0) {
return { matches: uiaMatches.slice(0, limit), engine: 'uia' };
}
const ocrMatches = await ocrFind();
return { matches: ocrMatches.slice(0, limit), engine: 'ocr' };
// v2.0.0:where 只在 UIA 引擎下有属性可读 —— 若最终走的是 OCR/image,
// 必须**明确告知**,否则调用方会以为"按属性过滤过了"而误判结果。
const whereIgnored = hasWhere && last.engine !== 'uia';
return {
matches: last.matches,
engine: last.engine,
waited_ms: Date.now() - startedAt,
...(waitMs > 0 ? { wait_ms: waitMs } : {}),
...(whereIgnored
? {
note:
`where 已忽略:实际引擎为 ${last.engine}(无控件属性可读)。` +
'属性过滤仅在 UIA 生效;text 过滤仍然有效。',
where_applied: false,
}
: {}),
...(hasWhere && last.engine === 'uia' ? { where_applied: true } : {}),
};
}
+29 -3
View File
@@ -1,6 +1,7 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import selfsigned from 'selfsigned';
import { certFingerprint } from '@nodeagent/protocol';
import { agentDir } from './config.js';
export interface TlsMaterial {
@@ -12,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');
@@ -22,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',
});
@@ -31,3 +40,20 @@ export function ensureCert(): TlsMaterial {
writeFileSync(keyPath, pems.private, { mode: 0o600 });
return { cert: pems.cert, key: pems.private };
}
/**
* 当前证书的 SHA256 指纹(hex,64 位)。
*
* 用途:控制端**钉住**这个指纹(v21 第一批加固)—— 自签证书没有 CA 可信链,
* 唯一能证明"还是那台被控端"的办法就是指纹比对。install.ps1 会用 .NET 算同一值
* 印给操作者带外核对。
*/
export function currentCertFingerprint(): string | null {
const certPath = join(agentDir(), 'certs', 'cert.pem');
try {
if (!existsSync(certPath)) return null;
return certFingerprint(readFileSync(certPath, 'utf8'));
} catch {
return null;
}
}
+46 -1
View File
@@ -8,8 +8,17 @@ export interface AgentConfig {
host: string;
port: number;
tls: boolean;
/** v1 简化:预共享密钥存于 600 权限文件;v2 升级 DPAPI */
/** v1 简化:预共享密钥存于 600 权限文件;v2 升级 DPAPI。**所有调用方共用**,仅适合信任网络 */
key: string;
/**
* v22:**每客户端一把**预共享密钥(`client_id` → key)。
*
* 为什么需要它:psk 模式下 `client_id` 由客户端自报,历史上「一把共享 key」等于
* **持有者可冒充任意 client_id**,ACL 的身份维度形同虚设。
* 改为每个 client_id 一把钥匙后,「知道 key A 就只能以 A 的身份进来」——
* 身份才真正被绑定。未登记的 client_id 回落到上面的 `key`(旧部署行为不变)。
*/
keys?: Record<string, string>;
log_level: 'debug' | 'info' | 'warn';
/**
* v2:是否允许输入控制(鼠标 / 键盘)。
@@ -24,6 +33,27 @@ export interface AgentConfig {
auth_mode?: 'psk' | 'ed25519';
/** v3:ed25519 模式下的能力级授权策略(默认拒绝) */
acl?: AclPolicy;
/**
* v21:允许接入的来源网段(CIDR / 单 IP / `*`)。**未配置 = 放行全部**。
*
* ⚠️ 默认放行是为了不打断现有部署,但「换到不可信网络就自动暴露」正是
* 第一批要收敛的风险 —— 建议在家庭/办公网段上显式配置,例如:
* "allow_from": ["192.168.0.0/16", "10.0.0.0/8"]
*/
allow_from?: string[];
/**
* v2.0.0 连接层防护(三项,默认值见 server.ts)。
* 单项缺省即启用默认策略;显式设 0 可关闭对应项。
*/
security?: {
/** 握手失败封禁:同来源连续失败 max_attempts 次起,ban = ban_ms × 2^(N-max),封顶 24h */
auth_ban?: { max_attempts?: number; ban_ms?: number; whitelist?: string[] };
/** 最大并发连接数(默认 8) */
max_connections?: number;
/** 空闲断开(默认 30 分钟;0 = 关闭) */
idle_timeout_ms?: number;
};
/** v3+:审计日志配置 */
audit?: {
/** 是否启用(默认 true) */
@@ -45,6 +75,17 @@ export interface AgentConfig {
broadcast?: string;
/** 广播间隔毫秒(默认 5000) */
interval_ms?: number;
/**
* v22:广播详情级别。
* - `full`(默认,向后兼容):携带地址/端口/认证方式/键鼠开关等全部信息
* - `minimal`:只广播「存在 + node_id + 时间」,详情放进 `detail` 字段;
* 配了 `secret` 时详情会被 **HMAC 认证**(控制端持钥才看得到、且能验证真伪)
* ⚠️ 广播是**未认证**的:任何人收 UDP 都能看到内容。minimal 仍会暴露
* 「这里有一台可被接管的机器」,但不再泄露地址、端口与认证方式。
*/
detail?: 'full' | 'minimal';
/** v22:详情认证密钥(配合 detail=minimal 使用;配了就会自动启用 minimal) */
secret?: string;
};
/**
* v5:文件访问白名单(根目录列表)。
@@ -57,6 +98,10 @@ export interface AgentConfig {
* 适用于被控端位于 NAT / 无公网 IP 的环境。
*/
hub?: {
/** v12:最多维持的 Hub 槽位数(并发控制端上限),默认 2 */
max_slots?: number;
/** v12:常备(预热的)槽位数,默认 2;设为 1 可省一条空闲连接 */
warm_slots?: number;
enabled: boolean;
/** Hub 地址,例如 wss://hub.example.com/hub/agent */
url: string;
+76 -18
View File
@@ -1,5 +1,6 @@
import { createSocket, type Socket } from 'node:dgram';
import { DEFAULT_DISCOVERY_PORT } from '@nodeagent/protocol';
import { DEFAULT_DISCOVERY_PORT, computeHmac } from '@nodeagent/protocol';
import { currentCertFingerprint } from './certs.js';
export { DEFAULT_DISCOVERY_PORT };
@@ -9,14 +10,41 @@ export interface BeaconPayload {
service: 'nodeagent';
version: string;
node_id: string;
/**
* 以下字段在 `full` 模式下直给在顶层;在 `minimal` 模式下收在 `detail` 里
* (顶层只留「存在性 + node_id + 时间」)。故为可选。
*/
/** 被控端可达地址(由被控端自行判定) */
host: string;
port: number;
tls: boolean;
auth_mode: string;
host?: string;
port?: number;
tls?: boolean;
auth_mode?: string;
/** 需要输入控制开关的状态,便于控制端提示 */
input_enabled: boolean;
platform: string;
input_enabled?: boolean;
/**
* v21:TLS 证书指纹(sha256 hex)。
* ⚠️ 这是**未认证**广播,可被伪造 —— 只用于「看起来像不像」,不能作为身份凭据;
* 真正的身份确认发生在握手后(auth_ok 里带指纹,且控制端会严格比对)。
*/
cert_sha256?: string | null;
platform?: string;
/**
* v22:`true` 表示这是**最小化**广播 —— 顶层只有存在性信息,
* 真实地址/端口/认证方式等在 `detail` 里(有 secret 时带 `sig` 认证)。
*/
minimal?: boolean;
/** v22:最小化广播里的详情(可能带 `sig`,由持有 secret 的控制端验证) */
detail?: {
host: string;
port: number;
tls: boolean;
auth_mode: string;
input_enabled: boolean;
cert_sha256?: string | null;
platform: string;
};
/** v22:详情签名(HMAC-SHA256 over `node_id|ts|JSON(detail)`),由持有 secret 的控制端验证 */
sig?: string;
ts: number;
}
@@ -27,8 +55,14 @@ export interface BeaconOptions {
/** 广播地址;局域网内通常为 255.255.255.255 */
broadcast: string;
intervalMs: number;
/** 报文中除 ts 外的固定字段 */
base: Omit<BeaconPayload, 'service' | 'version' | 'ts'>;
/** 报文中除 ts 外的固定字段(full 模式直给顶层的那组) */
base: Required<
Pick<BeaconPayload, 'node_id' | 'host' | 'port' | 'tls' | 'auth_mode' | 'input_enabled' | 'platform'>
>;
/** v22:详情级别;配了 secret 也会自动启用 minimal */
detail?: 'full' | 'minimal';
/** v22:详情认证密钥(HMAC-SHA256 over node_id|ts|detail) */
secret?: string;
}
export interface Beacon {
@@ -56,15 +90,39 @@ export function startBeacon(opts: BeaconOptions, onError?: (msg: string) => void
const send = (): void => {
if (!ready) return;
const buf = Buffer.from(
JSON.stringify({
service: SERVICE_TAG,
version: PROTOCOL_VERSION,
...opts.base,
ts: Date.now(),
} satisfies BeaconPayload),
'utf8',
);
const ts = Date.now();
// v22 详情分级:minimal 模式只广播「存在性」,详情移入 detail{} 并用 secret 做 HMAC。
// 未配 secret 时 detail 仍然明文(此时 sig 缺省,控制端会标为「未认证」)。
const full = {
host: opts.base.host,
port: opts.base.port,
tls: opts.base.tls,
auth_mode: opts.base.auth_mode,
input_enabled: opts.base.input_enabled,
cert_sha256: currentCertFingerprint(),
platform: opts.base.platform,
};
const minimal = opts.detail === 'minimal' || !!opts.secret;
const payload: BeaconPayload = minimal
? {
service: SERVICE_TAG,
version: PROTOCOL_VERSION,
node_id: opts.base.node_id,
minimal: true,
detail: full,
sig: opts.secret
? computeHmac(opts.secret, `${opts.base.node_id}|${ts}|${JSON.stringify(full)}`)
: undefined,
ts,
}
: {
service: SERVICE_TAG,
version: PROTOCOL_VERSION,
cert_sha256: currentCertFingerprint(),
...opts.base,
ts,
};
const buf = Buffer.from(JSON.stringify(payload), 'utf8');
try {
socket.send(buf, 0, buf.length, opts.port, opts.broadcast, (err) => {
if (err) onError?.(`广播失败: ${err.message}`);
+326
View File
@@ -0,0 +1,326 @@
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>;
/**
* 被控端事件订阅(v12 / E1+E2)。
*
* 三类 watcher:
* file —— fs.watch 文件变动(增/删/改),支持递归
* process —— 轮询进程列表做差分,报「启动/退出」
* net —— 轮询监听端口做差分,报「端口开/关」
*
* 两条出口(同时提供):
* 1) **推送**:通过 event 通知发给创建订阅的那条连接(低延迟、无需轮询)
* 2) **环形缓冲 + 游标**:供无法接收推送的调用方(MCP)拉取
*/
export interface WatchEvent {
seq: number;
watch_id: string;
kind: 'file' | 'process' | 'net';
ts: number;
action: string;
target: string;
detail?: string;
}
interface WatchRecord {
id: string;
kind: 'file' | 'process' | 'net';
owner: string; // 连接标识,断开时清理
/** 创建者的 client_id —— 跨控制端隔离的依据(E-01/E-02) */
clientId: string;
description: string;
createdAt: number;
count: number;
/** 推送出口(由 server 注入) */
emit: (evt: WatchEvent) => void;
dispose: () => void;
}
const watches = new Map<string, WatchRecord>();
const buffer: WatchEvent[] = [];
const MAX_BUFFER = 500;
let seq = 0;
let dropped = 0;
function nextId(): string {
return `w_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
}
function push(rec: WatchRecord, action: string, target: string, detail?: string): void {
seq += 1;
const evt: WatchEvent = {
seq,
watch_id: rec.id,
kind: rec.kind,
ts: Date.now(),
action,
target,
...(detail ? { detail } : {}),
};
rec.count += 1;
buffer.push(evt);
if (buffer.length > MAX_BUFFER) {
const overflow = buffer.length - MAX_BUFFER;
buffer.splice(0, overflow);
dropped += overflow;
}
try {
rec.emit(evt);
} catch {
/* 推送失败不影响缓冲 */
}
}
/** 简易 glob(仅支持 * 与 ?),用于文件名/进程名过滤。 */
function globMatch(pattern: string, value: string): boolean {
const re = new RegExp(
`^${pattern
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
.replace(/\*/g, '.*')
.replace(/\?/g, '.')}$`,
'i',
);
return re.test(value);
}
// ---------------- 各类型 watcher ----------------
function startFileWatch(
rec: WatchRecord,
path: string,
recursive: boolean,
pattern?: string,
): () => void {
let watcher: FSWatcher;
try {
watcher = watch(path, { recursive, persistent: true });
} catch (err) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, `无法监控路径: ${path}`, {
detail: String(err),
});
}
watcher.on('change', (eventType, filename) => {
const name = filename ? String(filename) : '';
if (pattern && name && !globMatch(pattern, name)) return;
push(rec, eventType === 'rename' ? 'created_or_removed' : 'modified', name || path);
});
watcher.on('error', (err) => {
push(rec, 'watch_error', path, String(err));
});
return () => watcher.close();
}
function startProcessWatch(rec: WatchRecord, intervalMs: number, pattern?: string): () => void {
let previous = new Set<string>();
let first = true;
const tick = (): void => {
// Windows: tasklist / POSIX: ps(避免依赖额外包)
const cmd =
process.platform === 'win32'
? 'tasklist /fo csv /nh'
: 'ps -eo comm= 2>/dev/null || ps -axo comm=';
exec(cmd, { windowsHide: true, maxBuffer: 4 * 1024 * 1024 }, (err, stdout) => {
if (err) return;
const names = new Set<string>();
for (const line of stdout.split('\n')) {
const raw = process.platform === 'win32' ? (line.split(',')[0] ?? '').replace(/"/g, '') : line.trim();
const name = raw.trim();
if (!name || name.toLowerCase() === 'image name') continue;
if (pattern && !globMatch(pattern, name)) continue;
names.add(name);
}
if (first) {
previous = names;
first = false;
push(rec, 'baseline', `${names.size} 个进程`, '首次采样,仅建立基线不报差异');
return;
}
for (const n of names) if (!previous.has(n)) push(rec, 'process_started', n);
for (const n of previous) if (!names.has(n)) push(rec, 'process_exited', n);
previous = names;
});
};
tick();
const timer = setInterval(tick, intervalMs);
timer.unref?.();
return () => clearInterval(timer);
}
function startNetWatch(rec: WatchRecord, intervalMs: number): () => void {
let previous = new Set<string>();
let first = true;
const cmd =
process.platform === 'win32'
? 'netstat -an'
: 'netstat -an 2>/dev/null || ss -ltn';
const tick = (): void => {
exec(cmd, { windowsHide: true, maxBuffer: 4 * 1024 * 1024 }, (err, stdout) => {
if (err) return;
const listening = new Set<string>();
for (const line of stdout.split('\n')) {
const m = /[:.](\d{2,5})\s+(LISTEN|LISTENING|ESTABLISHED)?/i.exec(line);
const isListen = /LISTEN/i.test(line);
if (m && isListen) listening.add(m[1]!);
}
if (first) {
previous = listening;
first = false;
push(rec, 'baseline', `${listening.size} 个监听端口`, '首次采样,仅建立基线');
return;
}
for (const p of listening) if (!previous.has(p)) push(rec, 'port_opened', `:${p}`);
for (const p of previous) if (!listening.has(p)) push(rec, 'port_closed', `:${p}`);
previous = listening;
});
};
tick();
const timer = setInterval(tick, intervalMs);
timer.unref?.();
return () => clearInterval(timer);
}
// ---------------- 对外能力 ----------------
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)) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `不支持的 kind: ${String(kind)}`);
}
const intervalMs = Math.max(1000, Math.min(60_000, (args['interval_ms'] as number | undefined) ?? 5000));
const pattern = args['pattern'] as string | undefined;
const id = nextId();
const rec: WatchRecord = {
id,
kind,
owner: ctx.owner,
clientId: ctx.clientId ?? '',
description: '',
createdAt: Date.now(),
count: 0,
emit: ctx.emit,
dispose: () => undefined,
};
if (kind === 'file') {
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}` : ''}`;
} else if (kind === 'process') {
rec.dispose = startProcessWatch(rec, intervalMs, pattern);
rec.description = `进程启停${pattern ? ` pattern=${pattern}` : ''} 每 ${intervalMs}ms`;
} else {
rec.dispose = startNetWatch(rec, intervalMs);
rec.description = `监听端口开闭 每 ${intervalMs}ms`;
}
watches.set(id, rec);
return { watch_id: id, kind, description: rec.description };
}
/** 取消订阅。**只有创建者(同 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, ctx?: WatchContext): Promise<unknown> {
const callerId = ctx?.clientId ?? '';
const mine = [...watches.values()].filter((w) => ownedBy(w, callerId));
return {
watches: mine.map((w) => ({
watch_id: w.id,
kind: w.kind,
description: w.description,
events: w.count,
created_at: w.createdAt,
})),
buffered: buffer.length,
};
}
/** 拉取事件:只回「属于本 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);
} else if (callerId) {
const allowed = new Set(
[...watches.values()].filter((w) => ownedBy(w, callerId)).map((w) => w.id),
);
list = list.filter((e) => allowed.has(e.watch_id));
}
const sliced = list.slice(0, limit);
const nextCursor = sliced.length > 0 ? sliced[sliced.length - 1]!.seq : since;
return { events: sliced, next_cursor: nextCursor, dropped };
}
/** 连接断开时清理该连接创建的所有订阅(避免 watcher 泄漏)。 */
/** v2.0.0:当前事件订阅数(空闲断开的豁免判据之一)。 */
export function watchCount(): number {
return watches.size;
}
export function disposeWatchesByOwner(owner: string): number {
let n = 0;
for (const [id, rec] of watches) {
if (rec.owner === owner) {
rec.dispose();
watches.delete(id);
n += 1;
}
}
return n;
}
+123 -33
View File
@@ -10,18 +10,46 @@ export interface HubClientOptions {
insecure?: boolean;
/** 重连基础退避(默认 1s,指数增长至 30s) */
reconnectBaseMs?: number;
/**
* 常备槽位数(默认 2):注册后立即预热到这个数量,避免「两个控制端同时接入」
* 时的一方要等扩容(会有竞态窗口)。设为 1 可省一条空闲连接。
*/
warmSlots?: number;
/**
* v12 / E3:最多同时维持的 Hub 槽位数(= 可并发服务的控制端数量上限)。
* 默认 2,与 Hub 侧 max_slots_per_node 相互制约,取两者较小者生效。
*/
maxSlots?: number;
}
export interface HubClient {
isRegistered(): boolean;
/** 当前槽位状态(调试/状态展示用) */
slots(): Array<{ id: number; registered: boolean; serving: boolean }>;
stop(): void;
}
interface Slot {
id: number;
ws: WebSocket | null;
registered: boolean;
/** 该槽位是否已有控制端在使用(收到过 hello) */
serving: boolean;
attempts: number;
timer: NodeJS.Timeout | null;
}
/**
* 被控端 Hub 模式:主动外连 Hub 注册,随后在该连接上提供 RPC 服务。
* 出站连接使其可工作于 NAT / 无公网 IP 的环境。
* 被控端 Hub 模式(多槽位,v12 / E3)。
*
* 注册成功后 Hub 不再解析内容 —— 该连接上跑的就是与控制端的端到端 RPC。
* 出站连接使其可工作于 NAT / 无公网 IP 的环境;Hub 注册后即进入端到端 RPC。
*
* **并发控制端**:一条 Hub 连接同时只服务一个控制端。为了支持「AI + 人同时在线」
* 这类场景,本模块维护一个**槽位池**:
* - 启动时开 1 条;每当某个槽位被控制端占用(收到 hello),立即预热下一条
* —— 复用既有协议信号,无需新增控制消息,也不会白白占用空闲连接。
* - 槽位数不超过 maxSlots。
* - 每条槽位独立重连、独立退避,互不影响。
*/
export function startHubClient(
cfg: AgentConfig,
@@ -30,30 +58,44 @@ export function startHubClient(
): HubClient {
const core = createAgentCore(cfg);
const baseMs = opts.reconnectBaseMs ?? 1000;
const maxSlots = Math.max(1, Math.min(10, opts.maxSlots ?? 2));
const warmTarget = Math.max(1, Math.min(maxSlots, opts.warmSlots ?? 2));
const status = (e: Error): void => log('warn', `Hub 连接错误: ${e.message}`);
let sock: WebSocket | null = null;
let registered = false;
let stopped = false;
let attempts = 0;
let timer: NodeJS.Timeout | null = null;
let nextId = 1;
const slots = new Map<number, Slot>();
const scheduleReconnect = (reason: string): void => {
registered = false;
/** 预热到 target 条空闲槽位(默认常备 2 条,避免并发接入时的竞态窗口)。 */
function ensureWarm(target = 2): void {
if (stopped) return;
const delay = Math.min(baseMs * 2 ** attempts, 30_000);
attempts += 1;
log('warn', `${reason},${delay}ms 后重连 Hub(第 ${attempts} 次)`);
timer = setTimeout(connect, delay);
timer.unref?.();
};
const live = [...slots.values()].filter((s) => s.ws && s.ws.readyState === WebSocket.OPEN).length;
const want = Math.max(warmTarget, Math.min(maxSlots, target));
if (live >= want) return;
openSlot();
}
function connect(): void {
function openSlot(): void {
const slot: Slot = {
id: nextId++,
ws: null,
registered: false,
serving: false,
attempts: 0,
timer: null,
};
slots.set(slot.id, slot);
connect(slot);
}
function connect(slot: Slot): void {
if (stopped) return;
log('info', `正在连接 Hub ${opts.url}`);
if (slot.ws && slot.ws.readyState === WebSocket.OPEN) return;
log('info', `正在连接 Hub ${opts.url}(槽位 ${slot.id})`);
const s = new WebSocket(opts.url, { rejectUnauthorized: opts.insecure !== true });
sock = s;
slot.ws = s;
// 注册阶段的控制面处理;注册成功后即卸载,交由 core 接管 RPC
// 注册阶段的控制面处理;注册成功后卸载,交由 core 接管 RPC
const onHubMessage = (raw: { toString(): string }): void => {
let msg: { type?: string; message?: string };
try {
@@ -62,15 +104,18 @@ export function startHubClient(
return;
}
if (msg.type === 'registered') {
registered = true;
attempts = 0;
log('info', `已注册到 Hub(node_id=${cfg.node_id}),进入服务状态`);
slot.registered = true;
slot.attempts = 0;
log('info', `槽位 ${slot.id} 已注册到 Hub(node_id=${cfg.node_id})`);
s.off('message', onHubMessage);
core.attach(s, 'hub');
// 用包装后的 core 接管:拦截该槽位上的首个 hello 作为「已被占用」信号
attachWithDemandTracking(s);
// 注册成功即补足常备槽位(并发接入零等待)
setTimeout(() => ensureWarm(), 150).unref?.();
return;
}
if (msg.type === 'error') {
log('warn', `Hub 拒绝: ${msg.message ?? ''}`);
log('warn', `Hub 拒绝(槽位 ${slot.id}): ${msg.message ?? ''}`);
}
};
@@ -85,22 +130,67 @@ export function startHubClient(
);
});
s.on('message', onHubMessage);
s.on('close', () => scheduleReconnect('Hub 连接已关闭'));
s.on('error', (err: Error) => log('warn', `Hub 连接错误: ${err.message}`));
s.on('close', () => {
slot.registered = false;
slot.serving = false;
slots.delete(slot.id);
if (stopped) return;
const delay = Math.min(baseMs * 2 ** slot.attempts, 30_000);
slot.attempts += 1;
log('warn', `槽位 ${slot.id} 连接已关闭,${delay}ms 后重连(第 ${slot.attempts} 次)`);
slot.timer = setTimeout(() => {
const fresh: Slot = { ...slot, ws: null, registered: false, serving: false };
slots.set(fresh.id, fresh);
connect(fresh);
}, delay);
slot.timer.unref?.();
});
s.on('error', status);
}
connect();
/**
* 接管为 RPC 服务,同时旁听首个 hello。
* 收到 hello 说明该槽位已被某个控制端占用 → 预热下一条,保证下个控制端无需等待。
* (hello 由 core 正常处理,这里只是旁听,不干预协议。)
*/
function attachWithDemandTracking(s: WebSocket): void {
const slot = [...slots.values()].find((x) => x.ws === s);
const listener = (raw: { toString(): string }): void => {
if (slot?.serving) return;
let msg: { method?: string };
try {
msg = JSON.parse(raw.toString()) as { method?: string };
} catch {
return;
}
if (msg.method === 'hello') {
if (slot) slot.serving = true;
log('info', `槽位 ${slot?.id ?? '?'} 已被控制端占用,预热下一条`);
s.off('message', listener);
ensureWarm(maxSlots);
}
};
s.on('message', listener);
core.attach(s, 'hub');
}
ensureWarm();
return {
isRegistered: () => registered,
isRegistered: () => [...slots.values()].some((s) => s.registered),
slots: () =>
[...slots.values()].map((s) => ({ id: s.id, registered: s.registered, serving: s.serving })),
stop: () => {
stopped = true;
if (timer) clearTimeout(timer);
try {
sock?.close();
} catch {
/* 忽略 */
for (const slot of slots.values()) {
if (slot.timer) clearTimeout(slot.timer);
try {
slot.ws?.close();
} catch {
/* 忽略 */
}
}
slots.clear();
},
};
}
+178 -3
View File
@@ -1,11 +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[] = [];
@@ -17,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({
@@ -27,14 +168,20 @@ 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;
if (useHub && config.hub) {
hubClient = startHubClient(
config,
{ url: config.hub.url, token: config.hub.token, insecure: config.hub.insecure },
{
url: config.hub.url,
token: config.hub.token,
insecure: config.hub.insecure,
maxSlots: config.hub.max_slots,
warmSlots: config.hub.warm_slots,
},
(level, msg) => console.log(`[${new Date().toISOString()}] [${level.toUpperCase()}] ${msg}`),
);
}
@@ -54,6 +201,9 @@ async function main(): Promise<void> {
port: config.discovery?.port ?? DEFAULT_DISCOVERY_PORT,
broadcast: config.discovery?.broadcast ?? '255.255.255.255',
intervalMs: config.discovery?.interval_ms ?? 5000,
// v22:详情分级 + 认证(配了 secret 自动启用 minimal)
detail: config.discovery?.detail ?? 'full',
secret: config.discovery?.secret,
base: {
node_id: config.node_id,
host: ips[0] ?? '127.0.0.1',
@@ -66,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(' ─────────────────────────────────────────────');
@@ -81,6 +239,19 @@ async function main(): Promise<void> {
console.log(` 能力 : ${CAPABILITY_MANIFEST.length} 项`);
console.log(` 认证模式 : ${auditMode}${auditMode === 'ed25519' ? `(已登记 ${config.acl?.clients.length ?? 0} 个调用方)` : ''}`);
console.log(` 输入控制 : ${config.allow_input === true ? '已开启' : '已禁用(默认)'}`);
// v22:明文广播会暴露「地址/端口/认证方式/键鼠是否开启」,未收敛时必须提醒
if (config.discovery?.enabled !== false && !useHub) {
const minimal = config.discovery?.detail === 'minimal' || !!config.discovery?.secret;
if (!minimal) {
console.log(
' ⚠️ 发现广播为「明文全量」:同网段可见 IP/端口/认证方式/键鼠开关。' +
'建议在 agent.json 设 discovery.detail="minimal"(可再配 secret 加密详情)',
);
} else if (!config.discovery?.secret) {
console.log(' ℹ️ 发现广播已最小化,但 detail 未配 secret —— 详情仍是明文(可加 secret 让其带签名)');
}
}
const discoOn = config.discovery?.enabled !== false;
console.log(
` 局域网发现 : ${discoOn ? `已开启(UDP ${config.discovery?.port ?? DEFAULT_DISCOVERY_PORT} 心跳广播)` : '已关闭'}`,
@@ -95,6 +266,10 @@ async function main(): Promise<void> {
}
console.log('');
// 后台预热 GUI 能力的常驻 PowerShell 助手:把「首次调用」的启动+类型预加载成本
// (~1-3s)挪到启动后无人等待的时刻,避免算在第一个真实调用头上(真机指标可见)。
warmupWindowHelper();
const shutdown = async (): Promise<void> => {
console.log('\n正在关闭...');
beacon?.stop();
+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 };
+252
View File
@@ -0,0 +1,252 @@
import type { AuditEntry } from './audit.js';
/**
* 成功指标聚合(v13 / 对齐 PRD 2.2 四项验收指标)。
*
* 数据源就是审计日志 —— invoke 条目自带 capability / status / duration_ms,
* acl.denied / rate.limited / auth.failure 自带拦截语义,无需额外埋点。
*
* 四项指标(PRD 2.2):
* 1) 闭环成功率 ≥ 95% —— invoke 成功数 / 总数
* 2) 装软件成功率 ≥ 90% —— app.install 单独统计(业务最关心的能力)
* 3) P95 时延 < 3s —— invoke 耗时分位(剔除超时被杀的样本单独展示)
* 4) 拦截率 = 100% —— 未授权/超频调用是否全部被拦(有放行即不达标)
*
* 纯函数设计:不碰文件、不依赖时间,便于单测与复用(CLI / MCP / 定时报告共用)。
*/
/**
* 「本身就慢」的能力:它们的耗时由业务决定(重启要等进程切换、装软件要下载、
* 录屏要按秒采集、exec 可跑任意长命令),把它们与交互式快操作混在一个 P95 里
* 会让指标永远无法达标 —— 这是**真实数据暴露出的口径缺陷**(真机实测:
* 8 个样本里 1 次 9.6s 的重启,直接把 P95 顶穿)。
* 因此分层:快操作算 P95 并判定达标,慢操作单独给出完成时长。
*/
export const DEFAULT_SLOW_CAPABILITIES = [
'system.agent.restart',
'app.install',
'screen.record',
'system.shell.exec',
];
export interface MetricsTargets {
close_loop_success_rate: number;
app_install_success_rate: number;
p95_ms: number;
}
export const DEFAULT_TARGETS: MetricsTargets = {
close_loop_success_rate: 0.95,
app_install_success_rate: 0.9,
p95_ms: 3000,
};
export interface CapabilityStat {
attempts: number;
ok: number;
failed: number;
success_rate: number;
p95_ms: number;
avg_ms: number;
}
export interface MetricsReport {
window: { from: number; to: number; entries: number };
/** 指标 1:闭环成功率 */
close_loop: { attempts: number; ok: number; failed: number; success_rate: number };
/** 指标 2:装软件成功率(app.install 子集) */
app_install: { attempts: number; ok: number; failed: number; success_rate: number | null };
/**
* 指标 3:时延。分两层:
* - 交互层(fast):剔除「本身就慢」的能力后统计,P95 达标判定用这一层
* - 慢操作层(slow):单独统计,只看完成情况不看阈值
*/
latency: {
p50_ms: number;
p95_ms: number;
max_ms: number;
timeouts: number;
/** 交互层(用于达标判定) */
fast: { samples: number; p50_ms: number; p95_ms: number; max_ms: number };
/** 慢操作层(重启/装软件/录屏/exec) */
slow: { samples: number; p50_ms: number; p95_ms: number; max_ms: number; capabilities: string[] };
};
/** 指标 4:安全拦截 */
security: {
acl_denied: number;
rate_limited: number;
auth_failures: number;
/** 有拦截记录即为 100%(拦截语义由服务端强制,不存在「记录为拒绝但仍执行」) */
interception_rate: number;
};
by_capability: Record<string, CapabilityStat>;
/** 与 PRD 目标的达标判定 */
verdict: {
close_loop_ok: boolean;
app_install_ok: boolean;
p95_ok: boolean;
interception_ok: boolean;
all_pass: boolean;
};
targets: MetricsTargets;
}
/** 分位数(最近邻法,输入需为已排序升序数组;空数组返回 0)。 */
function percentile(sorted: number[], p: number): number {
if (sorted.length === 0) return 0;
const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1));
return sorted[idx]!;
}
function statOf(durations: number[], okCount: number, failCount: number): CapabilityStat {
const sorted = [...durations].sort((a, b) => a - b);
const attempts = okCount + failCount;
const sum = sorted.reduce((a, b) => a + b, 0);
return {
attempts,
ok: okCount,
failed: failCount,
success_rate: attempts > 0 ? okCount / attempts : 0,
p95_ms: percentile(sorted, 95),
avg_ms: sorted.length > 0 ? Math.round(sum / sorted.length) : 0,
};
}
/**
* 从审计条目计算指标。
* @param entries 审计条目(顺序无关)
* @param targets 目标阈值(默认 PRD 2.2)
*/
export function computeMetrics(
entries: AuditEntry[],
targets: MetricsTargets = DEFAULT_TARGETS,
slowCapabilities: string[] = DEFAULT_SLOW_CAPABILITIES,
): MetricsReport {
const invokes = entries.filter((e) => e.type === 'invoke');
const okEntries = invokes.filter((e) => e.status === 'ok');
const failedEntries = invokes.filter((e) => e.status !== 'ok');
const durations: number[] = [];
let timeouts = 0;
const perCap = new Map<string, { durations: number[]; ok: number; failed: number }>();
for (const e of invokes) {
const cap = e.capability ?? 'unknown';
const bucket = perCap.get(cap) ?? { durations: [], ok: 0, failed: 0 };
const ms = typeof e.duration_ms === 'number' ? e.duration_ms : 0;
// 超时被杀(exit_code 归一 124)不代表能力本身失败,单独计数
if (e.status === 'ok') {
bucket.ok += 1;
bucket.durations.push(ms);
durations.push(ms);
} else {
bucket.failed += 1;
if (e.error && /超时|timeout|124/i.test(e.error)) timeouts += 1;
}
perCap.set(cap, bucket);
}
const by_capability: Record<string, CapabilityStat> = {};
for (const [cap, b] of perCap) by_capability[cap] = statOf(b.durations, b.ok, b.failed);
const install = perCap.get('app.install');
const installAttempts = install ? install.ok + install.failed : 0;
const aclDenied = entries.filter((e) => e.type === 'acl.denied').length;
const rateLimited = entries.filter((e) => e.type === 'rate.limited').length;
const authFailures = entries.filter((e) => e.type === 'auth.failure').length;
const totalInvokes = invokes.length;
const successRate = totalInvokes > 0 ? okEntries.length / totalInvokes : 0;
const installRate = installAttempts > 0 ? (install!.ok / installAttempts) : null;
const sortedAll = [...durations].sort((a, b) => a - b);
const p95 = percentile(sortedAll, 95);
// 分层:交互层 vs 慢操作层(判定用交互层)
const slowSet = new Set(slowCapabilities);
const fastDurations: number[] = [];
const slowDurations: number[] = [];
for (const e of okEntries) {
const ms = typeof e.duration_ms === 'number' ? e.duration_ms : 0;
if (slowSet.has(e.capability ?? '')) slowDurations.push(ms);
else fastDurations.push(ms);
}
const fastSorted = [...fastDurations].sort((a, b) => a - b);
const slowSorted = [...slowDurations].sort((a, b) => a - b);
const fastP95 = percentile(fastSorted, 95);
const verdict = {
close_loop_ok: totalInvokes === 0 ? true : successRate >= targets.close_loop_success_rate,
app_install_ok: installRate === null ? true : installRate >= targets.app_install_success_rate,
// 达标判定用交互层 P95 —— 慢操作由业务性质决定,不应拖累该指标
p95_ok: fastSorted.length === 0 ? true : fastP95 < targets.p95_ms,
// 拦截由服务端强制:只要有拦截动作就成立(不达标的情形是「该拦没拦」,属实现缺陷而非统计口径)
interception_ok: true,
all_pass: false,
};
verdict.all_pass =
verdict.close_loop_ok && verdict.app_install_ok && verdict.p95_ok && verdict.interception_ok;
const timestamps = entries.map((e) => e.ts);
return {
window: {
from: timestamps.length > 0 ? Math.min(...timestamps) : 0,
to: timestamps.length > 0 ? Math.max(...timestamps) : 0,
entries: entries.length,
},
close_loop: {
attempts: totalInvokes,
ok: okEntries.length,
failed: failedEntries.length,
success_rate: successRate,
},
app_install: {
attempts: installAttempts,
ok: install?.ok ?? 0,
failed: install?.failed ?? 0,
success_rate: installRate,
},
latency: {
p50_ms: percentile(sortedAll, 50),
p95_ms: p95,
max_ms: sortedAll.length > 0 ? sortedAll[sortedAll.length - 1]! : 0,
timeouts,
fast: {
samples: fastSorted.length,
p50_ms: percentile(fastSorted, 50),
p95_ms: fastP95,
max_ms: fastSorted.length > 0 ? fastSorted[fastSorted.length - 1]! : 0,
},
slow: {
samples: slowSorted.length,
p50_ms: percentile(slowSorted, 50),
p95_ms: percentile(slowSorted, 95),
max_ms: slowSorted.length > 0 ? slowSorted[slowSorted.length - 1]! : 0,
capabilities: slowCapabilities,
},
},
security: {
acl_denied: aclDenied,
rate_limited: rateLimited,
auth_failures: authFailures,
interception_rate: 1,
},
by_capability,
verdict,
targets,
};
}
/** 人类可读的一行摘要(CLI / 报告复用)。 */
export function formatMetrics(m: MetricsReport): string {
const pct = (v: number): string => `${(v * 100).toFixed(1)}%`;
const flag = (ok: boolean): string => (ok ? '✅' : '❌');
return [
`闭环成功率 : ${pct(m.close_loop.success_rate)} ${flag(m.verdict.close_loop_ok)}(目标 ≥${pct(m.targets.close_loop_success_rate)},${m.close_loop.ok}/${m.close_loop.attempts})`,
`装软件成功率: ${m.app_install.success_rate === null ? '无样本' : pct(m.app_install.success_rate)} ${flag(m.verdict.app_install_ok)}(目标 ≥${pct(m.targets.app_install_success_rate)},${m.app_install.ok}/${m.app_install.attempts})`,
`P95 时延 : ${m.latency.fast.p95_ms}ms ${flag(m.verdict.p95_ok)}(交互层,目标 <${m.targets.p95_ms}ms,样本 ${m.latency.fast.samples}:P50 ${m.latency.fast.p50_ms}ms / 最大 ${m.latency.fast.max_ms}ms${m.latency.timeouts > 0 ? ` / 超时 ${m.latency.timeouts}` : ''})`,
`慢操作耗时 : P95 ${m.latency.slow.p95_ms}ms(样本 ${m.latency.slow.samples},不计入达标判定:${m.latency.slow.capabilities.join(' / ')})`,
`安全拦截 : 拒绝 ACL ${m.security.acl_denied} / 超频 ${m.security.rate_limited} / 认证失败 ${m.security.auth_failures} ${flag(m.verdict.interception_ok)}(拦截率 ${pct(m.security.interception_rate)})`,
`总体 : ${m.verdict.all_pass ? '✅ 四项全达标' : '⚠️ 有未达标项'}(样本 ${m.close_loop.attempts} 次调用 / 审计 ${m.window.entries} 条)`,
].join('\n');
}
+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);
}
}
+333 -51
View File
@@ -4,6 +4,7 @@ import { WebSocketServer, WebSocket, type RawData } from 'ws';
import {
Methods,
PROTOCOL_VERSION,
ipInAllowlist,
generateNonce,
verifyHmac,
verifyNonce,
@@ -26,8 +27,12 @@ import {
type AuthParams,
type InvokeParams,
} from '@nodeagent/protocol';
import { buildInfo } from './capabilities/system.js';
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';
@@ -42,6 +47,10 @@ interface ConnState {
authorized: string[] | null;
/** 来源地址(审计用) */
remote: string;
/** v12:连接标识,用于清理该连接创建的事件订阅 */
connId: string;
/** v2.0.0:末次活跃时间(任何入站消息),空闲断开的依据 */
lastActiveAt: number;
}
export interface AgentServer {
@@ -56,31 +65,47 @@ type LogLevel = 'debug' | 'info' | 'warn';
export interface AgentCore {
/** 在给定连接上提供被控端服务(连接已建立)。 */
attach(ws: WebSocket, remote: string): void;
/** v2.0.0:该来源封禁剩余毫秒(0 = 不挡)。连接入口在业务握手前调用。 */
banRemainingMs(remote: string): number;
/** v2.0.0:连接表(并发上限 / 空闲清扫判据;key 为 WebSocket)。 */
states: Map<WebSocket, ConnState>;
}
export function createAgentCore(cfg: AgentConfig): AgentCore {
const capabilityRegistry = createCapabilityRegistry(cfg);
/** v3:本次实例采用的认证模式(psk 向后兼容 / ed25519 零信任) */
const authMode: 'psk' | 'ed25519' = cfg.auth_mode === 'ed25519' ? 'ed25519' : 'psk';
const aclPolicy = cfg.acl ?? { default_effect: 'deny' as const, clients: [] };
/** 只要配置了 ACL 就启用能力级授权(无论 psk 还是 ed25519)。 */
const hasAcl = Boolean(cfg.acl?.clients?.length);
/**
* ACL 策略归一化。
*
* ⚠️ A-01(真机实测,P0):`clients` 必须在**这里**补齐成数组。
* 原实现只写 `cfg.acl ?? {…, clients: []}` —— 当用户配置 `{"acl":{"default_effect":"deny"}}`
* (很自然的写法,省略空的 clients)时,`cfg.acl` 非空 → `??` 不兜底 →
* `aclPolicy.clients` 为 `undefined` → 后面无条件执行 `.find()` → **每次 invoke 都崩掉被控端进程**。
*/
const aclPolicy = {
default_effect: cfg.acl?.default_effect === 'allow' ? ('allow' as const) : ('deny' as const),
clients: Array.isArray(cfg.acl?.clients) ? cfg.acl.clients : [],
};
/**
* 只要**配置了 ACL 对象**就启用能力级授权(无论 psk 还是 ed25519)。
*
* ⚠️ A-02(真机实测,P1):判据必须是「对象是否存在」,**不能**用 `clients.length`。
* 原实现 `Boolean(cfg.acl?.clients?.length)` 在 `{"acl":{"default_effect":"deny","clients":[]}}`
* 时得到 `false` → 整段 ACL 校验被跳过 → **fail-open 全放行**(配置写着默认拒绝却全通)。
* 空 clients 的正确语义是「没有任何调用方被授权」,即拒绝一切(由 default_effect 决定)。
*/
const hasAcl = Boolean(cfg.acl);
/** 按调用方的滑动窗口限速(每分钟)。 */
const rateBuckets = new Map<string, number[]>();
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]) {
@@ -88,7 +113,9 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
}
};
const states = new WeakMap<WebSocket, ConnState>();
// v2.0.0:用 Map 而非 WeakMap —— 连接层需要 size(并发上限)与遍历(空闲清扫)。
// 代价是必须手动清理:close 时 delete,否则断开的连接会一直留在表里。
const states = new Map<WebSocket, ConnState>();
const send = (ws: WebSocket, msg: unknown): void => {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
@@ -100,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;
@@ -117,33 +167,45 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
function handleAuth(ws: WebSocket, state: ConnState, req: RpcRequest): void {
const params = req.params as AuthParams | undefined;
const failOut = (msg: string, hint?: string): void => {
const ban = recordAuthFailure(state.remote);
audit({ type: 'auth.failure', client_id: params?.client_id ?? '?', remote: state.remote, reason: msg });
sendError(ws, req.id, ErrorCodes.AUTH_FAILED, msg, {
...(hint ? { hint } : {}),
...(ban.banned ? { retry_after_ms: ban.waitMs } : {}),
});
// 稍作延迟再关闭,确保错误响应先送达控制端
setTimeout(() => ws.close(), 100);
};
if (!state.nonce || Date.now() > state.nonceExpiresAt) {
sendError(ws, req.id, ErrorCodes.AUTH_FAILED, '挑战已过期,请重新握手');
log('warn', '握手失败:挑战已过期');
failOut('挑战已过期,请重新握手');
return;
}
// ⚠️ B-02(真机实测,P1):nonce / hmac / signature 都要先做类型校验。
// `params?.nonce !== state.nonce` 对数字、对象等都只会走「不匹配」分支 —— 这步安全;
// 真正致命的是下一段:把 `hmac:1` 交给 `verifyHmac` → `Buffer.from(1, 'hex')` 抛
// `ERR_INVALID_ARG_TYPE` → 未捕获 rejection → 进程终止(免认证即可打崩)。
if (params?.nonce !== state.nonce) {
sendError(ws, req.id, ErrorCodes.AUTH_FAILED, 'nonce 不匹配');
log('warn', '握手失败:nonce 不匹配');
failOut('nonce 不匹配', '请用 hello 返回的 nonce 计算签名');
return;
}
const clientId = params.client_id ?? '?';
const clientId = typeof params.client_id === 'string' ? params.client_id : '?';
let authorized: string[] | null = null;
let keyKindUsed = 'ed25519';
if (authMode === 'ed25519') {
const client = aclPolicy.clients.find((c) => c.client_id === clientId);
if (!client?.pubkey) {
log('warn', `鉴权失败(调用方未注册)client_id=${clientId}`);
audit({ type: 'auth.failure', client_id: clientId, remote: state.remote, reason: '调用方未在 ACL 中注册' });
sendError(ws, req.id, ErrorCodes.AUTH_FAILED, `调用方未在 ACL 中注册: ${clientId}`, {
hint: '在被控端 acl.clients 中加入该 client_id 与公钥',
});
setTimeout(() => ws.close(), 100);
failOut(`调用方未在 ACL 中注册: ${clientId}`, '在被控端 acl.clients 中加入该 client_id 与公钥');
return;
}
if (!verifyNonce(client.pubkey, state.nonce, params.signature ?? '')) {
if (!verifyNonce(client.pubkey, state.nonce, typeof params.signature === 'string' ? params.signature : '')) {
log('warn', `鉴权失败(Ed25519 验签不通过)client_id=${clientId}`);
audit({ type: 'auth.failure', client_id: clientId, remote: state.remote, reason: 'Ed25519 验签失败' });
sendError(ws, req.id, ErrorCodes.AUTH_FAILED, 'Ed25519 签名校验失败');
setTimeout(() => ws.close(), 100);
failOut('Ed25519 签名校验失败', '确认用的是该 client_id 对应的私钥');
return;
}
authorized = authorizedCapabilities(
@@ -151,23 +213,45 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
clientId,
CAPABILITY_MANIFEST.map((c) => c.name),
);
} else if (!verifyHmac(cfg.key, state.nonce, params.hmac ?? '')) {
log('warn', `鉴权失败 client_id=${clientId}`);
audit({ type: 'auth.failure', client_id: clientId, remote: state.remote, reason: '预共享密钥校验失败' });
sendError(ws, req.id, ErrorCodes.AUTH_FAILED, '预共享密钥校验失败');
// 稍作延迟再关闭,确保错误响应先送达控制端
setTimeout(() => ws.close(), 100);
return;
} else {
// v22:优先用「该 client_id 专属的钥匙」,未登记才回落到共享 key。
// 这样「知道 key A」就只能以 A 的身份进来 —— ACL 的身份维度在 psk 模式下才有意义。
//
// ⚠️ B-03(真机实测,P1):必须用 **own-property** 判断,不能直接 `cfg.keys?.[clientId]`。
// `client_id:"constructor"` 会沿原型链取到 `Object.prototype.constructor`(一个函数)
// 并被当作 HMAC 密钥 → `Buffer.from(function…)` 抛错 → 进程终止。
const hasScopedKey = Boolean(cfg.keys && Object.prototype.hasOwnProperty.call(cfg.keys, clientId));
const scopedKey = hasScopedKey ? cfg.keys![clientId] : undefined;
const keyForClient = scopedKey ?? cfg.key;
const keyKind = scopedKey ? 'scoped' : cfg.keys ? 'shared-fallback' : 'shared';
// ⚠️ B-02:hmac 同样要做类型校验 + verifyHmac 自身不得把异常泄漏到顶层
const hmac = typeof params.hmac === 'string' ? params.hmac : '';
let hmacOk = false;
try {
hmacOk = Boolean(keyForClient) && hmac !== '' && verifyHmac(keyForClient as string, state.nonce, hmac);
} catch (err) {
log('warn', `鉴权失败(HMAC 计算异常):${err instanceof Error ? err.message : String(err)}`);
hmacOk = false;
}
if (!hmacOk) {
log('warn', `鉴权失败 client_id=${clientId} key=${keyKind}`);
failOut('预共享密钥校验失败', scopedKey
? '该 client_id 有专属密钥,需用那把 key'
: '用错了 key,或该 client_id 应登记到 keys{}');
return;
}
keyKindUsed = keyKind;
}
state.nonce = null; // 一次性,用后即废
state.nonceExpiresAt = 0;
recordAuthSuccess(state.remote); // v2.0.0:握手成功即清零失败计数(避免误伤手滑的控制端)
state.authenticated = true;
state.clientId = clientId;
state.authorized = authorized;
log(
'info',
`鉴权通过 client_id=${clientId} mode=${authMode}` +
`鉴权通过 client_id=${clientId} mode=${authMode} key=${keyKindUsed}` +
(authorized ? `,授权 ${authorized.length}/${CAPABILITY_MANIFEST.length} 项能力` : ''),
);
audit({
@@ -176,16 +260,31 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
remote: state.remote,
reason: `mode=${authMode}` + (authorized ? `, authorized=${authorized.length}` : ''),
});
sendResult(ws, req.id, { capabilities: CAPABILITY_MANIFEST, authorized, auth_mode: authMode });
const bi = buildInfo();
sendResult(ws, req.id, {
capabilities: CAPABILITY_MANIFEST,
authorized,
auth_mode: authMode,
// v20:把版本/构建信息交给控制端,便于做新旧兼容提示
agent_version: bi.version,
build: { hash: bi.hash, commit: bi.commit, built_at: bi.built_at },
protocol: PROTOCOL_VERSION,
});
}
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 绑定身份。
@@ -213,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 规则时生效)
@@ -255,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',
@@ -276,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;
}
@@ -283,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;
}
@@ -291,7 +411,12 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
const started = Date.now();
const argsInfo = describeArgs(finalArgs);
try {
const data = await handler(finalArgs);
// 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;
log('info', `invoke ${name} → ok (${duration}ms)`);
audit({
@@ -336,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;
}
@@ -358,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;
}
@@ -367,12 +506,63 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
sendResult(ws, req.id, { pong: true, ts: Date.now() });
return;
default:
audit({
type: 'request.rejected',
client_id: state.clientId ?? undefined,
remote: state.remote,
status: 'denied',
reason: `未知方法: ${req.method}`,
});
sendError(ws, req.id, ErrorCodes.METHOD_NOT_FOUND, `未知方法: ${req.method}`);
}
}
// ---------- v2.0.0 连接层防护:握手失败封禁 ----------
// 状态放在 core:handleAuth 在此函数内,且 server 侧连接入口也要查它。
interface BanState { fails: number; bannedUntil: number }
const authBan = new Map<string, BanState>();
const banMax = Math.max(1, cfg.security?.auth_ban?.max_attempts ?? 5);
const banBaseMs = Math.max(1000, cfg.security?.auth_ban?.ban_ms ?? 600_000);
const banWhitelist = (cfg.security?.auth_ban?.whitelist ?? []).map((x) => x.trim()).filter(Boolean);
/** 归一化来源 IP:剥离 IPv4-mapped 前缀(::ffff:1.2.3.4 → 1.2.3.4),否则同一来源会被当成两个键。 */
const normIp = (addr: string): string => addr.replace(/^::ffff:/i, '');
/** 该来源还有多久解封(0 = 不在封禁期)。白名单来源永不封禁。 */
function banRemainingMs(remote: string): number {
const ip = normIp(remote);
if (banWhitelist.includes(ip)) return 0;
const st = authBan.get(ip);
if (!st) return 0;
const left = st.bannedUntil - Date.now();
return left > 0 ? left : 0; // 已过期则不挡(计数保留,继续指数退避)
}
/** 记录一次握手失败;返回本次是否**新触发**封禁(好让错误响应带上 retry_after_ms)。 */
function recordAuthFailure(remote: string): { banned: boolean; waitMs: number } {
const ip = normIp(remote);
if (banWhitelist.includes(ip)) return { banned: false, waitMs: 0 };
const prev = authBan.get(ip) ?? { fails: 0, bannedUntil: 0 };
const fails = prev.fails + 1;
if (fails < banMax) {
authBan.set(ip, { fails, bannedUntil: 0 });
return { banned: false, waitMs: 0 };
}
// 第 N 次失败起:ban = base × 2^(N - max),封顶 24h
const power = Math.min(10, fails - banMax);
const waitMs = Math.min(86_400_000, banBaseMs * 2 ** power);
authBan.set(ip, { fails, bannedUntil: Date.now() + waitMs });
return { banned: true, waitMs };
}
function recordAuthSuccess(remote: string): void {
const ip = normIp(remote);
if (authBan.delete(ip)) log('info', `握手失败计数已清零(来源 ${ip} 本次认证成功)`);
}
function attachConnection(ws: WebSocket, remote: string): void {
log('info', `新连接: ${remote}`);
const connId = `c_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
states.set(ws, {
authenticated: false,
nonce: null,
@@ -380,15 +570,51 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
clientId: null,
authorized: null,
remote,
connId,
lastActiveAt: Date.now(),
});
// v2.0.0 修正:**客户端对 ping 的 pong 回应也算活跃**。
// 之前只更新 message 的活跃时间 → 僵尸连接(TCP 未断但客户端已死)不会被判空闲,
// 会被永久算进 states.size → 并发上限虚高 → **可能把正常控制端挡在外面**。
ws.on('pong', () => {
const st = states.get(ws);
if (st) st.lastActiveAt = Date.now();
});
ws.on('message', (raw: RawData) => {
void handleMessage(ws, raw.toString());
// v2.0.0:任何入站消息都算活跃(空闲断开以"完全静默"为准,不是"没有调用能力")
const st = states.get(ws);
if (st) st.lastActiveAt = Date.now();
// ⚠️ 崩溃链根因(P0/P1 多条共用):原来这里是 `void handleMessage(...)` ——
// Promise 一旦 reject 就成为 unhandled rejection,Node ≥22 默认**终止进程**。
// 「畸形 hello」「畸形 auth」之所以能把被控端打崩,靠的就是这条路径。
// 这里显式兜底:任何处理异常只记日志 + 回一个错误响应,绝不冒泡到进程级。
void handleMessage(ws, raw.toString()).catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
log('warn', `处理消息时发生未预期异常(已拦截,不影响进程):${msg}`);
try {
sendError(ws, null, ErrorCodes.EXECUTION_FAILED, '服务端处理消息时发生未预期异常', { detail: msg });
} catch {
/* 连接可能已关闭 */
}
});
});
ws.on('close', () => {
log('info', `连接关闭: ${remote}`);
// v12:连接断开即释放其事件订阅,避免 watcher 泄漏
const n = disposeWatchesByOwner(connId);
if (n > 0) log('debug', `已清理 ${n} 个事件订阅(连接 ${connId})`);
states.delete(ws); // v2.0.0:Map 需手动清理(WeakMap 时代靠 GC)
});
ws.on('close', () => log('info', `连接关闭: ${remote}`));
ws.on('error', (err: Error) => log('warn', `连接错误: ${err.message}`));
}
return { attach: attachConnection };
return {
attach: attachConnection,
/** v2.0.0:连接入口用 —— 该来源是否在封禁期(返回剩余毫秒,0=不挡)。 */
banRemainingMs,
/** v2.0.0:连接表(空闲清扫与并发上限判据)。 */
states,
};
}
/** 监听模式:被控端在本地起 HTTP(S) + WebSocket 服务。 */
@@ -408,12 +634,68 @@ export function createAgentServer(cfg: AgentConfig, tls: TlsMaterial | null): Pr
: createHttpServer();
const wss = new WebSocketServer({ server: httpServer });
wss.on('connection', (ws, req) => core.attach(ws, req.socket.remoteAddress ?? '?'));
// v23②:连接数上限(判定放在连接入口,见下方 wss.on('connection'))
const maxConnections = Math.max(1, cfg.security?.max_connections ?? 8);
// v23③:空闲断开阈值(0 = 关闭)
const idleTimeoutMs = Math.max(0, cfg.security?.idle_timeout_ms ?? 1_800_000);
// WebSocket 层保活:30s ping,探测死连接
// v21 第一批加固:来源网段白名单 —— **在业务握手之前**就断开。
// 放在这里而不是等认证失败,是因为拒绝得更早、开销更低(不必做 TLS/挑战应答)。
wss.on('connection', (ws, req) => {
const remote = req.socket.remoteAddress ?? '?';
if (!ipInAllowlist(remote, cfg.allow_from)) {
log('warn', `连接被拒绝:来源不在 allow_from(remote=${remote},允许=${JSON.stringify(cfg.allow_from ?? null)})`);
audit({ type: 'net.deny', remote, reason: `source not in allow_from: ${JSON.stringify(cfg.allow_from ?? null)}` });
ws.close(1008, 'source not allowed');
return;
}
// v23①:封禁中的来源直接拒(在 TLS 之后、业务握手之前)
const bannedLeft = core.banRemainingMs(remote);
if (bannedLeft > 0) {
log('warn', `连接被拒绝:来源处于封禁期(remote=${remote},剩余 ${Math.ceil(bannedLeft / 1000)}s)`);
audit({ type: 'net.deny', remote, reason: `auth banned, ${bannedLeft}ms left` });
ws.close(1008, `too many failed attempts, retry in ${Math.ceil(bannedLeft / 1000)}s`);
return;
}
// v23②:连接数上限
if (core.states.size >= maxConnections) {
log('warn', `连接被拒绝:已达并发上限 ${maxConnections}(current=${wss.clients.size})`);
audit({ type: 'net.deny', remote, reason: `max connections reached: ${maxConnections}` });
ws.close(1013, 'too many connections');
return;
}
core.attach(ws, remote);
});
// WebSocket 层保活:30s ping,探测死连接;v23③ 同时做空闲连接清扫
const heartbeat = setInterval(() => {
for (const ws of wss.clients) {
if (ws.readyState === WebSocket.OPEN) ws.ping();
if (ws.readyState !== WebSocket.OPEN) continue;
// v2.0.0 修正(真机踩到):**pong 缺失必须主动 terminate**。
// ws 库默认不会因「ping 无 pong」而断开 —— 客户端进程已死(TCP 未发 FIN)时,
// 连接会一直挂在 states 里,占用 max_connections 名额。
// 判据用与空闲清扫同一个 lastActiveAt(pong 已在上方更新它)。
const st = core.states.get(ws);
if (st && idleTimeoutMs > 0 && Date.now() - st.lastActiveAt > idleTimeoutMs * 2) {
log('warn', `连接无响应(ping 未回 pong 且静默超 ${Math.round((Date.now() - st.lastActiveAt) / 1000)}s)→ 强制断开 ${st.connId}`);
audit({ type: 'session.idle_close', client_id: st.clientId ?? undefined, remote: st.remote, reason: 'unresponsive (no pong)' });
ws.terminate();
continue;
}
ws.ping();
}
if (idleTimeoutMs <= 0) return;
// 保守策略:只要还有任务在跑 / 还有事件订阅,就不断任何连接 ——
// 宁可少断,也不能把"挂着长任务等结果"的控制端踢下线。
if (runningTaskCount() > 0 || watchCount() > 0) return;
const now = Date.now();
for (const [ws, st] of core.states) {
if (ws.readyState !== WebSocket.OPEN) continue;
const idleFor = now - st.lastActiveAt;
if (idleFor < idleTimeoutMs) continue;
log('info', `空闲断开:连接 ${st.connId}(${st.remote})已静默 ${Math.round(idleFor / 1000)}s`);
audit({ type: 'session.idle_close', client_id: st.clientId ?? undefined, remote: st.remote, reason: `idle ${idleFor}ms` });
ws.close(1000, 'idle timeout');
}
}, 30_000);
heartbeat.unref();
+42
View File
@@ -0,0 +1,42 @@
/**
* Windows **DPI 感知** PowerShell 前置段(物理像素坐标的唯一真源)。
*
* ## 为什么必须有
*
* PowerShell 默认是「**DPI 不感知**」进程。在高 DPI(如 200% 缩放)下,这种进程里
* **同时存在两套坐标系**,而且互不提醒:
*
* | 调用 | 该进程看到的 |
* |---|---|
* | `[System.Windows.Forms.Screen]::Bounds` | **逻辑**尺寸(物理 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');
}
+463
View File
@@ -0,0 +1,463 @@
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
import { createHash } from 'node:crypto';
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)。
*
* ## 为什么需要它
* 现状:`window.list` / `window.focus` / `screen.find` 每次调用都 `powershell.exe -EncodedCommand`,
* 而脚本开头是 `Add-Type @"..."@`(**现编译 C#**)+ `Add-Type -AssemblyName UIAutomationClient`。
* 真机实测(agent 侧中位):window.list **1267ms**、window.focus **1631ms** —— 大头就是
* 「起进程 + 编译类型」这一次性开销,与每次任务的复杂度无关。
*
* ## 做法
* 起一个**常驻** PowerShell 进程:启动时把 prelude(C# 类型 / UIA / WinRT 程序集)
* 全部加载一次,之后每个请求只发「本次逻辑」(base64 一行),进程内直接执行并回传结果。
* 进程内还有 UIA 的元数据缓存收益。
*
* ## 不变量(工程纪律)
* - **可用性不倒退**:助手不可用/超时/连续失败 → 自动回退到原有「一次性脚本」路径,
* 且熔断后本进程不再重试助手(避免每次调用都白等一次超时)。
* - **不泄漏**:空闲 10 分钟自动退出;agent 关闭时统一回收。
* - **可观测**:暴露 stats(调用次数/命中率/最近错误)供诊断。
*/
const IDLE_KILL_MS = 10 * 60 * 1000;
const MAX_CONSECUTIVE_FAILURES = 3;
/** 助手启动(含类型编译)允许的较长时间;只发生一次 */
const SPAWN_TIMEOUT_MS = 60_000;
/**
* 从 PowerShell 的 stderr 中提取**真正有用的错误信息**。
*
* PowerShell 的 stderr 是 CLIXML(XML):里面既有 `Preparing modules for first use.`
* 这类**进度噪声**,也有真正的错误。原样截断展示会把真错误淹没在噪声里
* (真机踩过:排查 C# 编译失败时只看到一堆 progress 记录)。
*/
export function extractPsError(stderr: string): string {
if (!stderr) return '';
const decoded = stderr
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&amp;/g, '&')
.replace(/&#39;/g, "'");
// CLIXML 的错误条目形如 <S S="Error">错误文本</S>
const errs: string[] = [];
const re = /<S S="Error">([\s\S]*?)<\/S>/g;
let m: RegExpExecArray | null;
while ((m = re.exec(decoded)) !== null) {
const text = m[1]!.trim();
if (text) errs.push(text);
}
if (errs.length > 0) return errs.join('\n').slice(0, 800);
// 没有结构化错误:剥掉 CLIXML 标签后取残余文本
const stripped = decoded
.replace(/<[^>]+>/g, ' ')
.replace(/Preparing modules for first use\./g, ' ')
.replace(/\s+/g, ' ')
.trim();
return stripped.slice(0, 500);
}
/**
* 解析助手输出的一行协议报文。
* 协议:`@@R <base64>` 成功 / `@@E <base64>` 脚本异常;其他行(Add-Type 噪声等)返回 null。
*/
export function parseHelperLine(line: string): { isErr: boolean; payload: string } | null {
const isResult = line.startsWith('@@R ');
const isErr = line.startsWith('@@E ');
if (!isResult && !isErr) return null;
try {
return { isErr, payload: Buffer.from(line.slice(4), 'base64').toString('utf8') };
} catch {
return null;
}
}
export interface PsHelperStats {
enabled: boolean;
calls: number;
hits: number;
failures: number;
consecutiveFailures: number;
spawned: number;
lastError?: string;
avg_ms: number;
}
export function toEncodedCommand(script: string): string {
// -EncodedCommand 要求 UTF-16LE base64,可彻底规避引号/换行/中文转义问题
return Buffer.from(script, 'utf16le').toString('base64');
}
/** 把 prelude 与「读一行、执行、回结果」的循环拼成 bootstrap 脚本。 */
export function buildBootstrap(prelude: string): string {
return [
`$ErrorActionPreference = 'Stop'`,
// 助手自己的诊断信息一律走 stderr,stdout 只留协议行(避免污染)
`[Console]::OutputEncoding = [System.Text.Encoding]::UTF8`,
`$ProgressPreference = 'SilentlyContinue'`,
prelude,
`[Console]::Error.WriteLine('@@READY')`,
// 逐个处理请求:每行 = 一段 base64 编码的 PowerShell 片段
`while ($true) {`,
` $line = [Console]::In.ReadLine()`,
` if ($null -eq $line) { break }`,
` if ($line -eq '@@EXIT') { break }`,
` try {`,
` $src = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($line))`,
` $out = & ([scriptblock]::Create($src))`,
` $txt = ($out | Out-String)`,
` $b64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($txt))`,
` [Console]::Out.WriteLine('@@R ' + $b64)`,
` } catch {`,
` $msg = $_.Exception.Message`,
` $b64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($msg))`,
` [Console]::Out.WriteLine('@@E ' + $b64)`,
` }`,
` [Console]::Out.Flush()`,
`}`,
].join('\n');
}
/**
* 一个常驻 PowerShell 进程(按 prelude 复用同一个实例 —— 同 prelude 视为同一环境)。
* 请求串行化:单进程单 stdin,天然 FIFO;响应按顺序回,故只需匹配队首。
*/
export class PsShell {
private proc: ChildProcessWithoutNullStreams | null = null;
private ready = false;
private starting: Promise<void> | null = null;
private queue: Array<{
body: string;
timer: NodeJS.Timeout;
resolve: (v: string) => void;
reject: (e: Error) => void;
}> = [];
private stdoutBuf = '';
private stderrBuf = '';
private idleTimer: NodeJS.Timeout | null = null;
private stats: PsHelperStats = {
enabled: true,
calls: 0,
hits: 0,
failures: 0,
consecutiveFailures: 0,
spawned: 0,
avg_ms: 0,
};
private totalMs = 0;
constructor(
private readonly prelude: string,
private readonly label: string,
private readonly log: (level: 'info' | 'warn', msg: string) => void,
) {}
getStats(): PsHelperStats {
return { ...this.stats, avg_ms: this.stats.hits > 0 ? Math.round(this.totalMs / this.stats.hits) : 0 };
}
/** 助手里执行一段脚本;返回 stdout。抛出异常表示「本次不可用」,调用方应降级。 */
async run(body: string, timeoutMs = 30_000): Promise<string> {
if (!this.stats.enabled) throw new Error(`PsShell[${this.label}] 已熔断`);
const started = Date.now();
try {
// ensureStarted 必须在 try 内:它失败(如 spawn 失败)同样要计入统计,
// 否则会表现成「spawned 一直涨、calls 恒为 0」的诡异状态(真机踩过,掩盖了根因)。
await this.ensureStarted();
const out = await this.enqueue(body, timeoutMs);
this.stats.calls += 1;
this.stats.hits += 1;
this.stats.consecutiveFailures = 0;
this.totalMs += Date.now() - started;
this.touchIdle();
return out;
} catch (err) {
this.stats.calls += 1;
this.stats.failures += 1;
this.stats.consecutiveFailures += 1;
this.stats.lastError = err instanceof Error ? err.message : String(err);
this.kill(); // 状态可疑,直接销毁,下次重建
if (this.stats.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
this.stats.enabled = false;
this.log(
'warn',
`PsShell[${this.label}] 连续失败 ${this.stats.consecutiveFailures} 次,熔断并回退到一次性脚本路径`,
);
}
throw err instanceof Error ? err : new Error(String(err));
}
}
private enqueue(body: string, timeoutMs: number): Promise<string> {
return new Promise<string>((resolve, reject) => {
const timer = setTimeout(() => {
this.queue = this.queue.filter((q) => q.timer !== timer);
reject(new Error(`PsShell[${this.label}] 请求超时(${timeoutMs}ms)`));
}, timeoutMs);
this.queue.push({ body, timer, resolve, reject });
this.pump();
});
}
/** 把队首请求写进 stdin(仅当没有在途请求时)。 */
private pump(): void {
const head = this.queue[0];
if (!head || !this.ready) return;
// 已在途:队列长度 > 1 说明队首已发出,等待响应即可
if (this.queue.length > 1) return;
const line = Buffer.from(head.body, 'utf8').toString('base64');
this.proc?.stdin.write(`${line}\n`);
}
private ensureStarted(): Promise<void> {
if (this.ready && this.proc) return Promise.resolve();
if (this.starting) return this.starting;
this.starting = new Promise<void>((resolve, reject) => {
this.stats.spawned += 1;
const bootstrap = buildBootstrap(this.prelude);
// ⚠️ 不能用 -EncodedCommand:预加载段含内嵌 C#(编码后 ~36KB)超过 Windows
// 命令行 ~32KB 上限,spawn 会直接失败(ENAMETOOLONG)→ 每次都静默降级到
// 慢路径(真机踩过:助手 spawned 计数增长但 calls 恒为 0)。
// 改为落地临时脚本 + -File(无长度限制)。
const dir = mkdtempSync(join(tmpdir(), 'na-psh-'));
const bootPath = join(dir, 'bootstrap.ps1');
writeFileSync(bootPath, `\uFEFF${bootstrap}`, 'utf8');
const p = spawn(
'powershell.exe',
['-NoProfile', '-NoLogo', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', bootPath],
{ windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] },
);
this.proc = p;
const failTimer = setTimeout(() => {
if (!this.ready) {
this.kill();
reject(new Error(`PsShell[${this.label}] 启动超时(${SPAWN_TIMEOUT_MS}ms)`));
}
}, SPAWN_TIMEOUT_MS);
p.stdout.setEncoding('utf8');
p.stdout.on('data', (chunk: string) => this.onStdout(chunk));
p.stderr.setEncoding('utf8');
p.stderr.on('data', (chunk: string) => {
if (chunk.includes('@@READY')) {
clearTimeout(failTimer);
this.ready = true;
this.log('info', `PsShell[${this.label}] 就绪(类型已预加载)`);
this.pump();
resolve();
return;
}
this.stderrBuf = (this.stderrBuf + chunk).slice(-4000);
});
p.on('error', (err) => {
clearTimeout(failTimer);
this.log('warn', `PsShell[${this.label}] 进程错误: ${err.message}`);
this.ready = false;
reject(err);
});
p.on('exit', (code) => {
clearTimeout(failTimer);
const wasReady = this.ready;
this.ready = false;
this.proc = null;
this.starting = null;
if (wasReady) this.log('warn', `PsShell[${this.label}] 进程退出(code=${String(code)})`);
// 在途请求全部失败 -> 触发降级
const pending = this.queue.splice(0);
for (const q of pending) {
clearTimeout(q.timer);
q.reject(new Error(`PsShell[${this.label}] 进程退出`));
}
if (!wasReady) {
// 带上 stderr 尾巴 —— 否则只看到「启动失败(code=1)」无从下手(真机踩过)
const tail = this.stderrBuf.trim().slice(-600);
reject(new Error(`PsShell[${this.label}] 启动失败(code=${String(code)})${tail ? ` :: ${tail}` : ''}`));
}
});
}).finally(() => {
this.starting = null;
});
return this.starting;
}
private onStdout(chunk: string): void {
this.stdoutBuf += chunk;
let idx: number;
while ((idx = this.stdoutBuf.indexOf('\n')) >= 0) {
const line = this.stdoutBuf.slice(0, idx).trim();
this.stdoutBuf = this.stdoutBuf.slice(idx + 1);
if (!line) continue;
const parsed = parseHelperLine(line);
if (!parsed) continue; // 非协议行(如 Add-Type 噪声)忽略
const { isErr, payload } = parsed;
const head = this.queue.shift();
if (!head) continue;
clearTimeout(head.timer);
if (isErr) head.reject(new Error(`PsShell[${this.label}] 脚本异常: ${payload.slice(0, 300)}`));
else head.resolve(payload);
this.pump();
}
}
private touchIdle(): void {
if (this.idleTimer) clearTimeout(this.idleTimer);
this.idleTimer = setTimeout(() => {
if (this.queue.length === 0 && this.proc) {
this.log('info', `PsShell[${this.label}] 空闲回收`);
this.kill();
}
}, IDLE_KILL_MS);
this.idleTimer.unref?.();
}
kill(): void {
this.ready = false;
if (this.idleTimer) {
clearTimeout(this.idleTimer);
this.idleTimer = null;
}
const p = this.proc;
this.proc = null;
if (p && !p.killed) {
try {
p.stdin.end();
p.kill();
} catch {
/* 忽略 */
}
}
const pending = this.queue.splice(0);
for (const q of pending) {
clearTimeout(q.timer);
q.reject(new Error(`PsShell[${this.label}] 已回收`));
}
}
}
// ---------------- 对外:带降级的执行入口 ----------------
const shells = new Map<string, PsShell>();
/** 进程退出时统一回收(避免留下孤儿 PowerShell) */
let cleanupRegistered = false;
/**
* 取(或创建)某 prelude 对应的助手。
* key 含 prelude 指纹:**预加载段一旦变化,自动切换到新助手并回收旧的** ——
* 否则进程内更新代码后仍会用到旧进程里已加载的类型定义(难查的幽灵问题)。
*/
export function getPsShell(
prelude: string,
label: string,
log: (level: 'info' | 'warn', msg: string) => void,
): PsShell {
const fingerprint = createHash('sha256').update(prelude).digest('hex').slice(0, 8);
const key = `${label}#${fingerprint}`;
// 同 label 但指纹不同的旧助手(预加载段已变更)直接回收
for (const [k, old] of shells) {
if (k.startsWith(`${label}#`) && k !== key) {
log('info', `PsShell[${label}] 预加载段已变更,回收旧助手`);
old.kill();
shells.delete(k);
}
}
let sh = shells.get(key);
if (!sh) {
sh = new PsShell(prelude, label, log);
shells.set(key, sh);
if (!cleanupRegistered) {
cleanupRegistered = true;
process.once('exit', () => {
for (const s of shells.values()) s.kill();
});
}
}
return sh;
}
export function allPsShellStats(): Record<string, PsHelperStats> {
const out: Record<string, PsHelperStats> = {};
for (const [k, v] of shells) out[k] = v.getStats();
return out;
}
export function disposeAllPsShells(): void {
for (const s of shells.values()) s.kill();
shells.clear();
}
export interface RunPsOptions {
/** 本次逻辑(不含 prelude) */
body: string;
/** 类型/程序集预加载段(助手启动时执行一次;降级时与 body 拼成完整脚本) */
prelude: string;
/** 一次性路径的超时(降级用) */
timeoutMs?: number;
/** 助手可用性开关(便于测试与排障;默认 true) */
useHelper?: boolean;
log?: (level: 'info' | 'warn', msg: string) => void;
label?: string;
}
/**
* 执行 PowerShell:**优先常驻助手**,不可用时自动回退到一次性脚本。
* 返回值始终是脚本 stdout(两条路径语义一致)。
*/
export async function runPowerShellSmart(opts: RunPsOptions): Promise<{ stdout: string; via: 'helper' | 'oneshot' }> {
const { body, prelude, timeoutMs = 30_000, useHelper = true, log, label = 'win' } = opts;
const noop = (): void => undefined;
const logger = log ?? noop;
if (useHelper && process.platform === 'win32' && !process.env['NODEAGENT_NO_PS_HELPER']) {
try {
const shell = getPsShell(prelude, label, logger);
const stdout = await shell.run(body, timeoutMs);
return { stdout: stdout.trim(), via: 'helper' };
} catch (err) {
logger('warn', `PsShell 不可用,回退一次性脚本:${err instanceof Error ? err.message : String(err)}`);
}
}
// ⚠️ 不能用 -EncodedCommand 传整个脚本:预加载段(含内嵌 C#)已远超
// Windows 的 ~32KB 命令行上限,会报 spawn ENAMETOOLONG(真机踩过)。
// 改为**落地成临时脚本再 -File 执行**:无长度限制,且 UTF-8 BOM 保证
// PowerShell 5.1 正确读取非 ASCII(BOM 缺失会被当 ANSI 解析)。
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 ${scriptPath}`,
timeoutMs,
});
if (r.exit_code !== 0) {
const detail = extractPsError(r.stderr) || r.stdout.slice(0, 400);
const err = new Error(detail || `PowerShell 退出码 ${r.exit_code}`) as Error & { exitCode?: number };
err.exitCode = r.exit_code;
throw err;
}
return { stdout: r.stdout.trim(), via: 'oneshot' };
}
+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": "0.1.0",
"version": "2.1.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
+277
View File
@@ -0,0 +1,277 @@
import { closeSync, existsSync, openSync, readFileSync, readSync, renameSync, statSync, writeFileSync, writeSync } from 'node:fs';
import fsSync from 'node:fs';
import net from 'node:net';
import os from 'node:os';
import path, { join } from 'node:path';
import {
NodeAgentClient,
loadMacroFile,
runMacro,
ClientError,
loadConfig,
saveConfig,
configPath,
toWsUrl,
loadKeys,
createKeys,
keysFilePath,
discoverOnce,
resolveTarget,
resolveNodeSelector,
emptyConfig,
type ClientConfig,
type ResolvedTarget,
type NodeProfile,
} from '@nodeagent/client';
import {
CapabilityNames,
DEFAULT_DISCOVERY_PORT,
generateSharedKey,
matchPattern,
type CapabilityDescriptor,
type InvokeResult,
} from '@nodeagent/protocol';
import {
callAndPrint,
reportCompat,
fail,
getClientConfig,
humanSize,
printJson,
riskIcon,
withClient,
withClientDirect,
} from '../core.js';
import { CHUNK_BYTES, type Options } from '../types.js';
// CLI 命令组:cmd/conn.ts
export async function cmdConnect(host: string, opts: Options): Promise<void> {
const port = Number(opts.port ?? 8765);
const authMode: 'psk' | 'ed25519' = opts.authMode === 'ed25519' ? 'ed25519' : 'psk';
if (authMode === 'psk' && !opts.key) {
fail('缺少 --key <预共享密钥>(零信任模式请用 --auth-mode ed25519)');
}
const name = opts.name ?? host;
const clientId = opts.id ?? 'mac_01';
const profile: NodeProfile = {
host,
port,
// 原来这里直接扫 `process.argv.includes('--no-tls')` —— 绕过了解析器,
// 于是「帮助里承诺的选项」与「解析器认识的选项」是两套(L-01 同类)。
tls: !opts.noTls,
insecure: opts.insecure,
key: opts.key ?? '',
auth_mode: authMode,
};
if (opts.note) profile.note = opts.note;
// v6:经 Hub 中转
if (opts.hubToken) profile.hub = { token: opts.hubToken, node_id: opts.hubNode ?? name };
const keys = authMode === 'ed25519' ? loadKeys() : null;
if (authMode === 'ed25519' && !keys) {
fail(`零信任模式需要本机密钥,请先运行: nodeagent keygen --id ${clientId}`);
}
const client = new NodeAgentClient({
url: toWsUrl(profile),
key: profile.key ?? '',
clientId,
insecure: profile.insecure,
authMode,
privateKey: keys?.privateKey,
hub: profile.hub ? { token: profile.hub.token, nodeId: profile.hub.node_id } : undefined,
});
try {
const caps = await client.connect();
const authorized = client.listAuthorized();
// 连接成功才落盘,避免把错配置写进设备表
const cfg = loadConfig() ?? emptyConfig(clientId);
// v21:TOFU 钉住证书指纹 —— 首次连接记录,之后每次连接都严格比对。
// 已有钉住值时**不覆盖**(除非 --forget-cert),否则冒充者可借重连刷新钉住值。
const fp = client.getPeerCertFingerprint();
if (opts.forgetCert === true) {
delete profile.cert_sha256;
console.log('已清除旧指纹,将按本次连接重新钉住');
}
if (fp && !profile.cert_sha256) {
profile.cert_sha256 = fp;
console.log(`\n🔐 已钉住被控端证书指纹(TOFU):${fp}`);
console.log(' 请与被控端安装时打印的指纹核对;不一致说明连的可能不是那台机器');
}
cfg.client_id = clientId;
cfg.nodes[name] = profile;
cfg.current = name;
saveConfig(cfg);
const via = profile.hub ? `经 Hub ${profile.host}:${profile.port}` : `${profile.host}:${profile.port}`;
console.log(
`✓ 已连接并保存设备「${name}」${via}(${profile.tls ? 'wss' : 'ws'},${authMode})`,
);
console.log(` 配置: ${configPath()}(当前设备 ${name},共 ${Object.keys(cfg.nodes).length} 台)`);
const isOk = (n: string): boolean => !authorized || authorized.some((p) => matchPattern(p, n));
console.log(`\n被控端声明 ${caps.length} 项能力${authorized ? `,其中 ${authorized.length} 项已授权` : ''}:`);
for (const c of caps) {
console.log(` ${isOk(c.name) ? riskIcon(c.risk) : '🚫'} ${c.name.padEnd(24)} ${c.description}`);
}
} catch (err) {
if (err instanceof ClientError) fail(`连接失败 ${err.name}: ${err.message}`);
throw err;
} finally {
client.close();
}
}
// ---------- v5 多设备管理 ----------
export async function cmdNodes(opts: Options): Promise<void> {
const cfg = loadConfig();
if (!cfg || Object.keys(cfg.nodes).length === 0) {
console.log('尚未配置任何设备。用 nodeagent connect <host> --key <密钥> 添加,或 nodeagent discover 先发现。');
return;
}
const names = Object.keys(cfg.nodes);
if (opts.json) {
return printJson({ current: cfg.current, client_id: cfg.client_id, nodes: cfg.nodes });
}
console.log(`控制端身份: ${cfg.client_id} 共 ${names.length} 台设备\n`);
console.log(` ${'名称'.padEnd(16)} ${'地址'.padEnd(22)} ${'协议'.padEnd(6)} ${'认证'.padEnd(9)} 备注`);
for (const n of names) {
const p = cfg.nodes[n]!;
const mark = n === cfg.current ? '●' : ' ';
const auth = `${p.auth_mode ?? 'psk'}${p.insecure ? '*' : ''}`;
console.log(
`${mark} ${n.padEnd(16)} ${`${p.host}:${p.port}`.padEnd(22)} ${(p.tls ? 'wss' : 'ws').padEnd(6)} ${auth.padEnd(9)} ${p.note ?? ''}`,
);
}
console.log('\n(● = 当前设备;认证列 * = 跳过证书校验)');
}
export async function cmdUse(name: string | undefined, opts: Options): Promise<void> {
if (!name) fail('用法: nodeagent use <设备名>');
const cfg = loadConfig();
if (!cfg) fail('尚未配置任何设备。请先运行: nodeagent connect <host> --key <密钥>');
if (!cfg.nodes[name]) {
fail(`未配置的设备: ${name};已配置:${Object.keys(cfg.nodes).join(', ') || '(空)'}`);
}
cfg.current = name;
saveConfig(cfg);
if (opts.json) return printJson({ current: cfg.current });
const p = cfg.nodes[name]!;
console.log(`✓ 当前设备已切换为「${name}」 → ${p.host}:${p.port}`);
}
export async function cmdRemove(name: string | undefined, opts: Options): Promise<void> {
if (!name) fail('用法: nodeagent remove <设备名>');
const cfg = loadConfig();
if (!cfg || !cfg.nodes[name]) fail(`未配置的设备: ${name}`);
delete cfg.nodes[name];
if (cfg.current === name) cfg.current = Object.keys(cfg.nodes)[0] ?? '';
saveConfig(cfg);
if (opts.json) return printJson({ removed: name, current: cfg.current });
console.log(`✓ 已移除设备「${name}」${cfg.current ? `,当前设备: ${cfg.current}` : ''}`);
}
/** v3:生成 Ed25519 密钥对,并输出可直接粘贴的被控端 ACL 配置片段。 */
export async function cmdKeygen(opts: Options): Promise<void> {
const clientId = opts.id ?? 'mac_01';
// v22:`--psk` 生成**每客户端专属**的预共享密钥(配合被控端 agent.json 的 keys{})。
// 与 ed25519 的区别:这是「对称」方案 —— 实现简单、不用管公钥,但同样是**一把一个身份**
// (知道 A 的 key 就冒充不了 B),适合不方便管理公钥的场景。
if (opts.psk === true) {
const psk = generateSharedKey();
console.log('✓ 已生成该 client_id 的专属预共享密钥(psk 模式)');
console.log(` client_id : ${clientId}`);
console.log(` 密钥 : ${psk}`);
console.log('\n请把下面这段加入被控端 agent.json(可与其它 client_id 并列):\n');
console.log(
JSON.stringify(
{ keys: { [clientId]: psk } },
null,
2,
),
);
console.log('\n然后控制端这样连(key 即上面的密钥):');
console.log(` nodeagent connect <被控端IP> --port 8765 --key ${psk} --id ${clientId} --insecure`);
console.log(
'\n提示:登记了 keys{} 之后,未登记的 client_id 会回落到共享 key(若被控端仍配有 key)。',
);
return;
}
const keys = createKeys(clientId);
console.log('✓ 已生成 Ed25519 密钥对');
console.log(` client_id : ${keys.client_id}`);
console.log(` key_id : ${keys.key_id}`);
console.log(` 私钥 : ${keysFilePath()} (600 权限,永不外传)`);
console.log('\n请把下面这段加入被控端 agent.json 的 acl.clients 数组:\n');
console.log(
JSON.stringify(
{
client_id: keys.client_id,
pubkey: keys.publicKey,
allow: ['system.*', 'app.list', 'screen.*'],
deny: ['input.*'],
note: 'generated by nodeagent keygen',
},
null,
2,
),
);
console.log('\n然后让被控端启用零信任模式:agent.json 设 "auth_mode": "ed25519" 并重启 Agent。');
}
export async function cmdDiscover(opts: Options): Promise<void> {
const waitMs = (opts.wait ? Number(opts.wait) : 5) * 1000;
const port = opts.discoveryPort ? Number(opts.discoveryPort) : DEFAULT_DISCOVERY_PORT;
console.log(`正在监听局域网广播(UDP ${port},最多 ${waitMs / 1000}s)...\n`);
let nodes: Awaited<ReturnType<typeof discoverOnce>>;
try {
nodes = await discoverOnce(waitMs, { port, secret: opts.discoverSecret });
} catch (err) {
fail(`监听失败:${err instanceof Error ? err.message : String(err)}`);
}
if (nodes.length === 0) {
console.log('未发现设备。请检查:');
console.log(' 1) 被控端与本机在同一局域网(跨网段广播通常不通)');
console.log(' 2) 被控端配置未关闭 discovery.enabled');
console.log(` 3) 防火墙未拦截 UDP ${port}`);
return;
}
if (opts.json) return printJson(nodes);
console.log(`发现 ${nodes.length} 台被控端:\n`);
console.log(
` ${'节点 ID'.padEnd(16)} ${'地址'.padEnd(20)} ${'协议'.padEnd(6)} ${'认证'.padEnd(8)} ${'来源'.padEnd(10)} 平台`,
);
for (const n of nodes) {
const ctl = n.input_enabled ? ' [输入控制已开]' : '';
// v22:广播本身是未认证的,只有验签通过的才标 🔒
const trust = n.authenticated ? '🔒 已认证' : '⚠️ 明文';
console.log(
` ${n.node_id.padEnd(16)} ${`${n.host}:${n.port}`.padEnd(20)} ${(n.tls ? 'wss' : 'ws').padEnd(6)} ${n.auth_mode.padEnd(8)} ${trust.padEnd(10)} ${n.platform}${ctl}`,
);
}
if (nodes.some((n) => !n.authenticated) && !opts.discoverSecret) {
console.log(
'\n ℹ️ 标「⚠️ 明文」的条目:广播可被同网段任意伪造,其地址/认证方式仅作参考。' +
'\n 要可信发现,请给被控端 discovery.secret,并在此用 --discover-secret <同一密钥>。',
);
}
const first = nodes[0]!;
console.log('\n连接示例:');
console.log(` nodeagent connect ${first.host} --port ${first.port} --key <密钥>${first.tls ? ' --insecure' : ''}`);
}
// ---------- v5 文件传输 ----------
/** 分块大小:1MB(与被控端 max_bytes 默认值配合) */
+181
View File
@@ -0,0 +1,181 @@
import { closeSync, existsSync, openSync, readFileSync, readSync, renameSync, statSync, writeFileSync, writeSync } from 'node:fs';
import fsSync from 'node:fs';
import net from 'node:net';
import os from 'node:os';
import path, { join } from 'node:path';
import {
NodeAgentClient,
loadMacroFile,
runMacro,
ClientError,
loadConfig,
saveConfig,
configPath,
toWsUrl,
loadKeys,
createKeys,
keysFilePath,
discoverOnce,
resolveTarget,
resolveNodeSelector,
emptyConfig,
type ClientConfig,
type ResolvedTarget,
type NodeProfile,
} from '@nodeagent/client';
import {
CapabilityNames,
matchPattern,
DEFAULT_DISCOVERY_PORT,
type CapabilityDescriptor,
type InvokeResult,
} from '@nodeagent/protocol';
import {
callAndPrint,
fail,
getClientConfig,
humanSize,
printJson,
riskIcon,
withClient,
withClientDirect,
} from '../core.js';
import { CHUNK_BYTES, type Options } from '../types.js';
// CLI 命令组:cmd/fs.ts
export async function cmdLs(pathArg: string | undefined, opts: Options): Promise<void> {
if (!pathArg) fail('用法: nodeagent ls <远端路径> [--recursive] [--pattern "*.log"]');
const args: Record<string, unknown> = { path: pathArg };
if (opts.recursive) args['recursive'] = true;
if (opts.pattern) args['pattern'] = opts.pattern;
await withClient((c) =>
callAndPrint(c, CapabilityNames.FsList, args, opts.json, (data) => {
const d = data as {
entries: Array<{ name: string; type: string; size: number; mtime: number }>;
total: number;
truncated: boolean;
};
for (const e of d.entries) {
const icon = e.type === 'dir' ? '📁' : '📄';
const when = new Date(e.mtime).toLocaleString('zh-CN');
console.log(` ${icon} ${e.name.padEnd(34)} ${humanSize(e.size).padStart(10)} ${when}`);
}
console.log(`\n共 ${d.total} 项${d.truncated ? '(已截断,可加 --pattern 过滤)' : ''}`);
}),
);
}
export async function cmdStat(pathArg: string | undefined, opts: Options): Promise<void> {
if (!pathArg) fail('用法: nodeagent stat <远端路径>');
await withClient((c) =>
callAndPrint(c, CapabilityNames.FsStat, { path: pathArg }, opts.json, (data) => {
const d = data as { path: string; type: string; size: number; mtime: number; exists: boolean };
if (!d.exists) {
console.log(`✗ 不存在: ${d.path}`);
return;
}
console.log(` 路径 : ${d.path}`);
console.log(` 类型 : ${d.type}`);
console.log(` 大小 : ${humanSize(d.size)}`);
console.log(` 修改 : ${new Date(d.mtime).toLocaleString('zh-CN')}`);
}),
);
}
export async function cmdCat(pathArg: string | undefined, opts: Options): Promise<void> {
if (!pathArg) fail('用法: nodeagent cat <远端路径> [--out 本地文件]');
await withClient((c) =>
callAndPrint(c, CapabilityNames.FsRead, { path: pathArg, encoding: 'utf8' }, opts.json, (data) => {
const d = data as { data: string; bytes: number; total_bytes: number; eof: boolean };
if (opts.out) {
writeFileSync(opts.out, d.data, 'utf8');
console.log(`✓ 已保存 ${opts.out}(${d.bytes} 字节${d.eof ? '' : ',文件较大仅首块,请用 pull 下载完整文件'})`);
return;
}
process.stdout.write(d.data);
if (!d.eof) console.log(`\n\n… 仅显示首 ${humanSize(d.bytes)}(共 ${humanSize(d.total_bytes)}),完整下载请用 pull`);
}),
);
}
export async function cmdPull(remote: string | undefined, opts: Options): Promise<void> {
if (!remote) fail('用法: nodeagent pull <远端路径> [--out 本地文件]');
const local = opts.out ?? remote.split(/[\\/]/).pop() ?? 'download.bin';
await withClient(async (c) => {
const statRes = await c.invoke(CapabilityNames.FsStat, { path: remote });
if (statRes.status !== 'ok') fail(`读取远端信息失败: ${statRes.error?.message}`);
const st = statRes.data as { exists: boolean; type: string; size: number };
if (!st.exists) fail(`远端文件不存在: ${remote}`);
if (st.type === 'dir') fail(`目标是目录,不是文件: ${remote}(先用 ls 查看)`);
const tmp = `${local}.nodeagent-part`;
const fd = openSync(tmp, 'w');
let offset = 0;
try {
for (;;) {
const r = await c.invoke(CapabilityNames.FsRead, {
path: remote,
encoding: 'base64',
offset,
max_bytes: CHUNK_BYTES,
});
if (r.status !== 'ok') fail(`下载失败: ${r.error?.name}: ${r.error?.message}`);
const d = r.data as { data: string; bytes: number; eof: boolean };
if (d.bytes > 0) writeSync(fd, Buffer.from(d.data, 'base64'));
offset += d.bytes;
if (d.eof || d.bytes === 0) break;
}
} finally {
closeSync(fd);
}
renameSync(tmp, local); // 原子落盘,避免半截文件
console.log(`✓ 已下载 ${remote} → ${local}(${humanSize(offset)})`);
});
}
export async function cmdPush(local: string | undefined, remote: string | undefined, opts: Options): Promise<void> {
if (!local || !remote) fail('用法: nodeagent push <本地文件> <远端路径> [--create-dirs]');
let st;
try {
st = statSync(local);
} catch {
fail(`本地文件不存在: ${local}`);
}
if (!st.isFile()) fail(`不是文件: ${local}`);
await withClient(async (c) => {
const fd = openSync(local, 'r');
let offset = 0;
let first = true;
try {
while (offset < st.size) {
const len = Math.min(CHUNK_BYTES, st.size - offset);
const buf = Buffer.alloc(len);
readSync(fd, buf, 0, len, offset);
const r = await c.invoke(CapabilityNames.FsWrite, {
path: remote,
data: buf.toString('base64'),
encoding: 'base64',
append: !first,
create_dirs: first && opts.createDirs === true,
});
if (r.status !== 'ok') fail(`上传失败: ${r.error?.name}: ${r.error?.message}`);
offset += len;
first = false;
}
if (st.size === 0) {
const r = await c.invoke(CapabilityNames.FsWrite, {
path: remote,
data: '',
create_dirs: opts.createDirs === true,
});
if (r.status !== 'ok') fail(`上传失败: ${r.error?.message}`);
}
} finally {
closeSync(fd);
}
console.log(`✓ 已上传 ${local} → ${remote}(${humanSize(offset)})`);
});
}
+295
View File
@@ -0,0 +1,295 @@
import { closeSync, existsSync, openSync, readFileSync, readSync, renameSync, statSync, writeFileSync, writeSync } from 'node:fs';
import fsSync from 'node:fs';
import net from 'node:net';
import os from 'node:os';
import path, { join } from 'node:path';
import {
NodeAgentClient,
loadMacroFile,
runMacro,
ClientError,
loadConfig,
saveConfig,
configPath,
toWsUrl,
loadKeys,
createKeys,
keysFilePath,
discoverOnce,
resolveTarget,
resolveNodeSelector,
emptyConfig,
type ClientConfig,
type ResolvedTarget,
type NodeProfile,
} from '@nodeagent/client';
import {
CapabilityNames,
HOTKEY_PRESETS,
matchPattern,
DEFAULT_DISCOVERY_PORT,
type CapabilityDescriptor,
type InvokeResult,
} from '@nodeagent/protocol';
import {
callAndPrint,
fail,
getClientConfig,
humanSize,
printJson,
riskIcon,
withClient,
withClientDirect,
} from '../core.js';
import { CHUNK_BYTES, type Options } from '../types.js';
// CLI 命令组:cmd/gui.ts
export async function cmdScreen(opts: Options): Promise<void> {
await withClient((c) =>
callAndPrint(c, CapabilityNames.ScreenInfo, {}, opts.json, (data) => {
const rows = (
data as { displays: Array<{ id: number; name: string; width: number; height: number; is_primary: boolean }> }
).displays;
for (const d of rows) {
console.log(
` #${d.id} ${String(d.width).padStart(5)}x${String(d.height).padEnd(5)} ${d.is_primary ? '[主屏]' : ' '} ${d.name}`,
);
}
}),
);
}
export async function cmdScreenshot(opts: Options): Promise<void> {
const args: Record<string, unknown> = {};
if (opts.format) args['format'] = opts.format;
if (opts.scale) {
const s = Number(opts.scale);
// 协议限定 scale ∈ (0, 1](缩小)。原先直接把非法值发出去,
// 被控端只回一句「参数校验失败」,看不出是哪一项 —— 2026-10-05 实测踩过。
if (!Number.isFinite(s) || s <= 0 || s > 1) {
fail(`--scale 应在 0 到 1 之间(1=原尺寸,0.5=半尺寸),收到: ${opts.scale}`);
}
args['scale'] = s;
}
if (opts.region) {
const nums = opts.region.split(',').map(Number);
if (nums.length !== 4 || nums.some((n) => !Number.isFinite(n))) fail('--region 格式应为 x,y,width,height');
args['region'] = { x: nums[0], y: nums[1], width: nums[2], height: nums[3] };
}
await withClient((c) =>
callAndPrint(c, CapabilityNames.ScreenCapture, args, opts.json, (data) => {
const d = data as { image: string; format: string; width: number; height: number; bytes: number };
const out = opts.out ?? `screenshot.${d.format === 'png' ? 'png' : 'jpg'}`;
writeFileSync(out, Buffer.from(d.image, 'base64'));
console.log(`✓ 已保存 ${out} ${d.width}x${d.height} ${humanSize(d.bytes)}`);
}),
);
}
export async function cmdMouse(action: string | undefined, positionals: string[], opts: Options): Promise<void> {
const [a, b] = positionals;
switch (action) {
case 'move': {
if (!a || !b) fail('用法: nodeagent mouse move <x> <y> [--duration 300]');
const args: Record<string, unknown> = { x: Number(a), y: Number(b) };
if (opts.duration) args['duration_ms'] = Number(opts.duration);
await withClient((c) =>
callAndPrint(c, CapabilityNames.MouseMove, args, opts.json, (d) => {
const r = d as { x: number; y: number };
console.log(`✓ 鼠标已移动到 (${r.x}, ${r.y})`);
}),
);
return;
}
case 'click': {
const args: Record<string, unknown> = {};
if (a && b) {
args['x'] = Number(a);
args['y'] = Number(b);
}
if (opts.button) args['button'] = opts.button;
await withClient((c) =>
callAndPrint(c, CapabilityNames.MouseClick, args, opts.json, (d) => {
const r = d as { x: number; y: number; button: string };
console.log(`✓ 已${r.button}键点击 (${r.x}, ${r.y})`);
}),
);
return;
}
case 'scroll': {
if (!a) fail('用法: nodeagent mouse scroll <delta> [y]');
const args: Record<string, unknown> = { delta: Number(a) };
if (b) args['y'] = Number(b);
await withClient((c) =>
callAndPrint(c, CapabilityNames.MouseScroll, args, opts.json, (d) => {
const r = d as { delta: number };
console.log(`✓ 已滚动 ${r.delta} 格`);
}),
);
return;
}
case 'drag': {
const [x1, y1, x2, y2] = positionals;
if (!x1 || !y1 || !x2 || !y2) {
fail('用法: nodeagent mouse drag <x1> <y1> <x2> <y2> [--button left|right|middle]');
}
const args: Record<string, unknown> = {
from_x: Number(x1),
from_y: Number(y1),
to_x: Number(x2),
to_y: Number(y2),
};
if (opts.button) args['button'] = opts.button;
if (opts.duration) args['step_delay_ms'] = Number(opts.duration);
await withClient((c) =>
callAndPrint(c, CapabilityNames.MouseDrag, args, opts.json, (d) => {
const r = d as { from: { x: number; y: number }; to: { x: number; y: number }; steps: number };
console.log(
`✓ 已拖拽 (${r.from.x}, ${r.from.y}) → (${r.to.x}, ${r.to.y}),${r.steps} 步`,
);
}),
);
return;
}
default:
fail('用法: nodeagent mouse <move|click|scroll> ...');
}
}
export async function cmdKey(action: string | undefined, positionals: string[], opts: Options): Promise<void> {
switch (action) {
case 'type': {
const text = positionals.join(' ');
if (!text) fail('用法: nodeagent key type "<文本>"');
const args: Record<string, unknown> = { text };
if (opts.interval) args['interval_ms'] = Number(opts.interval);
await withClient((c) =>
callAndPrint(c, CapabilityNames.KeyType, args, opts.json, (d) => {
const r = d as { length: number };
console.log(`✓ 已输入 ${r.length} 个字符`);
}),
);
return;
}
case 'press': {
// v1.5.0:三种用法(一次一种)
// nodeagent key press "ctrl+shift+esc" 字符串热键
// nodeagent key press copy 预设名
// nodeagent key press ctrl c 老形式(空格分隔的各键)
if (positionals.length === 0) fail('用法: nodeagent key press "<热键>" | <预设名> | <键1> [键2] ...(如 "ctrl+shift+esc" / copy / ctrl c)');
const args: Record<string, unknown> = {};
let positionalsEcho = positionals;
if (positionals.length === 1) {
const one = positionals[0]!;
// 含分隔符 → 字符串热键;不含但命中预设表 → 预设;都不中 → 单键(也当热键交给被控端解析)
const spec = HOTKEY_PRESETS[one.trim().toLowerCase()];
if (spec) {
args['preset'] = one.trim().toLowerCase();
} else {
args['hotkey'] = one;
}
} else {
args['keys'] = positionals;
}
// 老形式说明:位置参数以空格分隔 → 兼容"ctrl c"(无 + 号)
if (opts.interval) args['interval_ms'] = Number(opts.interval);
// v1.5.0 新增可选参数
if (opts.hold) args['hold_ms'] = Number(opts.hold);
if (opts.route) args['route'] = opts.route;
// 媒体键可定向到指定进程(播放器常建 30+ 辅助窗口,只投主窗口往往无效)
if (opts.pid) args['target_pid'] = Number(opts.pid);
await withClient((c) =>
callAndPrint(c, CapabilityNames.KeyPress, args, opts.json, (d) => {
// 被控端返回 { pressed, chords, times },**没有 keys 字段**
// (真机 2026-10-04 实测:原先直接读 r.keys.join() 会抛
// "Cannot read properties of undefined (reading 'join')")
// 故优先用请求参数回显,缺失时再退回 chords 展开。
const r = d as {
keys?: string[]; chords?: string[][]; times?: number; via?: string;
presets?: string[]; channel?: string; route?: string; sent_windows?: number;
target_pid?: number | null; note?: string;
};
const shown = (r.chords ?? []).map((ch) => ch.join('+')).join(' → ');
const label = r.keys?.length
? r.keys.join('+')
: shown || positionalsEcho.join('+');
const times = r.times && r.times > 1 ? `(重复 ${r.times} 次)` : '';
const presetEcho = r.presets?.length ? `(预设 ${r.presets.join('→')} = ${shown})` : '';
const via =
r.channel === 'appcommand'
? `(媒体通道${r.target_pid ? ` → pid ${r.target_pid}` : ' · 广播'})`
: r.channel === 'postmessage'
? `(后台投递 → pid ${r.target_pid},命中 ${r.sent_windows ?? 0} 窗口)`
: '';
console.log(`✓ 已按下 ${label}${times}${presetEcho}${via}`);
if (r.note) console.log(` ℹ️ ${r.note}`);
}),
);
return;
}
default:
fail('用法: nodeagent key <type|press> ...');
}
}
// ---------- v1.6.0 等待条件(gui.await) ----------
/**
* 等待一个条件成立再返回(只读低危)。
*
* nodeagent await --condition control --text "完成" --timeout 30000
* nodeagent await --condition window --title "安装程序"
* nodeagent await --condition process --process setup.exe
* nodeagent await --condition file --path "C:\\log.txt" --state absent
*
* 四类条件各自复用 window.list / screen.find / process.list / fs.stat,
* 平台行为与那些能力一致。超时是**正常返回**(satisfied:false + note),不抛异常。
*/
export async function cmdAwait(opts: Options): Promise<void> {
const condition = opts.condition as string | undefined;
if (!condition) {
fail('用法: nodeagent await --condition <window|control|process|file> --timeout <ms> [--text/--title/--process/--path] [--state absent]');
}
const args: Record<string, unknown> = { condition };
// v2.0.0:where / any_of 是 JSON 字符串(属性谓词与组合条件不便用命令行开关表达)
for (const k of ['where', 'any_of']) {
const raw = (opts as unknown as Record<string, string | undefined>)[k];
if (raw !== undefined) {
try {
args[k] = JSON.parse(raw);
} catch {
fail(`--${k} 不是合法 JSON: ${raw.slice(0, 60)}`);
}
}
}
if (opts.text !== undefined) args['text'] = opts.text;
if (opts.title !== undefined) args['title'] = opts.title;
if (opts.process !== undefined) args['process'] = opts.process;
if (opts.path !== undefined) args['path'] = opts.path;
if (opts.state !== undefined) args['state'] = opts.state;
if (opts.timeoutMs !== undefined) args['timeout_ms'] = Number(opts.timeoutMs);
if (opts.interval !== undefined) args['interval_ms'] = Number(opts.interval);
await withClient((c) =>
callAndPrint(c, CapabilityNames.GuiAwait, args, opts.json, (d) => {
const r = d as {
satisfied: boolean; condition: string; state: string;
elapsed_ms: number; attempts: number; last_seen: string; last_error?: string; note?: string;
};
const mark = r.satisfied ? '✓' : '✗';
const label = r.condition === 'control' ? String(args['text'] ?? '')
: r.condition === 'window' ? String(args['title'] ?? '')
: r.condition === 'process' ? String(args['process'] ?? '')
: String(args['path'] ?? '');
console.log(
`${mark} ${r.state === 'absent' ? '已消失' : '已出现'}: ${r.condition} ${label}` +
`(${r.elapsed_ms}ms,${r.attempts} 次轮询,末次=${r.last_seen})`,
);
if (r.note) console.log(` ℹ️ ${r.note}`);
if (r.last_error) console.log(` ⚠️ 期间末次异常:${r.last_error.slice(0, 120)}`);
}),
);
}
// ---------- v3+ 审计 ----------
/** v11:审计链完整性校验。 */
+125
View File
@@ -0,0 +1,125 @@
import { closeSync, existsSync, openSync, readFileSync, readSync, renameSync, statSync, writeFileSync, writeSync } from 'node:fs';
import fsSync from 'node:fs';
import net from 'node:net';
import os from 'node:os';
import path, { join } from 'node:path';
import {
NodeAgentClient,
loadMacroFile,
runMacro,
ClientError,
loadConfig,
saveConfig,
configPath,
toWsUrl,
loadKeys,
createKeys,
keysFilePath,
discoverOnce,
resolveTarget,
resolveNodeSelector,
emptyConfig,
type ClientConfig,
type ResolvedTarget,
type NodeProfile,
} from '@nodeagent/client';
import {
CapabilityNames,
matchPattern,
DEFAULT_DISCOVERY_PORT,
type CapabilityDescriptor,
type InvokeResult,
} from '@nodeagent/protocol';
import {
callAndPrint,
fail,
getClientConfig,
humanSize,
printJson,
riskIcon,
withClient,
withClientDirect,
} from '../core.js';
import { CHUNK_BYTES, type Options } from '../types.js';
// CLI 命令组:cmd/net.ts
export async function cmdNet(sub: string | undefined, opts: Options): Promise<void> {
if (sub === 'status' || sub === undefined) {
await withClient((c) =>
callAndPrint(c, CapabilityNames.NetStatus, {}, opts.json, (data) => {
const d = data as {
interfaces: Array<Record<string, unknown>>;
pending: Array<{ task_name: string; seconds_left: number; rollback_at: number }>;
pending_count: number;
note: string;
};
console.log('网卡地址:');
for (const i of d.interfaces ?? []) {
console.log(` ${String(i['InterfaceAlias'] ?? '?')} ip=${String(i['ip'] ?? '-')} gw=${String(i['gw'] ?? '-')}`);
}
if (d.pending_count > 0) {
console.log('\n⏳ 待确认的网络变更(逾期自动回滚):');
for (const p of d.pending) {
console.log(` ${p.task_name} 剩余 ${p.seconds_left}s`);
}
console.log(' 确认提交:nodeagent net confirm');
} else {
console.log('\n(无待确认变更)');
}
}),
);
return;
}
if (sub === 'confirm') {
const args: Record<string, unknown> = {};
if (opts.taskName) args['task_name'] = opts.taskName;
await withClient((c) =>
callAndPrint(c, CapabilityNames.NetConfirm, args, opts.json, (data) => {
const d = data as { confirmed: number; cancelled: string[]; remaining: string[]; note?: string };
if (d.confirmed === 0) console.log(`✓ 无需确认:${d.note ?? '没有待确认的网络变更'}`);
else console.log(`✓ 已确认(提交)${d.confirmed} 项:${d.cancelled.join(', ')}`);
if ((d.remaining ?? []).length > 0) console.error(`⚠️ 仍有未取消:${d.remaining.join(', ')}`);
}),
);
return;
}
if (sub === 'apply') {
const modeRaw = opts.mode ?? 'static';
const args: Record<string, unknown> = { mode: modeRaw };
if (opts.iface) args['interface'] = opts.iface;
if (opts.ip) args['ip'] = opts.ip;
if (opts.mask) args['mask'] = opts.mask;
if (opts.gateway) args['gateway'] = opts.gateway;
if (opts.dns) args['dns'] = String(opts.dns).split(',').map((x) => x.trim());
if (opts.command) args['command'] = opts.command;
if (opts.confirmWithin) args['confirm_within_ms'] = Number(opts.confirmWithin);
if (!opts.yes) {
fail(
'网络变更属高危操作,需显式加 --yes 确认。\n' +
'行为:备份当前配置 → 应用变更 → 注册 OS 级定时回滚任务 → 你在窗口期内 net confirm 即提交;\n' +
' 逾期未确认则自动回滚到变更前配置(不会把自己关在门外)。',
);
}
// 网络变更本身耗时较长(netsh 重配网卡 + 注册计划任务),默认 60s 不够
await withClient((c) =>
callAndPrint(c, CapabilityNames.NetApply, args, opts.json, (data) => {
const d = data as {
applied: boolean;
interface: string;
backup_path: string;
task_name: string;
confirm_within_ms: number;
hint: string;
};
console.log(`✓ 已应用变更(网卡 ${d.interface}),备份:${d.backup_path}`);
console.log(` 自动回滚任务:${d.task_name}(${Math.round(d.confirm_within_ms / 1000)}s 后触发)`);
console.log(` ${d.hint}`);
}, 200_000),
);
return;
}
fail('用法: nodeagent net [status] | net apply --mode static|dhcp|command [...] --yes | net confirm [--task-name X]');
}
+754
View File
@@ -0,0 +1,754 @@
import { createHash } from 'node:crypto';
import { closeSync, existsSync, openSync, readFileSync, readSync, renameSync, statSync, writeFileSync, writeSync } from 'node:fs';
import fsSync from 'node:fs';
import net from 'node:net';
import os from 'node:os';
import path, { join } from 'node:path';
import {
NodeAgentClient,
loadMacroFile,
runMacro,
ClientError,
loadConfig,
saveConfig,
configPath,
toWsUrl,
loadKeys,
createKeys,
keysFilePath,
discoverOnce,
resolveTarget,
resolveNodeSelector,
emptyConfig,
type ClientConfig,
type ResolvedTarget,
type NodeProfile,
} from '@nodeagent/client';
import {
CapabilityNames,
matchPattern,
DEFAULT_DISCOVERY_PORT,
type CapabilityDescriptor,
type InvokeResult,
} from '@nodeagent/protocol';
import {
callAndPrint,
fail,
getClientConfig,
getNodeOverride,
probeOnce,
humanSize,
printJson,
riskIcon,
withClient,
withClientDirect,
} from '../core.js';
import { CHUNK_BYTES, type Options } from '../types.js';
// CLI 命令组:cmd/ops.ts
export async function cmdBg(command: string, opts: Options): Promise<void> {
await withClient((c) =>
callAndPrint(
c,
CapabilityNames.ShellExec,
{ command, async: true, timeout_ms: opts.timeoutMs ? Number(opts.timeoutMs) : undefined },
opts.json,
(data) => {
const d = data as { task_id: string; state: string };
console.log(`✓ 后台任务已启动: ${d.task_id}(${d.state})`);
console.log(' 查询: nodeagent task ' + d.task_id);
console.log(' 终止: nodeagent task ' + d.task_id + ' --kill');
},
),
);
}
export async function cmdTasks(opts: Options): Promise<void> {
await withClient((c) =>
callAndPrint(c, CapabilityNames.TaskList, {}, opts.json, (data) => {
const rows = (data as { tasks: Array<{ task_id: string; state: string; duration_ms: number; exit_code?: number }> }).tasks;
if (rows.length === 0) {
console.log('当前没有后台任务');
return;
}
console.log(`${'任务ID'.padEnd(26)} ${'状态'.padEnd(9)} ${'耗时'.padStart(9)} 退出码`);
for (const t of rows) {
console.log(
`${t.task_id.padEnd(26)} ${t.state.padEnd(9)} ${String(t.duration_ms + 'ms').padStart(9)} ${t.exit_code ?? '-'}`,
);
}
}),
);
}
export async function cmdTask(taskId: string, opts: Options): Promise<void> {
if (opts.kill) {
await withClient((c) =>
callAndPrint(c, CapabilityNames.TaskKill, { task_id: taskId }, opts.json, (data) => {
const d = data as { killed: boolean };
console.log(d.killed ? `✓ 已终止 ${taskId}` : `任务 ${taskId} 已不在运行`);
}),
);
return;
}
const offset = opts.offset ? Number(opts.offset) : 0;
await withClient((c) =>
callAndPrint(
c,
CapabilityNames.TaskGet,
{ task_id: taskId, offset },
opts.json,
(data) => {
const d = data as {
state: string;
exit_code: number | null;
duration_ms: number;
data: string;
offset: number;
total_bytes: number;
};
if (d.data) console.log(d.data);
console.error(
`[${d.state} · ${d.duration_ms}ms · 已读 ${d.offset}/${d.total_bytes} 字节${d.exit_code !== null && d.state !== 'running' ? ` · 退出码 ${d.exit_code}` : ''}]`,
);
if (d.state === 'running' && d.offset < d.total_bytes) {
console.error(' 续读: nodeagent task ' + taskId + ' --offset ' + d.offset);
}
},
),
);
}
/**
* v11 / C5:把一个能力并发下发到多台设备并汇总结果。
* 每台设备独立连接、互不阻塞(allSettled),任一失败不影响其他。
*/
export async function cmdFanout(capability: string, opts: Options): Promise<void> {
const cfg = getClientConfig();
const names = (opts.nodes ?? '')
.split(',')
.map((x) => x.trim())
.filter(Boolean);
const selector = names.length > 0 ? names : Object.keys(cfg.nodes);
// v12:支持 @组名(组内可嵌套引用其它组)
const { nodes: targets, resolvedGroups } = resolveNodeSelector(cfg, selector);
if (targets.length === 0) fail('没有可用的设备,请先 nodeagent connect 或检查分组定义');
for (const [g, members] of Object.entries(resolvedGroups)) {
if (members.length === 0) fail(`分组「${g}」为空或不存在(可用: ${Object.keys(cfg.groups ?? {}).join(', ') || '无'})`);
}
let args: Record<string, unknown> = {};
if (opts.args) {
try {
args = JSON.parse(opts.args) as Record<string, unknown>;
} catch {
fail('--args 需为合法 JSON,例如 --args {"limit":5}');
}
}
const started = Date.now();
const results = await Promise.allSettled(
targets.map(async (n) => {
const t0 = Date.now();
const data = await withClient(async (c) => {
const r = await c.invoke(capability, args, Number(opts.timeoutMs ?? 60_000));
if (r.status === 'failed') throw new Error(`${r.error?.name}: ${r.error?.message}`);
return r.data;
}, n);
return { node: n, ms: Date.now() - t0, data };
}),
);
if (opts.json) {
printJson(
results.map((r, i) =>
r.status === 'fulfilled'
? { node: r.value.node, ok: true, ms: r.value.ms, data: r.value.data }
: { node: targets[i], ok: false, error: String(r.reason) },
),
);
return;
}
console.log(`并发下发 ${capability} → ${targets.length} 台设备(总耗时 ${Date.now() - started}ms)\n`);
results.forEach((r, i) => {
const name = (targets[i] ?? '?').padEnd(16);
if (r.status === 'fulfilled') {
const preview = JSON.stringify(r.value.data);
console.log(` ✓ ${name} ${String(r.value.ms + 'ms').padStart(7)} ${preview.slice(0, 150)}`);
} else {
console.log(` ✗ ${name} ${'—'.padStart(7)} ${String(r.reason).slice(0, 150)}`);
}
});
}
export async function cmdRecord(opts: Options): Promise<void> {
const args: Record<string, unknown> = {};
if (opts.duration) args['duration_ms'] = Number(opts.duration);
if (opts.fps) args['fps'] = Number(opts.fps);
if (opts.scale) args['scale'] = Number(opts.scale);
if (opts.region) {
const n = opts.region.split(',').map(Number);
if (n.length !== 4 || n.some((v) => !Number.isFinite(v))) fail('region 格式应为 "x,y,width,height"');
args['region'] = { x: n[0], y: n[1], width: n[2], height: n[3] };
}
await withClient((c) =>
callAndPrint(c, CapabilityNames.ScreenRecord, args, opts.json, (data) => {
const d = data as { dir: string; frames: number; fps: number; elapsed_ms: number; video_path?: string; frames_only: boolean };
console.log(`✓ 录制完成:${d.frames} 帧 @ ${d.fps}fps(${d.elapsed_ms}ms)`);
console.log(` 目录: ${d.dir}`);
if (d.video_path) console.log(` 视频: ${d.video_path}`);
else if (d.frames_only) console.log(' 提示: 无 ffmpeg,用 `nodeagent pull` 或 `nodeagent ls` 取回帧');
}),
);
}
/**
* v11 / D2:一键部署 / 升级被控端 agent。
* 流程:被控端自报入口路径(system.info)→ 上传新 agent.mjs → 受控重启 → 复验能力数。
* 全程无需手工拷文件或起计划任务。
*/
/**
* 部署/更新被控端 agent(v18 起带指纹校验)。
*
* 流程:本地算 sha256 → 问远端构建指纹 → 相同则跳过(--force 可强制)→
* 备份 → 分块上传 → 受控重启 → **轮询直到远端指纹等于本地**(替代盲等)。
*
* 为什么必须比指纹:只看「PID 变了 / 能力数对了」证明不了换成了新代码
* (旧版可能恰好有同样多的能力)。内容哈希是唯一可靠判据。
*/
export async function cmdDeploy(localFile: string, opts: Options): Promise<void> {
if (!existsSync(localFile)) fail(`本地文件不存在: ${localFile}`);
const buf = readFileSync(localFile);
if (buf.length < 10_000) fail(`文件过小,疑似不是 agent 包: ${localFile}`);
const localHash = createHash('sha256').update(buf).digest('hex').slice(0, 12);
// 单条 RPC 的安全分块(与上传保持一致)
const BACKUP_CHUNK = 512 * 1024;
type Info = {
agent_script?: string;
pid?: number;
node_path?: string;
build?: { hash?: string; node?: string; started_at?: number };
};
const remote = await withClient((c) => c.invoke<Info>(CapabilityNames.SystemInfo, {}));
if (remote.status === 'failed') fail('无法读取被控端信息');
const info: Info = remote.data ?? {};
const remoteHash = info.build?.hash ?? 'unknown';
const target = opts.path ?? info.agent_script;
if (!target) fail('被控端未上报入口路径,请用 --path <远端路径> 指定');
console.log(`本地 : ${localHash} (${(buf.length / 1024).toFixed(0)} KB)`);
console.log(`远端 : ${remoteHash} ${info.build?.node ? `(node ${info.build.node})` : ''}`);
console.log(`目标 : ${target}(当前 PID ${info.pid ?? '?'})`);
const unknownRemote = remoteHash === 'unknown';
if (unknownRemote) {
console.log('ℹ️ 远端未上报构建指纹(多为 v18 之前的旧版)—— 无法比对,建议直接部署');
}
if (opts.check === true) {
if (remoteHash === localHash) {
console.log('✓ 已是最新,无需更新');
} else if (unknownRemote) {
console.log('⚠️ 无法比对(远端过旧),建议执行 `nodeagent deploy <文件>` 更新');
process.exitCode = 2;
} else {
console.log('⚠️ 版本不一致 —— 执行 `nodeagent deploy <文件>` 更新(加 --force 可强制覆盖)');
process.exitCode = 2;
}
return;
}
if (!unknownRemote && remoteHash === localHash && opts.force !== true) {
console.log('✓ 远端指纹与本地一致,已是最新(如需强制覆盖请加 --force)');
return;
}
// 备份 + 上传 + 重启
const uploadInfo = await withClient(async (c) => {
// 备份走协议自带的 fs 能力(而非 shell 的 Copy-Item)——
// Copy-Item 是 Windows 专用,在 macOS 被控端上会静默失败(真机踩过:以为备份了,其实没有)。
// 备份用**固定名**(覆盖式):此前用 `.bak-<时间戳>`,每部署一次就多一个文件 ——
// 真机 2026-10-09 发现 D:\Nodeagent 下堆了 15+ 个 bak(单文件 1MB,纯浪费)。
// 部署回滚只需要「上一个可用版本」,一份就够,故改为覆盖写。
const backup = `${target}.bak`;
let backupPath = '';
try {
const st = await c.invoke<{ size?: number; exists?: boolean }>(CapabilityNames.FsStat, { path: target });
const size = st.status === 'ok' ? (st.data?.size ?? 0) : 0;
if (size > 0) {
let off = 0;
let first = true;
while (off < size) {
// 自行计算本次长度并据此前进(对齐 cmdPush 的成熟写法):
// fs.read 返回的 offset 是**本次读取的起始位置**,拿它当前进量会原地打转
// → 死循环 + 文件暴涨(真机踩过:备份 33B→268KB 且部署卡死 200s)。
const len = Math.min(BACKUP_CHUNK, size - off);
const r = await c.invoke<{ data?: string }>(CapabilityNames.FsRead, {
path: target,
offset: off,
max_bytes: len,
// ⚠️ 必须显式 base64:默认 utf8 会把二进制按文本读(损坏),
// 再当 base64 写回就得到几十字节的垃圾备份(真机踩过:备份 33 字节)
encoding: 'base64',
});
if (r.status !== 'ok') break;
const chunk = r.data?.data ?? '';
if (!chunk) break;
const w = await c.invoke(CapabilityNames.FsWrite, {
path: backup,
data: chunk,
encoding: 'base64',
append: !first,
});
if (w.status !== 'ok') break;
first = false;
off += len;
}
if (!first) backupPath = backup;
}
} catch {
/* 备份失败不阻断部署,但下面会提示 */
}
if (backupPath) console.log(`已备份: ${backupPath}`);
else console.log('⚠️ 备份未成功(部署继续,但无自动回滚点)');
const CHUNK = 512 * 1024;
let offset = 0;
while (offset < buf.length) {
const part = buf.subarray(offset, Math.min(offset + CHUNK, buf.length));
const w = await c.invoke(CapabilityNames.FsWrite, {
path: target,
data: part.toString('base64'),
encoding: 'base64',
append: offset > 0,
});
if (w.status === 'failed') fail(`上传失败于 offset ${offset}: ${w.error?.message}`);
offset += part.length;
}
console.log(`✓ 已上传 ${(buf.length / 1024).toFixed(0)} KB`);
// best-effort 清理历史的时间戳备份(老格式 `.bak-<ts>`)。
// 平台分流:Windows 用 PS,类 Unix 用 rm。清不掉不影响部署,只提示。
try {
const isWin = String((info as { os?: string }).os ?? '').toLowerCase().startsWith('win');
const cleanup = isWin
? `Get-ChildItem '${target}.bak-*' -EA SilentlyContinue | Remove-Item -Force -EA SilentlyContinue; (Get-ChildItem '${target}.bak-*' -EA SilentlyContinue | Measure-Object).Count`
: `rm -f '${target}.bak-'* 2>/dev/null; ls '${target}.bak-'* 2>/dev/null | wc -l`;
const r = await c.invoke<{ stdout?: string }>(CapabilityNames.ShellExec, { command: cleanup, timeout_ms: 15_000 });
const left = Number((r.data?.stdout ?? '').trim());
if (r.status === 'ok' && Number.isFinite(left)) {
if (left === 0) console.log('✓ 已清理历史时间戳备份(老格式)');
else console.log(`⚠️ 仍有 ${left} 个历史备份未清理(可手动删除)`);
}
} catch {
/* 清理是附加项,失败不影响部署结果 */
}
return { backupPath };
});
// 触发重启:**用独立连接且吞掉连接中断**。
// 重启会把本连接切断,这属于预期行为;若当成失败抛出,整个部署会被误判为失败
// (真机踩过:文件其实已换好、新进程也起来了,CLI 却报 E_NODE_OFFLINE)。
try {
const r = await withClient((c) =>
c.invoke(CapabilityNames.AgentRestart, { delay_ms: 1500, reason: 'deploy' }),
);
if (r.status === 'failed') fail(`重启失败: ${r.error?.message}`);
} catch (err) {
console.log(`(重启切断了连接,属预期:${err instanceof Error ? err.message : String(err)})`);
}
console.log('✓ 已触发重启,校验新版本上线…');
// 轮询校验:直到远端指纹 == 本地指纹。
// 用 probeOnce(永不退出),**不能用 withClient** —— 后者连不上会 fail() 退出,
// 使得「等待重连」根本无法实现(真机踩过:部署成功却报 E_NODE_OFFLINE)。
// 按墙钟限时而非固定次数:每次连接耗时不定,按次数算总时长不可预期。
let verified = false;
let lastSeen = '';
let pidSeen: number | undefined;
let capsSeen = 0;
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';
pidSeen = p.pid;
capsSeen = p.caps;
if (lastSeen === localHash) {
verified = true;
console.log(`✓ 新版本已上线并校验通过:指纹 ${lastSeen},PID ${pidSeen ?? '?'},能力 ${capsSeen} 项`);
break;
}
}
if (!verified) {
const waited = Math.round((Date.now() - (deadline - 90_000)) / 1000);
console.error(`✗ ${waited}s 内未校验到新版本(当前远端指纹 ${lastSeen || '连不上'})`);
if (uploadInfo.backupPath) {
console.error(` 回滚方式:把备份复制回目标即可 ——`);
console.error(` nodeagent exec "Copy-Item '${uploadInfo.backupPath}' '${target}' -Force"`);
}
console.error(' 排查:被控端杀软可能处置了被覆盖的 agent 文件');
process.exitCode = 1;
}
}
/**
* v12 / E1+E2:订阅被控端事件并实时接收推送。
* 与普通命令不同,它需要**保持连接**一段时间(长连接 + 推送),因此不走 withClient 的「用完即关」。
*/
export async function cmdEvents(opts: Options): Promise<void> {
const cfg = getClientConfig();
let target: ResolvedTarget;
try {
target = resolveTarget(cfg, getNodeOverride());
} catch (err) {
fail(err instanceof Error ? err.message : String(err));
}
// 无 --kind 时列出当前订阅
if (!opts.kind) {
await withClient((c) =>
callAndPrint(c, CapabilityNames.EventList, {}, opts.json, (data) => {
const d = data as {
watches: Array<{ watch_id: string; kind: string; description: string; events: number }>;
buffered: number;
};
if (d.watches.length === 0) {
console.log(`当前没有事件订阅(缓冲 ${d.buffered} 条)`);
console.log('用法: nodeagent events --kind file --path <路径> [--seconds 20]');
return;
}
console.log(`${'订阅ID'.padEnd(24)} ${'类型'.padEnd(8)} 事件数 说明`);
for (const w of d.watches) {
console.log(`${w.watch_id.padEnd(24)} ${w.kind.padEnd(8)} ${String(w.events).padStart(6)} ${w.description}`);
}
}),
);
return;
}
const seconds = Math.max(1, Math.min(600, Number(opts.seconds ?? 15)));
const { profile, clientId } = target;
const keys = profile.auth_mode === 'ed25519' ? loadKeys() : null;
const received: Array<Record<string, unknown>> = [];
const client = new NodeAgentClient({
url: toWsUrl(profile),
key: profile.key ?? '',
clientId,
insecure: profile.insecure,
authMode: profile.auth_mode,
privateKey: keys?.privateKey,
hub: profile.hub ? { token: profile.hub.token, nodeId: profile.hub.node_id } : undefined,
onEvent: (evt) => {
received.push(evt);
const ts = new Date(Number(evt['ts'] ?? Date.now())).toLocaleTimeString('zh-CN');
const line = `${ts} ${String(evt['action'] ?? '').padEnd(20)} ${String(evt['target'] ?? '')}${evt['detail'] ? ` (${evt['detail']})` : ''}`;
if (opts.json) console.log(JSON.stringify(evt));
else console.log(` ${line}`);
},
});
try {
await client.connect();
const args: Record<string, unknown> = { kind: opts.kind };
if (opts.path) args['path'] = opts.path;
if (opts.pattern) args['pattern'] = opts.pattern;
if (opts.recursive) args['recursive'] = true;
if (opts.intervalMs) args['interval_ms'] = Number(opts.intervalMs);
const r = await client.invoke<{ watch_id: string; description: string }>(
CapabilityNames.EventWatch,
args,
);
if (r.status === 'failed') fail(`${r.error?.name}: ${r.error?.message}`);
const watchId = (r.data as { watch_id: string }).watch_id;
if (!opts.json) {
console.log(`已订阅:${(r.data as { description: string }).description}`);
console.log(`订阅 ID: ${watchId} 监听 ${seconds}s(Ctrl+C 可提前结束)\n`);
}
await new Promise<void>((resolve) => {
const t = setTimeout(resolve, seconds * 1000);
process.once('SIGINT', () => {
clearTimeout(t);
resolve();
});
});
await client.invoke(CapabilityNames.EventUnwatch, { watch_id: watchId });
if (!opts.json) {
console.log(`\n共收到 ${received.length} 个事件(订阅已取消)`);
}
} catch (err) {
if (err instanceof ClientError) fail(`${err.name}: ${err.message}`);
throw err;
} finally {
client.close();
}
}
/**
* v12 / E4:GUI 宏 —— 把一次成功操作固化为可重放的步骤序列。
* 子命令:run(回放)/ validate(仅校验文件)/ init(生成示例)
*/
export async function cmdMacro(sub: string | undefined, file: string | undefined, opts: Options): Promise<void> {
if (sub === 'init') {
const target = file ?? 'macro.json';
if (existsSync(target)) fail(`文件已存在: ${target}`);
const sample = {
name: '示例:在记事本里打字并复制',
description: '演示 focus / find / type / key / clip / assert 组合;坐标与文本请按实际调整',
default_delay_ms: 300,
steps: [
{ action: 'focus', title: 'Notepad', retry: 3, interval_ms: 800 },
{ action: 'key', keys: ['ctrl', 'a'] },
{ action: 'key', keys: ['delete'] },
{ action: 'type', text: '宏回放演示 ${STAMP:-demo}' },
{ action: 'sleep', ms: 300 },
{ action: 'key', keys: ['ctrl', 'a'] },
{ action: 'key', keys: ['ctrl', 'c'] },
{ action: 'clip', expect: '宏回放演示' },
{ action: 'assert', kind: 'window', text: 'Notepad' },
],
};
writeFileSync(target, JSON.stringify(sample, null, 2) + '\n');
console.log(`✓ 已生成示例宏: ${target}`);
console.log(' 试用: nodeagent --node=<设备> macro run ' + target + ' --var STAMP=hello');
return;
}
if (!file) fail('用法: nodeagent macro run <文件.json> [--var k=v] / macro validate <文件.json> / macro init [文件.json]');
if (!existsSync(file)) fail(`宏文件不存在: ${file}`);
const macro = loadMacroFile(file);
if (sub === 'validate') {
const actions = macro.steps.map((s) => String(s['action'] ?? '?'));
console.log(`✓ 语法有效:${macro.steps.length} 个步骤`);
console.log(` 名称: ${macro.name ?? '(未命名)'}`);
console.log(` 步骤: ${actions.join(' → ')}`);
return;
}
// 解析 --var k=v
const vars: Record<string, string> = {};
for (const kv of opts.vars ?? []) {
const i = kv.indexOf('=');
if (i <= 0) fail(`--var 需为 k=v 形式: ${kv}`);
vars[kv.slice(0, i)] = kv.slice(i + 1);
}
await withClient(async (c) => {
const outDir = opts.out ?? '.';
const t0 = Date.now();
const res = await runMacro(
{
client: c,
saveCapture: opts.out
? (name, b64) => {
const p = join(outDir, name);
writeFileSync(p, Buffer.from(b64, 'base64'));
return p;
}
: undefined,
log: (m) => console.error(m),
},
macro,
vars,
);
if (opts.json) {
printJson(res);
if (!res.ok) process.exitCode = 1;
return;
}
console.log(`宏「${res.name}」共 ${macro.steps.length} 步,耗时 ${Date.now() - t0}ms\n`);
for (const s of res.steps) {
const mark = s.ok ? '✓' : '✗';
console.log(` ${mark} [${String(s.index).padStart(2)}] ${s.action.padEnd(8)} ${String(s.ms + 'ms').padStart(7)} ${s.detail ?? ''}`);
}
if (res.ok) {
console.log('\n✓ 全部步骤通过');
} else {
console.error(`\n✗ 在第 ${res.failed_at} 步失败,宏已中止`);
process.exitCode = 1;
}
});
}
/** v13:成功指标(对齐 PRD 2.2 四项验收指标)。 */
export async function cmdClip(opts: Options): Promise<void> {
// 图片上传:从本地文件读入(避免超长命令行参数)
if (opts.imageFile) {
const p = opts.imageFile;
if (!existsSync(p)) fail(`文件不存在: ${p}`);
const b64 = readFileSync(p).toString('base64');
await withClient((c) =>
callAndPrint(c, CapabilityNames.ClipSet, { image_base64: b64 }, opts.json, (data) => {
const d = data as { written_bytes: number };
console.log(`✓ 图片已写入剪贴板(${d.written_bytes} 字节)`);
}),
);
return;
}
if (opts.set !== undefined) {
await withClient((c) =>
callAndPrint(c, CapabilityNames.ClipSet, { text: opts.set }, opts.json, (data) => {
const d = data as { written: number };
console.log(`✓ 已写入剪贴板 ${d.written} 字符`);
}),
);
return;
}
// 图片下载:--out 指定保存路径
if (opts.out) {
await withClient((c) =>
callAndPrint(c, CapabilityNames.ClipGet, { format: 'image' }, opts.json, (data) => {
const d = data as { image_base64: string; bytes: number; format: string };
writeFileSync(opts.out!, Buffer.from(d.image_base64, 'base64'));
console.log(`✓ 剪贴板图片已保存 ${opts.out}(${d.bytes} 字节,${d.format})`);
}),
);
return;
}
await withClient((c) =>
callAndPrint(c, CapabilityNames.ClipGet, { format: opts.format ?? 'auto' }, opts.json, (data) => {
const d = data as {
type?: string;
text?: string;
bytes?: number;
image_base64?: string;
};
if (d.type === 'image') {
console.log(`(剪贴板为图片,${d.bytes} 字节;用 --out <路径> 保存)`);
return;
}
if (d.text) console.log(d.text);
else console.log('(剪贴板为空或非文本)');
}),
);
}
// ---------- v2.0.0 状态采样(monitor / log) ----------
/**
* 状态采样与回看。
*
* nodeagent monitor start --source port --target 127.0.0.1:8765 --interval 2000
* nodeagent monitor report <id>
* nodeagent monitor stop <id>
* nodeagent monitor list
* nodeagent monitor delete <id>
*/
export async function cmdMonitor(action: string | undefined, positional: string[], opts: Options): Promise<void> {
switch (action) {
case 'start': {
const source = opts.source;
if (!source) fail('用法: nodeagent monitor start --source <port|process|command|metric> [--target X] [--interval ms] [--id name]');
const args: Record<string, unknown> = { source };
if (opts.target !== undefined) args['target'] = opts.target;
if (opts.interval !== undefined) args['interval_ms'] = Number(opts.interval);
if (positional[0]) args['id'] = positional[0];
await withClient((c) =>
callAndPrint(c, CapabilityNames.MonitorStart, args, opts.json, (d) => {
const r = d as { id: string; source: string; target: string; interval_ms: number; file: string };
console.log(`✓ 已启动监控 ${r.id}(${r.source}${r.target ? ' → ' + r.target : ''},每 ${r.interval_ms}ms 一针)`);
console.log(` 样本文件:${r.file}`);
console.log(` 回看:nodeagent monitor report ${r.id}`);
}),
);
return;
}
case 'report': {
const id = positional[0];
if (!id) fail('用法: nodeagent monitor report <id> [--limit n]');
const args: Record<string, unknown> = { id };
if (opts.limit !== undefined) args['limit'] = Number(opts.limit);
await withClient((c) =>
callAndPrint(c, CapabilityNames.MonitorReport, args, opts.json, (d) => {
const r = d as {
running: boolean; source: string; target: string; samples: number;
summary: Record<string, number>;
};
console.log(`${r.running ? '● 运行中' : '○ 已停止'} ${r.source}${r.target ? ' → ' + r.target : ''},共 ${r.samples} 个样本`);
// 摘要直接说人话(这是本能力的主要价值)
const sm = r.summary;
if (sm.down_count !== undefined) {
console.log(` 连通:${sm.up_count} 通 / ${sm.down_count} 断;最长连续中断 ${sm.longest_outage_samples} 针` +
(sm.ms_avg !== undefined ? `;延迟 min/avg/max = ${sm.ms_min}/${sm.ms_avg}/${sm.ms_max}ms` : ''));
}
if (sm.dead_count !== undefined) console.log(` 存活:${sm.alive_count} 在 / ${sm.dead_count} 不在;最长连续缺失 ${sm.longest_dead_samples} 针`);
if (sm.fail_count !== undefined) console.log(` 命令:${sm.ok_count} 成功 / ${sm.fail_count} 失败;退出码 ${JSON.stringify(sm.exit_codes_seen)}`);
if (sm.cpu_avg !== undefined) console.log(` CPU min/avg/max = ${sm.cpu_min}/${sm.cpu_avg}/${sm.cpu_max}%;内存 avg ${sm.mem_avg}%`);
if (sm.window_ms !== undefined) console.log(` 时间窗:${(sm.window_ms / 1000).toFixed(1)}s`);
}),
);
return;
}
case 'stop': {
const id = positional[0];
if (!id) fail('用法: nodeagent monitor stop <id>');
await withClient((c) =>
callAndPrint(c, CapabilityNames.MonitorStop, { id }, opts.json, (d) => {
const r = d as { id: string; samples: number; file: string };
console.log(`✓ 已停止 ${r.id}(共 ${r.samples} 个样本);样本留在 ${r.file}`);
}),
);
return;
}
case 'list': {
await withClient((c) =>
callAndPrint(c, CapabilityNames.MonitorList, {}, opts.json, (d) => {
const r = d as { running: Array<{ id: string; source: string; target: string; samples: number }>; running_count: number; history_files: string[] };
console.log(`运行中 ${r.running_count} 个:`);
for (const m of r.running) console.log(` ● ${m.id}(${m.source}${m.target ? ' → ' + m.target : ''},${m.samples} 样本)`);
if (r.history_files.length) console.log(`历史文件:${r.history_files.join(', ')}`);
}),
);
return;
}
case 'delete': {
const id = positional[0];
if (!id) fail('用法: nodeagent monitor delete <id>(运行中需先 stop)');
await withClient((c) =>
callAndPrint(c, CapabilityNames.MonitorDelete, { id }, opts.json, () => console.log(`✓ 已删除 ${id} 的历史样本`)),
);
return;
}
default:
fail('用法: nodeagent monitor <start|report|stop|list|delete> ...');
}
}
/** 在被控端侧过滤日志(v2.0.0):日志常几万行,过滤必须发生在被控端。 */
export async function cmdLog(opts: Options): Promise<void> {
const logPath = opts.path;
if (!logPath) fail('用法: nodeagent log --path <文件> [--pattern 正则] [--level ERROR] [--limit n] [--tail]');
const args: Record<string, unknown> = { path: logPath };
if (opts.pattern !== undefined) args['pattern'] = opts.pattern;
if (opts.level !== undefined) args['level'] = opts.level;
if (opts.since !== undefined) args['since'] = Number(opts.since);
if (opts.limit !== undefined) args['limit'] = Number(opts.limit);
if (opts.offset !== undefined) args['offset'] = Number(opts.offset);
if (opts.tail === true) args['tail'] = true;
await withClient((c) =>
callAndPrint(c, CapabilityNames.LogQuery, args, opts.json, (d) => {
const r = d as { matched: Array<{ n: number; text: string }>; scanned: number; truncated: boolean; note?: string };
console.log(`✓ 扫描 ${r.scanned} 行,命中 ${r.matched.length} 条${r.truncated ? '(结果被截断)' : ''}`);
for (const m of r.matched) console.log(` ${String(m.n).padStart(6)} ${m.text.slice(0, 160)}`);
if (r.note) console.log(` ℹ️ ${r.note}`);
}),
);
}
+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;
}
+535
View File
@@ -0,0 +1,535 @@
import { closeSync, existsSync, openSync, readFileSync, readSync, renameSync, statSync, writeFileSync, writeSync } from 'node:fs';
import fsSync from 'node:fs';
import net from 'node:net';
import os from 'node:os';
import path, { join } from 'node:path';
import {
NodeAgentClient,
loadMacroFile,
runMacro,
ClientError,
loadConfig,
saveConfig,
configPath,
toWsUrl,
loadKeys,
createKeys,
keysFilePath,
discoverOnce,
resolveTarget,
resolveNodeSelector,
emptyConfig,
type ClientConfig,
type ResolvedTarget,
type NodeProfile,
} from '@nodeagent/client';
import {
CapabilityNames,
matchPattern,
DEFAULT_DISCOVERY_PORT,
type CapabilityDescriptor,
type InvokeResult,
} from '@nodeagent/protocol';
import {
callAndPrint,
reportCompat,
fail,
getClientConfig,
humanSize,
printJson,
probeOnce,
riskIcon,
withClient,
withClientDirect,
} from '../core.js';
import { CHUNK_BYTES, type Options } from '../types.js';
// CLI 命令组:cmd/system.ts
export async function cmdList(opts: Options): Promise<void> {
const caps = await withClient(async (c) => {
return c.listCapabilities();
});
if (opts.json) return printJson(caps);
console.log(`被控端可用能力(${caps.length}):`);
for (const c of caps as CapabilityDescriptor[]) {
console.log(` ${riskIcon(c.risk)} ${c.name.padEnd(24)} ${c.description}`);
}
}
export async function cmdStatus(opts: Options): Promise<void> {
await withClient((c) =>
callAndPrint(c, CapabilityNames.SystemStatus, {}, opts.json, (data) => {
const d = data as {
cpu_pct: number;
memory_used: number;
memory_total: number;
memory_pct: number;
disks: Array<{ drive: string; total: number; free: number; used_pct: number }>;
net: Array<{ adapter: string; ip: string }>;
};
console.log(`CPU 占用 : ${d.cpu_pct}%`);
console.log(`内存 : ${humanSize(d.memory_used)} / ${humanSize(d.memory_total)} (${d.memory_pct}%)`);
console.log('磁盘 :');
for (const disk of d.disks) {
console.log(` ${disk.drive.padEnd(12)} ${humanSize(disk.total - disk.free)} / ${humanSize(disk.total)} 已用 ${disk.used_pct}%`);
}
console.log('网络 :');
for (const n of d.net) console.log(` ${n.adapter.padEnd(16)} ${n.ip}`);
}),
);
}
/**
* 把 info 的字段值渲染成可读单行。
*
* 修正:原先一律 `String(v)`,遇到 `ps_helper` / `build` 这类**嵌套对象**会打印
* `[object Object]`(真机 2026-10-04 发现),排查时完全看不到内容。
* 现改为:null/undefined → 空占位;数组/对象 → `key=value` 紧凑摘要(截断防刷屏)。
*/
function renderInfoValue(v: unknown): string {
if (v === null || v === undefined) return '—';
if (typeof v === 'string') return v === '' ? '—(未设置)' : v;
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
try {
const flat: Record<string, unknown> = {};
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
flat[k] = val === null || val === undefined ? '—' : typeof val === 'object' ? JSON.stringify(val) : val;
}
const s = Object.entries(flat)
.map(([k, val]) => `${k}=${val}`)
.join(' ');
return s.length > 180 ? `${s.slice(0, 177)}...` : s;
} catch {
return String(v);
}
}
export async function cmdInfo(opts: Options): Promise<void> {
// 先打版本与兼容性(v20)——info 是最自然的「体检」命令
await withClient((c) => {
reportCompat(c);
// v21:把「身份钉定」与「来源网段限制」这两项安全状态摆出来
const cfg = getClientConfig();
const prof = cfg.nodes[cfg.current ?? ''];
const fp = c.getPeerCertFingerprint();
console.log(
prof?.cert_sha256
? `证书指纹 : 已钉住 ${prof.cert_sha256.slice(0, 16)}…${fp === prof.cert_sha256 ? '(本次一致 ✓)' : '(本次不一致 ✗)'}`
: `证书指纹 : **未钉住** —— 建议重连一次自动钉住(nodeagent connect <地址> …)`,
);
const info = c.listCapabilities();
void info;
return Promise.resolve();
});
await withClient((c) =>
callAndPrint(c, CapabilityNames.SystemInfo, {}, opts.json, (data) => {
const d = data as Record<string, unknown>;
for (const [k, v] of Object.entries(d)) {
const val = k === 'memory_total' && typeof v === 'number' ? humanSize(v) : renderInfoValue(v);
console.log(`${k.padEnd(14)}: ${val}`);
}
}),
);
}
export async function cmdPs(opts: Options): Promise<void> {
const limit = Number(opts.limit ?? 20);
await withClient((c) =>
callAndPrint(c, CapabilityNames.ProcessList, { limit }, opts.json, (data) => {
const rows = (data as { processes: Array<{ pid: number; name: string; cpu_pct: number; memory_bytes: number }> }).processes;
console.log(`${'PID'.padStart(7)} ${'CPU%'.padStart(7)} ${'内存'.padStart(9)} 名称`);
for (const p of rows) {
console.log(`${String(p.pid).padStart(7)} ${String(p.cpu_pct).padStart(7)} ${humanSize(p.memory_bytes).padStart(9)} ${p.name}`);
}
}),
);
}
export async function cmdServices(opts: Options): Promise<void> {
const limit = Number(opts.limit ?? 20);
await withClient((c) =>
callAndPrint(c, CapabilityNames.ServiceList, { limit }, opts.json, (data) => {
const rows = (data as { services: Array<{ name: string; display_name: string; state: string }> }).services;
console.log(`${'状态'.padEnd(10)} ${'服务名'.padEnd(28)} 显示名`);
for (const s of rows) console.log(`${s.state.padEnd(10)} ${s.name.padEnd(28)} ${s.display_name}`);
}),
);
}
export async function cmdExec(command: string, opts: Options): Promise<void> {
await withClient((c) =>
callAndPrint(c, CapabilityNames.ShellExec, { command }, opts.json, (data) => {
const d = data as { exit_code: number; stdout: string; stderr: string; duration_ms: number; truncated: boolean };
// 命令自身的输出原样打到 stdout —— 保证 `nodeagent exec "..." | jq` 不被污染
if (d.stdout) console.log(d.stdout);
if (d.stderr) console.error(d.stderr);
// 诊断信息(退出码/耗时)走 stderr:终端下照常可见,管道里不干扰数据
console.error(
`[退出码 ${d.exit_code} · ${d.duration_ms}ms${d.truncated ? ' · 输出已截断' : ''}]`,
);
}),
);
}
export async function cmdRestart(opts: Options): Promise<void> {
const delayMs = opts.delay ? Number(opts.delay) : 2000;
await withClient((c) =>
callAndPrint(c, CapabilityNames.AgentRestart, { delay_ms: delayMs, reason: 'cli' }, opts.json, (data) => {
const d = data as { scheduled: boolean; delay_ms: number; mechanism: string; message: string };
console.log(`✓ ${d.message}`);
console.log(` 机制: ${d.mechanism}`);
console.log(' 提示: 约 3~4 秒后重连,可执行 `nodeagent info` 验证是否已恢复');
}),
);
}
// ---------- v2.0.0 审计链外部锚定 ----------
/**
* 打印审计链头(可选与最近锚点比对)。
*
* nodeagent audit head [--compare]
*/
export async function cmdAuditHead(opts: Options): Promise<void> {
const args: Record<string, unknown> = {};
if (opts.compare === true) args['compare'] = true;
if (opts.anchorPath !== undefined) args['anchor_path'] = opts.anchorPath;
await withClient((c) =>
callAndPrint(c, CapabilityNames.AuditHead, args, opts.json, (data) => {
const d = data as {
entries: number; head_hash: string | null; head_ts: number | null;
rotated_segments: number; file_bytes: number;
comparison?: { ok: boolean; anchors_checked: number; verdict: string };
};
console.log(`审计链头:${d.entries} 条,轮转 ${d.rotated_segments} 段,${(d.file_bytes / 1024).toFixed(0)} KB`);
console.log(` head_hash : ${d.head_hash ?? '(空链)'}`);
console.log(` head_ts : ${d.head_ts ? new Date(d.head_ts).toLocaleString() : '-'}`);
console.log(' 💡 把 head_hash 连同 entries 存到链外(另一台机器/网盘)—— 这是外部锚定');
if (d.comparison) {
const k = d.comparison.ok ? '✓' : '✗';
console.log(` ${k} 锚点比对(${d.comparison.anchors_checked} 条锚点):${d.comparison.verdict}`);
}
}),
);
}
/**
* 追加一个锚点到锚点文件。
*
* nodeagent audit anchor [--anchor-path <文件>] [--note "每日例行"]
*/
export async function cmdAuditAnchor(opts: Options): Promise<void> {
const args: Record<string, unknown> = {};
if (opts.anchorPath !== undefined) args['path'] = opts.anchorPath;
if (opts.note !== undefined) args['note'] = opts.note;
await withClient((c) =>
callAndPrint(c, CapabilityNames.AuditAnchor, args, opts.json, (data) => {
const d = data as { file: string; total_lines: number; record: { entries: number; head_hash: string | null } };
console.log(`✓ 已锚定:${d.record.entries} 条,链头 ${String(d.record.head_hash).slice(0, 16)}…`);
console.log(` 锚点文件:${d.file}(累计 ${d.total_lines} 条锚点)`);
}),
);
}
/**
* 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) => {
const d = data as {
ok: boolean;
checked: number;
legacy: number;
broken_at?: { file: string; line: number; reason: string };
};
if (d.ok) {
console.log(`✓ 审计链完整(校验 ${d.checked} 条${d.legacy ? `,跳过历史条目 ${d.legacy} 条` : ''})`);
return;
}
console.error(`✗ 审计链已损坏:${d.broken_at?.reason ?? '未知原因'}`);
if (d.broken_at) console.error(` 位置: ${d.broken_at.file}:${d.broken_at.line}`);
console.error(` 已校验 ${d.checked} 条`);
process.exitCode = 1;
}),
);
}
export async function cmdAudit(opts: Options): Promise<void> {
const args: Record<string, unknown> = {};
if (opts.limit) args['limit'] = Number(opts.limit);
if (opts.since) args['since'] = Number(opts.since);
if (opts.type) args['type'] = opts.type;
if (opts.clientId) args['client_id'] = opts.clientId;
await withClient((c) =>
callAndPrint(c, CapabilityNames.AuditList, args, opts.json, (data) => {
const d = data as { entries: Array<Record<string, unknown>>; total: number; file: string };
console.log(`审计文件: ${d.file}`);
console.log(`读取 ${d.total} 条,展示最新 ${d.entries.length} 条:\n`);
for (const e of d.entries) {
const ts = new Date(Number(e['ts'])).toLocaleString('zh-CN');
const cols = [
ts.padEnd(20),
String(e['type'] ?? '').padEnd(17),
String(e['client_id'] ?? '-').padEnd(10),
String(e['capability'] ?? '-').padEnd(20),
String(e['status'] ?? '-').padEnd(7),
e['duration_ms'] !== undefined ? `${e['duration_ms']}ms` : '',
e['error'] ? `err=${e['error']}` : '',
e['reason'] ? String(e['reason']) : '',
];
console.log(' ' + cols.filter((x) => x !== '').join(' '));
}
}),
);
}
// ---------- v4 局域网发现 ----------
export async function cmdMetrics(opts: Options): Promise<void> {
const args: Record<string, unknown> = {};
if (opts.since) args['since'] = Number(opts.since);
await withClient((c) =>
callAndPrint(c, CapabilityNames.Metrics, args, opts.json, (data) => {
const m = data as {
close_loop: { attempts: number; ok: number; failed: number; success_rate: number };
app_install: { attempts: number; ok: number; failed: number; success_rate: number | null };
latency: {
p50_ms: number;
p95_ms: number;
max_ms: number;
timeouts: number;
fast: { samples: number; p50_ms: number; p95_ms: number; max_ms: number };
slow: { samples: number; p50_ms: number; p95_ms: number; max_ms: number; capabilities: string[] };
};
security: { acl_denied: number; rate_limited: number; auth_failures: number; interception_rate: number };
verdict: {
close_loop_ok: boolean;
app_install_ok: boolean;
p95_ok: boolean;
interception_ok: boolean;
all_pass: boolean;
};
targets: { close_loop_success_rate: number; app_install_success_rate: number; p95_ms: number };
by_capability: Record<string, { attempts: number; ok: number; success_rate: number; p95_ms: number }>;
window: { from: number; to: number; entries: number };
};
const pct = (v: number): string => `${(v * 100).toFixed(1)}%`;
const mark = (ok: boolean): string => (ok ? '✅' : '❌');
const from = m.window.from ? new Date(m.window.from).toLocaleString('zh-CN') : '-';
const to = m.window.to ? new Date(m.window.to).toLocaleString('zh-CN') : '-';
console.log(`窗口: ${from} → ${to} (审计 ${m.window.entries} 条)\n`);
console.log(`闭环成功率 ${pct(m.close_loop.success_rate).padStart(7)} ${mark(m.verdict.close_loop_ok)} 目标 ≥${pct(m.targets.close_loop_success_rate)} 样本 ${m.close_loop.ok}/${m.close_loop.attempts}`);
const ir = m.app_install.success_rate;
console.log(`装软件成功率 ${ir === null ? ' 无样本' : pct(ir).padStart(7)} ${mark(m.verdict.app_install_ok)} 目标 ≥${pct(m.targets.app_install_success_rate)} 样本 ${m.app_install.ok}/${m.app_install.attempts}`);
console.log(`P95 时延 ${String(m.latency.fast.p95_ms + 'ms').padStart(7)} ${mark(m.verdict.p95_ok)} 交互层(目标 <${m.targets.p95_ms}ms,样本 ${m.latency.fast.samples}:P50 ${m.latency.fast.p50_ms}ms / 最大 ${m.latency.fast.max_ms}ms${m.latency.timeouts ? ` / 超时 ${m.latency.timeouts}` : ''})`);
console.log(`慢操作耗时 ${String(m.latency.slow.p95_ms + 'ms').padStart(7)} —— 样本 ${m.latency.slow.samples}(${m.latency.slow.capabilities.join(' / ')};本身耗时由业务决定,不计入达标判定)`);
console.log(`安全拦截 ${pct(m.security.interception_rate).padStart(7)} ${mark(m.verdict.interception_ok)} ACL 拒绝 ${m.security.acl_denied} / 超频 ${m.security.rate_limited} / 认证失败 ${m.security.auth_failures}`);
console.log(`\n总体: ${m.verdict.all_pass ? '✅ 四项全达标' : '⚠️ 存在未达标项'}`);
const caps = Object.entries(m.by_capability).sort((a, b) => b[1].attempts - a[1].attempts);
if (caps.length > 0) {
console.log('\n按能力细分(Top 10):');
console.log(` ${'能力'.padEnd(26)} ${'次数'.padStart(6)} ${'成功率'.padStart(8)} ${'P95'.padStart(9)}`);
for (const [name, st] of caps.slice(0, 10)) {
console.log(` ${name.padEnd(26)} ${String(st.attempts).padStart(6)} ${pct(st.success_rate).padStart(8)} ${String(st.p95_ms + 'ms').padStart(9)}`);
}
}
}),
);
}
/** v16:网络变更两阶段提交(备份 → 应用 → 定时回滚 → 确认提交)。 */
export async function cmdInvoke(capability: string, opts: Options): Promise<void> {
let args: Record<string, unknown> = {};
if (opts.args) {
try {
args = JSON.parse(opts.args) as Record<string, unknown>;
} catch {
fail('--args 必须是合法 JSON');
}
}
await withClient(async (c) => {
const result: InvokeResult = await c.invoke(capability, args);
printJson(result);
if (result.status === 'failed') process.exitCode = 2;
});
}
// ---------- v2 图形操作 ----------
export async function cmdApps(opts: Options): Promise<void> {
await withClient((c) =>
callAndPrint(c, CapabilityNames.AppList, {}, opts.json, (data) => {
const rows = (data as { apps: Array<{ name: string; version: string; publisher: string }> }).apps;
console.log(`已安装软件(${rows.length}):`);
for (const a of rows) console.log(` ${a.name.padEnd(40)} ${(a.version || '-').padEnd(14)} ${a.publisher}`);
}),
);
}
export async function cmdInstall(pkg: string, opts: Options): Promise<void> {
await withClient((c) =>
callAndPrint(c, CapabilityNames.AppInstall, { package: pkg, id: opts.id }, opts.json, (data) => {
const d = data as { installed: boolean; name: string; version: string; detail: string };
console.log(d.installed ? `✓ 安装成功: ${d.name} ${d.version}` : `✗ 安装失败: ${d.name}`);
if (d.detail) console.log(`\n${d.detail}`);
}),
);
}
/** v17:音频控制(静音 / 主音量)。 */
export async function cmdAudio(sub: string | undefined, opts: Options): Promise<void> {
if (sub === 'set') {
const args: Record<string, unknown> = {};
if (opts.mute !== undefined) {
const v = String(opts.mute).toLowerCase();
if (!['on', 'off', 'true', 'false', '1', '0'].includes(v)) {
fail('--mute 只接受 on|off');
}
args['mute'] = ['on', 'true', '1'].includes(v);
}
if (opts.volume !== undefined) args['volume'] = Number(opts.volume);
if (Object.keys(args).length === 0) fail('用法: nodeagent audio set [--mute on|off] [--volume 0-100]');
await withClient((c) =>
callAndPrint(c, CapabilityNames.AudioSet, args, opts.json, (data) => {
const d = data as { muted: boolean; volume: number; backend: string };
console.log(`✓ 已应用:${d.muted ? '🔇 静音' : '🔊 未静音'} · 音量 ${d.volume}%(${d.backend})`);
}),
);
return;
}
if (sub === undefined || sub === 'get') {
await withClient((c) =>
callAndPrint(c, CapabilityNames.AudioGet, {}, opts.json, (data) => {
const d = data as { muted: boolean; volume: number; backend: string };
console.log(`${d.muted ? '🔇 静音' : '🔊 未静音'} · 主音量 ${d.volume}%(${d.backend})`);
}),
);
return;
}
fail('用法: nodeagent audio [get] | audio set [--mute on|off] [--volume 0-100]');
}
/**
* v19:拉取式自更新 —— 被控端自己从 URL 下载新版本并替换自身。
*
* 与 `deploy`(推送式)的分工:推送式要求控制端与被控端**可达**;本命令只要求
* **被控端能上网**(跨网段/NAT/异地场景的正解)。
*
* ⚠️ 复用 deploy 那一课:更新会重启 agent 并切断连接,属预期,
* 不能把「连接断了」当成失败 —— 故连接部分吞掉异常,再用 probeOnce 复核指纹。
*/
export async function cmdUpdate(opts: Options): Promise<void> {
if (!opts.url) {
fail(
'用法: nodeagent update --url <更新包地址> --sha256 <哈希> [--dry-run]\n' +
' 拉取式自更新:被控端自己下载并替换(要求被控端能上网,不要求与此刻可达)\n' +
' --dry-run 只下载校验,不改动任何文件',
);
}
if (!opts.sha256) {
fail('--sha256 必填:**安全底线** —— 不校验哈希等于开放远程代码执行');
}
const args: Record<string, unknown> = {
url: opts.url,
sha256: opts.sha256,
dry_run: opts.dryRun === true,
};
if (opts.check === true) args['dry_run'] = true;
let incoming = '';
try {
await withClient((c) =>
callAndPrint(c, CapabilityNames.AgentUpdate, args, opts.json, (data) => {
const d = data as {
dry_run?: boolean;
verified?: boolean;
updated?: boolean;
bytes: number;
current_hash?: string;
incoming_hash?: string;
previous?: { hash?: string; bytes?: number };
backup_path?: string;
restarted?: boolean;
note?: string;
};
incoming = d.incoming_hash ?? '';
if (d.dry_run) {
console.log(`✓ 校验通过(dry-run):${(d.bytes / 1024).toFixed(0)} KB`);
console.log(` 当前 ${d.current_hash} → 待更新 ${d.incoming_hash}`);
console.log(` ${d.note ?? ''}`);
return;
}
console.log(`✓ 已替换:${d.previous?.hash} → ${d.current_hash}(${(d.bytes / 1024).toFixed(0)} KB)`);
if (d.backup_path) console.log(` 回滚点: ${d.backup_path}`);
if (d.restarted) console.log(' 已触发重启,连接会断开(属预期)');
}, 600_000),
);
} catch (err) {
// 重启切断了连接 —— 属预期
console.log(`(连接中断,属重启预期:${err instanceof Error ? err.message : String(err)})`);
}
if (args['dry_run'] === true || !incoming) return;
// 复核:等被控端回来并确认指纹已变成新的
console.log('等待被控端重启并复核…');
const deadline = Date.now() + 90_000;
while (Date.now() < deadline) {
await new Promise((t) => setTimeout(t, 1500));
const probe = await probeOnce();
if (probe.ok && probe.buildHash === incoming) {
console.log(`✓ 自更新完成并校验通过:指纹 ${probe.buildHash},PID ${probe.pid ?? '?'},能力 ${probe.caps} 项`);
return;
}
}
console.error('✗ 90s 内未复核到新指纹 —— 请检查被控端是否被杀软处置,或把备份覆盖回入口回滚');
process.exitCode = 1;
}
+301
View File
@@ -0,0 +1,301 @@
import { closeSync, existsSync, openSync, readFileSync, readSync, renameSync, statSync, writeFileSync, writeSync } from 'node:fs';
import fsSync from 'node:fs';
import net from 'node:net';
import os from 'node:os';
import path, { join } from 'node:path';
import {
NodeAgentClient,
loadMacroFile,
runMacro,
ClientError,
loadConfig,
saveConfig,
configPath,
toWsUrl,
loadKeys,
createKeys,
keysFilePath,
discoverOnce,
resolveTarget,
resolveNodeSelector,
emptyConfig,
type ClientConfig,
type ResolvedTarget,
type NodeProfile,
} from '@nodeagent/client';
import {
CAPABILITY_MANIFEST,
CapabilityNames,
capabilityDiff,
matchPattern,
DEFAULT_DISCOVERY_PORT,
type CapabilityDescriptor,
type InvokeResult,
} from '@nodeagent/protocol';
import { type Options } from './types.js';
// CLI 共享运行时(客户端/守护进程代理/输出工具)
export function getClientConfig(): ClientConfig {
const cfg = loadConfig();
if (!cfg) {
fail('尚未配置被控端。请先运行:\n nodeagent connect <host> --port 8765 --key <密钥>');
}
return cfg;
}
/** 本次命令的临时目标设备(来自全局 --node)。 */
// v16:全局 --node 的临时覆盖。跨模块共享可变状态:ESM 的 import 绑定只读,
// 故一律经访问器读写(直接 import 变量再赋值会编译不过)。
let currentNodeOverride: string | undefined;
/** 设置本次命令的 --node 覆盖(由入口在解析参数后调用)。 */
export function setNodeOverride(v: string | undefined): void {
currentNodeOverride = v;
}
/** 读取当前生效的 --node 覆盖。 */
export function getNodeOverride(): string | undefined {
return currentNodeOverride;
}
// ---------- v10 daemon 转发 ----------
export const DAEMON_SOCK = path.join(os.tmpdir(), 'nodeagentd.sock');
interface DaemonResponse {
status: 'ok' | 'failed' | 'daemon_error';
data?: unknown;
error?: { name: string; message: string } | string;
}
/** 经 daemon 转发一次能力调用(JSON lines over UDS)。 */
export function viaDaemon(node: string, capability: string, args: Record<string, unknown>, timeoutMs?: number): Promise<DaemonResponse> {
return new Promise((resolve, reject) => {
const s = net.connect(DAEMON_SOCK);
s.setTimeout(timeoutMs ?? 60_000);
let buf = '';
const failOnce = (err: Error): void => {
s.destroy();
reject(err);
};
s.once('error', failOnce);
s.once('timeout', () => failOnce(new Error('daemon 转发超时')));
s.once('connect', () => {
s.write(JSON.stringify({ node, capability, args, timeout_ms: timeoutMs }) + '\n');
});
s.on('data', (chunk) => {
buf += chunk.toString('utf8');
const idx = buf.indexOf('\n');
if (idx >= 0) {
try {
resolve(JSON.parse(buf.slice(0, idx)) as DaemonResponse);
} catch (err) {
failOnce(err instanceof Error ? err : new Error(String(err)));
return;
}
s.end();
}
});
});
}
/** 「虚拟客户端」:invoke 转发给 daemon,业务错误原样返回、daemon 自身错误抛异常触发回退。 */
export function daemonProxyClient(nodeName: string): { invoke: NodeAgentClient['invoke'] } {
return {
async invoke<T>(capability: string, args: Record<string, unknown>, timeoutMs?: number) {
const resp = await viaDaemon(nodeName, capability, args, timeoutMs);
if (resp.status === 'daemon_error') {
throw new Error(typeof resp.error === 'string' ? resp.error : resp.error?.message);
}
if (resp.status === 'failed') {
const e = resp.error;
return {
status: 'failed' as const,
error: typeof e === 'string' ? { name: 'E_EXECUTION_FAILED', message: e } : e,
} as never;
}
return { status: 'ok' as const, data: resp.data } as never;
},
};
}
export async function withClient<T>(fn: (client: NodeAgentClient) => Promise<T>, nodeName?: string): Promise<T> {
const cfg = getClientConfig();
let target: ResolvedTarget;
try {
target = resolveTarget(cfg, nodeName ?? getNodeOverride());
} catch (err) {
fail(err instanceof Error ? err.message : String(err));
}
const node = nodeName ?? getNodeOverride() ?? cfg.current;
// v10 daemon 快路径:socket 存活则经 daemon 复用长连接(省 1~2s TLS+握手)
if (node && existsSync(DAEMON_SOCK)) {
try {
return await fn(daemonProxyClient(node) as unknown as NodeAgentClient);
} catch (err) {
// daemon 自身错误(转发失败/超时)→ 静默回退直连;业务错误原样抛出
if (!(err instanceof ClientError)) {
return withClientDirect(fn, nodeName);
}
throw err;
}
}
return withClientDirect(fn, nodeName);
}
export async function withClientDirect<T>(fn: (client: NodeAgentClient) => Promise<T>, nodeName?: string): Promise<T> {
const cfg = getClientConfig();
let target: ResolvedTarget;
try {
target = resolveTarget(cfg, nodeName ?? getNodeOverride());
} catch (err) {
fail(err instanceof Error ? err.message : String(err));
}
const { profile, clientId } = target;
const keys = profile.auth_mode === 'ed25519' ? loadKeys() : null;
const client = new NodeAgentClient({
url: toWsUrl(profile),
key: profile.key ?? '',
clientId,
insecure: profile.insecure,
certSha256: profile.cert_sha256, // v21:钉住的证书指纹(不一致则拒绝连接)
authMode: profile.auth_mode,
privateKey: keys?.privateKey,
hub: profile.hub ? { token: profile.hub.token, nodeId: profile.hub.node_id } : undefined,
});
try {
await client.connect();
return await fn(client);
} catch (err) {
if (err instanceof ClientError) {
fail(`${err.name}: ${err.message}`);
}
throw err;
} finally {
client.close();
}
}
export function fail(msg: string): never {
console.error(`✗ ${msg}`);
process.exit(1);
}
export function printJson(value: unknown): void {
console.log(JSON.stringify(value, null, 2));
}
/** 调用能力并处理 status === 'failed' 的业务失败。 */
export async function callAndPrint(
client: NodeAgentClient,
capability: string,
args: Record<string, unknown>,
json: boolean,
render: (data: unknown) => void,
timeoutMs?: number,
): Promise<void> {
const result = await client.invoke(capability, args, timeoutMs);
if (result.status === 'failed') {
fail(`${result.error?.name ?? 'E_EXECUTION_FAILED'}: ${result.error?.message ?? '执行失败'}`);
}
if (json) printJson(result.data);
else render(result.data);
}
export function humanSize(bytes: number): string {
if (!Number.isFinite(bytes)) return String(bytes);
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let v = bytes;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i += 1;
}
return `${v.toFixed(v >= 10 || i === 0 ? 0 : 1)} ${units[i]}`;
}
export function riskIcon(risk: string): string {
return risk === 'high' ? '🔴' : risk === 'medium' ? '🟡' : '🟢';
}
/**
* 探测一次被控端(用于「等待重启/上线」这类**需要反复重试**的场景)。
*
* ⚠️ 不能用 withClient:它在连接失败时会直接 `fail()` **退出进程**(见 withClientDirect),
* 于是重试循环第一次连不上就整个 CLI 退出了 —— 真机踩过:部署其实已成功,
* CLI 却报 E_NODE_OFFLINE,且看不到任何自定义提示。本函数**永不抛出**。
*/
export async function probeOnce(nodeName?: string): Promise<{
ok: boolean;
pid?: number;
buildHash?: string;
caps: number;
error?: string;
}> {
try {
const cfg = getClientConfig();
const target = resolveTarget(cfg, nodeName ?? getNodeOverride());
const keys = target.profile.auth_mode === 'ed25519' ? loadKeys() : null;
const client = new NodeAgentClient({
url: toWsUrl(target.profile),
key: target.profile.key ?? '',
clientId: target.clientId,
insecure: target.profile.insecure,
certSha256: target.profile.cert_sha256, // v21:钉住的证书指纹
authMode: target.profile.auth_mode,
privateKey: keys?.privateKey,
hub: target.profile.hub ? { token: target.profile.hub.token, nodeId: target.profile.hub.node_id } : undefined,
});
await client.connect();
const r = await client.invoke<{ pid?: number; build?: { hash?: string } }>(CapabilityNames.SystemInfo, {});
const caps = client.listCapabilities().length;
client.close();
const d = r.data ?? {};
return { ok: r.status === 'ok', pid: d.pid, buildHash: d.build?.hash, caps };
} catch (err) {
return { ok: false, caps: 0, error: err instanceof Error ? err.message : String(err) };
}
}
/**
* 新旧版本兼容提示(v20)。
*
* 背景:agent 与 CLI 是**分开更新**的(CLI 随 Mac 装,agent 推/拉到被控端),
* 必然出现新旧混用。等到调用时才抛 E_CAPABILITY_NOT_FOUND 会让人摸不着头脑,
* 所以在握手后就明确告诉用户「谁旧、缺什么、怎么升」。
*
* 兼容性依据(见 docs/VERSIONING.md):**只增不改** ——
* 新 agent 向后兼容旧 CLI(新增能力/可选参数),因此升级顺序建议先被控端。
*/
export function reportCompat(client: NodeAgentClient): void {
const remoteNames = client.listCapabilities().map((c) => c.name);
const localNames = CAPABILITY_MANIFEST.map((c) => c.name);
const diff = capabilityDiff(localNames, remoteNames);
const v = client.getAgentVersion();
const b = client.getAgentBuild();
if (v) {
console.log(`被控端版本 : v${v}${b?.commit ? ` @ ${b.commit}` : ''}${b?.hash ? ` 构建 ${b.hash}` : ''}`);
} else {
console.log('被控端版本 : 未上报(多为 v20 之前的版本,建议更新一次)');
}
if (diff.missingOnRemote.length > 0) {
console.warn(`⚠️ 被控端缺少 ${diff.missingOnRemote.length} 项本控制端已知的能力 —— 远端较旧:`);
console.warn(` ${diff.missingOnRemote.join(', ')}`);
console.warn(' → 建议更新被控端:nodeagent update --url <包地址> --sha256 <哈希>');
}
if (diff.unknownLocally.length > 0) {
console.warn(`ℹ️ 被控端还有 ${diff.unknownLocally.length} 项本控制端不认识的能力 —— 本 CLI 较旧:`);
console.warn(` ${diff.unknownLocally.join(', ')}`);
console.warn(' → 不影响既有功能;要用到这些新能力需更新控制端(git pull 后重跑 pack:mac + install-macos.sh)');
}
if (diff.missingOnRemote.length === 0 && diff.unknownLocally.length === 0) {
console.log(`能力对齐 : ${localNames.length} 项,两端一致 ✓`);
}
}
+104
View File
@@ -0,0 +1,104 @@
// CLI 帮助文本(从 index.ts 拆出)
export const HELP = `nodeagent —— 跨机 AI 接管框架(控制端 CLI)
用法:
nodeagent connect <host> [--port 8765] [--key <key>] [--insecure] [--id mac_01] [--auth-mode ed25519]
配置并连接被控端(握手成功后打印能力清单)
首次连接会自动钉住被控端证书指纹(TOFU);被控端重装/换证书后用 --forget-cert 重新钉住
经 Hub 中转: 追加 --hub-token <Hub 令牌> --hub-node <被控端 node_id> (v6)
nodeagent keygen [--id mac_01] 生成 Ed25519 密钥对并输出被控端 ACL 配置片段 (v3 零信任)
nodeagent nodes 列出已配置的被控端设备 (v5 多设备)
nodeagent use <设备名> 切换当前默认设备
nodeagent remove <设备名> 移除设备
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] 设置静音 / 音量
nodeagent net [status] 网络现状 + 待确认变更 (v16)
nodeagent net apply --mode static --ip <ip> --mask <m> [--gateway g] --yes
改网络(两阶段提交:未确认则自动回滚,不会失联)
nodeagent net confirm [--task-name X] 确认提交网络变更(取消自动回滚)
nodeagent discover [--wait 5] 发现局域网内的被控端(UDP 广播,免手抄 IP)(v4)
文件传输 (v5):
nodeagent ls <远端路径> [--recursive] [--pattern "*.log"] 列目录
nodeagent stat <远端路径> 看元信息
nodeagent cat <远端路径> [--out 本地文件] 读文本(首块)
nodeagent pull <远端路径> [--out 本地文件] 下载(自动分块,支持大文件)
nodeagent push <本地文件> <远端路径> [--create-dirs] 上传(自动分块)
nodeagent info 查看系统信息 (system.info)
nodeagent status 查看资源状态 (system.status)
nodeagent ps [--limit 20] 查看进程 (system.process.list)
nodeagent services [--limit 20] 查看服务 (system.service.list)
nodeagent exec "<命令>" 执行命令 (system.shell.exec)
nodeagent apps 列出已安装软件 (app.list)
nodeagent install <包名|ID> 安装软件 (app.install)
nodeagent restart [--delay 2000] 受控重启被控端(配置变更后让自身生效)(v7)
nodeagent bg "<命令>" [--timeout-ms N] 后台执行长命令,立即返回 task_id (v10)
nodeagent tasks 列出后台任务
nodeagent task <id> [--offset N] [--kill] 读取/终止后台任务(支持增量续读)
nodeagent clip [--set "文本"] [--out <路径>] [--image-file <路径>]
读/写剪贴板文本或图片(--out 保存图片)(v11)
nodeagent macro run <file.json> [--var k=v] [--out <目录>]
回放 GUI 宏(步骤序列,逐步校验)(v12)
nodeagent macro validate <file.json> | macro init [文件]
nodeagent events [--kind file|process|net] [--path <路径>] [--pattern G] [--seconds 15]
订阅事件并实时接收推送(无 --kind 则列出订阅)(v12)
nodeagent monitor start --source <port|process|command|metric> [--target X] [--interval ms] [--id 名]
启动定时采样并落盘 (v2.0.0):port 验连通性/process 验存活/
command 取退出码/metric 取 CPU·内存
nodeagent monitor report <id> 回看样本 + 摘要(断几次、最长断多久、值域)(v2.0.0)
nodeagent monitor stop|list|delete <id>
nodeagent log --path <文件> [--pattern 正则] [--level ERROR] [--limit n] [--tail]
在被控端侧过滤日志,只回匹配行(日志几万行也不拖网)(v2.0.0)
nodeagent record [--duration 5000] [--fps 2] [--scale 0.5] [--region x,y,w,h]
录屏为帧序列(有 ffmpeg 则封装 mp4)(v11)
nodeagent update --url <地址> --sha256 <哈希> [--dry-run]
被控端自己拉更新(只需它能上网,不要求与你可达)(v19)
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)
nodeagent daemon [start|stop|status] 常驻连接池(批量操作提速 5~10 倍)(v10)
nodeagent list 列出被控端可用能力
nodeagent invoke <capability> [--args '<json>'] 通用调用
图形操作 (v2,输入控制需被控端开启 allow_input):
nodeagent screen 显示器信息 (screen.info)
nodeagent screenshot [--out f.jpg] [--scale 0.5] [--region x,y,w,h] [--format jpeg]
截屏并保存 (screen.capture)
nodeagent mouse move <x> <y> [--duration 300]
nodeagent mouse click [<x> <y>] [--button left|right|middle]
nodeagent mouse drag <x1> <y1> <x2> <y2> 鼠标拖拽(拖文件/框选)(v11)
nodeagent mouse scroll <delta>
nodeagent key type "<文本>" [--interval 10]
nodeagent key press <键1> [键2] ... (组合键,如 ctrl c)
nodeagent key press media_play_pause --pid 1234 (媒体键,v1.2:走 WM_APPCOMMAND,
--pid 定向到该进程的全部顶层窗口;省略则广播。
窗口被遮挡/最小化/游戏全屏时依然有效)
通用选项:
--node <设备名> 本次命令临时指定目标设备(不改变 current)
可放命令后(nodeagent info --node win_b)或前置(nodeagent --node=win_b info)
--json 以原始 JSON 输出
--config 显示当前配置路径
`;
+113 -1297
View File
File diff suppressed because it is too large. Load diff
+227
View File
@@ -0,0 +1,227 @@
import { closeSync, existsSync, openSync, readFileSync, readSync, renameSync, statSync, writeFileSync, writeSync } from 'node:fs';
import fsSync from 'node:fs';
import net from 'node:net';
import os from 'node:os';
import path, { join } from 'node:path';
import { parseArgs } from 'node:util';
import {
NodeAgentClient,
loadMacroFile,
runMacro,
ClientError,
loadConfig,
saveConfig,
configPath,
toWsUrl,
loadKeys,
createKeys,
keysFilePath,
discoverOnce,
resolveTarget,
resolveNodeSelector,
emptyConfig,
type ClientConfig,
type ResolvedTarget,
type NodeProfile,
} from '@nodeagent/client';
import {
CapabilityNames,
matchPattern,
DEFAULT_DISCOVERY_PORT,
type CapabilityDescriptor,
type InvokeResult,
} from '@nodeagent/protocol';
import { type Options } from './types.js';
// 命令行参数解析(从 index.ts 拆出)
export function parseOptions(rest: string[]): { opts: Options; positionals: string[] } {
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' },
key: { type: 'string' },
id: { type: 'string' },
limit: { type: 'string' },
args: { type: 'string' },
config: { type: 'boolean', default: false },
// v2 图形操作
out: { type: 'string' },
format: { type: 'string' },
scale: { type: 'string' },
region: { type: 'string' },
button: { type: 'string' },
duration: { type: 'string' },
interval: { type: 'string' },
tail: { type: 'boolean' },
compare: { type: 'boolean' },
'anchor-path': { type: 'string' },
note: { type: 'string' },
level: { type: 'string' },
target: { type: 'string' },
source: { type: 'string' },
where: { type: 'string' },
any_of: { type: 'string' },
title: { type: 'string' },
text: { type: 'string' },
process: { type: 'string' },
state: { type: 'string' },
condition: { type: 'string' },
// 媒体键定向投递的目标进程 PID(v1.2)
pid: { type: 'string' },
hold: { type: 'string' },
route: { type: 'string' },
'auth-mode': { type: 'string' },
since: { type: 'string' },
type: { type: 'string' },
'client-id': { type: 'string' },
wait: { type: 'string' },
'discovery-port': { type: 'string' },
node: { type: 'string' },
name: { type: 'string' },
recursive: { type: 'boolean', default: false },
pattern: { type: 'string' },
'create-dirs': { type: 'boolean', default: false },
'hub-token': { type: 'string' },
'hub-node': { type: 'string' },
// v7 受控重启
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' },
'image-file': { type: 'string' },
fps: { type: 'string' },
nodes: { type: 'string' },
path: { type: 'string' },
kind: { type: 'string' },
var: { type: 'string', multiple: true },
mode: { type: 'string' },
iface: { type: 'string' },
ip: { type: 'string' },
mask: { type: 'string' },
gateway: { type: 'string' },
dns: { type: 'string' },
command: { type: 'string' },
'confirm-within': { type: 'string' },
'task-name': { type: 'string' },
yes: { type: 'boolean', default: false },
url: { type: 'string' },
sha256: { type: 'string' },
'dry-run': { type: 'boolean', default: false },
psk: { type: 'boolean', default: false },
'discover-secret': { type: 'string' },
'forget-cert': { type: 'boolean', default: false },
check: { type: 'boolean', default: false },
force: { type: 'boolean', default: false },
// v2.1.0 rollback 逃生门(--backup <远端备份路径>)
backup: { type: 'string' },
mute: { type: 'string' },
volume: { type: 'string' },
seconds: { type: 'string' },
'interval-ms': { type: 'string' },
},
allowPositionals: true,
strict: false,
});
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,
key: values['key'] as string | undefined,
id: values['id'] as string | undefined,
limit: values['limit'] as string | undefined,
args: values['args'] as string | undefined,
out: values['out'] as string | undefined,
format: values['format'] as string | undefined,
scale: values['scale'] as string | undefined,
region: values['region'] as string | undefined,
button: values['button'] as string | undefined,
duration: values['duration'] as string | undefined,
interval: values['interval'] as string | undefined,
tail: values['tail'] as boolean | undefined,
compare: values['compare'] as boolean | undefined,
anchorPath: values['anchor-path'] as string | undefined,
note: values['note'] as string | undefined,
level: values['level'] as string | undefined,
target: values['target'] as string | undefined,
source: values['source'] as string | undefined,
where: values['where'] as string | undefined,
any_of: values['any_of'] as string | undefined,
condition: values['condition'] as string | undefined,
state: values['state'] as string | undefined,
process: values['process'] as string | undefined,
text: values['text'] as string | undefined,
title: values['title'] as string | undefined,
pid: values['pid'] as string | undefined,
hold: values['hold'] as string | undefined,
route: values['route'] as string | undefined,
authMode: values['auth-mode'] as string | undefined,
since: values['since'] as string | undefined,
type: values['type'] as string | undefined,
clientId: values['client-id'] as string | undefined,
wait: values['wait'] as string | undefined,
discoveryPort: values['discovery-port'] as string | undefined,
node: values['node'] as string | undefined,
delay: values['delay'] 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,
imageFile: values['image-file'] as string | undefined,
fps: values['fps'] as string | undefined,
nodes: values['nodes'] as string | undefined,
path: values['path'] as string | undefined,
kind: values['kind'] as string | undefined,
vars: (values['var'] as string[] | undefined) ?? [],
mode: values['mode'] as string | undefined,
iface: values['iface'] as string | undefined,
ip: values['ip'] as string | undefined,
mask: values['mask'] as string | undefined,
gateway: values['gateway'] as string | undefined,
dns: values['dns'] as string | undefined,
command: values['command'] as string | undefined,
confirmWithin: values['confirm-within'] as string | undefined,
taskName: values['task-name'] as string | undefined,
yes: Boolean(values['yes']),
url: values['url'] as string | undefined,
sha256: values['sha256'] as string | undefined,
dryRun: Boolean(values['dry-run']),
psk: Boolean(values['psk']),
discoverSecret: values['discover-secret'] as string | undefined,
forgetCert: Boolean(values['forget-cert']),
check: Boolean(values['check']),
force: Boolean(values['force']),
backup: values['backup'] as string | undefined,
mute: values['mute'] as string | undefined,
volume: values['volume'] as string | undefined,
seconds: values['seconds'] as string | undefined,
intervalMs: values['interval-ms'] as string | undefined,
name: values['name'] as string | undefined,
recursive: Boolean(values['recursive']),
pattern: values['pattern'] as string | undefined,
createDirs: Boolean(values['create-dirs']),
hubToken: values['hub-token'] as string | undefined,
hubNode: values['hub-node'] as string | undefined,
},
positionals,
};
}
+120
View File
@@ -0,0 +1,120 @@
// CLI 共享类型与常量(从 index.ts 拆出)
/** 已解析的常用选项。 */
export interface Options {
/** 打印帮助后退出(`--help` / `-h`)。选项而非位置参数 */
help: boolean;
/** 关闭 TLS(`--no-tls`)。原来是直接扫 process.argv,现归位到解析器 */
noTls: boolean;
json: boolean;
insecure: boolean;
port?: string;
key?: string;
id?: string;
limit?: string;
args?: string;
// v2 图形操作
out?: string;
format?: string;
scale?: string;
region?: string;
button?: string;
duration?: string;
interval?: string;
/** 媒体键定向投递的目标进程 PID(v1.2 keyPress) */
/** 通用选项:文本 / 标题 */
text?: string;
title?: string;
/** v2.0.0 审计锚定 */
compare?: boolean;
anchorPath?: string;
note?: string;
/** v2.0.0 状态采样 */
source?: string;
target?: string;
pattern?: string;
level?: string;
since?: string;
offset?: string;
tail?: boolean;
/** v2.0.0 gui.await 扩展 */
where?: string;
any_of?: string;
/** v1.6.0 gui.await */
condition?: string;
state?: string;
path?: string;
process?: string;
/** v1.5.0 快捷键扩展:长按毫秒 */
hold?: string;
/** v1.5.0 快捷键扩展:投递路由 foreground|post */
route?: string;
pid?: string;
/** v3:认证模式 psk | ed25519 */
authMode?: string;
/** v3+ 审计查询 */
type?: string;
clientId?: string;
/** v4 发现 */
wait?: string;
discoveryPort?: string;
/** v5 多设备 */
node?: string;
name?: string;
/** v5 文件 */
recursive?: boolean;
createDirs?: boolean;
/** v6 Hub */
hubToken?: string;
hubNode?: string;
/** v7 受控重启:延时毫秒 */
delay?: string;
/** v10 后台任务 / 剪贴板 */
timeoutMs?: string;
kill?: boolean;
set?: string;
/** v11 剪贴板图片 */
imageFile?: string;
/** v11 录屏 */
fps?: string;
/** v11 多设备并发 */
nodes?: string;
/** v11 部署目标路径 */
/** v12 事件订阅 */
kind?: string;
/** v12 宏变量 */
vars?: string[];
/** v22 发现广播认证密钥 */
discoverSecret?: string;
/** v22 每客户端 PSK */
psk?: boolean;
/** v21 证书指纹 */
forgetCert?: boolean;
/** v19 拉取式自更新 */
url?: string;
sha256?: string;
dryRun?: boolean;
/** v18 部署校验 */
check?: boolean;
force?: boolean;
/** v2.1.0 rollback 逃生门:直接指定远端备份路径为回滚源 */
backup?: string;
/** v17 音频 */
mute?: string;
volume?: string;
/** v16 网络变更 */
mode?: string;
iface?: string;
ip?: string;
mask?: string;
gateway?: string;
dns?: string;
command?: string;
confirmWithin?: string;
taskName?: string;
yes?: boolean;
seconds?: string;
intervalMs?: string;
}
/** 文件分块传输的块大小(fs.read/write 用,v5) */
export const CHUNK_BYTES = 1024 * 1024;
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+7
View File
@@ -0,0 +1,7 @@
# Tauri + Vue + TypeScript
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
## Recommended IDE Setup
- [VS Code](https://code.visualstudio.com/) + [Vue - Official](https://marketplace.visualstudio.com/items?itemName=Vue.volar) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
+28
View File
@@ -0,0 +1,28 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>nodeagent 控制台</title>
<script>
// 首帧防闪:Vue 接管之前浏览器已经会画一次背景,若不先刷上——
// 「高级灰」用户每次启动都会看到一帧刺眼的白。
//
// 此处只重复**两个**底色值(真值在 src/theme/index.ts),因为在此之前
// 没有任何模块可依赖;其余所有颜色仍由 App.vue 运行期注入的 --na-* 负责。
// 改调色板底色时记得同步这两行。
try {
var isGray = localStorage.getItem("nodeagent.console.theme") === "gray";
document.documentElement.style.background = isGray ? "#1d1f22" : "#f4f4f5";
document.documentElement.style.colorScheme = isGray ? "dark" : "light";
} catch (e) {
/* localStorage 不可用(隐私模式)时走默认底色即可 */
}
</script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+35
View File
@@ -0,0 +1,35 @@
{
"name": "nodeagent-console",
"private": true,
"version": "2.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"tauri": "tauri",
"sidecar:dev": "node sidecar/main.mjs",
"sidecar:build": "node sidecar/build.mjs",
"prepare:node": "node ../../scripts/prepare-console.mjs",
"verify:bundle": "node scripts/verify-bundle.mjs",
"bundle": "pnpm sidecar:build && pnpm --filter @nodeagent/agent build && pnpm prepare:node && tauri build --config src-tauri/tauri.bundle.gen.json && pnpm verify:bundle"
},
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2",
"echarts": "^6.1.0",
"naive-ui": "^2.45.3",
"pinia": "^4.0.3",
"vue": "^3.5.13",
"vue-router": "^5.3.1",
"@nodeagent/client": "workspace:*",
"@nodeagent/protocol": "workspace:*"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@vitejs/plugin-vue": "^6.0.7",
"typescript": "~6.0.3",
"vite": "^8.3.5",
"vue-tsc": "^3.3.5"
}
}
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env node
/**
* 验证「控制台的打包产物能否自举」—— 即打包内的 Node 运行时能否跑起打包内的 sidecar。
*
* 用法:
* node scripts/verify-bundle.mjs # 自动定位打包产物(macOS)
* node scripts/verify-bundle.mjs --app <x.app>
* node scripts/verify-bundle.mjs --runtime # 只用 prepare 出的 runtime + sidecar.dist
*
* 为什么需要它
* ----------
* `pnpm bundle` 成功、`tauri build` 报告 Finished,**不代表产物能跑**:
* 2026-10-11 实测踩到 —— macOS 上 `strip` 会使 Mach-O 代码签名失效,
* 内核对签名无效的可执行文件**直接 SIGKILL(exit 137)**,没有任何有用报错。
* 这类问题只存在于「打包产物」中:源码与单测都正常,只有真跑一遍产物才会暴露。
* 本脚本把那次人工排查固化成一条命令。
*
* 它同时覆盖三件事(缺一不可):
* ① 打包内的 node 真能执行(签名 / strip / 可执行位都对)
* ② 打包内的 sidecar.mjs 真能被它加载(bundle 完整、无缺依赖)
* ③ 两者之间的行分隔 JSON-RPC 通道真的通
*/
import { spawn, spawnSync } from 'node:child_process';
import { existsSync, readdirSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url)); // apps/console/scripts
const consoleDir = resolve(here, '..'); // apps/console
const isWin = process.platform === 'win32';
const NODE_NAME = isWin ? 'node.exe' : 'node';
const arg = (n) => {
const i = process.argv.indexOf(`--${n}`);
return i >= 0 && process.argv[i + 1] && !process.argv[i + 1].startsWith('--')
? process.argv[i + 1]
: undefined;
};
/** 定位打包产物(macOS:bundle/macos/*.app;Windows:无 .app,回落到 runtime)。 */
function findApp() {
const explicit = arg('app');
if (explicit) return resolve(explicit);
const dir = join(consoleDir, 'src-tauri/target/release/bundle/macos');
if (existsSync(dir)) {
const app = readdirSync(dir).find((f) => f.endsWith('.app'));
if (app) return join(dir, app);
}
return undefined;
}
let nodeBin;
let sidecar;
let agentScript;
let source;
const app = process.argv.includes('--runtime') ? undefined : findApp();
if (app) {
// ⚠️ Tauri 的 resource 映射 `"runtime/node": "node"` 使产物落在 Contents/Resources/node
// —— 必须与 src-tauri/src/sidecar.rs 的 resolve_node/resolve_sidecar 保持一致;
// `"runtime/agent.mjs": "agent.mjs"` 则落在 Contents/Resources/agent.mjs,
// 与 src-tauri/src/agent.rs 的 resolve_bundled_agent 一致。
const res = join(app, 'Contents/Resources');
nodeBin = join(res, NODE_NAME);
sidecar = join(res, 'sidecar.mjs');
agentScript = join(res, 'agent.mjs');
source = `打包产物 ${app}`;
} else {
nodeBin = join(consoleDir, `src-tauri/runtime/${NODE_NAME}`);
sidecar = join(consoleDir, 'sidecar/dist/sidecar.mjs');
agentScript = join(consoleDir, 'src-tauri/runtime/agent.mjs');
source = '准备产物(runtime + sidecar/dist)';
}
console.log(`=== 验证控制台产物自举 ===`);
console.log(` 来源:${source}`);
for (const [label, p] of [
['Node 运行时', nodeBin],
['sidecar', sidecar],
['被控端 agent.mjs', agentScript],
]) {
if (!existsSync(p)) {
console.error(`✗ 缺少 ${label}:${p}`);
console.error(' 提示:先跑 `pnpm bundle`(含 prepare:node),或 `pnpm prepare:node`。');
process.exit(1);
}
console.log(` ✓ ${label}:${p}`);
}
// ---------- ③ 打包内的 node 必须能真正**解析**打包内的 agent.mjs ----------
// 只查「文件存在」是不够的:截断、半写、依赖缺失都会让文件在、却加载不了,
// 而那要到用户点「启动」时才炸。`node --check` 只做语法/模块解析、不执行,
// 无副作用、不占端口,适合流水线。
{
const r = spawnSync(nodeBin, ['--check', agentScript], { encoding: 'utf8' });
if (r.status !== 0) {
console.error('\n✗ 打包内的 node 无法解析 agent.mjs —— 被控端资源已损坏');
console.error(' ' + (r.stderr || r.stdout || '').trim().split('\n').slice(0, 4).join('\n '));
console.error(' 提示:重跑 `pnpm prepare:node`(会重新 esbuild 打包被控端)。');
process.exit(1);
}
console.log(' ✓ 打包内 node 可解析 agent.mjs(语法/模块解析通过)');
}
// ---------- ①②③ 合一的实测:真 spawn,真握手 ----------
const child = spawn(nodeBin, [sidecar], { stdio: ['pipe', 'pipe', 'pipe'] });
let out = '';
let err = '';
child.stdout.on('data', (d) => (out += d));
child.stderr.on('data', (d) => (err += d));
// 用一个只读、无副作用的方法:未连接时应回 {"connected":false}
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'state', params: {} }) + '\n');
const done = (code) => {
try {
child.kill();
} catch {
/* 已退出 */
}
process.exit(code);
};
setTimeout(() => {
let parsed = null;
const line = out.split('\n').find((l) => l.trim().startsWith('{'));
try {
parsed = JSON.parse(line);
} catch {
/* 下面统一报错 */
}
if (err.trim()) {
console.log(' sidecar stderr:' + err.trim().split('\n').slice(0, 3).join(' | '));
}
if (parsed && parsed.id === 1 && parsed.result && 'connected' in parsed.result) {
console.log(
`\n✓ 自举成功:打包内 node(${process.version} 级)真跑起打包内 sidecar` +
`(connected=${parsed.result.connected})`,
);
done(0);
} else {
console.error('\n✗ 未收到预期的 JSON-RPC 响应 —— 产物可能无法自举');
console.error(' stdout:' + (out.trim().slice(0, 300) || '(空)'));
console.error(
' 若 node 直接运行即被 SIGKILL(137):macOS 上 strip 破坏了代码签名,' +
'需 adhoc 重签(见 scripts/prepare-console.mjs 的 ② 段注释)。',
);
done(1);
}
}, 4000);
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env node
/**
* 把 sidecar 打成**单文件**(供 Tauri 打包进应用)。
*
* 用法:node apps/console/sidecar/build.mjs [--out <dir>]
*
* 为什么用 esbuild 的 **JS API** 而不是 spawn `node_modules/.bin/esbuild`:
* pnpm 的 `.bin` 布局下 spawn 该路径会 ENOENT(scripts/pack.mjs 真机踩过)。
* 用 API 则从模块解析走,与包管理器布局无关。
*
* 依赖处理:`@nodeagent/client` 与 `@nodeagent/protocol` 是 workspace 包,
* **一并 bundle 进去**(sidecar 要作为独立文件被 Tauri 拉起,不能依赖仓库 node_modules)。
* `ws` 亦被打入(它是纯 JS)。Node 内置模块(readline/process)保持 external。
*/
import { build } from 'esbuild';
import { mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const argv = process.argv.slice(2);
const outIdx = argv.indexOf('--out');
const outFile = join(outIdx >= 0 ? argv[outIdx + 1] : join(here, 'dist'), 'sidecar.mjs');
mkdirSync(dirname(outFile), { recursive: true });
const result = await build({
entryPoints: [join(here, 'main.mjs')],
outfile: outFile,
bundle: true,
platform: 'node',
format: 'esm',
target: 'node20',
// Node 内置模块保持外部引用;其余(含 ws / workspace 包)全部内联
external: ['node:*'],
// ⚠️ CJS 依赖(如 ws)里有**动态 require**(`require('events')`),esbuild 打成 ESM 后
// 无法转换 → 运行时报 "Dynamic require of X is not supported"。注入 createRequire shim
// 是标准解法:让 bundle 内的 `require(...)` 走真实的 CJS 解析。
banner: {
js:
'// nodeagent console sidecar(由 apps/console/sidecar/build.mjs 生成,请勿手工编辑)\n' +
"import { createRequire as __naCreateRequire } from 'node:module';\n" +
'const require = __naCreateRequire(import.meta.url);',
},
legalComments: 'none',
minify: false, // 保持可读:出问题时能直接看堆栈
metafile: true,
});
const bytes = Object.values(result.metafile.outputs)[0]?.bytes ?? 0;
console.log(`✓ sidecar 已打包: ${outFile} ${(bytes / 1024).toFixed(0)} KB`);
+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);
}
}
+14
View File
@@ -0,0 +1,14 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas
# 随包分发的 Node 运行时(由 scripts/prepare-console.mjs 生成,~87MB;不入库)
# 体积原因 + 它是从本机 node 复制/剥离而来,不是源代码。
/runtime/
# 构建期注入片段(生成物,引用 runtime/ 下的 node;不入库,见 prepare-console.mjs 注释)
/tauri.bundle.gen.json
+4854
View File
File diff suppressed because it is too large. Load diff
+41
View File
@@ -0,0 +1,41 @@
[package]
name = "nodeagent-console"
version = "2.1.0"
description = "nodeagent 桌面控制台(主控 + 被控一体,含本机服务托管)"
authors = ["jkooe"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "nodeagent_console_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
# `tray-icon`:托盘常驻(后台运行 / 开机自启的载体)必需。
# 不给这个 feature,`TrayIconBuilder` 根本不存在(编译期就找不到符号)。
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-opener = "2"
# ⚠️ 单实例保护(v2.0.3 修):缺了它,「开机自启的隐藏实例 + 用户手开的实例」会并存,
# 各自托管一个被控端 → 同机两个被控端抢同一个 8765。详见 src/lib.rs 顶部「单实例」一节。
tauri-plugin-single-instance = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# ⚠️ 2026-10:不再依赖 crates/nodeagent-client —— 协议实现改由 Node sidecar 承担
# (见 src/sidecar.rs 的说明:避免 Rust/TS 两套协议实现静默漂移)
tokio = { version = "1", features = ["process", "io-util", "sync", "time", "macros", "rt-multi-thread"] }
# Read the optimization guideline for more details: https://tauri.app/concept/size/#cargo-configuration
[profile.release]
codegen-units = 1
lto = true
opt-level = 3
panic = "abort"
strip = true
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
@@ -0,0 +1,10 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"opener:default"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 567 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 777 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1020 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 848 B

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

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)
}
+831
View File
@@ -0,0 +1,831 @@
//! nodeagent 桌面控制台(主控 + 被控一体)——Tauri 后端。
//!
//! ## 结构(2026-10 起:sidecar 方案)
//! 本层只做**进程管理 + 消息转发**,真正的协议实现只有 TS 一份(`@nodeagent/client`),
//! 由 `sidecar.rs` 拉起的 Node 进程承载。原先「Rust 版 client 与 TS 版并存」的漂移风险就此消除。
//!
//! `agent.rs` 是同一思路的延伸:本机**被控端**也由本层托管(启动 / 停止 / 重启 / 日志 /
//! 崩溃自愈),用户在界面上点按钮即可,全程不必碰命令行。
//!
//! ## command 面
//! 连接类(签名与语义从未变过):
//! - `connect` / `disconnect` / `invoke_capability` / `get_capabilities` / `get_state`
//!
//! 本机服务类(2026-10-11 新增):
//! - `agent_status` / `agent_start` / `agent_stop` / `agent_restart`
//! - `agent_logs` / `agent_logs_clear`
//! - `autostart_status` / `autostart_set`
//! - `check_update` / `apply_update`
//! - `settings_get` / `settings_set` / `system_info`
//!
//! ## 事件面
//! - 连接:`na-state` / `na-log` / `na-event`
//! - 本机服务:`na-agent-state` / `na-agent-log`
//!
//! ## 启动模式
//! - 默认:显示主窗口
//! - `--agent-only`:**不显示窗口**,直接进托盘并托管本机被控端(开机自启用它,
//! 这样登录自启也不会有任何窗口弹出来)
//!
//! ## 单实例(v2.0.3)
//! 见下方「单实例」一节 —— 真机缺陷:无保护时同机会跑起两个被控端。
mod agent;
mod autostart;
mod settings;
mod sidecar;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use serde_json::{json, Value};
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
use tauri::{AppHandle, Emitter, Manager, State};
use agent::{AgentEvent, AgentManager};
use sidecar::{Sidecar, SidecarEvent};
/// 全局状态。
///
/// `connect` 成功后缓存「能力清单 / 版本 / 连接态」——这样 `get_capabilities`、
/// `get_state` 能保持**同步**(无需每次往返 sidecar),签名与改造前一致。
struct AppState {
sidecar: tokio::sync::Mutex<Option<Arc<Sidecar>>>,
capabilities: Mutex<Value>,
agent_version: Mutex<Option<String>>,
conn_state: Mutex<String>,
/// 本机被控端管理器(在 `setup` 里建,因为需要 AppHandle 来桥接事件)
agent: Mutex<Option<Arc<AgentManager>>>,
settings: Mutex<settings::Settings>,
/// 控制台自身配置目录(设置存这里)
config_dir: PathBuf,
/// 控制台版本(由 `package_info` 注入,用于更新比对;不在代码里写死)
app_version: String,
}
impl AppState {
fn agent(&self) -> Result<Arc<AgentManager>, String> {
self.agent
.lock()
.unwrap()
.as_ref()
.map(Arc::clone)
.ok_or_else(|| "被控端管理器尚未初始化".to_string())
}
fn settings(&self) -> settings::Settings {
self.settings.lock().unwrap().clone()
}
}
/// 连接成功后回传给前端的元信息(字段名与改造前一致)。
#[derive(serde::Serialize)]
struct ConnectInfo {
capabilities: Value,
agent_version: Option<String>,
authorized: Option<Value>,
peer_cert_fp: Option<String>,
auth_mode: String,
client_id: String,
}
/// 本机被控端的三条路径。
struct AgentPaths {
node: PathBuf,
script: PathBuf,
data_dir: PathBuf,
}
/// 解析(并在需要时播种)本机被控端所需路径。
fn resolve_agent_paths(app: &AppHandle, state: &AppState) -> Result<AgentPaths, String> {
let resource_dir = app.path().resource_dir().ok();
let node = sidecar::resolve_node(resource_dir.as_deref())?;
let bundled = agent::resolve_bundled_agent(resource_dir.as_deref())?;
let data_dir = agent::agent_data_dir();
let script = agent::seed_agent_script(&bundled, &data_dir, &state.app_version)?;
Ok(AgentPaths { node, script, data_dir })
}
/// 懒启动 sidecar(首次调用时拉起,之后复用同一进程)。
async fn ensure_sidecar(app: &AppHandle, state: &AppState) -> Result<Arc<Sidecar>, String> {
let mut guard = state.sidecar.lock().await;
if let Some(sc) = guard.as_ref() {
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(
app: AppHandle,
state: State<'_, AppState>,
url: String,
key: String,
client_id: Option<String>,
insecure: Option<bool>,
) -> Result<ConnectInfo, String> {
let sc = ensure_sidecar(&app, &state).await?;
let client_id = client_id.unwrap_or_else(|| "console".to_string());
let snap = sc
.request(
"connect",
json!({
"url": url,
"key": key,
"client_id": client_id,
"insecure": insecure.unwrap_or(true),
}),
30,
)
.await?;
let caps = snap.get("capabilities").cloned().unwrap_or_else(|| json!([]));
let ver = snap.get("agent_version").and_then(Value::as_str).map(str::to_string);
*state.capabilities.lock().unwrap() = caps.clone();
*state.agent_version.lock().unwrap() = ver.clone();
*state.conn_state.lock().unwrap() = "connected".to_string();
Ok(ConnectInfo {
capabilities: caps,
agent_version: ver,
authorized: snap.get("authorized").cloned(),
peer_cert_fp: snap.get("peer_cert_fp").and_then(Value::as_str).map(str::to_string),
auth_mode: snap
.get("auth_mode")
.and_then(Value::as_str)
.unwrap_or("psk")
.to_string(),
client_id,
})
}
/// 断开连接(停止自动重连)。
#[tauri::command]
async fn disconnect(state: State<'_, AppState>) -> Result<(), String> {
let sc = { state.sidecar.lock().await.as_ref().map(Arc::clone) };
if let Some(sc) = sc {
sc.request("disconnect", json!({}), 10).await?;
}
*state.conn_state.lock().unwrap() = "未连接".to_string();
*state.capabilities.lock().unwrap() = json!([]);
Ok(())
}
/// 调用一项被控端能力。`timeout_ms` 可选:长任务(装软件 / 长命令)需显式放大。
#[tauri::command]
async fn invoke_capability(
state: State<'_, AppState>,
capability: String,
args: Option<Value>,
timeout_ms: Option<u64>,
) -> Result<Value, String> {
let sc = {
state
.sidecar
.lock()
.await
.as_ref()
.map(Arc::clone)
.ok_or_else(|| "未连接".to_string())?
};
let mut params = json!({ "capability": capability, "args": args.unwrap_or_else(|| json!({})) });
if let Some(ms) = timeout_ms.filter(|m| *m > 0) {
params["timeout_ms"] = json!(ms);
}
// sidecar 侧已把「未知能力 / ACL 拒绝 / 超时」归一成 {status:"failed",error:{...}},
// 故这里拿到的是稳定的 InvokeResult 形态(前端只需判 status)。
sc.request("invoke", params, timeout_ms.map(|m| (m / 1000) + 10).unwrap_or(70)).await
}
/// 当前连接的能力清单(读缓存,保持同步签名)。
#[tauri::command]
fn get_capabilities(state: State<'_, AppState>) -> Result<Value, String> {
Ok(state.capabilities.lock().unwrap().clone())
}
/// 当前连接状态字符串(connected / reconnecting / closed / 未连接)。
#[tauri::command]
fn get_state(state: State<'_, AppState>) -> String {
state.conn_state.lock().unwrap().clone()
}
// ---------------------------------------------------------------------------
// 本机服务(被控端)command
// ---------------------------------------------------------------------------
/// 本机服务总览:进程状态 + 配置 + 自启 + 设置 + 版本。
#[tauri::command]
async fn agent_status(app: AppHandle, state: State<'_, AppState>) -> Result<Value, String> {
// 管理器可能尚未建(极早的调用);此时给一个纯配置视图,而不是报错
let mgr = state.agent.lock().unwrap().clone();
let snap = match &mgr {
Some(m) => m.snapshot().await,
None => json!({ "state": agent::state::STOPPED }),
};
// 数据目录以「实际使用的」为准;管理器还没跑过就用默认值
let data_dir = snap
.get("data_dir")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(PathBuf::from)
.unwrap_or_else(agent::agent_data_dir);
let cfg = agent::read_agent_config(&data_dir);
let resource_dir = app.path().resource_dir().ok();
Ok(json!({
"manager": snap,
"config": cfg,
"autostart": autostart::status(),
"settings": settings::to_json(&state.settings()),
"app_version": state.app_version,
"config_dir": state.config_dir.to_string_lossy(),
"installed_agent_version": agent::installed_agent_version(&data_dir),
"bundled_agent": agent::resolve_bundled_agent(resource_dir.as_deref())
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default(),
}))
}
/// 启动本机被控端。`auto_restart` 缺省时取设置里的值。
#[tauri::command]
async fn agent_start(
app: AppHandle,
state: State<'_, AppState>,
auto_restart: Option<bool>,
) -> Result<Value, String> {
let paths = resolve_agent_paths(&app, &state)?;
let ar = auto_restart.unwrap_or_else(|| state.settings().auto_restart);
state
.agent()?
.start(&paths.node, &paths.script, &paths.data_dir, ar)
.await
}
/// 停止本机被控端(幂等)。
#[tauri::command]
async fn agent_stop(state: State<'_, AppState>) -> Result<Value, String> {
state.agent()?.stop().await
}
/// 退出控制台 —— 由前端在用户确认后调用。
///
/// `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())
.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,
// 本机服务
agent_status,
agent_start,
agent_stop,
quit_app,
agent_restart,
agent_logs,
agent_logs_clear,
autostart_status,
autostart_set,
// 更新
check_update,
apply_update,
// 设置与信息
settings_get,
settings_set,
system_info,
])
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app, event| {
// app 退出时优雅收尾:先让 sidecar 主动 disconnect,再杀进程
//(Sidecar 设了 kill_on_drop,即使这里失败也不会留孤儿进程)
if let tauri::RunEvent::Exit = event {
let state = app.state::<AppState>();
let sc = tauri::async_runtime::block_on(async {
state.sidecar.lock().await.as_ref().map(Arc::clone)
});
if let Some(sc) = sc {
tauri::async_runtime::block_on(async {
sc.shutdown().await;
});
}
}
});
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
nodeagent_console_lib::run()
}
+78
View File
@@ -0,0 +1,78 @@
//! 控制台自身的偏好设置(持久化到磁盘)。
//!
//! ## 为什么设置要在 Rust 侧存一份
//! 前端有 `localStorage`,本来够用。但**「仅服务模式」(`--agent-only`,开机自启用)下
//! 窗口是隐藏的** —— 此时仍需要知道「要不要拉起被控端」「崩了要不要自动重启」。
//! 若只存 localStorage,就得靠隐藏的 webview 跑 JS 来驱动,既不直观也不可靠。
//! 故由 Rust 侧持有唯一真相,前端只做读写。
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Settings {
/// 应用启动时自动拉起本机被控端(默认开 —— 这正是「打开就有服务」的期望)
pub auto_start_agent: bool,
/// 被控端意外退出时自动重启(默认开 —— 「后台运行」的应有之义)
pub auto_restart: bool,
/// 点窗口关闭按钮时最小化到托盘而非退出(默认开 —— 「后台运行」)
pub close_to_tray: bool,
/// 连接本机被控端时是否自动接受自签证书(默认开;关掉就得手工钉指纹)
pub insecure: bool,
}
impl Default for Settings {
fn default() -> Self {
Self {
auto_start_agent: true,
auto_restart: true,
close_to_tray: true,
insecure: true,
}
}
}
pub fn settings_path(config_dir: &Path) -> PathBuf {
config_dir.join("console.json")
}
pub fn load(config_dir: &Path) -> Settings {
let p = settings_path(config_dir);
match std::fs::read_to_string(&p) {
Ok(text) => {
// 剥离 BOM(Windows 上由 PowerShell 写出的 JSON 可能带 BOM)
serde_json::from_str(text.trim_start_matches('\u{feff}')).unwrap_or_default()
}
Err(_) => Settings::default(),
}
}
pub fn save(config_dir: &Path, s: &Settings) -> Result<(), String> {
std::fs::create_dir_all(config_dir).map_err(|e| format!("创建配置目录失败:{e}"))?;
let text = serde_json::to_string_pretty(s).map_err(|e| format!("序列化设置失败:{e}"))?;
std::fs::write(settings_path(config_dir), format!("{text}\n"))
.map_err(|e| format!("写入设置失败:{e}"))
}
/// 把前端传来的「部分字段」合并进当前设置(未提供的字段保持原值)。
pub fn merge(config_dir: &Path, patch: &Value) -> Result<Settings, String> {
let mut cur = load(config_dir);
let mut raw = serde_json::to_value(&cur).map_err(|e| e.to_string())?;
if let (Some(obj), Some(p)) = (raw.as_object_mut(), patch.as_object()) {
for (k, v) in p {
// 类型不匹配的键直接忽略(前端传 `undefined` 序列化成 null 是常见情形)
if obj.get(k).map(|old| std::mem::discriminant(old) == std::mem::discriminant(v)).unwrap_or(false) {
obj.insert(k.clone(), v.clone());
}
}
}
cur = serde_json::from_value(raw).map_err(|e| format!("设置字段类型不合法:{e}"))?;
save(config_dir, &cur)?;
Ok(cur)
}
pub fn to_json(s: &Settings) -> Value {
serde_json::to_value(s).unwrap_or_else(|_| json!({}))
}
+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 说明仍在干等超时"
);
}
}
+38
View File
@@ -0,0 +1,38 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "nodeagent-console",
"version": "2.1.0",
"identifier": "cn.jkooe.nodeagent-console",
"build": {
"beforeDevCommand": "pnpm dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "pnpm build && pnpm sidecar:build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "nodeagent 控制台",
"width": 1100,
"height": 720
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"resources": {
"../sidecar/dist/sidecar.mjs": "sidecar.mjs"
}
}
}
Loaded 100 of 247 files, more files were not shown because too many files have changed in this diff. Show more