157 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
214 changed files with 38491 additions and 1504 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
+18
View File
@@ -40,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
+271 -36
View File
@@ -27,11 +27,148 @@ nodeagent 把一台 Windows 机器的能力**标准化成一组可授权、可
| **屏幕** | `screen.info` · `screen.capture` · `screen.record` · `screen.find` | 显示器 / 截屏 / **录屏为帧序列** / **元素定位三引擎**(UIA 控件树 → OCR 文字 → **图像模板**) |
| **窗口** | `window.list` · `window.focus` | 枚举可见窗口(精确矩形)/ 置前聚焦(**Windows + macOS**) |
| **文件** | `fs.list` · `fs.stat` · `fs.read` · `fs.write` | 列目录 / 元信息 / 分块读 / 原子写 |
| **输入** | `input.mouse.move` · `input.mouse.click` · `input.mouse.scroll` · `input.mouse.drag` · `input.key.type` · `input.key.press` | 键鼠控制(🔒 **默认禁用**),含**拖拽**与**按键序列/连按** |
| **输入** | `input.mouse.move` · `input.mouse.click` · `input.mouse.scroll` · `input.mouse.drag` · `input.key.type` · `input.key.press` | 键鼠控制(🔒 **默认禁用**),含**拖拽**、**字符串热键 / 预设 / 长按 / 后台定向投递** |
| **剪贴板** | `clip.get` · `clip.set` | 读写文本**或图片**(PNG Base64) |
| **事件订阅** | `event.watch` · `event.unwatch` · `event.list` · `event.poll` | 文件变动 / 进程启停 / 端口开闭,**主动推送**(无需轮询) |
| **GUI 等待** | `gui.await` | 等条件成立再返回(窗口/元素/进程/文件 × 出现/消失);支持**属性谓词 `where`** 与 **`any_of` 组合** |
| **状态采样** | `log.query` · `monitor.start` · `monitor.report` · `monitor.stop` · `monitor.list` · `monitor.delete` | 日志**在被控端侧过滤**(不上传整份)/ 定时采样落盘 + **摘要回看**(断几次、最长断多久、CPU/内存值域) |
| **审计锚定** | `system.audit.head` · `system.audit.anchor` | 链头可读 + 锚点**追加**落盘,让「有 root 者整链重写」可被发现 |
**36 项能力** · **32 个 MCP 工具** · **114 项单元测试** · **26 项端到端用例**(CI 在真实 Windows 上验证)
## 快捷键(hotkey)控制 —— v1.5.0
`input.key.press` 支持 **4 种输入形式**(一次只用一种):
```bash
# ① 字符串热键(最常用;分隔符支持 + - 空格,大小写不敏感)
nodeagent --node win key press "ctrl+shift+esc"
nodeagent --node win key press "win+d" # 显示桌面
nodeagent --node win key press "alt+tab" # 切换应用
# ② 热键序列
nodeagent --node win key press "ctrl+c" # 老形式也兼容:key press ctrl c
nodeagent --node win invoke input.key.press --args '{"hotkeys":["ctrl+c","ctrl+v"]}'
# ③ 预设语义名(约 50 个,免记键位)
nodeagent --node win key press copy # = ctrl+c
nodeagent --node win key press task_manager # = ctrl+shift+esc
nodeagent --node win key press volume_mute_toggle # 媒体键(走 WM_APPCOMMAND)
# ④ 老数组形式
nodeagent --node win key press ctrl c
```
**按键覆盖**:字母/数字 · F1–F24 · 左右侧修饰键(`lalt`/`rctrl`/`lwin`…)·
小键盘(`numpad7`、`numpad_add`…,**与数字键分开**——Excel 靠 NumLock 区分)·
OEM 符号键(`oem_plus`/`oem_comma`/`oem_period`… → 支持 `win+d`、`ctrl++`、`win+.`)·
媒体键 · 浏览器键 · IME 键(`convert`/`kana`…)· 系统键(`sleep`/`help`…)
**进阶参数**:
| 参数 | 说明 |
|---|---|
| `hold_ms` | 长按:按下后保持 N 毫秒再释放(≤5000) |
| `route=post` + `target_pid` | **后台定向投递**:向目标进程全部顶层窗口 PostMessage(目标窗口被遮挡/最小化时用)。⚠️ 游戏与部分输入型程序不响应后台键消息 |
| `target_pid`(默认路由) | 媒体键定向(播放器常建 30+ 辅助窗口,指定 PID 投给该进程全部顶层窗口) |
**预设速查**(完整表见 `packages/protocol/src/hotkeys.ts` 的 `HOTKEY_PRESETS`):
| 类别 | 预设名 |
|---|---|
| 编辑 | `copy` `cut` `paste` `paste_plain` `undo` `redo` `select_all` |
| 文件 | `save` `save_as` `new` `open` `close_tab` `print` |
| 窗口 | `switch_app` `task_view` `task_manager` `show_desktop` `minimize_all` `maximize` `snap_left` `snap_right` `rename` `properties` |
| 系统 | `lock_screen` `run` `explorer` `search` `settings` `clipboard_history` `emoji_picker` `screenshot` |
| 虚拟桌面 | `new_desktop` `desktop_left` `desktop_right` |
| 标签/视图 | `new_tab` `next_tab` `reopen_tab` `zoom_in` `zoom_out` `fullscreen` |
| 媒体 | `music_play_pause` `music_next` `music_prev` `volume_add` `volume_mute_toggle` |
> MCP 侧同名工具为 `na_key`(`action: "press"`),支持同一套参数。
## 等待条件 gui.await —— v1.6.0
GUI 操作从「按一下→睡几秒→截图碰运气」变成**可断言**流程:
```bash
# 等界面元素出现(最多 30s)
nodeagent --node win invoke gui.await --args '{"condition":"control","text":"完成","timeout_ms":30000}'
# 等它消失
nodeagent --node win invoke gui.await --args '{"condition":"control","text":"安装中","state":"absent"}'
# 等窗口 / 进程 / 文件
nodeagent --node win invoke gui.await --args '{"condition":"window","title":"安装程序","timeout_ms":10000}'
nodeagent --node win invoke gui.await --args '{"condition":"process","process":"setup.exe"}'
nodeagent --node win invoke gui.await --args '{"condition":"file","path":"C:\\log.txt"}'
```
四类条件各自复用 `window.list` / `screen.find` / `process.list` / `fs.stat`,
平台行为(Windows UIA/OCR、macOS Vision OCR)与那些能力完全一致,**只读低危**。
轮询期单次异常不视为失败(启动中的程序常短暂报错),超时未命中才带回 `last_error`。
**典型闭环**:`await control(text:"立即安装")` → `mouse.click(x, y)` → `await control(text:"完成", timeout_ms:60000)` → `click` → `await absent(text:"安装中")`。
### 属性谓词与组合(v2.0.0)
`screen.find` 现在返回 UIA 属性,`gui.await` 可据此等**状态**而不只是「出现」:
```bash
# 等一个「可用」的按钮(不指定文字)
nodeagent --node win await --condition control --where '{"enabled":true}'
# 等值里含「已」的元素
nodeagent --node win await --condition control --where '{"value":"*已*"}' --timeout 15000
# 组合:装成功 或 报错,先出现的算
nodeagent --node win await --any_of '[{"condition":"window","title":"安装完成"},{"condition":"control","text":"错误"}]'
```
| 属性 | 来源 | 拿不到时 |
|---|---|---|
| `enabled` | `IsEnabled` | — |
| `value` | `ValuePattern` | `null`(**不猜**) |
| `selected` | `SelectionItemPattern` | `null` |
| `toggle` | `TogglePattern` | `null` |
> 属性只在 **UIA 引擎**下可读(Windows)。走 OCR/图像模板时 `where` 被忽略并**带回 `note`**,
> 不会让调用方误以为「按属性过滤过了」。macOS 无 UIA 等价物,`where` 不可用(会明确报错提示用 `text`)。
---
## 状态采样:日志过滤与定时采样 —— v2.0.0
治**间歇性**问题(代理不通、杀软拦截、端口时开时闭)—— `system.status` 只能看当下快照,
而 `event.watch` 只解决「有事件时通知」,没有「**持续记录并回看**」。
```bash
# 日志在被控端侧过滤,只回匹配行(几万行日志也不整份拉回)
nodeagent --node win log --path "C:\app\app.log" --level ERROR --limit 20
nodeagent --node win log --path "C:\app\app.log" --pattern "timeout|refused" --tail
# 连续采样 5 分钟,事后回看「断过几次、最长断多久」
nodeagent --node win invoke monitor.start --args '{"source":"port","target":"127.0.0.1:8765","interval_ms":2000,"id":"probe"}'
nodeagent --node win monitor report probe
nodeagent --node win monitor stop probe
```
采样源四类:`port`(连通性 + 延迟)/ `process`(存活 + PID)/ `command`(退出码 + 首行输出)/
`metric`(CPU·内存)。样本落 `<数据目录>/monitors/<id>.jsonl`,**摘要直接给结论**。
---
## 审计链外部锚定 —— v2.0.0
链内哈希只能发现「改一条」。**有 root 的攻击者可以整链重写** —— 从零重建一份自洽的假日志,
`audit.verify` 照样通过。外部锚定是唯一能戳破它的手段:把链头记到**链外**。
```bash
nodeagent --node win audit head --compare # 看链头 + 与最近锚点比对
nodeagent --node win audit anchor --note "每日例行" # 追加一个锚点
```
**锚点记四元组** `{entries, head_hash, head_ts, rotated_segments}`,不能只记哈希 ——
日志轮转会丢弃最旧段使条目数下降,只比哈希会把**轮转误判成篡改**。
锚点文件**只追加**(可覆盖的话,攻击者重写链后再刷新锚点即可抹痕)。
> 真正发挥价值的前提:把锚点文件指向**另一台机器 / 网盘同步目录**。
> 本机文件被整盘重写时,锚点也随之消失 —— 那就等于没锚。
**52 项能力** · **42 个 MCP 工具** · **483 项单元测试** · **50 项端到端用例**(34 通用 + 16 Windows 专属) · **23 项 Rust 壳单测**(真跑 Node sidecar)(CI 在真实 Windows / Linux runner 上验证)
> 关键里程碑:**GUI 语义**(`window.list` + `screen.find`,UIA 找不到自动降级 OCR)
> 让 AI 从「看得到画面但读不懂界面」变成「按名字取坐标点下去」。
@@ -42,9 +179,15 @@ nodeagent 把一台 Windows 机器的能力**标准化成一组可授权、可
```bash
pnpm install
pnpm build
pnpm build # 全部 workspace(protocol/client/agent/cli/mcp/hub + 桌面控制台前端)
pnpm test:unit # 单元测试(协议纯函数 / 清单守护 / 编码脚本守卫 / 契约守卫)
pnpm test:e2e # 端到端(起真实被控端跑全链路)
pnpm test:rust # Rust 控制端客户端(需 cargo)
```
> 桌面控制台是 Tauri 应用,**打包**需另进目录:`cd apps/console && pnpm tauri build`。
> 仅前端产物则 `pnpm build` 已覆盖。
### 1. Windows 侧(被控端)
**方式 A:安装包(推荐,免装 Node.js)**
@@ -52,7 +195,7 @@ pnpm build
1. 在控制端构建安装包(**需在项目目录执行**):
```bash
cd <项目目录> # 例如 ~/WorkBuddy/NodeAgent/nodeagent
cd <项目目录> # 例如 ~/projects/nodeagent
pnpm pack:win
# 或从任意目录调用:
@@ -168,11 +311,17 @@ nodeagent pull "C:\big.iso" --out ./big.iso # 下载(大文件自动
## 架构
```
┌────────────────────┐ ┌────────────────────┐
│ 控制端 (macOS) │ ◄──── JSON-RPC 2.0 ────► │ 被控端 (Windows) │
│ CLI · MCP Server │ over WebSocket │ Agent(开机自启) │
└────────────────────┘ └────────────────────┘
│ │
┌──────────────────────────────────────────┐
│ 控制端 (macOS / Windows) │
│ ① 桌面控制台(Tauri 2) ② CLI ③ MCP │
└──────────────────────────────────────────┘
│
JSON-RPC 2.0 over WebSocket
│
┌──────────────────────────────────────────┐
│ 被控端 Agent(Windows 常驻 / 开机自启) │
│ 同时也是「本地被控端」,供本机控制台直连 │
└──────────────────────────────────────────┘
└──────────── 三种链路可选 ──────────────────────┘
① 局域网直连 ② UDP 广播自动发现 ③ Hub 中转(跨网段/公网)
```
@@ -185,6 +334,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 没有交互桌面,截图/窗口类能力会失败)。
## 安全模型
三层防护,逐层收窄权限:
@@ -273,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
@@ -328,7 +553,7 @@ nodeagent mouse drag 300 200 700 500 # 拖拽(拖文件/框选
| `na_clip` | 读写剪贴板文本或图片 |
| `na_fs_list` · `na_fs_read` · `na_fs_write` · `na_fs_stat` | 读目录 / 读文件 / 写文件 / 元信息 |
| `na_restart` | 受控重启被控端 |
| `na_audit` · `na_discover` · `na_nodes` · `na_use` | 查审计、发现设备、多设备切换 |
| `na_audit` · `na_audit_l2` · `na_discover` · `na_nodes` · `na_use` | 查审计(`na_audit_l2` = 系统级 L2,仅 Windows 被控端)、发现设备、多设备切换 |
| `na_event_watch` · `na_event_poll` · `na_event_list` · `na_event_unwatch` | 事件订阅;`notify:true` 走 MCP 日志通知实时推送,否则用 `na_event_poll` 拉取 |
| `na_macro_run` | 回放 GUI 宏(步骤序列,逐步校验) |
| `na_metrics` | 成功指标(闭环率 / 装软件率 / P95 / 拦截率)+ 达标判定 |
@@ -427,47 +652,57 @@ nodeagent/
├── scripts/ # install.cmd / control.cmd(一键入口)/ install.ps1 / control.ps1 / verify.mjs
├── tests/e2e/ # 端到端测试
├── docs/ # 开发文档
├── PRD.md # 产品需求文档
├── docs/ # 项目文档(含 PRD)
└── README.md
```
## 路线图
| 阶段 | 目标 | 状态 |
> 下表「**批次**」是**开发阶段的内部代号**(一批相关改动的集合),**不是版本号** ——
> 项目的唯一版本号是 semver(见 `git tag` / `package.json`)。批次与发布版本的对应关系
> 及书写规范见 [`docs/VERSIONING.md`](docs/VERSIONING.md) §0。
| 阶段(开发批次) | 目标 | 状态 |
|---|---|---|
| **v1** | 命令级接管 + 装软件 + 查状态 + 基本鉴权 + CLI + MCP | ✅ |
| **v2** | 图形接管(`screen.*` 截屏 + `input.*` 键鼠)+ 输入控制开关 | ✅ |
| **v3** | 零信任:Ed25519 身份认证 + 能力级 ACL | ✅ |
| **v3+** | 审计日志(留痕 + 查询)+ 调用限速 | ✅ |
| **v4** | 无感体验:局域网自动发现 + 断线自动重连 | ✅ |
| **v5** | 多设备管理 + 文件传输(分块 / 大文件 / 原子写) | ✅ |
| **v6** | Hub 中转:跨网段 / 公网接入(被控端主动外连,穿 NAT) | ✅ |
| **v7** | **自持**:受控重启(配置变更后让自身生效,不再丢连接) | ✅ |
| **v8** | **GUI 语义**:`window.list` / `window.focus` / `screen.find`(UIA 按名取坐标) | ✅ |
| **v9** | `screen.find` **OCR 兜底**(自绘 UI 也能定位)+ MCP 工具同步 | ✅ |
| **v10** | **长任务异步化**(`system.task.*`)+ 剪贴板 + CLI 常驻连接池 daemon | ✅ |
| **v11** | **安全加固**:私钥入系统密钥库(Keychain/DPAPI)+ 审计链防篡改 + ACL 细化(IP/时段/按能力限速) | ✅ |
| **v11+** | **操作面补完**:鼠标拖拽 / 录屏 / 剪贴板图片 / 多设备并发 / 一键升级 | ✅ |
| **v12** | **事件订阅**(file/process/net 主动推送)+ **GUI 宏**(步骤序列回放)+ **Hub 并发多控制端** + 设备分组 | ✅ |
| **v12.1** | **Hub 侧按控制端授权**(多令牌 + 按设备白/黑名单 + 注册准入) | ✅ |
| **v12.2** | GUI **等待/重试语义**(`wait_ms`)—— 界面加载/动画不再假失败 | ✅ |
| **v12.3** | **macOS 端 GUI 对齐**:AppleScript 窗口枚举/聚焦 + Vision OCR 元素定位(含 Retina 坐标换算) | ✅ |
| **v12.4** | **事件直达 MCP 宿主**(实时推送 + 拉取双通路) | ✅ |
| **v13** | **成功指标聚合**(PRD 2.2 四项)+ 依真实数据修正口径(时延分层) | ✅ |
| **v14** | **PowerShell 常驻助手** —— GUI 能力提速 4~25 倍(预加载 C#/UIA,免每次现编译) | ✅ |
| **v15** | `screen.find` **图像模板匹配**(纯图标/无文字控件盲区)+ 一堆真机根因修复 | ✅ |
| **待办** | CI 单元测试步骤已就绪(分支 `ci/workflow-unit-tests`,**需 `gh auth refresh -s workflow` 授权后推送**);GUI 图像锚点匹配(自绘 UI 更稳) | 🚧 |
| **批次 1** | 命令级接管 + 装软件 + 查状态 + 基本鉴权 + CLI + MCP | ✅ |
| **批次 2** | 图形接管(`screen.*` 截屏 + `input.*` 键鼠)+ 输入控制开关 | ✅ |
| **批次 3** | 零信任:Ed25519 身份认证 + 能力级 ACL | ✅ |
| **批次 3+** | 审计日志(留痕 + 查询)+ 调用限速 | ✅ |
| **批次 4** | 无感体验:局域网自动发现 + 断线自动重连 | ✅ |
| **批次 5** | 多设备管理 + 文件传输(分块 / 大文件 / 原子写) | ✅ |
| **批次 6** | Hub 中转:跨网段 / 公网接入(被控端主动外连,穿 NAT) | ✅ |
| **批次 7** | **自持**:受控重启(配置变更后让自身生效,不再丢连接) | ✅ |
| **批次 8** | **GUI 语义**:`window.list` / `window.focus` / `screen.find`(UIA 按名取坐标) | ✅ |
| **批次 9** | `screen.find` **OCR 兜底**(自绘 UI 也能定位)+ MCP 工具同步 | ✅ |
| **批次 10** | **长任务异步化**(`system.task.*`)+ 剪贴板 + CLI 常驻连接池 daemon | ✅ |
| **批次 11** | **安全加固**:私钥入系统密钥库(Keychain/DPAPI)+ 审计链防篡改 + ACL 细化(IP/时段/按能力限速) | ✅ |
| **批次 11+** | **操作面补完**:鼠标拖拽 / 录屏 / 剪贴板图片 / 多设备并发 / 一键升级 | ✅ |
| **批次 12** | **事件订阅**(file/process/net 主动推送)+ **GUI 宏**(步骤序列回放)+ **Hub 并发多控制端** + 设备分组 | ✅ |
| **批次 12.1** | **Hub 侧按控制端授权**(多令牌 + 按设备白/黑名单 + 注册准入) | ✅ |
| **批次 12.2** | GUI **等待/重试语义**(`wait_ms`)—— 界面加载/动画不再假失败 | ✅ |
| **批次 12.3** | **macOS 端 GUI 对齐**:AppleScript 窗口枚举/聚焦 + Vision OCR 元素定位(含 Retina 坐标换算) | ✅ |
| **批次 12.4** | **事件直达 MCP 宿主**(实时推送 + 拉取双通路) | ✅ |
| **批次 13** | **成功指标聚合**(PRD 2.2 四项)+ 依真实数据修正口径(时延分层) | ✅ |
| **批次 14** | **PowerShell 常驻助手** —— GUI 能力提速 4~25 倍(预加载 C#/UIA,免每次现编译) | ✅ |
| **批次 15** | `screen.find` **图像模板匹配**(纯图标/无文字控件盲区)+ 一堆真机根因修复 | ✅ |
| **批次 16 ~ 26** | 网络两阶段提交 · 音频控制 · 构建指纹 · 自更新 · 证书钉住 · 每客户端 PSK · 连接层三防护 · 零信任安装 · 审计锚定 | ✅ |
| **批次 27 ~ 29** | **语义可断言**(`gui.await` + UIA 属性 + `where` 谓词 + `any_of`)· **状态可回看**(`log.query` + `monitor.*`) | ✅ |
| **发布** | **v2.0.2**(2026-10-11):51 项能力 · 274 单测 · Windows/Rust CI 全绿 —— 本版为**资产修复**(v2.0.1 的 `arm64` 包内实为 x64 node,详见 CHANGELOG) | ✅ 已发布 |
## 文档
- [`docs/SECURITY.md`](docs/SECURITY.md) —— **安全模型与加固指南**(钥匙=root、证书指纹钉住、网段白名单、应急处置)
- [`docs/VERSIONING.md`](docs/VERSIONING.md) —— **版本与兼容性契约**(升级顺序、什么算破坏性、发版流程、更新方式)
- [`docs/REMOTE-LIMITS.md`](docs/REMOTE-LIMITS.md) —— 远程能力边界与真实故障案例(含杀软误拦)
- [`docs/DEPLOY-LINUX.md`](docs/DEPLOY-LINUX.md) —— **Linux 被控端部署**(Debian/Ubuntu/NAS 系统;含实测能力清单与排查)
- [`docs/VM-VERIFICATION.md`](docs/VM-VERIFICATION.md) —— **无 Windows 真机时,用宿主虚拟化平台的 Win11 ARM 虚拟机补验证**(含逐条命令与结论标注规则)
- [`CHANGELOG.md`](CHANGELOG.md) —— **变更日志**(未发布版本的内容汇总 / 历史版本)
- [`docs/ROADMAP.md`](docs/ROADMAP.md) —— **当前进度盘点与下一步路线**(2026-10-07 基线:待办收尾 / 语义可断言 / 状态采样 / 安全残余)
- [产品需求文档(PRD)](./PRD.md)
- [产品需求文档(PRD)](./docs/PRD.md)
- [开发文档(DEVELOPMENT)](./docs/DEVELOPMENT.md) —— 协议细节、能力 schema、安全模型、各端实现指南
- [**跨机接管的边界与局限**](./docs/REMOTE-LIMITS.md) —— 哪些问题能远程修、哪些必须人工,附真实故障诊断案例
- [**实战复盘:短板与改进优先级**](./docs/RETROSPECTIVE.md) —— 基于真机使用的短板分析(GUI 语义 / 编码 / 自愈 / 可观测性)
- [**实战复盘:短板与改进优先级**](./docs/RETROSPECTIVE.md) —— 基于真机使用的短板分析(GUI 语义 / 编码 / 自愈 / 可观测性)。**写于 2026-09-28(19 项能力时),多数 P0/P1 已解决 —— 最新状态见 [ROADMAP](./docs/ROADMAP.md) §3 对照表**
## 关键决策
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/agent",
"version": "1.4.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": {
+196 -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'
@@ -17,7 +24,45 @@ export type AuditType =
/** v21:来源不在 allow_from 被拒 */
| 'net.deny'
/** v19:agent 自更新(拉取式) */
| 'agent.update';
| '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 {
@@ -33,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;
@@ -129,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)) };
}
/** 写入一条审计记录。任何异常都被吞掉 —— 审计绝不阻断主流程。 */
@@ -332,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;
+9 -1
View File
@@ -1,6 +1,8 @@
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { IS_WINDOWS, execCommand } from '../util/exec.js';
import { runPowerShellSmart } from '../util/ps-helper.js';
import { shLit } from '../util/quote.js';
import { assertGuiPlatform } from '../util/platform.js';
type Args = Record<string, unknown>;
@@ -132,7 +134,9 @@ async function runWindows(body: string, timeoutMs = 30_000): Promise<string> {
/** macOS:osascript 原生音量控制(静音用 `output muted`,音量用 `output volume`)。 */
async function runMac(script: string): Promise<string> {
const r = await execCommand({ command: `osascript -e ${JSON.stringify(script)}`, timeoutMs: 15_000 });
// ⚠️ AppleScript 源码里含用户可控文本 → 必须走 shell 单引号字面量。
// 曾用 JSON.stringify(双引号)→ bash 会在双引号内展开 `$(...)` 与反引号(注入面)。
const r = await execCommand({ command: `osascript -e ${shLit(script)}`, timeoutMs: 15_000 });
if (r.exit_code !== 0) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '音频控制失败(osascript)', {
detail: (r.stderr || r.stdout).slice(0, 300),
@@ -160,6 +164,8 @@ async function macState(): Promise<{ muted: boolean; volume: number }> {
const AUDIO_BACKEND_WIN = 'NAudio/CoreAudioAPI';
export async function audioGet(_args: Args): Promise<unknown> {
// 该能力仅在 Windows/macOS 有实现(Linux 端未实现)
assertGuiPlatform('audio.get');
if (IS_WINDOWS) {
const state = parseAudioState(await runWindows('[NAAudio]::Get()'));
return { ...state, platform: 'windows', backend: AUDIO_BACKEND_WIN };
@@ -175,6 +181,8 @@ export async function audioGet(_args: Args): Promise<unknown> {
// ---------------- system.audio.set ----------------
export async function audioSet(args: Args): Promise<unknown> {
// 该能力仅在 Windows/macOS 有实现(Linux 端未实现)
assertGuiPlatform('audio.set');
const muteArg = args['mute'];
const volumeArg = args['volume'];
const hasMute = typeof muteArg === 'boolean';
+364
View File
@@ -0,0 +1,364 @@
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { join } from 'node:path';
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { execCommand } from '../util/exec.js';
import { assertWindows } from '../util/platform.js';
import { agentDir } from '../config.js';
import { audit } from '../audit.js';
/**
* L2 系统级审计兜底(2026-10-17)。
*
* ## 它补的是 L1 结构上记不到的那块
* L1 是「agent 自己记的」:只看得见经过自己 RPC 的调用。
* 攻击者绕过 agent 直接跑系统命令、或干脆把 agent 停掉 —— L1 全都看不到。
* L2 采集**操作系统**的审计事件,补的正是这块。
*
* ## ⚠️ 信任边界(不要夸大它的作用)
* 采集器与被审计对象**同进程同权限** —— 攻击者能停 agent 就能停采集器。
* 故本地 `audit-l2.log` 只是一份**归一化副本**,防篡改价值有限。
* 真正的独立性来自**把记录转发出机**(与 SECURITY.md §4.8 的锚定同一思路)。
* 本模块不实现自动转发,只提供导出接口。
*
* ## 为什么 Windows 侧选内置 auditpol 而不是 Sysmon
* - Sysmon 是第三方驱动:需单独分发、需重启生效、版本行为有差异;
* - 内置高级审计策略是 Windows 自带的,零依赖即可立起来;
* - 策略:检测到装了 Sysmon 就并用,没装也不尝试下载(不在本仓库职责内)。
*
* ## 平台现状
* - Windows:**已落地**(status / enable / collect / verify)
* - Linux:仅设计(auditd,需预装)—— 见 docs/AUDIT-L2.md
* - macOS:仅设计,**ESF 需 Apple entitlement,个人自签无法启用** —— 不写假装能用的代码
*/
type Args = Record<string, unknown>;
/** 归一化后的事件类别(跨平台枚举,详见 docs/AUDIT-L2.md §4)。 */
export type L2Kind =
| 'process.create'
| 'process.exit'
| 'auth.success'
| 'auth.failure'
| 'file.access';
export interface L2Entry {
/** 事件发生时间(来自 OS,不是采集时刻) */
ts: number;
src: 'windows-security' | 'sysmon' | 'linux-auditd' | 'macos-esf';
/** OS 原始事件 ID —— 保留原始值以便人工核对 */
event_id: number | string;
kind: L2Kind;
subject?: string;
object?: string;
detail?: Record<string, unknown>;
/** 采集时刻。与 ts 相差过大说明期间离线或 agent 未运行 */
collected_at: number;
prev?: string;
hash?: string;
}
const GENESIS = 'genesis';
/** L2 独立落盘,不与 L1 混 —— 混了就无法区分「agent 记的」与「OS 记的」。 */
export function l2FilePath(): string {
return join(agentDir(), 'audit-l2.log');
}
function hashEntry(record: Record<string, unknown>, prev: string): string {
const keys = Object.keys(record).sort();
const body = JSON.stringify(record, keys);
return createHash('sha256').update(`${prev}|${body}`).digest('hex').slice(0, 32);
}
function lastHashOf(): string {
try {
const p = l2FilePath();
if (!existsSync(p)) return GENESIS;
const lines = readFileSync(p, 'utf8').split('\n').filter((l) => l.trim());
const last = lines[lines.length - 1];
if (!last) return GENESIS;
return (JSON.parse(last) as L2Entry).hash ?? GENESIS;
} catch {
return GENESIS;
}
}
/** 追加一批 L2 记录(带独立哈希链)。 */
function appendL2(entries: Omit<L2Entry, 'prev' | 'hash' | 'collected_at'>[]): number {
const p = l2FilePath();
mkdirSync(agentDir(), { recursive: true });
let prev = lastHashOf();
const now = Date.now();
let n = 0;
for (const e of entries) {
const full = { ...e, collected_at: now, prev } as unknown as Record<string, unknown>;
const hash = hashEntry(full, prev);
(full as Record<string, unknown>)['hash'] = hash;
appendFileSync(p, `${JSON.stringify(full)}\n`, 'utf8');
prev = hash;
n += 1;
}
return n;
}
// ---------------- Windows:Security 事件 → 归一化 ----------------
/** Windows 事件 ID → 归一化类别。 */
const WIN_EVENT_KIND: Record<number, L2Kind> = {
4688: 'process.create',
4689: 'process.exit',
4624: 'auth.success',
4625: 'auth.failure',
4663: 'file.access',
};
const WIN_WATCHED_IDS = Object.keys(WIN_EVENT_KIND).map(Number);
/**
* 采集 Windows Security 日志。
*
* 只用 PowerShell 内置 cmdlet(Get-WinEvent),不引外部依赖。
* @param sinceMs 起始时间(Unix ms);默认 24 小时内
* @param limit 上限,防止一次拉爆(Security 日志可能极多)
*/
async function collectWindows(sinceMs: number, limit: number): Promise<Omit<L2Entry, 'prev' | 'hash' | 'collected_at'>[]> {
// 用 ISO8601 传时间,避免区域设置导致的日期解析差异
const sinceIso = new Date(sinceMs).toISOString();
const idList = WIN_WATCHED_IDS.join(',');
const ps = [
`$ErrorActionPreference = 'Stop'`,
`$ids = @(${idList})`,
`$since = (Get-Date '${sinceIso}').ToUniversalTime()`,
`$evs = Get-WinEvent -FilterHashtable @{LogName='Security'; Id=$ids; StartTime=$since} -MaxEvents ${limit} -ErrorAction SilentlyContinue`,
`if ($null -eq $evs) { Write-Output '[]'; exit 0 }`,
`$out = foreach ($e in $evs) {`,
` $x = @{ id = $e.Id; t = $e.TimeCreated.ToUniversalTime().ToString('o'); msg = $e.Message }`,
` try { $x.sub = $e.Properties[0].Value.ToString() } catch { $x.sub = '' }`,
` $x | ConvertTo-Json -Compress -Depth 3`,
`}`,
`Write-Output ($out -join "\`n")`,
].join('\n');
const r = await execCommand({ command: ps, timeoutMs: 60_000 });
if (r.exit_code !== 0) {
// 常见:权限不足(非管理员读不了 Security 日志)
throw new CapabilityError(
ErrorCodes.EXECUTION_FAILED,
'读取 Windows Security 日志失败(常见原因:未以管理员运行,或审计策略未启用)',
{ detail: (r.stderr || r.stdout).slice(0, 300) },
);
}
const text = r.stdout.trim();
if (!text || text === '[]') return [];
const out: Omit<L2Entry, 'prev' | 'hash' | 'collected_at'>[] = [];
for (const line of text.split('\n')) {
if (!line.trim()) continue;
try {
const o = JSON.parse(line) as { id?: number; t?: string; msg?: string; sub?: string };
const id = Number(o.id);
const kind = WIN_EVENT_KIND[id];
if (!kind) continue;
const ts = o.t ? Date.parse(o.t) : Date.now();
// 从 Message 里抠进程名(4688 的「新进程名称」),拿不到就留空 —— 不猜
// 括号不可省:TS 不允许 ?? 与 || 无括号混用
const obj =
(/新进程名称:\s*(.+)/.exec(o.msg ?? '')?.[1]?.trim() ??
/进程名称:\s*(.+)/.exec(o.msg ?? '')?.[1]?.trim()) ||
undefined;
out.push({
ts: Number.isFinite(ts) ? ts : Date.now(),
src: 'windows-security',
event_id: id,
kind,
subject: o.sub || undefined,
object: obj,
detail: { raw_message_head: (o.msg ?? '').slice(0, 300) },
});
} catch {
/* 跳过解析失败的单条,不影响整体 */
}
}
return out;
}
// ---------------- 对外能力 ----------------
export async function auditL2(args: Args): Promise<unknown> {
const action = (args['action'] as string | undefined) ?? 'status';
// 平台门控走统一 assertWindows(与 app.* / system.service.list 同一形状):
// ① 错误信息格式统一(能力名 + 未实现平台 + 受支持平台);
// ② 让 console-contract 的平台门控守卫扫得到本能力(裸 throw 会漏检 → 控制台
// WINDOWS_ONLY_CAPS 少列一项 → 非 Windows 被控端上按钮照常显示,点了才报错)。
// 平台现状与 Linux/macOS 未实现的原因见文件头注释与 docs/AUDIT-L2.md。
assertWindows('system.audit.l2');
if (action === 'status') return l2Status();
if (action === 'enable') return await l2Enable();
if (action === 'collect') {
const since = typeof args['since'] === 'number' ? args['since'] : Date.now() - 24 * 3600 * 1000;
const limit = Math.max(1, Math.min(5000, (args['limit'] as number | undefined) ?? 1000));
const entries = await collectWindows(since, limit);
const written = appendL2(entries);
audit({ type: 'agent.l2', status: 'ok', reason: `l2.collect written=${written} since=${new Date(since).toISOString()}` });
return {
collected: entries.length,
written,
since,
limit,
file: l2FilePath(),
note:
'本地 audit-l2.log 只是归一化副本;真正防篡改依赖把记录转发出机(见 docs/AUDIT-L2.md §2)',
};
}
if (action === 'verify') return verifyL2();
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `action 只支持 status|enable|collect|verify,收到 ${action}`);
}
/** 检查 Windows 审计策略是否已启用(并提示命令行记录开关)。 */
async function l2Status(): Promise<unknown> {
const ps = [
`$ErrorActionPreference='Stop'`,
`$o = auditpol /get /category:* /r`,
// /r 输出 CSV:Machine Name,Policy Target,Subcategory,Subcategory GUID,Inclusion Setting,Exclusion Setting
`$rows = $o | ConvertFrom-Csv`,
`$want = @('Process Creation','Logon','Process Termination')`,
`$got = @()`,
`foreach ($w in $want) {`,
` $m = $rows | Where-Object { $_.Subcategory -eq $w } | Select-Object -First 1`,
` if ($m) { $got += ($w + '=' + $m.'Inclusion Setting') } else { $got += ($w + '=not found') }`,
`}`,
`Write-Output ($got -join '; ')`,
`$legacy = (Get-ItemProperty 'HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Lsa' -Name SCENoApplyLegacyAuditPolicy -ErrorAction SilentlyContinue).SCENoApplyLegacyAuditPolicy`,
`Write-Output ("SCENoApplyLegacyAuditPolicy=" + $(if ($null -eq $legacy) { 'not set' } else { $legacy }))`,
`$cmdline = (Get-ItemProperty 'HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Policies\\System\\Audit' -Name ProcessCreationIncludeCmdLine_Enabled -ErrorAction SilentlyContinue).ProcessCreationIncludeCmdLine_Enabled`,
`Write-Output ("ProcessCreationIncludeCmdLine_Enabled=" + $(if ($null -eq $cmdline) { 'not set' } else { $cmdline }))`,
].join('\n');
const r = await execCommand({ command: ps, timeoutMs: 60_000 });
const out = r.stdout || '';
const sysmon = await detectSysmon();
return {
platform: 'windows',
supported: true,
auditpol: out.trim(),
// 不设它,组策略的旧式类别策略会盖掉 subcategory 设置 ——
// 表现为「auditpol 显示已启用,但事件日志里没有 4688」
legacy_policy_disabled: /SCENoApplyLegacyAuditPolicy=1/.test(out),
cmdline_logging: /ProcessCreationIncludeCmdLine_Enabled=1/.test(out),
sysmon_installed: sysmon,
file: l2FilePath(),
file_exists: existsSync(l2FilePath()),
note: r.exit_code !== 0 ? 'auditpol 查询未成功返回(可能需要管理员权限)' : undefined,
};
}
/** 启用 Windows 高级审计策略(需管理员)。 */
async function l2Enable(): Promise<unknown> {
const ps = [
`$ErrorActionPreference='Stop'`,
// ① 让「高级审计策略」生效,覆盖旧式类别策略
`New-ItemProperty -Path 'HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Lsa' -Name SCENoApplyLegacyAuditPolicy -Value 1 -PropertyType DWORD -Force | Out-Null`,
// ② 命令行记录:不开的话 4688 只有进程名,看不到参数
`New-Item -Path 'HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Policies\\System\\Audit' -Force | Out-Null`,
`New-ItemProperty -Path 'HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Policies\\System\\Audit' -Name ProcessCreationIncludeCmdLine_Enabled -Value 1 -PropertyType DWORD -Force | Out-Null`,
// ③ 开启关注的 subcategory
`auditpol /set /subcategory:"进程创建" /success:enable /failure:enable`,
`auditpol /set /subcategory:"登录" /success:enable /failure:enable`,
`auditpol /set /subcategory:"进程终止" /success:enable`,
`Write-Output 'enabled'`,
].join('\n');
const r = await execCommand({ command: ps, timeoutMs: 90_000 });
if (r.exit_code !== 0) {
throw new CapabilityError(
ErrorCodes.EXECUTION_FAILED,
'启用审计策略失败(需要管理员权限)。未提权时请先用管理员身份运行。',
{ detail: (r.stderr || r.stdout).slice(0, 300) },
);
}
audit({ type: 'agent.l2', status: 'ok', reason: 'l2.enable windows audit policy' });
return {
enabled: true,
platform: 'windows',
policies: ['进程创建 4688', '登录 4624/4625', '进程终止 4689'],
cmdline_logging: true,
hint: '策略生效后需要一段时间才会产生事件;用 action=collect 拉取',
};
}
/** 检测 Sysmon 是否安装(装了就并用其日志,没装不尝试下载)。 */
async function detectSysmon(): Promise<boolean> {
const r = await execCommand({
command: `$ErrorActionPreference='SilentlyContinue'; (Get-Service Sysmon -ErrorAction SilentlyContinue).Name`,
timeoutMs: 20_000,
});
return r.stdout.trim() === 'Sysmon';
}
// ---------------- L2 链校验 ----------------
export interface L2VerifyResult {
ok: boolean;
checked: number;
broken_at?: { line: number; reason: string };
}
/** 校验 L2 链完整性(与 L1 同构算法,独立文件独立链)。 */
export function verifyL2(): L2VerifyResult {
const p = l2FilePath();
if (!existsSync(p)) return { ok: true, checked: 0 };
let expected: string | null = null;
let checked = 0;
let line = 0;
try {
for (const raw of readFileSync(p, 'utf8').split('\n')) {
if (!raw.trim()) continue;
line += 1;
let rec: L2Entry;
try {
rec = JSON.parse(raw) as L2Entry;
} catch {
return { ok: false, checked, broken_at: { line, reason: 'JSON 解析失败(条目被破坏)' } };
}
const { hash, ...body } = rec;
if (!hash) continue;
const recalc = hashEntry(body as unknown as Record<string, unknown>, rec.prev ?? GENESIS);
if (recalc !== hash) {
return { ok: false, checked, broken_at: { line, reason: '内容与哈希不符(条目被篡改)' } };
}
if (expected !== null && rec.prev !== expected) {
return { ok: false, checked, broken_at: { line, reason: '链断裂(prev 与上一条哈希不匹配)' } };
}
expected = hash;
checked += 1;
}
} catch {
return { ok: false, checked, broken_at: { line, reason: '读取 L2 文件失败' } };
}
return { ok: true, checked };
}
/** 读取 L2 记录(供导出转发 —— 真正的独立性来自这里,而非本地副本)。 */
export function readL2(limit = 200): L2Entry[] {
const p = l2FilePath();
if (!existsSync(p)) return [];
const out: L2Entry[] = [];
try {
const lines = readFileSync(p, 'utf8').split('\n').filter((l) => l.trim());
for (const l of lines.slice(-Math.max(1, limit))) {
try {
out.push(JSON.parse(l) as L2Entry);
} catch {
/* 跳过损坏行 */
}
}
} catch {
/* 读失败返回已有部分 */
}
return out;
}
+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));
}
}
+5
View File
@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { IS_WINDOWS, execCommand } from '../util/exec.js';
import { assertGuiPlatform } from '../util/platform.js';
type Args = Record<string, unknown>;
@@ -79,6 +80,8 @@ async function writeClipboardWindows(b64: string, expect: string): Promise<void>
// ---------------- clip.get ----------------
export async function clipGet(args: Args = {}): Promise<unknown> {
// 该能力仅在 Windows/macOS 有实现(Linux 端未实现)
assertGuiPlatform('clipboard.get');
const format = (args['format'] as string | undefined) ?? 'auto';
if (IS_WINDOWS) {
@@ -132,6 +135,8 @@ async function getImageWindows(): Promise<Record<string, unknown> | null> {
// ---------------- clip.set ----------------
export async function clipSet(args: Args): Promise<unknown> {
// 该能力仅在 Windows/macOS 有实现(Linux 端未实现)
assertGuiPlatform('clipboard.set');
const image = args['image_base64'] as string | undefined;
const text = args['text'] as string | undefined;
+14 -3
View File
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { IS_WINDOWS, execCommand } from '../util/exec.js';
import { shLit } from '../util/quote.js';
import { agentDir } from '../config.js';
type Args = Record<string, unknown>;
@@ -317,7 +318,15 @@ async function ensureOcrBin(): Promise<string> {
export async function macScreenFind(args: Args): Promise<unknown> {
assertDarwin('screen.find');
const text = args['text'] as string;
if (!text) throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'text 不能为空');
if (!text) {
// macOS 走 Vision OCR,**没有控件属性可读** —— where-only 无法满足,
// 明确说清原因,别让调用方以为是参数写错。
throw new CapabilityError(
ErrorCodes.UNSUPPORTED_PLATFORM,
'macOS 无 UIA 等价物:where 属性过滤不可用,请提供 text(OCR 按文字查找)',
{ platform: 'darwin', engine: 'ocr' },
);
}
const limit = (args['limit'] as number | undefined) ?? 20;
const waitMs = Math.max(0, Math.min(30_000, (args['wait_ms'] as number | undefined) ?? 0));
const intervalMs = Math.max(100, Math.min(2000, (args['interval_ms'] as number | undefined) ?? 400));
@@ -336,7 +345,7 @@ export async function macScreenFind(args: Args): Promise<unknown> {
try {
for (;;) {
const cap = await execCommand({
command: `screencapture -x -t png -R ${captureRegion.x},${captureRegion.y},${captureRegion.width},${captureRegion.height} ${JSON.stringify(shot)}`,
command: `screencapture -x -t png -R ${captureRegion.x},${captureRegion.y},${captureRegion.width},${captureRegion.height} ${shLit(shot)}`,
timeoutMs: 30_000,
});
if (cap.exit_code !== 0) {
@@ -351,8 +360,10 @@ export async function macScreenFind(args: Args): Promise<unknown> {
const scale =
img && captureRegion.width > 0 ? img.width / captureRegion.width : 1;
// ⚠️ `text` 来自控制端,`bin`/`shot` 是内部路径 —— 一律 shLit 包成 shell 单引号字面量。
// 曾用 JSON.stringify(双引号)→ `$(touch /tmp/p)` 会被 bash 执行(P1 D-01,实测复现)。
const r = await execCommand({
command: `${JSON.stringify(bin)} ${JSON.stringify(shot)} ${JSON.stringify(text)}`,
command: `${shLit(bin)} ${shLit(shot)} ${shLit(text)}`,
timeoutMs: 60_000,
maxOutputBytes: 4 * 1024 * 1024,
});
+57 -8
View File
@@ -1,4 +1,4 @@
import { createHash } from 'node:crypto';
import { createHash, randomBytes } from 'node:crypto';
import {
appendFileSync,
closeSync,
@@ -7,6 +7,7 @@ import {
openSync,
readSync,
readdirSync,
realpathSync,
renameSync,
statSync,
writeFileSync,
@@ -26,24 +27,70 @@ const HASH_LIMIT_BYTES = 8 * 1024 * 1024;
let allowedRoots: string[] = [];
export function setFsRoots(roots: string[] | undefined): void {
allowedRoots = (roots ?? []).map((r) => resolve(r));
// 白名单根也做一次 realpath 归一:否则「配置里写的是软链」会与后面 realpath 后的路径对不上
allowedRoots = (roots ?? []).map((r) => realPathBestEffort(resolve(r)));
}
function guardPath(input: string): string {
/**
* 尽力取真实路径(解析符号链接)。
*
* - 路径存在 → 直接 `realpathSync`
* - 不存在(写入场景很常见)→ 向上找到**最近的已存在祖先**做 realpath,再把剩余段拼回去
*
* 这样即便目标文件本身还不存在,也能正确判断「它最终会落在哪里」,
* 从而拦住「用软链接把访问点引到白名单之外」这一类绕过。
*/
function realPathBestEffort(abs: string): string {
try {
return realpathSync.native(abs);
} catch {
/* 继续向上找 */
}
const parts: string[] = [];
let cur = abs;
for (;;) {
const parent = dirname(cur);
if (parent === cur) return abs; // 到根了仍不存在 → 原样返回
parts.unshift(cur.slice(parent.length + (parent.endsWith(sep) ? 0 : 1)));
try {
const base = realpathSync.native(parent);
return parts.length > 0 ? join(base, ...parts) : base;
} catch {
cur = parent;
}
}
}
/**
* 路径白名单守卫(v5)。
*
* ⚠️ 两道防线,缺一不可:
* 1. **解析符号链接后的真实路径**再做包含判断 —— 只比较字面路径时,
* `白名单/a_软链 → /etc` 这样的路径能直接穿出去(绕过 `fs_roots`)。
* 2. 前缀比较必须带上分隔符(`/root` 不得匹配 `/rootX`),且大小写按平台归一
* (Windows 文件系统不区分大小写)。
*/
export function guardPath(input: string): string {
if (typeof input !== 'string' || input.length === 0) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'path 不能为空');
}
const abs = resolve(input);
if (allowedRoots.length === 0) return abs;
const ok = allowedRoots.some((root) => {
if (abs === root) return true;
const real = realPathBestEffort(abs);
const norm = (p: string): string => (process.platform === 'win32' ? p.toLowerCase() : p);
const target = norm(real);
const ok = allowedRoots.some((rootRaw) => {
const root = norm(rootRaw);
if (target === root) return true;
const prefix = root.endsWith(sep) ? root : root + sep;
return abs.startsWith(prefix);
return target.startsWith(prefix);
});
if (!ok) {
throw new CapabilityError(ErrorCodes.ACL_DENIED, `路径不在允许范围内: ${abs}`, {
allowed_roots: allowedRoots,
resolved: real,
});
}
return abs;
@@ -207,8 +254,10 @@ export async function fsWrite(args: Args): Promise<unknown> {
if (append) {
appendFileSync(p, buf);
} else {
// 原子写:先写临时文件再重命名,避免中途失败留下半截文件
const tmp = `${p}.nodeagent-tmp-${process.pid}`;
// 原子写:先写临时文件再重命名,避免中途失败留下半截文件。
// ⚠️ 临时名必须**唯一**:原来只用 PID,同一进程内并发写同一路径(或两个会话同时写)
// 会互相踩对方的 tmp(先完成的 rename 走后,后一个 renameSync 会 ENOENT 失败)。
const tmp = `${p}.nodeagent-tmp-${process.pid}-${randomBytes(4).toString('hex')}`;
writeFileSync(tmp, buf);
renameSync(tmp, p);
}
+27 -1
View File
@@ -1,6 +1,7 @@
import { CapabilityNames } from '@nodeagent/protocol';
import type { AgentConfig } from '../config.js';
import { systemInfo, systemStatus, processList, serviceList, shellExec, auditList, auditVerify, metricsReport, 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';
@@ -13,10 +14,21 @@ import { audioGet, audioSet } from './audio.js';
import { agentUpdate } from './selfupdate.js';
import { mouseMove, mouseClick, mouseScroll, mouseDrag, keyType, keyPress, setInputPolicy } from './input.js';
import { fsList, fsStat, fsRead, fsWrite, setFsRoots } from './fs.js';
import { guiAwait } from './await.js';
import { logQuery, monitorStart, monitorReport, monitorStop, monitorList, monitorDelete } from './monitor.js';
export interface HandlerContext {
/** 连接标识(事件订阅归属 / 断开清理) */
owner: string;
/**
* 调用方身份(`client_id`)。用于**跨控制端隔离**:
* 事件订阅的 list / unwatch / poll 只暴露给「创建它的那个 client_id」。
*
* 为什么按 client_id 而不是连接:同一次工作的多个连接(如 MCP 每次调用新建连接、
* CLI 的 watch 与 poll 分属两个进程)必须能互相看到自己的订阅,否则语义就断了。
* psk 模式下 client_id 由客户端自报(配置级约束);ed25519 + 每客户端独立 PSK 下身份才被真正绑定。
*/
clientId?: string;
/** 向该连接推送事件 */
emit: (evt: unknown) => void;
}
@@ -52,10 +64,24 @@ 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 文件传输
+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;
}
+208 -58
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>;
@@ -128,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 ?? '';
}
// ---------------- 鼠标 ----------------
@@ -155,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;
@@ -182,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';
@@ -201,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);
@@ -211,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;
@@ -230,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;
@@ -256,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');
@@ -303,35 +388,96 @@ export async function keyPress(args: Args): Promise<unknown> {
assertAllowed('input.key.press');
requireWindows('input.key.press');
// v10.2:支持三种语义
// 1) keys —— 单个和弦(如 ["ctrl","c"])
// 2) repeat —— 和弦重复 N 次(解决「连按上键 3 次」这类需求)
// 3) sequence —— 任意和弦序列(如 [["ctrl","c"], ["ctrl","v"]])
const sequence = args['sequence'] as string[][] | undefined;
const repeat = Math.max(1, Math.min(50, (args['repeat'] as number | undefined) ?? 1));
const keys = args['keys'] as string[] | undefined;
// v1.5.0:入参统一经 protocol 的 expandChords 展开 —— 字符串热键 / 预设名 / 老数组形式
// 全部收敛成「归一化和弦序列」。混用多种形式会直接报错,避免"以谁为准"的歧义。
let expanded;
try {
expanded = expandChords({
hotkey: args['hotkey'] as string | undefined,
hotkeys: args['hotkeys'] as string[] | undefined,
preset: args['preset'] as string | undefined,
presets: args['presets'] as string[] | undefined,
keys: args['keys'] as string[] | undefined,
sequence: args['sequence'] as string[][] | undefined,
repeat: args['repeat'] as number | undefined,
});
} catch (err) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `快捷键参数错误: ${err instanceof Error ? err.message : String(err)}`);
}
const chords = expanded.chords;
const presets = expanded.presets;
const chords: string[][] = sequence?.length
? sequence
: keys?.length
? Array.from({ length: repeat }, () => keys)
: [];
if (chords.length === 0) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, '需提供 keys(可配 repeat)或 sequence');
const gap = Math.max(0, Math.min(2000, (args['interval_ms'] as number) ?? 40));
// v1.5.0:长按(按下到释放的保持时长),用于"长按音量/持续按住方向键"等场景
const holdMs = Math.max(0, Math.min(5000, Math.round((args['hold_ms'] as number | undefined) ?? 0)));
// v1.5.0:投放路由。foreground = SendInput 注入当前焦点(默认,旧行为);
// post = 向目标进程全部顶层窗口 PostMessage(窗口在后台时可用,但兼容性差)
const route = (args['route'] as string | undefined) ?? 'foreground';
if (route !== 'foreground' && route !== 'post') {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `不支持的 route: ${route}`, { allowed: ['foreground', 'post'] });
}
const pid = (args['target_pid'] as number) ?? 0;
if (route === 'post' && pid <= 0) {
throw new CapabilityError(
ErrorCodes.PARAM_INVALID,
'route=post 需要提供 target_pid(≥1)—— 后台投递必须知道投给哪个进程',
{ hint: '用 window.list 取目标进程 PID;无目标时请用默认 route=foreground' },
);
}
const body: string[] = [];
const gap = Math.max(0, Math.min(2000, (args['interval_ms'] as number | undefined) ?? 40));
for (let i = 0; i < chords.length; i += 1) {
const chord = chords[i]!;
if (chord.length === 0) continue;
const vks = chord.map(toVk);
for (const vk of vks) body.push(`[NAInput]::Vk(${vk}, $false)`);
// 逆序释放,保证组合键正确
for (const vk of [...vks].reverse()) body.push(`[NAInput]::Vk(${vk}, $true)`);
if (i < chords.length - 1 && gap > 0) body.push(`Start-Sleep -Milliseconds ${gap}`);
// 媒体键走 WM_APPCOMMAND 通道(2026-10-05 补)
//
// 仅当**整条序列都是媒体键**时启用。若混在普通按键里(如 ["ctrl","media_play_pause"])
// 则回退到 VK 注入 —— 那种组合场景本就罕见,SendInput 足够。
const allMedia = isAllMedia(chords);
if (allMedia) {
// target_pid 非 0 时定向投递到该进程的全部顶层窗口(部分播放器会建数十个辅助窗口,
// 只投主窗口常常无效 —— 实测踩过);否则广播。
const mediaBody = buildMediaBody(chords, { gapMs: gap, holdMs, targetPid: pid, route });
await runPs(mediaBody);
return {
pressed: true,
keys: chords.length === 1 ? chords[0] : undefined,
chords,
times: chords.length,
presets,
via: expanded.via,
channel: 'appcommand',
target_pid: pid || null,
};
}
await runPs(body);
// v1.5.0:后端定向投递(route=post)—— 目标窗口不在前台时使用
if (route === 'post') {
let sent = 0;
const out = await runPs(buildPostBody(chords, { gapMs: gap, holdMs, targetPid: pid, route }));
// 累计投递成功的窗口数(C# 侧每次 PostMessage 成功计数)
let total = 0;
for (const line of out.split('\n')) {
const m = /(\d+)/.exec(line.trim());
if (m) total += Number(m[1]);
}
sent = total;
return {
pressed: true,
keys: chords.length === 1 ? chords[0] : undefined,
chords,
times: chords.length,
presets,
via: expanded.via,
channel: 'postmessage',
route,
target_pid: pid,
sent_windows: sent,
note:
sent === 0
? '未投递到任何窗口(进程可能已退出或没有任何顶层窗口)'
: '后台投递兼容性有限:游戏/部分浏览器与输入型程序不响应后台键盘消息,' +
'无效时改用 route=foreground 并确保目标窗口可见',
};
}
await runPs(buildForegroundBody(chords, { gapMs: gap, holdMs, targetPid: pid, route }));
// 同时回传 keys(单和弦时的回显)与 chords(完整序列)。
// 修正 2026-10-04:此前只回 chords,控制端 CLI 读 keys.join() 直接崩溃。
return {
@@ -339,5 +485,9 @@ export async function keyPress(args: Args): Promise<unknown> {
keys: chords.length === 1 ? chords[0] : undefined,
chords,
times: chords.length,
presets,
via: expanded.via,
hold_ms: holdMs || undefined,
route,
};
}
+151
View File
@@ -0,0 +1,151 @@
/**
* log.query(v2.0.0,方向二「状态」第一块)。
*
* ## 为什么必须有
* 治间歇性问题(代理不通、杀软拦截、端口时开时闭)要看**历史**,而被控端日志常在
* 几万行以上。`fs.read` 虽有 8MB 上限,但"整份拉回再筛"既慢又占带宽 ——
* 过滤必须发生在 **Windows 侧**。
*
* ## 实现要点
* - 流式逐行读(readline),**不全量载入内存**;扫到 limit 条即停
* - 三级过滤:`pattern`(正则,大小写不敏感)/ `level`(ERROR|WARN|INFO|DEBUG,
* 命中行内含 `[LEVEL]` 或 `LEVEL:` 即算)/ `since`(Unix ms,按行首时间戳或
* 文件 mtime 粗筛)
* - 路径仍走 `fs.guardPath` 的白名单(fs_roots 非空即受限)—— 不新开安全面
* - 单行超长保护:超过 4KB 的行截断后再匹配,避免一行巨型 JSON 拖垮内存
*/
import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';
import { statSync } from 'node:fs';
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { guardPath } from './fs.js';
type Args = Record<string, unknown>;
const MAX_LINE = 4096;
const MAX_SCAN = 2_000_000; // 单次查询最多扫 200 万行,防失控文件拖死 agent
export interface LogLine {
/** 行号(从 1 开始)—— 回看时便于对照原文件 */
n: number;
text: string;
}
export interface LogQueryResult {
path: string;
matched: LogLine[];
/** 实际扫描过的行数(用于判断"是扫完了还是被 limit 截住") */
scanned: number;
/** 是否因达到 MAX_SCAN 提前结束 */
truncated: boolean;
note?: string;
}
/** 从行里推断日志级别(找不到返回 null,不猜)。 */
function detectLevel(line: string): string | null {
const m = /\[(ERROR|WARN|WARNING|INFO|DEBUG|TRACE)\]|\b(ERROR|WARN|INFO|DEBUG|TRACE):/i.exec(line);
if (!m) return null;
const raw = (m[1] ?? m[2] ?? '').toUpperCase();
return raw === 'WARNING' ? 'WARN' : raw;
}
export async function logQuery(args: Args): Promise<LogQueryResult> {
const p = guardPath(args['path'] as string);
const limit = Math.max(1, Math.min(5000, (args['limit'] as number | undefined) ?? 100));
const offset = Math.max(0, (args['offset'] as number | undefined) ?? 0);
const patternStr = args['pattern'] as string | undefined;
const level = (args['level'] as string | undefined)?.toUpperCase();
const since = args['since'] as number | undefined;
const tail = (args['tail'] as boolean | undefined) ?? false;
let re: RegExp | null = null;
if (patternStr) {
try {
re = new RegExp(patternStr, 'i');
} catch (err) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `pattern 不是合法正则: ${patternStr}`, {
detail: err instanceof Error ? err.message : String(err),
});
}
}
if (level && !['ERROR', 'WARN', 'INFO', 'DEBUG', 'TRACE'].includes(level)) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `不支持的 level: ${level}`, {
allowed: ['ERROR', 'WARN', 'INFO', 'DEBUG', 'TRACE'],
});
}
let st;
try {
st = statSync(p);
} catch {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `文件不存在或不可读: ${p}`);
}
if (!st.isFile()) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `不是普通文件: ${p}`);
}
// since 粗筛:文件在 since 之前就没改过,必然没有更新的行 —— 省一次全扫
if (since !== undefined && st.mtimeMs < since) {
return { path: p, matched: [], scanned: 0, truncated: false, note: '文件 mtime 早于 since,未扫描' };
}
// tail 模式:保留最后 limit 条命中的环形缓冲(边扫边丢旧的)
const ring: LogLine[] = [];
let matched = 0;
let scanned = 0;
let truncated = false;
const rl = createInterface({
input: createReadStream(p, { encoding: 'utf8' }),
crlfDelay: Number.POSITIVE_INFINITY,
});
for await (const rawLine of rl) {
scanned += 1;
if (scanned > MAX_SCAN) {
truncated = true;
break;
}
let line = rawLine;
if (line.length > MAX_LINE) line = `${line.slice(0, MAX_LINE)}…[截断]`;
if (re && !re.test(line)) continue;
if (level && detectLevel(line) !== level) continue;
// since 精确筛:只在行首能解出时间戳时才按时间比(解不出就放行,交由调用方判断)
if (since !== undefined) {
const ts = parseLeadingTs(line);
if (ts !== null && ts < since) continue;
}
matched += 1;
if (tail) {
ring.push({ n: scanned, text: line });
if (ring.length > limit) ring.shift();
} else {
if (matched > offset && matched <= offset + limit) ring.push({ n: scanned, text: line });
if (matched > offset + limit) break; // 已取够,提前收工
}
}
return {
path: p,
matched: ring,
scanned,
truncated,
...(truncated ? { note: `扫描超过 ${MAX_SCAN} 行上限,结果可能不完整` } : {}),
};
}
/**
* 行首时间戳 → Unix ms(支持 ISO 与常见 `[2026-10-07 12:34:56]` 形态;解不出返回 null)。
*
* ⚠️ 必须**连同时区后缀一起解析**:只截取到秒会丢掉 `Z` / `+08:00`,
* 让 UTC 时间被当本地时间读 —— 实测会整体偏 8 小时(真机日志跨时区时判断"何时断的"就错了)。
*/
export function parseLeadingTs(line: string): number | null {
const m = /^\[?(\d{4}[-/]\d{2}[-/]\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:?\d{2})?)/.exec(line);
if (!m) return null;
// 无时区信息时按本地时间解析(与 `[2026-10-07 10:00:12]` 这类本地日志的语义一致);
// 带 Z / ±hh:mm 时 Date.parse 自行按该时区处理 —— 关键是**别把后缀截掉**。
const iso = m[1]!.replace(/\//g, '-').replace(' ', 'T');
const t = Date.parse(iso);
return Number.isFinite(t) ? t : null;
}
+411
View File
@@ -0,0 +1,411 @@
/**
* monitor.*(v2.0.0,方向二「状态」第二块)—— 定时采样落盘与回看。
*
* ## 为什么需要(复盘 §四)
* 代理不通、杀软拦截、端口时开时闭都是**间歇性**的,而 `system.status` 只能看当下快照。
* `event.watch` 解决「有事件时通知」,没解决「**持续记录,事后能查**」。
*
* ## 设计
* - 四类采样源:`port`(连通性 + 延迟)/ `process`(存活 + PID 列表)/
* `command`(退出码 + 首行输出)/ `metric`(CPU/内存)
* - 落盘 `<数据目录>/monitors/<id>.jsonl`(一行一样本,追加写 + 立即 flush 语义)
* - `monitor.report` 读回并给摘要:min/max/均值、断线次数、最长中断 —— 直接回答
* 「5 分钟里断过几次、最长断了多久」
* - 并发上限 8 个;单文件 10 万行封顶(防写满磁盘)
* - 采样循环 `unref()`,不阻止 agent 退出;`monitor.stop` 或进程退出即停
*/
import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { connect } from 'node:net';
import { exec, execFile } from 'node:child_process';
import { join, resolve as resolvePath, sep } from 'node:path';
import { homedir, totalmem, freemem, cpus } from 'node:os';
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { logQuery, type LogLine } from './log.js';
type Args = Record<string, unknown>;
export type MonitorSource = 'port' | 'process' | 'command' | 'metric';
interface Sample {
ts: number;
/** 各源自定义字段 */
[k: string]: unknown;
}
interface MonitorRecord {
id: string;
source: MonitorSource;
target: string;
intervalMs: number;
createdAt: number;
samples: number;
file: string;
timer: ReturnType<typeof setInterval>;
/** 首次采样失败时记下,避免每次 tick 都刷错误 */
lastError: string | null;
}
const monitors = new Map<string, MonitorRecord>();
const MAX_MONITORS = 8;
const MAX_SAMPLES_PER_FILE = 100_000;
function dataDir(): string {
return process.env['NODEAGENT_HOME'] ?? join(homedir(), '.nodeagent');
}
function monitorDir(): string {
const d = join(dataDir(), 'monitors');
if (!existsSync(d)) mkdirSync(d, { recursive: true });
return d;
}
/**
* 校验监控 id:**唯一入口**,四个命令(start/report/stop/delete)都必须先过它。
*
* ⚠️ M-01/M-02(真机实测):`monitor.report` / `monitor.delete` 曾经**不校验 id**
* 就直接 `join(monitorDir(), id + '.jsonl')` → `id:"../escape-target"` 可读/删
* 数据目录外的任意 `.jsonl` 文件(路径穿越)。
* 这里除了字符白名单,还做一次**解析后前缀比对**(`..` 与符号链接都拦得住)。
*/
function safeMonitorId(raw: unknown): string {
const id = typeof raw === 'string' ? raw.trim() : '';
if (!id) throw new CapabilityError(ErrorCodes.PARAM_INVALID, '需要 id');
if (!/^[\w.-]{1,64}$/.test(id) || id.includes('..')) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'id 只允许字母数字与 . _ -(≤64 字符,不含 ..)', {
got: id,
});
}
return id;
}
/** 监控样本文件路径 —— id 已在 safeMonitorId 校验过,这里再兜一次「必须在 monitors 目录内」。 */
function monitorFile(id: string): string {
const dir = resolvePath(monitorDir());
const file = resolvePath(dir, `${id}.jsonl`);
if (!file.startsWith(dir + sep)) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'id 解析后越出 monitors 目录', { id });
}
return file;
}
// ---------------- 采样器 ----------------
/** port:TCP 连通性 + 握手耗时(跨平台,无额外依赖)。 */
function samplePort(host: string, port: number, timeoutMs: number): Promise<Sample> {
return new Promise((resolve) => {
const started = Date.now();
const sock = connect({ host, port });
const done = (up: boolean, err?: string): void => {
sock.destroy();
resolve({ ts: Date.now(), up, ms: Date.now() - started, ...(err ? { error: err } : {}) });
};
sock.setTimeout(timeoutMs);
sock.once('connect', () => done(true));
sock.once('timeout', () => done(false, 'timeout'));
sock.once('error', (e) => done(false, String(e.message ?? e)));
});
}
/** process:按名判断存活(Windows tasklist / POSIX ps,与 events.ts 同策略)。 */
function sampleProcess(name: string): Promise<Sample> {
return new Promise((resolve) => {
// ⚠️ D-02(真机实测):原来把 `name` 拼进 `tasklist /FI "IMAGENAME eq ${name}"` 与
// `ps … | grep -i "${name}"`,payload `zzz"; touch /tmp/m…; #` 会被 shell 执行。
// 正解:**不经过 shell** —— Windows 用 execFile 传参数数组,POSIX 直接把 ps 输出
// 交给 JS 过滤(不再 grep)。
const collect = (stdout: string, err: Error | null): void => {
const lines = stdout
.split('\n')
.map((l) => l.trim())
.filter((l) => l.length > 0 && !/^INFO:/i.test(l));
const pids = lines
.map((l) => {
const m = /^(\d+)/.exec(l);
return m ? Number(m[1]) : null;
})
.filter((x): x is number => x !== null);
resolve({
ts: Date.now(),
alive: pids.length > 0,
pids,
...(err && pids.length === 0 ? { error: String(err.message) } : {}),
});
};
if (process.platform === 'win32') {
// 无 shell:name 作为单个参数传入,`"` `;` `&` 等都不再具特殊含义
execFile('tasklist', ['/FI', `IMAGENAME eq ${name}`, '/NH'], { timeout: 8000 }, (err, stdout) =>
collect(stdout ?? '', err),
);
return;
}
execFile('ps', ['-axo', 'pid=,comm='], { timeout: 8000 }, (err, stdout) => {
const needle = name.toLowerCase();
const filtered = (stdout ?? '')
.split('\n')
.filter((l) => l.toLowerCase().includes(needle))
.join('\n');
collect(filtered, err);
});
});
}
/** command:退出码 + 输出首行(用于"跑个探针命令"这类自定义采样)。 */
function sampleCommand(command: string, timeoutMs: number): Promise<Sample> {
return new Promise((resolve) => {
exec(command, { timeout: timeoutMs }, (err, stdout, stderr) => {
const first = (stdout || stderr || '').split('\n')[0]?.slice(0, 500) ?? '';
resolve({
ts: Date.now(),
exit_code: err ? (typeof err.code === 'number' ? err.code : 1) : 0,
first_line: first,
});
});
});
}
/** metric:CPU(两次采样差分)+ 内存。 */
async function sampleMetric(): Promise<Sample> {
const a = cpus();
await new Promise((r) => setTimeout(r, 200));
const b = cpus();
const idle = (c: typeof a): number =>
c.reduce((s, x) => s + x.times.idle + x.times.irq, 0) / c.length;
const total = (c: typeof a): number =>
c.reduce((s, x) => s + Object.values(x.times).reduce((p, q) => p + q, 0), 0) / c.length;
const idleDiff = idle(b) - idle(a);
const totalDiff = total(b) - total(a);
const cpu = totalDiff > 0 ? Math.round((1 - idleDiff / totalDiff) * 10000) / 100 : 0;
const mem = totalmem();
const free = freemem();
return { ts: Date.now(), cpu_pct: cpu, mem_pct: Math.round(((mem - free) / mem) * 10000) / 100 };
}
async function takeSample(rec: MonitorRecord): Promise<Sample> {
switch (rec.source) {
case 'port': {
const [host, portStr] = rec.target.split(':');
return samplePort(host || '127.0.0.1', Number(portStr), Math.min(rec.intervalMs, 5000));
}
case 'process':
return sampleProcess(rec.target);
case 'command':
return sampleCommand(rec.target, Math.min(rec.intervalMs * 2, 20_000));
case 'metric':
return sampleMetric();
}
}
// ---------------- 能力实现 ----------------
export async function monitorStart(args: Args): Promise<unknown> {
const source = args['source'] as MonitorSource | undefined;
if (!source || !['port', 'process', 'command', 'metric'].includes(source)) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `不支持的 source: ${String(source)}`, {
allowed: ['port', 'process', 'command', 'metric'],
hint: 'port 需 target="host:port";process 需 target=进程名;command 需 target=命令;metric 不需要 target',
});
}
const intervalMs = Math.max(500, Math.min(600_000, (args['interval_ms'] as number | undefined) ?? 2000));
const target = (args['target'] as string | undefined) ?? '';
if (source !== 'metric' && !target) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `source=${source} 需要 target`);
}
if (source === 'port' && !/^.+:\d+$/.test(target)) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'port 采样的 target 应为 "host:port"', { got: target });
}
if (monitors.size >= MAX_MONITORS) {
throw new CapabilityError(ErrorCodes.RATE_LIMITED, `并发监控上限 ${MAX_MONITORS} 个`, {
running: [...monitors.keys()],
});
}
const id =
(args['id'] as string | undefined)?.trim() ||
`m_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
safeMonitorId(id); // 与 report/delete 同一套校验(防路径穿越 + 统一口径)
if (monitors.has(id)) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `监控 id 已存在: ${id}`, { hint: '换个 id,或先 monitor.stop' });
}
const file = monitorFile(id);
const rec: MonitorRecord = {
id, source, target, intervalMs, createdAt: Date.now(), samples: 0, file,
timer: setInterval(() => { void tick(rec); }, intervalMs),
lastError: null,
};
monitors.set(id, rec);
// 立即采第一针(调用方往往想马上知道"现在通不通"),不额外等一个 interval
void tick(rec);
return { id, source, target, interval_ms: intervalMs, file, started_at: rec.createdAt };
}
async function tick(rec: MonitorRecord): Promise<void> {
try {
const s = await takeSample(rec);
rec.lastError = null;
appendFileSync(rec.file, `${JSON.stringify(s)}\n`, 'utf8');
rec.samples += 1;
// 行数封顶:超过就从头截断(保留后半),防止磁盘写满
if (rec.samples > MAX_SAMPLES_PER_FILE) {
const lines = readFileSync(rec.file, 'utf8').trim().split('\n');
writeFileSync(rec.file, `${lines.slice(-MAX_SAMPLES_PER_FILE).join('\n')}\n`, 'utf8');
rec.samples = MAX_SAMPLES_PER_FILE;
}
} catch (err) {
rec.lastError = err instanceof Error ? err.message : String(err);
// 采样失败也记一行("查不到"本身也是信息),但不无限刷盘
if (rec.samples < MAX_SAMPLES_PER_FILE) {
appendFileSync(rec.file, `${JSON.stringify({ ts: Date.now(), error: rec.lastError })}\n`, 'utf8');
rec.samples += 1;
}
}
}
export async function monitorReport(args: Args): Promise<unknown> {
const id = safeMonitorId(args['id']);
const rec = monitors.get(id);
const file = monitorFile(id);
if (!rec && !existsSync(file)) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `没有这个监控: ${id}`, { running: [...monitors.keys()] });
}
const since = args['since'] as number | undefined;
const limit = Math.max(1, Math.min(20_000, (args['limit'] as number | undefined) ?? 5000));
const samples: Sample[] = [];
let truncated = false;
if (existsSync(file)) {
const lines = readFileSync(file, 'utf8').split('\n');
for (const line of lines) {
if (!line.trim()) continue;
try {
const s = JSON.parse(line) as Sample;
if (since !== undefined && s.ts < since) continue;
samples.push(s);
} catch {
/* 半行(写入中断)跳过 */
}
}
if (samples.length > limit) {
samples.splice(0, samples.length - limit);
truncated = true;
}
}
return {
id,
running: rec !== undefined,
source: rec?.source ?? 'unknown',
target: rec?.target ?? '',
interval_ms: rec?.intervalMs,
file,
samples: samples.length,
truncated,
last_error: rec?.lastError ?? undefined,
summary: summarize(samples, rec?.source ?? 'port'),
series: samples,
};
}
/** 摘要:把样本压成"人话"—— 断几次、最长多久、值域。 */
function summarize(samples: Sample[], source: MonitorSource): Record<string, unknown> {
if (samples.length === 0) return { n: 0 };
const base: Record<string, unknown> = { n: samples.length };
const ts = samples.map((s) => s.ts);
base.window_ms = Math.max(...ts) - Math.min(...ts);
if (source === 'port') {
const ups = samples.filter((s) => s.up === true);
base.up_count = ups.length;
base.down_count = samples.length - ups.length;
const lat = samples.map((s) => Number(s.ms ?? 0)).filter((x) => Number.isFinite(x));
if (lat.length) {
base.ms_min = Math.min(...lat);
base.ms_max = Math.max(...lat);
base.ms_avg = Math.round((lat.reduce((a, b) => a + b, 0) / lat.length) * 100) / 100;
}
// 最长连续中断
let run = 0;
let worst = 0;
for (const s of samples) {
if (s.up === true) { run = 0; } else { run += 1; worst = Math.max(worst, run); }
}
base.longest_outage_samples = worst;
} else if (source === 'process') {
base.alive_count = samples.filter((s) => s.alive === true).length;
base.dead_count = samples.filter((s) => s.alive !== true).length;
let run = 0;
let worst = 0;
for (const s of samples) {
if (s.alive === true) { run = 0; } else { run += 1; worst = Math.max(worst, run); }
}
base.longest_dead_samples = worst;
} else if (source === 'command') {
const codes = samples.map((s) => Number(s.exit_code ?? -1));
base.ok_count = codes.filter((c) => c === 0).length;
base.fail_count = codes.filter((c) => c !== 0).length;
base.exit_codes_seen = [...new Set(codes)];
} else {
const cpu = samples.map((s) => Number(s.cpu_pct ?? 0));
const mem = samples.map((s) => Number(s.mem_pct ?? 0));
if (cpu.length) {
base.cpu_min = Math.min(...cpu); base.cpu_max = Math.max(...cpu);
base.cpu_avg = Math.round((cpu.reduce((a, b) => a + b, 0) / cpu.length) * 100) / 100;
}
if (mem.length) {
base.mem_min = Math.min(...mem); base.mem_max = Math.max(...mem);
base.mem_avg = Math.round((mem.reduce((a, b) => a + b, 0) / mem.length) * 100) / 100;
}
}
return base;
}
export async function monitorStop(args: Args): Promise<unknown> {
const id = safeMonitorId(args['id']);
const rec = monitors.get(id);
if (!rec) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `没有正在运行的监控: ${id}`, { running: [...monitors.keys()] });
}
clearInterval(rec.timer);
monitors.delete(id);
const report = (await monitorReport({ id })) as { samples: number; summary: Record<string, unknown> };
return { id, stopped: true, samples: report.samples, summary: report.summary, file: rec.file };
}
export async function monitorList(_args: Args): Promise<unknown> {
const running = [...monitors.values()].map((r) => ({
id: r.id, source: r.source, target: r.target, interval_ms: r.intervalMs,
samples: r.samples, started_at: r.createdAt, file: r.file, last_error: r.lastError ?? undefined,
}));
const dir = monitorDir();
let files: string[] = [];
try {
files = (await import('node:fs')).readdirSync(dir).filter((f) => f.endsWith('.jsonl'));
} catch {
/* 目录不存在即无历史 */
}
return {
running,
running_count: running.length,
history_files: files.map((f) => f.replace(/\.jsonl$/, '')),
};
}
/** 供 monitor.delete:删除历史文件(不影响运行中的实例)。 */
export async function monitorDelete(args: Args): Promise<unknown> {
const id = safeMonitorId(args['id']);
if (monitors.has(id)) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, '该监控仍在运行,请先 stop', { hint: `monitor.stop {"id":"${id}"}` });
}
const file = monitorFile(id);
if (!existsSync(file)) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `没有历史文件: ${id}`);
}
rmSync(file, { force: true });
return { id, deleted: true, file };
}
// log.query 与 monitor 同属"状态"方向,放在同一能力文件里导出
export { logQuery };
export type { LogLine };
+24 -23
View File
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFile
import { join } from 'node:path';
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { IS_WINDOWS, execCommand } from '../util/exec.js';
import { psLit as psLitShared, shLit } from '../util/quote.js';
import { agentDir } from '../config.js';
import { audit } from '../audit.js';
import { extractPsError } from '../util/ps-helper.js';
@@ -59,7 +60,7 @@ async function runPsFile(script: string, timeoutMs: number, tag = 'ps'): Promise
const path = join(dir, `${tag}-${Date.now()}.ps1`);
writeFileSync(path, withBom(script), 'utf8');
return execCommand({
command: `powershell -NoProfile -ExecutionPolicy Bypass -File ${JSON.stringify(path)}`,
command: `powershell -NoProfile -ExecutionPolicy Bypass -File ${psLit(path)}`,
timeoutMs,
});
}
@@ -68,7 +69,7 @@ async function runPsFile(script: string, timeoutMs: number, tag = 'ps'): Promise
* 生成的 .ps1 **必须带 UTF-8 BOM**。
*
* ⚠️ 真机踩过:PowerShell 5.1 读**无 BOM** 的 .ps1 会按系统 ANSI(GBK) 解析,
* 脚本里的中文(如网卡名「Ethernet」)会吃掉后面的闭合引号 →
* 脚本里的非 ASCII 字符(如中文网卡名)会吃掉后面的闭合引号 →
* 报「The string is missing the terminator」→ 计划任务退出码 1 且不留日志。
* 所有落盘的 PowerShell 脚本一律经此函数。
*/
@@ -83,17 +84,17 @@ export function withBom(content: string): string {
* 曾经用 JSON.stringify(双引号)或手动把 \ 翻倍当转义,结果生成 `C:\\Users\\...`
* 这种非法路径 → Out-File 失败 → 计划任务退出码 1 且不留日志(真机踩过)。
* 需要字面量时一律走本函数;需要插值时用双引号但避免反斜杠。
*
* 注:实现已收敛到 `util/quote.ts`(单一真源),此处保留同名导出以兼容既有引用与测试。
*/
export function psLit(v: string): string {
return `'${v.replace(/'/g, "''")}'`;
}
export const psLit = psLitShared;
/**
* 生成「注册一次性计划任务」的 PowerShell 脚本(唯一实现,两处复用)。
*
* ⚠️ 两个真机教训都固化在这里:
* 1) **账号名必须用 WindowsIdentity 取**,不要用 "$env:USERDOMAIN\$env:USERNAME" 拼 ——
* 在 TS 模板串里 `\$` 会被当转义吃掉反斜杠,拼出「机器名<user>」这种不可解析的账号,
* 在 TS 模板串里 `\$` 会被当转义吃掉反斜杠,拼出「机器名+纯数字」这种不可解析的账号,
* Register-ScheduledTask 报 HRESULT 0x80070534「No mapping between account names...」。
* 2) 复用同一实现:此前回滚/延时两处各写一份,改了一处漏了另一处(真机踩过)。
*/
@@ -173,7 +174,7 @@ export interface IfaceSnapshot {
async function snapshotIface(iface: string): Promise<IfaceSnapshot> {
const script = `
$ErrorActionPreference='Continue'
$a = ${JSON.stringify(iface)}
$a = ${psLit(iface)}
$o = [ordered]@{ alias = $a; dhcp = $false; ipv4 = @(); gateway = @(); dns = @() }
$ipif = Get-NetIPInterface -InterfaceAlias $a -AddressFamily IPv4 -ErrorAction SilentlyContinue
if ($ipif) { $o.dhcp = ($ipif.Dhcp -eq 'Enabled') }
@@ -210,7 +211,7 @@ $o | ConvertTo-Json -Compress -Depth 4
/** 由结构化快照生成**原生 cmdlet** 回滚脚本(不用 netsh set —— 它在 Win11 上会挂起)。 */
export function buildRollbackScript(snap: IfaceSnapshot, backupTxt: string, logPath: string): string {
const a = JSON.stringify(snap.alias);
const a = psLit(snap.alias);
const L = psLit(logPath);
const lines = [
`# nodeagent 自动回滚(生成于 ${new Date().toISOString()})`,
@@ -229,23 +230,23 @@ export function buildRollbackScript(snap: IfaceSnapshot, backupTxt: string, logP
// 同样「先加后清 + 先清默认路由」——否则回滚本身也会踩 already exists(真机事故根因)
for (const addr of snap.ipv4) {
lines.push(
` New-NetIPAddress -InterfaceAlias ${a} -IPAddress ${JSON.stringify(addr.ip)} -PrefixLength ${addr.prefix} -ErrorAction SilentlyContinue | Out-Null`,
` New-NetIPAddress -InterfaceAlias ${a} -IPAddress ${psLit(addr.ip)} -PrefixLength ${addr.prefix} -ErrorAction SilentlyContinue | Out-Null`,
);
}
lines.push(` Get-NetRoute -InterfaceAlias ${a} -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue | Remove-NetRoute -Confirm:$false -ErrorAction SilentlyContinue`);
const keep = snap.ipv4.map((x) => JSON.stringify(x.ip)).join(',');
const keep = snap.ipv4.map((x) => psLit(x.ip)).join(',');
lines.push(` $keep = @(${keep})`);
lines.push(` Get-NetIPAddress -InterfaceAlias ${a} -AddressFamily IPv4 -ErrorAction SilentlyContinue |`);
lines.push(` Where-Object { $keep -notcontains $_.IPAddress -and $_.PrefixOrigin -ne 'WellKnown' } |`);
lines.push(` ForEach-Object { Remove-NetIPAddress -IPAddress $_.IPAddress -InterfaceAlias ${a} -Confirm:$false -ErrorAction SilentlyContinue }`);
if (snap.gateway[0]) {
lines.push(
` New-NetRoute -InterfaceAlias ${a} -DestinationPrefix '0.0.0.0/0' -NextHop ${JSON.stringify(snap.gateway[0])} -ErrorAction SilentlyContinue | Out-Null`,
` New-NetRoute -InterfaceAlias ${a} -DestinationPrefix '0.0.0.0/0' -NextHop ${psLit(snap.gateway[0])} -ErrorAction SilentlyContinue | Out-Null`,
);
}
if (snap.dns.length > 0) {
lines.push(
` Set-DnsClientServerAddress -InterfaceAlias ${a} -ServerAddresses ${snap.dns.map((d) => JSON.stringify(d)).join(',')} -ErrorAction SilentlyContinue`,
` Set-DnsClientServerAddress -InterfaceAlias ${a} -ServerAddresses ${snap.dns.map((d) => psLit(d)).join(',')} -ErrorAction SilentlyContinue`,
);
}
lines.push(` W 'restored: static'`);
@@ -260,14 +261,14 @@ export function buildRollbackScript(snap: IfaceSnapshot, backupTxt: string, logP
async function readIfaceWindows(iface: string): Promise<Record<string, unknown>> {
const script = `
$ErrorActionPreference='Continue'
$c = Get-NetIPConfiguration -InterfaceAlias ${JSON.stringify(iface)} -ErrorAction SilentlyContinue
$c = Get-NetIPConfiguration -InterfaceAlias ${psLit(iface)} -ErrorAction SilentlyContinue
if (-not $c) { Write-Output '{}'; return }
$o = [ordered]@{ interface = ${JSON.stringify(iface)} }
$o = [ordered]@{ interface = ${psLit(iface)} }
$o.ipv4 = @($c.IPv4Address | ForEach-Object { $_.IPAddress })
$o.prefix = @($c.IPv4Address | ForEach-Object { $_.PrefixLength })
$o.gateway = @($c.IPv4DefaultGateway | ForEach-Object { $_.NextHop })
$o.dns = @($c.DNSServer | Where-Object { $_.AddressFamily -eq 2 } | ForEach-Object { $_.ServerAddresses } | Select-Object -First 4)
$dhcp = Get-NetIPInterface -InterfaceAlias ${JSON.stringify(iface)} -AddressFamily IPv4 -ErrorAction SilentlyContinue
$dhcp = Get-NetIPInterface -InterfaceAlias ${psLit(iface)} -AddressFamily IPv4 -ErrorAction SilentlyContinue
$o.dhcp = ($dhcp.Dhcp -eq 'Enabled')
$o | ConvertTo-Json -Compress
`;
@@ -436,7 +437,7 @@ export async function netApply(args: Args): Promise<unknown> {
* netsh 仅保留用于「读取」与人工恢复用的 dump 备份(对操作者友好)。
*/
export function buildApplyCommand(mode: string, iface: string, args: Args, snap: IfaceSnapshot): string {
const a = JSON.stringify(iface);
const a = psLit(iface);
if (mode === 'command') {
const cmd = args['command'] as string | undefined;
if (!cmd) throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'mode=command 需要 command 参数');
@@ -459,7 +460,7 @@ export function buildApplyCommand(mode: string, iface: string, args: Args, snap:
}
const prefix = maskToPrefix(mask);
const dns = (args['dns'] as string[] | undefined) ?? [];
const ipLit = JSON.stringify(ip);
const ipLit = psLit(ip);
const lines = [
`$ErrorActionPreference='Continue'`,
// 关掉 DHCP(否则设静态地址会与 DHCP 租约打架)
@@ -477,12 +478,12 @@ export function buildApplyCommand(mode: string, iface: string, args: Args, snap:
];
if (gateway) {
lines.push(
`New-NetRoute -InterfaceAlias ${a} -DestinationPrefix '0.0.0.0/0' -NextHop ${JSON.stringify(gateway)} -ErrorAction SilentlyContinue | Out-Null`,
`New-NetRoute -InterfaceAlias ${a} -DestinationPrefix '0.0.0.0/0' -NextHop ${psLit(gateway)} -ErrorAction SilentlyContinue | Out-Null`,
);
}
if (dns.length > 0) {
lines.push(
`Set-DnsClientServerAddress -InterfaceAlias ${a} -ServerAddresses ${dns.map((d) => JSON.stringify(d)).join(',')} -ErrorAction SilentlyContinue`,
`Set-DnsClientServerAddress -InterfaceAlias ${a} -ServerAddresses ${dns.map((d) => psLit(d)).join(',')} -ErrorAction SilentlyContinue`,
);
}
lines.push(`Write-Output 'static applied'`);
@@ -495,10 +496,10 @@ export function buildApplyCommand(mode: string, iface: string, args: Args, snap:
* 而调用方还以为变更成功了(真机事故:机器掉到 APIPA 彻底失联)。
*/
export function buildSelfCheck(iface: string, expectIp: string | null, rollbackScriptPath: string): string {
const a = JSON.stringify(iface);
const rb = JSON.stringify(rollbackScriptPath);
const a = psLit(iface);
const rb = psLit(rollbackScriptPath);
const cond = expectIp
? `$ok = @(Get-NetIPAddress -InterfaceAlias ${a} -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -eq ${JSON.stringify(expectIp)} }).Count -gt 0`
? `$ok = @(Get-NetIPAddress -InterfaceAlias ${a} -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -eq ${psLit(expectIp)} }).Count -gt 0`
: `$ok = @(Get-NetIPAddress -InterfaceAlias ${a} -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.PrefixOrigin -ne 'WellKnown' }).Count -gt 0`;
return [
``,
@@ -551,7 +552,7 @@ async function posixCommandApply(
'utf8',
);
const launched = await execCommand({
command: `chmod +x ${JSON.stringify(rollbackPath)} && setsid nohup sh ${JSON.stringify(rollbackPath)} > ${JSON.stringify(join(dir, `rollback-${ts}.log`))} 2>&1 & echo started`,
command: `chmod +x ${shLit(rollbackPath)} && setsid nohup sh ${shLit(rollbackPath)} > ${shLit(join(dir, `rollback-${ts}.log`))} 2>&1 & echo started`,
timeoutMs: 15_000,
});
+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 屏幕录制权限)', {
+247 -53
View File
@@ -1,11 +1,21 @@
import { createHash } from 'node:crypto';
import { copyFileSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import {
copyFileSync,
mkdirSync,
readFileSync,
readdirSync,
renameSync,
statSync,
unlinkSync,
writeFileSync,
} from 'node:fs';
import { join, dirname, basename } from 'node:path';
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { IS_WINDOWS } from '../util/exec.js';
import { agentDir } from '../config.js';
import { audit } from '../audit.js';
import { agentRestart } from './system.js';
import { httpGet, resolveProxy } from '../net/index.js';
type Args = Record<string, unknown>;
@@ -21,59 +31,204 @@ type Args = Record<string, unknown>;
* 1. **哈希必须钉死**:`sha256` 必填,且**逐字节校验下载内容**后才替换。
* 没有它,GitHub 账号被盗 / DNS 被污染 / 中间人 = 直接拿下被控端。
* 2. 只接受 http/https;拒绝 file:// 等其它 scheme。
* 3. 大小上限(默认 64MB)+ 下载超时,避免被喂垃圾撑爆磁盘。
* 4. **先备份再原子替换**(同目录 rename),失败可回滚。
* 3. 大小上限(默认 64MB,content-length 预检 + 流式中断双闸)+ 下载超时。
* 4. **先备份再替换**(同目录备份;Windows 上就地覆写,不用 rename —— 真机踩过),
* 失败可回滚;写后校验不一致自动回滚。
* 5. `dry_run` 只下载校验、不动现有文件 —— 用于「先确认能不能更新」。
* 6. 动作全程进审计(能力调用本身已记录 `invoke`,这里再加一条 `agent.update`)。
*
* ## 为什么不用 `fetch` 的代理能力
* Node 内置 `fetch` 不支持 HTTP 代理(需要 undici 的 ProxyAgent,而 undici 未对外暴露)。
* 国内访问 GitHub 不稳时,正确做法是**把 url 指向自建镜像 / 加速服务**(本能力接受任意
* http(s) 地址,天然支持),而不是在 agent 里再塞一套代理隧道。
* ## 出网:经 `net/proxy`(v2.1.0,已批准的方案 A)
* 历史立场(v19~v2.0.3)是「改用镜像地址,而不是在 agent 里塞代理隧道」——
* 真机教训证明该立场不成立:境内无透明代理的机器上,**即使配好了代理**,
* Node 内置 fetch 也不读代理环境变量、更读不到 Windows/macOS 系统代理,
* 「自更新」必然失败(与控制台 sidecar 2026-10-13 的同一缺陷)。
* 故 v2.1.0 起复用 sidecar 已验证的出网方案(`apps/console/sidecar/proxy.mjs`
* 的 TS 移植,见 `net/proxy.ts`):
* `NA_PROXY` → 目标协议环境变量 → 系统代理(reg query / scutil);
* PAC / socks **明确报错不静默降级**;整个重定向链共享一个超时预算。
*/
const MAX_BYTES = 64 * 1024 * 1024;
/** 下载到内存(带大小上限与超时)。 */
async function download(url: string, timeoutMs: number): Promise<Buffer> {
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), timeoutMs);
/** 出网诊断信息(返回结构与 CapabilityError.data 共用,保证成功/失败口径一致)。 */
interface OutboundInfo {
via: string;
proxy: string | null;
proxy_source: string | null;
notes: string[];
}
/** 组一条可操作的失败线索:走了哪条路、代理从哪来、为什么不走代理。 */
function outboundHint(o: OutboundInfo, timeoutMs: number): Record<string, unknown> {
return {
via: o.via,
proxy: o.proxy,
proxy_source: o.proxy_source,
timeout_ms: timeoutMs,
notes: o.notes,
hint: o.proxy
? '代理已生效但仍失败:请确认代理本身能访问更新源(浏览器里试同一个地址)'
: '未使用代理。若本机需要代理才能访问更新源,请在系统里开启「系统代理」,或设置环境变量 NA_PROXY=http://127.0.0.1:<端口> 后重启被控端',
};
}
/**
* 是否为「主动中止」类错误 —— **只认真正的 abort**。
*
* ⚠️ 别把 `ECONNRESET` 之类也塞进来(sidecar 第一版就这么写的,真机上立刻现形):
* 那次耗时 82.9s、预算 120s,连接是被对端**中途重置**的,却被报成
* 「请求超时(120000ms 内未完成)」—— 一句话把用户引到完全错误的方向上。
* 此处按 sidecar `main.mjs:146-151` **同款移植**,不许重新发明。
*/
export function isAbortLike(err: unknown): boolean {
const name = (err as { name?: string } | null)?.name ?? '';
const code = (err as { code?: string } | null)?.code ?? '';
const msg = String((err as { message?: unknown } | null)?.message ?? '');
return name === 'AbortError' || code === 'ABORT_ERR' || /\babort(ed)?\b/i.test(msg);
}
/**
* 把底层网络错误翻译成「用户能据以行动」的一句话。
*
* 按 sidecar `main.mjs:154-165` **同款移植**(含真机教训:ECONNRESET 不是超时)。
*/
export function describeNetError(err: unknown, timeoutMs: number): string {
const msg = err instanceof Error ? err.message : String(err);
const code = (err as { code?: string } | null)?.code ?? '';
if (isAbortLike(err)) return `请求超时(${timeoutMs}ms 内未完成)`;
if (code === 'ECONNRESET' || code === 'EPIPE') {
return `${msg}(连接被对端中途断开;若走了代理,多半是代理或线路的问题)`;
}
if (code === 'ENOTFOUND' || code === 'EAI_AGAIN' || code === 'ECONNREFUSED') {
return `${msg}(域名解析失败或目标拒绝连接;若配了代理,请核对代理地址与端口)`;
}
// 2026-10-27 真机补:境内直连 GitHub 的**最常见**失败形态就是 TCP 层 ETIMEDOUT
// (不是我们自己的 timeoutMs,也不是 ECONNRESET)。原先它落到下面兜底分支,
// 用户只看到一串 IP:443 超时,不知道该干什么 —— 而这时唯一有效的动作就是配代理。
if (code === 'ETIMEDOUT' || code === 'EHOSTUNREACH' || code === 'ENETUNREACH') {
return `${msg}(连接超时/网络不可达;若目标在境外或需要代理,请设置 NA_PROXY=<代理地址> 或配置系统代理后重试)`;
}
return msg;
}
/** 决议代理;任何异常都退化成「直连」—— 探测本身绝不该成为新的失败点。 */
async function resolveProxySafe(targetUrl: string): Promise<{ proxy: URL | null; info: OutboundInfo }> {
try {
const res = await fetch(url, { signal: ac.signal, redirect: 'follow' });
if (!res.ok) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, `下载失败: HTTP ${res.status} ${res.statusText}`, {
url,
});
}
const declared = Number(res.headers.get('content-length') ?? '0');
if (declared > MAX_BYTES) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `文件过大(${declared} 字节 > 上限 ${MAX_BYTES})`, { url });
}
const reader = res.body?.getReader();
if (!reader) {
return Buffer.from(await res.arrayBuffer());
}
const parts: Uint8Array[] = [];
let total = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
total += value.length;
if (total > MAX_BYTES) {
await reader.cancel();
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `下载超过上限 ${MAX_BYTES} 字节,已中断`, { url });
}
parts.push(value);
const r = await resolveProxy({ targetUrl });
return {
proxy: r.proxy,
info: {
via: r.proxy ? `经代理 ${r.proxy.href}` : '直连',
proxy: r.proxy ? r.proxy.href : null,
proxy_source: r.source,
notes: r.notes,
},
};
} catch (err) {
return {
proxy: null,
info: {
via: '直连',
proxy: null,
proxy_source: null,
notes: [`代理探测异常(${err instanceof Error ? err.message : String(err)}),按直连处理`],
},
};
}
}
/**
* 下载到内存(带大小上限与超时;经代理出网)。
*
* 超时语义:`timeoutMs` 是**整条重定向链的总预算**(与 sidecar 一致:链共享一个
* AbortController,防 5 跳 × 30s 穿过 RPC 超时)。
*
* @returns 下载内容 + 出网诊断(成功/失败都带,保证「代理没配 / 哈希不符 / 超时」三类可分)
*/
async function download(
url: string,
timeoutMs: number,
): Promise<{ buf: Buffer; outbound: OutboundInfo }> {
const { proxy, info } = await resolveProxySafe(url);
let res;
try {
res = await httpGet(url, { proxy, timeoutMs, maxBytes: MAX_BYTES });
} catch (err) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, `下载失败: ${describeNetError(err, timeoutMs)}`, {
url,
...outboundHint(info, timeoutMs),
});
}
if (res.status < 200 || res.status >= 300) {
throw new CapabilityError(
ErrorCodes.EXECUTION_FAILED,
`下载失败: HTTP ${res.status} ${res.statusText}`,
{ url, ...outboundHint(info, timeoutMs) },
);
}
const declared = Number(res.headers['content-length'] ?? '0');
if (declared > MAX_BYTES) {
throw new CapabilityError(
ErrorCodes.PARAM_INVALID,
`文件过大(${declared} 字节 > 上限 ${MAX_BYTES})`,
{ url, ...outboundHint(info, timeoutMs) },
);
}
if (res.body.length > MAX_BYTES) {
throw new CapabilityError(
ErrorCodes.PARAM_INVALID,
`下载超过上限 ${MAX_BYTES} 字节,已中断`,
{ url, ...outboundHint(info, timeoutMs) },
);
}
return { buf: res.body, outbound: info };
}
/**
* 备份清理计划(纯函数,供单测):
* 按 mtime **新→旧**排序,保留最近 `keep` 个,返回**应删除**的名单。
*
* 为什么需要:selfupdate 的备份是 `<script>.bak-<ts>`、永不清理 ——
* 推送式 deploy 踩过的坑(真机堆了 15+ 个 bak)在拉取式路径复发。
* 保留最近 3 个:再往前的版本可由更早的备份或重新发布获得,磁盘不无限堆积。
*/
export function planBackupCleanup(files: { name: string; mtime_ms: number }[], keep = 3): string[] {
const sorted = [...files].sort((a, b) => b.mtime_ms - a.mtime_ms);
// 反过来给「最旧先删」:万一删到一半失败,已删掉的也是价值最低的那些
return sorted.slice(Math.max(0, keep)).reverse().map((f) => f.name);
}
/**
* 清理入口同目录的旧 selfupdate 备份(保留最近 3 个)。
*
* 删除失败一律忽略(被杀软占位的 .bak 删不掉不影响正确性)。
* @returns 实际删除的文件名(供审计/返回值观察)
*/
function cleanupOldBackups(scriptPath: string, keep = 3): string[] {
try {
const dir = dirname(scriptPath);
const prefix = `${basename(scriptPath)}.bak-`;
const files: { name: string; mtime_ms: number }[] = [];
for (const name of readdirSync(dir)) {
if (!name.startsWith(prefix)) continue;
try {
files.push({ name, mtime_ms: statSync(join(dir, name)).mtimeMs });
} catch {
/* 文件可能已消失,跳过 */
}
}
return Buffer.concat(parts);
} catch (err) {
if (err instanceof CapabilityError) throw err;
const msg = err instanceof Error ? err.message : String(err);
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, `下载失败: ${msg}`, { url });
} finally {
clearTimeout(timer);
const removed: string[] = [];
for (const name of planBackupCleanup(files, keep)) {
try {
unlinkSync(join(dir, name));
removed.push(name);
} catch {
/* 删不掉不影响正确性 */
}
}
return removed;
} catch {
return [];
}
}
@@ -109,9 +264,9 @@ export async function agentUpdate(args: Args): Promise<unknown> {
const dir = join(agentDir(), 'update');
mkdirSync(dir, { recursive: true });
// ① 下载 + ② 校验哈希(先校验再落盘,避免半成品污染)
// ① 下载(经代理出网)+ ② 校验哈希(先校验再落盘,避免半成品污染)
const t0 = Date.now();
const buf = await download(url, timeoutMs);
const { buf, outbound } = await download(url, timeoutMs);
const got = createHash('sha256').update(buf).digest('hex');
if (!got.startsWith(expect) && got !== expect) {
audit({ type: 'agent.update', status: 'failed', reason: `hash mismatch url=${url} expect=${expect} got=${got.slice(0, 12)}` });
@@ -119,6 +274,7 @@ export async function agentUpdate(args: Args): Promise<unknown> {
expected: expect,
actual: got.slice(0, 12),
bytes: buf.length,
...outboundHint(outbound, timeoutMs),
});
}
@@ -146,7 +302,7 @@ export async function agentUpdate(args: Args): Promise<unknown> {
} catch {
/* 忽略 */
}
audit({ type: 'agent.update', status: 'ok', reason: `dry_run url=${url} hash=${got.slice(0, 12)}` });
audit({ type: 'agent.update', status: 'ok', reason: `dry_run url=${url} hash=${got.slice(0, 12)} via=${outbound.via}` });
return {
dry_run: true,
verified: true,
@@ -155,10 +311,15 @@ export async function agentUpdate(args: Args): Promise<unknown> {
current_hash: prevHash,
current_bytes: prevBytes,
note: '仅下载并校验,未改动任何文件',
via: outbound.via,
proxy: outbound.proxy,
proxy_source: outbound.proxy_source,
outbound_notes: outbound.notes,
};
}
// ③ 备份 + ④ 原子替换(同目录 rename,失败时旧文件不受影响)
// ③ 备份 + ④ 替换(同目录备份;就地覆写不用 rename —— Windows 不允许替换
// 正被运行进程打开的文件,真机踩过:rename 必失败)
const backup = `${scriptPath}.bak-${Date.now()}`;
try {
copyFileSync(scriptPath, backup);
@@ -167,23 +328,51 @@ export async function agentUpdate(args: Args): Promise<unknown> {
detail: err instanceof Error ? err.message : String(err),
});
}
// ⚠️ 必须**就地覆写**,不能用 rename 覆盖 —— Windows 不允许重命名/替换「正被运行进程
// 打开的文件」(EPERM/EBUSY),而 node.exe 正持有 agent.mjs。真机踩过:rename 必失败。
// 覆写打开中的文件是允许的(只有删除/重命名被禁),这也正是推送式 deploy 能工作的原因。
try {
renameSync(staged, scriptPath);
writeFileSync(scriptPath, buf);
} catch (err) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '替换脚本失败', {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '写入脚本失败(已保留备份,可回滚)', {
detail: err instanceof Error ? err.message : String(err),
backup_path: backup,
staged_path: staged,
});
}
// 写后校验:内容必须与刚校验过的下载一致,否则立即回滚(防止截断/半写)
const written = createHash('sha256').update(readFileSync(scriptPath)).digest('hex');
if (written !== got) {
try {
copyFileSync(backup, scriptPath); // 回滚
} catch {
/* 回滚失败时靠 backup_path 人工兜底 */
}
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '写入后校验不一致,已自动回滚', {
expected: got.slice(0, 12),
actual: written.slice(0, 12),
backup_path: backup,
});
}
try {
unlinkSync(staged); // 暂存文件已并入入口文件,及时清理
} catch {
/* 清理失败不影响正确性 */
}
// ⑤ 备份清理:保留最近 3 个,其余删(A-P0-3:selfupdate 路径此前永不清理)
const removedBackups = cleanupOldBackups(scriptPath, 3);
audit({
type: 'agent.update',
status: 'ok',
reason: `url=${url} ${prevHash}->${got.slice(0, 12)} bytes=${buf.length} backup=${backup}`,
reason:
`url=${url} ${prevHash}->${got.slice(0, 12)} bytes=${buf.length} backup=${backup} via=${outbound.via}` +
(removedBackups.length > 0 ? ` cleaned=${removedBackups.length}` : ''),
});
// ⑤ 受控重启(复用 v7;Windows 走计划任务、POSIX 走 detached,均不依赖本进程存活)
// ⑥ 受控重启(复用 v7;Windows 走计划任务、POSIX 走 detached,均不依赖本进程存活)
let restarted = false;
if (doRestart) {
await agentRestart({ delay_ms: 1500, reason: 'self-update' });
@@ -204,5 +393,10 @@ export async function agentUpdate(args: Args): Promise<unknown> {
? '已替换并重启;请用 system.info 的 build.hash 复核(应等于 incoming_hash)。若异常,把 backup_path 覆盖回入口即可回滚'
: '已替换但未重启(restart=false);重启后才会生效',
platform: IS_WINDOWS ? 'windows' : 'posix',
// v2.1.0 新增(只增字段):出网诊断
via: outbound.via,
proxy: outbound.proxy,
proxy_source: outbound.proxy_source,
outbound_notes: outbound.notes,
};
}
+65 -10
View File
@@ -1,4 +1,5 @@
import { createHash } from 'node:crypto';
import { guardPath } from './fs.js';
import { readFileSync, statSync } from 'node:fs';
import { spawn } from 'node:child_process';
import os from 'node:os';
@@ -6,10 +7,12 @@ import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { IS_WINDOWS, execCommand } from '../util/exec.js';
import { currentCertFingerprint } from '../certs.js';
import { loadAgentConfig } from '../config.js';
import { readAudit, readAuditAll, verifyAudit } from '../audit.js';
import { readAudit, readAuditAll, verifyAudit, computeAuditHead, anchorAudit, compareWithAnchors } from '../audit.js';
import { computeMetrics } from '../metrics.js';
import { allPsShellStats } from '../util/ps-helper.js';
import { lkgStatus } from '../lkg.js';
import { startTask } from './task.js';
import { psLit } from '../util/quote.js';
type Args = Record<string, unknown>;
@@ -55,7 +58,6 @@ async function isAdmin(): Promise<boolean> {
// 故一律用 typeof 探测,绝不能直接引用(否则 dev 启动就 ReferenceError)。
declare const __AGENT_VERSION__: string | undefined;
declare const __BUILD_COMMIT__: string | undefined;
declare const __BUILD_TIME__: string | undefined;
/** v21:读取当前生效的来源白名单(用于 system.info 上报)。 */
function configAllowFrom(): string[] | null {
@@ -67,10 +69,9 @@ function configAllowFrom(): string[] | null {
}
}
function injected(name: 'version' | 'commit' | 'time'): string {
function injected(name: 'version' | 'commit'): string {
if (name === 'version') return typeof __AGENT_VERSION__ === 'undefined' ? 'dev' : __AGENT_VERSION__;
if (name === 'commit') return typeof __BUILD_COMMIT__ === 'undefined' ? 'dev' : __BUILD_COMMIT__;
return typeof __BUILD_TIME__ === 'undefined' ? 'dev' : __BUILD_TIME__;
return typeof __BUILD_COMMIT__ === 'undefined' ? 'dev' : __BUILD_COMMIT__;
}
const STARTED_AT = Date.now();
@@ -118,7 +119,9 @@ export function buildInfo(): {
// v20:语义化版本 + 提交 + 构建时间(来自构建期注入)
version: injected('version'),
commit: injected('commit'),
built_at: injected('time'),
// 构建时间取入口文件的 mtime(而非构建期注入)—— 注入会让每次打包字节不同,
// 破坏「指纹 = 构建身份」的确定性(v22 真机踩过)
built_at: buildCache.mtime_ms ? new Date(buildCache.mtime_ms).toISOString() : 'unknown',
// v21:TLS 证书指纹(控制端据此钉住被控端身份)
cert_sha256: currentCertFingerprint(),
};
@@ -149,6 +152,10 @@ export async function systemInfo(args: Args): Promise<unknown> {
network: {
allow_from: configAllowFrom(),
},
// 交付流水线 §6:最后已知可用版本(回滚锚点)。
// matches=false 表示「当前版本还没被证明能启动」—— 刚更新完、尚未完成一次成功
// 启动时会是这种情况,控制端应据此暂缓向其余设备放量。
lkg: lkgStatus(),
};
if (fields && fields.length > 0) {
@@ -526,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}'`,
@@ -600,6 +630,31 @@ export async function auditVerify(_args: Args): Promise<unknown> {
return verifyAudit();
}
/**
* v2.0.0:返回审计链的链头(只读)。
*
* 用途:控制端定期拉取并存到**链外**(Mac 本地 / 另一台机器)—— 这是"外部锚定"的一半。
* compare=true 时附带与最近一条锚点的比对结论(另一半)。
*/
export async function auditHead(args: Args): Promise<unknown> {
const head = computeAuditHead();
if (args['compare'] !== true) return head;
return { ...head, comparison: compareWithAnchors(args['anchor_path'] as string | undefined) };
}
/**
* v2.0.0:把当前链头**追加**写到锚点文件(默认 <数据目录>/audit-anchors.jsonl)。
*
* 为什么是追加而不是覆盖:历史锚点一旦写成就不可被后续覆盖,否则攻击者重写链后
* 再"刷新"锚点即可抹掉痕迹。追加式让每次锚定都留痕。
* path 若指定,仍受 fs_roots 白名单约束(不新开写入面)。
*/
export async function auditAnchor(args: Args): Promise<unknown> {
const raw = args['path'] as string | undefined;
const path = raw ? guardPath(raw) : undefined;
return anchorAudit({ ...(path ? { path } : {}), ...(args['note'] ? { note: String(args['note']) } : {}) });
}
/** v13:成功指标聚合(对齐 PRD 2.2)—— 数据源为审计日志,无需额外埋点。 */
export async function metricsReport(args: Args): Promise<unknown> {
const since = args['since'] as number | undefined;
+7
View File
@@ -132,6 +132,13 @@ function getTask(id: string): TaskRecord {
// ---------------- system.task.list ----------------
/** v2.0.0:正在跑的任务数(连接层"有空闲断开但不动长任务"的判据)。 */
export function runningTaskCount(): number {
let n = 0;
for (const t of tasks.values()) if (t.state === 'running') n += 1;
return n;
}
export async function taskList(_args: Args): Promise<unknown> {
return {
tasks: [...tasks.values()].map((t) => ({
@@ -0,0 +1,42 @@
/**
* 枚举可见顶层窗口 → JSON(**不含 prelude**,供常驻助手与一次性路径复用)
*
* 2026-10 从 window.ts 抽出(结构拆分,**内容逐字节不变**)。
* ⚠️ WIN_HELPER_PRELUDE 是本段与另外几段的拼接 —— 改动会直接影响常驻 PS 助手。
* 拆分以 tests/unit/win-prelude.test.mjs + prelude sha256 比对双重把关。
*/
/**
* 枚举可见顶层窗口 → JSON(**不含 prelude**,供常驻助手与一次性路径复用)。
* 注意:本段会被送进常驻进程执行,**严禁出现 exit**(会杀掉助手进程)。
*/
export const LIST_BODY = `
$fg = [NAWin32]::GetForegroundWindow()
$list = New-Object System.Collections.ArrayList
$cb = [NAWin32+EnumProc]{
param($h, $l)
if (-not [NAWin32]::IsWindowVisible($h)) { return $true }
$sb = New-Object System.Text.StringBuilder 512
[void][NAWin32]::GetWindowTextW($h, $sb, 512)
$title = $sb.ToString()
if ([string]::IsNullOrWhiteSpace($title)) { return $true }
$r = New-Object NAWin32+RECT
if (-not [NAWin32]::GetWindowRect($h, [ref]$r)) { return $true }
$w = $r.Right - $r.Left; $ht = $r.Bottom - $r.Top
if ($w -le 0 -or $ht -le 0) { return $true }
# ⚠️ 变量名不能叫 $pid —— 它是 PowerShell 的自动变量(当前进程 ID),赋值会被忽略,
# 导致所有窗口都报成 powershell 进程(真机踩过)。
$procId = 0
[void][NAWin32]::GetWindowThreadProcessId($h, [ref]$procId)
$pn = ''
try { $pn = (Get-Process -Id $procId -ErrorAction Stop).ProcessName } catch {}
[void]$list.Add([pscustomobject]@{
hwnd = ('0x{0:X}' -f $h.ToInt64()); title = $title; process = $pn; pid = $procId
x = $r.Left; y = $r.Top; width = $w; height = $ht
is_foreground = ($h -eq $fg); is_minimized = [NAWin32]::IsIconic($h)
})
return $true
}
[void][NAWin32]::EnumWindows($cb, [IntPtr]::Zero)
$list | ConvertTo-Json -Compress
`;
@@ -0,0 +1,226 @@
/**
* 图像模板匹配预加载段(ZNCC 粗到精)
*
* 2026-10 从 window.ts 抽出(结构拆分,**内容逐字节不变**)。
* ⚠️ WIN_HELPER_PRELUDE 是本段与另外几段的拼接 —— 改动会直接影响常驻 PS 助手。
* 拆分以 tests/unit/win-prelude.test.mjs + prelude sha256 比对双重把关。
*/
/**
* 图像模板匹配预加载段(v15)。
*
* 动机:UIA 靠控件树、OCR 靠文字 —— **纯图标/无文字的控件**两者都抓不到(审查报告 §5.2 指出的盲区)。
* 做法:把模板图与屏幕区域都转灰度,做**零均值归一化互相关(ZNCC)**;
* 全分辨率逐像素太慢(3440×1440 屏 × 64×64 模板 ≈ 190 亿次),故**粗到精**:
* ① 1/4 分辨率全图扫描(约 8000 万次)取候选
* ② 对候选在全分辨率 ±10px 邻域精修
* 编译一次常驻(随助手预加载),运行时纯 native 循环。
* 亮度和对比度无关(ZNCC 归一化),故对主题/亮度变化不敏感。
*/
export const IMAGE_MATCH_PRELUDE = `
Add-Type -AssemblyName System.Drawing
# ⚠️ Add-Type 的老坑:-AssemblyName 只把程序集加载进 PowerShell 会话,
# 编译 C# 时还必须显式 -ReferencedAssemblies,否则报「命名空间 System.Drawing
# 中不存在 Imaging」。真机踩过:编译失败会让整个助手起不来,并伴随误导性的
# 「找不到类型 System.Windows.Forms.SystemInformation」二次错误。
Add-Type @"
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Imaging;
using System.Runtime.InteropServices;
public class NAImageMatch {
public class Hit { public int x; public int y; public int w; public int h; public double score; }
private static byte[] Gray(Bitmap src, out int w, out int h) {
w = src.Width; h = src.Height;
var dst = new byte[w * h];
var rect = new Rectangle(0, 0, w, h);
Bitmap bmp = src.PixelFormat == PixelFormat.Format24bppRgb ? src : src.Clone(rect, PixelFormat.Format24bppRgb);
var data = bmp.LockBits(rect, ImageLockMode.ReadOnly, PixelFormat.Format24bppRgb);
try {
int stride = data.Stride;
var buf = new byte[stride * h];
Marshal.Copy(data.Scan0, buf, 0, buf.Length);
for (int y = 0; y < h; y++) {
int row = y * stride;
for (int x = 0; x < w; x++) {
int i = row + x * 3;
// BGR -> luma (integer approximation of ITU-R BT.601)
dst[y * w + x] = (byte)((buf[i + 2] * 77 + buf[i + 1] * 150 + buf[i] * 29) >> 8);
}
}
} finally { bmp.UnlockBits(data); if (bmp != src) bmp.Dispose(); }
return dst;
}
// Area-average downscale (coarse search layer)
private static byte[] Downscale(byte[] src, int w, int h, int f, out int ow, out int oh) {
ow = w / f; oh = h / f;
var dst = new byte[ow * oh];
for (int y = 0; y < oh; y++) {
for (int x = 0; x < ow; x++) {
int sum = 0;
for (int dy = 0; dy < f; dy++) {
int row = (y * f + dy) * w;
for (int dx = 0; dx < f; dx++) sum += src[row + x * f + dx];
}
dst[y * ow + x] = (byte)(sum / (f * f));
}
}
return dst;
}
// Zero-mean normalized cross correlation; 1.0 = identical
private static double Zncc(byte[] S, int sw, int sx, int sy, byte[] T, int tw, int th) {
int n = tw * th;
double sumT = 0;
for (int i = 0; i < n; i++) sumT += T[i];
double meanT = sumT / n;
double sumS = 0;
for (int y = 0; y < th; y++) {
int row = (sy + y) * sw + sx;
for (int x = 0; x < tw; x++) sumS += S[row + x];
}
double meanS = sumS / n;
double num = 0, dS = 0, dT = 0;
for (int y = 0; y < th; y++) {
int row = (sy + y) * sw + sx;
int trow = y * tw;
for (int x = 0; x < tw; x++) {
double a = S[row + x] - meanS;
double b = T[trow + x] - meanT;
num += a * b; dS += a * a; dT += b * b;
}
}
double den = Math.Sqrt(dS * dT);
if (den < 1e-9) return 0;
return num / den;
}
/**
// (comment removed: ASCII only)
// (comment removed: ASCII only)
*/
public static string Match(string screenPath, string templatePath, int maxResults, double threshold, int searchX, int searchY, int searchW, int searchH) {
var hits = new List<Hit>();
using (var screenBmp = new Bitmap(screenPath))
using (var tmplBmp = new Bitmap(templatePath)) {
int sw, sh, tw, th;
var S0 = Gray(screenBmp, out sw, out sh);
var T0 = Gray(tmplBmp, out tw, out th);
if (tw >= sw || th >= sh) return "[]";
// Guard: a flat template carries no gradient information, so correlation is
// undefined (ZNCC denominator ~ 0). Report it explicitly instead of silently
// returning no hits - a uniform crop usually means the wrong region was picked.
//
// NOTE: build all JSON/string literals with the char constant Q.
// Writing a backslash-quote inside a TS template literal collapses it to a bare
// quote and breaks the C# compile - this trap bit this file twice.
const char Q = '"';
var ci0 = System.Globalization.CultureInfo.InvariantCulture;
double tSum = 0, tSum2 = 0;
for (int i = 0; i < T0.Length; i++) { tSum += T0[i]; tSum2 += (double)T0[i] * T0[i]; }
double tMean = tSum / T0.Length;
double tVar = (tSum2 / T0.Length) - (tMean * tMean);
if (tVar < 4.0) {
return "[" + "{" + Q + "__error" + Q + ":" + Q + "template_has_no_contrast" + Q
+ "," + Q + "variance" + Q + ":" + tVar.ToString("F2", ci0) + "}" + "]";
}
// Optional search window: crop a sub-image, then add the offset back to hit coords
int ox = 0, oy = 0;
if (searchW > 0 && searchH > 0) {
ox = Math.Max(0, Math.Min(searchX, sw - 1));
oy = Math.Max(0, Math.Min(searchY, sh - 1));
int w = Math.Min(searchW, sw - ox), h = Math.Min(searchH, sh - oy);
var sub = new byte[w * h];
for (int y = 0; y < h; y++) Array.Copy(S0, (oy + y) * sw + ox, sub, y * w, w);
S0 = sub; sw = w; sh = h;
}
const int F = 4;
int sw4, sh4, tw4, th4;
var S4 = Downscale(S0, sw, sh, F, out sw4, out sh4);
var T4 = Downscale(T0, tw, th, F, out tw4, out th4);
// (1) coarse scan: collect candidates.
// NOTE: the pre-filter must be MUCH looser than the final threshold.
// Downsampling lowers the correlation of the true position (typically 0.7-0.85
// for a 0.9+ full-res match), so a tight pre-filter discards the right answer
// before refinement ever runs (this exact bug made every match return empty).
var cands = new List<Hit>();
if (tw4 > 0 && th4 > 0 && tw4 <= sw4 && th4 <= sh4) {
double preFilter = Math.Max(0.30, threshold - 0.30);
double bestCoarse = -1; int bx4 = 0, by4 = 0;
for (int y = 0; y <= sh4 - th4; y++) {
for (int x = 0; x <= sw4 - tw4; x++) {
double sc = Zncc(S4, sw4, x, y, T4, tw4, th4);
if (sc > bestCoarse) { bestCoarse = sc; bx4 = x; by4 = y; }
if (sc >= preFilter) cands.Add(new Hit { x = x * F, y = y * F, w = tw, h = th, score = sc });
}
}
// Belt and braces: always keep the coarse best, even if it missed the pre-filter
if (bestCoarse > 0) {
bool seen = false;
foreach (var c in cands) { if (Math.Abs(c.x - bx4 * F) < F && Math.Abs(c.y - by4 * F) < F) { seen = true; break; } }
if (!seen) cands.Add(new Hit { x = bx4 * F, y = by4 * F, w = tw, h = th, score = bestCoarse });
}
}
cands.Sort((a, b) => b.score.CompareTo(a.score));
if (cands.Count > 40) cands.RemoveRange(40, cands.Count - 40);
// (2) refine at full resolution within a +/-F*2 neighborhood
foreach (var c in cands) {
double best = -1; int bx = c.x, by = c.y;
for (int dy = -F * 2; dy <= F * 2; dy++) {
for (int dx = -F * 2; dx <= F * 2; dx++) {
int x = c.x + dx, y = c.y + dy;
if (x < 0 || y < 0 || x + tw > sw || y + th > sh) continue;
double sc = Zncc(S0, sw, x, y, T0, tw, th);
if (sc > best) { best = sc; bx = x; by = y; }
}
}
if (best >= threshold) hits.Add(new Hit { x = bx + ox, y = by + oy, w = tw, h = th, score = best });
}
// (3) de-dup: non-maximum suppression (keep best within 8px)
hits.Sort((a, b) => b.score.CompareTo(a.score));
var final = new List<Hit>();
foreach (var h in hits) {
bool dup = false;
foreach (var f in final) {
if (Math.Abs(f.x - h.x) < 8 && Math.Abs(f.y - h.y) < 8) { dup = true; break; }
}
if (!dup) final.Add(h);
if (final.Count >= maxResults) break;
}
var parts = new List<string>();
foreach (var h in final) {
// Build JSON by concatenation; reuse the shared Q / ci0 declared above.
var ci = ci0;
parts.Add("{" + Q + "x" + Q + ":" + h.x.ToString(ci) + "," + Q + "y" + Q + ":" + h.y.ToString(ci)
+ "," + Q + "width" + Q + ":" + h.w.ToString(ci) + "," + Q + "height" + Q + ":" + h.h.ToString(ci)
+ "," + Q + "score" + Q + ":" + h.score.ToString("F4", ci) + "}");
}
return "[" + String.Join(",", parts.ToArray()) + "]";
}
}
// Capture a screen region to a file (avoids extra process round-trips)
public static string Capture(string outPath, int x, int y, int w, int h) {
using (var bmp = new Bitmap(w, h))
using (var g = Graphics.FromImage(bmp)) {
g.CopyFromScreen(x, y, 0, 0, new Size(w, h));
bmp.Save(outPath, ImageFormat.Png);
}
return outPath;
}
}
"@ -ReferencedAssemblies System.Drawing
`;
@@ -0,0 +1,39 @@
/**
* OCR 引擎预加载段:截图 / DPI / WinRT 类型与 Await 辅助函数
*
* 2026-10 从 window.ts 抽出(结构拆分,**内容逐字节不变**)。
* ⚠️ WIN_HELPER_PRELUDE 是本段与另外几段的拼接 —— 改动会直接影响常驻 PS 助手。
* 拆分以 tests/unit/win-prelude.test.mjs + prelude sha256 比对双重把关。
*/
/**
* OCR 引擎预加载段(v14):截图 / WinRT 类型与 Await 辅助函数。
* 注意:WinRT 类型加载用 try/catch 包住 —— 个别环境缺组件时只应让 OCR 退化,
* 不该让整个助手(连带 window.list/focus/UIA)起不来。
*
* ⚠️ DPI 感知(`SetProcessDPIAware`)**已从本段移出**,改由 WIN_HELPER_PRELUDE 最前面的
* `PS_DPI_AWARE` 无条件执行:放在这个 try 里会被 catch 顺带吞掉,一旦吞掉
* 助手就退回 DPI 不感知,`GetWindowRect` 与 UIA / 截图坐标口径不一致(真机 2026-10-12 排查)。
*/
export const OCR_PRELUDE = `
Add-Type -AssemblyName System.Drawing
Add-Type -AssemblyName System.Windows.Forms
try {
Add-Type -AssemblyName System.Runtime.WindowsRuntime
$null = [Windows.Media.Ocr.OcrEngine, Windows.Foundation, ContentType=WindowsRuntime]
$null = [Windows.Graphics.Imaging.BitmapDecoder, Windows.Foundation, ContentType=WindowsRuntime]
$null = [Windows.Storage.Streams.InMemoryRandomAccessStream, Windows.Foundation, ContentType=WindowsRuntime]
$null = [Windows.Storage.Streams.DataWriter, Windows.Foundation, ContentType=WindowsRuntime]
$null = [Windows.Graphics.Imaging.SoftwareBitmap, Windows.Foundation, ContentType=WindowsRuntime]
$script:asTaskGeneric = ([System.WindowsRuntimeSystemExtensions].GetMethods() | Where-Object { $_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation\`1' })[0]
function global:Await($WinRtTask, $ResultType) {
$asTask = $script:asTaskGeneric.MakeGenericMethod($ResultType)
$netTask = $asTask.Invoke($null, @($WinRtTask))
$netTask.Wait(-1) | Out-Null
$netTask.Result
}
$script:ocrReady = $true
} catch {
$script:ocrReady = $false
}
`;
@@ -0,0 +1,117 @@
/**
* UIA 控件树预加载段(含 Where 谓词与属性读取)
*
* 2026-10 从 window.ts 抽出(结构拆分,**内容逐字节不变**)。
* ⚠️ WIN_HELPER_PRELUDE 是本段与另外几段的拼接 —— 改动会直接影响常驻 PS 助手。
* 拆分以 tests/unit/win-prelude.test.mjs + prelude sha256 比对双重把关。
*/
/**
* v14:常驻助手预加载段 —— 这些内容**只在助手启动时执行一次**。
* 过去每次调用都重新编译 C# / 加载程序集,真机实测占单次耗时的绝大部分
* (window.list 1267ms / window.focus 1631ms,其中「起进程+编译」是固定成本)。
*/
export const UIA_ASSEMBLY_PRELUDE = `
Add-Type -AssemblyName UIAutomationClient
Add-Type -AssemblyName UIAutomationTypes
Add-Type -AssemblyName WindowsBase
function Get-WinByTitle([string]$re) {
$root = [System.Windows.Automation.AutomationElement]::RootElement
$cond = New-Object System.Windows.Automation.PropertyCondition(
[System.Windows.Automation.AutomationElement]::ControlTypeProperty,
[System.Windows.Automation.ControlType]::Window)
$wins = $root.FindAll([System.Windows.Automation.TreeScope]::Children, $cond)
foreach ($w in $wins) {
try {
$t = $w.Current.Name
if ($t -and $t -match $re) { return $w }
} catch {}
}
return $null
}
# v14:按子串在给定 scope 内遍历查找(FindAll + 子串过滤)。
# UIA 的 PropertyCondition 只能精确匹配,故必须遍历后过滤;遍历规模设上限防超大 UI 树卡死。
# v2.0.0:读取元素的 UIA 属性(v2.0.0 语义属性化)。
# 为什么逐个 try:ValuePattern / SelectionItemPattern / TogglePattern 并非所有控件
# 都支持(不支持时 GetSupportedPattern 返回 false 或抛异常),而且**部分应用
# (Electron / 游戏 UI)根本给不出值** —— 拿不到就留空,由上层判"未知"而非"false"。
function Get-UiaProps($e) {
$enabled = $true
try { $enabled = [bool]$e.Current.IsEnabled } catch {}
$value = $null
try {
$vp = $e.GetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern)
if ($vp -ne $null) { $value = $vp.Current.Value }
} catch {}
$selected = $null
try {
$sp = $e.GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern)
if ($sp -ne $null) { $selected = [bool]$sp.Current.IsSelected }
} catch {}
$toggle = $null
try {
$tp = $e.GetCurrentPattern([System.Windows.Automation.TogglePattern]::Pattern)
if ($tp -ne $null) { $toggle = "$($tp.Current.ToggleState)" }
} catch {}
return @{ enabled = $enabled; value = $value; selected = $selected; toggle = $toggle }
}
function Test-UiaWhere($props, $where) {
if ($where -eq $null) { return $true }
foreach ($k in $where.Keys) {
$want = $where[$k]
switch ($k) {
'enabled' { if ($props.enabled -ne [bool]$want) { return $false } }
'selected' { if ($null -eq $props.selected -or $props.selected -ne [bool]$want) { return $false } }
'value' { if ($props.value -eq $null -or $props.value -notlike [string]$want) { return $false } }
default { } # 未知键忽略(Node 侧已白名单校验)
'toggle' { if ($props.toggle -eq $null -or $props.toggle -notlike [string]$want) { return $false } }
}
}
return $true
}
function Find-UiaByText($scope, [string]$text, [int]$limit, [string]$ct, $where) {
$cond = [System.Windows.Automation.Condition]::TrueCondition
if ($ct -ne '') {
$ctObj = $null
try { $ctObj = [System.Windows.Automation.ControlType]::$ct } catch {}
if ($ctObj -ne $null) {
$cond = New-Object System.Windows.Automation.PropertyCondition(
[System.Windows.Automation.AutomitionElement]::ControlTypeProperty, $ctObj)
}
}
$found = $scope.FindAll([System.Windows.Automation.TreeScope]::Descendants, $cond)
$out = New-Object System.Collections.ArrayList
$scanned = 0
foreach ($e in $found) {
if ($out.Count -ge $limit) { break }
$scanned++
if ($scanned -gt 20000) { break }
try {
$nm = $e.Current.Name
if ([string]::IsNullOrEmpty($nm)) { continue }
if ($text -ne '' -and $nm.IndexOf($text, [System.StringComparison]::OrdinalIgnoreCase) -lt 0) { continue }
$r = $e.Current.BoundingRectangle
if ($r.Width -le 0 -or $r.Height -le 0) { continue }
$props = Get-UiaProps $e
if (-not (Test-UiaWhere $props $where)) { continue }
[void]$out.Add([pscustomobject]@{
name = $nm
control_type = ($e.Current.ControlType.ProgrammaticName -replace 'ControlType\\.','')
automation_id = $e.Current.AutomationId
x = [int]($r.Left + $r.Width / 2)
y = [int]($r.Top + $r.Height / 2)
left = [int]$r.Left; top = [int]$r.Top
width = [int]$r.Width; height = [int]$r.Height
enabled = $props.enabled
value = $props.value
selected = $props.selected
toggle = $props.toggle
})
} catch {}
}
return ,$out
}
`;
@@ -0,0 +1,30 @@
/**
* Win32 窗口枚举与矩形读取(供 window.list / window.focus 复用)
*
* 2026-10 从 window.ts 抽出(结构拆分,**内容逐字节不变**)。
* ⚠️ WIN_HELPER_PRELUDE 是本段与另外几段的拼接 —— 改动会直接影响常驻 PS 助手。
* 拆分以 tests/unit/win-prelude.test.mjs + prelude sha256 比对双重把关。
*/
export const WIN32_PRELUDE = `
Add-Type @"
using System;
using System.Text;
using System.Runtime.InteropServices;
public class NAWin32 {
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left, Top, Right, Bottom; }
public delegate bool EnumProc(IntPtr hWnd, IntPtr lParam);
[DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr p);
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);
[DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetWindowTextW(IntPtr h, StringBuilder s, int n);
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
[DllImport("user32.dll")] public static extern void mouse_event(uint f, int dx, int dy, uint d, UIntPtr e);
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd);
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
}
"@
`;
+87 -385
View File
@@ -1,11 +1,19 @@
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { IS_WINDOWS, execCommand } from '../util/exec.js';
import { runPowerShellSmart } from '../util/ps-helper.js';
import { psLit } from '../util/quote.js';
import { agentDir } from '../config.js';
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { macScreenFind, macWindowFocus, macWindowList } from './darwin.js';
import { PS_DPI_AWARE } from '../util/dpi.js';
import { WIN32_PRELUDE } from './win-ps/prelude-win32.js';
import { LIST_BODY } from './win-ps/body-list.js';
import { UIA_ASSEMBLY_PRELUDE } from './win-ps/prelude-uia.js';
import { OCR_PRELUDE } from './win-ps/prelude-ocr.js';
import { IMAGE_MATCH_PRELUDE } from './win-ps/prelude-image.js';
type Args = Record<string, unknown>;
function toArray<T>(v: T | T[] | null | undefined): T[] {
@@ -80,381 +88,11 @@ function extractJson<T>(raw: string): T | null {
}
/** Win32 窗口枚举与矩形读取(供 window.list / window.focus 复用)。 */
const WIN32_PRELUDE = `
Add-Type @"
using System;
using System.Text;
using System.Runtime.InteropServices;
public class NAWin32 {
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left, Top, Right, Bottom; }
public delegate bool EnumProc(IntPtr hWnd, IntPtr lParam);
[DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr p);
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);
[DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetWindowTextW(IntPtr h, StringBuilder s, int n);
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
[DllImport("user32.dll")] public static extern void mouse_event(uint f, int dx, int dy, uint d, UIntPtr e);
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd);
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
}
"@
`;
/**
* 枚举可见顶层窗口 → JSON(**不含 prelude**,供常驻助手与一次性路径复用)。
* 注意:本段会被送进常驻进程执行,**严禁出现 exit**(会杀掉助手进程)。
*/
const LIST_BODY = `
$fg = [NAWin32]::GetForegroundWindow()
$list = New-Object System.Collections.ArrayList
$cb = [NAWin32+EnumProc]{
param($h, $l)
if (-not [NAWin32]::IsWindowVisible($h)) { return $true }
$sb = New-Object System.Text.StringBuilder 512
[void][NAWin32]::GetWindowTextW($h, $sb, 512)
$title = $sb.ToString()
if ([string]::IsNullOrWhiteSpace($title)) { return $true }
$r = New-Object NAWin32+RECT
if (-not [NAWin32]::GetWindowRect($h, [ref]$r)) { return $true }
$w = $r.Right - $r.Left; $ht = $r.Bottom - $r.Top
if ($w -le 0 -or $ht -le 0) { return $true }
# ⚠️ 变量名不能叫 $pid —— 它是 PowerShell 的自动变量(当前进程 ID),赋值会被忽略,
# 导致所有窗口都报成 powershell 进程(真机踩过)。
$procId = 0
[void][NAWin32]::GetWindowThreadProcessId($h, [ref]$procId)
$pn = ''
try { $pn = (Get-Process -Id $procId -ErrorAction Stop).ProcessName } catch {}
[void]$list.Add([pscustomobject]@{
hwnd = ('0x{0:X}' -f $h.ToInt64()); title = $title; process = $pn; pid = $procId
x = $r.Left; y = $r.Top; width = $w; height = $ht
is_foreground = ($h -eq $fg); is_minimized = [NAWin32]::IsIconic($h)
})
return $true
}
[void][NAWin32]::EnumWindows($cb, [IntPtr]::Zero)
$list | ConvertTo-Json -Compress
`;
/**
* v14:常驻助手预加载段 —— 这些内容**只在助手启动时执行一次**。
* 过去每次调用都重新编译 C# / 加载程序集,真机实测占单次耗时的绝大部分
* (window.list 1267ms / window.focus 1631ms,其中「起进程+编译」是固定成本)。
*/
const UIA_ASSEMBLY_PRELUDE = `
Add-Type -AssemblyName UIAutomationClient
Add-Type -AssemblyName UIAutomationTypes
Add-Type -AssemblyName WindowsBase
function Get-WinByTitle([string]$re) {
$root = [System.Windows.Automation.AutomationElement]::RootElement
$cond = New-Object System.Windows.Automation.PropertyCondition(
[System.Windows.Automation.AutomationElement]::ControlTypeProperty,
[System.Windows.Automation.ControlType]::Window)
$wins = $root.FindAll([System.Windows.Automation.TreeScope]::Children, $cond)
foreach ($w in $wins) {
try {
$t = $w.Current.Name
if ($t -and $t -match $re) { return $w }
} catch {}
}
return $null
}
# v14:按子串在给定 scope 内遍历查找(FindAll + 子串过滤)。
# UIA 的 PropertyCondition 只能精确匹配,故必须遍历后过滤;遍历规模设上限防超大 UI 树卡死。
function Find-UiaByText($scope, [string]$text, [int]$limit, [string]$ct) {
$cond = [System.Windows.Automation.Condition]::TrueCondition
if ($ct -ne '') {
$ctObj = $null
try { $ctObj = [System.Windows.Automation.ControlType]::$ct } catch {}
if ($ctObj -ne $null) {
$cond = New-Object System.Windows.Automation.PropertyCondition(
[System.Windows.Automation.AutomationElement]::ControlTypeProperty, $ctObj)
}
}
$found = $scope.FindAll([System.Windows.Automation.TreeScope]::Descendants, $cond)
$out = New-Object System.Collections.ArrayList
$scanned = 0
foreach ($e in $found) {
if ($out.Count -ge $limit) { break }
$scanned++
if ($scanned -gt 20000) { break }
try {
$nm = $e.Current.Name
if ([string]::IsNullOrEmpty($nm)) { continue }
if ($nm.IndexOf($text, [System.StringComparison]::OrdinalIgnoreCase) -lt 0) { continue }
$r = $e.Current.BoundingRectangle
if ($r.Width -le 0 -or $r.Height -le 0) { continue }
[void]$out.Add([pscustomobject]@{
name = $nm
control_type = ($e.Current.ControlType.ProgrammaticName -replace 'ControlType\\.','')
automation_id = $e.Current.AutomationId
x = [int]($r.Left + $r.Width / 2)
y = [int]($r.Top + $r.Height / 2)
left = [int]$r.Left; top = [int]$r.Top
width = [int]$r.Width; height = [int]$r.Height
})
} catch {}
}
return ,$out
}
`;
/** 助手统一预加载段(window.list / window.focus / screen.find 共用同一个进程)。 */
/**
* OCR 引擎预加载段(v14):截图 / DPI / WinRT 类型与 Await 辅助函数。
* 注意:WinRT 类型加载用 try/catch 包住 —— 个别环境缺组件时只应让 OCR 退化,
* 不该让整个助手(连带 window.list/focus/UIA)起不来。
*/
const OCR_PRELUDE = `
Add-Type -AssemblyName System.Drawing
Add-Type -AssemblyName System.Windows.Forms
try {
Add-Type -AssemblyName System.Runtime.WindowsRuntime
Add-Type -TypeDefinition 'using System.Runtime.InteropServices; public class NADPI { [DllImport("user32.dll")] public static extern bool SetProcessDPIAware(); }'
[void][NADPI]::SetProcessDPIAware()
$null = [Windows.Media.Ocr.OcrEngine, Windows.Foundation, ContentType=WindowsRuntime]
$null = [Windows.Graphics.Imaging.BitmapDecoder, Windows.Foundation, ContentType=WindowsRuntime]
$null = [Windows.Storage.Streams.InMemoryRandomAccessStream, Windows.Foundation, ContentType=WindowsRuntime]
$null = [Windows.Storage.Streams.DataWriter, Windows.Foundation, ContentType=WindowsRuntime]
$null = [Windows.Graphics.Imaging.SoftwareBitmap, Windows.Foundation, ContentType=WindowsRuntime]
$script:asTaskGeneric = ([System.WindowsRuntimeSystemExtensions].GetMethods() | Where-Object { $_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation\`1' })[0]
function global:Await($WinRtTask, $ResultType) {
$asTask = $script:asTaskGeneric.MakeGenericMethod($ResultType)
$netTask = $asTask.Invoke($null, @($WinRtTask))
$netTask.Wait(-1) | Out-Null
$netTask.Result
}
$script:ocrReady = $true
} catch {
$script:ocrReady = $false
}
`;
/**
* 图像模板匹配预加载段(v15)。
*
* 动机:UIA 靠控件树、OCR 靠文字 —— **纯图标/无文字的控件**两者都抓不到(审查报告 §5.2 指出的盲区)。
* 做法:把模板图与屏幕区域都转灰度,做**零均值归一化互相关(ZNCC)**;
* 全分辨率逐像素太慢(3440×1440 屏 × 64×64 模板 ≈ 190 亿次),故**粗到精**:
* ① 1/4 分辨率全图扫描(约 8000 万次)取候选
* ② 对候选在全分辨率 ±10px 邻域精修
* 编译一次常驻(随助手预加载),运行时纯 native 循环。
* 亮度和对比度无关(ZNCC 归一化),故对主题/亮度变化不敏感。
*/
const IMAGE_MATCH_PRELUDE = `
Add-Type -AssemblyName System.Drawing
# ⚠️ Add-Type 的老坑:-AssemblyName 只把程序集加载进 PowerShell 会话,
# 编译 C# 时还必须显式 -ReferencedAssemblies,否则报「命名空间 System.Drawing
# 中不存在 Imaging」。真机踩过:编译失败会让整个助手起不来,并伴随误导性的
# 「找不到类型 System.Windows.Forms.SystemInformation」二次错误。
Add-Type @"
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Imaging;
using System.Runtime.InteropServices;
public class NAImageMatch {
public class Hit { public int x; public int y; public int w; public int h; public double score; }
private static byte[] Gray(Bitmap src, out int w, out int h) {
w = src.Width; h = src.Height;
var dst = new byte[w * h];
var rect = new Rectangle(0, 0, w, h);
Bitmap bmp = src.PixelFormat == PixelFormat.Format24bppRgb ? src : src.Clone(rect, PixelFormat.Format24bppRgb);
var data = bmp.LockBits(rect, ImageLockMode.ReadOnly, PixelFormat.Format24bppRgb);
try {
int stride = data.Stride;
var buf = new byte[stride * h];
Marshal.Copy(data.Scan0, buf, 0, buf.Length);
for (int y = 0; y < h; y++) {
int row = y * stride;
for (int x = 0; x < w; x++) {
int i = row + x * 3;
// BGR -> luma (integer approximation of ITU-R BT.601)
dst[y * w + x] = (byte)((buf[i + 2] * 77 + buf[i + 1] * 150 + buf[i] * 29) >> 8);
}
}
} finally { bmp.UnlockBits(data); if (bmp != src) bmp.Dispose(); }
return dst;
}
// Area-average downscale (coarse search layer)
private static byte[] Downscale(byte[] src, int w, int h, int f, out int ow, out int oh) {
ow = w / f; oh = h / f;
var dst = new byte[ow * oh];
for (int y = 0; y < oh; y++) {
for (int x = 0; x < ow; x++) {
int sum = 0;
for (int dy = 0; dy < f; dy++) {
int row = (y * f + dy) * w;
for (int dx = 0; dx < f; dx++) sum += src[row + x * f + dx];
}
dst[y * ow + x] = (byte)(sum / (f * f));
}
}
return dst;
}
// Zero-mean normalized cross correlation; 1.0 = identical
private static double Zncc(byte[] S, int sw, int sx, int sy, byte[] T, int tw, int th) {
int n = tw * th;
double sumT = 0;
for (int i = 0; i < n; i++) sumT += T[i];
double meanT = sumT / n;
double sumS = 0;
for (int y = 0; y < th; y++) {
int row = (sy + y) * sw + sx;
for (int x = 0; x < tw; x++) sumS += S[row + x];
}
double meanS = sumS / n;
double num = 0, dS = 0, dT = 0;
for (int y = 0; y < th; y++) {
int row = (sy + y) * sw + sx;
int trow = y * tw;
for (int x = 0; x < tw; x++) {
double a = S[row + x] - meanS;
double b = T[trow + x] - meanT;
num += a * b; dS += a * a; dT += b * b;
}
}
double den = Math.Sqrt(dS * dT);
if (den < 1e-9) return 0;
return num / den;
}
/**
// (comment removed: ASCII only)
// (comment removed: ASCII only)
*/
public static string Match(string screenPath, string templatePath, int maxResults, double threshold, int searchX, int searchY, int searchW, int searchH) {
var hits = new List<Hit>();
using (var screenBmp = new Bitmap(screenPath))
using (var tmplBmp = new Bitmap(templatePath)) {
int sw, sh, tw, th;
var S0 = Gray(screenBmp, out sw, out sh);
var T0 = Gray(tmplBmp, out tw, out th);
if (tw >= sw || th >= sh) return "[]";
// Guard: a flat template carries no gradient information, so correlation is
// undefined (ZNCC denominator ~ 0). Report it explicitly instead of silently
// returning no hits - a uniform crop usually means the wrong region was picked.
//
// NOTE: build all JSON/string literals with the char constant Q.
// Writing a backslash-quote inside a TS template literal collapses it to a bare
// quote and breaks the C# compile - this trap bit this file twice.
const char Q = '"';
var ci0 = System.Globalization.CultureInfo.InvariantCulture;
double tSum = 0, tSum2 = 0;
for (int i = 0; i < T0.Length; i++) { tSum += T0[i]; tSum2 += (double)T0[i] * T0[i]; }
double tMean = tSum / T0.Length;
double tVar = (tSum2 / T0.Length) - (tMean * tMean);
if (tVar < 4.0) {
return "[" + "{" + Q + "__error" + Q + ":" + Q + "template_has_no_contrast" + Q
+ "," + Q + "variance" + Q + ":" + tVar.ToString("F2", ci0) + "}" + "]";
}
// Optional search window: crop a sub-image, then add the offset back to hit coords
int ox = 0, oy = 0;
if (searchW > 0 && searchH > 0) {
ox = Math.Max(0, Math.Min(searchX, sw - 1));
oy = Math.Max(0, Math.Min(searchY, sh - 1));
int w = Math.Min(searchW, sw - ox), h = Math.Min(searchH, sh - oy);
var sub = new byte[w * h];
for (int y = 0; y < h; y++) Array.Copy(S0, (oy + y) * sw + ox, sub, y * w, w);
S0 = sub; sw = w; sh = h;
}
const int F = 4;
int sw4, sh4, tw4, th4;
var S4 = Downscale(S0, sw, sh, F, out sw4, out sh4);
var T4 = Downscale(T0, tw, th, F, out tw4, out th4);
// (1) coarse scan: collect candidates.
// NOTE: the pre-filter must be MUCH looser than the final threshold.
// Downsampling lowers the correlation of the true position (typically 0.7-0.85
// for a 0.9+ full-res match), so a tight pre-filter discards the right answer
// before refinement ever runs (this exact bug made every match return empty).
var cands = new List<Hit>();
if (tw4 > 0 && th4 > 0 && tw4 <= sw4 && th4 <= sh4) {
double preFilter = Math.Max(0.30, threshold - 0.30);
double bestCoarse = -1; int bx4 = 0, by4 = 0;
for (int y = 0; y <= sh4 - th4; y++) {
for (int x = 0; x <= sw4 - tw4; x++) {
double sc = Zncc(S4, sw4, x, y, T4, tw4, th4);
if (sc > bestCoarse) { bestCoarse = sc; bx4 = x; by4 = y; }
if (sc >= preFilter) cands.Add(new Hit { x = x * F, y = y * F, w = tw, h = th, score = sc });
}
}
// Belt and braces: always keep the coarse best, even if it missed the pre-filter
if (bestCoarse > 0) {
bool seen = false;
foreach (var c in cands) { if (Math.Abs(c.x - bx4 * F) < F && Math.Abs(c.y - by4 * F) < F) { seen = true; break; } }
if (!seen) cands.Add(new Hit { x = bx4 * F, y = by4 * F, w = tw, h = th, score = bestCoarse });
}
}
cands.Sort((a, b) => b.score.CompareTo(a.score));
if (cands.Count > 40) cands.RemoveRange(40, cands.Count - 40);
// (2) refine at full resolution within a +/-F*2 neighborhood
foreach (var c in cands) {
double best = -1; int bx = c.x, by = c.y;
for (int dy = -F * 2; dy <= F * 2; dy++) {
for (int dx = -F * 2; dx <= F * 2; dx++) {
int x = c.x + dx, y = c.y + dy;
if (x < 0 || y < 0 || x + tw > sw || y + th > sh) continue;
double sc = Zncc(S0, sw, x, y, T0, tw, th);
if (sc > best) { best = sc; bx = x; by = y; }
}
}
if (best >= threshold) hits.Add(new Hit { x = bx + ox, y = by + oy, w = tw, h = th, score = best });
}
// (3) de-dup: non-maximum suppression (keep best within 8px)
hits.Sort((a, b) => b.score.CompareTo(a.score));
var final = new List<Hit>();
foreach (var h in hits) {
bool dup = false;
foreach (var f in final) {
if (Math.Abs(f.x - h.x) < 8 && Math.Abs(f.y - h.y) < 8) { dup = true; break; }
}
if (!dup) final.Add(h);
if (final.Count >= maxResults) break;
}
var parts = new List<string>();
foreach (var h in final) {
// Build JSON by concatenation; reuse the shared Q / ci0 declared above.
var ci = ci0;
parts.Add("{" + Q + "x" + Q + ":" + h.x.ToString(ci) + "," + Q + "y" + Q + ":" + h.y.ToString(ci)
+ "," + Q + "width" + Q + ":" + h.w.ToString(ci) + "," + Q + "height" + Q + ":" + h.h.ToString(ci)
+ "," + Q + "score" + Q + ":" + h.score.ToString("F4", ci) + "}");
}
return "[" + String.Join(",", parts.ToArray()) + "]";
}
}
// Capture a screen region to a file (avoids extra process round-trips)
public static string Capture(string outPath, int x, int y, int w, int h) {
using (var bmp = new Bitmap(w, h))
using (var g = Graphics.FromImage(bmp)) {
g.CopyFromScreen(x, y, 0, 0, new Size(w, h));
bmp.Save(outPath, ImageFormat.Png);
}
return outPath;
}
}
"@ -ReferencedAssemblies System.Drawing
`;
/**
* 助手统一预加载段(window.list / window.focus / screen.find 的 UIA/OCR/图像三引擎共用)。
* ⚠️ 必须定义在四段之后(块级作用域),且**四段缺一不可** ——
@@ -483,7 +121,17 @@ export function warmupWindowHelper(): void {
}, 1500).unref?.();
}
export const WIN_HELPER_PRELUDE = `${WIN32_PRELUDE}
/**
* 常驻助手的预加载段。
*
* ⚠️ `PS_DPI_AWARE` **必须排在最前**,且不能放进 `OCR_PRELUDE` 的 try/catch 里 ——
* 那段 catch 是为了「缺 WinRT 组件时只让 OCR 退化」,但它同时也会把
* `SetProcessDPIAware` 一起吞掉;一旦吞掉,助手退回 DPI 不感知,
* `GetWindowRect`(window.list)就与 UIA / 截图的口径不一致了。
* DPI 感知是**坐标口径前提**,不是可退化项。
*/
export const WIN_HELPER_PRELUDE = `${PS_DPI_AWARE}
${WIN32_PRELUDE}
${UIA_ASSEMBLY_PRELUDE}
${OCR_PRELUDE}
${IMAGE_MATCH_PRELUDE}`;
@@ -570,14 +218,18 @@ export async function windowFocus(args: Args): Promise<unknown> {
/**
* 聚焦脚本主体(不含 prelude)。⚠️ 严禁 exit —— 会杀掉常驻助手进程;
* 未找到时改为输出 `{"found":false}` 并 return。
*
* ⚠️⚠️ `title` / `hwnd` 来自控制端,**必须**用 `psLit` 包成 PS 单引号字面量。
* 曾用 `JSON.stringify` → 双引号串 + `\"` 被 PS 解析成「字面反斜杠 + 结束引号」
* → 任意命令执行(P0 W-01,真机实测「返回正常业务错误但命令已执行」)。
*/
function focusBody(title: string | undefined, hwnd: string | undefined): string {
return `
$h = [IntPtr]::Zero
$want = ${JSON.stringify(hwnd ?? '')}
$want = ${psLit(hwnd ?? '')}
if ($want -ne '') { $h = [IntPtr][Convert]::ToInt64($want, 16) }
else {
$re = ${JSON.stringify(title ?? '')}
$re = ${psLit(title ?? '')}
$fg = [NAWin32]::GetForegroundWindow()
$found = [IntPtr]::Zero
$cb = [NAWin32+EnumProc]{
@@ -664,11 +316,26 @@ export async function screenFind(args: Args): Promise<unknown> {
}
const text = (args['text'] as string | undefined) ?? '';
const wantImage = (args['method'] as string | undefined) === 'image';
if (!wantImage && (typeof text !== 'string' || text.length === 0)) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'text 不能为空(method=image 时用 template 代替)');
// v2.0.0 修正:`where` 可单独使用(不传 text)——「找一个 enabled 的按钮」比
// 「找一个叫某名字的按钮」更贴近真实意图。此前只在 gui.await 侧放开了,
// screen.find 自身的入口校验没同步,导致直连调用(以及 await 的 where-only)
// 被这里的"text 不能为空"拦下(真机 2026-10-09 验证时暴露)。
const hasWhere = (() => {
const w = args['where'];
return typeof w === 'object' && w !== null && !Array.isArray(w) && Object.keys(w as object).length > 0;
})();
if (!wantImage && text.length === 0 && !hasWhere) {
throw new CapabilityError(
ErrorCodes.PARAM_INVALID,
'text 与 where 至少给一个(method=image 时用 template)',
{ hint: 'where 仅 UIA 引擎生效,如 {"enabled":true}' },
);
}
const windowTitle = args['window'] as string | undefined;
const controlType = args['control_type'] as string | undefined;
// v2.0.0:属性谓词。仅 UIA 引擎生效;OCR / 图像模板忽略(无属性可读),
// 此时在返回里带 note 说明,避免调用方误以为"按属性过滤过了"。
const whereArg = args['where'] as Record<string, unknown> | undefined;
const limit = (args['limit'] as number | undefined) ?? 20;
const method = (args['method'] as string | undefined) ?? 'auto'; // auto | uia | ocr
// v12.2:等待语义 —— 界面常有动画/加载延迟,一次性查找极易假失败。
@@ -686,17 +353,39 @@ export async function screenFind(args: Args): Promise<unknown> {
// ---------- UIA 引擎 ----------
const uiaFind = async (): Promise<Record<string, unknown>[]> => {
const ct = controlType ?? '';
// ⚠️ 全部来自控制端,一律 psLit(详见文件顶部 windowFocus 处的 P0 说明)
const body = `
$text = ${JSON.stringify(text)}
$text = ${psLit(text)}
$limit = ${Number(limit)}
$ct = ${JSON.stringify(controlType ?? '')}
$winTitle = ${JSON.stringify(windowTitle ?? '')}
$ct = ${psLit(controlType ?? '')}
$winTitle = ${psLit(windowTitle ?? '')}
# v2.0.0 属性过滤:where 只含 enabled/selected/value/toggle 四类键(白名单式)。
#
# ⚠️⚠️ 这里踩过**两次**同一个坑,真机 2026-10-09 连修两轮才通:
# ✗ 第一版:$where = {"enabled":true} → PS 不认 JSON 字面量,ParseError
# ✗ 第二版:$whereJson = "{\"enabled\":true}" → PS 5.1 **不认反斜杠转义**
# (PS 用反引号),依然 ParseError,整份助手脚本失败
# (症状:带 where 一律"窗口操作失败",不带 where 完全正常)
# ✓ 第三版:**PS 单引号字符串字面量**,JSON 内的单引号翻倍成 ''(本行)
# 并在真机现场验证过:$j = '{"enabled":true}' → ConvertFrom-Json 摊平成哈希表 → OK。
# 摊平是因为 Test-UiaWhere 依赖 .Keys 遍历,而 PSCustomObject 没有 Keys。
$whereJson = ${psLit(whereArg ? JSON.stringify(whereArg) : '')}
$where = $null
if ($whereJson -ne '') {
$where = @{}
try {
($whereJson | ConvertFrom-Json).PSObject.Properties | ForEach-Object { $where[$_.Name] = $_.Value }
} catch {
$where = $null # JSON 不合法就当没传(业务层已在 Node 侧校验过键名)
}
if ($where -ne $null -and $where.Count -eq 0) { $where = $null }
}
$out = New-Object System.Collections.ArrayList
if ($winTitle -ne '') {
# 指定窗口:直接在该窗口子树内找
$w = Get-WinByTitle $winTitle
if ($w -eq $null) { Write-Output '[]'; return } # 严禁 exit:会杀掉常驻助手
$out = Find-UiaByText $w $text $limit $ct
$out = Find-UiaByText $w $text $limit $ct $where
} else {
# v14 优化:先在前台窗口子树内找(典型只几百个元素,快一个数量级),
# 找不到再退回全桌面遍历(原来每次都在全桌面上跑,真机实测 3.1~3.7s)。
@@ -704,9 +393,9 @@ if ($winTitle -ne '') {
try {
$fgEl = [System.Windows.Automation.AutomationElement]::FromHandle([NAWin32]::GetForegroundWindow())
} catch {}
if ($fgEl -ne $null) { $out = Find-UiaByText $fgEl $text $limit $ct }
if ($fgEl -ne $null) { $out = Find-UiaByText $fgEl $text $limit $ct $where }
if ($out.Count -eq 0) {
$out = Find-UiaByText ([System.Windows.Automation.AutomationElement]::RootElement) $text $limit $ct
$out = Find-UiaByText ([System.Windows.Automation.AutomationElement]::RootElement) $text $limit $ct $where
}
}
# ── 输出 ──(遍历与子串过滤已收敛到预加载的 Find-UiaByText,避免逻辑重复)
@@ -730,7 +419,8 @@ if ($out.Count -eq 0) { Write-Output '[]' } else { $out | ConvertTo-Json -Compre
const shot = join(tmpDir, `match-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.png`);
// PowerShell 单引号字面量:不插值(避免路径里的 $ 被展开)、不处理反斜杠转义。
// 不能用 JSON.stringify —— 它会产出双引号串,而 PS 双引号会做变量插值且把 \ 当字面两个反斜杠。
const q = (v: string): string => `'${v.replace(/'/g, "''")}'`;
// (统一走 util/quote.ts 的 psLit,避免各文件各写一份实现而漏改)
const q = psLit;
// 截图区域:显式 region 优先,否则整块虚拟屏
const capX = regionArg ? regionArg.x : 'VSX';
@@ -801,7 +491,7 @@ $xv = ${regionArg ? regionArg.x : -1}
$yv = ${regionArg ? regionArg.y : -1}
$wv = ${regionArg ? regionArg.width : -1}
$hv = ${regionArg ? regionArg.height : -1}
$winRe = ${JSON.stringify(regionArg ? '' : windowTitle ?? '')}
$winRe = ${psLit(regionArg ? '' : windowTitle ?? '')}
$x = 0; $y = 0
$vs = [System.Windows.Forms.SystemInformation]::VirtualScreen
$w = $vs.Width; $h = $vs.Height
@@ -861,11 +551,11 @@ $engine = [Windows.Media.Ocr.OcrEngine]::TryCreateFromUserProfileLanguages()
if ($null -eq $engine) { Write-Output '[]'; return }
$result = Await ($engine.RecognizeAsync($soft)) ([Windows.Media.Ocr.OcrResult])
$text = ${JSON.stringify(text)}
$text = ${psLit(text)}
$limit = ${Number(limit)}
# Windows OCR 会在中文字符间插入空格(如「我的加速」识别为「我 的 加 速」),
# 因此匹配一律在「去除空白后」的文本上进行(两边同样处理)。
$textPlain = ${JSON.stringify(text)}.Replace(' ','')
$textPlain = (${psLit(text)}).Replace(' ','')
if ($textPlain -eq '') { $textPlain = ($text -replace '\\s','') }
$out = New-Object System.Collections.ArrayList
foreach ($line in $result.Lines) {
@@ -954,10 +644,22 @@ $out | ConvertTo-Json -Compress
await new Promise((r) => setTimeout(r, intervalMs));
last = await attempt();
}
// v2.0.0:where 只在 UIA 引擎下有属性可读 —— 若最终走的是 OCR/image,
// 必须**明确告知**,否则调用方会以为"按属性过滤过了"而误判结果。
const whereIgnored = hasWhere && last.engine !== 'uia';
return {
matches: last.matches,
engine: last.engine,
waited_ms: Date.now() - startedAt,
...(waitMs > 0 ? { wait_ms: waitMs } : {}),
...(whereIgnored
? {
note:
`where 已忽略:实际引擎为 ${last.engine}(无控件属性可读)。` +
'属性过滤仅在 UIA 生效;text 过滤仍然有效。',
where_applied: false,
}
: {}),
...(hasWhere && last.engine === 'uia' ? { where_applied: true } : {}),
};
}
+11 -3
View File
@@ -13,7 +13,7 @@ export interface TlsMaterial {
* 确保自签证书存在(首次生成并缓存到 ~/.nodeagent/certs/)。
* v1 用自签证书做传输加密;控制端可校验证书指纹或临时跳过校验。
*/
export function ensureCert(): TlsMaterial {
export async function ensureCert(): Promise<TlsMaterial> {
const dir = join(agentDir(), 'certs');
const certPath = join(dir, 'cert.pem');
const keyPath = join(dir, 'key.pem');
@@ -23,8 +23,16 @@ export function ensureCert(): TlsMaterial {
}
mkdirSync(dir, { recursive: true });
const pems = selfsigned.generate([{ name: 'commonName', value: 'nodeagent' }], {
days: 3650,
// selfsigned 5.x:`generate` 改为**异步**(旧版同步返回)。
// 升级动因不是追新,而是安全:2.x 依赖 node-forge 1.4.0,
// 该版本有 high 级 advisory 且**上游无修复版本**(patched_versions: None);
// 5.x 已整体换成 @peculiar/x509 + pkijs,node-forge 被彻底移除。
// v5 把 `days` 换成了 `notAfterDate`(绝对时间),语义相同但必须换算 ——
// 这是升级时的**静默不兼容点**:老写法编译期报 TS2353,能拦住;
// 但若只改类型不改值,就会签出一个有效期不对的证书,运行期才暴露。
const notAfterDate = new Date(Date.now() + 3650 * 24 * 60 * 60 * 1000);
const pems = await selfsigned.generate([{ name: 'commonName', value: 'nodeagent' }], {
notAfterDate,
keySize: 2048,
algorithm: 'sha256',
});
+13
View File
@@ -41,6 +41,19 @@ export interface AgentConfig {
* "allow_from": ["192.168.0.0/16", "10.0.0.0/8"]
*/
allow_from?: string[];
/**
* v2.0.0 连接层防护(三项,默认值见 server.ts)。
* 单项缺省即启用默认策略;显式设 0 可关闭对应项。
*/
security?: {
/** 握手失败封禁:同来源连续失败 max_attempts 次起,ban = ban_ms × 2^(N-max),封顶 24h */
auth_ban?: { max_attempts?: number; ban_ms?: number; whitelist?: string[] };
/** 最大并发连接数(默认 8) */
max_connections?: number;
/** 空闲断开(默认 30 分钟;0 = 关闭) */
idle_timeout_ms?: number;
};
/** v3+:审计日志配置 */
audit?: {
/** 是否启用(默认 true) */
+59 -7
View File
@@ -1,6 +1,8 @@
import { watch, type FSWatcher } from 'node:fs';
import { exec } from 'node:child_process';
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
// ⚠️ E-03:file watcher 必须与 fs.* 用**同一套**路径白名单守卫(否则 fs_roots 形同虚设)
import { guardPath } from './capabilities/fs.js';
type Args = Record<string, unknown>;
@@ -30,6 +32,8 @@ interface WatchRecord {
id: string;
kind: 'file' | 'process' | 'net';
owner: string; // 连接标识,断开时清理
/** 创建者的 client_id —— 跨控制端隔离的依据(E-01/E-02) */
clientId: string;
description: string;
createdAt: number;
count: number;
@@ -186,10 +190,23 @@ function startNetWatch(rec: WatchRecord, intervalMs: number): () => void {
export interface WatchContext {
/** 连接标识(用于断开时清理) */
owner: string;
/** 调用方 client_id(跨控制端隔离;缺省视为匿名,彼此可见) */
clientId?: string;
/** 该连接的推送出口 */
emit: (evt: WatchEvent) => void;
}
/**
* 该 watcher 是否属于 caller。
*
* 兼容性:`clientId` 为空(匿名 / 老客户端)时**不做隔离** —— 但只要有 client_id,
* 隔离就必须生效:否则 B 控制端能看到甚至 `event.unwatch` 掉 A 的订阅(E-01/E-02 实测)。
*/
function ownedBy(rec: WatchRecord, callerId: string): boolean {
if (!callerId) return true;
return !rec.clientId || rec.clientId === callerId;
}
export async function eventWatch(args: Args, ctx: WatchContext): Promise<unknown> {
const kind = args['kind'] as 'file' | 'process' | 'net';
if (!['file', 'process', 'net'].includes(kind)) {
@@ -202,6 +219,7 @@ export async function eventWatch(args: Args, ctx: WatchContext): Promise<unknown
id,
kind,
owner: ctx.owner,
clientId: ctx.clientId ?? '',
description: '',
createdAt: Date.now(),
count: 0,
@@ -210,8 +228,12 @@ export async function eventWatch(args: Args, ctx: WatchContext): Promise<unknown
};
if (kind === 'file') {
const path = args['path'] as string | undefined;
if (!path) throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'file 类型需要 path');
const raw = args['path'] as string | undefined;
if (!raw) throw new CapabilityError(ErrorCodes.PARAM_INVALID, 'file 类型需要 path');
// ⚠️ E-03(实测复现):file watcher **必须**过 `guardPath`。
// 原来 `fs.read` 会被 `fs_roots` 正确拦下,而 `event.watch` 用同一路径却畅通无阻
// —— 白名单形同虚设(可通过事件流观察白名单外的目录变动)。
const path = guardPath(raw);
const recursive = args['recursive'] === true;
rec.dispose = startFileWatch(rec, path, recursive, pattern);
rec.description = `文件变动 ${path}${recursive ? '(递归)' : ''}${pattern ? ` pattern=${pattern}` : ''}`;
@@ -227,18 +249,27 @@ export async function eventWatch(args: Args, ctx: WatchContext): Promise<unknown
return { watch_id: id, kind, description: rec.description };
}
export async function eventUnwatch(args: Args): Promise<unknown> {
/** 取消订阅。**只有创建者(同 client_id)能取消** —— 否则任意控制端可掐掉别人的监控。 */
export async function eventUnwatch(args: Args, ctx?: WatchContext): Promise<unknown> {
const id = args['watch_id'] as string;
const rec = watches.get(id);
if (!rec) return { removed: false };
const callerId = ctx?.clientId ?? '';
if (!ownedBy(rec, callerId)) {
// 明确回报,而不是假装成功(也便于审计追查越权尝试)
return { removed: false, reason: `订阅 ${id} 属于其他控制端,无权取消` };
}
rec.dispose();
watches.delete(id);
return { removed: true };
}
export async function eventList(_args: Args): Promise<unknown> {
/** 列出订阅:**只列自己的**(含匿名创建的)。 */
export async function eventList(_args: Args, ctx?: WatchContext): Promise<unknown> {
const callerId = ctx?.clientId ?? '';
const mine = [...watches.values()].filter((w) => ownedBy(w, callerId));
return {
watches: [...watches.values()].map((w) => ({
watches: mine.map((w) => ({
watch_id: w.id,
kind: w.kind,
description: w.description,
@@ -249,18 +280,39 @@ export async function eventList(_args: Args): Promise<unknown> {
};
}
export async function eventPoll(args: Args): Promise<unknown> {
/** 拉取事件:只回「属于本 client_id 的订阅」产生的事件。 */
export async function eventPoll(args: Args, ctx?: WatchContext): Promise<unknown> {
const limit = Math.max(1, Math.min(500, (args['limit'] as number | undefined) ?? 50));
const since = (args['since'] as number | undefined) ?? 0;
const watchId = args['watch_id'] as string | undefined;
const callerId = ctx?.clientId ?? '';
// watch_id 显式指定时先验归属,避免「猜 id 就能读别人事件」
if (watchId) {
const rec = watches.get(watchId);
if (rec && !ownedBy(rec, callerId)) {
throw new CapabilityError(ErrorCodes.ACL_DENIED, `订阅 ${watchId} 属于其他控制端`, { watch_id: watchId });
}
}
let list = buffer.filter((e) => e.seq > since);
if (watchId) list = list.filter((e) => e.watch_id === watchId);
if (watchId) {
list = list.filter((e) => e.watch_id === watchId);
} else if (callerId) {
const allowed = new Set(
[...watches.values()].filter((w) => ownedBy(w, callerId)).map((w) => w.id),
);
list = list.filter((e) => allowed.has(e.watch_id));
}
const sliced = list.slice(0, limit);
const nextCursor = sliced.length > 0 ? sliced[sliced.length - 1]!.seq : since;
return { events: sliced, next_cursor: nextCursor, dropped };
}
/** 连接断开时清理该连接创建的所有订阅(避免 watcher 泄漏)。 */
/** v2.0.0:当前事件订阅数(空闲断开的豁免判据之一)。 */
export function watchCount(): number {
return watches.size;
}
export function disposeWatchesByOwner(owner: string): number {
let n = 0;
for (const [id, rec] of watches) {
+150 -2
View File
@@ -1,12 +1,15 @@
import os from 'node:os';
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { warmupWindowHelper } from './capabilities/window.js';
import { CAPABILITY_MANIFEST } from '@nodeagent/protocol';
import { loadAgentConfig, agentConfigPath } from './config.js';
import { agentDir, loadAgentConfig, agentConfigPath } from './config.js';
import { ensureCert } from './certs.js';
import { createAgentServer } from './server.js';
import { initAudit, audit, auditFilePath } from './audit.js';
import { startBeacon, DEFAULT_DISCOVERY_PORT, type Beacon } from './discovery.js';
import { startHubClient, type HubClient } from './hub-mode.js';
import { recordLKG } from './lkg.js';
function listLocalIps(): string[] {
const out: string[] = [];
@@ -18,6 +21,143 @@ function listLocalIps(): string[] {
return out;
}
/**
* 看门狗失败计数归零(v2.1.0,决策 R-1;scripts/startup-guard.mjs)。
*
* 看门狗用 `<数据目录>/state/startup-state.json` 记录「连续启动失败次数」,
* 达到阈值即从 LKG 恢复入口。计数必须在**启动成功后**归零 —— 与 LKG 同一
* 铁律:不把「启动中」当成功,否则坏版本每崩一次都会被上一次的成功洗白,
* 阈值形同虚设。写入失败一律吞掉:重置计数不能影响启动(与 recordLKG 同哲学)。
*/
function resetStartupGuardState(): void {
try {
const dir = join(agentDir(), 'state');
mkdirSync(dir, { recursive: true });
writeFileSync(
join(dir, 'startup-state.json'),
`${JSON.stringify({ failures: 0, updated_at: new Date().toISOString() })}\n`,
'utf8',
);
} catch {
/* 重置失败不影响启动 */
}
}
// ---------------------------------------------------------------------------
// v2.0.3:进程级最后一道防线(崩溃链兜底)
// ---------------------------------------------------------------------------
//
// ## 为什么需要
// 真机缺陷链的共同根因:`ws.on('message')` 里用 `void handleMessage(...)` 启动异步处理,
// 未挂 `.catch`;而全仓**没有任何** `unhandledRejection` 处理器 → Node ≥22 的默认策略是
// **直接终止进程**。于是一条畸形报文就能把被控端打死(外部只见 `code=1` / 连接断开),
// 且因为 `verifyHmac`(psk 默认路径)没有 try/catch,连**未认证**的报文都能触发。
//
// 根治在源头(`server.ts` 已改 `.catch(...)` + 逐处报文类型校验),这里补的是
// 「将来某处再漏一个 await」时的兜底 —— 有证据地活着,胜过静默地死。
//
// ## 为什么**不**退出进程
// 被控端是常驻服务,「可用性」优先于「出错即死」。若这里改成 `process.exit(1)`,
// 就退化回「一条畸形报文打死服务」—— 正是本次要修的东西。
// 代价(可能带着不一致的状态继续跑)由两点对冲:
// ① 源头已有报文校验与 `.catch`;② 这里每次都写审计 + 日志,问题不会被掩盖。
//
// ## 为什么要限流审计
// 畸形报文可以高频重复(扫描/爆破)→ 无限写 `agent.error` 会把审计日志冲爆、把
// 正常记录挤掉。故日志每次都打(stdout 由控制台的环形缓冲兜底),但**审计按时间窗限流**。
let procErrCount = 0;
let lastProcErrAuditAt = 0;
/** 两条 `agent.error` 审计之间的最小间隔 */
const PROC_ERR_AUDIT_INTERVAL_MS = 10_000;
function reportProcessError(kind: string, detail: string): void {
procErrCount += 1;
console.error(
`[${new Date().toISOString()}] [ERROR] ${kind}(已拦截,进程继续运行;累计 ${procErrCount} 次):${detail}`,
);
const now = Date.now();
if (now - lastProcErrAuditAt < PROC_ERR_AUDIT_INTERVAL_MS) return;
lastProcErrAuditAt = now;
audit({
type: 'agent.error',
status: 'failed',
reason: `${kind}: ${detail}(累计 ${procErrCount} 次,审计按 ${PROC_ERR_AUDIT_INTERVAL_MS / 1000}s 限流)`,
});
}
process.on('unhandledRejection', (reason: unknown) => {
reportProcessError(
'未处理的 Promise 拒绝',
reason instanceof Error ? `${reason.name}: ${reason.message}` : String(reason),
);
});
// ---------------------------------------------------------------------------
// stdout / stderr 断管容错(2026-10-17 真机两次踩到)
// ---------------------------------------------------------------------------
//
// ## 现象
// 端口仍然 `OPEN`,但**所有** RPC 都超时(握手都不回)。持续数分钟不自愈,
// 只能靠重开控制台恢复。
//
// ## 根因
// 被控端由控制台 spawn 时,stdout/stderr 是**管道**而非终端。用户退出控制台
// (尤其选「保留被控端」)后,管道**读端消失**,而 agent 仍在往这个死管道写日志:
// - Node 对未监听 'error' 的 stream 会把 `EPIPE` **抛成未捕获异常** → 进程退出;
// - 更糟的是写端在无读者时可能**阻塞**,把事件循环一起卡住 ——
// 于是端口保住了(OS 层 socket 还在),但 JS 再也不执行了。
// 这解释了为什么「退出控制台后被控端还在,却谁都连不上」:它活着,但哑了。
//
// ## 做法
// ① 监听 'error' 吞掉 EPIPE,不让它升级成未捕获异常;
// ② 一旦判定通道已断,就**短路后续写**,不再往死管道里堆字节。
//
// 日志不会因此丢失:审计走文件(`audit.log`),控制台在时照常收到管道输出。
// 宁可少一行 stdout,也不能让常驻服务因为「没人看日志」而假死。
let stdoutDead = false;
let stderrDead = false;
function markDead(e: NodeJS.ErrnoException, isErr: boolean): void {
if (e.code === 'EPIPE' || e.code === 'ERR_STREAM_DESTROYED' || e.code === 'ERR_STREAM_WRITE_AFTER_END') {
if (isErr) stderrDead = true;
else stdoutDead = true;
return;
}
// 其他写错误也一律吞掉:日志通道的故障不该打死被控端
if (isErr) stderrDead = true;
else stdoutDead = true;
}
process.stdout.on('error', (e: NodeJS.ErrnoException) => markDead(e, false));
process.stderr.on('error', (e: NodeJS.ErrnoException) => markDead(e, true));
const rawLog = console.log.bind(console);
const rawErr = console.error.bind(console);
console.log = (...a: unknown[]): void => {
if (stdoutDead) return;
try {
rawLog(...a);
} catch {
stdoutDead = true;
}
};
console.error = (...a: unknown[]): void => {
if (stderrDead) return;
try {
rawErr(...a);
} catch {
stderrDead = true;
}
};
process.on('uncaughtException', (err: unknown) => {
reportProcessError(
'未捕获异常',
err instanceof Error ? `${err.name}: ${err.message}` : String(err),
);
});
async function main(): Promise<void> {
const { config, isNew } = loadAgentConfig();
initAudit({
@@ -28,7 +168,7 @@ async function main(): Promise<void> {
});
// v6:Hub 模式下不监听本地端口,改为主动外连 Hub(穿 NAT)
const useHub = config.hub?.enabled === true;
const tls = !useHub && config.tls ? ensureCert() : null;
const tls = !useHub && config.tls ? await ensureCert() : null;
const server = useHub ? null : await createAgentServer(config, tls);
let hubClient: HubClient | null = null;
@@ -76,6 +216,14 @@ async function main(): Promise<void> {
},
(msg) => console.log(`[WARN] ${msg}`),
);
// 交付流水线 §6:此刻脚本已加载、配置已读、证书已签、端口已绑、心跳已起 ——
// 走到这里说明「这个版本至少能启动」,故记为最后已知可用版本(LKG)。
// 注意**不在更新写完时就记**:那会把尚未验证的版本当成已知可用,LKG 就失去意义。
recordLKG();
// 同理(v2.1.0 看门狗):启动成功才把「连续启动失败」计数归零。
// 顺序在 recordLKG 之后、启动横幅之前 —— 两者都是「起来了」的注脚。
resetStartupGuardState();
console.log('');
console.log(' nodeagent 被控端已启动');
console.log(' ─────────────────────────────────────────────');
+179
View File
@@ -0,0 +1,179 @@
import { readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { join, basename } from 'node:path';
import { agentDir } from './config.js';
import { buildInfo } from './capabilities/system.js';
/**
* 最后已知可用版本(Last-Known-Good, LKG)—— 交付流水线 §6 的回滚锚点。
*
* ## 为什么需要(本项目特有风险,通用发布规范里没有)
* 对 Web 服务,发布失败可以回滚、可以摘流量。对 nodeagent:
* **回滚通道本身就是被更新的那个程序**。一次失败的更新会让一台物理不可达的
* 远程设备永久脱管 —— 这是本项目最严重的上线失败形态。
*
* ## 记什么、什么时候记
* 关键设计:**只在「本次启动确实起来了」之后才记**。
*
* 若在「更新刚写完」时就记 LKG,等于把还没验证的版本当成已知可用 ——
* 那 LKG 就失去了意义。故由 `index.ts` 在服务器 listen 成功、打印完启动信息后
* 调用 `recordLKG()`:此刻能走到这一行,说明脚本能被 Node 加载、配置能读、
* 证书能签、端口能绑。**能启动 ≠ 功能全对,但能启动 = 至少不是「打死」状态。**
*
* ## 明确不做的事:自动回滚
* 本文刻意**不实现**「启动失败自动回滚」。理由:
* - 远程设备上无法区分「更新本身坏了」与「网络抖动 / 端口被占 / 依赖服务没起」;
* - 自动回滚可能触发**回滚循环**(回滚→启动→又判定失败→再回滚),
* 在无人值守设备上比「停在坏版本」更难恢复;
* - 被控端一旦自作主张改自己的程序,控制端将失去对「当前跑的是什么」的掌控。
*
* 因此 LKG 的定位是**给回滚提供确定的目标**,而不是替人做回滚决策:
* 控制端 `nodeagent rollback` 读 LKG → 覆盖回入口文件 → 重启。
*/
export interface LkgRecord {
/** 入口脚本(agent.mjs)的 sha256 前 12 位 —— 与 system.info 的 build.hash 同口径 */
hash: string;
/**
* v2.1.0 新增(只增字段):入口脚本的 sha256 **全量 64 位**。
*
* 为什么加:回滚时控制端要用「枚举备份 + 指纹匹配」定位内容,12 位前缀存在
* 理论碰撞风险(1/16¹²,可接受但非零);全量比对消除该风险。
* 老记录(无此字段)回落 12 位前缀比对,见 `lkgHashMatches`。
*/
hash_full?: string;
bytes: number;
script_path: string;
/**
* v2.1.0 新增(只增字段):回滚内容候选的文件名模式(`<basename>.bak*`)。
* 覆盖推送式 deploy 的固定名 `.bak` 与拉取式 selfupdate 的时间戳名 `.bak-<ts>`
* 两套命名,供控制端枚举候选(`fs.list` + 过滤)。
*/
backup_glob?: string;
/** 记录时刻(Unix ms) */
recorded_at: number;
/** 当时的 node 版本,便于判断「是不是 Node 运行时升级导致的」 */
node_version: string;
platform: string;
}
export function lkgFilePath(): string {
return join(agentDir(), 'agent-lkg.json');
}
/**
* 计算当前入口脚本指纹。
* 与 `capabilities/system.ts` 的 buildInfo() 同口径(都是脚本内容的 sha256 前 12 位),
* 故可与 `nodeagent info` 里的 `build.hash` 直接比对。
*/
function currentScriptHash(): { hash: string; hash_full: string; bytes: number; path: string } | null {
const p = process.argv[1] ?? '';
if (!p) return null;
try {
const buf = readFileSync(p);
const full = createHash('sha256').update(buf).digest('hex');
return {
hash: full.slice(0, 12),
hash_full: full,
bytes: buf.length,
path: p,
};
} catch {
return null;
}
}
/**
* LKG 指纹比对(v2.1.0):有 `hash_full` 按**全量**比对,老记录回落 12 位前缀。
*
* 纯函数、不猜:两边都拿不出可比的值时返回 false。
* 供 rollback 的「备份指纹匹配」与任何需要判断「这份内容是不是 LKG」的场景共用,
* 避免各写一份比对规则(两份规则必然漂移)。
*/
export function lkgHashMatches(rec: LkgRecord | null, candidateFullHash: string | undefined | null): boolean {
if (!rec || !candidateFullHash) return false;
if (rec.hash_full) return rec.hash_full === candidateFullHash;
return rec.hash === candidateFullHash.slice(0, 12);
}
/**
* 记录当前版本为 LKG。
*
* 任何失败都被吞掉 —— 写入失败不能影响启动。
* @returns 写入的记录,失败返回 null
*/
export function recordLKG(): LkgRecord | null {
const cur = currentScriptHash();
if (!cur) return null;
const rec: LkgRecord = {
hash: cur.hash,
hash_full: cur.hash_full,
bytes: cur.bytes,
script_path: cur.path,
backup_glob: `${basename(cur.path)}.bak*`,
recorded_at: Date.now(),
node_version: process.version,
platform: process.platform,
};
try {
mkdirSync(agentDir(), { recursive: true });
writeFileSync(lkgFilePath(), `${JSON.stringify(rec, null, 2)}\n`, 'utf8');
return rec;
} catch {
return null;
}
}
/**
* 读取 LKG 记录;不存在或损坏返回 null(不作任何猜测)。
*
* 向后兼容:v2.1.0 之前写的记录没有 `hash_full` / `backup_glob` —— 原样读回,
* 由消费方按 `lkgHashMatches` 的 12 位前缀回落处理,不在这里猜测补齐。
*/
export function readLKG(): LkgRecord | null {
try {
const raw = readFileSync(lkgFilePath(), 'utf8');
const rec = JSON.parse(raw) as LkgRecord;
if (!rec || typeof rec.hash !== 'string') return null;
return rec;
} catch {
return null;
}
}
/**
* 当前版本与 LKG 是否一致。
*
* 用途:更新后复核 —— 若「当前 hash ≠ LKG」且刚做过更新,
* 说明新版本**尚未被证明能启动**,控制端应暂缓放量到其余设备(交付流水线 §6 分批策略)。
*/
export function lkgStatus(): {
lkg: LkgRecord | null;
current_hash: string | null;
matches: boolean;
/** LKG 与当前版本的字节差,便于快速判断是「升级」还是「回退」 */
byte_delta: number | null;
} {
const lkg = readLKG();
const cur = currentScriptHash();
const current_hash = cur?.hash ?? null;
return {
lkg,
current_hash,
matches: !!lkg && lkg.hash === current_hash,
byte_delta: lkg && cur ? cur.bytes - lkg.bytes : null,
};
}
/** 供审计/排障:LKG 文件是否存在及其大小(不读内容,避免大文件 IO)。 */
export function lkgFileInfo(): { path: string; exists: boolean; bytes: number } {
const p = lkgFilePath();
try {
return { path: p, exists: true, bytes: statSync(p).size };
} catch {
return { path: p, exists: false, bytes: 0 };
}
}
/** 让 system.info 能顺带暴露构建指纹(与 buildInfo 同源,避免两处口径漂移)。 */
export { buildInfo };
+28
View File
@@ -0,0 +1,28 @@
/**
* 被控端出网模块(桶文件)—— 导出面收敛。
*
* 目前只有代理出网(`proxy.ts`)。将来若新增其它出网能力(如镜像探测),
* 也从这里统一导出,调用方只依赖本文件,不直接深入子模块。
*
* 依赖纪律:本目录下的模块**只依赖 node 内置模块**,不 import 项目内任何模块 ——
* 保证可被单测独立加载、依赖方向严格单向(capabilities/* → net/)。
*/
export {
normalizeProxyUrl,
isBypassed,
isLoopbackTarget,
pickEnvProxy,
parseScutilProxy,
parseRegQueryProxy,
detectSystemProxy,
systemProxy,
__resetSystemProxyCache,
resolveProxy,
tunnelConnection,
httpGet,
type SystemProxyInfo,
type ProxyResolution,
type HttpResponse,
type CommandRunner,
} from './proxy.js';
+618
View File
@@ -0,0 +1,618 @@
/**
* 出站代理支持 —— 被控端「拉取式自更新」的出网模块。
*
* ## 为什么必须自己实现(这是缺陷,不是优化)
*
* Node 内置 `fetch`(undici)**默认不读任何代理环境变量** —— 实测:设了 `HTTPS_PROXY`
* 仍然 `fetch failed`。能开的只有 `NODE_USE_ENV_PROXY=1`(Node 22.x 实验性、24 正式),
* 而它**只能读环境变量**,读不到 Windows 系统代理(WinINET)或 macOS 系统代理
* —— 而中文用户用 Clash / v2rayN 时配置的恰恰是「系统代理」。
* 结果:境内用户即使代理一切正常,`system.agent.update` 也**必然**报 `fetch failed`。
* (真机取证见 apps/console/sidecar/proxy.mjs 顶部:VM 上 DNS 与 TCP 443 都通、
* 却 `fetch failed`;同机经代理取 `latest.json` 返回 302。)
*
* 另外 `fetch(url, { dispatcher })` 也不通:`dispatcher` 必须是 undici 的 Dispatcher 实例,
* 而 Node 没有把 undici 暴露成可导入的模块(只能整包 vendored 进来,会把 agent.mjs
* 撑胖一大截,且 undici 依赖 `.wasm` 解析器、esbuild 单文件打包有风险)。
*
* ## 做法
*
* 不用 `fetch`,改用 `node:http` / `node:https` 自己发请求:
* - **https 目标经代理**:先向代理发 `CONNECT host:443` 建隧道,再在隧道 socket 上做 TLS
* (通过 `createConnection` 交给 `http.request` —— 注意这里刻意用 `http` 而非 `https`,
* 因为我们交出去的**已经是 TLS socket**,用 `https.request` 会二次握手);
* - **http 目标经代理**:按 HTTP 代理规范发**绝对形态**请求行(`GET http://… HTTP/1.1`);
* - **无代理**:与改动前一致,直连(走 `https.request`)。
*
* ## 代理来源优先级
*
* ① `NA_PROXY`(显式覆盖,便于排查与将来做配置项)
* ② 目标协议对应的环境变量(`HTTPS_PROXY`/`https_proxy` → `ALL_PROXY`/`all_proxy` → …)
* ③ **系统代理**(Windows 读注册表 WinINET;macOS 读 `scutil --proxy`)
*
* `NO_PROXY` / `no_proxy` 命中的主机一律直连。只支持 **HTTP 代理**(`http(s)://`)——
* HTTPS 目标经 HTTP 代理走 CONNECT,是通用做法;`socks5://` 会**明确**记一条 note
* 而不是静默直连(静默正是本次缺陷最恶劣的地方)。
*
* ## 依赖纪律
*
* 本模块**只依赖 node 内置模块**(http/https/tls/child_process),不 import 项目内
* 任何模块 —— 保证可被单测独立加载、可被将来其它出网能力复用;依赖方向严格单向
* (selfupdate → net/proxy),杜绝环。
*
* ## 安全
*
* 只做「出站取数据」,不引入任何新依赖;TLS 证书默认校验,不提供跳过开关。
*/
import http from 'node:http';
import https from 'node:https';
import tls from 'node:tls';
import { execFile } from 'node:child_process';
/** 代理来源优先级(按目标协议分列)。`NA_PROXY` 恒为第一优先。 */
const ENV_ORDER: Record<string, string[]> = {
'https:': ['NA_PROXY', 'HTTPS_PROXY', 'https_proxy', 'ALL_PROXY', 'all_proxy', 'HTTP_PROXY', 'http_proxy'],
'http:': ['NA_PROXY', 'HTTP_PROXY', 'http_proxy', 'ALL_PROXY', 'all_proxy'],
};
const NO_PROXY_KEYS = ['NO_PROXY', 'no_proxy'];
const REDIRECT_STATUS = new Set([301, 302, 303, 307, 308]);
const UA = 'nodeagent-agent';
/** 系统代理探测结果(`url` 为 `host:port` 或完整 URL;`bypass` 为逗号分隔绕过列表)。 */
export interface SystemProxyInfo {
url: string | null;
bypass: string;
notes: string[];
}
/** 代理决议结果。 */
export interface ProxyResolution {
proxy: URL | null;
source: string | null;
notes: string[];
}
/** `httpGet` 的单次响应(跟随重定向后的最终响应)。 */
export interface HttpResponse {
status: number;
statusText: string;
headers: Record<string, unknown>;
body: Buffer;
url: string;
}
/** 可注入的只读命令 runner(单测用假 runner,不必真去读注册表)。 */
export type CommandRunner = (file: string, args: string[]) => Promise<string | null>;
/**
* 把环境里/配置里写的各种代理形态归一成可用的 URL。
*
* 接受 `http://127.0.0.1:7890`、`127.0.0.1:7890`(补 `http://`)、带账密的 URL;
* 其余(`socks5://`、空串、无法解析)返回 `null` —— 由调用方把「看到了但用不了」
* 明确报出来,**不许静默直连**。
*/
export function normalizeProxyUrl(raw: unknown): URL | null {
if (typeof raw !== 'string') return null;
const s = raw.trim();
if (!s) return null;
let u: URL;
try {
u = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(s) ? s : `http://${s}`);
} catch {
return null;
}
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
if (!u.hostname) return null;
return u;
}
/** 代理 URL 的主机端口(缺省按代理自身协议补 80/443)。 */
function proxyEndpoint(u: URL): { host: string; port: number } {
return { host: u.hostname, port: u.port ? Number(u.port) : u.protocol === 'https:' ? 443 : 80 };
}
/** 代理 URL 里的账密 → `Proxy-Authorization` 头(没有则 null)。 */
function proxyAuthHeader(u: URL): string | null {
if (!u.username) return null;
const raw = `${decodeURIComponent(u.username)}:${decodeURIComponent(u.password)}`;
return 'Basic ' + Buffer.from(raw).toString('base64');
}
/**
* `NO_PROXY` 命中判定。支持:精确主机、后缀(`example.com` 或 `.example.com` 都匹配
* `a.example.com`)、`host:port`、`*`。大小写不敏感。
*
* ⚠️ 刻意**不支持 CIDR**(如 `169.254/16`):本模块的目标只有公网域名,
* 而 CIDR 主机名判定极易写错、错了就是「该走代理的没走」。遇到疑似 CIDR 条目
* 由调用方记 note(见 `pickEnvProxy` 的 notes 语义)。
*/
export function isBypassed(targetUrl: string, noProxy: string): boolean {
if (!noProxy) return false;
let host: string;
let port: string;
try {
const u = new URL(targetUrl);
host = u.hostname.toLowerCase();
port = u.port || (u.protocol === 'https:' ? '443' : '80');
} catch {
return false;
}
for (const rawEntry of String(noProxy).split(',')) {
const entry = rawEntry.trim().toLowerCase();
if (!entry) continue;
if (entry === '*') return true;
let eHost = entry;
let ePort: string | null = null;
const i = entry.lastIndexOf(':');
// 排除 IPv6 字面量(`[::1]:8080`)里的冒号误判
if (i > 0 && !entry.slice(0, i).includes(':')) {
eHost = entry.slice(0, i);
ePort = entry.slice(i + 1);
}
if (ePort && ePort !== port) continue;
const bare = eHost.replace(/^\./, '');
if (!bare) continue;
if (host === bare || host.endsWith(`.${bare}`)) return true;
}
return false;
}
/** 从环境变量里挑代理(纯函数,便于单测)。 */
export function pickEnvProxy(
env: Record<string, string | undefined>,
targetUrl: string,
): { url: URL | null; source: string | null; notes: string[] } {
let proto = 'https:';
try {
proto = new URL(targetUrl).protocol;
} catch {
/* 保持默认 */
}
const keys = ENV_ORDER[proto] ?? ENV_ORDER['https:']!;
const notes: string[] = [];
for (const k of keys) {
const v = env?.[k];
if (!v) continue;
const u = normalizeProxyUrl(v);
if (u) return { url: u, source: k, notes };
notes.push(`${k}=${String(v).trim()} 不是受支持的代理协议(仅支持 http/https 代理),已忽略`);
}
return { url: null, source: null, notes };
}
/**
* 解析 macOS `scutil --proxy` 的输出。
*
* 形如:
* <dictionary> {
* HTTPSEnable : 1
* HTTPSPort : 7890
* HTTPSProxy : 127.0.0.1
* ExceptionsList : <array> { 0 : *.local 1 : 169.254/16 }
* }
*
* 注意 macOS 上 HTTPS 与 HTTP 是**分别开关**的;这里 https 优先,取不到再退 HTTP。
* `ProxyAutoConfigEnable : 1`(PAC 自动配置脚本)**无法解析** —— 返回 notes 说明,
* 让上层给出「请显式设 HTTPS_PROXY」这类可操作提示,而不是含糊的 fetch failed。
*/
export function parseScutilProxy(text: string): SystemProxyInfo | null {
if (typeof text !== 'string' || !text.includes('{')) return null;
const scalar = (key: string): string | null => {
const m = new RegExp(`^\\s*${key}\\s*:\\s*(.+?)\\s*$`, 'm').exec(text);
return m ? m[1]!.trim() : null;
};
const isOn = (v: string | null): boolean => v === '1' || v === 'true';
const notes: string[] = [];
// ExceptionsList 是 <array> 块里的 “序号 : 值” 行 —— 收集成逗号分隔的绕过列表
const bypass: string[] = [];
const arrIdx = text.indexOf('ExceptionsList');
if (arrIdx >= 0) {
const body = text.slice(arrIdx, text.indexOf('}', arrIdx) + 1);
const re = /^\s*\d+\s*:\s*(.+?)\s*$/gm;
let m: RegExpExecArray | null;
while ((m = re.exec(body)) !== null) bypass.push(m[1]!.trim());
}
for (const [flag, hostKey, portKey, dflt] of [
['HTTPSEnable', 'HTTPSProxy', 'HTTPSPort', '443'],
['HTTPEnable', 'HTTPProxy', 'HTTPPort', '80'],
] as const) {
if (!isOn(scalar(flag))) continue;
const host = scalar(hostKey);
if (!host) continue;
const port = scalar(portKey) ?? dflt;
return { url: `${host}:${port}`, bypass: bypass.join(','), notes };
}
if (isOn(scalar('ProxyAutoConfigEnable'))) {
notes.push('系统代理使用 PAC 自动配置脚本(ProxyAutoConfigEnable=1),本程序无法解析脚本内容,已按「无代理」处理');
}
return { url: null, bypass: bypass.join(','), notes };
}
/**
* 解析 Windows `reg query …\Internet Settings` 的输出。
*
* 形如(`REG_SZ` / `REG_DWORD` 是**类型名,不随系统语言变化**,可安全按 token 解析):
* ProxyEnable REG_DWORD 0x1
* ProxyOverride REG_SZ *.local;<local>
* ProxyServer REG_SZ 127.0.0.1:7890 (也可能是 http=…;https=… 分协议形态)
*
* `ProxyEnable=0x0` 视为**显式关闭** → 无代理(哪怕 ProxyServer 有值)。
* `<local>` 是「本机地址直连」的占位符,不是域名,从绕过列表里剔除。
*/
export function parseRegQueryProxy(text: string): SystemProxyInfo | null {
if (typeof text !== 'string' || !text.includes('REG_')) return null;
const value = (name: string): string | null => {
const m = new RegExp(`^\\s*${name}\\s+REG_(?:SZ|EXPAND_SZ|DWORD|BINARY)\\s+(.*?)\\s*$`, 'm').exec(text);
return m ? m[1]!.trim() : null;
};
const notes: string[] = [];
const enable = value('ProxyEnable');
if (enable !== null && /^0x0+$/i.test(enable)) return { url: null, bypass: '', notes };
const server = value('ProxyServer');
if (!server) return { url: null, bypass: '', notes };
const override = value('ProxyOverride');
const bypass = override
? override
.split(';')
.map((s) => s.trim())
.filter((s) => s && !/^<local>$/i.test(s))
.join(',')
: '';
// 分协议形态:http=127.0.0.1:7890;https=127.0.0.1:7891 —— https 优先
let url: string | null = server;
if (server.includes('=')) {
const map = new Map<string, string>();
for (const part of server.split(';')) {
const i = part.indexOf('=');
if (i > 0) map.set(part.slice(0, i).trim().toLowerCase(), part.slice(i + 1).trim());
}
url = map.get('https') ?? map.get('http') ?? null;
if (!url) notes.push(`系统代理仅配置了非 http(s) 协议(ProxyServer=${server})`);
}
return { url, bypass, notes };
}
/**
* 跑一个只读命令,失败/超时一律返回 null(探测系统代理**绝不允许**把自更新搞挂)。
*
* `windowsHide: true` —— 被控端由计划任务/服务拉起时是「无控制台」进程,
* 若再 spawn 控制台程序(reg.exe),Windows 会**新开一个控制台** → 黑窗闪一下。
*/
function runQuiet(file: string, args: string[]): Promise<string | null> {
return new Promise((resolve) => {
execFile(
file,
args,
{ windowsHide: true, timeout: 5000, encoding: 'utf8', maxBuffer: 1024 * 1024 },
(err, stdout) => resolve(err ? null : String(stdout)),
);
});
}
/**
* 探测系统代理。`run` 可注入(单测用假 runner,不必真去读注册表)。
*
* 任何异常都吞掉返回 null —— 探测失败绝不能成为新的失败点。
*/
export async function detectSystemProxy(
platform: NodeJS.Platform = process.platform,
run: CommandRunner = runQuiet,
): Promise<SystemProxyInfo | null> {
try {
if (platform === 'win32') {
const out = await run('reg', [
'query',
'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings',
]);
return out ? parseRegQueryProxy(out) : null;
}
if (platform === 'darwin') {
const out = await run('scutil', ['--proxy']);
return out ? parseScutilProxy(out) : null;
}
// Linux:桌面环境各异(gsettings / 环境变量 / PAC),不做猜测 —— 只认环境变量
return null;
} catch {
return null;
}
}
/** 系统代理只探测一次(同一进程内结果不变;用户改代理需重启被控端)。 */
let systemProxyPromise: Promise<SystemProxyInfo | null> | undefined;
export function systemProxy(): Promise<SystemProxyInfo | null> {
if (!systemProxyPromise) systemProxyPromise = detectSystemProxy().catch(() => null);
return systemProxyPromise;
}
/** 单测用:清掉系统代理缓存。 */
export function __resetSystemProxyCache(): void {
systemProxyPromise = undefined;
}
/**
* 目标是否为「本机回环 / 链路本地」地址。
*
* 为什么强制直连(对 sidecar 原版的有意加强):代理另一侧的 `localhost` **不是**
* 本机的 localhost —— 把发往 `127.0.0.1:<自建镜像/测试源站>` 的请求交给代理,
* 必然失败且报错形态费解(代理返回它自己那侧的 404 / 拒连)。
* CI 与开发机普遍设置 `HTTP_PROXY`(本仓库 e2e 就这样踩到过:测试源站在回环,
* agent 继承沙箱代理后拿到 404)。公开域名的行为**不变**(本函数只命中回环)。
*/
export function isLoopbackTarget(targetUrl: string): boolean {
let host: string;
try {
host = new URL(targetUrl).hostname.toLowerCase();
} catch {
return false;
}
if (host === 'localhost') return true;
// IPv6 回环:URL 会把 [::1] 规范化成 ::1
if (host === '::1' || host === '[::1]') return true;
// IPv4 回环段 127.0.0.0/8(只认 0-255 的合法字节,不把 127.999 也算进去)
const m = /^127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
if (m) {
const ok = m.slice(1).every((octet) => Number(octet) <= 255);
if (ok) return true;
}
return false;
}
/**
* 决议本次请求该用哪个代理。
*
* @param opts `system` 显式传入时不做探测(单测用);传 `null` 表示「确认没有系统代理」。
*/
export async function resolveProxy(
opts: {
targetUrl: string;
env?: Record<string, string | undefined>;
system?: SystemProxyInfo | null;
} = { targetUrl: '' },
): Promise<ProxyResolution> {
const { targetUrl } = opts;
const env = opts.env ?? process.env;
const notes: string[] = [];
const noProxy = NO_PROXY_KEYS.map((k) => env?.[k]).find(Boolean) ?? '';
if (noProxy && isBypassed(targetUrl, noProxy)) {
return { proxy: null, source: null, notes: [`目标命中 NO_PROXY=${noProxy},按直连处理`] };
}
// 回环/链路本地目标恒直连(理由见 isLoopbackTarget:代理的另一侧没有这台机器的 localhost)
if (isLoopbackTarget(targetUrl)) {
return { proxy: null, source: null, notes: ['目标为本机回环地址,按直连处理(代理另一侧的 localhost 不是本机)'] };
}
const fromEnv = pickEnvProxy(env ?? {}, targetUrl);
notes.push(...fromEnv.notes);
if (fromEnv.url) return { proxy: fromEnv.url, source: fromEnv.source, notes };
const sys = opts.system === undefined ? await systemProxy() : opts.system;
if (sys?.url) {
notes.push(...(sys.notes ?? []));
if (sys.bypass && isBypassed(targetUrl, sys.bypass)) {
notes.push(`目标命中系统代理的绕过列表(${sys.bypass}),按直连处理`);
return { proxy: null, source: null, notes };
}
const u = normalizeProxyUrl(String(sys.url));
if (u) return { proxy: u, source: 'system', notes };
notes.push(`系统代理 ${sys.url} 不受支持(仅支持 http/https 代理),已按直连处理`);
return { proxy: null, source: null, notes };
}
notes.push(...(sys?.notes ?? []));
return { proxy: null, source: null, notes };
}
/**
* 造一个 `createConnection`:向代理发 CONNECT 建隧道,https 目标再在隧道上叠 TLS。
*
* 返回的 socket 交给 `http.request` 使用(那已经是 TLS socket,故用 `http` 模块)。
*
* ## ⚠️ 为什么必须自己管 `signal`(真机踩过)
* `createConnection` 的回调**返回之前**,外层 `http.request` 还没建立 —— 此时
* 外层请求上的 `signal` / `timeout` 都**无人受理**。真机形态:代理 TCP 能连上、却对
* CONNECT 或随后的 TLS 握手**永不响应**,于是「75s 超时」形同虚设,实际挂了 109s。
* 所以:CONNECT 请求要带 signal,**拿到 socket 与 tlsSock 之后还要自己挂 abort**,
* 失败/成功时都要摘掉监听,避免泄漏与误伤。
*/
export function tunnelConnection(
proxy: URL,
target: { protocol: string; hostname: string; port: string },
signal: AbortSignal | undefined,
): (opts: http.RequestOptions, cb: (err: Error | null, socket?: tls.TLSSocket) => void) => void {
const pe = proxyEndpoint(proxy);
return (_opts, cb) => {
let settled = false;
let tunnelSock: import('node:net').Socket | null = null;
let tlsSock: tls.TLSSocket | null = null;
const detach = (): void => {
if (signal) signal.removeEventListener('abort', onAbort);
if (tunnelSock) tunnelSock.removeListener('error', onSockError);
};
const fail = (err: Error): void => {
if (settled) return;
settled = true;
detach();
// 半开的隧道必须销毁,否则这个悬挂连接会一直占着(点几次就攒一堆)
try {
tlsSock?.destroy();
tunnelSock?.destroy();
} catch {
/* 已经没了 */
}
cb(err);
};
const onSockError = (e: Error): void => fail(e);
const onAbort = (): void => fail(signal?.reason instanceof Error ? signal.reason : new Error('请求已中止'));
if (signal?.aborted) {
onAbort();
return;
}
signal?.addEventListener('abort', onAbort, { once: true });
const port = target.port || (target.protocol === 'https:' ? '443' : '80');
const headers: Record<string, string> = { host: `${target.hostname}:${port}` };
const auth = proxyAuthHeader(proxy);
if (auth) headers['proxy-authorization'] = auth;
const req = http.request({
host: pe.host,
port: pe.port,
method: 'CONNECT',
path: `${target.hostname}:${port}`,
headers,
agent: false,
signal,
});
req.once('connect', (res, socket, head) => {
tunnelSock = socket;
if (res.statusCode !== 200) {
fail(new Error(`代理拒绝 CONNECT(HTTP ${res.statusCode})`));
return;
}
if (head && head.length) socket.unshift(head);
if (target.protocol !== 'https:') {
settled = true;
detach();
cb(null, socket as unknown as tls.TLSSocket);
return;
}
socket.once('error', onSockError);
tlsSock = tls.connect({ socket, servername: target.hostname });
tlsSock.once('secureConnect', () => {
if (settled) return;
settled = true;
detach();
cb(null, tlsSock!);
});
tlsSock.once('error', onSockError);
});
req.once('error', onSockError);
req.end();
};
}
/** 单跳响应(`url` 由 httpGet 在跟随重定向后补上)。 */
type SingleResponse = Omit<HttpResponse, 'url'>;
/** 单跳请求(不跟随重定向)。 */
function once(
urlStr: string,
{ proxy, timeoutMs, signal, maxBytes }: { proxy: URL | null; timeoutMs: number; signal: AbortSignal; maxBytes?: number },
): Promise<SingleResponse> {
return new Promise((resolve, reject) => {
const u = new URL(urlStr);
const isHttps = u.protocol === 'https:';
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
reject(new Error(`不支持的协议:${u.protocol}`));
return;
}
// ⚠️ 手搭 options,**不要**把 URL 对象当第一参再覆盖字段 —— 传 URL 时 Node 会用
// URL 里的 host/port/path 覆盖后面的同名字段(原型实测踩过:代理形同无效、退化成直连)。
const opts: http.RequestOptions = {
method: 'GET',
signal,
timeout: timeoutMs,
host: u.hostname,
port: u.port || (isHttps ? 443 : 80),
path: `${u.pathname}${u.search}`,
headers: { accept: '*/*', 'user-agent': UA, host: u.host },
};
let req: http.ClientRequest;
if (proxy && !isHttps) {
// http 目标经代理 → 绝对形态请求行,且请求行交给代理
const pe = proxyEndpoint(proxy);
opts.host = pe.host;
opts.port = pe.port;
opts.path = u.href;
const auth = proxyAuthHeader(proxy);
if (auth) (opts.headers as Record<string, string>)['proxy-authorization'] = auth;
req = http.request(opts);
} else if (proxy) {
opts.createConnection = tunnelConnection(proxy, u, signal) as never;
req = http.request(opts);
} else {
req = (isHttps ? https : http).request(opts);
}
req.once('timeout', () => req.destroy(new Error(`请求超时(${timeoutMs}ms 无响应)`)));
req.once('response', (res) => {
// 上限第一闸:content-length 声明超限 → 根本不开始读 body
const declared = Number(res.headers['content-length'] ?? '0');
if (maxBytes !== undefined && declared > maxBytes) {
res.destroy(new Error(`响应体超过上限 ${maxBytes} 字节(content-length=${declared}),已中断`));
return;
}
const chunks: Buffer[] = [];
let total = 0;
res.on('data', (c: Buffer) => {
// 上限第二闸(流式):服务端谎报/不报 content-length 时,收到即累计,
// 超限立刻断,不让无界数据先把被控端内存吃爆
total += c.length;
if (maxBytes !== undefined && total > maxBytes) {
res.destroy(new Error(`响应体超过上限 ${maxBytes} 字节,已中断`));
return;
}
chunks.push(c);
});
res.once('error', reject);
res.once('end', () =>
resolve({
status: res.statusCode ?? 0,
statusText: res.statusMessage ?? '',
headers: res.headers as Record<string, unknown>,
body: Buffer.concat(chunks),
}),
);
});
req.once('error', reject);
req.end();
});
}
/**
* GET 一个 URL(跟随重定向)。
*
* 与 `fetch(url, {redirect:'follow'})` 的差异:不自动解压(不发 `accept-encoding`,
* 服务端会回 identity)、不解析 cookies —— 这两条对本用途都无影响。
*
* 整个重定向链共享**同一个**超时预算与 abort 信号(而不是每跳各自计时),
* 否则 5 跳 × 30s = 150s 会穿过上层的 RPC 超时。
*
* @param maxBytes 响应体流式上限(累计字节);超限立即中断。缺省不设限。
*/
export async function httpGet(
url: string,
{
proxy = null,
timeoutMs = 30_000,
maxRedirects = 5,
maxBytes,
}: { proxy?: URL | null; timeoutMs?: number; maxRedirects?: number; maxBytes?: number } = {},
): Promise<HttpResponse> {
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), timeoutMs);
try {
let current = String(url);
for (let hop = 0; ; hop += 1) {
const res = await once(current, { proxy, timeoutMs, signal: ac.signal, maxBytes });
const loc = res.headers?.['location'];
if (REDIRECT_STATUS.has(res.status) && typeof loc === 'string' && hop < maxRedirects) {
current = new URL(loc, current).href;
continue;
}
return { ...res, url: current };
}
} finally {
clearTimeout(timer);
}
}
+283 -53
View File
@@ -30,7 +30,9 @@ import {
import { buildInfo } from './capabilities/system.js';
import { createCapabilityRegistry } from './capabilities/index.js';
import { audit, describeArgs } from './audit.js';
import { disposeWatchesByOwner } from './events.js';
import { disposeWatchesByOwner, watchCount } from './events.js';
import { runningTaskCount } from './capabilities/task.js';
import { createRateLimiter } from './util/rate-limit.js';
import type { AgentConfig } from './config.js';
import type { TlsMaterial } from './certs.js';
@@ -47,6 +49,8 @@ interface ConnState {
remote: string;
/** v12:连接标识,用于清理该连接创建的事件订阅 */
connId: string;
/** v2.0.0:末次活跃时间(任何入站消息),空闲断开的依据 */
lastActiveAt: number;
}
export interface AgentServer {
@@ -61,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]) {
@@ -93,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));
@@ -105,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;
@@ -122,15 +167,32 @@ 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';
@@ -138,18 +200,12 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
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(
@@ -160,22 +216,28 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
} else {
// v22:优先用「该 client_id 专属的钥匙」,未登记才回落到共享 key。
// 这样「知道 key A」就只能以 A 的身份进来 —— ACL 的身份维度在 psk 模式下才有意义。
const scopedKey = cfg.keys?.[clientId];
//
// ⚠️ B-03(真机实测,P1):必须用 **own-property** 判断,不能直接 `cfg.keys?.[clientId]`。
// `client_id:"constructor"` 会沿原型链取到 `Object.prototype.constructor`(一个函数)
// 并被当作 HMAC 密钥 → `Buffer.from(function…)` 抛错 → 进程终止。
const hasScopedKey = Boolean(cfg.keys && Object.prototype.hasOwnProperty.call(cfg.keys, clientId));
const scopedKey = hasScopedKey ? cfg.keys![clientId] : undefined;
const keyForClient = scopedKey ?? cfg.key;
const keyKind = scopedKey ? 'scoped' : cfg.keys ? 'shared-fallback' : 'shared';
if (!keyForClient || !verifyHmac(keyForClient, state.nonce, params.hmac ?? '')) {
// ⚠️ B-02:hmac 同样要做类型校验 + verifyHmac 自身不得把异常泄漏到顶层
const hmac = typeof params.hmac === 'string' ? params.hmac : '';
let hmacOk = false;
try {
hmacOk = Boolean(keyForClient) && hmac !== '' && verifyHmac(keyForClient as string, state.nonce, hmac);
} catch (err) {
log('warn', `鉴权失败(HMAC 计算异常):${err instanceof Error ? err.message : String(err)}`);
hmacOk = false;
}
if (!hmacOk) {
log('warn', `鉴权失败 client_id=${clientId} key=${keyKind}`);
audit({
type: 'auth.failure',
client_id: clientId,
remote: state.remote,
reason: scopedKey
? '该 client_id 的专属密钥校验失败'
: '预共享密钥校验失败(未登记专属密钥,已回落到共享 key)',
});
sendError(ws, req.id, ErrorCodes.AUTH_FAILED, '预共享密钥校验失败');
// 稍作延迟再关闭,确保错误响应先送达控制端
setTimeout(() => ws.close(), 100);
failOut('预共享密钥校验失败', scopedKey
? '该 client_id 有专属密钥,需用那把 key'
: '用错了 key,或该 client_id 应登记到 keys{}');
return;
}
keyKindUsed = keyKind;
@@ -183,6 +245,7 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
state.nonce = null; // 一次性,用后即废
state.nonceExpiresAt = 0;
recordAuthSuccess(state.remote); // v2.0.0:握手成功即清零失败计数(避免误伤手滑的控制端)
state.authenticated = true;
state.clientId = clientId;
state.authorized = authorized;
@@ -211,11 +274,17 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
async function handleInvoke(ws: WebSocket, state: ConnState, req: RpcRequest): Promise<void> {
if (!state.authenticated) {
audit({
type: 'request.rejected',
remote: state.remote,
status: 'denied',
reason: '未完成认证即调用能力',
});
sendError(ws, req.id, ErrorCodes.AUTH_REQUIRED, '未完成认证,请先握手');
return;
}
const params = req.params as InvokeParams | undefined;
const name = params?.capability ?? '';
const name = typeof params?.capability === 'string' ? params.capability : '';
// 能力级 ACL:只要被控端配置了 ACL 即执行(deny 优先 → allow → 默认拒绝)。
// 注:psk 模式下 client_id 由客户端自报,此为「配置级约束」;对抗恶意调用方仍需 ed25519 绑定身份。
@@ -243,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 规则时生效)
@@ -285,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',
@@ -306,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;
}
@@ -313,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;
}
@@ -324,6 +414,7 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
// v12:把连接上下文交给能力处理器(事件订阅需要「谁订阅的」与「往哪推」)
const data = await handler(finalArgs, {
owner: state.connId,
clientId: state.clientId ?? '',
emit: (evt: unknown) => send(ws, { jsonrpc: '2.0', method: 'event', params: evt }),
});
const duration = Date.now() - started;
@@ -370,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;
}
@@ -392,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;
}
@@ -401,10 +506,60 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
sendResult(ws, req.id, { pong: true, ts: Date.now() });
return;
default:
audit({
type: 'request.rejected',
client_id: state.clientId ?? undefined,
remote: state.remote,
status: 'denied',
reason: `未知方法: ${req.method}`,
});
sendError(ws, req.id, ErrorCodes.METHOD_NOT_FOUND, `未知方法: ${req.method}`);
}
}
// ---------- v2.0.0 连接层防护:握手失败封禁 ----------
// 状态放在 core:handleAuth 在此函数内,且 server 侧连接入口也要查它。
interface BanState { fails: number; bannedUntil: number }
const authBan = new Map<string, BanState>();
const banMax = Math.max(1, cfg.security?.auth_ban?.max_attempts ?? 5);
const banBaseMs = Math.max(1000, cfg.security?.auth_ban?.ban_ms ?? 600_000);
const banWhitelist = (cfg.security?.auth_ban?.whitelist ?? []).map((x) => x.trim()).filter(Boolean);
/** 归一化来源 IP:剥离 IPv4-mapped 前缀(::ffff:1.2.3.4 → 1.2.3.4),否则同一来源会被当成两个键。 */
const normIp = (addr: string): string => addr.replace(/^::ffff:/i, '');
/** 该来源还有多久解封(0 = 不在封禁期)。白名单来源永不封禁。 */
function banRemainingMs(remote: string): number {
const ip = normIp(remote);
if (banWhitelist.includes(ip)) return 0;
const st = authBan.get(ip);
if (!st) return 0;
const left = st.bannedUntil - Date.now();
return left > 0 ? left : 0; // 已过期则不挡(计数保留,继续指数退避)
}
/** 记录一次握手失败;返回本次是否**新触发**封禁(好让错误响应带上 retry_after_ms)。 */
function recordAuthFailure(remote: string): { banned: boolean; waitMs: number } {
const ip = normIp(remote);
if (banWhitelist.includes(ip)) return { banned: false, waitMs: 0 };
const prev = authBan.get(ip) ?? { fails: 0, bannedUntil: 0 };
const fails = prev.fails + 1;
if (fails < banMax) {
authBan.set(ip, { fails, bannedUntil: 0 });
return { banned: false, waitMs: 0 };
}
// 第 N 次失败起:ban = base × 2^(N - max),封顶 24h
const power = Math.min(10, fails - banMax);
const waitMs = Math.min(86_400_000, banBaseMs * 2 ** power);
authBan.set(ip, { fails, bannedUntil: Date.now() + waitMs });
return { banned: true, waitMs };
}
function recordAuthSuccess(remote: string): void {
const ip = normIp(remote);
if (authBan.delete(ip)) log('info', `握手失败计数已清零(来源 ${ip} 本次认证成功)`);
}
function attachConnection(ws: WebSocket, remote: string): void {
log('info', `新连接: ${remote}`);
const connId = `c_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
@@ -416,20 +571,50 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
authorized: null,
remote,
connId,
lastActiveAt: Date.now(),
});
// v2.0.0 修正:**客户端对 ping 的 pong 回应也算活跃**。
// 之前只更新 message 的活跃时间 → 僵尸连接(TCP 未断但客户端已死)不会被判空闲,
// 会被永久算进 states.size → 并发上限虚高 → **可能把正常控制端挡在外面**。
ws.on('pong', () => {
const st = states.get(ws);
if (st) st.lastActiveAt = Date.now();
});
ws.on('message', (raw: RawData) => {
void handleMessage(ws, raw.toString());
// v2.0.0:任何入站消息都算活跃(空闲断开以"完全静默"为准,不是"没有调用能力")
const st = states.get(ws);
if (st) st.lastActiveAt = Date.now();
// ⚠️ 崩溃链根因(P0/P1 多条共用):原来这里是 `void handleMessage(...)` ——
// Promise 一旦 reject 就成为 unhandled rejection,Node ≥22 默认**终止进程**。
// 「畸形 hello」「畸形 auth」之所以能把被控端打崩,靠的就是这条路径。
// 这里显式兜底:任何处理异常只记日志 + 回一个错误响应,绝不冒泡到进程级。
void handleMessage(ws, raw.toString()).catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
log('warn', `处理消息时发生未预期异常(已拦截,不影响进程):${msg}`);
try {
sendError(ws, null, ErrorCodes.EXECUTION_FAILED, '服务端处理消息时发生未预期异常', { detail: msg });
} catch {
/* 连接可能已关闭 */
}
});
});
ws.on('close', () => {
log('info', `连接关闭: ${remote}`);
// v12:连接断开即释放其事件订阅,避免 watcher 泄漏
const n = disposeWatchesByOwner(connId);
if (n > 0) log('debug', `已清理 ${n} 个事件订阅(连接 ${connId})`);
states.delete(ws); // v2.0.0:Map 需手动清理(WeakMap 时代靠 GC)
});
ws.on('error', (err: Error) => log('warn', `连接错误: ${err.message}`));
}
return { attach: attachConnection };
return {
attach: attachConnection,
/** v2.0.0:连接入口用 —— 该来源是否在封禁期(返回剩余毫秒,0=不挡)。 */
banRemainingMs,
/** v2.0.0:连接表(空闲清扫与并发上限判据)。 */
states,
};
}
/** 监听模式:被控端在本地起 HTTP(S) + WebSocket 服务。 */
@@ -449,6 +634,11 @@ export function createAgentServer(cfg: AgentConfig, tls: TlsMaterial | null): Pr
: createHttpServer();
const wss = new WebSocketServer({ server: httpServer });
// v23②:连接数上限(判定放在连接入口,见下方 wss.on('connection'))
const maxConnections = Math.max(1, cfg.security?.max_connections ?? 8);
// v23③:空闲断开阈值(0 = 关闭)
const idleTimeoutMs = Math.max(0, cfg.security?.idle_timeout_ms ?? 1_800_000);
// v21 第一批加固:来源网段白名单 —— **在业务握手之前**就断开。
// 放在这里而不是等认证失败,是因为拒绝得更早、开销更低(不必做 TLS/挑战应答)。
wss.on('connection', (ws, req) => {
@@ -459,13 +649,53 @@ export function createAgentServer(cfg: AgentConfig, tls: TlsMaterial | null): Pr
ws.close(1008, 'source not allowed');
return;
}
// v23①:封禁中的来源直接拒(在 TLS 之后、业务握手之前)
const bannedLeft = core.banRemainingMs(remote);
if (bannedLeft > 0) {
log('warn', `连接被拒绝:来源处于封禁期(remote=${remote},剩余 ${Math.ceil(bannedLeft / 1000)}s)`);
audit({ type: 'net.deny', remote, reason: `auth banned, ${bannedLeft}ms left` });
ws.close(1008, `too many failed attempts, retry in ${Math.ceil(bannedLeft / 1000)}s`);
return;
}
// v23②:连接数上限
if (core.states.size >= maxConnections) {
log('warn', `连接被拒绝:已达并发上限 ${maxConnections}(current=${wss.clients.size})`);
audit({ type: 'net.deny', remote, reason: `max connections reached: ${maxConnections}` });
ws.close(1013, 'too many connections');
return;
}
core.attach(ws, remote);
});
// WebSocket 层保活:30s ping,探测死连接
// WebSocket 层保活:30s ping,探测死连接;v23③ 同时做空闲连接清扫
const heartbeat = setInterval(() => {
for (const ws of wss.clients) {
if (ws.readyState === WebSocket.OPEN) ws.ping();
if (ws.readyState !== WebSocket.OPEN) continue;
// v2.0.0 修正(真机踩到):**pong 缺失必须主动 terminate**。
// ws 库默认不会因「ping 无 pong」而断开 —— 客户端进程已死(TCP 未发 FIN)时,
// 连接会一直挂在 states 里,占用 max_connections 名额。
// 判据用与空闲清扫同一个 lastActiveAt(pong 已在上方更新它)。
const st = core.states.get(ws);
if (st && idleTimeoutMs > 0 && Date.now() - st.lastActiveAt > idleTimeoutMs * 2) {
log('warn', `连接无响应(ping 未回 pong 且静默超 ${Math.round((Date.now() - st.lastActiveAt) / 1000)}s)→ 强制断开 ${st.connId}`);
audit({ type: 'session.idle_close', client_id: st.clientId ?? undefined, remote: st.remote, reason: 'unresponsive (no pong)' });
ws.terminate();
continue;
}
ws.ping();
}
if (idleTimeoutMs <= 0) return;
// 保守策略:只要还有任务在跑 / 还有事件订阅,就不断任何连接 ——
// 宁可少断,也不能把"挂着长任务等结果"的控制端踢下线。
if (runningTaskCount() > 0 || watchCount() > 0) return;
const now = Date.now();
for (const [ws, st] of core.states) {
if (ws.readyState !== WebSocket.OPEN) continue;
const idleFor = now - st.lastActiveAt;
if (idleFor < idleTimeoutMs) continue;
log('info', `空闲断开:连接 ${st.connId}(${st.remote})已静默 ${Math.round(idleFor / 1000)}s`);
audit({ type: 'session.idle_close', client_id: st.clientId ?? undefined, remote: st.remote, reason: `idle ${idleFor}ms` });
ws.close(1000, 'idle timeout');
}
}, 30_000);
heartbeat.unref();
+42
View File
@@ -0,0 +1,42 @@
/**
* Windows **DPI 感知** PowerShell 前置段(物理像素坐标的唯一真源)。
*
* ## 为什么必须有
*
* PowerShell 默认是「**DPI 不感知**」进程。在高 DPI(如 200% 缩放)下,这种进程里
* **同时存在两套坐标系**,而且互不提醒:
*
* | 调用 | 该进程看到的 |
* |---|---|
* | `[System.Windows.Forms.Screen]::Bounds` | **逻辑**尺寸(物理 2560×1440 → 报 1280×720) |
* | `[System.Windows.Forms.SystemInformation]::VirtualScreen` | **逻辑**(同上) |
* | `CopyFromScreen(0,0,1280,720)` | **物理**裁剪 → 只截到屏幕左上 1/4 |
* | `SetCursorPos(552,745)` | 落点实际在物理 (1104, 1490)(被系统 ×2) |
* | UIA(`screen.find`) | **物理**(与进程 DPI 感知无关) |
*
* 于是最要命的一条链路会静默断掉:`screen.find` 返回物理坐标 → 直接喂给
* `input.mouse.click` → **点到两倍远的地方**(还可能被屏幕边界截断)。
* 「找得到、点不准」,而且不报任何错。
*
* ## 用法
*
* 在 **任何** `System.Windows.Forms` / 截屏 / 光标调用**之前**执行本段;
* 同一进程内只需一次(`SetProcessDPIAware` 幂等)。新起的 PowerShell 每次都要带上。
*
* ## 真机实证(2026-10-12)
*
* 某虚拟化平台上的 Win11 ARM 虚拟机,宿主缩放 200%,物理 2560×1440:
* - 缺省 `screen.capture` 只拿到 1280×720(= 屏幕左上 1/4),且 `screen.info` 报 1280×720
* - `input.mouse.move 100,100` → DPI 感知读回 **(200,200)**
* - `input.mouse.move 552,745` → 实际落点 **(1104, 1439)**(截断到屏幕底部)
* - 补上本段后:`screen.capture` 拿到完整 2560×1440;`screen.find` 的物理坐标
* 可直接用于 `input.mouse.click`(同一坐标一次点中)
*
* ⚠️ 不要删。这不是「锦上添花」的兼容代码,而是坐标口径统一的**前提**。
* 守卫见 `tests/unit/dpi-awareness.test.mjs`。
*/
export const PS_DPI_AWARE = [
// Add-Type 现编译一个小 P/Invoke 壳;类名固定,每进程只应出现一次。
`Add-Type -TypeDefinition 'using System.Runtime.InteropServices; public class NADpiAware { [DllImport("user32.dll")] public static extern bool SetProcessDPIAware(); }'`,
`[void][NADpiAware]::SetProcessDPIAware()`,
].join('\n');
+80
View File
@@ -0,0 +1,80 @@
/**
* 平台能力守卫(跨平台最小实现)。
*
* ## 为什么要统一
* 各能力原本各自判断平台,风格不一:
* - `darwin.ts` 有 `assertDarwin()`
* - `window.ts` 写 `if (platform === 'darwin') return macXxx()`,其余平台**静默走到 Windows 实现**,
* 于是在 Linux 上表现为 `spawn /bin/bash ENOENT` 这类**误导性错误**(像是环境缺依赖,
* 实际是"本平台根本没实现")。
*
* 统一后的语义:**没实现就明确说"不支持当前平台"**(`E_UNSUPPORTED_PLATFORM`),
* 并带上「支持哪些平台」,让调用方一眼看懂,也让能力清单的"声明可用"不至于撒谎。
*
* ## 用法
* ```ts
* assertPlatform('screen.capture', 'win32', 'darwin');
* ```
*/
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
/** 人类可读的平台名(错误信息里用)。 */
const PLATFORM_LABEL: Record<string, string> = {
win32: 'Windows',
darwin: 'macOS',
linux: 'Linux',
};
export function platformLabel(p: NodeJS.Platform): string {
return PLATFORM_LABEL[p] ?? p;
}
/**
* 当前平台是否在允许集合内。
*
* 与 `assertPlatform` 的分工:这个用于**条件注册/条件分支**,那个用于**进入能力时兜底拒绝**。
*/
export function isPlatform(...allowed: NodeJS.Platform[]): boolean {
return allowed.includes(process.platform);
}
/**
* 断言当前平台受支持;否则抛 `E_UNSUPPORTED_PLATFORM`。
*
* @param capability 能力名(写进错误信息,便于定位)
* @param allowed 支持该能力的平台(如 `'win32', 'darwin'`)
*/
export function assertPlatform(capability: string, ...allowed: NodeJS.Platform[]): void {
if (isPlatform(...allowed)) return;
const supported = allowed.map(platformLabel).join(' / ');
throw new CapabilityError(
ErrorCodes.UNSUPPORTED_PLATFORM,
`${capability} 未在 ${platformLabel(process.platform)} 上实现(仅支持 ${supported})`,
{
platform: process.platform,
supported,
},
);
}
/**
* 仅 Windows 的能力(PowerShell / Win32 实现)。
*
* 覆盖:截屏与录屏、键鼠注入、剪贴板、音量、软件装卸(winget)、窗口与 UIA 语义。
* 注意 **不含** `system.info` / `system.status` / `system.process.list` / `shell.exec` / `fs.*` ——
* 这些有分平台实现,在 Linux 上可用。
*/
export function assertWindows(capability: string): void {
assertPlatform(capability, 'win32');
}
/**
* 图形界面类能力(Windows 或 macOS 有实现,Linux 目前没有)。
*
* 用于 `screen.*` / `window.*` / `input.*` 这类:在 Windows 走 Win32/UIA、
* 在 macOS 走 AppleScript/Vision,**Linux 端尚未实现**(需 X11/Wayland 后端)。
*/
export function assertGuiPlatform(capability: string): void {
assertPlatform(capability, 'win32', 'darwin');
}
+12 -1
View File
@@ -4,6 +4,7 @@ import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { execCommand } from './exec.js';
import { isSafeBareWindowsPathArg } from './quote.js';
/**
* PowerShell 常驻助手(v14)。
@@ -438,8 +439,18 @@ export async function runPowerShellSmart(opts: RunPsOptions): Promise<{ stdout:
const dir = mkdtempSync(join(tmpdir(), 'na-ps-'));
const scriptPath = join(dir, 'run.ps1');
writeFileSync(scriptPath, `\uFEFF${prelude}\n${body}`, 'utf8');
// ⚠️ 这里**只能裸拼**路径:`-File` 的引号由 powershell.exe 自己的命令行解析器处理,
// 塞 PS 单引号字面量进去不保证被剥掉。既然裸拼,就必须**显式**确认路径里没有
// 会改变解析的字符(引号/$/&/|/^/…)—— 正常它由 mkdtempSync 生成,但 TMP 被设成
// 怪路径时前提就破了,而且破了也是静默的(与 W-01 同类)。破前提就当场报错。
if (!isSafeBareWindowsPathArg(scriptPath)) {
throw new Error(
`PowerShell 临时脚本路径含可能改变命令行解析的字符,拒绝拼接:${JSON.stringify(scriptPath)}` +
'(请检查 TMP/TEMP 或用户目录名)',
);
}
const r = await execCommand({
command: `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ${JSON.stringify(scriptPath)}`,
command: `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ${scriptPath}`,
timeoutMs,
});
if (r.exit_code !== 0) {
+69
View File
@@ -0,0 +1,69 @@
/**
* 跨平台「安全字面量」工具 —— 把不可信字符串安全地嵌进 shell / PowerShell 脚本。
*
* ## 为什么必须有这个文件
*
* 本项目真机踩到过 **P0 级静默 RCE**:把 `JSON.stringify(title)` 直接插进 PowerShell
* 双引号串,payload `x"; New-Item …; #` 就执行了任意命令 —— 而接口还返回「正常的业务错误」,
* 调用方完全看不出异常(见 `window.ts` 的历史注释与 bug 报告 W-01)。
*
* 两条铁律(违反任一条都会重新引入漏洞):
*
* 1. **PowerShell 里 `\` 不是转义符**(那是 C/JS 的规则)。JSON 产出的 `\"` 会被 PS 解析成
* 「字面反斜杠 + 字符串结束引号」,后面的内容就成了新语句。
* 2. **PS 双引号串会做变量插值与子表达式展开**(`$x`、`$(...)`、反引号),
* 而**单引号串什么都不展开** —— 这是唯一正确的选择。
*
* 因此:**任何**嵌入 PowerShell 的不可信值都必须走 `psLit()`;
* 嵌入 bash/sh 的走 `shLit()`;嵌进命令行参数数组的(`spawn(file, args)`)根本不需要引用,
* 那是首选方案,不要拼字符串。
*/
/** PowerShell 单引号字面量:内部单引号翻倍,其余字符(含 `$`、反引号、反斜杠)全部原样。 */
export function psLit(v: string): string {
return `'${v.replace(/'/g, "''")}'`;
}
/** PowerShell 数组字面量:`@('a','b')`。空数组产出 `@()`。 */
export function psLitArray(values: readonly string[]): string {
return `@(${values.map(psLit).join(',')})`;
}
/**
* POSIX shell 单引号字面量:把 `'` 拆成 `'\''`(闭合 → 转义单引号 → 重开)。
* 单引号内不会有变量展开、命令替换或反斜杠转义。
*/
export function shLit(v: string): string {
return `'${v.replace(/'/g, `'\\''`)}'`;
}
/**
* 校验单引号字面量的形状(供调用侧自检):首尾是 `'`,且内部**没有落单的单引号**。
*
* 原理:正确转义后内部每个 `'` 都成对(`''`),把 `''` 全部消去后不应再残留任何 `'`。
* 只做形状检查,不替代真机验证。
*/
export function assertSingleQuotedLiteral(lit: string): boolean {
if (!lit.startsWith("'") || !lit.endsWith("'")) return false;
return !lit.slice(1, -1).replace(/''/g, '').includes("'");
}
/**
* Windows 原生命令行参数的「裸传」前提校验。
*
* 有些位置**不能用** `psLit()`:例如 `powershell.exe -File <path>` —— 这一段的引号由
* `powershell.exe` 自己的命令行解析器处理,塞 PS 单引号字面量进去不保证被剥掉。
* 这种位置只能把路径**裸着**拼进命令行,于是必须保证它**本身含不了任何会改变语义的字符**。
*
* 真例(v2.0.3 审查发现,与 W-01 同类):`util/ps-helper.ts` 把 `mkdtempSync` 产出的临时脚本
* 路径直接拼进 `-File`。那条路径由本进程生成、正常含不了引号,但**这是隐含前提**——
* 一旦 `TMP`/`TEMP` 被设成怪路径(或用户目录名含特殊字符),前提就破了,而且是静默的。
* 故把前提**显式化**:不满足就当场报错,而不是把可疑字符串拼进命令行。
*/
export function isSafeBareWindowsPathArg(v: string): boolean {
if (v === '' || v.includes('\0')) return false;
// 引号/反引号/$/&/|/</>/(/)/%/^/!/换行:任何一个都能改变 cmd.exe 或 PS 的解析
if (/["'`$&|<>()%^!\r\n\t]/.test(v)) return false;
// 结尾是单个反斜杠会在双引号包裹时吃掉闭引号(cmd 的经典怪癖)
return !/\\$/.test(v);
}
+83
View File
@@ -0,0 +1,83 @@
/**
* 按调用方的滑动窗口限速器(每分钟)。
*
* ## 为什么从 server.ts 抽出来(A-P0-2,2026-10-25)
* 原实现有两处缺陷,且逻辑埋在 `createAgentCore` 闭包里无法单测:
*
* 1. **桶 key 用错值**:原 `allowByRate(clientId, limit, String(limit))` 第三参
* (bucket)传的是**限速值本身**。于是「screen.capture ≤ 5/min」与
* 「app.install ≤ 5/min」两条同值规则共用 `clientId|5` 一个桶:
* 截图 5 次后装软件也被限速(误伤),且两者总量仍按 5/min(漏过)。
* 正确口径:key = `${身份}:${能力名}`。
* 2. **桶只增不删**:`rateBuckets` Map 无任何清扫,key 永久留存。而 psk 模式下
* `client_id` 由客户端**自报**(配置级约束)→ 外部可枚举任意 client_id 造键
* → 长期运行内存无界增长。现在:每次允许判定顺手删过期键 + 容量上限淘汰最旧。
*
* ## 语义
* - 窗口 60s 滑动:只数「最近 60 秒内」的调用;
* - `limitPerMin` 为空/≤0 表示不限速(直接放行,**不建桶** —— 与旧行为一致);
* - 身份用「认证身份 ?? 远端地址」:psk 自报 client_id 不可信,但限速键仍以它为纲
* (配了限速就说明该 client_id 已在 ACL 里登记),无身份的调用方退化按远端地址。
*/
/** 桶容量上限:超出淘汰最旧(Map 保持插入顺序,首键即最旧)。 */
const DEFAULT_MAX_KEYS = 10_000;
const WINDOW_MS = 60_000;
export interface RateLimiter {
/**
* 请求一次调用配额。
*
* @param identity 调用方身份(认证身份 ?? 远端地址)
* @param limitPerMin 每分钟上限;空/≤0 = 不限速
* @param bucket 桶名(能力名)—— 不同能力各占一桶,互不挤占
*/
allow(identity: string, limitPerMin: number | undefined, bucket: string): boolean;
/** 当前桶数(供测试与观测)。 */
size(): number;
}
export interface RateLimiterOptions {
/** 桶容量上限(超出淘汰最旧)。默认 10_000。 */
maxKeys?: number;
/** 时钟注入(单测用假时钟推进 60s 窗口,不必真等)。 */
now?: () => number;
}
export function createRateLimiter(opts: RateLimiterOptions = {}): RateLimiter {
const maxKeys = opts.maxKeys ?? DEFAULT_MAX_KEYS;
const now = opts.now ?? Date.now;
const buckets = new Map<string, number[]>();
return {
allow(identity: string, limitPerMin: number | undefined, bucket: string): boolean {
if (!limitPerMin || limitPerMin <= 0) return true;
const key = `${identity}|${bucket}`;
const t = now();
// 清扫①:过期键直接删(只增不删 = 内存无界增长;自报 client_id 可枚举造键)
for (const [k, times] of buckets) {
if (times.length === 0 || t - times[times.length - 1]! >= WINDOW_MS) buckets.delete(k);
}
// 清扫②:容量上限,超出淘汰最旧(插入序 = 时间序,首键最旧)
while (buckets.size >= maxKeys) {
const oldest = buckets.keys().next();
if (oldest.done) break;
buckets.delete(oldest.value);
}
const recent = (buckets.get(key) ?? []).filter((ts) => t - ts < WINDOW_MS);
if (recent.length >= limitPerMin) {
buckets.set(key, recent);
return false;
}
recent.push(t);
buckets.set(key, recent);
return true;
},
size(): number {
return buckets.size;
},
};
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/cli",
"version": "1.4.0",
"version": "2.1.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
+3 -1
View File
@@ -57,7 +57,9 @@ export async function cmdConnect(host: string, opts: Options): Promise<void> {
const profile: NodeProfile = {
host,
port,
tls: !process.argv.includes('--no-tls'),
// 原来这里直接扫 `process.argv.includes('--no-tls')` —— 绕过了解析器,
// 于是「帮助里承诺的选项」与「解析器认识的选项」是两套(L-01 同类)。
tls: !opts.noTls,
insecure: opts.insecure,
key: opts.key ?? '',
auth_mode: authMode,
+111 -6
View File
@@ -25,6 +25,7 @@ import {
} from '@nodeagent/client';
import {
CapabilityNames,
HOTKEY_PRESETS,
matchPattern,
DEFAULT_DISCOVERY_PORT,
type CapabilityDescriptor,
@@ -61,7 +62,15 @@ export async function cmdScreen(opts: Options): Promise<void> {
export async function cmdScreenshot(opts: Options): Promise<void> {
const args: Record<string, unknown> = {};
if (opts.format) args['format'] = opts.format;
if (opts.scale) args['scale'] = Number(opts.scale);
if (opts.scale) {
const s = Number(opts.scale);
// 协议限定 scale ∈ (0, 1](缩小)。原先直接把非法值发出去,
// 被控端只回一句「参数校验失败」,看不出是哪一项 —— 2026-10-05 实测踩过。
if (!Number.isFinite(s) || s <= 0 || s > 1) {
fail(`--scale 应在 0 到 1 之间(1=原尺寸,0.5=半尺寸),收到: ${opts.scale}`);
}
args['scale'] = s;
}
if (opts.region) {
const nums = opts.region.split(',').map(Number);
if (nums.length !== 4 || nums.some((n) => !Number.isFinite(n))) fail('--region 格式应为 x,y,width,height');
@@ -163,19 +172,57 @@ export async function cmdKey(action: string | undefined, positionals: string[],
return;
}
case 'press': {
if (positionals.length === 0) fail('用法: nodeagent key press <键1> [键2] ...(如 ctrl c)');
// v1.5.0:三种用法(一次一种)
// nodeagent key press "ctrl+shift+esc" 字符串热键
// nodeagent key press copy 预设名
// nodeagent key press ctrl c 老形式(空格分隔的各键)
if (positionals.length === 0) fail('用法: nodeagent key press "<热键>" | <预设名> | <键1> [键2] ...(如 "ctrl+shift+esc" / copy / ctrl c)');
const args: Record<string, unknown> = {};
let positionalsEcho = positionals;
if (positionals.length === 1) {
const one = positionals[0]!;
// 含分隔符 → 字符串热键;不含但命中预设表 → 预设;都不中 → 单键(也当热键交给被控端解析)
const spec = HOTKEY_PRESETS[one.trim().toLowerCase()];
if (spec) {
args['preset'] = one.trim().toLowerCase();
} else {
args['hotkey'] = one;
}
} else {
args['keys'] = positionals;
}
// 老形式说明:位置参数以空格分隔 → 兼容"ctrl c"(无 + 号)
if (opts.interval) args['interval_ms'] = Number(opts.interval);
// v1.5.0 新增可选参数
if (opts.hold) args['hold_ms'] = Number(opts.hold);
if (opts.route) args['route'] = opts.route;
// 媒体键可定向到指定进程(播放器常建 30+ 辅助窗口,只投主窗口往往无效)
if (opts.pid) args['target_pid'] = Number(opts.pid);
await withClient((c) =>
callAndPrint(c, CapabilityNames.KeyPress, { keys: positionals }, opts.json, (d) => {
callAndPrint(c, CapabilityNames.KeyPress, args, opts.json, (d) => {
// 被控端返回 { pressed, chords, times },**没有 keys 字段**
// (真机 2026-10-04 实测:原先直接读 r.keys.join() 会抛
// "Cannot read properties of undefined (reading 'join')")
// 故优先用请求参数回显,缺失时再退回 chords 展开。
const r = d as { keys?: string[]; chords?: string[][]; times?: number };
const r = d as {
keys?: string[]; chords?: string[][]; times?: number; via?: string;
presets?: string[]; channel?: string; route?: string; sent_windows?: number;
target_pid?: number | null; note?: string;
};
const shown = (r.chords ?? []).map((ch) => ch.join('+')).join(' → ');
const label = r.keys?.length
? r.keys.join('+')
: (r.chords ?? []).map((ch) => ch.join('+')).join(' → ') || positionals.join('+');
: shown || positionalsEcho.join('+');
const times = r.times && r.times > 1 ? `(重复 ${r.times} 次)` : '';
console.log(`✓ 已按下 ${label}${times}`);
const presetEcho = r.presets?.length ? `(预设 ${r.presets.join('→')} = ${shown})` : '';
const via =
r.channel === 'appcommand'
? `(媒体通道${r.target_pid ? ` → pid ${r.target_pid}` : ' · 广播'})`
: r.channel === 'postmessage'
? `(后台投递 → pid ${r.target_pid},命中 ${r.sent_windows ?? 0} 窗口)`
: '';
console.log(`✓ 已按下 ${label}${times}${presetEcho}${via}`);
if (r.note) console.log(` ℹ️ ${r.note}`);
}),
);
return;
@@ -185,6 +232,64 @@ export async function cmdKey(action: string | undefined, positionals: string[],
}
}
// ---------- v1.6.0 等待条件(gui.await) ----------
/**
* 等待一个条件成立再返回(只读低危)。
*
* nodeagent await --condition control --text "完成" --timeout 30000
* nodeagent await --condition window --title "安装程序"
* nodeagent await --condition process --process setup.exe
* nodeagent await --condition file --path "C:\\log.txt" --state absent
*
* 四类条件各自复用 window.list / screen.find / process.list / fs.stat,
* 平台行为与那些能力一致。超时是**正常返回**(satisfied:false + note),不抛异常。
*/
export async function cmdAwait(opts: Options): Promise<void> {
const condition = opts.condition as string | undefined;
if (!condition) {
fail('用法: nodeagent await --condition <window|control|process|file> --timeout <ms> [--text/--title/--process/--path] [--state absent]');
}
const args: Record<string, unknown> = { condition };
// v2.0.0:where / any_of 是 JSON 字符串(属性谓词与组合条件不便用命令行开关表达)
for (const k of ['where', 'any_of']) {
const raw = (opts as unknown as Record<string, string | undefined>)[k];
if (raw !== undefined) {
try {
args[k] = JSON.parse(raw);
} catch {
fail(`--${k} 不是合法 JSON: ${raw.slice(0, 60)}`);
}
}
}
if (opts.text !== undefined) args['text'] = opts.text;
if (opts.title !== undefined) args['title'] = opts.title;
if (opts.process !== undefined) args['process'] = opts.process;
if (opts.path !== undefined) args['path'] = opts.path;
if (opts.state !== undefined) args['state'] = opts.state;
if (opts.timeoutMs !== undefined) args['timeout_ms'] = Number(opts.timeoutMs);
if (opts.interval !== undefined) args['interval_ms'] = Number(opts.interval);
await withClient((c) =>
callAndPrint(c, CapabilityNames.GuiAwait, args, opts.json, (d) => {
const r = d as {
satisfied: boolean; condition: string; state: string;
elapsed_ms: number; attempts: number; last_seen: string; last_error?: string; note?: string;
};
const mark = r.satisfied ? '✓' : '✗';
const label = r.condition === 'control' ? String(args['text'] ?? '')
: r.condition === 'window' ? String(args['title'] ?? '')
: r.condition === 'process' ? String(args['process'] ?? '')
: String(args['path'] ?? '');
console.log(
`${mark} ${r.state === 'absent' ? '已消失' : '已出现'}: ${r.condition} ${label}` +
`(${r.elapsed_ms}ms,${r.attempts} 次轮询,末次=${r.last_seen})`,
);
if (r.note) console.log(` ℹ️ ${r.note}`);
if (r.last_error) console.log(` ⚠️ 期间末次异常:${r.last_error.slice(0, 120)}`);
}),
);
}
// ---------- v3+ 审计 ----------
/** v11:审计链完整性校验。 */
+134 -2
View File
@@ -273,7 +273,10 @@ export async function cmdDeploy(localFile: string, opts: Options): Promise<void>
const uploadInfo = await withClient(async (c) => {
// 备份走协议自带的 fs 能力(而非 shell 的 Copy-Item)——
// Copy-Item 是 Windows 专用,在 macOS 被控端上会静默失败(真机踩过:以为备份了,其实没有)。
const backup = `${target}.bak-${Date.now()}`;
// 备份用**固定名**(覆盖式):此前用 `.bak-<时间戳>`,每部署一次就多一个文件 ——
// 真机 2026-10-09 发现 D:\Nodeagent 下堆了 15+ 个 bak(单文件 1MB,纯浪费)。
// 部署回滚只需要「上一个可用版本」,一份就够,故改为覆盖写。
const backup = `${target}.bak`;
let backupPath = '';
try {
const st = await c.invoke<{ size?: number; exists?: boolean }>(CapabilityNames.FsStat, { path: target });
@@ -329,6 +332,23 @@ export async function cmdDeploy(localFile: string, opts: Options): Promise<void>
offset += part.length;
}
console.log(`✓ 已上传 ${(buf.length / 1024).toFixed(0)} KB`);
// best-effort 清理历史的时间戳备份(老格式 `.bak-<ts>`)。
// 平台分流:Windows 用 PS,类 Unix 用 rm。清不掉不影响部署,只提示。
try {
const isWin = String((info as { os?: string }).os ?? '').toLowerCase().startsWith('win');
const cleanup = isWin
? `Get-ChildItem '${target}.bak-*' -EA SilentlyContinue | Remove-Item -Force -EA SilentlyContinue; (Get-ChildItem '${target}.bak-*' -EA SilentlyContinue | Measure-Object).Count`
: `rm -f '${target}.bak-'* 2>/dev/null; ls '${target}.bak-'* 2>/dev/null | wc -l`;
const r = await c.invoke<{ stdout?: string }>(CapabilityNames.ShellExec, { command: cleanup, timeout_ms: 15_000 });
const left = Number((r.data?.stdout ?? '').trim());
if (r.status === 'ok' && Number.isFinite(left)) {
if (left === 0) console.log('✓ 已清理历史时间戳备份(老格式)');
else console.log(`⚠️ 仍有 ${left} 个历史备份未清理(可手动删除)`);
}
} catch {
/* 清理是附加项,失败不影响部署结果 */
}
return { backupPath };
});
@@ -375,7 +395,7 @@ export async function cmdDeploy(localFile: string, opts: Options): Promise<void>
console.error(` 回滚方式:把备份复制回目标即可 ——`);
console.error(` nodeagent exec "Copy-Item '${uploadInfo.backupPath}' '${target}' -Force"`);
}
console.error(' 排查:被控端杀软(主流杀软/杀软)可能处置了被覆盖的 agent 文件');
console.error(' 排查:被控端杀软可能处置了被覆盖的 agent 文件');
process.exitCode = 1;
}
}
@@ -620,3 +640,115 @@ export async function cmdClip(opts: Options): Promise<void> {
}),
);
}
// ---------- v2.0.0 状态采样(monitor / log) ----------
/**
* 状态采样与回看。
*
* nodeagent monitor start --source port --target 127.0.0.1:8765 --interval 2000
* nodeagent monitor report <id>
* nodeagent monitor stop <id>
* nodeagent monitor list
* nodeagent monitor delete <id>
*/
export async function cmdMonitor(action: string | undefined, positional: string[], opts: Options): Promise<void> {
switch (action) {
case 'start': {
const source = opts.source;
if (!source) fail('用法: nodeagent monitor start --source <port|process|command|metric> [--target X] [--interval ms] [--id name]');
const args: Record<string, unknown> = { source };
if (opts.target !== undefined) args['target'] = opts.target;
if (opts.interval !== undefined) args['interval_ms'] = Number(opts.interval);
if (positional[0]) args['id'] = positional[0];
await withClient((c) =>
callAndPrint(c, CapabilityNames.MonitorStart, args, opts.json, (d) => {
const r = d as { id: string; source: string; target: string; interval_ms: number; file: string };
console.log(`✓ 已启动监控 ${r.id}(${r.source}${r.target ? ' → ' + r.target : ''},每 ${r.interval_ms}ms 一针)`);
console.log(` 样本文件:${r.file}`);
console.log(` 回看:nodeagent monitor report ${r.id}`);
}),
);
return;
}
case 'report': {
const id = positional[0];
if (!id) fail('用法: nodeagent monitor report <id> [--limit n]');
const args: Record<string, unknown> = { id };
if (opts.limit !== undefined) args['limit'] = Number(opts.limit);
await withClient((c) =>
callAndPrint(c, CapabilityNames.MonitorReport, args, opts.json, (d) => {
const r = d as {
running: boolean; source: string; target: string; samples: number;
summary: Record<string, number>;
};
console.log(`${r.running ? '● 运行中' : '○ 已停止'} ${r.source}${r.target ? ' → ' + r.target : ''},共 ${r.samples} 个样本`);
// 摘要直接说人话(这是本能力的主要价值)
const sm = r.summary;
if (sm.down_count !== undefined) {
console.log(` 连通:${sm.up_count} 通 / ${sm.down_count} 断;最长连续中断 ${sm.longest_outage_samples} 针` +
(sm.ms_avg !== undefined ? `;延迟 min/avg/max = ${sm.ms_min}/${sm.ms_avg}/${sm.ms_max}ms` : ''));
}
if (sm.dead_count !== undefined) console.log(` 存活:${sm.alive_count} 在 / ${sm.dead_count} 不在;最长连续缺失 ${sm.longest_dead_samples} 针`);
if (sm.fail_count !== undefined) console.log(` 命令:${sm.ok_count} 成功 / ${sm.fail_count} 失败;退出码 ${JSON.stringify(sm.exit_codes_seen)}`);
if (sm.cpu_avg !== undefined) console.log(` CPU min/avg/max = ${sm.cpu_min}/${sm.cpu_avg}/${sm.cpu_max}%;内存 avg ${sm.mem_avg}%`);
if (sm.window_ms !== undefined) console.log(` 时间窗:${(sm.window_ms / 1000).toFixed(1)}s`);
}),
);
return;
}
case 'stop': {
const id = positional[0];
if (!id) fail('用法: nodeagent monitor stop <id>');
await withClient((c) =>
callAndPrint(c, CapabilityNames.MonitorStop, { id }, opts.json, (d) => {
const r = d as { id: string; samples: number; file: string };
console.log(`✓ 已停止 ${r.id}(共 ${r.samples} 个样本);样本留在 ${r.file}`);
}),
);
return;
}
case 'list': {
await withClient((c) =>
callAndPrint(c, CapabilityNames.MonitorList, {}, opts.json, (d) => {
const r = d as { running: Array<{ id: string; source: string; target: string; samples: number }>; running_count: number; history_files: string[] };
console.log(`运行中 ${r.running_count} 个:`);
for (const m of r.running) console.log(` ● ${m.id}(${m.source}${m.target ? ' → ' + m.target : ''},${m.samples} 样本)`);
if (r.history_files.length) console.log(`历史文件:${r.history_files.join(', ')}`);
}),
);
return;
}
case 'delete': {
const id = positional[0];
if (!id) fail('用法: nodeagent monitor delete <id>(运行中需先 stop)');
await withClient((c) =>
callAndPrint(c, CapabilityNames.MonitorDelete, { id }, opts.json, () => console.log(`✓ 已删除 ${id} 的历史样本`)),
);
return;
}
default:
fail('用法: nodeagent monitor <start|report|stop|list|delete> ...');
}
}
/** 在被控端侧过滤日志(v2.0.0):日志常几万行,过滤必须发生在被控端。 */
export async function cmdLog(opts: Options): Promise<void> {
const logPath = opts.path;
if (!logPath) fail('用法: nodeagent log --path <文件> [--pattern 正则] [--level ERROR] [--limit n] [--tail]');
const args: Record<string, unknown> = { path: logPath };
if (opts.pattern !== undefined) args['pattern'] = opts.pattern;
if (opts.level !== undefined) args['level'] = opts.level;
if (opts.since !== undefined) args['since'] = Number(opts.since);
if (opts.limit !== undefined) args['limit'] = Number(opts.limit);
if (opts.offset !== undefined) args['offset'] = Number(opts.offset);
if (opts.tail === true) args['tail'] = true;
await withClient((c) =>
callAndPrint(c, CapabilityNames.LogQuery, args, opts.json, (d) => {
const r = d as { matched: Array<{ n: number; text: string }>; scanned: number; truncated: boolean; note?: string };
console.log(`✓ 扫描 ${r.scanned} 行,命中 ${r.matched.length} 条${r.truncated ? '(结果被截断)' : ''}`);
for (const m of r.matched) console.log(` ${String(m.n).padStart(6)} ${m.text.slice(0, 160)}`);
if (r.note) console.log(` ℹ️ ${r.note}`);
}),
);
}
+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;
}
+93
View File
@@ -183,6 +183,99 @@ export async function cmdRestart(opts: Options): Promise<void> {
);
}
// ---------- v2.0.0 审计链外部锚定 ----------
/**
* 打印审计链头(可选与最近锚点比对)。
*
* nodeagent audit head [--compare]
*/
export async function cmdAuditHead(opts: Options): Promise<void> {
const args: Record<string, unknown> = {};
if (opts.compare === true) args['compare'] = true;
if (opts.anchorPath !== undefined) args['anchor_path'] = opts.anchorPath;
await withClient((c) =>
callAndPrint(c, CapabilityNames.AuditHead, args, opts.json, (data) => {
const d = data as {
entries: number; head_hash: string | null; head_ts: number | null;
rotated_segments: number; file_bytes: number;
comparison?: { ok: boolean; anchors_checked: number; verdict: string };
};
console.log(`审计链头:${d.entries} 条,轮转 ${d.rotated_segments} 段,${(d.file_bytes / 1024).toFixed(0)} KB`);
console.log(` head_hash : ${d.head_hash ?? '(空链)'}`);
console.log(` head_ts : ${d.head_ts ? new Date(d.head_ts).toLocaleString() : '-'}`);
console.log(' 💡 把 head_hash 连同 entries 存到链外(另一台机器/网盘)—— 这是外部锚定');
if (d.comparison) {
const k = d.comparison.ok ? '✓' : '✗';
console.log(` ${k} 锚点比对(${d.comparison.anchors_checked} 条锚点):${d.comparison.verdict}`);
}
}),
);
}
/**
* 追加一个锚点到锚点文件。
*
* nodeagent audit anchor [--anchor-path <文件>] [--note "每日例行"]
*/
export async function cmdAuditAnchor(opts: Options): Promise<void> {
const args: Record<string, unknown> = {};
if (opts.anchorPath !== undefined) args['path'] = opts.anchorPath;
if (opts.note !== undefined) args['note'] = opts.note;
await withClient((c) =>
callAndPrint(c, CapabilityNames.AuditAnchor, args, opts.json, (data) => {
const d = data as { file: string; total_lines: number; record: { entries: number; head_hash: string | null } };
console.log(`✓ 已锚定:${d.record.entries} 条,链头 ${String(d.record.head_hash).slice(0, 16)}…`);
console.log(` 锚点文件:${d.file}(累计 ${d.total_lines} 条锚点)`);
}),
);
}
/**
* 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) => {
+23
View File
@@ -14,6 +14,11 @@ export const HELP = `nodeagent —— 跨机 AI 接管框架(控制端 CLI)
nodeagent audit [--limit 20] [--type invoke|auth|acl|agent] [--client-id X] [--since <ms>]
查询被控端审计日志 (v3+)
nodeagent audit verify 校验审计链完整性(防篡改检测)(v11)
nodeagent audit l2 [status|enable|collect|verify] [--since <ms>] [--limit N]
L2 系统级审计兜底:采集 OS 自身审计事件,
补 L1 记不到的那块(agent 被停/被绕过时)(见 docs/AUDIT-L2.md)
仅 Windows 已落地;Linux 需预装 auditd;
macOS ESF 受 Apple entitlement 限制不可用
nodeagent metrics [--since <ms>] 成功指标:闭环率/装软件率/P95/拦截率 (v13)
nodeagent audio [get] 查看被控端静音状态与主音量 (v17)
nodeagent audio set [--mute on|off] [--volume 0-100] 设置静音 / 音量
@@ -48,6 +53,13 @@ export const HELP = `nodeagent —— 跨机 AI 接管框架(控制端 CLI)
nodeagent macro validate <file.json> | macro init [文件]
nodeagent events [--kind file|process|net] [--path <路径>] [--pattern G] [--seconds 15]
订阅事件并实时接收推送(无 --kind 则列出订阅)(v12)
nodeagent monitor start --source <port|process|command|metric> [--target X] [--interval ms] [--id 名]
启动定时采样并落盘 (v2.0.0):port 验连通性/process 验存活/
command 取退出码/metric 取 CPU·内存
nodeagent monitor report <id> 回看样本 + 摘要(断几次、最长断多久、值域)(v2.0.0)
nodeagent monitor stop|list|delete <id>
nodeagent log --path <文件> [--pattern 正则] [--level ERROR] [--limit n] [--tail]
在被控端侧过滤日志,只回匹配行(日志几万行也不拖网)(v2.0.0)
nodeagent record [--duration 5000] [--fps 2] [--scale 0.5] [--region x,y,w,h]
录屏为帧序列(有 ffmpeg 则封装 mp4)(v11)
nodeagent update --url <地址> --sha256 <哈希> [--dry-run]
@@ -55,6 +67,14 @@ export const HELP = `nodeagent —— 跨机 AI 接管框架(控制端 CLI)
nodeagent deploy <agent.mjs> [--path <远端路径>] [--check] [--force]
一键升级被控端 (v11/v18):比指纹 → 备份 → 上传 → 重启
→ 轮询校验;--check 只比对不部署
nodeagent rollback [--dry-run] [--backup <远端备份路径>]
从 LKG(最后已知可用版本)回滚被控端 (v2.1.0):
指纹比对定位备份 → 覆盖回入口 → 受控重启 → 复核上线
⚠️ 需被控端可达(RPC 命令):新版本「完全起不来」时
没有监听可连,请依赖看门狗自动恢复(启动连续失败 N 次,
见 docs/DELIVERY-PIPELINE.md §6)或带外恢复(附录 C)
自动回滚刻意不做(无法归因/回滚循环/失控)
--dry-run 只读比对零写入;--backup 为高级逃生门
nodeagent group [add <组名> <设备...> | remove <组名>] 设备分组管理 (v12)
nodeagent fanout <能力名> [--nodes a,b|@组名] [--args JSON]
多设备并发调用并汇总 (v11)
@@ -72,6 +92,9 @@ export const HELP = `nodeagent —— 跨机 AI 接管框架(控制端 CLI)
nodeagent mouse scroll <delta>
nodeagent key type "<文本>" [--interval 10]
nodeagent key press <键1> [键2] ... (组合键,如 ctrl c)
nodeagent key press media_play_pause --pid 1234 (媒体键,v1.2:走 WM_APPCOMMAND,
--pid 定向到该进程的全部顶层窗口;省略则广播。
窗口被遮挡/最小化/游戏全屏时依然有效)
通用选项:
--node <设备名> 本次命令临时指定目标设备(不改变 current)
+32 -3
View File
@@ -34,13 +34,14 @@ import { HELP } from './help.js';
import { parseOptions } from './options.js';
import { cmdConnect, cmdDiscover, cmdKeygen, cmdNodes, cmdRemove, cmdUse } from './cmd/conn.js';
import {
cmdApps, cmdAudio, cmdAudit, cmdAuditVerify, cmdExec, cmdInfo, cmdInstall, cmdInvoke, cmdList,
cmdApps, cmdAudio, cmdAudit, cmdAuditAnchor, cmdAuditHead, cmdAuditL2, cmdAuditVerify, cmdExec, cmdInfo, cmdInstall, cmdInvoke, cmdList,
cmdMetrics, cmdPs, cmdRestart, cmdServices, cmdStatus, cmdUpdate,
} from './cmd/system.js';
import {
cmdBg, cmdClip, cmdDeploy, cmdEvents, cmdFanout, cmdMacro, cmdRecord, cmdTask, cmdTasks,
cmdBg, cmdClip, cmdDeploy, cmdEvents, cmdFanout, cmdLog, cmdMacro, cmdMonitor, cmdRecord, cmdTask, cmdTasks,
} from './cmd/ops.js';
import { cmdKey, cmdMouse, cmdScreen, cmdScreenshot } from './cmd/gui.js';
import { cmdRollback } from './cmd/rollback.js';
import { cmdAwait, cmdKey, cmdMouse, cmdScreen, cmdScreenshot } from './cmd/gui.js';
import { cmdCat, cmdLs, cmdPull, cmdPush, cmdStat } from './cmd/fs.js';
import { cmdNet } from './cmd/net.js';
import { DAEMON_SOCK, fail, getClientConfig, setNodeOverride } from './core.js';
@@ -60,6 +61,16 @@ async function main(): Promise<void> {
return;
}
// ⚠️ `--help` 是**选项**、不是位置参数。
// parseArgs 开了 `strict: false`,`--help` / `-h` 会被收进 `values`(作 boolean),
// `positionals` 里压根没有它 —— 原来靠「`positionals[0] === undefined` 时打印帮助」
// **偶然**蒙对,一旦前面多一个位置参数就会掉进「未知命令」。
// L-01 总闸守卫(tests/unit/cli-options.test.mjs)扫出这类「承诺了却没接上」的选项。
if (opts.help) {
console.log(HELP);
return;
}
switch (command) {
case 'connect': {
const host = positionals[0];
@@ -187,6 +198,14 @@ async function main(): Promise<void> {
case 'events':
await cmdEvents(opts);
return;
case 'monitor': {
const [action, ...rest2] = positionals;
await cmdMonitor(action, rest2, opts);
return;
}
case 'log':
await cmdLog(opts);
return;
case 'macro': {
const sub = positionals[0];
await cmdMacro(sub, positionals[1], opts);
@@ -242,6 +261,9 @@ async function main(): Promise<void> {
await cmdDeploy(f, opts);
return;
}
case 'rollback':
await cmdRollback(opts);
return;
case 'install': {
const pkg = positionals[0];
if (!pkg) fail('用法: nodeagent install <包名|ID>');
@@ -268,6 +290,9 @@ async function main(): Promise<void> {
return;
case 'audit':
if (positionals[0] === 'verify') await cmdAuditVerify(opts);
else if (positionals[0] === 'head') await cmdAuditHead(opts);
else if (positionals[0] === 'anchor') await cmdAuditAnchor(opts);
else if (positionals[0] === 'l2') await cmdAuditL2(positionals[1], opts);
else await cmdAudit(opts);
return;
case 'discover':
@@ -304,6 +329,10 @@ async function main(): Promise<void> {
await cmdKey(action, rest2, opts);
return;
}
case 'await': {
await cmdAwait(opts);
return;
}
case undefined:
case 'help':
case '--help':
+54 -3
View File
@@ -38,6 +38,11 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
const { values, positionals } = parseArgs({
args: rest,
options: {
// ⚠️ 这两个是 L-01 同类问题的「漏网之鱼」,由 cli-options 守卫扫出(v2.0.3):
// `--help` 原来靠 positionals[0]===undefined 偶然命中;`--no-tls` 则绕过解析器
// 直接在 cmd/conn.ts 里扫 `process.argv`。两者都是「能跑,但没接进解析器」。
help: { type: 'boolean', default: false },
'no-tls': { type: 'boolean', default: false },
json: { type: 'boolean', default: false },
insecure: { type: 'boolean', default: false },
port: { type: 'string' },
@@ -54,6 +59,24 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
button: { type: 'string' },
duration: { type: 'string' },
interval: { type: 'string' },
tail: { type: 'boolean' },
compare: { type: 'boolean' },
'anchor-path': { type: 'string' },
note: { type: 'string' },
level: { type: 'string' },
target: { type: 'string' },
source: { type: 'string' },
where: { type: 'string' },
any_of: { type: 'string' },
title: { type: 'string' },
text: { type: 'string' },
process: { type: 'string' },
state: { type: 'string' },
condition: { type: 'string' },
// 媒体键定向投递的目标进程 PID(v1.2)
pid: { type: 'string' },
hold: { type: 'string' },
route: { type: 'string' },
'auth-mode': { type: 'string' },
since: { type: 'string' },
type: { type: 'string' },
@@ -62,7 +85,6 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
'discovery-port': { type: 'string' },
node: { type: 'string' },
name: { type: 'string' },
note: { type: 'string' },
recursive: { type: 'boolean', default: false },
pattern: { type: 'string' },
'create-dirs': { type: 'boolean', default: false },
@@ -72,6 +94,12 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
delay: { type: 'string' },
// v10 后台任务 / 剪贴板
'timeout-ms': { type: 'string' },
// ⚠️ L-01(v2.0.3 修):`--timeout` 曾经**没在这里声明**。因为 `strict: false`,
// 未声明选项会被当成 boolean 塞进 `values.timeout`(`true`),而它的值
// `30000` 则掉进 `positionals` —— 于是 `--timeout` 静默失效,
// 并且**污染位置参数**(可能把子命令/主机名解析错)。
// 帮助文本与 `cmd/gui.ts` 的 await 命令用的都是 `--timeout`,故必须显式声明。
timeout: { type: 'string' },
kill: { type: 'boolean', default: false },
offset: { type: 'string' },
set: { type: 'string' },
@@ -99,6 +127,8 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
'forget-cert': { type: 'boolean', default: false },
check: { type: 'boolean', default: false },
force: { type: 'boolean', default: false },
// v2.1.0 rollback 逃生门(--backup <远端备份路径>)
backup: { type: 'string' },
mute: { type: 'string' },
volume: { type: 'string' },
seconds: { type: 'string' },
@@ -109,6 +139,8 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
});
return {
opts: {
help: Boolean(values['help']),
noTls: Boolean(values['no-tls']),
json: Boolean(values['json']),
insecure: Boolean(values['insecure']),
port: values['port'] as string | undefined,
@@ -123,6 +155,23 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
button: values['button'] as string | undefined,
duration: values['duration'] as string | undefined,
interval: values['interval'] as string | undefined,
tail: values['tail'] as boolean | undefined,
compare: values['compare'] as boolean | undefined,
anchorPath: values['anchor-path'] as string | undefined,
note: values['note'] as string | undefined,
level: values['level'] as string | undefined,
target: values['target'] as string | undefined,
source: values['source'] as string | undefined,
where: values['where'] as string | undefined,
any_of: values['any_of'] as string | undefined,
condition: values['condition'] as string | undefined,
state: values['state'] as string | undefined,
process: values['process'] as string | undefined,
text: values['text'] as string | undefined,
title: values['title'] as string | undefined,
pid: values['pid'] as string | undefined,
hold: values['hold'] as string | undefined,
route: values['route'] as string | undefined,
authMode: values['auth-mode'] as string | undefined,
since: values['since'] as string | undefined,
type: values['type'] as string | undefined,
@@ -131,7 +180,9 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
discoveryPort: values['discovery-port'] as string | undefined,
node: values['node'] as string | undefined,
delay: values['delay'] as string | undefined,
timeoutMs: values['timeout-ms'] as string | undefined,
// 两个写法都接受:`--timeout` 是帮助文本/`await` 命令里的正式写法,
// `--timeout-ms` 是早期在 `bg` 命令里引入的别名。二者同时给时以 `--timeout` 为准。
timeoutMs: (values['timeout'] ?? values['timeout-ms']) as string | undefined,
kill: Boolean(values['kill']),
offset: values['offset'] as string | undefined,
set: values['set'] as string | undefined,
@@ -159,12 +210,12 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
forgetCert: Boolean(values['forget-cert']),
check: Boolean(values['check']),
force: Boolean(values['force']),
backup: values['backup'] as string | undefined,
mute: values['mute'] as string | undefined,
volume: values['volume'] as string | undefined,
seconds: values['seconds'] as string | undefined,
intervalMs: values['interval-ms'] as string | undefined,
name: values['name'] as string | undefined,
note: values['note'] as string | undefined,
recursive: Boolean(values['recursive']),
pattern: values['pattern'] as string | undefined,
createDirs: Boolean(values['create-dirs']),
+35 -5
View File
@@ -1,6 +1,10 @@
// CLI 共享类型与常量(从 index.ts 拆出)
/** 已解析的常用选项。 */
export interface Options {
/** 打印帮助后退出(`--help` / `-h`)。选项而非位置参数 */
help: boolean;
/** 关闭 TLS(`--no-tls`)。原来是直接扫 process.argv,现归位到解析器 */
noTls: boolean;
json: boolean;
insecure: boolean;
port?: string;
@@ -16,10 +20,38 @@ export interface Options {
button?: string;
duration?: string;
interval?: string;
/** 媒体键定向投递的目标进程 PID(v1.2 keyPress) */
/** 通用选项:文本 / 标题 */
text?: string;
title?: string;
/** v2.0.0 审计锚定 */
compare?: boolean;
anchorPath?: string;
note?: string;
/** v2.0.0 状态采样 */
source?: string;
target?: string;
pattern?: string;
level?: string;
since?: string;
offset?: string;
tail?: boolean;
/** v2.0.0 gui.await 扩展 */
where?: string;
any_of?: string;
/** v1.6.0 gui.await */
condition?: string;
state?: string;
path?: string;
process?: string;
/** v1.5.0 快捷键扩展:长按毫秒 */
hold?: string;
/** v1.5.0 快捷键扩展:投递路由 foreground|post */
route?: string;
pid?: string;
/** v3:认证模式 psk | ed25519 */
authMode?: string;
/** v3+ 审计查询 */
since?: string;
type?: string;
clientId?: string;
/** v4 发现 */
@@ -28,10 +60,8 @@ export interface Options {
/** v5 多设备 */
node?: string;
name?: string;
note?: string;
/** v5 文件 */
recursive?: boolean;
pattern?: string;
createDirs?: boolean;
/** v6 Hub */
hubToken?: string;
@@ -41,7 +71,6 @@ export interface Options {
/** v10 后台任务 / 剪贴板 */
timeoutMs?: string;
kill?: boolean;
offset?: string;
set?: string;
/** v11 剪贴板图片 */
imageFile?: string;
@@ -50,7 +79,6 @@ export interface Options {
/** v11 多设备并发 */
nodes?: string;
/** v11 部署目标路径 */
path?: string;
/** v12 事件订阅 */
kind?: string;
/** v12 宏变量 */
@@ -68,6 +96,8 @@ export interface Options {
/** v18 部署校验 */
check?: boolean;
force?: boolean;
/** v2.1.0 rollback 逃生门:直接指定远端备份路径为回滚源 */
backup?: string;
/** v17 音频 */
mute?: string;
volume?: string;
+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"
}
}
}
+47
View File
@@ -0,0 +1,47 @@
<script setup lang="ts">
import { watchEffect } from "vue";
import { NConfigProvider, NMessageProvider, NDialogProvider, zhCN, dateZhCN } from "naive-ui";
import AppShell from "./components/AppShell.vue";
import QuitGuard from "./components/QuitGuard.vue";
import { cssVars } from "./theme";
import { useThemeStore } from "./stores/theme";
const theme = useThemeStore();
/**
* 把调色板写成 `--na-*` CSS 变量挂到根元素。
*
* 色值只在 `theme/index.ts` 定义一份,此处在运行期注入给自定义样式用 ——
* 免得「TS 调色板」与「CSS 硬编码」各写一份后静默漂移(换主题时一半新一半旧,
* 不报错、类型也抓不到)。
*
* 同时设 `color-scheme`:不设的话滚动条、`<input>` 等原生控件会一直是浅色,
* 在「高级灰」下白得刺眼。
*/
watchEffect(() => {
const { palette, name } = theme.preset;
const root = document.documentElement;
for (const [k, v] of Object.entries(cssVars(palette))) {
root.style.setProperty(k, v);
}
root.style.colorScheme = palette.scheme;
root.dataset.theme = name;
});
</script>
<template>
<n-config-provider
:theme="theme.preset.base"
:theme-overrides="theme.preset.overrides"
:locale="zhCN"
:date-locale="dateZhCN"
>
<n-message-provider>
<n-dialog-provider>
<!-- 必须在 dialog-provider 内部:它要用 useDialog() 弹退出确认 -->
<quit-guard />
<app-shell />
</n-dialog-provider>
</n-message-provider>
</n-config-provider>
</template>
+179
View File
@@ -0,0 +1,179 @@
/** Tauri command 调用封装(前端 → Rust 壳 → Node sidecar → 被控端)。 */
import { invoke } from "@tauri-apps/api/core";
import type {
AgentActionResult,
AgentLogPage,
AgentStatus,
ApplyUpdateResult,
AutostartStatus,
CapabilityDescriptor,
ConnectInfo,
ConsoleSettings,
ConsoleSystemInfo,
InvokeResult,
UpdateInfo,
} from "../types";
/** 建立连接并握手。 */
export function connect(url: string, key: string, clientId = "console", insecure = true) {
return invoke<ConnectInfo>("connect", { url, key, clientId, insecure });
}
/** 断开连接(停自动重连)。 */
export function disconnect() {
return invoke<void>("disconnect");
}
/** 泛型能力调用(`timeoutMs` 用于长任务,如装软件 / 长命令)。 */
export function call<T = unknown>(
capability: string,
args: Record<string, unknown> = {},
timeoutMs?: number,
) {
return invoke<InvokeResult<T>>("invoke_capability", { capability, args, timeoutMs });
}
/** 当前连接的能力清单。 */
export function getCapabilities() {
return invoke<CapabilityDescriptor[]>("get_capabilities");
}
/** 当前连接状态字符串。 */
export function getState() {
return invoke<string>("get_state");
}
/**
* 能力调用失败(业务层失败,非传输层失败)。
*
* 协议真源 `InvokeResult.error` 只有 `{ name, message, data? }` —— **不含 code**,
* 所以分支判断一律走 `name`(如 `E_UNSUPPORTED_PLATFORM`)。
*/
export class CapabilityFailure extends Error {
readonly capability: string;
readonly errorName: string;
readonly data?: unknown;
constructor(capability: string, errorName: string, message: string, data?: unknown) {
super(message);
this.name = "CapabilityFailure";
this.capability = capability;
this.errorName = errorName;
this.data = data;
}
}
/** 被控端平台不支持该能力(如 macOS 被控端上的 app.* / system.service.list)。 */
export function isUnsupportedPlatform(e: unknown): boolean {
return e instanceof CapabilityFailure && e.errorName === "E_UNSUPPORTED_PLATFORM";
}
/** 能力被 ACL 拒绝。 */
export function isAclDenied(e: unknown): boolean {
return e instanceof CapabilityFailure && e.errorName === "E_ACL_DENIED";
}
/** 能力被调用限速拦截。 */
export function isRateLimited(e: unknown): boolean {
return e instanceof CapabilityFailure && e.errorName === "E_RATE_LIMITED";
}
/**
* 调用并要求成功:失败时抛出 `CapabilityFailure`(携带被控端 error.name)。
* 便于页面里 `await ok("fs.list", { path })` 直接拿数据。
*/
export async function ok<T = unknown>(
capability: string,
args: Record<string, unknown> = {},
timeoutMs?: number,
): Promise<T> {
const res = await call<T>(capability, args, timeoutMs);
if (res.status !== "ok") {
throw new CapabilityFailure(
capability,
res.error?.name ?? "E_UNKNOWN",
res.error?.message ?? `${capability} 执行失败`,
res.error?.data,
);
}
return res.data as T;
}
// ---------------------------------------------------------------------------
// 本机服务(本机被控端)—— 全部走 Tauri command,不经被控端协议
// ---------------------------------------------------------------------------
/** 本机服务总览:进程状态 + 配置 + 自启 + 设置 + 版本。 */
export function agentStatus() {
return invoke<AgentStatus>("agent_status");
}
/** 启动本机被控端。`autoRestart` 省略时取设置里的值。 */
export function agentStart(autoRestart?: boolean) {
return invoke<AgentActionResult>("agent_start", { autoRestart });
}
/** 停止本机被控端(幂等)。 */
export function agentStop() {
return invoke<AgentActionResult>("agent_stop");
}
/** 重启本机被控端。 */
export function agentRestart(autoRestart?: boolean) {
return invoke<AgentActionResult>("agent_restart", { autoRestart });
}
/**
* 退出控制台(由前端的确认框调用)。
*
* `keepAgent = true` 保留被控端继续运行 —— 它将成为孤儿进程,
* 下次启动控制台时会被「复用」逻辑接管;`false` 则先停掉再退出。
*/
export function quitApp(keepAgent: boolean) {
return invoke<void>("quit_app", { keepAgent });
}
/** 增量取日志(传上次拿到的最大 seq)。 */
export function agentLogs(since = 0) {
return invoke<AgentLogPage>("agent_logs", { since });
}
/** 清空日志缓冲。 */
export function agentLogsClear() {
return invoke<void>("agent_logs_clear");
}
/** 开机自启状态。 */
export function autostartStatus() {
return invoke<AutostartStatus>("autostart_status");
}
/** 开关开机自启。 */
export function autostartSet(enabled: boolean) {
return invoke<AutostartStatus>("autostart_set", { enabled });
}
/** 检查更新(比对最新 Release)。 */
export function checkUpdate() {
return invoke<UpdateInfo>("check_update");
}
/** 应用更新:下载 → 校验 → 替换本机被控端脚本 → 重启。 */
export function applyUpdate(url: string, sha256: string, version: string) {
return invoke<ApplyUpdateResult>("apply_update", { url, sha256, version });
}
/** 读设置。 */
export function settingsGet() {
return invoke<ConsoleSettings>("settings_get");
}
/** 合并式写设置(只传要改的字段)。 */
export function settingsSet(patch: Partial<ConsoleSettings>) {
return invoke<ConsoleSettings>("settings_set", { patch });
}
/** 控制台自身信息。 */
export function systemInfo() {
return invoke<ConsoleSystemInfo>("system_info");
}
+204
View File
@@ -0,0 +1,204 @@
<script setup lang="ts">
import { computed, onMounted } from "vue";
import { RouterView, useRoute, useRouter } from "vue-router";
import {
NLayout,
NLayoutSider,
NLayoutHeader,
NLayoutContent,
NMenu,
NTag,
NButton,
NSpace,
NText,
NTooltip,
NRadioGroup,
NRadioButton,
type MenuOption,
} from "naive-ui";
import { navRoutes, type NavMeta } from "../router";
import { useConnStore } from "../stores/conn";
import { useAgentStore } from "../stores/agent";
import { useThemeStore } from "../stores/theme";
import { THEME_LIST } from "../theme";
const route = useRoute();
const router = useRouter();
const conn = useConnStore();
const agent = useAgentStore();
const theme = useThemeStore();
const themeName = computed({
get: () => theme.name,
set: (v) => theme.set(v),
});
onMounted(() => {
conn.bind();
agent.bind();
});
// 纯文字导航:应用内不使用图标(emoji 图标在部分渲染器里会退化成豆腐块)。
const menuOptions: MenuOption[] = navRoutes.map((r) => ({
key: r.name as string,
label: (r.meta as NavMeta).title,
}));
const activeKey = computed(() => (route.name as string) ?? "local");
function onMenu(key: string) {
router.push({ name: key });
}
const stateType = computed(() => {
if (conn.state === "connected") return "success" as const;
if (conn.state === "reconnecting") return "warning" as const;
return "error" as const;
});
const stateLabel = computed(() => {
if (conn.state === "connected") return "已连接";
if (conn.state === "reconnecting") return "重连中";
return "未连接";
});
/** 从侧栏快速启停本机服务(不必切页面)。 */
async function quickToggle() {
if (agent.running) await agent.stop();
else await agent.start();
}
</script>
<template>
<n-layout has-sider style="height: 100vh">
<n-layout-sider bordered :width="200" :native-scrollbar="false">
<div class="brand">
<div class="brand-title">nodeagent</div>
<div class="brand-sub">桌面控制台</div>
</div>
<n-menu :options="menuOptions" :value="activeKey" @update:value="onMenu" />
<div class="sider-foot">
<div class="foot-label">主题</div>
<n-radio-group v-model:value="themeName" size="small" class="theme-switch">
<n-radio-button v-for="t in THEME_LIST" :key="t.name" :value="t.name">
{{ t.label }}
</n-radio-button>
</n-radio-group>
<div class="foot-sep"></div>
<div class="foot-row">
<n-text depth="3" style="font-size: 12px">本机被控端</n-text>
<n-tag :type="agent.meta.type" size="tiny" round>{{ agent.meta.label }}</n-tag>
</div>
<n-button
size="tiny"
block
:loading="agent.busy"
:disabled="agent.busy || agent.externalInstance"
@click="quickToggle"
>
{{ agent.running ? "停止服务" : "启动服务" }}
</n-button>
</div>
</n-layout-sider>
<n-layout>
<n-layout-header bordered class="topbar">
<n-space align="center">
<n-tag :type="stateType" size="small" round>{{ stateLabel }}</n-tag>
<n-text depth="3" style="font-size: 12px">{{ conn.url }}</n-text>
<n-text v-if="conn.info" depth="3" style="font-size: 12px">
· agent {{ conn.info.agent_version ?? "?" }} · {{ conn.info.capabilities.length }} 项能力
</n-text>
</n-space>
<n-space align="center">
<n-tooltip v-if="agent.sysInfo?.agent_only" trigger="hover">
<template #trigger>
<n-tag size="small" type="info" round>仅服务模式</n-tag>
</template>
本控制台以「仅服务模式」启动(无窗口,常驻托盘)—— 通常来自开机自启
</n-tooltip>
<n-button size="small" :disabled="!conn.info" @click="conn.disconnect()">断开</n-button>
</n-space>
</n-layout-header>
<n-layout-content :native-scrollbar="false" content-style="padding: 20px;">
<router-view />
</n-layout-content>
</n-layout>
</n-layout>
</template>
<style>
/* 配色只走 `--na-*` 变量(值在 src/theme/index.ts 单点定义,App.vue 运行期注入),
此处不留任何写死的色值 —— 否则换主题时会一半新一半旧,且不报错。
`color-scheme` 也由 App.vue 按主题设,这里不重复声明。 */
html,
body {
background: var(--na-bg);
}
body {
margin: 0;
}
.brand {
padding: 18px 20px 10px;
}
.brand-title {
font-size: 18px;
font-weight: 600;
color: var(--na-brand);
letter-spacing: 0.5px;
}
.brand-sub {
font-size: 12px;
color: var(--na-text-3);
margin-top: 2px;
}
.topbar {
height: 48px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
}
/* 侧栏做成纵向弹性布局,才能把底部快捷块压到底 */
:deep(.n-layout-sider-scroll-container) {
display: flex;
flex-direction: column;
}
.sider-foot {
margin-top: auto;
padding: 12px 14px 16px;
border-top: 1px solid var(--na-border);
}
.foot-label {
font-size: 11px;
color: var(--na-text-3);
margin-bottom: 6px;
}
.foot-sep {
height: 1px;
background: var(--na-border);
margin: 12px 0;
}
/* 两个主题按钮各占一半宽(侧栏只有 200px,默认内边距会挤出去) */
.theme-switch {
display: flex;
width: 100%;
}
.theme-switch :deep(.n-radio-button) {
flex: 1;
}
.theme-switch :deep(.n-radio-button__label) {
display: flex;
justify-content: center;
font-size: 12px;
padding: 0 2px;
}
.foot-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
</style>
+71
View File
@@ -0,0 +1,71 @@
<script setup lang="ts">
/**
* 退出确认(2026-10-17 需求方提出)。
*
* ## 它解决的误解
* 旧行为:点托盘「退出」→ 控制台立刻消失,但被控端因 `kill_on_drop(false)`
* **成为孤儿继续监听端口**。用户以为「关了窗口就干净了」,实际还有个看不见的
* 进程在跑 —— 下次启动还会撞上「端口已被占用」。
*
* ## 为什么做成三选一
* 「留不留一个正在监听端口的后台进程」是有副作用的决定,必须由用户明确选择:
* - 保留被控端 → 远程仍可接管(下次启动控制台会自动复用它)
* - 停止被控端 → 彻底清净,但本机不再可被远程接管
* - 关闭弹窗 → 取消退出
*
* ## 为什么不在 Rust 里弹系统对话框
* 前端已有 `n-dialog-provider`,复用它就不必新增 `tauri-plugin-dialog` 依赖,
* 视觉也与全应用一致。Rust 侧只负责「发一个事件 + 提供 quit_app 命令」。
*/
import { onMounted, onUnmounted } from "vue";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { useDialog } from "naive-ui";
import { h } from "vue";
import { quitApp } from "../api";
const dialog = useDialog();
let unlisten: UnlistenFn | null = null;
onMounted(async () => {
try {
unlisten = await listen("na-quit-requested", () => {
const d = dialog.warning({
title: "退出控制台",
closable: true,
maskClosable: true,
content: () =>
h("div", { style: "line-height:1.7" }, [
h("div", "退出后本机被控端要怎么处理?"),
h(
"div",
{ style: "margin-top:6px;font-size:12px;opacity:.72" },
"保留:仍然可以被远程接管(下次打开控制台会自动复用它);停止:彻底结束,退出后不再有后台进程。",
),
]),
positiveText: "保留被控端,退出",
negativeText: "停止被控端,退出",
onPositiveClick: () => {
d.loading = true;
void quitApp(true);
},
onNegativeClick: () => {
d.loading = true;
void quitApp(false);
},
});
});
} catch {
/* 事件注册失败不应阻断应用启动 */
}
});
onUnmounted(() => {
unlisten?.();
unlisten = null;
});
</script>
<template>
<!-- 纯逻辑组件,不渲染任何可见内容 -->
<span style="display: none" />
</template>
+9
View File
@@ -0,0 +1,9 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import router from "./router";
import App from "./App.vue";
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.mount("#app");
+76
View File
@@ -0,0 +1,76 @@
/** 路由表。hash history 便于 Tauri 打包后稳定工作。 */
import { createRouter, createWebHashHistory } from "vue-router";
// 刻意不设 icon 字段:应用内一律不使用图标,导航以纯文字呈现。
// (原因见 CHANGELOG [未发布]:emoji 图标在非系统字体栈的渲染器里会退化成豆腐块,
// 而跨平台「哪套字体栈一定带 emoji」并不成立 —— 与其赌字形,不如不用。)
export interface NavMeta {
title: string;
}
const routes = [
// 默认落在「本机服务」—— 这是「打开应用就要能启动/停止/重启」的落点,
// 而不是先让用户去填一个连接地址。
{ path: "/", redirect: "/local" },
{
path: "/local",
name: "local",
component: () => import("../views/LocalServiceView.vue"),
meta: { title: "本机服务" } satisfies NavMeta,
},
{
path: "/logs",
name: "logs",
component: () => import("../views/LogsView.vue"),
meta: { title: "服务日志" } satisfies NavMeta,
},
{
path: "/nodes",
name: "nodes",
component: () => import("../views/NodesView.vue"),
meta: { title: "设备管理" } satisfies NavMeta,
},
{
path: "/overview",
name: "overview",
component: () => import("../views/OverviewView.vue"),
meta: { title: "状态总览" } satisfies NavMeta,
},
{
path: "/terminal",
name: "terminal",
component: () => import("../views/TerminalView.vue"),
meta: { title: "命令执行" } satisfies NavMeta,
},
{
path: "/processes",
name: "processes",
component: () => import("../views/ProcessesView.vue"),
meta: { title: "进程与服务" } satisfies NavMeta,
},
{
path: "/software",
name: "software",
component: () => import("../views/SoftwareView.vue"),
meta: { title: "软件管理" } satisfies NavMeta,
},
{
path: "/files",
name: "files",
component: () => import("../views/FilesView.vue"),
meta: { title: "文件管理" } satisfies NavMeta,
},
{
path: "/audit",
name: "audit",
component: () => import("../views/AuditView.vue"),
meta: { title: "审计日志" } satisfies NavMeta,
},
];
export const navRoutes = routes.filter((r) => "name" in r);
export default createRouter({
history: createWebHashHistory(),
routes,
});
Loaded 100 of 214 files, more files were not shown because too many files have changed in this diff. Show more