53 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
122 changed files with 13258 additions and 461 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
+90 -1
View File
@@ -43,6 +43,12 @@ on:
- '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 }}
@@ -316,4 +322,87 @@ jobs:
apps/console/src-tauri/target/release/bundle/nsis/*.exe
# 一条架构没产出就报错,避免「jobs 全绿但产物不全」
if-no-files-found: error
retention-days: 30
# 交付流水线 §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 }}
+1 -1
View File
@@ -159,7 +159,7 @@ jobs:
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
# 真机教训(Parallels 27 + Win11 ARM):曾出现 arm64 包里装的其实是 x64 的 node.exe
# 真机教训(虚拟化平台 + Win11 ARM):曾出现 arm64 包里装的其实是 x64 的 node.exe
# —— 两份包的 node.exe 字节完全相同。x64 node 在 ARM Windows 上**能 listen,
# 但 TLS 握手不完成**(客户端只见超时)→ 被控端实际不可用,且 macOS 侧 100% 测不出。
# 根因是 pack.mjs 的运行时缓存文件名不含架构。这里直接验**最终产物**的 PE 架构,
+4
View File
@@ -54,3 +54,7 @@ keys/
# 本地 agent 工作数据(memory / automations 等,属本机状态,不入库)
.workbuddy/
# 被控端 / 控制端本机运行配置(含真实密钥与节点地址,绝不入库)
config.json
agent.json
+25
View File
@@ -0,0 +1,25 @@
# gitleaks 误报台账(按指纹精确忽略,不影响任何规则)
#
# ## 为什么用 .gitleaksignore 而不是 .gitleaks.toml 的 allowlist
# - `.gitleaks.toml` 的顶层 `[allowlist].paths = ['tests/**']` 会对**所有规则**放行,
# 于是 `private-key`(私钥块)、`aws-access-token` 等在测试目录下也失效 ——
# 而「为了造样本把真实私钥贴进 fixture」恰恰是最常见的泄露方式。
# - `[[rules]]` 重定义 `generic-api-key` 会**替换**内置规则(gitleaks 要求重写 regex),
# 等于自己维护一份正则,反而更容易漏。
# - 指纹是 `<commit>:<file>:<rule>:<line>`,**只忽略这一条**,其余一概照旧。
#
# ## 逐条核对记录(2026-10-17)
# 4 条命中全部位于测试文件,值均为明显的递增占位(含 `0123456789abcdef` 模式):
# sec-e2e-key-0123456789abcdef0123456789
# test-key-0123456789abcdef
# fs-key-0123456789abcdef0123456789abcdef
# e2e-test-key-0123456789abcdef0123456789abcdef
# 已逐一核对:**均不等于被控端真实 PSK,均不含 64 位十六进制串** → 判定为误报。
#
# ## 若日后有人在此文件里追加指纹
# 必须先做同样的核对:确认该值不是真实凭据。**这个文件不是「让 CI 变绿」的开关。**
#
0d59d6032b96c42f9eda380c4b6c2e95d253f856:tests/e2e/run.mjs:generic-api-key:906
f4c605cc6d26e85c81b50e10da3a466f755d8221:tests/unit/hmac.test.mjs:generic-api-key:6
4ff1b4fd9021755d7ed060da5baadc5618dcbc3d:tests/e2e/run.mjs:generic-api-key:30
77c024703abf5ac07e63f73c551f623539ff0856:tests/e2e/run.mjs:generic-api-key:21
+595 -9
View File
@@ -7,6 +7,280 @@
## [未发布]
### 修复 —— 发版脚本在版本号已钉在树中时卡死(`451d44a`)
`scripts/release.mjs` 无条件执行 `git commit`。当版本号已经作为 `chore(release)`
提交进树(v2.1.0 的实际流程)时,`git add -A` 后暂存区为空,`git commit` 以
`nothing to commit` 非零退出,整个发版停在最后一步,且报错来自 git 而非发布逻辑
(极易误判为工作区问题)。改为:暂存区为空则跳过 commit,标签与 Release 照常创建。
### 安全 —— 依赖漏洞清零(`pnpm audit --audit-level=high` 由 3 high → 0)
Quality Gate 的「依赖漏洞扫描(SCA)」在 `451d44a` 上为红,全部来自
`@modelcontextprotocol/sdk` 的传递依赖与本仓 devDependency:
- `zod <4.6.6`(GHSA-mrhx-c7xc-m3w5)→ 根 `pnpm-workspace.yaml` 加 overrides `^4.6.6`;
- `body-parser <2.4.0`(GHSA-27r3-jxmh-p828)→ overrides `^2.4.0`;
- `vite <8.3.3`(GHSA-587h-7wmj-h5pm)→ 控制台 devDependency `^8.3.5`。
⚠️ pnpm 12 起 `package.json` 的 `pnpm` 字段不再被读取(设置迁移到
`pnpm-workspace.yaml`)—— 写在那里会被**静默忽略**,与「一直红所以等于没有」
是同一类失败形态,故覆盖项写在 workspace 文件里并注明原因。
### 安全 —— 依赖漏洞彻底清零(中低危一并处理)
在上一节之后复扫仍余 6 条(2 low / 4 moderate),全部经 MCP SDK 传递:
- `hono <4.13.11`(serveStatic 路径二次解码绕过中间件)→ overrides `^4.13.14`;
- `ip-address <=10.7.3`(zone 标识符二次方耗时 DoS)→ overrides `^10.7.5`;
- `fast-uri <3.1.9`(scheme 伪造 / host 混淆 / 路径等价 4 条)→ overrides `^3.1.9`。
三条与 dependabot #3/#6/#7 同源,覆盖后那三个 PR 已无增量(随提交一并关闭)。
当前 `pnpm audit` 输出 **No known vulnerabilities found**。
### 修复 —— 看门狗日志无界增长(常驻被控端,写满磁盘会拖死 agent)
`startup-guard.log` 一直是 `appendFileSync` 无限追加。看门狗本身常驻被控端,
「坏版本 + supervisor 反复重启」时写得很快,磁盘满会连带把 agent 的审计写入拖死。
现按 **1MB × 4 份**(当前 + `.1`…`.3`)轮换:超阈值先丢最旧、再逐个后移、当前滚为 `.1`。
轮换与写入**双双失败静默** —— 日志问题绝不能反过来拦住被控端启动。
判断逻辑放 `startup-guard-core.mjs` 纯函数(`shouldRotate` / `rotationTargets`),
并补 3 条单测(阈值边界含 NaN、命名序列、真实滚存三轮)。
### 修复 —— 控制台 L2 采集参数必须先出结果才出现
`AuditView.vue` 里「采集窗口 / 条数上限」两个输入框原先在
`v-if="l2Rows.length"` 容器内 —— 用户想「先设 72 小时再采集」做不到,只能先按
默认值点一次再改。参数与结果无生命周期关系,已拆出容器常显。
### 修复 —— e2e 连跑偶发失败(固定端口被上一轮残留占用)
套件有 8 处 `spawn`,各自 `finally` 只发 `kill()`(SIGTERM)就 `process.exit`,
被控端可能来不及退出 → 下一轮固定端口仍被占,表现为「连跑第二次偶发失败,重跑又绿」。
改为**包一层 spawn 统一登记**子进程(调用点零改动),进程退出时对存活者 `SIGKILL`;
单点收口,将来新增 spawn 也不会漏。实测连跑两次 34/34、退出后无残留 agent 进程。
## [2.1.0] - 2026-10-26
> 本段为 **v2.1.0** 定稿段(版本统一见 `a75c5c9`):selfupdate 代理出网、LKG 内容指针、
> 三个架构审查 P0 修复、L2 三端暴露,以及 rollback / 看门狗 / na-gui 清理 / 收口。
### 新增 —— 控制端 `nodeagent rollback`:从 LKG 回滚被控端(ARCH T03 / PRD P0-2)
`rollback` 补上「新版本能启动但需要回退」的通道:读 LKG → 枚举备份(`*.bak`/`*.bak-*`,
mtime 新→早)→ 分块流式 sha256 指纹匹配(`hash_full` 全量优先,老记录回落 12 位前缀)
→ 覆盖写入口文件(同 deploy 姿势:就地覆写不用 rename,Windows 真机教训)→ 写后校验
(失败用回滚源还原)→ 独立连接重启 → 90s 墙钟复核 `build.hash == LKG`。
- `--dry-run` 只读零写(列出候选与匹配结果);`--backup <路径>` 逃生门:显式指定
回滚源、跳过指纹匹配(用于 LKG 记录丢失/全部备份被污染的极端场景)。
- 无 LKG / 无指纹匹配:列候选后零写退出(code 2),不碰被控端一个字节。
- 纯函数(`planRollback` / `pickBackupByHash` / `hashRemoteFile`)与 IO 分离,
假 client 注入单测 19 例(含写后校验失败自动还原、多候选时序、Windows 路径)。
- 能力圈边界写进 HELP 与 README:RPC 命令**需被控端可达**;「完全起不来」的场景
由看门狗接管(下节)。
### 新增 —— 被控端启动看门狗:连续失败 N 次自动从 LKG 恢复(决策 R-1)
rollback 覆盖不了「进程完全起不来」—— 没有监听可连,RPC 无能为力。看门狗跑在
supervisor 链上(Windows 计划任务 / Linux systemd / macOS launchd 三端已接入),
启动连续失败 N 次(默认 3)自动从 LKG 指纹匹配的备份恢复入口文件。
- `scripts/startup-guard.mjs`(裸环境可跑:零项目依赖,只依赖 Node ≥22 内置模块)
+ `scripts/lib/startup-guard-core.mjs`(纯函数零 import:状态机判断可单测)。
- **30s 启动宽限期 + 端口探活**,两类误计数被消除:① agent 自身重启机制
(kill -9 / taskkill 打死长命进程)是计划内重启;② supervisor 重启造成的端口撞车
(新实例 EADDRINUSE 秒崩)—— 不区分的话连续 3 次即**误回滚好版本**。
- 安全边界:**无 LKG / 无指纹匹配只记日志放行,绝不擦除 install-dir**;恢复目标
恒为 LKG(单一、已验证,无回滚循环);恢复动作全量记 JSONL 日志。
- 被控端启动成功后归零失败计数(`resetStartupGuardState`,`index.ts` 唯一改动点);
守卫 spawn 时强制注入 `NODEAGENT_HOME = --data-dir`(Windows 计划任务动作不带
环境变量,不注入则 agent 数据目录与守卫漂移,恢复链路静默失效)。
- Windows `RestartCount` 3 → 10:看门狗需要「连续失败 3 次 + 恢复」才生效,3 次不够用
(良性撞车退 0 不消耗重启次数,故 10 不会造成重启风暴)。
### 新增 —— na-gui 陈旧计划任务白名单清理(ARCH B-P1-6)
历史版本在被控端上注册过 `na-gui` 等旧名计划任务,漏删会留下「端口被占、
新版本起不来」的幽灵任务。
- `scripts/lib/scheduled-tasks.mjs`:`TASK_WHITELIST`(`nodeagent` / `na-gui` /
`nodeagent-console`)+ `planTaskCleanup` 纯函数。**白名单外一律不删** ——
误删用户自己的任务是比漏删严重得多的生产事故;精确匹配(大小写敏感),
宁可漏删也不模糊匹配。
- `scripts/uninstall.ps1`:卸载末尾按白名单逐个注销(存在性检查 + 动作进日志)。
- `scripts/control.ps1`:新增 `cleanup` 子命令(`control.cmd` 菜单第 8 项),
供「拿到端口占用提示后自救」。
- 单测 10 例(白名单命中才删 / 白名单外零动作 / 空列表 / 重复名 / 脏输入)+
ps1 源码守卫与跨端一致性守卫(白名单不许 mjs 与 ps1 两边漂移,注入故障实测会红)。
### 收口 —— L2 e2e 补齐 + 文档数字刷新与数字守卫(ARCH T05)
- **e2e**:`run.mjs` 补 L2 平台门控用例(非 Windows 明确报
`E_UNSUPPORTED_PLATFORM`,按**错误名**断言不按 code —— 协议级错误才 throw,
能力执行失败走 `result.status=failed`);`windows.mjs` 补 status/collect/verify
三条(字段与 manifest `returns_schema` 逐字对齐,collect 对非管理员环境 skip 容错)。
e2e 总数 33 → 50(34 通用 + 16 Windows 专属)。
- **README 数字刷新**:52 项能力 · 42 个 MCP 工具 · 467 项单元测试 ·
50 项端到端用例 · 23 项 Rust 壳单测;补 `nodeagent rollback` 与看门狗段、
`na_audit_l2` 工具表项;删「控制台安装包只作为 CI artifact」过期行
(`9f95560` 起已随 Release 发布)。
- **数字守卫** `tests/unit/docs-numbers.test.mjs`:README 五个数字与可复现实测口径
(manifest 条目 / defs.ts 工具 / node --test 实测 / e2e 源码 / Rust 属性)钉死,
不一致即红 —— 「增能力不改 README」从此会红(注入故障实测验证)。
### 修复 —— selfupdate 在境内**必然**失败(P0-1:裸 `fetch` 不读代理)
真机症状:被控端自更新下载 `fetch failed`。根因不是网络差 —— Node 的 `fetch`(undici)
**不读** `HTTPS_PROXY` 等环境变量,而境内访问 GitHub 必经代理。
- 新增 `apps/agent/src/net/proxy.ts` + `net/index.ts`:sidecar 已验证出网方案的 TS 移植。
代理来源优先级 `NA_PROXY` → 目标协议环境变量 → 系统代理(Windows reg query WinINET /
macOS `scutil --proxy`);PAC/socks5 **明确报 note 不静默降级**;重定向链**共享超时预算**
(防 5 跳×30s 穿过 RPC 超时);CONNECT 隧道 abort 自管(外层 request 建立前 signal 无人
受理 —— 真机 75s 配置挂 109s 的教训);TLS 默认校验证书。只依赖 node 内置模块,
不 import 项目内任何模块。
- `httpGet` 增 `maxBytes` 双闸(content-length 预检 + 流式中断),保住「64MB 上限」。
- 错误分类同款移植 sidecar `main.mjs:146-165`:`ECONNRESET` 不得报成「请求超时」
(真机教训 —— 报错类型错会把人带去排查完全不相干的方向)。
- **回环目标恒直连**(有意的设计偏离):代理另一侧的 localhost 不是本机 —— CI/dev 机
设了 `HTTP_PROXY` 时,发往 `127.0.0.1` 自建镜像的请求会被送进远程代理而 404。
- 备份保留最近 3 个(`planBackupCleanup` 纯函数,最旧先删)。
- 安全属性逐条不变:sha256 强制校验 / 先校验后落盘 / 写后校验失败回滚 / 就地覆写不用
rename(Windows)/ 只接受 http(s)。
### 变更 —— LKG 补内容指针(A-P0-3),rollback 有确定目标
原 LKG 只有 12 位指纹,没有「备份文件长什么样」的记述,rollback 时只能猜。
- `LkgRecord` 只增两字段:`hash_full`(sha256 全量 64 位)与 `backup_glob`(`<basename>.bak*`);
老记录无字段时原样通过(向后兼容)。
- 新增 `lkgHashMatches`:有 `hash_full` 按全量比,老记录回落 12 位前缀,两侧都无值
**不猜**(返回 false)。
- `system.audit.l2` 的 `returns_schema` 从空壳补全为四 action 属性并集;
`system.agent.update` returns 增 `via/proxy/proxy_source/outbound_notes` 四个只增字段
(失败时能看出「走了代理还是直连、用的哪个代理源」)。
### 修复 —— 架构审查三个 P0(静态审查发现,均补「注入即红」守卫)
- **A-P0-1 validate 原型链绕过**:`key in schema.properties` 沿原型链,JSON 里写
`"constructor"`/`"__proto__"` 会被判「schema 已声明」从而跳过拒绝(fail-open,
与已修的 ACL B-03 同源复发)。改 `Object.prototype.hasOwnProperty.call`。
- **A-P0-2 限速桶 key 用错值**:原实现把**限速值**当桶名(`clientId|5`)—— 两条同值
规则的能力共用一个桶,互相挤占/漏过;且桶只增不删 + psk 模式 client_id 自报 →
可枚举造键 → 内存无界增长。改为 `${认证身份 ?? 远端地址}:${能力名}` + 过期键清扫
+ 容量上限淘汰最旧(滑动窗口边界与旧实现逐字节一致)。
- **A-P0-4 describeArgs 大参数阻塞事件循环**:原实现每次 invoke 全量
`JSON.stringify` + sha256,`fs.write` 的 data 可达数 MB —— 同步序列化阻塞期间
GUI 注入、事件推送、保活 ping 全部延迟。改为超过 64KB 只哈希前 64KB(按**字节**切),
总长记 `args_bytes`、截断记 `args_truncated`。口径变化只影响审计摘要,
不影响任何安全属性(摘要用途是比对与防泄密,不是完整性证明)。
### 新增 —— L2 三端暴露(MCP `na_audit_l2` + 控制台 L2 分区 + `agent.l2` 审计类型)
决策「L2 同时暴露 MCP+控制台」。此前 `system.audit.l2` 只存在于被控端实现里:
MCP 无工具(模型看不到)、控制台无入口无类型(用户点不到)、审计事件写成
`agent.start`(与进程生命周期混为一类,筛不出来)。
- **MCP**(41 → 42 个工具):`na_audit_l2` 紧随 `na_audit`,四 action
(status/enable/collect/verify)+ since/limit,映射 `system.audit.l2`。
- **控制台**:AuditView 新增 L2 卡片(查状态/拉取采集/校验 L2 链/启用策略 ——
enable 有二次确认并写明需管理员权限);非 Windows 被控端按钮禁用并明示原因;
信任边界如实说明(采集器与 agent 同权限,防篡改依赖**转发出机**,不夸大本地副本)。
- **`agent.l2` 审计类型四端同源**:protocol `AUDIT_TYPES`(真源)→ agent 联合
(AssertExact 编译期守卫)→ 写入点 → 控制台联合。
- 顺手修了两个守卫盲区:MCP 工具名正则 `na_[a-z_]+` 匹配不了带数字的名字
(`na_audit_l2` 会整个漏扫);平台门控守卫原只扫 app.ts/system.ts 两种写法,
audit-l2.ts 的裸 throw 扫不到 —— 已改用统一 `assertWindows` 并纳入扫描范围。
### 新增 —— L2 系统级审计兜底(`7d781d1`,`docs/AUDIT-L2.md`)
L1 是 agent 自己记的,只看得见经过自己 RPC 的调用;攻击者**绕过 agent 直接操作机器、
或把 agent 停掉**时 L1 全盲。L2 采集操作系统自己的审计事件,补的正是这块。
- **信任边界(写进能力描述与文档,不夸大)**:采集器与 agent 同进程同权限 ——
能停 agent 就能停采集器。故本地 `audit-l2.log` 只是**归一化副本**,防篡改价值有限;
真正的独立性来自**把记录转发出机**(与 SECURITY.md §4.8 锚定同一思路)。本实现不自动转发。
- **Windows 落地**(选内置 auditpol 而非 Sysmon —— 第三方驱动需单独分发、重启生效、
版本行为有差异;内置策略零依赖即可立起来;检测到 Sysmon 则并用,未装不尝试下载):
- `status`:查策略是否启用,并提示两个关键开关 —— `SCENoApplyLegacyAuditPolicy`
(不设它,组策略旧式类别策略会盖掉 subcategory,表现为「auditpol 显示已启用但
事件日志里没有 4688」)与 `ProcessCreationIncludeCmdLine_Enabled`(不开则 4688
只有进程名,看不到参数);
- `enable`(需管理员)/ `collect`(拉取并归一化落盘,独立于 L1 的 `audit-l2.log`)/
`verify`(校验 L2 链,与 L1 同构的「对含 prev 的记录求哈希」)。
- **三端现状如实记录**:Linux(auditd)需预装、macOS(ESF)受 Apple entitlement
限制,均**未实现** —— 非 Windows 被控端明确报 `E_UNSUPPORTED_PLATFORM`,
不伪装成空结果。CLI 已有 `nodeagent audit l2` 入口;v2.1.0 起 MCP 与
控制台亦暴露(见上方「L2 三端暴露」)。
### 新增 —— 交付流水线规范落地(`2bf45fd`,`docs/DELIVERY-PIPELINE.md`)
将《代码审计 → 上线 完整交付方案》适配到 nodeagent。原方案面向 Web/容器服务,
nodeagent 无服务端、无容器、无多环境 —— 照搬会产生「跑得很好但守不住任何东西」的
假门禁,故每个阶段给三档判定:适用 / 改造后适用 / 不适用(但必须给替代)。
关键替换:容器镜像扫描 → **交付物 PE 架构断言**(arm64 包误装 x64 node.exe 的真实事故);
DAST → **协议健壮性测试**(对外输入面是 WebSocket RPC,非 HTTP);
dev/staging/prod → **真机矩阵**(Win x64/arm64、macOS、Linux);
金丝雀 → **设备分批自更新**(先一台试点,latest.json 灰度);
Prometheus → `system.metrics` + 发布后复核清单。
- 覆盖率门禁 `scripts/ci/check-coverage.mjs`:全量**只观测不阻断**(实测基线 66.9%),
变更文件行覆盖 ≥70% 阻断。
- **LKG 回滚锚点**:被控端「能启动」后记最后已知可用版本(`agent-lkg.json`),
给回滚提供确定目标。**回滚策略分两档,边界写死**:
**启动期自动恢复已做**(看门狗:启动连续失败 N 次 → 从 LKG 恢复,
`scripts/startup-guard.mjs`,Windows 计划任务 / Linux systemd / macOS launchd
三端 supervisor 已接入);**运行期自动回滚仍刻意不做**(更新后健康检查失败即回滚
无法归因、有回滚循环风险、失控 —— 决策权留给控制端,`nodeagent rollback`)。
### 新增 —— Linux 被控端适配(`00992e5`,`docs/DEPLOY-LINUX.md`)
面向「服务器型被控端」(命令/状态/文件/审计;无 GUI),实测环境 Debian 12。
- `scripts/install-linux.sh`:一键安装器(对齐 Windows `install.ps1` 语义)——
自动获取 Node 运行时(走 npmmirror)、**systemd 服务**(系统级 / 用户级+linger /
`--no-service` 三种模式)开机自启、幂等(复用既有密钥并自动备份 `agent.json`)、
卸载保留配置(`--purge` 才删)。
- `apps/agent/src/util/platform.ts`:**统一平台守卫**(`assertPlatform` /
`assertGuiPlatform` / `assertWindows`),抛 `E_UNSUPPORTED_PLATFORM` 并带上
「支持哪些平台」。修复前的表现是**误导性错误**(`screen.capture` 在 Linux 上报
「region 宽高必须 ≥ 1」,像是参数没传对)。
- `scripts/verify.mjs`:补 `E_UNSUPPORTED_PLATFORM → skip`(此前把「平台不支持」
记成失败)。
### 变更 —— 质量门禁与 CI 修复(`141f479` / `c7d596e` / `630ea7d` / `3680218`)
质量门禁曾**连续 8 次运行全部失败**(从引入起就没绿过)—— 一道长期红的门禁等于
没有门禁。两处独立原因:
- **SCA**:`.npmrc` 指向 npmmirror(加速用),但镜像不实现 audit 接口,
`pnpm audit` 必报 `ERR_PNPM_AUDIT_ENDPOINT_NOT_EXISTS` → 显式加
`--registry=https://registry.npmjs.org`。
- **typecheck 缺 build 顺序**:各包 import 的是 `@nodeagent/protocol` 的**类型产物**
(`dist/*.d.ts`)。CI 全新检出没有 dist → 集体 `TS2307: Cannot find module`,
而本地因为 dist 还在看不出来(典型「本地绿 / CI 红」)→ typecheck 前先建 protocol。
注入验证:移走 `packages/protocol/dist` 复现 TS2307 → 执行 build 步骤 → 零错误。
- 精确忽略 4 处测试占位密钥误报(`630ea7d`);typecheck 前改为**全量构建**
(只建 protocol 不够,`3680218`)。
### 新增 —— 控制台安装包接入 Release(`9f95560`)
`scripts/release.mjs` 此前只产出 agent / cli / mcp,`apps/console` 从来没接进去。
后果很具体:控制台修复**只存在于 CI artifact**,而 artifact retention 是 7 天 ——
用户从 Release 页面下载到的永远是旧控制台(「修复发了但没人拿得到」)。
- `console-windows.yml` 加 `workflow_call:` + `publish` job:只有 tag 才发布;
下载两架构产物 → **断言安装包数 ≥ 4**(exe+msi × x64/arm64),少了直接报红,
绝不静默少发一个架构;`gh release upload --clobber` 挂到 Release。
- 新增 `console-release.yml`:只认 `push: tags: ['v*']`。
- **为什么不把 tags 直接写进 console-windows.yml**:它带 `paths` 过滤,
`paths` 与 `tags` 并存时 tag 推送会被路径判定挡掉而**静默不触发** ——
失败形态是「什么都没发生」,最难察觉。拆成「日常验证走分支 + 发布只认 tag」最稳。
---
> 主题:**控制台「去命令行化」** —— 双击应用即用,启停/重启/更新/日志/托盘常驻/开机自启全部在界面里完成。
> 目标原文:「不再打开命令行窗口,运行应用之后,有启动、停止、重启、检查更新等功能,
> 有日志功能,可以后台运行和开机自启等等功能」。
@@ -50,7 +324,7 @@
| # | 措施 | 位置 | 不加会怎样 |
|---|---|---|---|
| 1 | 控制台是 GUI 子系统程序,自身不弹控制台 | Tauri 默认 | 打开应用先弹黑窗 |
| 2 | spawn `node.exe` 时加 `CREATE_NO_WINDOW`(`0x0800_0000`) | `agent.rs` 的 `creation_flags` | **每次启动被控端都闪一个黑窗** —— 正是皇上要求消灭的现象 |
| 2 | spawn `node.exe` 时加 `CREATE_NO_WINDOW`(`0x0800_0000`) | `agent.rs` 的 `creation_flags` | **每次启动被控端都闪一个黑窗** —— 正是用户要求消灭的现象 |
| 3 | 开机自启拉起控制台而非裸 `node` | `autostart.rs` + `--agent-only` | 开机时黑窗一闪而过 |
### 新增 —— 契约守卫(`tests/unit/console-commands.test.mjs`,8 用例)
@@ -113,7 +387,9 @@
故 `taskkill` 收进程树、PowerShell 计划任务、`node.exe` 路径等分支此前从未产出过 Windows 产物。
- **实测产物**:x64 `.msi` **34.2 MiB** / NSIS **23.2 MiB**;arm64 NSIS **19.5 MiB**(均低于 40MB 目标)。
`if-no-files-found: error` + `fail-fast: false`:一条架构没产出就报错,同时一条挂了不掩盖另一条的结果。
- **尚未接入 `scripts/release.mjs`**:安装包目前只作为 CI artifact 产出,未随 Release 发布。
- **接入 Release**:安装包起初只作为 CI artifact 产出(retention 7 天),已由本版
「控制台安装包接入 Release」一节补上 —— tag 发布时四包(exe+msi × x64/arm64)
挂到 GitHub Release,修复才真正到得了用户手里。
> **首个 run 两个 job 全红,但错的不是构建,是断言。**(run `38151366534`)
> 两个 job 的失败点完全一致,而日志显示构建链路**一路全绿**:构建机架构(arm64 runner 上
@@ -206,13 +482,13 @@
> 按上一节自己立的规矩,发版时**必须**升版本号(PATCH,→ 2.0.3),
> 否则 `agent.mjs` 会谎报一个并不包含本修复的版本。
真机(Parallels 27 + Win11 ARM64,**200% 缩放**)暴露的缺陷:
真机(虚拟化平台 + Win11 ARM64,**200% 缩放**)暴露的缺陷:
被控端的 PowerShell 助手进程**默认 DPI 不感知**,于是**同一个进程里并存两套坐标系**:
| 调用 | 不感知时的结果 | 实际危害 |
|---|---|---|
| `Screen.Bounds` / `SystemInformation.VirtualScreen` | **逻辑**尺寸 1800×1010 | `screen.info` 报错尺寸;`screen.capture` 缺省 region 一并错 |
| `Screen.Bounds` / `SystemInformation.VirtualScreen` | **逻辑**尺寸 1280×720 | `screen.info` 报错尺寸;`screen.capture` 缺省 region 一并错 |
| `CopyFromScreen` | 按**物理**像素裁剪 | `screen.capture` 缺省**只截到左上 1/4** |
| `SetCursorPos` | 被系统 **×2** | `input.mouse.*` 喂 100,100 → 光标落到 200,200 |
| UIA(`screen.find`) | **物理**坐标(与进程是否感知无关) | 与上面三项**口径不一致** |
@@ -237,7 +513,7 @@
| 断言 | 结果 |
|---|---|
| `screen.info` | 报 **3600×2020**,与裸 `GetSystemMetrics`(感知后)一致 ✅ |
| `screen.info` | 报 **2560×1440**,与裸 `GetSystemMetrics`(感知后)一致 ✅ |
| `input.mouse.move(x,y)` → 读回光标(物理) | 四次抽样**精确等于** (x,y) ✅ |
| `screen.find` 物理矩形 → `input.mouse.click` **直接点击** | 移动后光标 = 物理中心;主题单选框 `selected` 翻转且**可逆** ✅ |
@@ -340,11 +616,321 @@ e2e 新增的 ACL / 畸形报文用例有个必须记住的姿势:**每个场
> ⚠️ 本批**含被控端变更**(`apps/agent/src/**` 十余个文件),按版本纪律,发版时
> `pnpm release patch`(→ **v2.0.3**)**必须**执行,否则 `agent.mjs` 会谎报一个并不包含这些修复的版本。
### 修复 —— 控制台复用已退出的 sidecar(真机症状:更新等满 45 秒报超时)
**现象**(Windows 11 ARM64 真机):应用启动被控端后,界面点「检查更新」**等满 45 秒**,
报「检查更新失败:**check_update 超时**」。
**排查对照**(决定性的一条):同一台机器、同一份 sidecar、同一个请求 ——
- 界面内发起:**45s 超时**(`check_update` 的 `request` 超时上限);
- 在 VM 上手工拉起 sidecar 跑同一请求:**6.989s 成功返回** `has_update: true, latest 2.0.3`。
现场另证:任务管理器里 `node.exe` **只有被控端一个** —— 控制台的 sidecar 进程**根本不存在**。
逐条排除网络(curl 取 release 资产 **HTTP 200 / 1096096 字节**)、哈希(探针 sha256 一致)、
版本判定(控制台 2.0.2 < 发布 2.0.3)、进程归属(被控端 pid 的父进程正是控制台)之后,
根因锁定在控制台上壳:**`ensure_sidecar` 只判「缓存里有没有实例」,不判「那个实例还活着没」**。
**为什么以前没暴露**:往**已退出进程**的管道里写,在 Windows 上未必立刻报错 ——
管道缓冲区把数据吞下就返回成功。于是请求既没报错、也永远等不到响应,只能一路干等到 `request` 的超时。
**修法**(`apps/console/src-tauri/src/sidecar.rs` + `lib.rs`):
- `Sidecar` 增 `dead: Arc<AtomicBool>`。三处置死:stdout 任务读到 **EOF**、`stdin` 写入/`flush` 失败、
`shutdown()` 主动关闭;**stdout 关闭时顺手唤醒所有在等的请求**,让它们立刻失败而不是各等各的超时。
- 新增 `is_alive()`:`dead` 为真即假;否则 `child.try_wait()` 为 `Ok(None)` 才为真;
**拿不准一律按不可用**(宁可付一次冷启动,也不要让用户每个操作都干等到 timeout)。
- `request()` **入口先判存活**,不可用则立刻返回 —— 这是「秒失败」的那一道。
- `ensure_sidecar` 取到缓存实例后先 `is_alive()`,已退出则**丢弃并重建**。
- 新增 3 条单测:shutdown 后必须判死、子进程被杀后必须判死、
**对已退出的 sidecar 发请求必须秒失败**(故意给 30s 超时,**断言耗时 < 5s** ——
修复前的表现恰恰是「干等到超时」,只断言「有没有报错」会漏掉这个缺陷)。
注入故障(把 `is_alive()` 换成恒 `true`)实测两条守卫如期变红。
- `pnpm test:rust` **14/14**(基线 11 + 新增 3)。
> ⚠️ **本项属控制台上壳变更,不是被控端变更**,因此**不触发** `pnpm release patch`
> (被控端 `agent.mjs` 无需重新发布)。但要**让用户真正拿到修复,必须重新构建并分发控制台安装包**
> (`.msi` / NSIS);直接重启已装的旧版控制台只是**临时绕开**(进程内缓存被清空 → 重新拉起 sidecar),
> 症状会消失,但下次 sidecar 再退出仍会复发。
### 修复 —— 两条守卫在 Windows 上必红(平台差异:CRLF 与路径分隔符)
CI 实证(run `38380306200`,提交 `73ed06f`):`Windows 真机验证 → 单元测试` **319/321**,
macOS 上同样两条却是绿的 —— 典型的「只在 Windows 暴露」。两条各有明确根因:
**① `仓库内全部版本号互一致`:CRLF 把版本号读成 `undefined`**
报错原文:`undefined apps/console/src-tauri/Cargo.lock`(其余 10 处均为 `2.0.3`,`2 !== 1`)。
根因:Windows 上 `actions/checkout` 默认 `core.autocrlf=true`,工作区文本是 **CRLF**,
而 `Cargo.lock` 的版本提取把 `\n` 写死了。更严重的是 —— **同一处隐患也在生产代码里**:
`version-write.mjs` 的写入路径另有一条 `name…\nversion…` 正则读旧值,CRLF 下同样返回 `undefined`,
于是它把 Cargo.lock 当成「没找到该 crate」记入 `skipped` 并**跳过** → 发版后该文件滞留旧版本,
**正是本模块要消灭的那类分叉,且不报错**。
- 收敛为**单一真源**:读旧值改走 `readCargoTomlVersion()` / `setCargoLockVersion().before`,
删掉那条会漂移的重复正则。
- 新增 `splitLineEol()`:写值前先把行尾拆下来、改完正文再拼回去 ——
不能偷懒写 `(".*)$` 兜行尾,因为 **JS 的 `.` 不匹配 `\r`**(与 `\n` 同属行终止符),照样失配。
- 读值一律 `split('\n')` 后对 `line.trim()` 匹配 → LF / CRLF 结果一致,写回**逐字节保真**。
- 新增 3 条守卫:Cargo.lock / Cargo.toml 纯函数级 CRLF 用例 + **CRLF 工作区上真跑一遍
`writeVersionEverywhere` 并读回文件断言**(注入故障后 3 条如期变红)。
**② `W-01 根因守卫:PowerShell 单引号字面量必须来自 util/quote.ts`:把唯一真源自己判成违规**
报错原文:`以下文件手写了 PS 单引号转义:util\quote.ts`。
根因:守卫用 `file.slice(dir.length + 1)` 得相对路径,Windows 上是 `util\quote.ts`,
与白名单里的 `util/quote.ts` 比不中 → **守卫自己错**(铁律 3 的第 ② 类),macOS 上复现不出。
- 新增 `relPosix()` 把相对路径统一归一成 `/`;并补一条**用 `path.win32` 形态字符串在 macOS 上
直接测掉 Windows 分支**的用例 —— 跨平台差异不必真上 Windows 才能验(铁律 4)。
回归:`test:unit` **325/325**(321 + 新增 4),注入故障实测 4 条守卫如期变红、还原逐字节一致。
> 附带发现(不构成缺陷,但值得记档):控制台随包的 `agent.mjs` 与发布版 `agent.mjs`
> **逐行一致、同报 2.0.3**,唯二差异是 esbuild 的模块相对路径串与内嵌 commit 常量
> (随包 `73ed06f` / 发布版 `a1c51d6`)—— 源于两次构建的**基准目录不同**。
> 因此二者 **sha256 必然不同**,不能用发布清单的哈希去校验控制台播种出来的那份。
### 修复 —— 打包后 sidecar 因 Windows verbatim 路径秒退(真机症状:「sidecar 已退出(stdout 关闭),请重试」)
上一节修好「复用已退出的 sidecar」之后,真机复验**症状变了但没解决**:不再干等 45 秒,
改为**立刻**报「检查更新失败:**sidecar 已退出(stdout 关闭),请重试**」——
说明被拉起的 sidecar **在请求进行中真的退出了**,只是这次被立刻发现。本节是它的真根因。
**真机 A/B(决定性的一条)**:Windows 11 ARM64;同一个 `node.exe`、同一份脚本,
用 `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 可直接看到)。
修复方向由此确定:**净化脚本参数是关键**,净化 node 属防御性。
另有一次更贴近原症状的观测(同一台机器、直接用真 `sidecar.mjs`):
普通路径 **2.5 秒**返回 `check_update` 结果、进程常驻且心跳持续;
`\\?\` 路径 **0.47 秒**即退出、exit 1、**零 JS 执行** —— 连 sidecar 自带的启动日志一行都没写。
「零 JS 执行」是实测出来的:sidecar 自己的启动日志一行没写、审计日志在 `agent.start` 之后
一片空白、判死探针文件 0 字节 —— 连第一行 JS 都没跑到。
**根因**:Tauri 的 `resource_dir()` 在 Windows 上返回的是 `std::fs::canonicalize` 之后的
**verbatim 路径**(`\\?\C:\Users\…\nodeagent-console`)。把它当**参数**交给 node 时,
node 启动流程在入口模块的 `realpathSync` 里把 `\\?\C:` 拆成盘符 `C:` 与剩余部分:
```text
Error: EISDIR: illegal operation on a directory, lstat 'C:'
at Object.realpathSync (node:fs:27xx)
```
→ exit 1,**一行 JS 都来不及执行**。两种界面表现都由这一条派生:
修存活检测**之前**是「写进已死进程的管道 → 干等 45 秒超时」,
修**之后**是「stdout 立即 EOF → 立刻报 sidecar 已退出」。
**为什么单测 / CI / 开发模式全都测不出来**:开发期 `resolve_sidecar` 走的是
`CARGO_MANIFEST_DIR` 拼出来的**普通路径**;只有**打包之后**才会从 `resource_dir()`
拿到 verbatim 路径。CI 里 `cargo test` 又只在 `ubuntu-latest` 跑(`.github/workflows/rust.yml`),
Windows 侧只有打包(`console-windows.yml`)。于是「本地全绿 + CI 全绿 + 真机必挂」。
**修法**(`apps/console/src-tauri/src/sidecar.rs` + `agent.rs` + `autostart.rs`):
- 新增 `unverbatim()`:剥 `\\?\UNC\` → `\\`、剥 `\\?\` → 无前缀,其余原样返回;
**纯字符串处理,任何平台都可安全调用** —— 因此可以用 Windows 形态的字符串在
macOS / Linux 上直接守住 Windows 分支(铁律 4:跨平台差异不必真上 Windows 才能验)。
实现用 `to_str()` 而**不是** `to_string_lossy()`:后者会把非法字节替换成 U+FFFD
再重建路径,那是**损坏路径**(把「node 报 EISDIR」换成「指向不存在的路径」,
更难排查)。非 UTF-8 一律原样返回。
- 新增 `sidecar_command()`:**唯一**把路径交给 node 的地方,内部统一净化。
纪律只在这里守一次,单测直接断言它产出的 `program` / `args`(守「最终下发文本」,
不是「源码里出现过某个常量」)。
- 四个来源统一加闸:`resolve_node` / `resolve_sidecar` / `resolve_bundled_agent` 的
**全部出口**(环境变量、resource_dir、系统 PATH / 开发路径),以及进程边界的
`Sidecar::spawn` 与 `AgentManager::start`(对 node / script / data_dir 三条路径都净化)。
- `autostart::app_exe()` 一并净化:该路径会被写进计划任务的 `New-ScheduledTaskAction -Execute`,
与上述同源风险。
**回归**:
- 新增 4 条守卫 —— `unverbatim_去掉各类_windows_前缀`(盘符 / UNC / 普通 / POSIX / **幂等**)、
`unverbatim_对_resource_dir_拼接后的路径同样生效`(前缀在目录上、拼完仍在最前)、
**`交给_node_的路径必须去掉_verbatim_前缀`**(断言最终 `program` 与 `args` 文本)、
`unverbatim_对非_utf8_的_verbatim_路径必须原样返回不得损坏`(`#[cfg(unix)]`,见上)。
- **注入故障实测(两轮)**:
① 把 `sidecar_command` 里的 `unverbatim` 去掉 → 第三条如期变红,
报错正文即 `program 仍带 verbatim 前缀(示例:\\?\C:\…\node.exe)`;
② 把 `unverbatim` 的实现退回 `to_string_lossy()` → 第四条如期变红。
两轮还原后均 **sha256 逐字节一致**,复跑全绿。
- `pnpm test:rust` **18/18**(14 → +4)。
> ⚠️ **本项属控制台上壳变更,不是被控端变更**,因此**不触发** `pnpm release patch`。
> 但要让用户真正拿到修复,**必须重新构建并分发控制台安装包**。
**真机复验(Windows 11 ARM64 VM,装 CI 产出的 arm64 NSIS 包)**:
| 项 | 修复前(2.0.3) | 修复后 |
|---|---|---|
| sidecar 进程命令行 | `"\\?\C:\…\node.exe" C:\…\sidecar.mjs`(**program 带 verbatim**) | `"C:\…\node.exe" C:\…\sidecar.mjs`(两侧均干净) |
| 点「检查更新」后 | sidecar 0.5 秒即退,界面报「sidecar 已退出(stdout 关闭)」 | sidecar 常驻(pid 恰在点击那一刻出现),界面显示**最新版本 v2.0.3 / 已是最新 / 发布时间 2026-10-13 17:36:54** |
| 安装包 | `e7636120…` | `27e41ef8…`(exe)/ `b2b78546…`(随包 sidecar) |
结论:**缺陷已消除,完整链路真机跑通**。复验同时暴露了一个既有缺陷,见下节。
### 修复 —— sidecar 拉起时弹出**常驻黑窗**(上项修复后暴露的既有缺陷)
**现象**:真机复验时「检查更新」能返回结果了,但屏幕上多出一个**不消失的黑窗** ——
由 `WindowsTerminal.exe` 承载,标题就是 `C:\Users\…\nodeagent-console\node.exe`。
**为什么以前没人发现**:修复 verbatim 之前 sidecar 0.5 秒即退,这个黑窗**一闪而过**;
sidecar 常驻之后它才留在屏幕上。所以它是**被上一项修复暴露出来的既有缺陷**,不是本次引入。
**根因**:控制台自身是 **GUI 子系统**程序(不占控制台)。GUI 父进程 spawn 一个
**控制台子系统**程序(`node.exe`)时,Windows 会**新建一个控制台** —— 必须靠
`CREATE_NO_WINDOW` 抑制。`AgentManager::start` 早已加了这个标志(所以被控端从不弹窗),
**`sidecar_command` 漏了**。换句话说:CHANGELOG 里「消灭命令行窗口」那节写的三条措施,
sidecar 这条路径一直没被覆盖。
**修法**:`sidecar_command` 补 `creation_flags(CREATE_NO_WINDOW)`;
常量提为 `sidecar::CREATE_NO_WINDOW`,由 sidecar 与 `AgentManager::start` **共用**,避免值漂移。
> ⚠️ **本项没有自动化守卫**,如实记档原因:`creation_flags` 只有 setter、**没有 getter**
> (`std::process::Command` 与 `tokio::process::Command` 都如此),断言不到「到底带没带」;
> 而「扫源码看有没有写过这个常量」属源码级检查,违反铁律 1(守卫要落在最终下发值上)。
> 故本条靠**真机截图对照**把关(修复前有黑窗 / 修复后无黑窗),并确保 Windows CI 能真编译到这段代码。
**真机复验**(同一 VM,装 CI 产出的下一版 arm64 包,exe `5800b972…`):
- 点「检查更新」→ sidecar 在**点击那一刻**被拉起(pid 4056 @ 20:52:58),
**命令行两侧均无 `\\?\`**(`verbatim=False`);再次点击时复用存活实例、不重建,符合设计。
- **黑窗消失**:`Get-Process | Where MainWindowTitle -like '*nodeagent-console*'` 返回空。
修复前该查询命中一个 **`WindowsTerminal`** 窗口,标题正是 `…\nodeagent-console\node.exe`。
- 界面未再出现「sidecar 已退出」。
> 附带(**与本次修复无关的环境问题**):该次点击时 VM 恰好连不上 GitHub ——
> 界面报「检查更新失败:**fetch failed**」,同机直接 `Invoke-WebRequest` 该 URL
> **21.5 秒后「无法连接到远程服务器」**。更早一次(网络通时)同一按钮已成功返回
> 「最新版本 v2.0.3 / 已是最新 / 发布时间 2026-10-13 17:36:54」,故功能本身已验证。
### 修复 —— 「检查更新」在境内**必然**失败(真机症状:`检查更新失败:fetch failed`)
> **订正上文**:上一项末尾把 `fetch failed` 归为「与本次修复无关的环境问题」。**那是误判** ——
> 环境确实不通,但**产品缺陷让它无解**:即使把代理配好,这条链路也永远连不上。本项即修复。
**现象**:控制台点「检查更新」,境内(无透明代理的机器)必报 `fetch failed`。
**真机取证**(Win11 ARM64 VM;同日多组对照):
| 观测 | 结果 |
|---|---|
| VM 上 DNS 解析 `github.com` | 正常(`20.205.243.166`) |
| VM 上 TCP 443 直连 | 握手成功 |
| VM 上 `fetch` 取该 URL | **`fetch failed`** |
| VM 经代理取同一 URL | **HTTP 302**(代理本身是好的) |
| Mac 上给 `fetch` 设 `HTTPS_PROXY` 后重试 | **仍 `fetch failed`**(`env -i` 隔离后仍有区分力) |
**三条根因**(缺一条都修不好):
1. **Node 内建 `fetch`(undici)默认不读任何代理环境变量**。`NODE_USE_ENV_PROXY=1` 可开
(Node 22.x 实验性、24 正式),但它**只认环境变量**。
2. **系统代理读不到**:Windows 用户用第三方代理客户端时配的是 **WinINET 系统代理**,
macOS 用户配的是**系统设置里的代理**——Node 两者都不读。所以「用户明明配了代理」也白搭。
而 `fetch(url,{dispatcher})` 也走不通:`dispatcher` 必须是 undici 的 `Dispatcher` 实例,
而 Node 没把 undici 暴露成可导入模块(整包 vendored 会把 sidecar 撑胖,且它依赖 `.wasm`,
esbuild 单文件打包有风险)。
3. **超时过短**:原 `check_update` 内部 20s。而该 URL 要**跟随两次重定向**到资产 CDN,
真机实测整链耗时 **2.0s / 10.0s / 39.7s / 68.6s / >109s**(同机同代理)——
20s 属「假定能直连」时代的数值,对真实链路**必然**超时。
**修法** —— 新增 `apps/console/sidecar/proxy.mjs`(自持出网,零新依赖):
- **不用 `fetch`,改用 `node:http`/`node:https`**:https 目标经代理走 `CONNECT` 隧道再叠 TLS
(隧道 socket 交 `http.request`,**刻意不用 `https.request`** —— 那样会二次握手);
http 目标经代理发**绝对形态**请求行(`GET http://… HTTP/1.1`)。
- **代理来源**:`NA_PROXY` → 目标协议对应的环境变量(`HTTPS_PROXY`/`ALL_PROXY`…)→
**系统代理**(Windows 读注册表 WinINET;macOS 读 `scutil --proxy`)。`NO_PROXY` 命中则直连。
- **只支持 HTTP 代理**;遇到 `socks5://`、或系统代理是**PAC 脚本**(本机 AdGuard 正是这种)
**明确记一条 note 并把提示带给用户** —— 静默降级正是本次缺陷最恶劣的地方。
- **超时成对下发**:`UPDATE_CHECK_TIMEOUT_MS` 120s(内层)/ `UPDATE_CHECK_RPC_TIMEOUT_SECS` 150s(外层),
由 Rust 通过 `timeout_ms` 参数传给 sidecar。**内层必须小于外层**,这条关系写成**编译期不变式**
(`const _: () => assert!(…)`)—— 破坏它只会在用户侧表现为「check_update 超时」这种**丢掉真实原因**的假失败。
- **失败信息可操作**:带上是直连还是经哪个代理、耗时多少、以及「为什么不走代理」(PAC / socks)。
`check_update` 成功时也回报 `via_proxy` / `via`,界面上新增一行「出网」——
这正是排查这类问题最需要看到的一条。
- 顺带修掉界面上的重复前缀(`检查更新失败:检查更新失败:…`)。
**同一类缺陷的第三处(**本次未改**,如实记录)**:
`apps/agent/src/capabilities/selfupdate.ts`(被控端拉取式自更新)仍是裸 `fetch`。
该文件顶部有明确设计立场「改用镜像地址,而不是在 agent 里塞代理隧道」,
且**属被控端变更**(改动即需升 PATCH、走发版流程),故不在本次控制台修复范围内。
**期间一并修掉的第二个缺陷**(复验时才暴露):`tunnelConnection` 里
**隧道与 TLS 握手阶段没有接 abort 信号** —— 那个阶段外层 `http.request` 还没建立,
`signal`/`timeout` 都无人受理。真机形态:配置 75s 超时,**实际挂了 109s** 才失败。
**第三个缺陷**(同一轮真机暴露):**`ECONNRESET` 被误归入 abort 类**,于是连接被中途重置时
报的是「请求超时」——真机形态是耗时 **82.9s**(预算 120s)却报「请求超时(120000ms 内未完成)」,
把「对端把连接掐断了」这条最有价值的信息抹掉。改为**只认真正的 abort**(`AbortError`/`ABORT_ERR`),
其余网络错误单列翻译(`ENOTFOUND`/`EAI_AGAIN`/`ECONNREFUSED`/`ECONNRESET`/`EPIPE`)。
**守卫**(`tests/unit/sidecar-proxy.test.mjs`,16 用例)+ 注入验证:
| # | 守什么 | 注入故障实测 |
|---|---|---|
| 1–8 | 纯函数:URL 归一化、`NO_PROXY`(含「条目比目标窄时不得反向豁免父域」)、来源优先级、`scutil`/`reg query` 解析(夹具取自真机,含 PAC 模式与「值不存在时 reg 走 stderr」)、探测失败一律视为无代理、决议优先级与 note 累积 | 去掉后缀匹配 → 用例 2 红 |
| 9–12 | 真发请求(**离线本地服务器**):直连跟随 302、经代理走绝对形态、**代理不通必须报错**(防静默降级)、CONNECT 可用 / 被拒报错、**隧道+TLS 阶段 abort 必须立即生效**(带看门狗,故障时失败而非挂死) | 去掉隧道阶段 abort 挂载 → 用例 12 红(`实际 4002ms`) |
| 13 | **端到端**:真 spawn sidecar 进程走 JSON-RPC 跑 `check_update`,断言返回正确且 **`via_proxy` 正确**、代理**真收到了**请求;反面对照(代理指死端口)必须失败 | 把 `check_update` 退回裸 `fetch` → 用例 13 红(`代理必须真收到这次请求`)+ 用例 14 红 |
| 14 | 形态守卫:sidecar 源码不得再出现裸 `fetch(`(防止修复被无声改回去) | 同上 |
| 15 | 错误映射:连接被**中途重置**时不得报成「请求超时」(真机踩过) | 把 `ECONNRESET` 塞回 abort 类 → 用例 15 红 |
**真机复验**(重打 sidecar 产物 249KB,用**打包后的 `dist/sidecar.mjs`** 直跑):
| 组 | 平台 / 配置 | 结果 |
|---|---|---|
| C | macOS / 无代理 | **成功 2169ms** → 最新版本 `v2.0.3`、已是最新;日志正确报出「系统代理使用 PAC 自动配置脚本…已按无代理处理」(`scutil` 探测真实生效) |
| A | macOS / `NA_PROXY=http://127.0.0.1:1`(死端口) | **51ms 明确失败**:`connect ECONNREFUSED 127.0.0.1:1(经代理 http://127.0.0.1:1/,耗时 5ms)`;`data` 里带全 `via`/`proxy`/`proxy_source`/`timeout_ms`/`hint` |
| B | macOS / `NA_PROXY=http://127.0.0.1:<代理端口>` | 代理路径被正确选中(日志 `经代理 …(来源 NA_PROXY)`);该次宿主代理线路拥塞未在预算内完成 —— **线路本身的问题**,错误信息如实说明「代理已生效但仍失败」 |
| D | **Win11 ARM64 VM** / 未配系统代理、未给任何环境变量(直连) | **30877ms 失败**,但报的是真实原因:`socket hang up(连接被对端中途断开…)`,`data` 明确给出 `via=直连`、`proxy=null`、`timeout_ms=120000` 与一句可操作提示「请在系统里开启『系统代理』,或设置 `HTTPS_PROXY=…` 后重启控制台」 |
| E | **Win11 ARM64 VM** / 只设 **WinINET 系统代理**指向转发端点(**不给任何环境变量**) | **成功 28883ms** → `v2.0.3`、`has_update=false`;日志 `检查更新:经代理 http://…(来源 system)`、结果 `via_proxy=system`,**退出码 0** |
> 组 D/E 用的是**随包 `node.exe`**(`%LOCALAPPDATA%\nodeagent-console\node.exe`)直跑
> `dist/sidecar.mjs`,两侧 sha256 一致(`07dd592b…`);E 组**没有任何环境变量参与**,
> 走通的正是本次新增的**系统代理发现**(`reg query` → `来源 system`)。
> E 组耗时 28.9s 同时反证根因 3:旧实现固定 20s 的内部超时对这条链路**必然**失败。
> 复验后 VM 的 `ProxyEnable`/`ProxyServer` 已**原样还原**(复原为 `0x0` / 值不存在)。
> **未覆盖(如实记档)**:**https 目标经 CONNECT 隧道的 TLS 叠层**没有离线自动化用例 ——
> 需要一个受信任证书的本地 TLS 源站,而本仓库没有证书夹具(`selfsigned` 只装在被控端包内,
> 测试进程解析不到,为它新增夹具又会污染 `~/.nodeagent/certs/`)。
> 在线路径已由「组 C 直连 + 原型实测经代理取 GitHub API 得 200」覆盖,但它**不构成回归守卫**。
---
### 安全 —— 公开仓库隐私脱敏(文档 / 注释 / 测试夹具)
按 `security-reports/` 的隐私审查台账收口,逐项清除**可反向定位到具体个人与设备**的字面量。
**纯脱敏与表述泛化,不含任何业务逻辑变更。**
- **去掉可执行的真机默认值** —— `scripts/verify-b2.mjs` 的 `NA_URL` 原先带缺省值且指向一台真实机器,
现改为**必填**(缺失即报错退出)。这是全仓库唯一「拿着就能连到某台真机」的常量,优先级最高。
- **主体标识泛化** —— 测试账户名、虚拟机与宿主的真实网段、测试用绝对路径,
分别改为 `na-test`、RFC 5737 文档保留段地址(`192.0.2.0/24`)、`C:\na-test`。
- **设备指纹泛化** —— 虚拟化产品及其 CLI / 访客工具名、NAS 设备型号与系统名、真机 CPU 型号、
真机物理分辨率、代理软件品牌与监听端口、杀软品牌 → 一律中性表述。
**只换称呼,结论与排查步骤一字未动**,避免把实测经验一起丢掉。
- **去除跨项目与私人称呼线索** —— 文档中指向作者其他项目的名称、以及私人称谓 → 中性表述。
- **测试夹具** —— `acl-v11` / `net-scripts` 的示例 IP 去掉与作者真实环境一致的尾八位;
**fixture 与断言同步更新**(只改 fixture 不改断言会让守卫变红 —— 本轮就这么红过一次)。
- **`.gitignore` 补 `config.json` / `agent.json`** —— 二者含真实密钥与节点地址,
此前只靠「没人会提交」兜着。
> 验证:`pnpm test:unit` **340/340 通过**;另用独立扫描器对 **235 个跟踪文件**做 **17 类模式**全量扫描,命中 **0**。
> 说明:**改写历史不等于收回** —— 旧内容只要被匿名抓取过就已外流,本节只做「止血 + 不再恶化」。
---
## [v2.0.2] —— 2026-10-11
> 一轮真机验证(Parallels 27 + Win11 ARM)抓出的**发布事故**修复批次。
> 一轮真机验证(虚拟化平台 + Win11 ARM)抓出的**发布事故**修复批次。
> 已公开发布的 `nodeagent-win-arm64.zip` 内装的其实是 **x64 的 `node.exe`** ——
> 该包在 ARM Windows 上能启动、能 listen,却 **TLS 握手不完成**(控制端只见 `E_TIMEOUT`),
> 实际不可用。本版重出资产,并把「交付物架构」直接钉进发版脚本与 CI。
@@ -405,7 +991,7 @@ e2e 新增的 ACL / 畸形报文用例有个必须记住的姿势:**每个场
## [v2.0.1] —— 2026-10-11
> 一轮**真机验证**(Parallels 27 + Win11 ARM)带来的修复批次:在补齐 6 项待验证项的过程中,
> 一轮**真机验证**(虚拟化平台 + Win11 ARM)带来的修复批次:在补齐 6 项待验证项的过程中,
> **抓到并修掉 5 个真问题** —— 全部只有真机能发现(macOS 上 100% 测不出)。
### 修复
@@ -428,7 +1014,7 @@ e2e 新增的 ACL / 畸形报文用例有个必须记住的姿势:**每个场
(**只增不减**,旧控制端忽略即可)。
- **`scripts/verify-b2.mjs`** —— v23 连接层三防护的真机验证脚本(`idle` / `maxconn` / `ban` 三模式),
全部走真实连接而非 mock。
- **`docs/VM-VERIFICATION.md`** —— 无 Windows 真机时用 Parallels Win11 ARM 补验证的完整清单
- **`docs/VM-VERIFICATION.md`** —— 无 Windows 真机时用虚拟化平台上的 Win11 ARM 补验证的完整清单
(含实测结果、能补/不能补矩阵、8 条排查项)。
### 真机验证状态(本版实测)
@@ -453,7 +1039,7 @@ e2e 新增的 ACL / 畸形报文用例有个必须记住的姿势:**每个场
> **验证状态**:本版发布时被控端整机离线,`v1.5.0` 的预设别名/长按/后台投递/热键序列
> 与 `v2.0.0` 的自重启任务清理策略**当时未经真机验证**(后者已在本地单测锁定判定条件)。
>
> ✅ **2026-10-11 已补齐**:在 Parallels 27 + Win11 ARM 虚拟机上完成真机验证 ——
> ✅ **2026-10-11 已补齐**:在虚拟化平台 + Win11 ARM 虚拟机上完成真机验证 ——
> A 组 15 通过/0 失败、v1.5 四项全过、v23 三防护三场景全过、v26 清理策略**验证并改进**
> (阈值由「1 小时」改为「本 agent 启动时刻」)、锚点端到端跑通(含云盘链外留存)。
> 并**顺带发现并修复 5 个真问题**(含 `control.ps1` 解析期错误、连接保活缺 pong 检测等)。
+23 -6
View File
@@ -168,7 +168,7 @@ nodeagent --node win audit anchor --note "每日例行" # 追加一个锚点
> 真正发挥价值的前提:把锚点文件指向**另一台机器 / 网盘同步目录**。
> 本机文件被整盘重写时,锚点也随之消失 —— 那就等于没锚。
**51 项能力** · **41 个 MCP 工具** · **269 项单元测试** · **30 项端到端用例** · **3 项 Rust 壳单测**(真跑 Node sidecar)(CI 在真实 Windows / Linux runner 上验证)
**52 项能力** · **42 个 MCP 工具** · **483 项单元测试** · **50 项端到端用例**(34 通用 + 16 Windows 专属) · **23 项 Rust 壳单测**(真跑 Node sidecar)(CI 在真实 Windows / Linux runner 上验证)
> 关键里程碑:**GUI 语义**(`window.list` + `screen.find`,UIA 找不到自动降级 OCR)
> 让 AI 从「看得到画面但读不懂界面」变成「按名字取坐标点下去」。
@@ -195,7 +195,7 @@ pnpm test:rust # Rust 控制端客户端(需 cargo)
1. 在控制端构建安装包(**需在项目目录执行**):
```bash
cd <项目目录> # 例如 ~/WorkBuddy/NodeAgent/nodeagent
cd <项目目录> # 例如 ~/projects/nodeagent
pnpm pack:win
# 或从任意目录调用:
@@ -384,8 +384,6 @@ Windows 版由 CI 产出(`.github/workflows/console-windows.yml`),**双架
> 前两条查的是打包**前**的中间产物,这一条查的才是**最终交付物**;
> 实测解出 x64 `node.exe` 82.9 MB / `0x8664`、arm64 73.5 MB / `0xaa64`)。
> 控制台安装包目前**只作为 CI artifact 产出**,尚未接入 `scripts/release.mjs` 随 Release 发布。
⚠️ 平台差异:`app.*` 与 `system.service.list` **仅在被控端为 Windows 时可用**,
非 Windows 被控端会返回 `E_UNSUPPORTED_PLATFORM`。控制台连接后会探测
`system.info.os` 并**事前禁用**这些入口,而不是发出注定失败的请求。
@@ -482,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
@@ -537,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 / 拦截率)+ 达标判定 |
@@ -678,7 +694,8 @@ nodeagent/
- [`docs/SECURITY.md`](docs/SECURITY.md) —— **安全模型与加固指南**(钥匙=root、证书指纹钉住、网段白名单、应急处置)
- [`docs/VERSIONING.md`](docs/VERSIONING.md) —— **版本与兼容性契约**(升级顺序、什么算破坏性、发版流程、更新方式)
- [`docs/REMOTE-LIMITS.md`](docs/REMOTE-LIMITS.md) —— 远程能力边界与真实故障案例(含杀软误拦)
- [`docs/VM-VERIFICATION.md`](docs/VM-VERIFICATION.md) —— **无 Windows 真机时,用 Parallels 的 Win11 ARM 虚拟机补验证**(含逐条命令与结论标注规则)
- [`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 基线:待办收尾 / 语义可断言 / 状态采样 / 安全残余)
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/agent",
"version": "2.0.3",
"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": {
+39 -4
View File
@@ -25,6 +25,12 @@ export type AuditType =
| 'net.deny'
/** v19:agent 自更新(拉取式) */
| 'agent.update'
/**
* v2.1.0:**L2 系统级审计事件**(Windows Security 日志归一化副本)。
* 真源注释见 `packages/protocol/src/audit-types.ts`——不复用 `agent.start`:
* L2 策略操作与进程生命周期无关,混入会污染两边的统计与筛选。
*/
| 'agent.l2'
/** v2.0.0:连接空闲被主动断开 */
| 'session.idle_close'
/**
@@ -72,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;
@@ -168,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)) };
}
/** 写入一条审计记录。任何异常都被吞掉 —— 审计绝不阻断主流程。 */
+5
View File
@@ -2,6 +2,7 @@ import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { IS_WINDOWS, execCommand } from '../util/exec.js';
import { runPowerShellSmart } from '../util/ps-helper.js';
import { shLit } from '../util/quote.js';
import { assertGuiPlatform } from '../util/platform.js';
type Args = Record<string, unknown>;
@@ -163,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 };
@@ -178,6 +181,8 @@ export async function audioGet(_args: Args): Promise<unknown> {
// ---------------- system.audio.set ----------------
export async function audioSet(args: Args): Promise<unknown> {
// 该能力仅在 Windows/macOS 有实现(Linux 端未实现)
assertGuiPlatform('audio.set');
const muteArg = args['mute'];
const volumeArg = args['volume'];
const hasMute = typeof muteArg === 'boolean';
+364
View File
@@ -0,0 +1,364 @@
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { join } from 'node:path';
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { execCommand } from '../util/exec.js';
import { assertWindows } from '../util/platform.js';
import { agentDir } from '../config.js';
import { audit } from '../audit.js';
/**
* L2 系统级审计兜底(2026-10-17)。
*
* ## 它补的是 L1 结构上记不到的那块
* L1 是「agent 自己记的」:只看得见经过自己 RPC 的调用。
* 攻击者绕过 agent 直接跑系统命令、或干脆把 agent 停掉 —— L1 全都看不到。
* L2 采集**操作系统**的审计事件,补的正是这块。
*
* ## ⚠️ 信任边界(不要夸大它的作用)
* 采集器与被审计对象**同进程同权限** —— 攻击者能停 agent 就能停采集器。
* 故本地 `audit-l2.log` 只是一份**归一化副本**,防篡改价值有限。
* 真正的独立性来自**把记录转发出机**(与 SECURITY.md §4.8 的锚定同一思路)。
* 本模块不实现自动转发,只提供导出接口。
*
* ## 为什么 Windows 侧选内置 auditpol 而不是 Sysmon
* - Sysmon 是第三方驱动:需单独分发、需重启生效、版本行为有差异;
* - 内置高级审计策略是 Windows 自带的,零依赖即可立起来;
* - 策略:检测到装了 Sysmon 就并用,没装也不尝试下载(不在本仓库职责内)。
*
* ## 平台现状
* - Windows:**已落地**(status / enable / collect / verify)
* - Linux:仅设计(auditd,需预装)—— 见 docs/AUDIT-L2.md
* - macOS:仅设计,**ESF 需 Apple entitlement,个人自签无法启用** —— 不写假装能用的代码
*/
type Args = Record<string, unknown>;
/** 归一化后的事件类别(跨平台枚举,详见 docs/AUDIT-L2.md §4)。 */
export type L2Kind =
| 'process.create'
| 'process.exit'
| 'auth.success'
| 'auth.failure'
| 'file.access';
export interface L2Entry {
/** 事件发生时间(来自 OS,不是采集时刻) */
ts: number;
src: 'windows-security' | 'sysmon' | 'linux-auditd' | 'macos-esf';
/** OS 原始事件 ID —— 保留原始值以便人工核对 */
event_id: number | string;
kind: L2Kind;
subject?: string;
object?: string;
detail?: Record<string, unknown>;
/** 采集时刻。与 ts 相差过大说明期间离线或 agent 未运行 */
collected_at: number;
prev?: string;
hash?: string;
}
const GENESIS = 'genesis';
/** L2 独立落盘,不与 L1 混 —— 混了就无法区分「agent 记的」与「OS 记的」。 */
export function l2FilePath(): string {
return join(agentDir(), 'audit-l2.log');
}
function hashEntry(record: Record<string, unknown>, prev: string): string {
const keys = Object.keys(record).sort();
const body = JSON.stringify(record, keys);
return createHash('sha256').update(`${prev}|${body}`).digest('hex').slice(0, 32);
}
function lastHashOf(): string {
try {
const p = l2FilePath();
if (!existsSync(p)) return GENESIS;
const lines = readFileSync(p, 'utf8').split('\n').filter((l) => l.trim());
const last = lines[lines.length - 1];
if (!last) return GENESIS;
return (JSON.parse(last) as L2Entry).hash ?? GENESIS;
} catch {
return GENESIS;
}
}
/** 追加一批 L2 记录(带独立哈希链)。 */
function appendL2(entries: Omit<L2Entry, 'prev' | 'hash' | 'collected_at'>[]): number {
const p = l2FilePath();
mkdirSync(agentDir(), { recursive: true });
let prev = lastHashOf();
const now = Date.now();
let n = 0;
for (const e of entries) {
const full = { ...e, collected_at: now, prev } as unknown as Record<string, unknown>;
const hash = hashEntry(full, prev);
(full as Record<string, unknown>)['hash'] = hash;
appendFileSync(p, `${JSON.stringify(full)}\n`, 'utf8');
prev = hash;
n += 1;
}
return n;
}
// ---------------- Windows:Security 事件 → 归一化 ----------------
/** Windows 事件 ID → 归一化类别。 */
const WIN_EVENT_KIND: Record<number, L2Kind> = {
4688: 'process.create',
4689: 'process.exit',
4624: 'auth.success',
4625: 'auth.failure',
4663: 'file.access',
};
const WIN_WATCHED_IDS = Object.keys(WIN_EVENT_KIND).map(Number);
/**
* 采集 Windows Security 日志。
*
* 只用 PowerShell 内置 cmdlet(Get-WinEvent),不引外部依赖。
* @param sinceMs 起始时间(Unix ms);默认 24 小时内
* @param limit 上限,防止一次拉爆(Security 日志可能极多)
*/
async function collectWindows(sinceMs: number, limit: number): Promise<Omit<L2Entry, 'prev' | 'hash' | 'collected_at'>[]> {
// 用 ISO8601 传时间,避免区域设置导致的日期解析差异
const sinceIso = new Date(sinceMs).toISOString();
const idList = WIN_WATCHED_IDS.join(',');
const ps = [
`$ErrorActionPreference = 'Stop'`,
`$ids = @(${idList})`,
`$since = (Get-Date '${sinceIso}').ToUniversalTime()`,
`$evs = Get-WinEvent -FilterHashtable @{LogName='Security'; Id=$ids; StartTime=$since} -MaxEvents ${limit} -ErrorAction SilentlyContinue`,
`if ($null -eq $evs) { Write-Output '[]'; exit 0 }`,
`$out = foreach ($e in $evs) {`,
` $x = @{ id = $e.Id; t = $e.TimeCreated.ToUniversalTime().ToString('o'); msg = $e.Message }`,
` try { $x.sub = $e.Properties[0].Value.ToString() } catch { $x.sub = '' }`,
` $x | ConvertTo-Json -Compress -Depth 3`,
`}`,
`Write-Output ($out -join "\`n")`,
].join('\n');
const r = await execCommand({ command: ps, timeoutMs: 60_000 });
if (r.exit_code !== 0) {
// 常见:权限不足(非管理员读不了 Security 日志)
throw new CapabilityError(
ErrorCodes.EXECUTION_FAILED,
'读取 Windows Security 日志失败(常见原因:未以管理员运行,或审计策略未启用)',
{ detail: (r.stderr || r.stdout).slice(0, 300) },
);
}
const text = r.stdout.trim();
if (!text || text === '[]') return [];
const out: Omit<L2Entry, 'prev' | 'hash' | 'collected_at'>[] = [];
for (const line of text.split('\n')) {
if (!line.trim()) continue;
try {
const o = JSON.parse(line) as { id?: number; t?: string; msg?: string; sub?: string };
const id = Number(o.id);
const kind = WIN_EVENT_KIND[id];
if (!kind) continue;
const ts = o.t ? Date.parse(o.t) : Date.now();
// 从 Message 里抠进程名(4688 的「新进程名称」),拿不到就留空 —— 不猜
// 括号不可省:TS 不允许 ?? 与 || 无括号混用
const obj =
(/新进程名称:\s*(.+)/.exec(o.msg ?? '')?.[1]?.trim() ??
/进程名称:\s*(.+)/.exec(o.msg ?? '')?.[1]?.trim()) ||
undefined;
out.push({
ts: Number.isFinite(ts) ? ts : Date.now(),
src: 'windows-security',
event_id: id,
kind,
subject: o.sub || undefined,
object: obj,
detail: { raw_message_head: (o.msg ?? '').slice(0, 300) },
});
} catch {
/* 跳过解析失败的单条,不影响整体 */
}
}
return out;
}
// ---------------- 对外能力 ----------------
export async function auditL2(args: Args): Promise<unknown> {
const action = (args['action'] as string | undefined) ?? 'status';
// 平台门控走统一 assertWindows(与 app.* / system.service.list 同一形状):
// ① 错误信息格式统一(能力名 + 未实现平台 + 受支持平台);
// ② 让 console-contract 的平台门控守卫扫得到本能力(裸 throw 会漏检 → 控制台
// WINDOWS_ONLY_CAPS 少列一项 → 非 Windows 被控端上按钮照常显示,点了才报错)。
// 平台现状与 Linux/macOS 未实现的原因见文件头注释与 docs/AUDIT-L2.md。
assertWindows('system.audit.l2');
if (action === 'status') return l2Status();
if (action === 'enable') return await l2Enable();
if (action === 'collect') {
const since = typeof args['since'] === 'number' ? args['since'] : Date.now() - 24 * 3600 * 1000;
const limit = Math.max(1, Math.min(5000, (args['limit'] as number | undefined) ?? 1000));
const entries = await collectWindows(since, limit);
const written = appendL2(entries);
audit({ type: 'agent.l2', status: 'ok', reason: `l2.collect written=${written} since=${new Date(since).toISOString()}` });
return {
collected: entries.length,
written,
since,
limit,
file: l2FilePath(),
note:
'本地 audit-l2.log 只是归一化副本;真正防篡改依赖把记录转发出机(见 docs/AUDIT-L2.md §2)',
};
}
if (action === 'verify') return verifyL2();
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `action 只支持 status|enable|collect|verify,收到 ${action}`);
}
/** 检查 Windows 审计策略是否已启用(并提示命令行记录开关)。 */
async function l2Status(): Promise<unknown> {
const ps = [
`$ErrorActionPreference='Stop'`,
`$o = auditpol /get /category:* /r`,
// /r 输出 CSV:Machine Name,Policy Target,Subcategory,Subcategory GUID,Inclusion Setting,Exclusion Setting
`$rows = $o | ConvertFrom-Csv`,
`$want = @('Process Creation','Logon','Process Termination')`,
`$got = @()`,
`foreach ($w in $want) {`,
` $m = $rows | Where-Object { $_.Subcategory -eq $w } | Select-Object -First 1`,
` if ($m) { $got += ($w + '=' + $m.'Inclusion Setting') } else { $got += ($w + '=not found') }`,
`}`,
`Write-Output ($got -join '; ')`,
`$legacy = (Get-ItemProperty 'HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Lsa' -Name SCENoApplyLegacyAuditPolicy -ErrorAction SilentlyContinue).SCENoApplyLegacyAuditPolicy`,
`Write-Output ("SCENoApplyLegacyAuditPolicy=" + $(if ($null -eq $legacy) { 'not set' } else { $legacy }))`,
`$cmdline = (Get-ItemProperty 'HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Policies\\System\\Audit' -Name ProcessCreationIncludeCmdLine_Enabled -ErrorAction SilentlyContinue).ProcessCreationIncludeCmdLine_Enabled`,
`Write-Output ("ProcessCreationIncludeCmdLine_Enabled=" + $(if ($null -eq $cmdline) { 'not set' } else { $cmdline }))`,
].join('\n');
const r = await execCommand({ command: ps, timeoutMs: 60_000 });
const out = r.stdout || '';
const sysmon = await detectSysmon();
return {
platform: 'windows',
supported: true,
auditpol: out.trim(),
// 不设它,组策略的旧式类别策略会盖掉 subcategory 设置 ——
// 表现为「auditpol 显示已启用,但事件日志里没有 4688」
legacy_policy_disabled: /SCENoApplyLegacyAuditPolicy=1/.test(out),
cmdline_logging: /ProcessCreationIncludeCmdLine_Enabled=1/.test(out),
sysmon_installed: sysmon,
file: l2FilePath(),
file_exists: existsSync(l2FilePath()),
note: r.exit_code !== 0 ? 'auditpol 查询未成功返回(可能需要管理员权限)' : undefined,
};
}
/** 启用 Windows 高级审计策略(需管理员)。 */
async function l2Enable(): Promise<unknown> {
const ps = [
`$ErrorActionPreference='Stop'`,
// ① 让「高级审计策略」生效,覆盖旧式类别策略
`New-ItemProperty -Path 'HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Lsa' -Name SCENoApplyLegacyAuditPolicy -Value 1 -PropertyType DWORD -Force | Out-Null`,
// ② 命令行记录:不开的话 4688 只有进程名,看不到参数
`New-Item -Path 'HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Policies\\System\\Audit' -Force | Out-Null`,
`New-ItemProperty -Path 'HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Policies\\System\\Audit' -Name ProcessCreationIncludeCmdLine_Enabled -Value 1 -PropertyType DWORD -Force | Out-Null`,
// ③ 开启关注的 subcategory
`auditpol /set /subcategory:"进程创建" /success:enable /failure:enable`,
`auditpol /set /subcategory:"登录" /success:enable /failure:enable`,
`auditpol /set /subcategory:"进程终止" /success:enable`,
`Write-Output 'enabled'`,
].join('\n');
const r = await execCommand({ command: ps, timeoutMs: 90_000 });
if (r.exit_code !== 0) {
throw new CapabilityError(
ErrorCodes.EXECUTION_FAILED,
'启用审计策略失败(需要管理员权限)。未提权时请先用管理员身份运行。',
{ detail: (r.stderr || r.stdout).slice(0, 300) },
);
}
audit({ type: 'agent.l2', status: 'ok', reason: 'l2.enable windows audit policy' });
return {
enabled: true,
platform: 'windows',
policies: ['进程创建 4688', '登录 4624/4625', '进程终止 4689'],
cmdline_logging: true,
hint: '策略生效后需要一段时间才会产生事件;用 action=collect 拉取',
};
}
/** 检测 Sysmon 是否安装(装了就并用其日志,没装不尝试下载)。 */
async function detectSysmon(): Promise<boolean> {
const r = await execCommand({
command: `$ErrorActionPreference='SilentlyContinue'; (Get-Service Sysmon -ErrorAction SilentlyContinue).Name`,
timeoutMs: 20_000,
});
return r.stdout.trim() === 'Sysmon';
}
// ---------------- L2 链校验 ----------------
export interface L2VerifyResult {
ok: boolean;
checked: number;
broken_at?: { line: number; reason: string };
}
/** 校验 L2 链完整性(与 L1 同构算法,独立文件独立链)。 */
export function verifyL2(): L2VerifyResult {
const p = l2FilePath();
if (!existsSync(p)) return { ok: true, checked: 0 };
let expected: string | null = null;
let checked = 0;
let line = 0;
try {
for (const raw of readFileSync(p, 'utf8').split('\n')) {
if (!raw.trim()) continue;
line += 1;
let rec: L2Entry;
try {
rec = JSON.parse(raw) as L2Entry;
} catch {
return { ok: false, checked, broken_at: { line, reason: 'JSON 解析失败(条目被破坏)' } };
}
const { hash, ...body } = rec;
if (!hash) continue;
const recalc = hashEntry(body as unknown as Record<string, unknown>, rec.prev ?? GENESIS);
if (recalc !== hash) {
return { ok: false, checked, broken_at: { line, reason: '内容与哈希不符(条目被篡改)' } };
}
if (expected !== null && rec.prev !== expected) {
return { ok: false, checked, broken_at: { line, reason: '链断裂(prev 与上一条哈希不匹配)' } };
}
expected = hash;
checked += 1;
}
} catch {
return { ok: false, checked, broken_at: { line, reason: '读取 L2 文件失败' } };
}
return { ok: true, checked };
}
/** 读取 L2 记录(供导出转发 —— 真正的独立性来自这里,而非本地副本)。 */
export function readL2(limit = 200): L2Entry[] {
const p = l2FilePath();
if (!existsSync(p)) return [];
const out: L2Entry[] = [];
try {
const lines = readFileSync(p, 'utf8').split('\n').filter((l) => l.trim());
for (const l of lines.slice(-Math.max(1, limit))) {
try {
out.push(JSON.parse(l) as L2Entry);
} catch {
/* 跳过损坏行 */
}
}
} catch {
/* 读失败返回已有部分 */
}
return out;
}
+5
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;
+3
View File
@@ -1,6 +1,7 @@
import { CapabilityNames } from '@nodeagent/protocol';
import type { AgentConfig } from '../config.js';
import { systemInfo, systemStatus, processList, serviceList, shellExec, auditList, auditVerify, auditHead, auditAnchor, metricsReport, agentRestart } from './system.js';
import { auditL2 } from './audit-l2.js';
import { appList, appInstall } from './app.js';
import { screenInfo, screenCapture } from './screen.js';
import { windowList, windowFocus, screenFind } from './window.js';
@@ -79,6 +80,8 @@ export function createCapabilityRegistry(cfg: AgentConfig): Record<string, Capab
// v2.0.0 审计链外部锚定(防整链重写)
[CapabilityNames.AuditHead]: auditHead,
[CapabilityNames.AuditAnchor]: auditAnchor,
// L2 系统级审计兜底(见 docs/AUDIT-L2.md)
[CapabilityNames.AuditL2]: auditL2,
// v13 成功指标
[CapabilityNames.Metrics]: metricsReport,
// v5 文件传输
+6 -1
View File
@@ -7,6 +7,7 @@ import {
} 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>;
@@ -227,7 +228,7 @@ export function buildInputPsScript(body: string[]): string {
// ⚠️ 必须最先执行,且**不可删**:不设 DPI 感知时 `SetCursorPos` / `mouse_event` 的坐标
// 会被系统按缩放比虚拟化(200% 下 ×2),而 `screen.find`(UIA)返回的是**物理**坐标 ——
// 「找得到、点不准」,且不报任何错。真机 2026-10-12 实证:
// 请求 (100,100) 实际落到 (200,200),请求 (552,1489) 实际落到 (1104,2019)。
// 请求 (100,100) 实际落到 (200,200),请求 (552,745) 实际落到 (1104,1439)。
PS_DPI_AWARE,
`$code = @'`,
CS_CODE,
@@ -256,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;
@@ -283,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';
@@ -314,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;
@@ -333,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;
+3
View File
@@ -4,6 +4,7 @@ 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>;
@@ -88,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,
+6 -1
View File
@@ -2,6 +2,7 @@ import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { IS_WINDOWS, execCommand } from '../util/exec.js';
import { shLit } from '../util/quote.js';
import { PS_DPI_AWARE } from '../util/dpi.js';
import { assertGuiPlatform } from '../util/platform.js';
type Args = Record<string, unknown>;
@@ -38,7 +39,7 @@ export function buildListDisplaysScript(): string {
return [
// ⚠️ 必须最先执行:不设 DPI 感知时,`Screen.Bounds` 返回的是**逻辑**尺寸,
// 而 `CopyFromScreen` 又按**物理**像素裁剪 → 缺省整屏截图只截到左上 1/4
//(200% 缩放下 3600×2020 的桌面报成 1800×1010)。真机 2026-10-12 实证。
//(200% 缩放下 2560×1440 的桌面报成 1280×720)。真机 2026-10-12 实证。
PS_DPI_AWARE,
'Add-Type -AssemblyName System.Windows.Forms',
'$s = [System.Windows.Forms.Screen]::AllScreens',
@@ -82,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 }) => ({
@@ -115,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',
+222 -51
View File
@@ -1,11 +1,21 @@
import { createHash } from 'node:crypto';
import { copyFileSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import {
copyFileSync,
mkdirSync,
readFileSync,
readdirSync,
renameSync,
statSync,
unlinkSync,
writeFileSync,
} from 'node:fs';
import { join, dirname, basename } from 'node:path';
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { IS_WINDOWS } from '../util/exec.js';
import { agentDir } from '../config.js';
import { audit } from '../audit.js';
import { agentRestart } from './system.js';
import { httpGet, resolveProxy } from '../net/index.js';
type Args = Record<string, unknown>;
@@ -21,59 +31,204 @@ type Args = Record<string, unknown>;
* 1. **哈希必须钉死**:`sha256` 必填,且**逐字节校验下载内容**后才替换。
* 没有它,GitHub 账号被盗 / DNS 被污染 / 中间人 = 直接拿下被控端。
* 2. 只接受 http/https;拒绝 file:// 等其它 scheme。
* 3. 大小上限(默认 64MB)+ 下载超时,避免被喂垃圾撑爆磁盘。
* 4. **先备份再原子替换**(同目录 rename),失败可回滚。
* 3. 大小上限(默认 64MB,content-length 预检 + 流式中断双闸)+ 下载超时。
* 4. **先备份再替换**(同目录备份;Windows 上就地覆写,不用 rename —— 真机踩过),
* 失败可回滚;写后校验不一致自动回滚。
* 5. `dry_run` 只下载校验、不动现有文件 —— 用于「先确认能不能更新」。
* 6. 动作全程进审计(能力调用本身已记录 `invoke`,这里再加一条 `agent.update`)。
*
* ## 为什么不用 `fetch` 的代理能力
* Node 内置 `fetch` 不支持 HTTP 代理(需要 undici 的 ProxyAgent,而 undici 未对外暴露)。
* 国内访问 GitHub 不稳时,正确做法是**把 url 指向自建镜像 / 加速服务**(本能力接受任意
* http(s) 地址,天然支持),而不是在 agent 里再塞一套代理隧道。
* ## 出网:经 `net/proxy`(v2.1.0,已批准的方案 A)
* 历史立场(v19~v2.0.3)是「改用镜像地址,而不是在 agent 里塞代理隧道」——
* 真机教训证明该立场不成立:境内无透明代理的机器上,**即使配好了代理**,
* Node 内置 fetch 也不读代理环境变量、更读不到 Windows/macOS 系统代理,
* 「自更新」必然失败(与控制台 sidecar 2026-10-13 的同一缺陷)。
* 故 v2.1.0 起复用 sidecar 已验证的出网方案(`apps/console/sidecar/proxy.mjs`
* 的 TS 移植,见 `net/proxy.ts`):
* `NA_PROXY` → 目标协议环境变量 → 系统代理(reg query / scutil);
* PAC / socks **明确报错不静默降级**;整个重定向链共享一个超时预算。
*/
const MAX_BYTES = 64 * 1024 * 1024;
/** 下载到内存(带大小上限与超时)。 */
async function download(url: string, timeoutMs: number): Promise<Buffer> {
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), timeoutMs);
/** 出网诊断信息(返回结构与 CapabilityError.data 共用,保证成功/失败口径一致)。 */
interface OutboundInfo {
via: string;
proxy: string | null;
proxy_source: string | null;
notes: string[];
}
/** 组一条可操作的失败线索:走了哪条路、代理从哪来、为什么不走代理。 */
function outboundHint(o: OutboundInfo, timeoutMs: number): Record<string, unknown> {
return {
via: o.via,
proxy: o.proxy,
proxy_source: o.proxy_source,
timeout_ms: timeoutMs,
notes: o.notes,
hint: o.proxy
? '代理已生效但仍失败:请确认代理本身能访问更新源(浏览器里试同一个地址)'
: '未使用代理。若本机需要代理才能访问更新源,请在系统里开启「系统代理」,或设置环境变量 NA_PROXY=http://127.0.0.1:<端口> 后重启被控端',
};
}
/**
* 是否为「主动中止」类错误 —— **只认真正的 abort**。
*
* ⚠️ 别把 `ECONNRESET` 之类也塞进来(sidecar 第一版就这么写的,真机上立刻现形):
* 那次耗时 82.9s、预算 120s,连接是被对端**中途重置**的,却被报成
* 「请求超时(120000ms 内未完成)」—— 一句话把用户引到完全错误的方向上。
* 此处按 sidecar `main.mjs:146-151` **同款移植**,不许重新发明。
*/
export function isAbortLike(err: unknown): boolean {
const name = (err as { name?: string } | null)?.name ?? '';
const code = (err as { code?: string } | null)?.code ?? '';
const msg = String((err as { message?: unknown } | null)?.message ?? '');
return name === 'AbortError' || code === 'ABORT_ERR' || /\babort(ed)?\b/i.test(msg);
}
/**
* 把底层网络错误翻译成「用户能据以行动」的一句话。
*
* 按 sidecar `main.mjs:154-165` **同款移植**(含真机教训:ECONNRESET 不是超时)。
*/
export function describeNetError(err: unknown, timeoutMs: number): string {
const msg = err instanceof Error ? err.message : String(err);
const code = (err as { code?: string } | null)?.code ?? '';
if (isAbortLike(err)) return `请求超时(${timeoutMs}ms 内未完成)`;
if (code === 'ECONNRESET' || code === 'EPIPE') {
return `${msg}(连接被对端中途断开;若走了代理,多半是代理或线路的问题)`;
}
if (code === 'ENOTFOUND' || code === 'EAI_AGAIN' || code === 'ECONNREFUSED') {
return `${msg}(域名解析失败或目标拒绝连接;若配了代理,请核对代理地址与端口)`;
}
// 2026-10-27 真机补:境内直连 GitHub 的**最常见**失败形态就是 TCP 层 ETIMEDOUT
// (不是我们自己的 timeoutMs,也不是 ECONNRESET)。原先它落到下面兜底分支,
// 用户只看到一串 IP:443 超时,不知道该干什么 —— 而这时唯一有效的动作就是配代理。
if (code === 'ETIMEDOUT' || code === 'EHOSTUNREACH' || code === 'ENETUNREACH') {
return `${msg}(连接超时/网络不可达;若目标在境外或需要代理,请设置 NA_PROXY=<代理地址> 或配置系统代理后重试)`;
}
return msg;
}
/** 决议代理;任何异常都退化成「直连」—— 探测本身绝不该成为新的失败点。 */
async function resolveProxySafe(targetUrl: string): Promise<{ proxy: URL | null; info: OutboundInfo }> {
try {
const res = await fetch(url, { signal: ac.signal, redirect: 'follow' });
if (!res.ok) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, `下载失败: HTTP ${res.status} ${res.statusText}`, {
url,
});
}
const declared = Number(res.headers.get('content-length') ?? '0');
if (declared > MAX_BYTES) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `文件过大(${declared} 字节 > 上限 ${MAX_BYTES})`, { url });
}
const reader = res.body?.getReader();
if (!reader) {
return Buffer.from(await res.arrayBuffer());
}
const parts: Uint8Array[] = [];
let total = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
total += value.length;
if (total > MAX_BYTES) {
await reader.cancel();
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `下载超过上限 ${MAX_BYTES} 字节,已中断`, { url });
}
parts.push(value);
const r = await resolveProxy({ targetUrl });
return {
proxy: r.proxy,
info: {
via: r.proxy ? `经代理 ${r.proxy.href}` : '直连',
proxy: r.proxy ? r.proxy.href : null,
proxy_source: r.source,
notes: r.notes,
},
};
} catch (err) {
return {
proxy: null,
info: {
via: '直连',
proxy: null,
proxy_source: null,
notes: [`代理探测异常(${err instanceof Error ? err.message : String(err)}),按直连处理`],
},
};
}
}
/**
* 下载到内存(带大小上限与超时;经代理出网)。
*
* 超时语义:`timeoutMs` 是**整条重定向链的总预算**(与 sidecar 一致:链共享一个
* AbortController,防 5 跳 × 30s 穿过 RPC 超时)。
*
* @returns 下载内容 + 出网诊断(成功/失败都带,保证「代理没配 / 哈希不符 / 超时」三类可分)
*/
async function download(
url: string,
timeoutMs: number,
): Promise<{ buf: Buffer; outbound: OutboundInfo }> {
const { proxy, info } = await resolveProxySafe(url);
let res;
try {
res = await httpGet(url, { proxy, timeoutMs, maxBytes: MAX_BYTES });
} catch (err) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, `下载失败: ${describeNetError(err, timeoutMs)}`, {
url,
...outboundHint(info, timeoutMs),
});
}
if (res.status < 200 || res.status >= 300) {
throw new CapabilityError(
ErrorCodes.EXECUTION_FAILED,
`下载失败: HTTP ${res.status} ${res.statusText}`,
{ url, ...outboundHint(info, timeoutMs) },
);
}
const declared = Number(res.headers['content-length'] ?? '0');
if (declared > MAX_BYTES) {
throw new CapabilityError(
ErrorCodes.PARAM_INVALID,
`文件过大(${declared} 字节 > 上限 ${MAX_BYTES})`,
{ url, ...outboundHint(info, timeoutMs) },
);
}
if (res.body.length > MAX_BYTES) {
throw new CapabilityError(
ErrorCodes.PARAM_INVALID,
`下载超过上限 ${MAX_BYTES} 字节,已中断`,
{ url, ...outboundHint(info, timeoutMs) },
);
}
return { buf: res.body, outbound: info };
}
/**
* 备份清理计划(纯函数,供单测):
* 按 mtime **新→旧**排序,保留最近 `keep` 个,返回**应删除**的名单。
*
* 为什么需要:selfupdate 的备份是 `<script>.bak-<ts>`、永不清理 ——
* 推送式 deploy 踩过的坑(真机堆了 15+ 个 bak)在拉取式路径复发。
* 保留最近 3 个:再往前的版本可由更早的备份或重新发布获得,磁盘不无限堆积。
*/
export function planBackupCleanup(files: { name: string; mtime_ms: number }[], keep = 3): string[] {
const sorted = [...files].sort((a, b) => b.mtime_ms - a.mtime_ms);
// 反过来给「最旧先删」:万一删到一半失败,已删掉的也是价值最低的那些
return sorted.slice(Math.max(0, keep)).reverse().map((f) => f.name);
}
/**
* 清理入口同目录的旧 selfupdate 备份(保留最近 3 个)。
*
* 删除失败一律忽略(被杀软占位的 .bak 删不掉不影响正确性)。
* @returns 实际删除的文件名(供审计/返回值观察)
*/
function cleanupOldBackups(scriptPath: string, keep = 3): string[] {
try {
const dir = dirname(scriptPath);
const prefix = `${basename(scriptPath)}.bak-`;
const files: { name: string; mtime_ms: number }[] = [];
for (const name of readdirSync(dir)) {
if (!name.startsWith(prefix)) continue;
try {
files.push({ name, mtime_ms: statSync(join(dir, name)).mtimeMs });
} catch {
/* 文件可能已消失,跳过 */
}
}
return Buffer.concat(parts);
} catch (err) {
if (err instanceof CapabilityError) throw err;
const msg = err instanceof Error ? err.message : String(err);
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, `下载失败: ${msg}`, { url });
} finally {
clearTimeout(timer);
const removed: string[] = [];
for (const name of planBackupCleanup(files, keep)) {
try {
unlinkSync(join(dir, name));
removed.push(name);
} catch {
/* 删不掉不影响正确性 */
}
}
return removed;
} catch {
return [];
}
}
@@ -109,9 +264,9 @@ export async function agentUpdate(args: Args): Promise<unknown> {
const dir = join(agentDir(), 'update');
mkdirSync(dir, { recursive: true });
// ① 下载 + ② 校验哈希(先校验再落盘,避免半成品污染)
// ① 下载(经代理出网)+ ② 校验哈希(先校验再落盘,避免半成品污染)
const t0 = Date.now();
const buf = await download(url, timeoutMs);
const { buf, outbound } = await download(url, timeoutMs);
const got = createHash('sha256').update(buf).digest('hex');
if (!got.startsWith(expect) && got !== expect) {
audit({ type: 'agent.update', status: 'failed', reason: `hash mismatch url=${url} expect=${expect} got=${got.slice(0, 12)}` });
@@ -119,6 +274,7 @@ export async function agentUpdate(args: Args): Promise<unknown> {
expected: expect,
actual: got.slice(0, 12),
bytes: buf.length,
...outboundHint(outbound, timeoutMs),
});
}
@@ -146,7 +302,7 @@ export async function agentUpdate(args: Args): Promise<unknown> {
} catch {
/* 忽略 */
}
audit({ type: 'agent.update', status: 'ok', reason: `dry_run url=${url} hash=${got.slice(0, 12)}` });
audit({ type: 'agent.update', status: 'ok', reason: `dry_run url=${url} hash=${got.slice(0, 12)} via=${outbound.via}` });
return {
dry_run: true,
verified: true,
@@ -155,10 +311,15 @@ export async function agentUpdate(args: Args): Promise<unknown> {
current_hash: prevHash,
current_bytes: prevBytes,
note: '仅下载并校验,未改动任何文件',
via: outbound.via,
proxy: outbound.proxy,
proxy_source: outbound.proxy_source,
outbound_notes: outbound.notes,
};
}
// ③ 备份 + ④ 原子替换(同目录 rename,失败时旧文件不受影响)
// ③ 备份 + ④ 替换(同目录备份;就地覆写不用 rename —— Windows 不允许替换
// 正被运行进程打开的文件,真机踩过:rename 必失败)
const backup = `${scriptPath}.bak-${Date.now()}`;
try {
copyFileSync(scriptPath, backup);
@@ -200,13 +361,18 @@ export async function agentUpdate(args: Args): Promise<unknown> {
/* 清理失败不影响正确性 */
}
// ⑤ 备份清理:保留最近 3 个,其余删(A-P0-3:selfupdate 路径此前永不清理)
const removedBackups = cleanupOldBackups(scriptPath, 3);
audit({
type: 'agent.update',
status: 'ok',
reason: `url=${url} ${prevHash}->${got.slice(0, 12)} bytes=${buf.length} backup=${backup}`,
reason:
`url=${url} ${prevHash}->${got.slice(0, 12)} bytes=${buf.length} backup=${backup} via=${outbound.via}` +
(removedBackups.length > 0 ? ` cleaned=${removedBackups.length}` : ''),
});
// ⑤ 受控重启(复用 v7;Windows 走计划任务、POSIX 走 detached,均不依赖本进程存活)
// ⑥ 受控重启(复用 v7;Windows 走计划任务、POSIX 走 detached,均不依赖本进程存活)
let restarted = false;
if (doRestart) {
await agentRestart({ delay_ms: 1500, reason: 'self-update' });
@@ -227,5 +393,10 @@ export async function agentUpdate(args: Args): Promise<unknown> {
? '已替换并重启;请用 system.info 的 build.hash 复核(应等于 incoming_hash)。若异常,把 backup_path 覆盖回入口即可回滚'
: '已替换但未重启(restart=false);重启后才会生效',
platform: IS_WINDOWS ? 'windows' : 'posix',
// v2.1.0 新增(只增字段):出网诊断
via: outbound.via,
proxy: outbound.proxy,
proxy_source: outbound.proxy_source,
outbound_notes: outbound.notes,
};
}
+6 -1
View File
@@ -10,6 +10,7 @@ import { loadAgentConfig } from '../config.js';
import { readAudit, readAuditAll, verifyAudit, computeAuditHead, anchorAudit, compareWithAnchors } from '../audit.js';
import { computeMetrics } from '../metrics.js';
import { allPsShellStats } from '../util/ps-helper.js';
import { lkgStatus } from '../lkg.js';
import { startTask } from './task.js';
import { psLit } from '../util/quote.js';
@@ -151,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) {
@@ -537,7 +542,7 @@ async function scheduleWindowsRestart(p: RestartParams): Promise<unknown> {
//
// 现策略:非 Running **或** LastRunTime **早于"本 agent 启动时刻"** → 清。
//
// ⚠️ 2026-10-11 真机(Parallels Win11 ARM)修正:原先用「早于 1 小时」作阈值,
// ⚠️ 2026-10-11 真机(虚拟化平台上的 Win11 ARM)修正:原先用「早于 1 小时」作阈值,
// 实测发现**频繁 restart(间隔 < 1h)时旧任务照样累积**(v26 想治的僵尸只是从
// "永不清理"变成"1 小时后才清")。根因是阈值选得太粗:
// 阈值应表达「**这个任务是不是我(当前 agent)的宿主**」——
+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',
});
+93 -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,28 @@ 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:进程级最后一道防线(崩溃链兜底)
// ---------------------------------------------------------------------------
@@ -68,6 +93,64 @@ process.on('unhandledRejection', (reason: unknown) => {
);
});
// ---------------------------------------------------------------------------
// 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(
'未捕获异常',
@@ -85,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;
@@ -133,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);
}
}
+14 -16
View File
@@ -32,6 +32,7 @@ import { createCapabilityRegistry } from './capabilities/index.js';
import { audit, describeArgs } from './audit.js';
import { disposeWatchesByOwner, watchCount } from './events.js';
import { runningTaskCount } from './capabilities/task.js';
import { createRateLimiter } from './util/rate-limit.js';
import type { AgentConfig } from './config.js';
import type { TlsMaterial } from './certs.js';
@@ -96,21 +97,15 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
*/
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]) {
@@ -360,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',
+7 -7
View File
@@ -8,10 +8,10 @@
*
* | 调用 | 该进程看到的 |
* |---|---|
* | `[System.Windows.Forms.Screen]::Bounds` | **逻辑**尺寸(物理 3600×2020 → 报 1800×1010) |
* | `[System.Windows.Forms.Screen]::Bounds` | **逻辑**尺寸(物理 2560×1440 → 报 1280×720) |
* | `[System.Windows.Forms.SystemInformation]::VirtualScreen` | **逻辑**(同上) |
* | `CopyFromScreen(0,0,1800,1010)` | **物理**裁剪 → 只截到屏幕左上 1/4 |
* | `SetCursorPos(552,1489)` | 落点实际在物理 (1104, 2978)(被系统 ×2) |
* | `CopyFromScreen(0,0,1280,720)` | **物理**裁剪 → 只截到屏幕左上 1/4 |
* | `SetCursorPos(552,745)` | 落点实际在物理 (1104, 1490)(被系统 ×2) |
* | UIA(`screen.find`) | **物理**(与进程 DPI 感知无关) |
*
* 于是最要命的一条链路会静默断掉:`screen.find` 返回物理坐标 → 直接喂给
@@ -25,11 +25,11 @@
*
* ## 真机实证(2026-10-12)
*
* Parallels Win11 ARM 虚拟机,主机缩放 200%,物理 3600×2020:
* - 缺省 `screen.capture` 只拿到 1800×1010(= 屏幕左上 1/4),且 `screen.info` 报 1800×1010
* 某虚拟化平台上的 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,1489` → 实际落点 **(1104, 2019)**(截断到屏幕底部)
* - 补上本段后:`screen.capture` 拿到完整 3600×2020;`screen.find` 的物理坐标
* - `input.mouse.move 552,745` → 实际落点 **(1104, 1439)**(截断到屏幕底部)
* - 补上本段后:`screen.capture` 拿到完整 2560×1440;`screen.find` 的物理坐标
* 可直接用于 `input.mouse.click`(同一坐标一次点中)
*
* ⚠️ 不要删。这不是「锦上添花」的兼容代码,而是坐标口径统一的**前提**。
+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');
}
+83
View File
@@ -0,0 +1,83 @@
/**
* 按调用方的滑动窗口限速器(每分钟)。
*
* ## 为什么从 server.ts 抽出来(A-P0-2,2026-10-25)
* 原实现有两处缺陷,且逻辑埋在 `createAgentCore` 闭包里无法单测:
*
* 1. **桶 key 用错值**:原 `allowByRate(clientId, limit, String(limit))` 第三参
* (bucket)传的是**限速值本身**。于是「screen.capture ≤ 5/min」与
* 「app.install ≤ 5/min」两条同值规则共用 `clientId|5` 一个桶:
* 截图 5 次后装软件也被限速(误伤),且两者总量仍按 5/min(漏过)。
* 正确口径:key = `${身份}:${能力名}`。
* 2. **桶只增不删**:`rateBuckets` Map 无任何清扫,key 永久留存。而 psk 模式下
* `client_id` 由客户端**自报**(配置级约束)→ 外部可枚举任意 client_id 造键
* → 长期运行内存无界增长。现在:每次允许判定顺手删过期键 + 容量上限淘汰最旧。
*
* ## 语义
* - 窗口 60s 滑动:只数「最近 60 秒内」的调用;
* - `limitPerMin` 为空/≤0 表示不限速(直接放行,**不建桶** —— 与旧行为一致);
* - 身份用「认证身份 ?? 远端地址」:psk 自报 client_id 不可信,但限速键仍以它为纲
* (配了限速就说明该 client_id 已在 ACL 里登记),无身份的调用方退化按远端地址。
*/
/** 桶容量上限:超出淘汰最旧(Map 保持插入顺序,首键即最旧)。 */
const DEFAULT_MAX_KEYS = 10_000;
const WINDOW_MS = 60_000;
export interface RateLimiter {
/**
* 请求一次调用配额。
*
* @param identity 调用方身份(认证身份 ?? 远端地址)
* @param limitPerMin 每分钟上限;空/≤0 = 不限速
* @param bucket 桶名(能力名)—— 不同能力各占一桶,互不挤占
*/
allow(identity: string, limitPerMin: number | undefined, bucket: string): boolean;
/** 当前桶数(供测试与观测)。 */
size(): number;
}
export interface RateLimiterOptions {
/** 桶容量上限(超出淘汰最旧)。默认 10_000。 */
maxKeys?: number;
/** 时钟注入(单测用假时钟推进 60s 窗口,不必真等)。 */
now?: () => number;
}
export function createRateLimiter(opts: RateLimiterOptions = {}): RateLimiter {
const maxKeys = opts.maxKeys ?? DEFAULT_MAX_KEYS;
const now = opts.now ?? Date.now;
const buckets = new Map<string, number[]>();
return {
allow(identity: string, limitPerMin: number | undefined, bucket: string): boolean {
if (!limitPerMin || limitPerMin <= 0) return true;
const key = `${identity}|${bucket}`;
const t = now();
// 清扫①:过期键直接删(只增不删 = 内存无界增长;自报 client_id 可枚举造键)
for (const [k, times] of buckets) {
if (times.length === 0 || t - times[times.length - 1]! >= WINDOW_MS) buckets.delete(k);
}
// 清扫②:容量上限,超出淘汰最旧(插入序 = 时间序,首键最旧)
while (buckets.size >= maxKeys) {
const oldest = buckets.keys().next();
if (oldest.done) break;
buckets.delete(oldest.value);
}
const recent = (buckets.get(key) ?? []).filter((ts) => t - ts < WINDOW_MS);
if (recent.length >= limitPerMin) {
buckets.set(key, recent);
return false;
}
recent.push(t);
buckets.set(key, recent);
return true;
},
size(): number {
return buckets.size;
},
};
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/cli",
"version": "2.0.3",
"version": "2.1.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
+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;
}
+45
View File
@@ -231,6 +231,51 @@ export async function cmdAuditAnchor(opts: Options): Promise<void> {
);
}
/**
* L2 系统级审计兜底(见 docs/AUDIT-L2.md)。
* nodeagent audit l2 [status|enable|collect|verify] [--since <ms>] [--limit N]
*/
export async function cmdAuditL2(action: string | undefined, opts: Options): Promise<void> {
const args: Record<string, unknown> = { action: action ?? 'status' };
if (opts.since !== undefined) args['since'] = Number(opts.since);
if (opts.limit !== undefined) args['limit'] = Number(opts.limit);
await withClient((c) =>
callAndPrint(c, CapabilityNames.AuditL2, args, opts.json, (data) => {
const d = data as Record<string, unknown>;
if (d['collected'] !== undefined) {
console.log(`✓ 已采集 ${d['collected']} 条,写入 ${d['written']} 条`);
console.log(` 文件:${d['file']}`);
if (typeof d['note'] === 'string') console.log(` ⚠️ ${d['note']}`);
return;
}
if (d['ok'] !== undefined) {
const ok = d['ok'] === true;
const broken = d['broken_at'] as { line: number; reason: string } | undefined;
console.log(
ok
? `✓ L2 链完整(校验 ${d['checked']} 条)`
: `✗ L2 链异常:第 ${broken?.line} 行 —— ${broken?.reason}`,
);
return;
}
if (d['enabled'] === true) {
console.log(`✓ 已启用 Windows 审计策略:${(d['policies'] as string[]).join(' / ')}`);
if (typeof d['hint'] === 'string') console.log(` ${d['hint']}`);
return;
}
// status
console.log(`平台 : ${d['platform']}`);
console.log(`审计策略 : ${d['auditpol']}`);
console.log(`已禁用旧式策略覆盖 : ${d['legacy_policy_disabled'] ? '是' : '否(⚠️ 会导致 4688 不产生)'}`);
console.log(`记录命令行 : ${d['cmdline_logging'] ? '是' : '否(只能看到进程名)'}`);
console.log(`Sysmon : ${d['sysmon_installed'] ? '已安装(可并用)' : '未安装(不自动下载)'}`);
console.log(`L2 文件 : ${d['file']}${d['file_exists'] ? '' : '(尚未生成)'}`);
if (typeof d['note'] === 'string') console.log(`⚠️ ${d['note']}`);
}),
);
}
export async function cmdAuditVerify(opts: Options): Promise<void> {
await withClient((c) =>
callAndPrint(c, CapabilityNames.AuditVerify, {}, opts.json, (data) => {
+13
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] 设置静音 / 音量
@@ -62,6 +67,14 @@ export const HELP = `nodeagent —— 跨机 AI 接管框架(控制端 CLI)
nodeagent deploy <agent.mjs> [--path <远端路径>] [--check] [--force]
一键升级被控端 (v11/v18):比指纹 → 备份 → 上传 → 重启
→ 轮询校验;--check 只比对不部署
nodeagent rollback [--dry-run] [--backup <远端备份路径>]
从 LKG(最后已知可用版本)回滚被控端 (v2.1.0):
指纹比对定位备份 → 覆盖回入口 → 受控重启 → 复核上线
⚠️ 需被控端可达(RPC 命令):新版本「完全起不来」时
没有监听可连,请依赖看门狗自动恢复(启动连续失败 N 次,
见 docs/DELIVERY-PIPELINE.md §6)或带外恢复(附录 C)
自动回滚刻意不做(无法归因/回滚循环/失控)
--dry-run 只读比对零写入;--backup 为高级逃生门
nodeagent group [add <组名> <设备...> | remove <组名>] 设备分组管理 (v12)
nodeagent fanout <能力名> [--nodes a,b|@组名] [--args JSON]
多设备并发调用并汇总 (v11)
+6 -1
View File
@@ -34,12 +34,13 @@ import { HELP } from './help.js';
import { parseOptions } from './options.js';
import { cmdConnect, cmdDiscover, cmdKeygen, cmdNodes, cmdRemove, cmdUse } from './cmd/conn.js';
import {
cmdApps, cmdAudio, cmdAudit, cmdAuditAnchor, cmdAuditHead, cmdAuditVerify, cmdExec, cmdInfo, cmdInstall, cmdInvoke, cmdList,
cmdApps, cmdAudio, cmdAudit, cmdAuditAnchor, cmdAuditHead, cmdAuditL2, cmdAuditVerify, cmdExec, cmdInfo, cmdInstall, cmdInvoke, cmdList,
cmdMetrics, cmdPs, cmdRestart, cmdServices, cmdStatus, cmdUpdate,
} from './cmd/system.js';
import {
cmdBg, cmdClip, cmdDeploy, cmdEvents, cmdFanout, cmdLog, cmdMacro, cmdMonitor, cmdRecord, cmdTask, cmdTasks,
} from './cmd/ops.js';
import { cmdRollback } from './cmd/rollback.js';
import { cmdAwait, cmdKey, cmdMouse, cmdScreen, cmdScreenshot } from './cmd/gui.js';
import { cmdCat, cmdLs, cmdPull, cmdPush, cmdStat } from './cmd/fs.js';
import { cmdNet } from './cmd/net.js';
@@ -260,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>');
@@ -288,6 +292,7 @@ async function main(): Promise<void> {
if (positionals[0] === 'verify') await cmdAuditVerify(opts);
else if (positionals[0] === 'head') await cmdAuditHead(opts);
else if (positionals[0] === 'anchor') await cmdAuditAnchor(opts);
else if (positionals[0] === 'l2') await cmdAuditL2(positionals[1], opts);
else await cmdAudit(opts);
return;
case 'discover':
+3
View File
@@ -127,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' },
@@ -208,6 +210,7 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
forgetCert: Boolean(values['forget-cert']),
check: Boolean(values['check']),
force: Boolean(values['force']),
backup: values['backup'] as string | undefined,
mute: values['mute'] as string | undefined,
volume: values['volume'] as string | undefined,
seconds: values['seconds'] as string | undefined,
+2
View File
@@ -96,6 +96,8 @@ export interface Options {
/** v18 部署校验 */
check?: boolean;
force?: boolean;
/** v2.1.0 rollback 逃生门:直接指定远端备份路径为回滚源 */
backup?: string;
/** v17 音频 */
mute?: string;
volume?: string;
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "nodeagent-console",
"private": true,
"version": "2.0.3",
"version": "2.1.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -29,7 +29,7 @@
"@tauri-apps/cli": "^2",
"@vitejs/plugin-vue": "^6.0.7",
"typescript": "~6.0.3",
"vite": "^8.0.16",
"vite": "^8.3.5",
"vue-tsc": "^3.3.5"
}
}
+109 -24
View File
@@ -13,8 +13,11 @@
* {"jsonrpc":"2.0","id":2,"method":"disconnect"}
* {"jsonrpc":"2.0","id":3,"method":"invoke","params":{capability,args,timeout_ms}}
* {"jsonrpc":"2.0","id":4,"method":"state"}
* {"jsonrpc":"2.0","id":5,"method":"check_update","params":{console_version,url?}}
* {"jsonrpc":"2.0","id":6,"method":"download","params":{url,sha256,dest}}
* {"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"}}
@@ -31,6 +34,7 @@ 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;
@@ -94,6 +98,72 @@ function cmpVersion(a, b) {
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) {
@@ -189,26 +259,30 @@ const methods = {
*/
async check_update(params) {
const consoleVersion = String(params?.console_version ?? '0.0.0');
const url =
params?.url ??
'https://github.com/jkooe/nodeagent/releases/latest/download/latest.json';
const url = String(
params?.url ?? 'https://github.com/jkooe/nodeagent/releases/latest/download/latest.json',
);
const timeoutMs = clampTimeoutMs(params?.timeout_ms, 120_000);
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), 20_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 fetch(url, { signal: ac.signal, redirect: 'follow' });
if (!res.ok) {
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 = await res.json();
meta = JSON.parse(res.body.toString('utf8'));
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
throw Object.assign(new Error(`检查更新失败:${msg}(地址 ${url})`), {
data: { url, hint: '若本机网络无法直连 GitHub,可在被控端配置里改用镜像地址' },
});
} finally {
clearTimeout(timer);
const 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 ?? '');
@@ -230,6 +304,9 @@ const methods = {
},
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(),
};
},
@@ -252,17 +329,25 @@ const methods = {
);
}
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), 300_000);
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 fetch(url, { signal: ac.signal, redirect: 'follow' });
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
buf = Buffer.from(await res.arrayBuffer());
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) {
throw new Error(`下载失败:${err instanceof Error ? err.message : String(err)}`);
} finally {
clearTimeout(timer);
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');
+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);
}
}
+1 -1
View File
@@ -2101,7 +2101,7 @@ checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nodeagent-console"
version = "2.0.3"
version = "2.1.0"
dependencies = [
"serde",
"serde_json",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "nodeagent-console"
version = "2.0.3"
version = "2.1.0"
description = "nodeagent 桌面控制台(主控 + 被控一体,含本机服务托管)"
authors = ["jkooe"]
edition = "2021"
+349 -16
View File
@@ -158,14 +158,19 @@ pub fn mark_installed_version(data_dir: &Path, version: &str, source: &str) -> R
///
/// 优先级:环境变量 `NODEAGENT_CONSOLE_AGENT` → 随包 resource 的 `agent.mjs` →
/// 开发期的 `apps/agent/dist/index.js`。
///
/// ⚠️ 出口一律经 `sidecar::unverbatim`:`resource_dir()` 在 Windows 上给的是
/// verbatim 路径(`\\?\C:\…`)。本函数的结果最终会**交给 node**(播种后作为脚本路径),
/// 带 `\\?\` 的路径会让 node **启动即 EISDIR 秒退** —— 这正是 2026-10-13
/// 「sidecar 秒退」真机缺陷的同类风险,必须在源头堵掉。
pub fn resolve_bundled_agent(resource_dir: Option<&Path>) -> Result<PathBuf, String> {
if let Ok(p) = std::env::var("NODEAGENT_CONSOLE_AGENT") {
if !p.is_empty() && Path::new(&p).exists() {
return Ok(PathBuf::from(p));
return Ok(crate::sidecar::unverbatim(PathBuf::from(p)));
}
}
if let Some(dir) = resource_dir {
let bundled = dir.join("agent.mjs");
let bundled = crate::sidecar::unverbatim(dir.join("agent.mjs"));
if bundled.exists() {
return Ok(bundled);
}
@@ -228,6 +233,112 @@ pub fn port_in_use(port: u16) -> bool {
TcpStream::connect_timeout(&addr, Duration::from_millis(400)).is_ok()
}
/// 查「谁在监听这个端口」,返回其 PID。
///
/// ## 为什么需要它(2026-10-17 「复用」需求)
/// 用户退出控制台时若选择**保留被控端**,那个被控端就成了孤儿进程继续监听。
/// 下次启动控制台时,端口已被占 —— 原实现直接报「端口已被占用」,于是
/// 用户明明有个健康的被控端在跑,界面上却只能看到一句错误、无法复用。
///
/// 有了 PID 才能:① 判断占用者是不是**我们自己的**被控端;② 接管它(复用):
/// ③ 需要真正托管时用 taskkill 收掉再自己 spawn。
///
/// 本函数只负责取 PID,**不判断它是不是 nodeagent** —— 那个判断在
/// `looks_like_agent()` 里单独做,便于各自测试。
pub fn port_owner_pid(port: u16) -> Option<u32> {
if cfg!(windows) {
// netstat 在所有 Windows 上都有;`-ano` 给出 PID。
// 输出形如: TCP 0.0.0.0:8765 0.0.0.0:0 LISTENING 26348
let out = std::process::Command::new("netstat")
.args(["-ano", "-p", "TCP"])
.output()
.ok()?;
let text = String::from_utf8_lossy(&out.stdout);
let needle = format!(":{port}");
for line in text.lines() {
let cols: Vec<&str> = line.split_whitespace().collect();
// 需要 ≥5 列;第 2 列是本地地址,最后一列是 PID,且状态为 LISTENING
if cols.len() < 5 || !cols[1].ends_with(&needle) {
continue;
}
if !line.contains("LISTENING") {
continue;
}
if let Ok(pid) = cols[cols.len() - 1].parse::<u32>() {
if pid != 0 {
return Some(pid);
}
}
}
None
} else {
// macOS / Linux:lsof 比 ss 更普遍可预期
let out = std::process::Command::new("lsof")
.args(["-nP", &format!("-iTCP:{port}"), "-sTCP:LISTEN", "-t"])
.output()
.ok()?;
String::from_utf8_lossy(&out.stdout)
.lines()
.find_map(|l| l.trim().parse::<u32>().ok())
}
}
/// 按 PID 结束一个**非本进程派生**的进程(复用来的外部实例)。
///
/// 复用实例没有 `Child` 句柄(不是我们 spawn 的),`kill()/wait()` 都用不了,
/// 只能用系统工具。失败时返回 false 由调用方如实上报 —— 不假装成功。
pub async fn stop_pid(pid: u32) -> bool {
if cfg!(windows) {
// /T 连子进程树一起收:被控端可能拉起 PowerShell 助手等子进程
let out = tokio::process::Command::new("taskkill")
.args(["/PID", &pid.to_string(), "/T", "/F"])
.output()
.await;
matches!(out, Ok(o) if o.status.success())
} else {
let out = tokio::process::Command::new("kill").args(["-TERM", &pid.to_string()]).output().await;
matches!(out, Ok(o) if o.status.success())
}
}
/// 该 PID 看起来是不是一个 nodeagent 被控端(命令行含 `agent.mjs`)。
///
/// 这层校验是**安全边界**:端口上跑的可能是任何东西(用户自己的服务、
/// 另一个应用)。只有确认是自家被控端才允许「复用」,
/// 否则必须继续报「端口被占用」—— 绝不能把别人的进程当成自己的接管。
///
/// ## ⚠️ 必须异步 + 带超时(2026-10-17 真机踩到)
/// 初版用的是 `std::process::Command::output()`:**同步阻塞**且**无超时**。
/// 在这台机器上 `Get-ScheduledTask` 要 2s、CIM 查询偶发卡死(实测有一次 30s 超时),
/// 于是 `start()` 被永久阻塞 —— 表现为**界面三个按钮一直转圈、PID 不再更新**,
/// 只能重开控制台。异步调用 + 3 秒超时后,最坏情况只是「放弃复用、照常报端口冲突」。
///
/// 超时/查询失败一律**判为「不是被控端」**(拒绝接管):宁可让用户看到
/// 「端口被占用」去手工排查,也不能误杀别人的进程。
pub async fn looks_like_agent(pid: u32) -> bool {
if cfg!(windows) {
let script = format!(
"(Get-CimInstance Win32_Process -Filter \"ProcessId={pid}\" -ErrorAction SilentlyContinue).CommandLine"
);
let fut = tokio::process::Command::new("powershell")
.args(["-NoProfile", "-NonInteractive", "-Command", &script])
.output();
match tokio::time::timeout(Duration::from_secs(3), fut).await {
Ok(Ok(o)) => String::from_utf8_lossy(&o.stdout).to_lowercase().contains("agent.mjs"),
// 超时或执行失败:无法确认,判为不是(安全优先)
_ => false,
}
} else {
let fut = tokio::process::Command::new("ps")
.args(["-p", &pid.to_string(), "-o", "command="])
.output();
match tokio::time::timeout(Duration::from_secs(3), fut).await {
Ok(Ok(o)) => String::from_utf8_lossy(&o.stdout).contains("agent.mjs"),
_ => false,
}
}
}
/// 读被控端配置(`<数据目录>/agent.json`)里与控制台有关的部分。
///
/// 只读、不创建 —— 配置由被控端自己在首次启动时生成(含随机密钥)。
@@ -260,7 +371,17 @@ pub fn read_agent_config(data_dir: &Path) -> Value {
// 它本来就在用户本机的 600 文件里,不构成额外暴露面。
"key": key,
"port_in_use": port_in_use(port as u16),
"url": format!("ws://127.0.0.1:{port}"),
// ⚠️ 协议必须跟随 `tls`(2026-10-17 真机:「连接本机」必失败)
//
// 原实现硬编码 `ws://`,而 agent 默认 `tls: true` —— 于是控制台
// 用**明文 WebSocket** 去连一个**只接受 TLS** 的端口:
// 服务端等 TLS ClientHello,收到的却是 HTTP GET,握手当场失败。
// 被控端审计里连一条记录都不会出现(根本没进到鉴权层),
// 用户只看到一句「连接失败」。
//
// 这类错误的特征是「配置看起来都对、密钥也对、就是连不上」——
// 因为问题不在鉴权,在**协议层**。
"url": format!("{}://127.0.0.1:{port}", if tls { "wss" } else { "ws" }),
})
}
@@ -271,6 +392,9 @@ struct Inner {
started_at: Option<u64>,
restarts: u32,
last_exit: Option<String>,
/// true = 复用了一个**非本控制台启动**的被控端(孤儿实例)。
/// 此时 `child` 为 None(拿不到 Child 句柄),停止要靠 PID + taskkill。
external: bool,
}
/// 本机被控端管理器。
@@ -295,6 +419,7 @@ impl AgentManager {
started_at: None,
restarts: 0,
last_exit: None,
external: false,
}),
logs: Arc::new(Mutex::new(VecDeque::with_capacity(LOG_CAP))),
seq: Arc::new(AtomicU64::new(1)),
@@ -333,7 +458,32 @@ impl AgentManager {
/// 当前状态快照(不探测端口,纯读内存 + 已记录的路径)。
pub async fn snapshot(&self) -> Value {
let inner = self.inner.lock().await;
let mut inner = self.inner.lock().await;
// ── 状态自愈(2026-10-17 真机缺陷)────────────────────────────
// 真机现象:被控端明明已在监听、验收全通,界面却一直停在「启动中」,
// 「停止 / 重启」按钮因此永久禁用(它们依赖 running),用户无从操作。
//
// 成因:`start()` 里 `set_state(STARTING)` 与 `set_state(RUNNING)` 之间
// 有多处 `.await`(拿锁、spawn、日志泵),并发调用或事件丢失会让
// 状态停在中间态。与其继续猜是哪一次 `.await` 被吞掉,
// **不如按客观事实校正**:只要已经拿到 PID、且距启动时刻超过 2 秒,
// 它就不可能还在「启动中」—— 这是进程已经起来的硬证据。
//
// 为什么用 2 秒而不是立即:给正常的 set_state(RUNNING) 留出执行窗口,
// 避免和它抢(正常路径仍由 start() 自己置位)。
if inner.state == state::STARTING {
if let (Some(t), Some(_)) = (inner.started_at, inner.pid) {
if now_ms().saturating_sub(t) > 2_000 {
inner.state = state::RUNNING.to_string();
}
}
}
// 反向同理:STOPPING 但已无 pid,说明停完了却没收尾
if inner.state == state::STOPPING && inner.pid.is_none() {
inner.state = state::STOPPED.to_string();
}
let paths = self.paths.lock().unwrap().clone();
let (node, script, data_dir) = match paths {
Some((n, s, d)) => (
@@ -350,6 +500,8 @@ impl AgentManager {
"uptime_ms": inner.started_at.map(|t| now_ms().saturating_sub(t)),
"restarts": inner.restarts,
"last_exit": inner.last_exit,
// 复用标记:前端据此显示「已复用外部被控端」并提示可「接管」
"external": inner.external,
"node": node,
"script": script,
"data_dir": data_dir,
@@ -401,6 +553,49 @@ impl AgentManager {
let cfg = read_agent_config(data_dir);
let port = cfg.get("port").and_then(Value::as_u64).unwrap_or(8765) as u16;
if port_in_use(port) {
// 2026-10-17「复用」需求:端口被占时**先判断占用者是不是自家被控端**。
//
// 背景:用户退出控制台时若选择「保留被控端」,那个进程会成为孤儿继续监听。
// 下次开控制台,端口已被自己家那个健康实例占着 —— 原实现直接报
// 「端口已被占用」,用户明明有可用被控端却只能看到一个错误。
//
// 故这里分两种:
// - 占用者确实是 nodeagent 被控端(命令行含 agent.mjs)→ **复用**:
// 记 PID、置 RUNNING,`external=true`(无 Child 句柄,停止走 taskkill)
// - 否则(用户自己的服务 / 别的应用)→ 仍然报错,绝不误接管
if let Some(pid) = {
// 先取占用者 PID,再**异步**确认它是不是自家被控端(带 3s 超时)
match port_owner_pid(port) {
Some(p) if looks_like_agent(p).await => Some(p),
_ => None,
}
} {
{
let mut inner = self.inner.lock().await;
inner.child = None;
inner.pid = Some(pid);
inner.external = true;
// 复用来的实例拿不到真实启动时间;置 None 而不是编一个,避免 UI 显示假 uptime
inner.started_at = None;
inner.last_exit = None;
}
*self.paths.lock().unwrap() = Some((node.to_path_buf(), script.to_path_buf(), data_dir.to_path_buf()));
self.set_state(state::RUNNING).await;
self.push_log(
"system",
format!(
"已复用正在运行的被控端(PID {pid},非本控制台启动)—— \
可点「连接本机」直接使用;点「重启」可收归本控制台完全托管"
),
);
return Ok(json!({
"pid": pid,
"state": state::RUNNING,
"adopted": true,
"note": "复用了已在运行的被控端(未新起进程)",
}));
}
let hint = if cfg!(windows) {
format!(
"netstat -ano | findstr :{port} 找到 PID,再 tasklist /FI \"PID eq <PID>\" 看是谁"
@@ -409,8 +604,7 @@ impl AgentManager {
format!("lsof -nP -iTCP:{port} -sTCP:LISTEN")
};
return Err(format!(
"端口 {port} 已被占用 —— 已有另一个被控端在监听(手工安装的、计划任务拉起的,\
或另一个控制台实例)。\n\
"端口 {port} 已被占用,且占用者**不是** nodeagent 被控端(无法复用)。\n\
排查占用者:{hint}\n\
处理:先停掉它;或把 {} 里的 port 改成别的端口后重启本机服务。",
data_dir.join("agent.json").display()
@@ -429,22 +623,34 @@ impl AgentManager {
),
);
let mut cmd = Command::new(node);
cmd.arg(script)
.current_dir(data_dir)
// 进程边界:交给 node 的三条路径都先去掉 verbatim 前缀。
// - `script` 来自数据目录(通常已是普通路径),但一旦带 `\\?\` 就**必炸** ——
// 脚本参数位置是 2026-10-13「sidecar 秒退」的致命维度(真机四格实验 G2 / G4:
// exit 1,`EISDIR ... lstat 'C:'`);
// - `node` 来自 `resource_dir()`(Windows 上是 `\\?\C:\…`),该位置实测**无害**
// (G3 照常启动),净化属防御性;
// - 两条都净化是为了守住「凡交给外部进程的路径都干净」这条单一纪律 ——
// 靠「记住哪个位置安全」省这一步,迟早会把安全的那个挪到致命的位置上。
let node = crate::sidecar::unverbatim(node.to_path_buf());
let script = crate::sidecar::unverbatim(script.to_path_buf());
let data_dir = crate::sidecar::unverbatim(data_dir.to_path_buf());
let mut cmd = Command::new(&node);
cmd.arg(&script)
.current_dir(&data_dir)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(false); // 由本管理器显式管理生命周期,不留孤儿也不误杀
// 让被控端与控制台用**同一个**数据目录(不设则各按各自规则解析)
cmd.env("NODEAGENT_HOME", data_dir);
cmd.env("NODEAGENT_HOME", &data_dir);
// ⚠️ 消灭黑窗:Windows 上不加这个标志,每 spawn 一次 node 就弹一个控制台窗口
// (与 sidecar 共用同一常量,避免两处硬编码漂移 —— 2026-10-13 sidecar 就是漏了这一行)
#[cfg(windows)]
{
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
cmd.creation_flags(CREATE_NO_WINDOW);
cmd.creation_flags(crate::sidecar::CREATE_NO_WINDOW);
}
let mut child = cmd
@@ -486,14 +692,54 @@ impl AgentManager {
/// 停止被控端(幂等)。
pub async fn stop(&self) -> Result<Value, String> {
self.set_state(state::STOPPING).await;
let child = {
// 分离两种所有权:
// - `child`:本控制台 spawn 的,有句柄,可 wait/kill
// - `ext_pid`:复用来的外部实例,只有 PID,只能 taskkill
let (child, ext_pid) = {
let mut inner = self.inner.lock().await;
inner.child.take()
let ext = if inner.external { inner.pid } else { None };
let c = inner.child.take();
inner.external = false;
(c, ext)
};
let Some(mut child) = child else {
// 复用的外部实例:按 PID 结束
if child.is_none() {
if let Some(p) = ext_pid {
self.push_log("system", format!("停止被复用的被控端(PID {p},外部实例)…"));
let killed = stop_pid(p).await;
{
let mut inner = self.inner.lock().await;
inner.pid = None;
inner.started_at = None;
inner.external = false;
inner.last_exit =
Some(if killed { "external instance stopped by console".into() } else { "stop failed".into() });
}
// ⚠️ 必须等端口真正释放再返回:`restart()` 是 stop → 600ms → start,
// 若端口还挂着,start 会走进「复用」分支、把刚杀掉的东西当成还活着,
// 表现为「点了重启但还是复用同一个 PID」。
// 用一个块把 MutexGuard 收掉 —— 持有 std::sync::Mutex 守卫跨 await
// 不是 Send,会直接编译失败
let dir = {
let guard = self.paths.lock().unwrap();
guard.as_ref().map(|(_, _, d)| d.clone()).unwrap_or_else(agent_data_dir)
};
let port = read_agent_config(&dir).get("port").and_then(Value::as_u64).unwrap_or(8765) as u16;
for _ in 0..12 {
if !port_in_use(port) {
break;
}
tokio::time::sleep(Duration::from_millis(400)).await;
}
self.set_state(state::STOPPED).await;
self.push_log("system", "被控端已停止(外部实例)");
return Ok(json!({ "state": state::STOPPED, "external": true, "killed": killed }));
}
self.set_state(state::STOPPED).await;
return Ok(json!({ "state": state::STOPPED, "note": "本来就未运行" }));
};
}
let mut child = child.expect("上面已确认 Some");
let pid = child.id();
self.push_log("system", format!("停止被控端(pid {})…", pid.map(|p| p.to_string()).unwrap_or_else(|| "?".into())));
@@ -684,6 +930,93 @@ mod tests {
d
}
/// 「连接本机」用的地址必须跟随 `tls` 开关。
///
/// 真机缺陷:原实现硬编码 `ws://`,而 agent 默认 `tls: true` ——
/// 明文 WebSocket 去连只接受 TLS 的端口,握手当场失败,
/// 被控端审计里连一条记录都没有(根本没进到鉴权层)。
/// 特征是「配置看着全对、密钥也对、就是连不上」。
#[test]
fn 连接地址应跟随tls开关而非硬编码ws() {
let dir = tmp_dir("cfg-tls");
let p = dir.join("agent.json");
std::fs::write(
&p,
r#"{"node_id":"win","port":9000,"key":"k","auth_mode":"psk","tls":true}"#,
)
.unwrap();
assert_eq!(
read_agent_config(&dir)["url"],
json!("wss://127.0.0.1:9000"),
"tls=true 时必须给 wss,否则连上才怪"
);
std::fs::write(
&p,
r#"{"node_id":"win","port":9000,"key":"k","auth_mode":"psk","tls":false}"#,
)
.unwrap();
assert_eq!(
read_agent_config(&dir)["url"],
json!("ws://127.0.0.1:9000"),
"tls=false 时必须给 ws"
);
}
/// 状态自愈:真机上曾出现「进程已起、界面永远显示启动中」,
/// 导致停止/重启按钮永久禁用。这条钉住校正逻辑,防止被"顺手简化"掉。
#[tokio::test]
async fn 启动中但已有pid且超两秒应自愈为running() {
let m = AgentManager::new(Arc::new(|_| {}));
{
let mut inner = m.inner.lock().await;
inner.state = state::STARTING.to_string();
inner.pid = Some(12345);
inner.started_at = Some(now_ms().saturating_sub(5_000)); // 5 秒前
}
assert_eq!(
m.snapshot().await["state"],
json!(state::RUNNING),
"拿得到 PID 且已过 2 秒,就不该还停在启动中"
);
}
/// 反向守卫:刚启动(<2 秒)时**不要**抢在正常路径前面置位,
/// 否则真实启动失败会被掩盖成「运行中」。
#[tokio::test]
async fn 刚启动不足两秒不得提前自愈() {
let m = AgentManager::new(Arc::new(|_| {}));
{
let mut inner = m.inner.lock().await;
inner.state = state::STARTING.to_string();
inner.pid = Some(12345);
inner.started_at = Some(now_ms()); // 刚刚
}
assert_eq!(
m.snapshot().await["state"],
json!(state::STARTING),
"刚启动的窗口期内应由 start() 自己置位,自愈不得抢先"
);
}
/// 没有 PID 的「启动中」不满足自愈条件(进程根本没起来)。
#[tokio::test]
async fn 启动中但无pid不得自愈() {
let m = AgentManager::new(Arc::new(|_| {}));
{
let mut inner = m.inner.lock().await;
inner.state = state::STARTING.to_string();
inner.pid = None;
inner.started_at = Some(now_ms().saturating_sub(9_000));
}
assert_eq!(
m.snapshot().await["state"],
json!(state::STARTING),
"没有 PID 说明没起来,不能伪装成 running"
);
}
#[tokio::test]
async fn 启动应捕获日志且状态为running() {
let dir = tmp_dir("start");
+49 -2
View File
@@ -30,8 +30,15 @@ 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_err(|e| format!("取当前程序路径失败:{e}"))
std::env::current_exe()
.map(crate::sidecar::unverbatim)
.map_err(|e| format!("取当前程序路径失败:{e}"))
}
/// macOS 上 LaunchAgent plist 的位置。
@@ -182,7 +189,16 @@ fn run_powershell(script: &str) -> Result<String, String> {
}
/// 自启是否已启用。
pub fn is_enabled() -> bool {
///
/// ## 为什么要缓存(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) {
@@ -196,6 +212,34 @@ pub fn is_enabled() -> bool {
}
}
/// 自启状态缓存:`(取值时刻 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();
@@ -246,6 +290,9 @@ pub fn set(enabled: bool) -> Result<Value, String> {
windows_schtasks(&exe, enabled)?
};
// 刚改过自启状态,缓存必然过期 —— 不清就会把 5 秒前的旧值回给界面
invalidate_cache();
let mut snap = status();
snap["detail"] = json!(detail);
Ok(snap)
+67 -5
View File
@@ -111,7 +111,20 @@ fn resolve_agent_paths(app: &AppHandle, state: &AppState) -> Result<AgentPaths,
async fn ensure_sidecar(app: &AppHandle, state: &AppState) -> Result<Arc<Sidecar>, String> {
let mut guard = state.sidecar.lock().await;
if let Some(sc) = guard.as_ref() {
return Ok(Arc::clone(sc));
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 + 仓库内脚本
@@ -301,6 +314,26 @@ 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(
@@ -349,11 +382,17 @@ fn autostart_set(enabled: bool) -> Result<Value, String> {
#[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 }),
45,
json!({
"console_version": state.app_version,
"timeout_ms": sidecar::UPDATE_CHECK_TIMEOUT_MS,
}),
sidecar::UPDATE_CHECK_RPC_TIMEOUT_SECS,
)
.await?;
@@ -397,8 +436,9 @@ async fn apply_update(
"url": url,
"sha256": sha256,
"dest": staged.to_string_lossy(),
"timeout_ms": sidecar::UPDATE_DOWNLOAD_TIMEOUT_MS,
}),
320,
sidecar::UPDATE_DOWNLOAD_RPC_TIMEOUT_SECS,
)
.await?;
@@ -583,7 +623,22 @@ fn build_tray(app: &AppHandle) -> tauri::Result<()> {
}
});
}
"quit" => app.exit(0),
// 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();
}
}
_ => {}
}
})
@@ -722,6 +777,12 @@ pub fn run() {
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", ());
}
}
})
@@ -736,6 +797,7 @@ pub fn run() {
agent_status,
agent_start,
agent_stop,
quit_app,
agent_restart,
agent_logs,
agent_logs_clear,
+434 -16
View File
@@ -19,12 +19,186 @@ use serde_json::{json, Value};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::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 {
@@ -46,6 +220,15 @@ pub struct Sidecar {
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 {
@@ -57,12 +240,13 @@ impl Sidecar {
script: &Path,
on_event: Arc<dyn Fn(SidecarEvent) + Send + Sync>,
) -> Result<Arc<Self>, String> {
let mut child = Command::new(node)
.arg(script)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true) // app 退出时不留孤儿进程
// 进程边界:先净化一次(用于报错信息与实际下发文本一致)。
// 真正「不得带 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()))?;
@@ -71,10 +255,12 @@ impl Sidecar {
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 {
@@ -124,6 +310,16 @@ impl Sidecar {
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()));
}
});
}
@@ -140,11 +336,34 @@ impl Sidecar {
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);
@@ -152,11 +371,20 @@ impl Sidecar {
let msg = json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params });
{
let mut stdin = self.stdin.lock().await;
stdin
.write_all(format!("{msg}\n").as_bytes())
.await
.map_err(|e| format!("写入 sidecar 失败:{e}"))?;
stdin.flush().await.map_err(|e| format!("flush sidecar 失败:{e}"))?;
// 写入/刷新失败 = 管道对面没人了 → 立刻置 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 {
@@ -177,6 +405,7 @@ impl Sidecar {
// 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;
}
@@ -190,14 +419,20 @@ impl Sidecar {
/// 解析 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(PathBuf::from(p));
return Ok(unverbatim(PathBuf::from(p)));
}
}
if let Some(dir) = resource_dir {
let bundled = dir.join(if cfg!(windows) { "node.exe" } else { "node" });
let bundled = unverbatim(dir.join(if cfg!(windows) { "node.exe" } else { "node" }));
if bundled.exists() {
return Ok(bundled);
}
@@ -227,14 +462,18 @@ fn which(cmd: &str) -> Option<PathBuf> {
///
/// 优先级:环境变量 `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(PathBuf::from(p));
return Ok(unverbatim(PathBuf::from(p)));
}
}
if let Some(dir) = resource_dir {
let bundled = dir.join("sidecar.mjs");
let bundled = unverbatim(dir.join("sidecar.mjs"));
if bundled.exists() {
return Ok(bundled);
}
@@ -268,6 +507,113 @@ mod tests {
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));
@@ -312,4 +658,76 @@ mod tests {
}
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 说明仍在干等超时"
);
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "nodeagent-console",
"version": "2.0.3",
"version": "2.1.0",
"identifier": "cn.jkooe.nodeagent-console",
"build": {
"beforeDevCommand": "pnpm dev",
+3
View File
@@ -2,6 +2,7 @@
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";
@@ -37,6 +38,8 @@ watchEffect(() => {
>
<n-message-provider>
<n-dialog-provider>
<!-- 必须在 dialog-provider 内部:它要用 useDialog() 弹退出确认 -->
<quit-guard />
<app-shell />
</n-dialog-provider>
</n-message-provider>
+10
View File
@@ -123,6 +123,16 @@ 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 });
+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>
+46 -8
View File
@@ -33,7 +33,10 @@ export const useAgentStore = defineStore("agent", () => {
const status = ref<AgentStatus | null>(null);
const sysInfo = ref<ConsoleSystemInfo | null>(null);
const update = ref<UpdateInfo | null>(null);
/** 启停/设置等毫秒级操作的全局忙标志(**不要**给网络类操作复用,见 checkUpdate 注释) */
const busy = ref(false);
/** 更新检查/下载专用忙标志 —— 与 `busy` 隔离,避免慢操作锁死整页按钮 */
const updateBusy = ref(false);
const error = ref<string | null>(null);
const bound = ref(false);
@@ -55,12 +58,21 @@ export const useAgentStore = defineStore("agent", () => {
error.value = null;
try {
const r = await fn();
await refresh();
return r;
} catch (e) {
error.value = e instanceof Error ? e.message : String(e);
return null;
} finally {
// ⚠️ 刷新必须放在 finally(2026-10-17 真机缺陷):
// 原来 `await refresh()` 写在 try 里,一旦动作**抛错**就被跳过,
// 界面状态会永远停在动作发生前的旧值。
//
// 真机症状:点「启动」→ 后端答「被控端已在运行」(说明它其实是 running)
// → 界面却一直显示「启动中」,「停止 / 重启」永久灰着,用户无从操作。
// 根因就是那次 start 抛了错,refresh 被跳过;而 `na-agent-state` 推送
// 在 WebView 就绪前发出,前端没收到 —— 两条更新通道同时失效。
// refresh() 自身已 try/catch,放这里不会掩盖原始错误。
await refresh();
busy.value = false;
}
}
@@ -110,9 +122,19 @@ export const useAgentStore = defineStore("agent", () => {
return run(() => api.settingsSet(patch));
}
/** 检查更新。 */
/**
* 检查更新。
*
* ⚠️ **刻意不用全局 `busy`**(2026-10-17 真机缺陷):
* `check_update` 要访问网络,境内可能一路等到超时(原 120s / RPC 150s)。
* 而 `busy` 是**全页共享**的(所有按钮 `:loading="busy"`),于是用户点一下
* 「检查更新」,整页按钮跟着转两分半 —— 看起来像「点什么都卡死」。
*
* 更新检查是**只读、可等待、与启停无关**的操作,本就不该锁住其他按钮。
* 故它自成一套 `updateBusy`;全局 `busy` 只服务毫秒级的启停与设置。
*/
async function checkUpdate() {
busy.value = true;
updateBusy.value = true;
error.value = null;
try {
update.value = await api.checkUpdate();
@@ -121,11 +143,16 @@ export const useAgentStore = defineStore("agent", () => {
error.value = e instanceof Error ? e.message : String(e);
return null;
} finally {
busy.value = false;
updateBusy.value = false;
}
}
/** 应用更新(仅更新本机被控端;控制台自身需重新下载安装包)。 */
/**
* 应用更新(仅更新本机被控端;控制台自身需重新下载安装包)。
*
* 同样走 `updateBusy` 而非全局 `busy`:下载更新的 RPC 超时是 330s,
* 若占用全局 busy,用户会看到「下载更新时整页按钮全部转圈五分钟」。
*/
async function applyUpdate(): Promise<ApplyUpdateResult | null> {
const u = update.value;
const url = u?.agent?.url;
@@ -134,9 +161,19 @@ export const useAgentStore = defineStore("agent", () => {
error.value = "最新版本信息不完整(缺少下载地址或哈希),无法更新";
return null;
}
const r = await run(() => api.applyUpdate(url, sha, u.latest_version));
if (r) await checkUpdate();
return r;
updateBusy.value = true;
error.value = null;
try {
const r = await api.applyUpdate(url, sha, u.latest_version);
await refresh();
if (r) await checkUpdate();
return r;
} catch (e) {
error.value = e instanceof Error ? e.message : String(e);
return null;
} finally {
updateBusy.value = false;
}
}
/** 一键连接本机被控端(用配置里的地址与密钥)。 */
@@ -152,6 +189,7 @@ export const useAgentStore = defineStore("agent", () => {
sysInfo,
update,
busy,
updateBusy,
error,
process,
state,
+3 -2
View File
@@ -14,11 +14,12 @@ export interface AgentEvent {
* 仅在被控端为 Windows 时可用(其余平台返回 E_UNSUPPORTED_PLATFORM)。
* 前端据此**事前门控**,而不是发出注定失败的请求再解释错误。
*/
export const WINDOWS_ONLY_CAPS = ["app.list", "app.install", "system.service.list"] as const;
export const WINDOWS_ONLY_CAPS = ["app.list", "app.install", "system.service.list", "system.audit.l2"] as const;
export const useConnStore = defineStore("conn", () => {
const state = ref<string>("closed");
const url = ref(localStorage.getItem("na.url") || "ws://127.0.0.1:8765");
// 默认 wss:被控端默认 tls=true(明文 ws 连 TLS 端口必然握手失败,2026-10-17 真机踩过)
const url = ref(localStorage.getItem("na.url") || "wss://127.0.0.1:8765");
const key = ref(localStorage.getItem("na.key") || "");
const info = ref<ConnectInfo | null>(null);
const logs = ref<string[]>([]);
+73
View File
@@ -109,6 +109,29 @@ export interface SystemInfo {
ps_helper?: PsHelperInfo;
network?: NetworkInfo;
build?: BuildInfo;
/** 最后已知可用版本 LKG(交付流水线 §6 回滚锚点) */
lkg?: LkgInfo;
}
/**
* 最后已知可用版本(Last-Known-Good)。
*
* 只在「本次启动真正起来了」之后才由被控端写入 ——
* 故 `matches=false` 意味着当前版本**尚未被证明能启动**(刚更新完、还没完成一次成功启动),
* 界面应据此提示「暂缓向其余设备放量」。
*/
export interface LkgInfo {
lkg?: {
hash?: string;
bytes?: number;
script_path?: string;
recorded_at?: number;
node_version?: string;
platform?: string;
};
current_hash?: string;
matches?: boolean;
byte_delta?: number;
}
/**
@@ -204,6 +227,8 @@ export type AuditType =
| "net.change"
| "net.deny"
| "agent.update"
// v2.1.0:L2 系统级审计事件(Windows Security 日志归一化副本)
| "agent.l2"
| "session.idle_close"
// v2.0.3:被拒请求统一留痕(U-01)+ 进程级兜底错误
| "request.rejected"
@@ -220,6 +245,10 @@ export interface AuditEntry {
error?: string | null;
/** 参数摘要(sha256 前缀)——默认只记摘要,不记内容 */
args_digest?: string;
/** v2.1.0(A-P0-4):参数序列化总字节数;与 args_truncated 同时出现表示摘要只覆盖前 64KB */
args_bytes?: number;
/** v2.1.0(A-P0-4):摘要是否为截断哈希(序列化超过 64KB 时只哈希前 64KB) */
args_truncated?: boolean;
/** 参数预览(仅 `log_args:true` 时记录,键含敏感词自动脱敏) */
args_preview?: string;
reason?: string;
@@ -251,6 +280,38 @@ export interface AuditVerifyResult {
broken_at?: AuditBrokenAt | null;
}
// ---------------------------------------------------------------------------
// L2 系统级审计(system.audit.l2,仅 Windows)—— 契约来源:manifest system.ts
// AuditL2 returns_schema(四种 action 的顶层属性并集;未出现的属性即本次不返回)
// ---------------------------------------------------------------------------
/** L2 策略状态 / 采集 / 启用 / 校验的返回(按 action 只出现对应字段)。 */
export interface AuditL2Result {
// status
platform?: string;
supported?: boolean;
auditpol?: string;
legacy_policy_disabled?: boolean;
cmdline_logging?: boolean;
sysmon_installed?: boolean;
file?: string;
file_exists?: boolean;
note?: string;
// enable
enabled?: boolean;
policies?: string[];
hint?: string;
// collect
collected?: number;
written?: number;
since?: number;
limit?: number;
// verify
ok?: boolean;
checked?: number;
broken_at?: AuditBrokenAt | null;
}
// ---------------------------------------------------------------------------
// 本机服务(本机被控端托管)—— 契约来源:src-tauri/src/agent.rs
// ---------------------------------------------------------------------------
@@ -272,6 +333,13 @@ export interface AgentProcess {
/** 实际运行的那份 agent.mjs(在数据目录里,不在 app 包内) */
script?: string;
data_dir?: string;
/**
* true = 复用了**非本控制台启动**的被控端(退出控制台时选择「保留」而留下的孤儿)。
*
* 此时它已在监听、可直接「连接本机」使用;但拿不到子进程句柄,
* 故「重启」会先按 PID 收掉再自己拉起(即把控制权收回本控制台)。
*/
external?: boolean;
}
/** 被控端配置(读自 `<数据目录>/agent.json`)。 */
@@ -355,6 +423,11 @@ export interface UpdateInfo {
agent: { url?: string | null; sha256?: string | null; bytes?: number | null };
release_url: string;
source: string;
/** 本次出网走的代理来源(`HTTPS_PROXY` / `system` / `NA_PROXY`…),直连为 null。
* 2026-10-13 加:境内「检查更新」失败的排查线索,必须能在界面上看到。 */
via_proxy?: string | null;
/** 人类可读的出网路径描述(「直连」/「经代理 http://…」)。 */
via?: string | null;
checked_at: number;
}
+160 -2
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { h, onMounted, ref } from "vue";
import { computed, h, onMounted, ref } from "vue";
import {
NCard,
NDataTable,
@@ -11,11 +11,14 @@ import {
NTag,
NEmpty,
NAlert,
NPopconfirm,
NDescriptions,
NDescriptionsItem,
useMessage,
type DataTableColumns,
} from "naive-ui";
import { useConnStore } from "../stores/conn";
import type { AuditEntry, AuditListResult, AuditVerifyResult } from "../types";
import type { AuditEntry, AuditListResult, AuditVerifyResult, AuditL2Result } from "../types";
import { formatTime } from "../utils/format";
const conn = useConnStore();
@@ -127,6 +130,80 @@ async function exportJsonl() {
}
}
// ---------- L2 系统级审计(system.audit.l2,仅 Windows)----------
/**
* L2 补的是 L1 结构上记不到的那块:攻击者绕过 agent 直接操作、或把 agent 停掉时,
* 上面的审计表全盲。L2 采集操作系统自己的审计事件(Windows Security 日志)归一化落盘。
*
* ⚠️ 信任边界:采集器与 agent 同权限,本地 audit-l2.log 只是归一化副本,
* 防篡改依赖把记录转发出机(界面文案已如实说明,不夸大)。
*/
const l2Loading = ref(false);
const l2Result = ref<AuditL2Result | null>(null);
const l2SinceHours = ref(24);
const l2Limit = ref(1000);
async function runL2(action: "status" | "enable" | "collect" | "verify") {
l2Loading.value = true;
try {
const args: Record<string, unknown> = { action };
if (action === "collect") {
args.since = Date.now() - l2SinceHours.value * 3600 * 1000;
args.limit = l2Limit.value;
}
l2Result.value = await conn.ok<AuditL2Result>("system.audit.l2", args);
} catch (e) {
// 平台不支持时错误信息里已带「仅支持 Windows」—— 原样透出,不包装
msg.error(`L2 ${action} 失败:${e}`);
l2Result.value = null;
} finally {
l2Loading.value = false;
}
}
/** 把 L2 返回(四 action 属性并集,只出现本次相关字段)摊成描述列表。 */
const l2Rows = computed(() => {
const r = l2Result.value;
if (!r) return [];
const rows: { label: string; text: string }[] = [];
const push = (label: string, v: string | number | boolean | undefined | null) => {
if (v === undefined || v === null) return;
rows.push({ label, text: typeof v === "boolean" ? (v ? "是" : "否") : String(v) });
};
// status
push("平台", r.platform);
push("受支持", r.supported);
push("禁用旧版策略", r.legacy_policy_disabled);
push("命令行记录", r.cmdline_logging);
push("已装 Sysmon", r.sysmon_installed);
push("副本文件", r.file);
push("副本存在", r.file_exists);
// enable
push("已启用", r.enabled);
push("已开启子类", r.policies?.join("、"));
push("提示", r.hint);
// collect
push("采集到", r.collected);
push("已落盘", r.written);
push("起始时间", r.since != null ? formatTime(r.since) : undefined);
push("条数上限", r.limit);
// verify
push("校验通过", r.ok);
push("已校验", r.checked);
if (r.broken_at) {
push(
"首个断点",
r.broken_at.file
? `${r.broken_at.file} 第 ${r.broken_at.line ?? "?"} 行${r.broken_at.reason ? `(${r.broken_at.reason})` : ""}`
: `第 ${r.broken_at.line ?? "?"} 行${r.broken_at.reason ? `(${r.broken_at.reason})` : ""}`,
);
}
// 说明类放最后
push("说明", r.note);
return rows;
});
onMounted(() => {
if (conn.connected) load();
});
@@ -177,6 +254,87 @@ onMounted(() => {
<n-empty :show-icon="false" v-if="!entries.length" description="暂无审计记录" size="small" />
<n-data-table v-else :columns="cols" :data="entries" :bordered="false" size="small" :max-height="560" />
</n-card>
<n-card size="small" style="margin-top: 12px">
<template #header>
<n-space align="center">
<span>L2 系统级审计</span>
<n-text depth="3" style="font-size: 12px">操作系统自己的审计事件(Windows Security 日志)</n-text>
<n-tag size="small" :type="conn.isWindows ? 'info' : 'warning'">
{{ conn.isWindows ? "当前被控端受支持" : "仅 Windows" }}
</n-tag>
</n-space>
</template>
<template #header-extra>
<n-space align="center">
<n-button size="small" :loading="l2Loading" :disabled="!conn.platformReady" @click="runL2('status')">
查状态
</n-button>
<n-button size="small" :loading="l2Loading" :disabled="!conn.platformReady" @click="runL2('collect')">
拉取采集
</n-button>
<n-button size="small" :loading="l2Loading" :disabled="!conn.platformReady" @click="runL2('verify')">
校验 L2 链
</n-button>
<n-popconfirm :show-icon="false" @positive-click="runL2('enable')">
<template #trigger>
<n-button size="small" type="warning" :loading="l2Loading" :disabled="!conn.platformReady">
启用策略
</n-button>
</template>
将开启 Windows 高级审计策略(进程创建 4688 / 登录 4624/4625 / 进程终止 4689)并记录命令行。
需要<strong>管理员权限</strong>,未提权会被拒。确定?
</n-popconfirm>
</n-space>
</template>
<n-alert v-if="conn.nodeOs && !conn.isWindows" type="warning" style="margin-bottom: 12px">
被控端当前为 {{ conn.nodeOs }} —— L2 采集仅落地 Windows(Linux auditd / macOS ESF 未实现,原因见
docs/AUDIT-L2.md)。按钮已禁用,避免点了才报错。
</n-alert>
<n-alert v-else type="default" style="margin-bottom: 12px">
L1 只记录经过 agent 的调用;攻击者绕过 agent、或把 agent 停掉时 L1 全盲,L2 补的正是这块。
本地副本防篡改价值有限,真正的独立性依赖把记录<strong>转发出机</strong>。
</n-alert>
<!-- 采集参数始终可见:早前它在 `v-if="l2Rows.length"` 里面,
必须先出一次结果才出现输入框 —— 用户想「先设窗口 72h 再采集」做不到,
只能先点一次默认参数再改。参数与结果的生命周期无关,故拆开。 -->
<n-space align="center" style="margin-bottom: 8px">
<n-text depth="3" style="font-size: 12px">采集窗口(小时)</n-text>
<n-input-number
v-model:value="l2SinceHours"
:min="1"
:max="720"
:step="1"
size="small"
style="width: 110px"
/>
<n-text depth="3" style="font-size: 12px">条数上限</n-text>
<n-input-number
v-model:value="l2Limit"
:min="1"
:max="5000"
:step="100"
size="small"
style="width: 120px"
/>
</n-space>
<n-space v-if="l2Rows.length" vertical size="small">
<n-descriptions :column="2" size="small" label-placement="left" bordered>
<n-descriptions-item v-for="row in l2Rows" :key="row.label" :label="row.label">
{{ row.text }}
</n-descriptions-item>
</n-descriptions>
</n-space>
<n-empty
v-else-if="!l2Loading"
:show-icon="false"
description="点「查状态」查看被控端审计策略是否已启用"
size="small"
/>
</n-card>
</div>
</template>
+32 -4
View File
@@ -37,6 +37,8 @@ const copied = ref<string | null>(null);
const cfg = computed(() => agent.status?.config ?? null);
const proc = computed(() => agent.process);
const busy = computed(() => agent.busy);
/** 更新检查/下载独立忙标志 —— 网络类操作不该锁住启停按钮(见 stores/agent.ts 注释) */
const updateBusy = computed(() => agent.updateBusy);
/** 最近 6 行日志(完整日志在「服务日志」页)。 */
const recent = computed(() => logs.lines.slice(-6));
@@ -72,16 +74,27 @@ async function doSetting(key: "auto_restart" | "close_to_tray" | "auto_start_age
async function doCheckUpdate() {
const u = await agent.checkUpdate();
if (agent.error) msg.error(`检查更新失败:${agent.error}`);
if (agent.error) msg.error(prefixOnce(agent.error, "检查更新失败:"));
else if (u) msg.success(u.has_update ? `有新版本 v${u.latest_version}` : "已是最新版本");
}
async function doApplyUpdate() {
const r = await agent.applyUpdate();
if (agent.error) msg.error(`更新失败:${agent.error}`);
if (agent.error) msg.error(prefixOnce(agent.error, "更新失败:"));
else if (r) msg.success(`已更新到 v${r.version}(${r.restarted ? "并已重启" : "未自动启动"})`);
}
/**
* 错误信息里若已带同一前缀就不再叠加。
*
* ⚠️ 2026-10-13:sidecar 侧把失败原因写成「检查更新失败:<真实原因>(经代理 …, 耗时 …)」,
* 而这里原本又拼一层 → 用户看到「检查更新失败:检查更新失败:请求超时…」。
* 但 `ensure_sidecar` 之类来自 Rust 壳的错误**没有**前缀,所以前缀本身要保留,只是不重复。
*/
function prefixOnce(text: string, prefix: string) {
return text.startsWith(prefix) ? text : prefix + text;
}
async function doConnectLocal() {
try {
await agent.connectLocal();
@@ -93,6 +106,11 @@ async function doConnectLocal() {
onMounted(async () => {
await agent.bind();
// `bind()` 内部有 `bound` 守卫:第二次进入本页会直接 return,**不会刷新**。
// 而 `na-agent-state` 推送可能在前端监听就绪前就发出(WebView 尚未 ready),
// 于是状态会一直停在旧值(真机症状:后端已是 running,界面永远「启动中」)。
// 故每次进入本页都显式拉一次 —— 这是最便宜的状态自愈。
await agent.refresh();
await logs.bind();
});
@@ -138,6 +156,11 @@ function openRelease() {
<n-text v-if="proc?.restarts" depth="3" style="font-size: 12px">
· 自动重启 {{ proc.restarts }} 次
</n-text>
<!-- 复用来的实例(退出控制台时选择「保留」留下的孤儿):可直接用,
但控制权不在本控制台 —— 点「重启」即收归托管 -->
<n-tag v-if="proc?.external" type="warning" size="small" round>
已复用外部实例
</n-tag>
</n-space>
<n-space style="margin-bottom: 14px">
@@ -290,15 +313,20 @@ function openRelease() {
<div v-if="agent.update?.published_at" class="kv">
<span>发布时间</span><b>{{ fmtTime(Date.parse(agent.update.published_at)) }}</b>
</div>
<!-- 出网路径:境内「检查更新」失败时最关键的一条排查线索(直连 / 哪个代理) -->
<div v-if="agent.update?.via" class="kv">
<span>出网</span>
<b class="mono ellipsis">{{ agent.update.via }}</b>
</div>
<n-space style="margin-top: 12px">
<n-button size="small" :loading="busy" @click="doCheckUpdate">检查更新</n-button>
<n-button size="small" :loading="updateBusy" @click="doCheckUpdate">检查更新</n-button>
<n-popconfirm :show-icon="false" @positive-click="doApplyUpdate">
<template #trigger>
<n-button
size="small"
type="primary"
:disabled="!agent.update?.agent_has_update || busy"
:disabled="!agent.update?.agent_has_update || updateBusy"
>
更新被控端
</n-button>
+2 -2
View File
@@ -22,7 +22,7 @@ const nodeStore = useNodeStore();
const msg = useMessage();
const newName = ref("");
const newUrl = ref("ws://127.0.0.1:8765");
const newUrl = ref("wss://127.0.0.1:8765");
const newKey = ref("");
async function doConnect() {
@@ -73,7 +73,7 @@ const capGroups = computed(() => {
<n-card title="连接被控端" size="small">
<n-space vertical>
<n-input-group>
<n-input v-model:value="conn.url" placeholder="ws://127.0.0.1:8765" style="flex: 3" />
<n-input v-model:value="conn.url" placeholder="wss://127.0.0.1:8765" style="flex: 3" />
<n-input
v-model:value="conn.key"
type="password"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/hub",
"version": "2.0.3",
"version": "2.1.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/mcp",
"version": "2.0.3",
"version": "2.1.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
@@ -14,7 +14,7 @@
"start": "node dist/index.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.0",
"@modelcontextprotocol/sdk": "^1.32.1",
"@nodeagent/client": "workspace:*",
"@nodeagent/protocol": "workspace:*"
}
+1
View File
@@ -147,6 +147,7 @@ const TOOL_TO_CAPABILITY: Record<string, string> = {
na_service_list: CapabilityNames.ServiceList,
na_screenshot: CapabilityNames.ScreenCapture,
na_audit: CapabilityNames.AuditList,
na_audit_l2: CapabilityNames.AuditL2,
na_fs_list: CapabilityNames.FsList,
na_fs_read: CapabilityNames.FsRead,
na_fs_write: CapabilityNames.FsWrite,
+21
View File
@@ -165,6 +165,27 @@ export const TOOLS = [
additionalProperties: false,
},
},
{
name: 'na_audit_l2',
description:
'L2 系统级审计兜底(仅 Windows):采集操作系统自己的审计事件(Windows Security 日志),补 na_audit 结构上记不到的那块 —— ' +
'攻击者绕过 agent 直接操作、或把 agent 停掉时 na_audit 全盲。' +
'action: status=查策略是否启用 / enable=开启(需管理员)/ collect=拉取并归一化落盘 / verify=校验 L2 链。' +
'⚠️ 本地副本防篡改价值有限,真正的独立性依赖把记录转发出机。',
inputSchema: {
type: 'object',
properties: {
action: {
type: 'string',
enum: ['status', 'enable', 'collect', 'verify'],
description: 'status(默认)/ enable / collect / verify',
},
since: { type: 'integer', description: 'collect 的起始时间(Unix ms),默认 24 小时内' },
limit: { type: 'integer', minimum: 1, maximum: 5000, description: 'collect 条数上限,默认 1000' },
},
additionalProperties: false,
},
},
{
name: 'na_discover',
description:
@@ -0,0 +1,570 @@
# ARCH-2026-10-25:nodeagent「全面完善」—— 代码/架构审查 + 增量设计 + 任务分解
> **文档性质**:阶段 2 产出(架构师高见远)。基线 `3680218`(v2.0.3)。
> 范围权威定义:`docs/PRD-2026-10-25-comprehensive-improvement.md`(需求方已批准:P0+P1 全做、P2 推后、版本 v2.1.0、selfupdate 出网走方案 A、L2 同期限 MCP+控制台)。
> 工程铁律来源:项目记忆(契约三段 / 错误规范 / 边界 / Windows 陷阱 / 守卫铁律 / LKG 时机 / 审计哈希口径 / 建文件禁 git add -A / 播种兼容 / git push 代理)。
> **所有结论均有 `文件:行号` 证据**;凡未找到证据的一律进「待确认」不写成事实。
---
## Part 0:审查方法与基线事实
### 0.1 审查范围(实读文件)
| 区域 | 文件 |
|---|---|
| 被控端 | `apps/agent/src/index.ts`、`server.ts`、`audit.ts`、`lkg.ts`、`config.ts`、`events.ts`、`discovery.ts`、`hub-mode.ts`、`capabilities/{index,system,selfupdate,audit-l2,fs,task,monitor,input,window,screen,record,clipboard,app,network,audio,log,await}.ts`、`util/{exec,ps-helper,quote,dpi,platform}.ts` |
| 协议 | `packages/protocol/src/{validate,acl,errors,messages,methods,net,ed25519,hmac,hotkeys,ulid,audit-types,audit-anchor}.ts`、`capabilities/{names,schemas}.ts`、`capabilities/manifest/*.ts` |
| 控制端 | `apps/cli/src/{index,options,core,help,types}.ts`、`cmd/*.ts`;`apps/mcp/src/index.ts`、`tools/defs.ts`;`apps/console/src/**`、`src-tauri/src/agent.rs`(节选);`sidecar/{main,proxy}.mjs` |
| 脚本/测试 | `scripts/lib/version-write.mjs`、`scripts/uninstall.ps1`、`install.ps1`(节选);`tests/unit/*`(43 个)、`tests/e2e/{run,windows}.mjs`(节选) |
### 0.2 基线实测(复扫确认,PRD §1.1 数据属实)
| 项 | 值 | 取证 |
|---|---|---|
| 能力数 | 52 | manifest 实点(51 + `system.audit.l2`,`names.ts:53`) |
| MCP 工具 | 41 | `apps/mcp/src/tools/defs.ts` 实点(`mcp-tools.test.mjs:25` 断言 41) |
| 单测 | 357 | PRD 实跑记载(本轮未重跑,以 PRD 为基线) |
| e2e | 33 + Windows 13 | `tests/e2e/run.mjs` + `windows.mjs` |
| L2 在 MCP/控制台 | 0 命中 | `apps/mcp/src grep l2` = 0;`apps/console/src grep l2` = 0 |
| CLI rollback | 不存在 | `apps/cli/src grep rollback` 仅 net.ts 类型注释;`index.ts` 无 case |
| L2 returns_schema | 空壳 | `manifest/system.ts:321` `returns_schema: { type: 'object' }` |
| selfupdate 裸 fetch | 存在 | `selfupdate.ts:42` |
---
## Part A:代码/架构审查报告(问题清单)
> 严重度定义:**P0 = 安全/正确性缺陷**(含 fail-open 守卫缺陷、可无线利用的崩溃/误伤);**P1 = 重要缺口**(功能缺口、观测污染、性能热点、可维护性硬伤);**P2 = 优化**。
> 「新」= PRD 需求池之外本轮新发现。
### A.1 P0(4 条新发现 + 2 条对 PRD 已知项的补充证据)
#### A-P0-1(新)参数校验的 `additionalProperties:false` 可被原型键绕过 —— fail-open 方向
- **证据**:`packages/protocol/src/validate.ts:69` `if (!(key in schema.properties))`。
- **机理**:`in` 运算符沿原型链查找。`schema.properties` 是普通对象字面量,继承 `Object.prototype`。调用方传入 `{"constructor": 1}`、`{"toString": 1}`、`{"valueOf": 1}` 等键时,`key in properties` 为 `true` → **不判 additional property** → 校验放行。
- **触发条件**:任何已认证调用方对**任何**能力传上述键(manifest 大量能力声明了 `additionalProperties: false`,如 `manifest/system.ts:24`)。
- **现状危害评估**:当前各 handler 均按名读取已知键,多余键被忽略,**暂无直接可利用路径**;但这是「fail-open 方向的守卫缺陷」—— 与本仓刚修过的 B-03 是**同一错误模式在不同文件复发**:`server.ts:228` 已用 `Object.prototype.hasOwnProperty.call(cfg.keys, clientId)` 修过「`client_id:"constructor"` 沿原型链取到函数当密钥打死进程」。
- **修复方向**:改为 `Object.prototype.hasOwnProperty.call(schema.properties, key)`;补单测「传 `constructor` 键必须被判 additional property」(守卫要会红:注入故障 = 改回 `in`,测试必须失败)。
#### A-P0-2(新)限速桶 key 用「限速值」而非「能力名」—— 同上限值的能力互相挤占;桶只增不删
- **证据**:`apps/agent/src/server.ts:364` `allowByRate(state.clientId ?? '', rateLimit, String(rateLimit ?? ''))` —— 第三参 `bucket` 传的是 `String(rateLimit)`(限速值本身);`server.ts:101-113` 桶内只过滤当次 60s 窗口,**key 永久留存**(`rateBuckets` Map 无任何清扫)。
- **机理一(正确性)**:ACL 支持按能力限速(`rateLimitFor(clientRule, name)`,精确 > glob > 全局)。当某 client 配了「screen.capture ≤ 5/min、app.install ≤ 5/min」两条同值规则时,两者 key 都是 `clientId|5` → **共用一个桶**:截图 5 次后装软件也被限速(误伤),且两者总量仍按 5/min(漏过)。
- **机理二(可用性/内存)**:psk 模式下 `client_id` 由客户端自报(`server.ts:295` 注释自陈「配置级约束」)→ 外部可枚举任意 client_id 发起调用 → `rateBuckets` 键无界增长(每个键一个数组,长期运行即内存泄漏);且没有限速配置时 `rateLimit` 为 undefined → `String(undefined)` = `"undefined"` → 所有无差别调用共用一个 key(此时 `allowByRate` 第一行 `if (!limitPerMin) return true` 直接放行,不写桶,故只在前一种情况受害)。
- **修复方向**:key 改 `${clientId}|${name}`(能力名);加桶清扫(每次 `allowByRate` 顺手删过期键,或 5 分钟定时);给桶总数设上限(如 10_000,超出淘汰最旧)。单测:同值两能力互不干扰;过期键被清。
#### A-P0-3(新,PRD P0-2 的前提缺口)LKG 只有指纹没有「内容指针」,且 selfupdate 备份永不清理
- **证据**:`apps/agent/src/lkg.ts:34-44` `LkgRecord` 只有 `hash`(12 位)/`bytes`/`script_path`/`recorded_at`/`node_version`/`platform` —— **没有任何字段指向「这个版本的内容在哪」**。而 `docs/DELIVERY-PIPELINE.md:233` 承诺「`nodeagent rollback` 读 LKG → 覆盖回入口 → 重启」却从未定义内容来源。
- **备份现状(两套命名、一个不清理)**:
- 推送式 deploy:固定名 `<target>.bak`,每次部署**覆盖**(`apps/cli/src/cmd/ops.ts:279`,注释记载「真机堆了 15+ 个 bak」后改覆盖);
- 拉取式 selfupdate:`<script>.bak-<ts>`(`apps/agent/src/capabilities/selfupdate.ts:162`),**永不清理** —— `ops.ts:336-351` 的清理命令只针对 `${target}.bak-*` 且由 CLI 在部署时触发;selfupdate 路径自己不清,也没有任何其他地方清。
- 后果:多次自更新后目录里堆 N 个 ~1MB 备份(deploy 踩过的坑在 selfupdate 路径复发),且**回滚时无法自动判断哪个备份是 LKG**。
- **修复方向**:见 B-P0-2(rollback 用「枚举备份 + 指纹匹配」定位内容;LkgRecord 增加 `hash_full` 与 `backup_glob` 只增字段;selfupdate 补「保留最近 3 个」清理)。
#### A-P0-4(新)`describeArgs` 对大参数同步序列化,阻塞被控端事件循环
- **证据**:`apps/agent/src/audit.ts:172-177` `JSON.stringify(args ?? {})` 每次 invoke 必做(`server.ts:414` 调用);`fs.write` 的 `data` 可达数 MB(协议单帧,ws 默认 maxPayload 隐式放行)。
- **机理**:同步 `JSON.stringify` + sha256 一个 8MB 字符串 ≈ 数十毫秒级 CPU 占用,且**阻塞事件循环** —— 期间 GUI 注入(`input.*`)、事件推送、保活 ping 全部延迟。输入控制类能力对延迟敏感(README 自陈助手预热就是为了省 1-3s)。
- **触发条件**:任何带大 payload 的调用(`fs.write` 大文件、`clip.set` 图片、`key.type` 长文本)。
- **修复方向**:超过阈值(建议 64KB)只对前 64KB 求摘要并记录总长度(`args_digest` 语义从「全量哈希」改为「截断哈希」需在审计文档注明口径——**只影响审计摘要,不影响安全属性**)。单测:大参数不抛、摘要稳定、耗时阈值不设内容判据(守卫铁律:禁时长阈值当判据 → 用「只序列化前 N 字节」的结构断言)。
#### A-P0-5(PRD P0-1 补充证据)selfupdate 的失败信息零诊断,与 sidecar 已验证方案差距明确
- **证据**:`selfupdate.ts:71-77` catch 只包 `url`;无 ECONNRESET/ENOTFOUND/ABORT 分类。对照 `apps/console/sidecar/main.mjs:146-165` 已有 `isAbortLike`/`describeNetError` 成熟实现(注释记载第一版把 ECONNRESET 误报成「超时」的真机教训)。
- **触发条件**:境内/代理环境 `fetch failed`(Node fetch 不读代理环境变量,`sidecar/proxy.mjs:5-12` 有真机取证)。
- 本体修复见 B-P0-1;此条登记为「错误分类必须同款移植,不许重新发明」。
#### A-P0-6(PRD P0-2 补充证据)rollback 的能力圈边界:「完全起不来」时 RPC 通道本身不存在
- **证据**:`index.ts:258-261` 启动失败 `process.exit(1)`;`lkg.ts:22-28` 明确「能启动 = 至少不是打死状态」,反证「打死的版本」根本不会运行 → 没有监听 → CLI 无法 invoke。
- **机理**:`nodeagent rollback` 本质是 RPC 命令,**要求被控端可达**。它覆盖的场景是「新版本能启动但需要回退(行为异常/主动避险)」;「新版本进程完全起不来」只能走 `docs/DELIVERY-PIPELINE.md` 附录 C 的物理/带外恢复(备份文件 + LKG 文件手工还原)。
- **修复方向**:不是代码修复,是**契约澄清** —— rollback 的帮助文本/README/CHANGELOG 必须写明这一边界,不得暗示「失联也能一条命令救回」。是否要做「启动失败 N 次自动恢复 LKG」的看门狗,与 DELIVERY-PIPELINE §6「刻意不做自动回滚」冲突 → 列为待决策点 R-1。
### A.2 P1(5 条新发现)
#### A-P1-1(新)L2 的 collect/enable 把审计写成了 `agent.start` —— 事件类型与事实不符,污染生命周期查询与指标
- **证据**:`apps/agent/src/capabilities/audit-l2.ts:207`(collect)与 `:286`(enable)均 `audit({ type: 'agent.start', status: 'ok', reason: 'l2.…' })`。
- **机理**:`agent.start` 语义是「agent 进程启动」(`index.ts:166` 写入)。L2 动作混入后:`nodeagent audit --type agent`、控制台审计页筛「agent 生命周期」、`computeMetrics` 按 type 的统计都会把「采集了一次 L2」当成「agent 启动过一次」——**观测污染**,且不报错(这正是 C-01 同类漂移)。
- **修复方向**:`packages/protocol/src/audit-types.ts`(唯一真源)新增 `agent.l2` → `apps/agent/src/audit.ts:13-46` 联合类型同步(编译期 AssertExact 自动守卫)→ `apps/console/src/types/index.ts:218-233` 前端联合同步 → `audit-filter.test.mjs` 正反守卫自动覆盖。三处同步是契约三段铁律的落地。
#### A-P1-2(新)monitor 的 tick 无重入保护 —— 慢采样源会并发堆积
- **证据**:`apps/agent/src/capabilities/monitor.ts:235` `timer: setInterval(() => { void tick(rec); }, intervalMs)` —— 前一个 `tick`(内含 `await takeSample`,command 源要 exec 一整条命令)未返回时,下一个 interval 照常触发。
- **触发条件**:`source=command` + 命令耗时 > interval(interval 下限 500ms,`monitor.ts:210`),或多监控并发。
- **后果**:并发 `execCommand` 数量无上限(进程风暴);样本文件 append 交错(`appendFileSync` 同步不会写坏行,但样本时间序失真)。
- **修复方向**:`tick` 加 in-flight 守卫(跳过本轮并记 `skipped` 观测字段);`takeSample` 对 command 源显式给超时(与 `system.shell.exec` 口径一致)。
#### A-P1-3(新)事件 watcher 的 exec 失败全静默 —— 「没有事件」与「采不到」不可区分
- **证据**:`apps/agent/src/events.ts:128`(process watch)与 `:163`(net watch)均 `if (err) return;`。
- **触发条件**:`tasklist`/`ps`/`netstat` 不存在、被杀软拦、超时(exec 无 timeout 选项——用的是 `node:child_process` 的 `exec`,`maxBuffer: 4MB`,**无超时**)。
- **后果**:用户订阅了进程启停,实际采了 0 个样本,界面显示「0 事件」—— 与「真没有进程启停」完全同形。
- **修复方向**:失败时 `push(rec, 'watch_error', …)` 推一条(file watch 已有 `watch_error` 先例,`events.ts:113-115`);给 exec 加 `timeout`。
#### A-P1-4(新)`readAudit` 的 `total` 与 manifest 描述不符 —— 跨轮转文件时偏小
- **证据**:`apps/agent/src/audit.ts:346-372`:`total` 只累加「已读文件」的条数,而 `:368` 凑够 `limit` 即 `break`,后续轮转文件不再读;manifest 描述是「本次读取的总条数(未截断前)」(`packages/protocol/src/capabilities/manifest/system.ts:234`)。
- **后果**:控制台「共 N 条」在记录量大时偏小(展示偏差,不影响安全与功能正确性)。
- **修复方向**(二选一,建议前者):① 把 total 改为「扫描到的候选文件全量条数」(提前退出只影响 entries 不影响 total——需要把 break 后的 total 补算改为近似值并注明);② 改 manifest 描述为「已扫描文件的总条数」。属契约描述与实现对齐,登记 P1 偏轻。
#### A-P1-5(PRD P0-1 设计输入)被控端无任何出网模块,方案 A 是「新设一个被控端子系统」
- **证据**:`apps/agent/src` 无 net/ 目录;`grep httpGet|resolveProxy apps/agent/src` = 0。sidecar 的方案(`apps/console/sidecar/proxy.mjs`,524 行,含 16 用例守卫 `tests/unit/sidecar-proxy.test.mjs`)是 .mjs + JSDoc;被控端是 TS + esbuild 单文件打包。
- **关注点**:① 移植必须保语法但改类型;② 被控端由计划任务/服务拉起时**环境变量不可见**(Windows 计划任务默认不继承用户 shell 的 HTTPS_PROXY)→ 系统代理探测(reg query)才是主力路径,文档必须写;③ agent.mjs 单文件打包,新增模块不要引外部依赖(铁律:零原生编译、被控端零依赖部署)。
- 设计见 B-P0-1。
### A.3 P2(登记,本轮不修)
| # | 项 | 证据 | 说明 |
|---|---|---|---|
| A-P2-1 | `task.ts:117` 死分支 `rec.state = code === 0 ? 'done' : 'done'` | 实读 | 意图应为非零也 done(exit_code 区分)或 failed;现状无害但坏味道 |
| A-P2-2 | `validate.ts:38` 每次调用 `new RegExp(schema.pattern)` | 实读 | 热路径重复编译;可缓存(pattern 来自 manifest,进程内不变) |
| A-P2-3 | ws `maxPayload` 未显式声明 | `server.ts:637` `new WebSocketServer({ server })` | 依赖 ws 默认值(100MiB)——隐式契约;建议显式 16MiB 并在 manifest fs.write 注明 |
| A-P2-4 | `readAudit total` 语义 | 见 A-P1-4 | 若选方案②则降为 P2 |
| A-P2-5 | 大文件拆分 | PRD P2-2 已登记(`mcp/src/index.ts` 530 行、`window.ts` 665 行) | 清单驱动小步拆,纯移动 |
| A-P2-6 | 协议 fuzz / TLS 夹具 / 覆盖率 / 分支保护 / Linux-macOS L2 | PRD P2-3~P2-7 | 全部推后 |
### A.4 未发现新违律的点(留档,避免下一轮重复审)
- Windows 陷阱铁律:`exec.ts:44-69` 数组传参不拼 shell 字符串;`ps-helper.ts:240` `windowsHide: true`;`selfupdate.ts:170-172` 就地覆写不 rename(注释有真机记录);`version-write.mjs` 的 CRLF 纪律完整。
- 守卫铁律:`lkg.test.mjs`、`mcp-tools.test.mjs`(sha 序列)、`console-commands.test.mjs`(invoke⇄handler 双向)、`cli-options.test.mjs`(L-01 总闸)均为「会红」的断言式守卫。
- 错误规范:`server.ts:434-458` 协议级错误 throw / 能力内部 CapabilityError → `{status:'failed'}` 信封,分层正确。
- 审计哈希口径:`audit.ts:107-111` 与 `audit-l2.ts:67-71` 均为「对含 prev 的记录求哈希」,同构。
---
## Part B:增量设计方案(P0/P1 逐项,文件级)
### B-P0-1 被控端自更新走代理出网(方案 A)
#### 模块边界与依赖方向
```
apps/agent/src/net/proxy.ts (新增,~450 行 TS;只依赖 node 内置模块)
▲
│ 单向依赖(selfupdate → proxy)
apps/agent/src/capabilities/selfupdate.ts (改 download())
```
- `net/proxy.ts` **不 import 任何项目内模块**(不碰 config/audit/capabilities)—— 保证可被单测独立加载、可被将来其它出网能力复用;依赖方向严格单向,杜绝环。
- `sidecar/proxy.mjs` → TS 移植映射表:
| proxy.mjs 导出 | proxy.ts 导出(签名) |
|---|---|
| `normalizeProxyUrl(raw)` | `normalizeProxyUrl(raw: unknown): URL \| null` |
| `isBypassed(targetUrl, noProxy)` | 同 |
| `pickEnvProxy(env, targetUrl)` | `pickEnvProxy(env: Record<string,string\|undefined>, targetUrl: string): {url,source,notes}` |
| `parseScutilProxy(text)` / `parseRegQueryProxy(text)` | 同 |
| `detectSystemProxy(platform, run)` | `detectSystemProxy(platform = process.platform, run = runQuiet)`(`run` 可注入假 runner) |
| `resolveProxy({targetUrl, env, system})` | 同 |
| `httpGet(url, {proxy, timeoutMs, maxRedirects})` | 同,返回 `{status,statusText,headers,body,url}` |
| `tunnelConnection(proxy, target, signal)` | 同(CONNECT 隧道 + 自管 abort,保留 proxy.mjs:359-364 的真机教训注释) |
#### `selfupdate.ts` 改动
```ts
// 改:download(url, timeoutMs)
async function download(url: string, timeoutMs: number): Promise<{ buf: Buffer; via: OutboundInfo }> {
const out = await resolveProxy({ targetUrl: url }); // NA_PROXY → env → 系统代理;PAC/SOCKS 记 note 不静默
const res = await httpGet(url, { proxy: out.proxy, timeoutMs });
// 保留:!res.ok → CapabilityError(EXECUTION_FAILED);content-length 预检;流式上限
// 错误分类同款移植 sidecar/main.mjs:146-165(isAbortLike / describeNetError)
}
```
- **超时/取消语义**:`timeoutMs`(现有参数,默认 120s,范围 5s~600s,`selfupdate.ts:85`)作为**整条重定向链的总预算**下发给 `httpGet`(与 sidecar 一致:链共享一个 AbortController,防 5 跳 × 30s 穿过 RPC 超时)。能力一旦启动无外部取消通道(协议无取消方法),超时即断由 AbortController 保证。
- **返回结构(只增)**:`updated/dry_run/bytes/previous/current_hash/incoming_hash/backup_path/restarted` 之外新增 `via`、`proxy`、`proxy_source`、`outbound_notes`;manifest `AgentUpdate.returns_schema`(`manifest/system.ts:423-435`)同步补这四个属性(契约三段:实现 → manifest → 前端类型;console 的 `UpdateInfo` 已有同名字段可对齐)。
- **失败信封**:`CapabilityError.data` 带 `{url, via, proxy, proxy_source, timeout_ms, notes, hint}`——「代理没配 / 哈希不符 / 超时」三类可分(PRD G1 的可诊断验收)。
- **安全属性不变**(逐条对照):仍只 http/https(`selfupdate.ts:100-102` 保留);sha256 仍必填且逐字节校验(`:116`);先校验后落盘(`:126` 之前不写目标);写后校验 + 不一致回滚(`:183-195`);TLS 证书默认校验(`tls.connect` 不传 `rejectUnauthorized:false`,与 proxy.mjs:39 纪律一致)。
- **备份清理(配套 A-P0-3)**:替换成功后,扫入口同目录 `basename(script).bak-*`,按 mtime 保留**最近 3 个**,其余删(删除失败忽略——被杀软占位的 .bak 删不掉不影响正确性)。抽成纯函数 `planBackupCleanup(files: {name,mtime_ms}[], keep=3): string[]` 供单测。
- **agent.json 不加配置项**(NA_PROXY 环境变量 + 系统探测已覆盖;YAGNI)。Windows 计划任务拉起时环境变量不可见的问题写进 DEVELOPMENT.md(系统代理是主力路径)。
#### 单测(新增 `tests/unit/agent-proxy.test.mjs`,≥16 用例,口径对标 sidecar-proxy.test.mjs)
1. `NA_PROXY` 优先于一切;2. env 顺序(https: 列表逐键命中);3. `HTTPS_PROXY` 大小写/裸主机补 `http://`;4. `NO_PROXY` 精确/后缀/`host:port`/`*`;5. socks5 记 note 且 `proxy=null`(不静默直连);6. scutil 解析(HTTPSEnable 优先 + ExceptionsList);7. reg 解析(ProxyEnable=0 → 无代理;分协议形态 https 优先);8. PAC → note;9. `<local>` 剔除;10. CIDR 条目不猜(note);11. `resolveProxy` 系统 bypass 命中直连;12. `httpGet` 重定向链共享预算(假 server 验证 abort 一次断全链);13. CONNECT 非 200 → 明确错误;14. http 目标经代理走绝对形态(假代理服务器断言请求行);15. `planBackupCleanup` 保留最近 3;16. 下载上限中断(content-length 超限 + 流式超限两条)。
#### 兼容性影响
- L0 被控端变更 → 必须发版(v2.1.0 MINOR 成立:新增返回字段 + 行为修复,无破坏)。
- 旧控制端调 `system.agent.update`:不认识新返回字段,忽略即可(VERSIONING §3.3「返回结构只加不减」)。
- 控制台会把被控端播种到同版本(`seed_agent_script`)——v2.1.0 发布时被控端随之播种,自更新链路在发布后即带代理能力;**发布顺序**:先发 v2.1.0(agent 含代理)再让设备自更新,天然兼容(旧版 agent 的 update 能力参数不变)。
### B-P0-2 `nodeagent rollback`(LKG 回滚入口)
#### 语义定义(含能力圈边界,见 A-P0-6)
- 目标版本:**LKG**(`system.info.lkg.lkg.hash`)。`--backup <远端路径>` 为高级逃生门(当前已是 LKG 但需回退到更早备份时用)。
- 前提:被控端**可达**(RPC 命令)。进程完全起不来的场景走 DELIVERY-PIPELINE 附录 C(物理/带外),帮助文本明示。
- **不提供自动回滚**(维持 DELIVERY-PIPELINE §6 取舍,本项只补手动入口)。
#### 数据结构(`apps/agent/src/lkg.ts` 只增两字段)
```ts
export interface LkgRecord {
hash: string; // 前 12 位(既有,展示/比对口径不变)
hash_full?: string; // 新增:sha256 全量 64 位 —— 备份指纹匹配用全值,消除 12 位前缀碰撞的理论风险
bytes: number;
script_path: string;
backup_glob?: string; // 新增:回滚内容候选的文件名模式(`<basename>.bak*`),供控制端枚举
recorded_at: number;
node_version: string;
platform: string;
}
```
`recordLKG()` 写全量哈希;`readLKG()` 对老文件(无 `hash_full`)回落 12 位比对(向后兼容,不猜)。
#### 指纹匹配算法(CLI 侧,只用既有能力,零协议变更)
1. `fs.list` 入口所在目录 → 过滤 `^<basename>\.bak(-.*)?$`(deploy 固定名 + selfupdate 时间戳名)→ 按 `mtime` 新→旧;
2. 对每个候选:`fs.read` 分块(512KB,`encoding: 'base64'`)流式计算 sha256;
3. 与 `lkg.hash_full ?? lkg.hash` 比对(LKG 只有 12 位时按前缀比);
4. 首个命中 = 回滚源;全部落空 → 列出各候选指纹,报错退出(**不动任何文件**)。
#### 执行流程(对齐 deploy 的成熟姿势)
```
① system.info → agent_script / build.hash / lkg
② 无 LKG ───────────────→ 报错退出(exit 2),零改动
③ current == LKG.hash ──→ 「当前即 LKG」提示退出(提示 --backup 逃生门)
④ 枚举备份候选(fs.list + 过滤)
⑤ 指纹匹配(流式读 + sha256)── 无匹配 → 列候选指纹报错退出
⑥ 覆盖写入:fs.write append:false 首块 + append:true 续块(同 cmdDeploy ops.ts:321-334)
写后校验:fs.read 回读算 sha256
├─ 一致 → ⑦
└─ 不一致 → 用「回滚源自身」再覆盖一次(还原到回滚源内容=原状)→ 报错退出(带 backup 路径)
⑦ system.agent.restart(独立连接,吞连接中断,同 ops.ts:355-365)
⑧ probeOnce 轮询直到 build.hash == LKG.hash(90s 墙钟,同 ops.ts:368-389)
```
#### CLI 设计
- 新文件 `apps/cli/src/cmd/rollback.ts`,导出:
- `planRollback(info): {action:'no-lkg'|'already-lkg'|'explicit'|'match', targetHash, scriptPath}`(纯函数)
- `pickBackupByHash(cands, wantHash): {path,hash}|null`(纯函数,mtime 排序在外层)
- `hashRemoteFile(client, path): Promise<{hash,bytes}>`(流式,client 注入 → 可假测)
- `cmdRollback(opts)`(主编排)
- 改 `apps/cli/src/index.ts`:`case 'rollback': await cmdRollback(opts)`;
- 改 `apps/cli/src/options.ts` + `types.ts`:新增 `backup: string` 选项(`dry-run` 已声明于 `options.ts:124`,L-01 总闸自动覆盖);
- 改 `apps/cli/src/help.ts`:命令速查加 `rollback` 行(L-01 守卫:HELP 里每个 `--flag` 必须在 parseArgs 声明——新增 `--backup` 两边同步)。
- 输出三段式(PRD §3.2):LKG 记录(hash/bytes/recorded_at/node_version)→ 指纹比对 → 执行结果与复核提示;`--dry-run` 只做 ①~⑤(含匹配结论),零写入。
- 退出码:0 成功/已是 LKG;1 执行失败(含写后校验失败);2 前提不满足(无 LKG/无匹配备份)。
#### 单测(`tests/unit/rollback.test.mjs`,PRD 验收②三条全覆盖)
1. **无 LKG**:`planRollback` 返回 `no-lkg`,`cmdRollback` 不发起任何 invoke(假 client 断言零调用);
2. **指纹已匹配**:`already-lkg` 分支零写入、退出 0;
3. **写后校验失败自动回滚**:假 client 让第二次 `fs.read`(回读校验)返回被篡改内容 → 断言流程执行了「还原写」且最终 `fs.read` 到的内容 == 回滚源内容,退出码 1;
4. `pickBackupByHash`:mtime 新→早优先、前缀/全量两种比对、空候选返回 null;
5. dry-run:只读 invoke(system.info/fs.list/fs.read),无 fs.write/restart。
#### e2e/真机(PRD 验收③)
- `tests/e2e/run.mjs`:起被控端 → `deploy` 一份新 agent.mjs(或 update dry-run 后手工替换)→ `rollback --dry-run`(只读断言)→ 实滚 → probe 复核 `build.hash == LKG`。端口/临时 HOME 遵守既有纪律。
- 真机矩阵(阶段 4):Windows x64 必跑一遍「更新→回滚→复核」闭环。
### B-P1-1 MCP 新增 `na_audit_l2`(41→42)
- `apps/mcp/src/tools/defs.ts`:在 `na_audit` 之后插入 `na_audit_l2`:
```ts
{
name: 'na_audit_l2',
description: 'L2 系统级审计兜底(仅 Windows 被控端):采集操作系统自己的审计事件(进程创建/登录),' +
'补 L1 结构上记不到的部分(攻击者绕过 agent 或停掉 agent 时 L1 全盲)。' +
'action=status 查策略/enable 开启(需管理员)/collect 采集落盘/verify 校验 L2 链。' +
'⚠️ 信任边界:本地 audit-l2.log 只是归一化副本,防篡改依赖把记录转发出机,不可当作不可篡改证据。',
inputSchema: { type:'object', properties: {
action: { type:'string', enum:['status','enable','collect','verify'], description:'默认 status' },
since: { type:'integer', description:'collect 起始时间(Unix ms),默认 24h 内' },
limit: { type:'integer', description:'collect 条数上限,默认 1000,最大 5000' },
}, additionalProperties: false },
}
```
- `apps/mcp/src/index.ts`:`TOOL_TO_CAPABILITY` 加 `na_audit_l2: CapabilityNames.AuditL2`;`resolveToolCall` 加 case(`action` 默认 `status`,`since/limit` 直传)。
- 非 Windows:被控端抛 `E_UNSUPPORTED_PLATFORM`(`audit-l2.ts:190-198`)→ 既有 invoke 归一逻辑转成 error result → **明确平台错误而非空结果**(PRD 验收②);单测断言错误名。
- `tests/unit/mcp-tools.test.mjs`:41→42 + `BASELINE_SEQ_SHA` 更新(顺序即呈现顺序,插在 `na_audit` 后)。
- **决策记录**:PRD 给了「新工具 vs na_audit 增参」二选一 —— 选**新工具**:工具表只增(旧宿主零副作用)、错误面隔离(平台不支持时不影响 na_audit)、信任边界文案有独立载体。
### B-P1-2 控制台 L2 审计视图
- `apps/console/src/views/AuditView.vue`:既有审计页内**增加 L2 分区**(不新增顶级路由,PRD §3.2):
- 上部 L2 状态卡:`platform/supported/auditpol/legacy_policy_disabled/cmdline_logging/sysmon_installed/file/file_exists`(与 `audit-l2.ts:247-259` 返回逐字段对齐);
- 下部操作行:collect(since/limit 输入 + 按钮)、verify(按钮);结果表列 `ts/kind/subject/object/event_id`(归一化 schema,`docs/AUDIT-L2.md` §4);
- 非 Windows:整区置灰 + 文案(复用既有门控模式)。
- `apps/console/src/stores/conn.ts:17`:`WINDOWS_ONLY_CAPS` 增加 `'system.audit.l2'`(该数组是事前门控真源,README:389-391 的 app./service 同款机制)。
- `apps/console/src/types/index.ts`:新增 `L2Status`/`L2Entry`/`L2VerifyResult`/`L2CollectResult` 类型(与 B-P1-4 补全后的 returns_schema 对齐)。
- **无需新 Tauri 命令**(走既有 `invoke_capability`,`api/index.ts` 泛型通道)→ `console-commands.test.mjs` 双向守卫不受影响,零 Rust 改动。
- 新增 `tests/unit/console-l2.test.mjs`:① AuditView 引用的每个 L2 字段名都能在 manifest `AuditL2.returns_schema` 顶层属性中找到(B-P1-4 落地后此守卫才生效——反向守卫同 console-contract 思路);② `WINDOWS_ONLY_CAPS` 含 `system.audit.l2`;③ 界面纪律源码守卫(不引图标库、只用 `--na-*` 变量——扫描新增代码块)。
### B-P1-4 `system.audit.l2` returns_schema 补全
- `packages/protocol/src/capabilities/manifest/system.ts:296-322`:`returns_schema` 从 `{ type: 'object' }` 补为四种 action 返回的**顶层属性并集**(校验器不支持 oneOf/anyOf,用并集 + description 说明「按 action 出现」,与仓库现有风格一致):
```ts
returns_schema: {
type: 'object',
description: '按 action 返回:status→策略状态;enable→启用结果;collect→采集结果;verify→链校验结果',
properties: {
// status
platform: { type: 'string' }, supported: { type: 'boolean' },
auditpol: { type: 'string' },
legacy_policy_disabled: { type: 'boolean' }, cmdline_logging: { type: 'boolean' },
sysmon_installed: { type: 'boolean' },
file: { type: 'string' }, file_exists: { type: 'boolean' }, note: { type: 'string' },
// enable
enabled: { type: 'boolean' }, policies: { type: 'array', items: { type: 'string' } }, hint: { type: 'string' },
// collect
collected: { type: 'integer' }, written: { type: 'integer' }, since: { type: 'integer' }, limit: { type: 'integer' },
// verify
ok: { type: 'boolean' }, checked: { type: 'integer' },
broken_at: { type: 'object', properties: { line: { type: 'integer' }, reason: { type: 'string' } } },
},
},
```
- 与 `audit-l2.ts` 四个返回形状(`:247-259` / `:287-293` / `:208-216` / `:314-346`)逐字段核对;**运行时返回不变**(纯文档性契约补齐 → 旧控制端零影响,符合 VERSIONING §3.3)。
- `console-contract.test.mjs` 的反向守卫对该能力从「跳过空壳」转为生效;若其实现按名单跳过,需把 `system.audit.l2` 移出豁免名单(或显式记录豁免理由——PRD 验收②的「或」分支)。
### B-P1-5 L2 e2e 覆盖
- `tests/e2e/run.mjs`(跨平台,Linux runner 可跑):`system.audit.l2 status` 在非 Windows 必须抛 `E_UNSUPPORTED_PLATFORM`(明确平台错误,不返回空结果)——1 条。
- `tests/e2e/windows.mjs`(Windows 专属):① status 返回结构可解析(字段齐全);② collect(since/limit)对空 Security 日志不崩、返回 `collected/written/file`;③ collect 后 verify 返回 `ok:true`;④ 自造 L2 链文件篡改后 verify 报 broken_at(可在单测层做,e2e 做 ①~③)——3 条。
- 纪律:不新增端口常量冲突、临时 HOME 隔离遵守既有模式。
### B-P1-6 `na-gui` 陈旧计划任务清理
- 抽纯函数到 `scripts/lib/scheduled-tasks.mjs`:`TASK_WHITELIST = ['nodeagent', 'na-gui', 'nodeagent-console']`(白名单,禁通配删除);`planTaskCleanup(existing: string[]): string[]`(返回需删的名单,只删白名单内且存在的)。
- `scripts/uninstall.ps1`:卸载流程末尾对白名单逐个 `Unregister-ScheduledTask`(存在性检查 + 动作进日志;纯 ASCII + CRLF + BOM 纪律,参照 `tests/unit/ps1-encoding.test.mjs` 守卫)。
- `scripts/control.ps1`:新增 `cleanup` 子命令(复用同一函数块),供「拿到端口占用提示后自救」。
- 验证:`tests/unit/na-gui-cleanup.test.mjs` 测纯函数(建名单→计划删除→断言只删白名单);ps1 侧加源码守卫(uninstall.ps1 必须包含白名单清理调用与日志)。真机验证(阶段 4):Windows 上建同名任务→跑 uninstall→断言已删。
### B-P1-3 文档回填(CHANGELOG / README / DEVELOPMENT / ROADMAP + 数字守卫)
- **CHANGELOG.md**:`[未发布]` 段重组 —— 当前内容(控制台去命令行化)归位到 `v2.0.3` 已发布节之下方?不:`[未发布]` 现有主题是**未归位**的控制台批次。按 PRD 验收②:`git log v2.0.3..HEAD` 全部提交主题进未发布段,分节:L2 系统级审计(`7d781d1`)、交付流水线(`2bf45fd`)、Linux 适配(`00992e5`)、质量门禁(`141f479`)、console-release(`9f95560`)、console 修复批次(`240c52c/0782d90/9af6e0f/0065916/704fb8c/3680218`)+ 本轮 v2.1.0 新增节(selfupdate 代理 / rollback / MCP L2 / 控制台 L2 / na-gui 清理 / L2 returns_schema+e2e / 文档数字)。
- **README.md**:`51 项能力 · 41 个 MCP 工具 · 269 单测 · 30 e2e · 3 Rust`(:171)→ `52 · 42 · 357 · 33+13 · 18`;MCP 工具表加 `na_audit_l2`;命令速查「自持与运维」加 `nodeagent rollback [--dry-run]`;「控制台安装包只作为 CI artifact」行(:387)已被 `9f95560` 修掉 → 删。
- **docs/DEVELOPMENT.md**:补 L2 能力条目(params/returns 落定后契约)、selfupdate 代理出网机制(NA_PROXY→env→系统代理 + 计划任务环境变量不可见的告诫)、rollback 能力圈边界。
- **docs/ROADMAP.md**:台账刷新(本轮 P0/P1 标记完成)。
- **数字守卫(把 PRD 验收③的「或至少人工核对」升级为断言)**:新增 `tests/unit/docs-numbers.test.mjs`:manifest 实点数 vs README「52 项能力」、defs.ts 工具名数 vs README「42」、`grep -c "test("`? 不——单测数不扫源码(会脆),改为:README 数字段与 `docs/ARCH/PRD 基线表` 的一致性由**人工**登记 + 守卫只覆盖「能力数/工具数」两个可从代码数出的值(铁律:守卫要能注入故障变红——改动 manifest 增能力不改 README → 红)。
---
## Part C:任务列表(工程师执行顺序)
> 铁律遵守:≤5 任务;每任务 ≥3 文件;T01 = 基础设施(版本/配置/入口);被控端(L0)只增不改既有行为;每个 P0/P1 完工即补「会红的守卫」。
### T01 项目基础设施:版本号统一写入 v2.1.0 + CHANGELOG 骨架
- **Source Files**:
- `scripts/lib/version-write.mjs`(不修改,作为写入工具被调用)
- 版本落点(一律经 version-write 统一写,禁手改散点):根 `package.json`、各 workspace `package.json`、`apps/console/src-tauri/tauri.conf.json`、`Cargo.toml`、`Cargo.lock`
- `CHANGELOG.md`(`[未发布]` 段重组为 v2.1.0 骨架)
- `tests/unit/version-write.test.mjs`(若钉了具体版本号则同步)
- **Dependencies**:无
- **Priority**:P0
- **验收标准**:① `node scripts/lib/version-write.mjs` 写入后 `git diff --stat` 覆盖全部 11 处且无散点遗漏;② `pnpm build` 后 `system.info` 自报 v2.1.0;③ CHANGELOG 未发布段涵盖 `git log v2.0.3..HEAD` 全部主题;④ 工作区干净后才允许进入发版流程(铁律 7)。
- **预估改动量**:~100 行(CHANGELOG 重组为主)
### T02 被控端 L0:selfupdate 代理出网(P0-1)+ L2 returns_schema(P1-4)+ 备份清理(A-P0-3)
- **Source Files**:
- 新增 `apps/agent/src/net/proxy.ts`
- 新增 `apps/agent/src/net/index.ts`(桶文件,导出面收敛)
- 改 `apps/agent/src/capabilities/selfupdate.ts`(download 走代理 + 诊断字段 + 备份清理 + 错误分类)
- 改 `packages/protocol/src/capabilities/manifest/system.ts`(AuditL2 returns_schema 补全;AgentUpdate returns 加 via/proxy/proxy_source/outbound_notes)
- 新增 `tests/unit/agent-proxy.test.mjs`(≥16 用例)
- 新增 `tests/unit/selfupdate-backup-cleanup.test.mjs`(planBackupCleanup + 下载上限,可并入 agent-proxy)
- **Dependencies**:T01(版本随包)
- **Priority**:P0
- **验收标准**:① 单测 16+ 全绿(含 NO_PROXY/socks 报错/PAC note/重定向共享预算);② `system.agent.update` 带 `--dry-run` 在配代理的离线环境(本地 HTTPS 源站 + 本地代理)端到端成功;③ 哈希不符/代理不可达时不落盘且错误带 via/hint;④ 357 单测 + e2e 33 全绿回归;⑤ manifest 变更后 `capabilities.test.mjs`/`console-contract.test.mjs` 守卫全绿。
- **预估改动量**:~700 行(proxy.ts 450 + selfupdate 120 + manifest 40 + 测试 200)
### T03 控制端 L1:`nodeagent rollback`(P0-2)
- **Source Files**:
- 新增 `apps/cli/src/cmd/rollback.ts`
- 改 `apps/cli/src/index.ts`(注册 case)、`apps/cli/src/options.ts`(`backup` 选项)、`apps/cli/src/types.ts`(Options 字段)、`apps/cli/src/help.ts`(用法 + 速查行)
- 改 `apps/agent/src/lkg.ts`(`hash_full`/`backup_glob` 只增字段,老记录回落 12 位)
- 改 `tests/unit/lkg.test.mjs`(补 hash_full 用例)
- 新增 `tests/unit/rollback.test.mjs`(5+ 用例,含写后校验失败回滚)
- **Dependencies**:T01(独立于 T02,可并行)
- **Priority**:P0
- **验收标准**:① `--dry-run` 只读(零写 invoke);② 单测覆盖无 LKG/指纹已匹配/写后校验失败自动回滚;③ e2e 走「更新→回滚→复核」闭环(T05 补 e2e 时联调);④ L-01 总闸(help flag ⇄ parseArgs)全绿;⑤ README/HELP 文案写明能力圈边界(可达性前提)。
- **预估改动量**:~450 行(rollback.ts 250 + 测试 150 + 周边 50)
### T04 三端暴露 L2:MCP `na_audit_l2`(P1-1)+ 控制台 L2 视图(P1-2)+ L2 审计类型修正(A-P1-1)
- **Source Files**:
- 改 `apps/mcp/src/tools/defs.ts`(na_audit_l2)、`apps/mcp/src/index.ts`(映射 + 分发)
- 改 `tests/unit/mcp-tools.test.mjs`(41→42 + BASELINE_SEQ_SHA)
- 改 `apps/console/src/views/AuditView.vue`(L2 分区)、`apps/console/src/stores/conn.ts`(WINDOWS_ONLY_CAPS +system.audit.l2)、`apps/console/src/types/index.ts`(L2 类型)
- 改 `packages/protocol/src/audit-types.ts` + `apps/agent/src/audit.ts` + `apps/console/src/types/index.ts`(新增 `agent.l2` 类型三处同步);改 `apps/agent/src/capabilities/audit-l2.ts`(collect/enable 审计改 `agent.l2`)
- 新增 `tests/unit/console-l2.test.mjs`(字段名 ⇄ returns_schema 反向守卫 + 门控 + 界面纪律)
- **Dependencies**:T02(manifest returns_schema 先落,前端守卫才有比对真源);与 T03 并行
- **Priority**:P1
- **验收标准**:① MCP 工具表 42、序列 sha 更新、`na_audit_l2` 在非 Windows 返回明确平台错误(单测断言错误名);② 控制台 L2 区在 macOS 被控端置灰;③ `agent.l2` 类型三处同步后 `audit-filter.test.mjs` 自动覆盖;④ console-commands 双向守卫不回归。
- **预估改动量**:~500 行(前端 250 + MCP 80 + 类型同步 50 + 测试 120)
### T05 收口:L2 e2e(P1-5)+ na-gui 清理(P1-6)+ 文档回填与数字守卫(P1-3)
- **Source Files**:
- 改 `tests/e2e/run.mjs`(L2 平台错误用例)、`tests/e2e/windows.mjs`(L2 status/collect/verify 3 条)
- 新增 `scripts/lib/scheduled-tasks.mjs`(纯函数);改 `scripts/uninstall.ps1`、`scripts/control.ps1`(白名单清理 + cleanup 子命令)
- 新增 `tests/unit/na-gui-cleanup.test.mjs` + `tests/unit/docs-numbers.test.mjs`
- 改 `CHANGELOG.md`(v2.1.0 节定稿)、`README.md`(数字/工具表/速查)、`docs/DEVELOPMENT.md`(L2 + 代理 + rollback 边界)、`docs/ROADMAP.md`
- **Dependencies**:T02、T03、T04
- **Priority**:P1
- **验收标准**:① e2e 新增 4 条全绿(33+13 → 37+13 口径);② na-gui 清理只删白名单(单测 + 真机各一遍);③ README/DEVELOPMENT/CHANGELOG 数字与实测一致且 `docs-numbers.test.mjs` 能对「增能力不改 README」变红;④ 全量 357+ 单测 / e2e / Rust 18 回归。
- **预估改动量**:~600 行(e2e 150 + 脚本 80 + 测试 120 + 文档 250)
### 任务依赖图
```mermaid
graph LR
T01[T01 基础设施<br/>版本 v2.1.0 + CHANGELOG 骨架]
T02[T02 被控端 L0<br/>selfupdate 代理 + L2 returns_schema + 备份清理]
T03[T03 控制端 L1<br/>nodeagent rollback]
T04[T04 三端暴露 L2<br/>MCP + 控制台 + agent.l2 类型]
T05[T05 收口<br/>L2 e2e + na-gui 清理 + 文档回填]
T01 --> T02
T01 --> T03
T02 --> T04
T01 --> T04
T02 --> T05
T03 --> T05
T04 --> T05
```
---
## Part D:多端适配差距分析
### D.1 能力矩阵现状(证据:各能力平台分支实读)
| 能力/组 | Windows | macOS | Linux | 证据 |
|---|---|---|---|---|
| system.info/status/process.list/shell.exec/task.*/metrics/audit.*(L1) | ✅ | ✅ | ✅ | 无平台分支 |
| system.service.list | ✅ | ❌ E_UNSUPPORTED_PLATFORM | ❌ 同 | `system.ts:379-386` |
| system.audit.l2 | ✅(auditpol 已落地) | ❌ 明确报错(ESF entitlement 硬限) | ❌ 明确报错(auditd 需预装) | `audit-l2.ts:190-198` |
| app.list / app.install | ✅(注册表 + winget) | ❌ | ❌ | `app.ts:14` |
| screen.info / screen.capture | ✅ | ✅(需屏幕录制权限) | ⚠️ 依桌面环境 | `screen.ts:268`(macOS 权限提示) |
| screen.record | ✅(帧序列 + ffmpeg 可选) | ❌ | ❌ | `record.ts:95` |
| screen.find | ✅ UIA→OCR→图像模板 | ✅ Vision OCR(无 UIA,`where` 明确报错) | ❌ | `window.ts:312`、`darwin.ts:18-19`、README:128-129 |
| window.list / window.focus | ✅ | ✅ AppleScript(需辅助功能权限) | ❌ | `window.ts:154-190`、`darwin.ts:14-17` |
| input.*(键鼠) | ✅(默认禁) | ❌ **只读** | ❌ | `input.ts:39` |
| clip.get/set(文本) | ✅ | ✅ | ✅ | 无分支 |
| clip 图片 | ✅ | ❌ | ❌ | `clipboard.ts:176` |
| event.watch(file/process/net) | ✅ | ✅ | ✅ | `events.ts:124-165` 平台分流 |
| gui.await | ✅ | ✅(复用 window/screen 的平台面) | 部分(file/process 可,window/control 随平台) | `await.ts` 复用 |
| log.query / monitor.* | ✅ | ✅ | ✅ | 无分支 |
| fs.* / audio.* | ✅ | ✅ | fs ✅ / audio ❌(仅 Win+macOS) | `audio.ts:176,209` |
| net.apply/confirm/status | ✅ | ❌ | ❌ | `network.ts:308` |
| system.agent.update/restart | ✅ | ✅ | ✅ | 无分支 |
### D.2 差距结论与处置
1. **本轮补(P1 范围内)**:
- L2 的**暴露面**补齐(MCP 工具 + 控制台视图 + returns_schema)—— Windows 已实现,补的是「三端可见性」,不改平台实现面。
- 非 Windows 的 L2 入口**事前禁用 + 明确原因**(控制台 WINDOWS_ONLY_CAPS 机制;MCP 错误名可断言)。
2. **登记 P2(推后)**:
- Linux L2(auditd 需预装,属部署前置条件, AUDIT-L2.md §3.2 已论证「不写假装能用的代码」);
- macOS L2(ESF entitlement 不可自签启用);
- macOS `input.*` 写入(当前只读;需 AX 授权 + 实现成本,PRD §2.2 诉求⑤已列后续);
- macOS/Linux `screen.record`、`net.*`、图片剪贴板;
- Windows arm64 CI runner(GitHub 不提供,人工真机流程化)。
3. **本轮顺带修正的文档漂移**:README「控制台安装包仅 CI artifact」已被 `9f95560` 修复(P1-3 回填时删该行);README:171 数字全量过期(P1-3)。
---
## Part E:设计风险与需再决策点
| # | 风险/决策 | 影响 | 建议 |
|---|---|---|---|
| R-1 | **rollback 能力圈边界**:被控端「进程完全起不来」时 RPC 通道不存在,CLI rollback 只能救「能启动但需回退」。PRD G1 用户故事表述为「更新后失联→一条命令恢复」 | 若预期「失联也能远程救」,需追加「启动失败 N 次由看门狗恢复 LKG」——与 DELIVERY-PIPELINE §6「刻意不做自动回滚」直接冲突,且会重新引入回滚循环风险 | 维持手动 rollback + 文档明示边界;物理/带外路径(附录 C)不变。是否加看门狗请需求方明确否决或排期 |
| R-2 | **LKG 匹配精度**:LkgRecord 现只有 12 位 hash,备份匹配按前缀比(碰撞概率 1/16¹²,可接受但非零) | 备份指纹匹配的正确性 | 已在 B-P0-2 设计 `hash_full` 只增字段;老记录回落 12 位。零风险落地 |
| R-3 | **MCP L2 形态**:PRD 给「新工具 / na_audit 增参」二选一 | 已代选新工具(工具表只增、错误隔离、文案载体) | 若倾向合并进 na_audit,返工量 ~1 小时(T04 内) |
| R-4 | **计划任务环境变量不可见**:Windows 计划任务拉起的 agent 读不到用户 shell 的 HTTPS_PROXY | 方案 A 的主力路径实际是「系统代理探测」(reg query WinINET),环境变量只在交互 shell 手工启动时生效 | 文档明示;不在 agent.json 加代理配置项(YAGLI/YAGNI)。若要求配置项,+0.5 天 |
| R-5 | **selfupdate 备份保留数**(设计取 3) | 保留太少 → 回退链断;太多 → 磁盘堆积(A-P0-3) | 3 个 + 可被将来配置化;真机验证后定稿 |
| R-6 | **`agent.l2` 新审计类型**:新增即改「审计类型唯一真源」三处 | 漏一处即漂移(C-01 教训) | 编译期 AssertExact + audit-filter 双向守卫兜底;T04 内必须三处同步 |
---
## 附:关键调用流
### selfupdate 代理出网(B-P0-1)
```mermaid
sequenceDiagram
participant CLI as 控制端 (CLI/MCP/console)
participant S as agent server.ts
participant SU as selfupdate.ts
participant PX as net/proxy.ts
participant NET as node:http/https
CLI->>S: invoke system.agent.update {url, sha256, timeout_ms}
S->>SU: agentUpdate(args)
SU->>PX: resolveProxy({targetUrl: url})
PX->>PX: NA_PROXY → env → 系统代理(reg/scutil)
PX-->>SU: {proxy, source, notes}
SU->>PX: httpGet(url, {proxy, timeoutMs})
PX->>NET: CONNECT 隧道 / 绝对形态 / 直连
NET-->>PX: 响应字节流(链共享 AbortController)
PX-->>SU: {status, body}
SU->>SU: sha256 校验(不符→丢弃,不落盘)
SU->>SU: 备份 → 就地覆写 → 写后校验(不符→回滚)
SU->>S: agentRestart({delay_ms:1500})
SU-->>S: {updated, via, proxy, proxy_source, incoming_hash, ...}
S-->>CLI: {status:'ok', data}
```
### rollback(B-P0-2)
```mermaid
sequenceDiagram
participant CLI as cmdRollback
participant C as NodeAgentClient
participant AG as 被控端
CLI->>C: invoke system.info {}
C->>AG: system.info
AG-->>CLI: {agent_script, build.hash, lkg:{lkg, matches}}
alt 无 LKG
CLI-->>CLI: 报错退出(2),零改动
else current == LKG.hash
CLI-->>CLI: 「当前即 LKG」退出(0),提示 --backup
end
CLI->>C: invoke fs.list {path: 入口目录}
C->>AG: fs.list
AG-->>CLI: 候选 *.bak / *.bak-*
loop 每个候选(mtime 新→早)
CLI->>C: invoke fs.read {path, encoding:'base64', offset} 分块
C->>AG: fs.read
AG-->>CLI: chunk
CLI->>CLI: sha256 累加
end
alt 无匹配
CLI-->>CLI: 列候选指纹报错退出(2)
end
opt --dry-run
CLI-->>CLI: 输出比对结论,零写入
end
CLI->>C: invoke fs.write {path: 入口, append:false/true} 覆盖
C->>AG: fs.write
AG-->>CLI: ok
CLI->>C: invoke fs.read 回读校验
alt 校验失败
CLI->>C: invoke fs.write 用回滚源还原
CLI-->>CLI: 报错退出(1)
end
CLI->>C: invoke system.agent.restart(独立连接,吞中断)
loop probeOnce 轮询(90s 墙钟)
CLI->>AG: 握手 + system.info
AG-->>CLI: build.hash
end
CLI-->>CLI: 复核 build.hash == LKG.hash → 成功
```
+155
View File
@@ -0,0 +1,155 @@
# L2 系统级审计兜底(设计 + Windows 落地)
> 本文是 `docs/DELIVERY-PIPELINE.md` 与 `docs/SECURITY.md` §4.8 的配套件。
> 编写日期:2026-10-17。基线提交:`2bf45fd`。
---
## 1. L1 与 L2 的分工(先界定问题)
| | L1(已实现) | L2(本文) |
|---|---|---|
| 记录者 | **nodeagent 自己**(`apps/agent/src/audit.ts`) | **操作系统** |
| 落盘位置 | `%NODEAGENT_HOME%/audit.log` | OS 审计子系统(Windows 事件日志 / Linux auditd / macOS ESF) |
| 防篡改 | 哈希链 + 外部锚定 | 由 OS 内核写入,agent 无权改写 |
| 覆盖内容 | 「谁调用了什么能力」 | 「这台机器上发生了什么」(进程创建、登录、文件访问…) |
### 为什么还需要 L2
L1 的哈希链能发现**链内**篡改,外部锚定能发现**整链重写**。但两者都有一个前提:
**agent 还在记录**。
一旦攻击者拿到被控端的 root:
1. 可以直接**停掉 agent**(或让它不再写审计),此后发生的一切 L1 都看不到;
2. 即使不停,L1 记的是「agent 知道的事」——攻击者绕过 agent、直接用系统命令做的事,
L1 **结构上就记不到**(它只看得见经过自己 RPC 的调用)。
L2 补的就是这块:**agent 不在场时,机器自己留下了记录**。
---
## 2. 必须先说清的信任边界(不要夸大 L2 的作用)
**L2 采集器本身不提供独立性。**
采集器是 agent 的一部分,与被审计对象同权限、同进程。攻击者既然能停 agent,
就能停采集器。**真正的独立性来自「记录不留在本地」**:
```
OS 审计子系统 ──(只读)──> 采集器 ──> ① 本地 audit-l2.log(副本,仅便利)
└─> ② 转发出机(控制端 / 云盘) ← 独立性在这里
```
- ① 只是**归一化副本**,方便查询,防篡改价值有限(我们写的,我们能改)。
- ② 才是 L2 的意义所在 —— 与 §4.8 的锚定思路一致:**留在攻击者碰不到的地方**。
> 因此本文的落地范围是「①采集与归一化 + ②导出接口」,
> **自动转发**属于部署侧配置(与锚点外发共用同一条通道),不在本文实现。
---
## 3. 三端设计
### 3.1 Windows —— **本次落地**
Windows 有两条可选路径:
| 方案 | 依赖 | 覆盖 | 取舍 |
|---|---|---|---|
| **A. 内置高级审计策略**(`auditpol.exe`) | **零依赖** | 进程创建 4688、登录 4624/4625、进程退出 4689、对象访问 4663 | ✅ **选它**:无需安装任何东西 |
| B. Sysmon(Sysinternals) | 需下载安装 Sysmon 本体 + 配置 XML | 更丰富(镜像加载、命名管道、WMI、DNS) | 可选增强:检测到已装则并用 |
**为什么先选 A**:Sysmon 是第三方驱动,需单独分发、需重启生效、且不同版本行为有差异;
内置策略是 Windows 自带的、开箱即用的那层。能用零依赖方案先立起来,Sysmon 作为可选增强。
**启用方式**(需管理员):
```cmd
:: 让「高级审计策略」覆盖旧的「类别级策略」—— 否则 subcategory 设置不生效
auditpol /set /subcategory:"进程创建" /success:enable /failure:enable
auditpol /set /subcategory:"登录" /success:enable /failure:enable
auditpol /set /subcategory:"进程终止" /success:enable
reg add HKLM\SYSTEM\CurrentControlSet\Control\Lsa /v SCENoApplyLegacyAuditPolicy /t REG_DWORD /d 1 /f
```
> ⚠️ `SCENoApplyLegacyAuditPolicy` 是关键:不设这个,组策略里的旧式类别策略会盖掉
> subcategory 设置,表现为「auditpol 显示已启用但事件日志里没有 4688」。
**采集**:`Get-WinEvent -FilterHashtable @{LogName='Security'; Id=4688,4624,4625; StartTime=…}`
(PowerShell 层,避免额外依赖)。
### 3.2 Linux —— 设计,本次不落地
- 机制:`auditd` + `auditctl` 规则。
- 规则示例:`-a always,exit -F arch=b64 -S execve`(进程执行)、`-w /etc/passwd -p wa`(关键文件)。
- 读取:`ausearch` 或 `/var/log/audit/audit.log`。
- **为什么本次不落地**:需要 `auditd` 已安装且运行;容器/精简发行版常常没有。
属部署侧前置条件,与 Windows 的「零依赖」不同。
### 3.3 macOS —— 设计,**存在硬性限制**
- 机制:Endpoint Security Framework(ESF)+ OpenBSM。
- ⚠️ **ESF 属 Apple 受限权限**:需向 Apple 申请 `com.apple.developer.endpoint-security.client`
entitlement,且客户端必须是**签名的 System Extension**。个人/自签分发**无法启用**。
- OpenBSM(`auditd`)在 macOS 上仍可用但自 macOS 11 起被弱化,事件覆盖有限。
- **结论**:macOS 侧本文**只出设计不落地**,如实标注可行性限制,不写「假装能用」的代码。
---
## 4. 归一化 schema
L2 记录与 L1 分开落盘(`audit-l2.log`),schema 独立 —— 因为来源与可信度不同,
混进 L1 会让「agent 记的」与「OS 记的」无法区分。
```jsonc
{
"ts": 1760000000000, // 事件发生时间(来自 OS,不是采集时刻)
"src": "windows-security", // 来源:windows-security | sysmon | linux-auditd | macos-esf
"event_id": 4688, // OS 原始事件 ID(保留原始,便于人工核对)
"kind": "process.create", // 归一化后的类别
"subject": "DOMAIN\\User", // 主体
"object": "C:\\Windows\\...", // 客体(进程映像 / 文件路径 / 目标)
"detail": { "...": "..." }, // 平台特有字段(命令行、PID、父进程…)
"collected_at": 1760000001234, // 采集时刻 —— ts 与它相差过大说明曾离线
"prev": "...", "hash": "..." // 与 L1 同构的哈希链
}
```
`kind` 取值(归一化枚举):
| kind | Windows | Linux | macOS |
|---|---|---|---|
| `process.create` | 4688 | `execve` | ESF `exec` |
| `process.exit` | 4689 | — | ESF `exit` |
| `auth.success` | 4624 | — | ESF `authentication` |
| `auth.failure` | 4625 | — | — |
| `file.access` | 4663 | `-w ... -p wa` | ESF `open` |
---
## 5. 落地范围(本次)
| 项 | 状态 |
|---|---|
| 设计文档(三端 + 信任边界 + 可行性限制) | ✅ 本文 |
| Windows **状态检查**(策略是否已启用) | ✅ `system.audit.l2 action=status` |
| Windows **启用**(auditpol + 关 legacy 覆盖) | ✅ `action=enable`(需管理员) |
| Windows **采集**(Security 日志 → 归一化 → 落盘) | ✅ `action=collect` |
| L2 链校验 | ✅ `action=verify` |
| Linux auditd | ⚠️ 仅设计,未落地 |
| macOS ESF | ⚠️ 仅设计,**受 Apple entitlement 限制,不可自签启用** |
| 自动转发出机 | ❌ 不在本文范围(与锚点外发共用通道) |
---
## 6. 已知限制(如实记录,不掩饰)
1. **采集器不提供独立性** —— 同进程同权限,攻击者能停 agent 就能停采集器。
真正独立性依赖转发出机(§2)。
2. **Windows 启用需管理员** —— 已提权时可用;未提权返回明确错误,不静默失败。
3. **4688 默认不含完整命令行** —— 需额外开
`HKLM\Software\Microsoft\Windows\CurrentVersion\Policies\System\Audit\ProcessCreationIncludeCmdLine_Enabled`
才能记命令行,否则只能看到进程名。采集器会检测并提示。
4. **量大** —— Security 日志可能极多;采集必须带 `since` 与 `limit`,默认窗口 24 小时、上限 1000 条。
5. **Sysmon 未自动安装** —— 检测到则并用,未检测到不尝试下载(不在本仓库职责内)。
+429
View File
@@ -0,0 +1,429 @@
# nodeagent 交付流水线规范
> 本文是《代码审计 → 上线 完整交付方案》v1.0(2026-10-14)在 **nodeagent** 项目上的落地适配。
> 适配日期:2026-10-17。基线提交:`590f62e`。
---
## 0. 适配原则(先说清楚为什么不能照搬)
原方案面向 **Web/容器服务**(同类多项目实践)——有服务端、有镜像、
有多套环境、有线上流量。nodeagent 与之有三处根本差异,照搬会产生**假门禁**(跑得很好但守不住任何东西)。
因此本文对每一阶段给出三档判定:
| 判定 | 含义 | 处置 |
|---|---|---|
| **适用** | 原样落地即可 | 直接实施 |
| **改造后适用** | 目标一致,但形态不同 | 换等价手段,并说明为什么等价 |
| **不适用** | 本项目不存在该风险面 | **必须给出替代手段**,不允许只写「N/A」了事 |
> **为什么「不适用」也必须给替代**:原方案每项都对应一类真实风险。若某类风险在本项目不存在,
> 说明风险换了形态,替代手段才是真正守它的东西;直接跳过等于承认该风险无人看守。
### nodeagent 项目画像
| 维度 | 事实 |
|---|---|
| 形态 | monorepo:TypeScript(agent/cli/mcp/hub/protocol)+ Rust(Tauri 控制台壳) |
| 交付物 | Windows 安装包 zip(含 `node.exe` + `agent.mjs`)、macOS CLI 三件套、macOS Tauri 控制台 app |
| 运行位置 | 常驻在**用户自己的机器**上(Windows/Linux/macOS),非服务器 |
| 有无服务端 | **无**(点对点直连 + 可选 Hub 中转,Hub 不在本仓库) |
| 有无容器 | **无** |
| 更新方式 | 被控端**拉取式自更新**(`system.agent.update` 读 `latest.json` + sha256) |
| 现有测试 | 单测 **341**(全绿)/ 协议 e2e / Windows 真机 e2e / Rust `cargo test` |
---
## 1. 代码审计(四维扫描 + 漏洞分级)
原方案四维:SAST / SCA / 密钥扫描 / 容器镜像。
| 维度 | 判定 | 现状 | 落地动作 |
|---|---|---|---|
| SAST 静态安全 | **适用** | ❌ 无 | 引入 Semgrep(TS/JS 规则集)进 CI,阻断 High 及以上 |
| SCA 依赖漏洞 | **适用** | ❌ 无 | `pnpm audit --audit-level=high` 进 CI;锁文件已入库 ✅ |
| 密钥与敏感信息 | **适用** | ⚠️ **有巡检无门禁** | 每日定时任务已做隐私巡检(`security-reports/`),但**不阻断合并** → 加 gitleaks 进 CI |
| 容器镜像 | **不适用** | — | 无容器。**替代:交付物断言**(见下) |
### 替代:交付物断言(比镜像扫描更贴本项目的那一层)
nodeagent 的「制品风险」不在镜像层,而在**分发包本身**。已有两道断言,本文将其升格为正式门禁:
1. **PE 架构断言**(`scripts/lib/zip-pe-arch.mjs`):解出最终 zip 内的 `node.exe` 读 PE 头,
与期望架构比对。
- 缘起(2026-10-11 事故):arm64 包里装进了 x64 的 `node.exe`,该包在 ARM Windows 上
**能启动、能 listen,只是 TLS 握手不完成** —— 单测/CI/macOS 侧全都抓不到,
且两个公开包内 `node.exe` 的 sha256 完全相同。
2. **打包期守卫**(`scripts/pack.mjs`):`.ps1` 必须带 UTF-8 BOM、`.cmd` 必须纯 ASCII + CRLF。
这两条各自对应一次真机事故(PowerShell 5.1 解析失败 / CMD 代码页截断)。
> **为什么这两条比通用镜像扫描更要紧**:镜像扫的是「第三方引入的漏洞」,
> 而这两条守的是「我们自己把错误的东西发了出去」——后者是本项目历史上真实发生过的。
### 准入判据(硬门禁)
- Critical / High 漏洞数 **必须为 0**,一票否决。
- Medium 登记并附限期修复计划;Low 可接受但须留痕。
- **黄金法则沿用**:密钥一旦提交即视为泄露,删除提交只是止损,**立即轮换**才是根治。
nodeagent 的密钥面比一般项目大:PSK、Ed25519 私钥、TLS 自签证书 —— 轮换路径已在
`docs/SECURITY.md` §6 应急处置中定义。
---
## 2. 质量门禁(Quality Gate)
| 指标 | 原方案阈值 | 现状 | 落地动作 |
|---|---|---|---|
| 新增代码覆盖率 | ≥ 80% | ❌ **未采集** | `node --test --experimental-test-coverage` 采集,设阈值(见下) |
| 圈复杂度 ≤ 10/方法 | — | ❌ 未检 | 暂不接入(见说明) |
| 重复率 ≤ 3% | — | ❌ 未检 | 暂不接入 |
| 坏味道新增 0 | — | ❌ 未检 | 暂不接入 |
| 阻断性 Bug 0 | — | ✅ `typecheck` + `clippy -D warnings` | 保持 |
**关于覆盖率阈值的现实校准**:原方案 80% 面向新项目。nodeagent 存量 341 个单测集中在
协议/编码/安全守卫,**部分模块(GUI 注入、平台脚本)无法在 CI 上无头验证**。
故采用**增量口径**:
- 全量覆盖率**先只观测、不阻断**(出数字,建立基线);
- **新增/修改文件的行覆盖率 ≥ 70% 才阻断**;
- 无法无头验证的模块显式标注豁免,并在文档中列出理由。
> 直接照搬 80% 全量阈值会导致两种后果:要么长期挂 `--skip`(门禁形同虚设),
> 要么逼迫团队为 GUI 注入代码写无意义的假断言。**门禁的价值在于守得住,不在于数字好看。**
**关于圈复杂度/重复率**:需引入 SonarQube,但对本地优先的单人项目运维成本高于收益,
本文标记为**可选项**,不进阻断门禁。
---
## 3. 自动化测试(金字塔分层)
| 层 | 判定 | 现状 | 说明 |
|---|---|---|---|
| 单元测试 | **适用** | ✅ **341 全绿** | `pnpm test:unit` |
| 集成测试 | **适用** | ✅ 协议 e2e | `pnpm test:e2e`(TLS / ed25519 / 发现 / 重连 / 文件 / Hub) |
| E2E 关键路径 | **适用** | ✅ **Windows 真机 e2e** | `pnpm test:windows`(含 `install.cmd` 双击链路)——**强于原方案**(真机而非模拟) |
| DAST 动态扫描 | **不适用** | — | 无对外 Web 服务面。**替代:协议健壮性测试** |
### 替代:协议健壮性测试(对应「运行态注入」这一类风险)
DAST 守的是「外部输入打进来会怎样」。nodeagent 的对外输入面是 **WebSocket RPC**,
已有等价覆盖(`tests/e2e/run.mjs` 内):
- 畸形 `hello`(protocol 传数字 / 传对象)
- 畸形 `auth`(hmac 传数字 / 对象 + nonce 为 null)
- **未认证直接 invoke**(U-01:必须留审计且不崩)
- 未知能力 / 参数非法 / ACL 拒绝 / 超频
> 这比通用 DAST 更精准:DAST 是黑盒猜测,这里是针对本项目协议状态机的定向攻击面覆盖。
**缺口**:畸形报文目前是**枚举式**用例,未做随机化 fuzz。建议后续加一轮
「随机字节 + 随机 JSON 结构」的模糊测试 → 记为可选改进项。
---
## 4. CI/CD 构建与制品
| 项 | 判定 | 现状 |
|---|---|---|
| 流水线 lint→test→build→scan | **适用** | ⚠️ 有 test/build/typecheck,**缺 scan** |
| 制品仓库 | **改造后适用** | ✅ GitHub Release(非镜像仓库) |
| 版本号 = 语义化 + git SHA | **适用** | ✅ 语义化版本;`release.mjs` 统一写入 11 处 |
| 签名与追溯 | **改造后适用** | ⚠️ 有 sha256 + `latest.json`,**无 cosign/SBOM** |
### 签名与追溯:容器 cosign 不适用,改为「分发包完整性」三件套
cosign 签的是容器镜像。nodeagent 分发的是 zip + `agent.mjs`,等价保障是:
1. **sha256 入库**:`SHA256SUMS.txt` 随包发布(已有)。
2. **包内容清单**:zip 内文件列表与体积随 release 记录,用于事后核对「发出去的到底是什么」。
3. **可验签**:`system.agent.update` **强制校验 sha256**(不校验等于开放远程代码执行)。
这是本项目**最强的一道**——被控端只认哈希,不认「看起来对」的包。
> **待补(可选)**:对 `agent.mjs` 做 detached GPG 签名,供高安全场景人工验签。
> 注意这不能替代 sha256 —— 被控端目前不支持验签,加签名只服务于人工审计场景。
### 现有 CI 的已知待改项
- `retention-days: 30` → 建议 **7**(CI artifact 公开可下载,留存越久暴露面越大)。
- 三个 workflow 均未集成密钥/依赖扫描(见 §1)。
---
## 5. 分级环境发布(dev / staging / prod)
**判定:不适用**,但必须给替代。
nodeagent 无服务端、无多环境:`agent` 跑在用户自己的机器上,控制端是本机 CLI。
「dev/staging/prod」这类环境隔离在物理上不存在。
### 替代:真机矩阵验证
风险换成了形态 —— 不是「哪个环境先炸」,而是「哪类机器先炸」。nodeagent 已多次栽在
**平台差异**上(CRLF、路径分隔符、verbatim 路径、ARM Windows 的 TLS、PowerShell 5.1 的 BOM)。
故替代为**真机矩阵**:
| 平台 | 架构 / 形态 | 验证方式 |
|---|---|---|
| Windows | x64 | `tests/e2e/windows.mjs`(`windows-latest` runner) |
| Windows | **arm64** | 真机验证(CI 无 arm64 Windows runner,必须人工) |
| macOS | CLI 三件套 | 本机安装冒烟 |
| macOS | Tauri 控制台 | `console-windows.yml` 构建 + `verify-bundle.mjs` |
| Linux | systemd 被控端 | `docs/DEPLOY-LINUX.md` + 真机验证 |
> **矩阵里最弱的一环是 Windows arm64** —— CI 覆盖不到,而它恰恰是历史上出过
> 「能启动但 TLS 握手不完成」这一最难发现故障的平台。本文将其列为**发版前必查项**。
---
## 6. 生产发布策略
| 原方案策略 | 判定 | 说明 |
|---|---|---|
| 滚动发布 | 不适用 | 无多实例 |
| 蓝绿部署 | 不适用 | 无流量切换 |
| 金丝雀 | **改造后适用** | 改为**设备分批**:先一台试点 |
| Feature Flag | 适用(有限) | 能力级 ACL 可视为开关 |
### 改造:金丝雀 → 设备分批自更新
被控端是**拉取式**更新(读 `latest.json`),天然支持分批:
1. **试点**:选 1 台可达且物理可及的设备先更新,观察 ≥ 30 分钟。
2. **放量**:确认无异常后,其余设备再触发更新。
3. **回滚**:每设备保留上一版本备份(`agent.mjs.bak-<ts>`)+ `latest.json` 可回退。
### ⚠️ 本项目特有风险:发布即失联(原方案未覆盖)
这是 nodeagent **最严重**的上线失败形态,且原方案完全没有对应条款:
> 一次失败的 agent 更新,会让一台**物理不可达**的远程设备**永久脱管**。
> 对 Web 服务,发布失败可以回滚、可以摘流量;对被控端,**回滚通道本身就是被更新的那个程序**。
缓解措施(部分已有,本文整合为强制项):
| 措施 | 状态 |
|---|---|
| 更新前备份当前 `agent.mjs` | ✅ 已有 |
| **先备份再原子替换**(同目录 rename,失败可回滚) | ✅ 已有 |
| **sha256 强制校验**(不校验不落盘) | ✅ 已有 |
| 更新后**重启并轮询校验新指纹**上线 | ✅ 已有(`deploy` 闭环) |
| **保留「最后已知可用版本」标记(LKG)** | ✅ 已有(`apps/agent/src/lkg.ts`) |
| **控制端手动回滚入口**(`nodeagent rollback`,读 LKG → 覆盖回入口 → 重启 → 复核) | ✅ **v2.1.0 新增**(需被控端可达) |
| **启动期自动恢复**(看门狗:启动连续失败 N 次 → 从 LKG 恢复) | ✅ **v2.1.0 新增**(`scripts/startup-guard.mjs`,三端 supervisor 已接入) |
| **运行期自动回滚**(更新后健康检查失败即自动回滚到备份) | ⚠️ **刻意不做**,见下 |
### LKG 的设计(v2.0.x 落地,v2.1.0 补全量指纹)
- **写入时机**:agent **成功启动后**(服务器已 listen、心跳已起)才写 `agent-lkg.json`。
**不在更新写完时就写** —— 那会把尚未验证的版本当成已知可用,LKG 就失去意义。
- **内容**:`hash`(与 `system.info` 的 `build.hash` 同口径,前 12 位)、
`hash_full`(sha256 **全量 64 位**,v2.1.0 起记录 —— 备份指纹匹配用全值,
消除 12 位前缀碰撞的理论风险)、`bytes`、`script_path`、
`backup_glob`(v2.1.0 起,回滚内容候选的文件名模式 `<basename>.bak*`)、
`recorded_at`、`node_version`、`platform`。
**老记录(无 `hash_full`/`backup_glob`)不猜**:消费方回落 12 位前缀比对与
`<basename>.bak*` 约定,向后兼容。
- **暴露**:`system.info.lkg = { lkg, current_hash, matches, byte_delta }`。
`matches=false` = 当前版本还没被证明能启动 → 控制端应**暂缓向其余设备放量**。
### 回滚通道:手动 rollback + 看门狗自动恢复 + 边界明示(v2.1.0 定稿)
| 场景 | 通道 | 前提 |
|---|---|---|
| 新版本能启动但需回退(行为异常 / 主动避险) | **手动** `nodeagent rollback [--dry-run] [--backup <远端路径>]`:指纹比对定位备份 → 覆盖回入口 → 受控重启 → 复核 `build.hash == LKG` | **被控端可达**(RPC 命令;`--dry-run` 只读零写) |
| 新版本进程**完全起不来**(无监听可连,CLI 救不了) | **看门狗自动恢复** `scripts/startup-guard.mjs`:supervisor 每次拉起先过守卫,启动连续失败 N 次(默认 3)自动从 LKG 恢复入口 | 外层 supervisor 在位(Windows 计划任务 / Linux systemd / macOS launchd,三端均已接入) |
| agent.mjs 自身损坏到 Node 无法加载 / Node 运行时缺失 | **带外恢复**(见附录 C):备份文件 + LKG 文件手工还原 | 物理或远程桌面可达 |
**为什么恢复目标只有 LKG 一个**:回滚循环(回滚 → 启动 → 又判定失败 → 再回滚)在
无人值守设备上比「停在坏版本」更难恢复。看门狗因此只做「回到上一个已知能启动的版本」
这一件事,恢复后计数归零;**不做多版本链式回滚、不做版本选择**。
> **边界(明示,任何文案不得暗示「失联也能一条命令救回」)**:
> ① `nodeagent rollback` 是 RPC 命令,**需被控端可达** —— 它救的是「新版本能启动但需回退」;
> ② 看门狗自身也是 Node 脚本 —— **agent.mjs 损坏到 Node 无法解析、或 Node 运行时缺失时,
> 守卫同样无法运行**,只能带外恢复(附录 C);
> ③ 看门狗只守护「启动」这一时刻;运行期崩溃由外层 supervisor 的重启策略负责
> (systemd `Restart=always` / 计划任务 `RestartCount=10`)。
**看门狗的判据(为什么不会误回滚)**:
- 只计**启动期**死亡(子进程存活 < 30s 宽限期);运行期死亡(计划内重启杀的是长命进程)不计;
- 端口已被别的实例服务 → 判定「使命达成」,退 0 不计数(防 supervisor 重启造成的撞车被误记成启动失败);
- 无 LKG / 无指纹匹配的备份 → **记日志后放行,绝不擦除 install-dir**;
- 计数在 agent **listen 成功后**由 agent 自身归零(与 LKG 同一铁律:不把「启动中」当成功);
- 恢复动作全量记 `<数据目录>/logs/startup-guard.log`(JSONL:时间、动作、命中备份、结果)。
### 为什么**运行期**不做自动回滚(明确的设计取舍,v2.1.0 收窄)
原三问(无法归因 / 回滚循环 / 失控)在**启动期**已被看门狗以受控方式回答
(判据见上:只看启动期死亡、单一 LKG 目标、恢复后归零、全程日志、无匹配不动盘)。
但**运行期**仍维持原取舍、刻意不做:
1. **无法归因**:远程设备上区分不出「更新本身坏了」与「网络抖动 / 端口被占 / 依赖服务未起」。
2. **回滚循环**:见上 —— 运行期自动回滚会把「停一次服务」放大成「来回换版本」。
3. **失控**:被控端一旦在运行期自作主张改自己的程序,控制端就失去对「当前跑的到底是什么」的掌控。
故定位为:**LKG 提供确定的回滚目标;启动期决策交给看门狗(有界、可审计),
运行期决策交给人**(`nodeagent rollback`)。写入期的闭环(备份、哈希校验、写后校验、
不一致即回滚)由 `system.agent.update` 既有实现承担,本文不改动它。
---
## 7. 上线监控与复盘
| 支柱 | 判定 | nodeagent 对应 |
|---|---|---|
| 指标 | **改造后适用** | `system.metrics`:闭环成功率 / P95 时延 / 安全拦截率 / 装软件成功率 |
| 日志 | **适用** | 审计日志 JSONL(哈希链 + 外部锚定),见 `docs/SECURITY.md` §4.8 |
| 链路追踪 | 不适用 | 点对点调用,无分布式链路 |
| 告警 | 改造后适用 | 无值班通道;改为**发版后主动复核** |
### 发布后复核清单(替代 Prometheus 盯盘)
| 项 | 命令 | 期望 |
|---|---|---|
| 能力数未减少 | `nodeagent list` | 与发版前一致 |
| 审计链完整 | `nodeagent audit verify` | `ok: true` |
| 锚点自洽 | `nodeagent audit anchor --compare` | 无「整链重写」告警 |
| 核心能力可用 | `node scripts/verify.mjs --host … --key …` | 全绿 |
| 指标未恶化 | `nodeagent metrics` | 闭环率 ≥ 95%、拦截率 100% |
### 复盘
已有载体:`bug-report-2026-10-13.html`(缺陷台账)、`docs/RETROSPECTIVE.md`(短板复盘)、
`security-reports/YYYY-MM-DD.md`(每日巡检)。
**待补**:原方案要求「任何事故/回滚必须写复盘 —— 时间线、根因、改进项、责任人」。
本项目为单人项目,「责任人」不适用,改为**改进项是否进入守卫**(即:是否变成一条会红的断言)。
> 这是本项目复盘最有价值的一问:**没有变成断言的复盘,等于没有复盘**。
> 历史上多次同类事故复发(CRLF、路径、编码)都栽在这一点上。
---
## 8. 落地清单(Action Checklist)
按优先级排序,✅ 表示本次已落地:
- [x] 建立 `.gitignore` 密钥基线(`PSK.txt` / `keys/` / `*.key` / `*.pem` / `config.json` / `agent.json`)— **已有**
- [x] 密钥扫描(gitleaks)进 CI 并阻断合并 — **本次落地**(`quality-gate.yml`)
- [x] 依赖漏洞扫描(`pnpm audit --audit-level=high`)进 CI — **本次落地**
- [ ] SAST(Semgrep TS/JS 规则集)进 CI — **未做**:需先定规则集,否则噪声会淹没真问题
- [x] 覆盖率采集接入 CI(全量观测 + 变更文件 ≥70% 阻断)— **本次落地**(`scripts/ci/check-coverage.mjs`)
- [x] 提交信息规范强制(Conventional Commits + CI 校验)— **本次落地**(`scripts/ci/check-commit-msg.mjs`)
- [x] 交付物 PE 架构断言 — **已有**,升格为门禁
- [x] 打包期 `.ps1` BOM / `.cmd` ASCII+CRLF 守卫 — **已有**
- [x] CI artifact `retention-days` 30 → 7 — **本次落地**
- [x] 保留「最后已知可用版本」标记(LKG)— **本次落地**
- [ ] 分支保护:main 需 PR + CI 全绿 — **需仓库设置**(当前 protection 返回 404,已知 P1)
- [ ] 发版前 Windows arm64 真机必查(CI 覆盖不到)— **流程项**,已写入本文档
- [ ] 协议模糊测试(随机报文 fuzz)— **可选改进项**
### 本次落地的实测证据
| 项 | 证据 |
|---|---|
| 覆盖率门禁 | 三条故障注入均 `exit=1`(阈值 100 / 全量 99 / lcov 缺失);正常路径 `exit=0` |
| 覆盖率基线 | 全量 **66.9%**(6399/9560 行,54 文件)—— 印证「照搬 80% 全量不现实」 |
| 提交规范校验 | 反例 3 类均红(无类型 / wip / 类型拼错);Merge 与无 scope 正确豁免;**历史 20 条提交一次通过** |
| LKG | 新增 8 条单测全绿;含「篡改 hash 必须检出 matches=false」故障注入 |
| 契约守卫 | 新增 `lkg` 字段后,既有契约测试**立刻报红**(`SystemInfo` 缺声明)→ 补接口后转绿。这本身是门禁有效的实证 |
| 全量单测 | **349 全绿**(落地前 341) |
| 门禁会红的验证 | 覆盖率 3 注入 + LKG 1 注入,均实测为真红,非摆设 |
---
## 附录 A:CI 骨架(GitHub Actions,适配 nodeagent)
```yaml
# .github/workflows/quality-gate.yml
name: Quality Gate
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with: { fetch-depth: 0 }
# 密钥扫描:历史 + 增量(nodeagent 密钥面:PSK / ed25519 / TLS 私钥)
- uses: gitleaks/gitleaks-action@v2
env: { GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} }
# 依赖漏洞
- uses: pnpm/action-setup@v4
- run: pnpm install --frozen-lockfile
- run: pnpm audit --audit-level=high
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck
# 覆盖率:全量出数字(观测),增量阈值由 coverage-gate 判定
- run: node --test --experimental-test-coverage --test-reporter=lcov \
--test-reporter-destination=coverage/lcov.info "tests/unit/**/*.test.mjs"
- uses: actions/upload-artifact@v4
with: { name: coverage, path: coverage/, retention-days: 7 }
```
> **注**:`windows-e2e.yml` 保留原样 —— 它跑的是真机链路(`install.cmd` / `control.cmd` 双击),
> 是本项目 CI 里最有价值的一环,不可被「更快」的 Linux 流水线替代。
## 附录 B:提交信息规范
项目**实际已在用** Conventional Commits(`git log` 可验证),但**无强制**。补齐校验:
```
<type>(<scope>): <subject>
type: feat | fix | docs | style | refactor | perf | test | chore | ci
scope: agent | cli | mcp | hub | protocol | console | pack | release | linux | …
```
示例(取自实际提交):
```
feat(linux): Linux 被控端适配 —— 安装器 + systemd 自启 + 平台守卫 + 部署文档
fix(console): sidecar 补 CREATE_NO_WINDOW(不再弹常驻黑窗)
chore(privacy): 公开仓库隐私脱敏 —— 清除可定位到个人与设备的字面量
```
## 附录 C:回滚检查清单(nodeagent 版)
**触发回滚的条件(满足任一)**
- 目标设备**失联**(更新后无法连接,且已排除网络问题)—— **最高优先级**
- `nodeagent list` 能力数少于发版前
- `nodeagent audit verify` 返回 `ok: false`
- `verify.mjs` 出现失败项
**回滚动作**
1. **被控端可达**(能连上)→ `nodeagent rollback`(指纹比对定位 LKG 备份 → 覆盖回入口 → 受控重启 → 复核 `build.hash == LKG`;`--dry-run` 可先只读比对)。
2. **被控端起不来**(连不上)→ 看门狗应已介入:启动连续失败 3 次后自动从 LKG 恢复,过程见 `<数据目录>/logs/startup-guard.log`。若看门狗未恢复(无 LKG / 无指纹匹配的备份)或状态异常,登录目标机(物理或带外方式),用备份恢复:`agent.mjs.bak`(或 `agent.mjs.bak-<ts>`)→ `agent.mjs`。
3. 若需回退发布通道:把 `latest.json` 指回上一版本 URL + sha256。
4. 重启 agent(计划任务或 `run-temp.cmd`)。
5. 复核:`verify.mjs` 全绿 → 记录时间线 → 写复盘 → **把根因变成一条会红的断言**。
> 手动回滚与看门狗的分工:rollback 是「人决策、被控端可达」;看门狗是「机器决策、只限启动期」。
> 两者都只认 LKG 一个目标;边界(agent.mjs/Node 损坏时两者都无能为力)见 §6 的明示块。
---
## 变更记录
| 日期 | 内容 |
|---|---|
| 2026-10-17 | 初版。基于《代码审计 → 上线 完整交付方案》v1.0 适配,基线 `590f62e`,单测 341 全绿。 |
| 2026-10-25 | v2.1.0:§6 定稿回滚通道 —— 手动 `nodeagent rollback`(需被控端可达)+ 看门狗自动恢复(`scripts/startup-guard.mjs`,启动连续失败 N 次从 LKG 恢复,三端 supervisor 接入)+ 边界明示(agent.mjs/Node 损坏时守卫也无法运行,只能带外恢复);「运行期自动回滚」维持刻意不做(三问论证收窄到运行期)。LKG 记录补 `hash_full`/`backup_glob` 只增字段(老记录回落 12 位前缀)。附录 C 同步。 |
+137
View File
@@ -0,0 +1,137 @@
# Linux 被控端部署(Debian 12 / Ubuntu / NAS 系统等 systemd 系统)
> 适用:把一台 Linux 机器作为**被控端**,让 Mac/Linux 控制端接管。
> 本文的能力清单是 **2026-10-13 在 Debian 12 容器里实测**得出的,不是推测。
---
## 0. 一句话
Linux 被控端 = **命令 / 状态 / 文件 / 审计**(服务器型用途);
**没有 GUI 能力**(截图、键鼠、窗口、剪贴板、音量均为 Windows/macOS 实现,
Linux 端未实现 —— 调用会明确返回 `E_UNSUPPORTED_PLATFORM`,不假装可用)。
---
## 1. 快速开始(以 NAS 设备为例)
```bash
# ① 把仓库放到 NAS 上(任选其一)
# - git clone https://github.com/jkooe/nodeagent.git
# - 或从控制端 scp 整个目录过去
# ② 生成被控端载荷(在**控制端**或任意有 Node 22+ 的机器上执行一次)
node scripts/pack.mjs # → release/agent.mjs
# ③ 在 NAS 上执行安装(需要 sudo;会自动注册 systemd 服务并开机自启)
cd nodeagent
sudo bash scripts/install-linux.sh --node-id nas-01
# ④ 抄下屏幕上打印的「密钥」与「连接命令」,回控制端执行:
# nodeagent connect <NAS的IP> --port 8765 --key <密钥> --insecure
```
**装完即自启**:`systemctl status nodeagent` 应为 `active (running)`;
重启 NAS 后也会自动拉起(`systemctl is-enabled nodeagent` → `enabled`)。
### 三种安装模式
| 模式 | 命令 | 程序目录 | 自启方式 | 适用 |
|---|---|---|---|---|
| **系统级**(默认) | `sudo bash scripts/install-linux.sh` | `/opt/nodeagent` | 系统 systemd(`multi-user.target`) | NAS / 服务器,需 sudo |
| **用户级** | `bash scripts/install-linux.sh --user` | `~/.local/share/nodeagent` | 用户 systemd + `linger` | 无 sudo 的场景 |
| **不注册服务** | `sudo bash scripts/install-linux.sh --no-service` | 同上(按模式定) | 自行安排(supervisor / cron `@reboot` / nohup) | systemd 受限的系统 |
其他参数:`--port 8765`、`--allow-input`(**Linux 上无实际输入能力**,仅留存配置)、
`--uninstall [--purge]`。
---
## 2. 运行依赖(脚本会自动处理能处理的)
| 依赖 | 是否必需 | 说明 |
|---|---|---|
| **Node ≥ 22** | 必需 | 脚本优先用系统 Node;**没有就自动下载** `node-v22.x-linux-<arch>` 到 `<安装目录>/runtime/`(用 npmmirror,国内快)。NAS 上通常没有 Node → 走自动下载 ✓ |
| **systemd** | 默认必需 | 用于开机自启。没有就加 `--no-service`(脚本会给出替代启动命令) |
| **bash** | 必需 | 安装脚本与部分能力用它。**Alpine 等只有 busybox 的系统需先装 bash**(`apk add bash`) |
| **procps(`ps`)** | **`system.process.list` 需要** | Debian/Ubuntu 自带 ✓;**精简容器镜像(如 `node:*-slim`)默认没有** → 表现为「进程列表为空」。装:`apt-get install -y procps` |
| **iproute2(`ip`)** | 可选 | 仅用于安装时显示本机 IP;缺失时脚本回退 `hostname -I` / `ifconfig`,再不行显示占位符 ✓ |
| **curl 或 wget** | 下载运行时时需要 | 若已装 Node 22+ 则不需要 |
> ⚠️ 实测踩到的坑:在 **Alpine** 容器里测会看到 `spawn /bin/bash ENOENT`,
> 极易误判成「Linux 不支持」——**实际是镜像没有 bash**。
> 同理 `node:22-slim` 里 `process.list` 返回空是**没有 procps**,不是代码问题。
---
## 3. Linux 上真实可用的能力(实测)
在同一台(Debian 12)机器上跑 `node scripts/verify.mjs` 的结果:
| 能力 | Linux | 说明 |
|---|---|---|
| `system.info` | ✅ | 返回 OS 名 "Linux"、CPU 核数、主机名 |
| `system.status` | ✅ | CPU / 内存 / 磁盘(多磁盘正常识别) |
| `system.process.list` | ✅** | 需 `procps`;缺则返回空列表 |
| `system.shell.exec` | ✅ | 含非零退出码回传、**超时强制终止**(实测 exit=124) |
| `system.service.list` | ⚠️ | Windows 服务概念,Linux 上退化为 systemd 单元(部分可用) |
| `system.audit.list` / 审计链 | ✅ | 与平台无关 |
| `system.task.*` | ✅ | 异步长任务框架 |
| `fs.*`(list/read/write/stat) | ✅ | 路径按 POSIX 语义(区分大小写) |
| `log.query` / `monitor.*` | ⚠️ 部分 | `monitor` 有 Windows 专属实现,部分子能力不可用 |
| `event.watch`(file/process/net) | ✅ | 与平台无关 |
| `system.agent.update`(自更新) | ✅ | 与平台无关 |
| `screen.*` / `input.*` / `window.*` / `clipboard.*` / `audio.*` / `record.*` | ❌ | **未实现** → 明确返回 `E_UNSUPPORTED_PLATFORM` |
| `app.list` / `app.install` | ❌ | winget 专属 → 同样明确拒绝 |
**汇总对比**:本机 macOS 15 通过/0 失败;Linux(Debian 12)**9 通过 / 1 失败(缺 procps)/ 6 跳过**(跳过的都是 GUI 类)。
---
## 4. 管理命令
```bash
# 系统级
systemctl status nodeagent
journalctl -u nodeagent -f # 实时日志
systemctl restart nodeagent
sudo bash scripts/install-linux.sh --uninstall # 卸载(保留配置与审计日志)
sudo bash scripts/install-linux.sh --uninstall --purge # 连数据目录一起删
# 用户级:把上面命令里的 systemctl / journalctl 加 --user
```
**配置与数据**(`~/.nodeagent`,与 agent 侧 `agentDir()` 一致):
| 文件 | 内容 |
|---|---|
| `agent.json` | 节点 ID / 端口 / **密钥**(权限 600)/ 认证模式 |
| `audit.log` | 审计日志(JSONL,10MB 轮转留 5 份) |
| `audit-anchors.jsonl` | 审计链锚点(启用锚定后产生) |
---
## 5. 故障排查(按实测顺序)
| 现象 | 原因 | 处理 |
|---|---|---|
| `未检测到 systemd` | 容器/非 systemd 系统 | 加 `--no-service`,或改用 supervisor |
| `spawn /bin/bash ENOENT` | **系统没有 bash**(Alpine 等) | `apk add bash` / `apt-get install -y bash` |
| `进程列表为空` | **没有 `ps`**(slim 镜像) | `apt-get install -y procps` |
| 脚本"跑到一半就没了" | `set -e` 下某个命令替换失败(如缺 `ip`) | 已在 v2.0.3+ 加固为多级回退;旧版请升级脚本 |
| 控制端连不上,被控端却显示已监听 | 防火墙 | Debian: `ufw allow 8765/tcp`;firewalld: `firewall-cmd --add-port=8765/tcp --permanent && firewall-cmd --reload` |
| `E_AUTH_FAILED` | 密钥不一致 | 以被控端 `~/.nodeagent/agent.json` 的 `key` 为准 |
| GUI 能力报 `E_UNSUPPORTED_PLATFORM` | **正常**(Linux 无 GUI 实现) | 无需处理;这是「诚实拒绝」而非故障 |
---
## 6. 建议(服务器型被控端)
1. **尽快启用审计链外部锚定** —— 把链头存到 NAS 之外(云盘/另一台机器)。
本机锚点等于没锚:被控端被 root 后锚点也会一起被改。
```bash
node scripts/anchor-audit.mjs collect --out /tmp/anchor-$(date +%Y%m%d).jsonl
# 再把该文件上传到云盘,之后用 verify 子命令比对
```
2. **不要开 `--allow-input`** —— Linux 上无实际输入能力,开着只是加大配置面。
3. 若机器只有公网无内网互通,考虑用 **Hub 中转**(被控端主动外连,穿 NAT)。
+102 -3
View File
@@ -62,7 +62,7 @@
- Hub 中枢(多设备注册/发现/路由)→ 预留接口
- Android / Linux 端 → 后续
- 公网穿透 → 后续(v1 仅局域网)
- 画面串流(远程桌面级)→ 属 OnlyInOne 领域
- 画面串流(远程桌面级)→ 属另一独立项目领域
### 1.4 分阶段路线
@@ -78,7 +78,7 @@
| 术语 | 定义 |
|---|---|
| **控制端** | 皇上的 MacBook,发起调用的 AI/CLI 所在端 |
| **控制端** | 用户的 MacBook,发起调用的 AI/CLI 所在端 |
| **被控端** | Windows 机器,执行命令/装软件/查状态的端 |
| **Agent** | 常驻在 Windows 上的后台服务,接收并执行控制端指令 |
| **能力** | 一个可被远程调用的原子操作(执行命令、装软件、查状态) |
@@ -469,7 +469,7 @@
| macOS 控制端 | Keychain | `security add-generic-password -s nodeagent -a <client_id> -w <key>` |
| Windows 被控端 | DPAPI / 凭据管理器 | `ConvertTo-SecureString` 或 `node-dpapi` |
**铁律**:密钥不明文落盘、不写日志、不经网络传输(HMAC 只传摘要,不传密钥原文)。**与 OnlyInOne「公钥上云 / 私钥永不上传」约定一致。**
**铁律**:密钥不明文落盘、不写日志、不经网络传输(HMAC 只传摘要,不传密钥原文)。**与另一项目「公钥上云 / 私钥永不上传」的约定一致。**
### 6.3 演进路径
@@ -728,6 +728,105 @@ const timer = setTimeout(() => {
| 证书 | 自签证书(两端固定校验,防中间人) |
| 连接方向 | 控制端主动 → 被控端监听 |
### 7.4 被控端自启与看门狗(v2.1.0,决策 R-1)
被控端的启动链是:**外层 supervisor → 看门狗(`scripts/startup-guard.mjs`)→ agent**。
看门狗解决 `nodeagent rollback` 覆盖不到的场景:进程**完全起不来**时没有监听可连,
RPC 命令无能为力;看门狗跑在 supervisor 链上,启动连续失败 N 次(默认 3)自动从
LKG 恢复入口文件。判据与边界见 `docs/DELIVERY-PIPELINE.md` §6。
| 平台 | supervisor | 接入点(仓库文件) |
|---|---|---|
| Windows | 计划任务 `nodeagent` | `scripts/install.ps1` 的 `New-ScheduledTaskAction`(启动动作包一层 guard,`RestartCount 10`;guard 向 agent 注入 `NODEAGENT_HOME`,不依赖任务环境变量) |
| Linux | systemd unit `nodeagent` | `scripts/install-linux.sh` 的 `ExecStart`(包一层 guard,`Restart=always` 负责重启) |
| macOS | launchd LaunchAgent | **无安装器** —— 手工按下方 plist 接入(仓库不含 macOS 被控端安装器) |
**Windows / Linux 的接入由安装器自动完成**,无需手工配置;`install.ps1` 找不到看门狗
脚本时按原样直启 agent(兜底,不因缺脚本而装不上)。
**macOS 手工接入(launchd)**——被控端以 LaunchAgent 形式常驻时,把
`ProgramArguments` 包一层看门狗(`~/Library/LaunchAgents/com.nodeagent.agent.plist`):
```xml
<?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">
<plist version="1.0">
<dict>
<key>Label</key><string>com.nodeagent.agent</string>
<key>ProgramArguments</key>
<array>
<string>/opt/nodeagent/runtime/bin/node</string>
<string>/opt/nodeagent/scripts/startup-guard.mjs</string>
<string>--data-dir</string><string>/Users/Shared/.nodeagent</string>
<string>--lkg-dir</string><string>/opt/nodeagent</string>
<string>--install-dir</string><string>/opt/nodeagent</string>
<string>--threshold</string><string>3</string>
<string>--</string>
<string>/opt/nodeagent/runtime/bin/node</string>
<string>/opt/nodeagent/agent.mjs</string>
</array>
<key>WorkingDirectory</key><string>/opt/nodeagent</string>
<key>EnvironmentVariables</key>
<dict><key>NODEAGENT_HOME</key><string>/Users/Shared/.nodeagent</string></dict>
<key>RunAtLoad</key><true/>
<!-- 退出码非 0 才拉起(0 = 服务已由别的实例提供 / 已干净停止);
配合 ThrottleInterval 防崩溃启动风暴 -->
<key>KeepAlive</key>
<dict><key>SuccessfulExit</key><false/></dict>
<key>ThrottleInterval</key><integer>10</integer>
<key>StandardOutPath</key><string>/opt/nodeagent/logs/agent.out.log</string>
<key>StandardErrorPath</key><string>/opt/nodeagent/logs/agent.err.log</string>
</dict>
</plist>
```
加载:`launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.nodeagent.agent.plist`。
看门狗日志(恢复动作、失败计数)在 `<data-dir>/logs/startup-guard.log`(JSONL)。
**铁律**:看门狗自身必须「裸环境可跑」——零项目依赖、不 import 项目内模块,
只依赖 Node ≥22 内置模块 + 同目录 `lib/startup-guard-core.mjs`(纯函数)。
分发时两个文件必须保持相对位置一起拷贝(pack.mjs / install-linux.sh 已保证)。
### 与 `nodeagent rollback` 的分工(v2.1.0 回滚双通道)
| 通道 | 触发者 | 覆盖场景 | 边界 |
|---|---|---|---|
| 手动 rollback | 控制端 RPC(`nodeagent rollback`) | 新版本**能启动**但需回退 | 需被控端可达(有监听可连) |
| 看门狗自动恢复 | supervisor 链上守卫 | 新版本**完全起不来**(启动连续失败 N 次) | 只恢复到 LKG;无 LKG/无匹配放行不动盘 |
**为什么运行期仍不做自动回滚**(v2.1.0 刻意收窄,引述 `docs/DELIVERY-PIPELINE.md` §6):
① 无法归因 —— 远程设备上「更新后健康检查失败」分不清是更新本身坏了还是
网络抖动 / 端口被占 / 依赖服务没起;② 回滚循环 —— 自动回滚可能触发
「回滚→启动→又判定失败→再回滚」,无人值守设备上比停在坏版本更难恢复;
③ 失控 —— 被控端一旦在运行期自作主张改自己的程序,控制端失去对
「当前跑的是什么」的掌控。故运行期的决策权留给控制端(rollback),
启动期则由看门狗兜底(目标只有一个:LKG)。
### 7.5 selfupdate 代理出网(v2.1.0,P0-1)
真机症状:被控端自更新下载 `fetch failed`。根因不是网络差 —— Node 的 `fetch`
(undici)**不读** `HTTPS_PROXY` 等环境变量,而境内访问 GitHub 必经代理。
`apps/agent/src/net/proxy.ts`(sidecar 已验证方案的 TS 移植,只依赖 node 内置模块)
接管 `system.agent.update` 的下载:
- **代理来源优先级**:`NA_PROXY` → 目标协议环境变量(`http_proxy` / `https_proxy` /
`HTTPS_PROXY` ...,大小写都认)→ 系统代理(Windows `reg query WinINET` /
macOS `scutil --proxy`)。
- **PAC 与 socks5 明确报 note、不静默降级**:PAC 需 JS 运行时解析、
socks5 无内置客户端 —— 报出来让人知道「为什么没走代理」,而不是
悄悄直连后给一个莫名的超时。
- **回环目标恒直连**(有意的设计偏离):代理另一侧的 localhost 不是本机 ——
CI/dev 机设了 `HTTP_PROXY` 时,发往 `127.0.0.1` 自建镜像的请求会被送进
远程代理而 404。
- **重定向链共享超时预算**(防 5 跳 × 30s 穿过 RPC 超时);
**CONNECT 隧道 abort 自管**(外层 request 建立前 signal 无人受理 ——
真机 75s 配置挂 109s 的教训);TLS 默认校验证书。
- `maxBytes` 双闸(content-length 预检 + 流式中断),保住「64MB 上限」。
- ⚠️ **计划任务环境变量不可见**:Windows 计划任务的启动动作**不带**用户环境变量,
在 `install.ps1` 里设 `NA_PROXY` 对任务里的 agent 无效 —— 要么写进任务的
`EnvironmentVariables`,要么配在 agent.json 的 `update` 段(被控端自己的配置,
不依赖任何外部环境)。
---
## 第 8 章 工程结构
@@ -0,0 +1,194 @@
# 增量 PRD:nodeagent「全面完善」
> **文档性质**:增量 PRD(简单规格,不做竞品分析)。基线:`3680218`(v2.0.3)。
> 撰写:许清楚(产品经理)|日期:2026-10-25|状态:待需求方确认范围后进入阶段 2。
## 1. 项目信息
| 项 | 值 |
|---|---|
| Language | 简体中文 |
| Programming Language | TypeScript(pnpm monorepo)+ Rust(Tauri 2 壳) |
| Project Name | nodeagent |
| 原始需求复述 | 「请帮我全面完善这个项目:审查现有代码与架构,指出存在的问题和改进空间;补充缺失的核心功能与边界情况处理;优化代码结构、性能与可维护性;完善必要的文档、注释与测试用例;增加多端适配;并给出清晰、可落地的修改方案和代码示例。」 |
### 1.1 基线实测证据(2026-10-25,HEAD `3680218`)
| 项 | 实测值 | 取证方式 |
|---|---|---|
| 单元测试 | **357 全绿** | `pnpm test:unit` 实跑 |
| 端到端 | e2e **33**(`tests/e2e/run.mjs`)+ Windows 专属 **13**(`windows.mjs`) | 用例计数 |
| Rust 单测 | 18 | CHANGELOG 记载 |
| 能力数 | **52**(51 + `system.audit.l2`) | manifest 实点 |
| MCP 工具 | 41 | `apps/mcp/src/tools/defs.ts` 计数 |
| 全量覆盖率 | **66.9%**(6399/9560 行) | `docs/DELIVERY-PIPELINE.md` §8 实测记载 |
| 覆盖率门禁 | **已落地**:变更文件行覆盖 ≥70% 阻断 | `scripts/ci/check-coverage.mjs` + `quality-gate.yml` |
### 1.2 对「历史待改进项」的核实结论(先纠偏,再规划)
主理人转来的 6 条待改进项来自历史审计,其中 **3 条已被近期提交修复或部分修复**。本 PRD 按仓库现状重新基线化,避免把已完工项排进本轮:
| # | 历史待改进项 | 核实结论 | 证据 |
|---|---|---|---|
| 1 | L2 系统级审计兜底未落地,docs/AUDIT-L2.md 暴露 L2 参数是 P1 风险 | ❌ **已落地**。Windows auditpol 方案已实现并发版待发;文档现为已实现功能的设计说明,「未落地暴露」风险消除。Linux/macOS 仍仅设计(macOS 受 Apple entitlement 硬限) | 提交 `7d781d1`;`apps/agent/src/capabilities/audit-l2.ts`;`tests/unit/audit-l2.test.mjs`;manifest `system.audit.l2` |
| 2 | 被控端自更新 `selfupdate.ts` 裸 fetch,境内失败 | ✅ **确认存在,未修** | `apps/agent/src/capabilities/selfupdate.ts:42` 裸 `fetch(url,…)`;CHANGELOG「检查更新在境内必然失败」节自陈「本次未改」 |
| 3 | 陈旧计划任务 `na-gui` 抢占 8765 | ✅ **确认遗留,未清理** | `scripts/uninstall.ps1` 无清理逻辑;`apps/console/src-tauri/src/agent.rs:1084` 测试注释引为动机;控制台侧只做了单实例保护+占用报错提示 |
| 4 | 契约漂移 6 项(缺 seq/session_id/risk 等字段) | ⚠️ **仓库内未找到该台账**,无法确认。已发现的相关漂移见需求池 P1-6/待确认问题 Q3 | 全仓 grep `session_id`/`seq:`/`risk` 于 protocol 源 0 命中;`tests/unit/console-contract.test.mjs` 20 用例守卫在役 |
| 5 | 控制台未接 release.mjs、无自更新通道 | ❌ **接入 Release 已完成**;「控制台自身自更新」仍是真缺口(派生项) | 提交 `9f95560`;`.github/workflows/console-release.yml`(tag 触发,publish job 挂 4 个安装包);`latest.json` 仅含 agent 资产(`scripts/release.mjs` 无 console 字样) |
| 6 | 覆盖率 66.9%,变更文件门禁 ≥70% | ✅ **门禁已落地**(含故障注入验证);66.9% 为存量基线,非缺陷 | `docs/DELIVERY-PIPELINE.md` §2/§8;`quality-gate.yml` |
## 2. 产品定义
### 2.1 产品目标(本次「全面完善」,可度量)
| # | 目标 | 衡量标准 |
|---|---|---|
| G1 | **两条「自我修复」通道恢复可用**:被控端拉取式自更新(境内/代理环境)与更新失败后的 LKG 回滚 | 自更新在配置代理后可拉取成功、失败时报真实原因(带 via/proxy 诊断字段);`nodeagent rollback` 可从 LKG 恢复并复核上线 |
| G2 | **三端暴露完整一致**:52 项能力在 agent / CLI / MCP 三端无缺口,文档数字与实现一致 | MCP 补齐 L2 工具(41→42);README/CHANGELOG/DEVELOPMENT 的能力数、测试数与实测一致;CHANGELOG 覆盖全部未发布提交 |
| G3 | **契约与测试守住新增面**:新能力有契约、有测试、有文档三件套 | `system.audit.l2` 的 `returns_schema` 补齐(消除空壳);L2 有 e2e 覆盖(当前 0);变更文件覆盖率门禁继续有效(≥70%) |
### 2.2 范围界定(关键:6 条诉求 → 做 / 不做 / 后续)
> nodeagent 是**已上线框架**,agent 与 CLI/MCP 分开更新、新旧混用是常态(`docs/VERSIONING.md`)。
> 任何改动按「影响面」分三层,L0 层一律遵守兼容性四铁律(只增不改 / 不加必填参数 / 返回结构只加不减 / 能力集合为唯一事实源)。
**影响面分层**
| 层 | 范围 | 兼容性成本 | 版本动作 |
|---|---|---|---|
| L0 | 被控端 `apps/agent/**`(agent.mjs 运行时) | 高——旧控制端可能仍在跑 | 被控端变更必须 `pnpm release patch|minor`,否则 agent 谎报版本 |
| L1 | 控制端 CLI / MCP / 控制台(`apps/cli`、`apps/mcp`、`apps/console`) | 低——只增工具/命令/页面,旧端忽略新能力 | 随发版走;不触发 agent 版本 |
| L2 | 文档 / 测试 / CI / 脚本 | 无 | 随时可改 |
**诉求映射**
| 用户诉求 | 本次做 | 本次不做 | 后续版本做 |
|---|---|---|---|
| ① 审查代码与架构、指出问题 | 按本 PRD 需求池做**靶向审查**(阶段 2 架构师主导),产出缺陷台账并逐条转守卫 | 全仓重写、架构重构、换框架 | — |
| ② 补充缺失核心功能与边界情况 | selfupdate 代理出网(L0);LKG 回滚入口(L1);MCP L2 工具(L1);控制台 L2 视图(L1);`na-gui` 清理(L2 脚本) | Linux auditd 落地(需预装,属部署前置);macOS ESF(Apple entitlement 不可自签启用);Sysmon 自动安装(第三方驱动) | Linux/macOS L2、Sysmon 并用增强 |
| ③ 优化代码结构、性能与可维护性 | 清单驱动的小步拆分(MCP index/defs、agent 大文件);单一真源收敛;无行为变化 | 大爆炸重构;为拆而拆 | 按坏味道数据持续治理 |
| ④ 完善文档、注释与测试 | CHANGELOG 回填未发布提交;README/DEVELOPMENT 数字与 L2 补章;L2 e2e;`returns_schema` 补全 | 重写历史文档 | 协议模糊测试(fuzz) |
| ⑤ 增加多端适配 | 控制台 L2 页面(Windows 门控,复用既有 `WINDOWS_ONLY_CAPS` 机制);Linux 部署文档现状同步 | macOS 被控端 GUI 写入能力(现为只读);Windows arm64 CI runner(GitHub 不提供,只能人工) | arm64 真机验证流程化 |
| ⑥ 修改方案和代码示例 | 阶段 2/3 产出,PRD 只定范围、优先级与验收 | — | — |
### 2.3 用户故事
1. **被控端运维者**:我在境内有一台无人值守的 Windows 被控端。「我想让它拉取自更新时能走系统代理,并且更新失败时能看到真实原因(是代理没配、还是哈希不符),而不是一句 `fetch failed`。」另外,「万一更新后失联,我希望能有一条命令把它恢复到上一个已知能启动的版本,而不是只能物理到场。」
2. **控制端使用者**:我在控制台审计页除了 L1(agent 自己记的)还想看到 **L2 系统级审计**(进程创建/登录事件)的状态与采集结果,「这样攻击者绕过 agent 直接操作机器时我也留有证据」;非 Windows 被控端该入口应事前禁用并说明原因。
3. **MCP / AI 接入方**:我通过 MCP 接入 nodeagent,「希望 52 项能力全部有对应工具——特别是 L2 审计兜底,我不想为了一个能力再去装 CLI」。
4. **项目维护者**:「每次发版前,README 的能力数/测试数、CHANGELOG 的未发布段必须与实现一致;每个新能力都要有契约(returns_schema)、有测试、有文档三件套,否则我无法相信门禁。」
## 3. 技术规范
### 3.1 需求池
> 每条含:问题描述 / 现状证据 / 期望行为 / 验收标准 / 预估影响面。
> **P0 = 安全或正确性缺陷,必修;P1 = 重要缺口;P2 = 优化项。**
#### P0(必修)
**P0-1 被控端自更新在境内必然失败(裸 fetch 不读代理)**
- 问题:被控端唯一的无人值守修复通道在无透明代理的网络下不可用,且失败信息不含真实原因。
- 证据:`apps/agent/src/capabilities/selfupdate.ts:42` `fetch(url, { signal, redirect:'follow' })`;CHANGELOG「检查更新在境内必然失败」节记录三条根因(Node fetch 不读代理环境变量 / 读不到系统代理 / 超时过短)并自陈「本次未改,属被控端变更」。
- 期望行为:复用 `apps/console/sidecar/proxy.mjs` 已验证的出网方案(`NA_PROXY` → 环境变量 → 系统代理,`NO_PROXY` 命中直连;只支持 HTTP 代理,PAC/socks 明确提示不静默降级),在 agent 侧落地等价能力;超时成对下发;失败信息带 via/proxy/hint。**不削弱任何既有安全属性**(sha256 强制校验、先校验再落盘、写后校验、备份回滚全部保留)。
- 验收标准:① 有代理可达时自更新端到端成功(真机或离线本地代理服务器 + 本地 HTTPS 源站);② 代理不可达时报明确错误且不落盘;③ 新增单测覆盖来源优先级、NO_PROXY、错误映射(参照 sidecar-proxy.test.mjs 的 16 用例口径);④ 回归 357 单测 + e2e 全绿。
- 影响面:L0 被控端(PATCH 起,视范围可并入 MINOR)。
**P0-2 LKG 已落地但控制端无回滚入口(恢复路径断裂)**
- 问题:`docs/DELIVERY-PIPELINE.md` §6 明确「LKG 提供确定的回滚目标,决策权留给控制端(`nodeagent rollback` 读 LKG → 覆盖回入口 → 重启)」,但 CLI 不存在 `rollback` 命令(`apps/cli/src/` grep `rollback` 仅命中 net.ts 的类型注释)。「发布即失联」场景下,设计好的最后一环没有工具兑现。
- 证据:`apps/agent/src/lkg.ts` + `system.info.lkg` + `tests/unit/lkg.test.mjs`(8 例)已在役;CLI 无入口;`docs/DELIVERY-PIPELINE.md` §6 的承诺与实现脱节。
- 期望行为:`nodeagent rollback`:读 LKG → 与当前 agent 指纹比对 → 用 LKG 指向的备份覆盖入口(沿用 deploy/selfupdate 的「就地覆写 + 写后校验」既定姿势)→ 受控重启 → 复核 `system.info.build.hash` 等于 LKG 记录。**不提供自动回滚**(DELIVERY-PIPELINE §6 已论证:无法归因、回滚循环、失控——本项只补手动入口,不改该取舍)。
- 验收标准:① 命令存在且有 `--dry-run`(只读比对不改动);② 单测覆盖「无 LKG / 指纹已匹配 / 覆盖后校验失败回滚」;③ e2e 或真机走一次「更新→回滚→复核」闭环;④ 帮助文本与 README 同步。
- 影响面:L1 控制端(新增命令,只增不改)。
#### P1(重要缺口)
**P1-1 MCP 未暴露 `system.audit.l2`(三端暴露不完整)**
- 问题:L2 已随 agent 落地、CLI 已有 `nodeagent audit l2`(`apps/cli/src/cmd/system.ts:236`),但 MCP 41 个工具中无 L2(`apps/mcp/src/` grep `l2` 0 命中)——AI 接入方完全看不到 L2 能力,与「三端完整」目标相悖。
- 证据:`apps/mcp/src/tools/defs.ts`(41 工具,`na_audit` 仅覆盖 L1 查询);`resolveToolCall` 分发结构。
- 期望行为:新增 `na_audit_l2` 工具(action=status/enable/collect/verify + since/limit),或在 `na_audit` 上增 `l2` 参数面——二选一,需与 MCP 工具表守卫(`tests/unit/mcp-tools.test.mjs` 工具名序列断言)同步。
- 验收标准:① 工具定义 + 分发 + 单测(工具名序列守卫同步更新);② `na_audit_l2` 在非 Windows 被控端返回明确平台错误而非空结果;③ 旧宿主不感知新工具无副作用(MCP 工具表只增)。
- 影响面:L1(MINOR:新增工具)。
**P1-2 控制台无 L2 视图**
- 问题:控制台审计页只有 L1 查询/校验/导出,L2 状态与采集结果无界面入口。
- 证据:`apps/console/src` grep `l2` 0 命中;审计页见 README「审计」行。
- 期望行为:审计页增加 L2 区(status 面板:策略是否启用、两个关键开关 `SCENoApplyLegacyAuditPolicy` / `ProcessCreationIncludeCmdLine_Enabled` 的提示;collect/verify 操作与结果);沿用既有平台门控机制(`conn.ts` 的 `WINDOWS_ONLY_CAPS` 同款思路,非 Windows 事前禁用并说明)。
- 验收标准:① 前端 `invoke` 新命令经 Rust 注册(契约守卫 `console-commands.test.mjs` 自动覆盖 invoke⇄handler、emit⇄listen 双向一致);② 界面在 macOS 被控端下禁用 L2 入口;③ 不加图标、配色走 `--na-*` 变量(遵守既定界面纪律)。
- 影响面:L1(MINOR:新页面元素,协议零变更)。
**P1-3 文档与实现脱节(CHANGELOG 落后约 10 个提交、README 数字过期、DEVELOPMENT 无 L2)**
- 问题:README 仍写「51 项能力 · 269 单测 · 30 e2e · 3 项 Rust 单测」(实际 52 / 357 / 33+13 / 18);CHANGELOG「未发布」段未覆盖 `7d781d1`(L2)、`2bf45fd`(交付流水线)、`00992e5`(Linux 适配)、`141f479`(质量门禁)、`9f95560`(console-release)及 6 个 console 修复;`docs/DEVELOPMENT.md`、`docs/REMOTE-LIMITS.md` 无 L2 章节。
- 证据:上述各文件实测。
- 期望行为:CHANGELOG 回填(L2 / 流水线 / 门禁 / console-release / console 修复批次各一节,注明版本归属);README 数字同步;DEVELOPMENT 补 L2 能力条目与 returns_schema 落定后的契约;ROADMAP 台账刷新。
- 验收标准:① 文档中能力数/单测数/e2e 数与 `pnpm test:unit`、manifest 实测一致;② CHANGELOG 未发布段涵盖 `git log v2.0.3..HEAD` 全部提交的主题;③ 数字一致性有守卫(参照 version-write.test.mjs 的「仓库版本号一致性」思路扩展到能力数/测试数,或至少人工核对记录在案)。
- 影响面:L2(无兼容性成本)。
**P1-4 `system.audit.l2` 的 returns_schema 是空壳(契约不完整)**
- 问题:manifest 中该能力 `returns_schema: { type: 'object' }`,无属性枚举——第三方接入方与前端无契约可依,且 `console-contract.test.mjs` 的反向守卫按设计跳过空壳能力,「契约加了字段前端没跟上」这类漂移对它无效。
- 证据:`packages/protocol/src/capabilities/manifest/system.ts` 中 AuditL2 条目。
- 期望行为:补齐四种 action 各自的返回结构(status 的策略/开关状态、enable 的结果、collect 的条目与归一化 schema、verify 的链校验结果),与 `docs/AUDIT-L2.md` §4 的归一化 schema 对齐;补全后既有守卫自动生效。
- 验收标准:① returns_schema 完整枚举顶层属性;② `console-contract.test.mjs` 对该能力反向守卫生效(或显式记录豁免理由);③ 旧控制端不受影响(返回结构只增不减——这里是补文档性契约,不改运行时返回)。
- 影响面:L0/L1 交界(manifest 变更,随被控端发版;运行时行为不变)。
**P1-5 L2 无 e2e 覆盖**
- 问题:`tests/e2e/run.mjs` 与 `windows.mjs` grep `l2` 均 0 命中——L2 只有单测(归一化/链校验/独立落盘),无「经真实 RPC 调用」的链路验证。
- 证据:上述 grep;`tests/unit/audit-l2.test.mjs` 覆盖的是模块级函数。
- 期望行为:e2e 增加 `system.audit.l2` 用例:status 在无策略环境返回可解析结构;collect 对空 Security 日志不崩、带 since/limit;verify 对自造 L2 链文件判定正确;非 Windows 平台(CI 的 Linux runner)明确报平台不支持。
- 验收标准:① 新增用例进 `run.mjs`(跨平台可跑部分)+ `windows.mjs`(Windows 专属部分);② 全绿;③ 与 33+13 基线不冲突(端口/临时 HOME 隔离遵守既有纪律)。
- 影响面:L2(测试)。
**P1-6 `na-gui` 陈旧计划任务清理(影响面窄、成本低)**
- 问题:历史测试机残留的 `na-gui` 计划任务会抢占 8765,导致控制台被控端起不来;当前只有单实例保护与「如何找出占用者」的报错提示,**没有清理路径**——用户拿到提示后仍要自己动手。
- 证据:`apps/console/src-tauri/src/agent.rs:1084`(测试注释引为动机);`scripts/uninstall.ps1` 无相关清理;CHANGELOG「控制台单实例保护」节自陈该任务「也在抢同一个端口」。
- 期望行为:`uninstall.ps1` / `control.ps1` 卸载与修复路径中,对 `na-gui`(及 nodeagent 自建的已知任务名)做存在性检查并清理,动作进日志。
- 验收标准:① 清理逻辑只针对白名单任务名,不做通配删除;② 有单测或脚本级验证(建同名任务→跑清理→断言已删);③ 不动其他计划任务。
- 影响面:L2 脚本(仅安装/卸载链路,不动协议)。
#### P2(优化项)
| # | 项 | 问题/证据 | 期望 | 影响面 |
|---|---|---|---|---|
| P2-1 | 控制台自身自更新通道 | `latest.json` 仅含 agent 资产(`scripts/release.mjs`),控制台 `check_update` 只能比对版本+更新内置 agent.mjs,无法下载/应用控制台安装包(sidecar `main.mjs:260`) | `latest.json` 增补 console 安装包资产(url+sha256),sidecar 增下载+校验+拉起安装器;**必须**沿用「sha256 不校验不落盘」铁律 | L1(大特性,需单独一轮) |
| P2-2 | 大文件拆分 | `apps/mcp/src/index.ts` 530 行、`tools/defs.ts` 618 行;`apps/agent/src/capabilities/window.ts` 963 行(ROADMAP 记载) | 按领域拆模块,纯移动不改行为;每次拆分跑全量测试 | L0/L1(PATCH 级) |
| P2-3 | 协议模糊测试 | DELIVERY-PIPELINE §3:畸形报文是枚举式,未做随机化 fuzz | 加一轮「随机字节+随机 JSON 结构」fuzz,断言「不崩、不留未审计入口」 | L2(测试) |
| P2-4 | sidecar CONNECT 隧道 TLS 叠层离线用例 | CHANGELOG 自陈:无受信任证书的本地 TLS 源站夹具,该路径无回归守卫 | 造证书夹具或采购测试证书,补离线用例 | L2(测试) |
| P2-5 | 存量覆盖率提升 | 全量 66.9%(DELIVERY-PIPELINE §8) | 只观测不设全量硬指标;靠变更门禁(≥70%)自然抬升;GUI 注入等无头不可验模块显式豁免并记录理由 | L2 |
| P2-6 | 分支保护 | `main` protection 返回 404(DELIVERY-PIPELINE §8 已知 P1,非代码项) | 仓库设置开启「PR + CI 全绿 + 禁止直推」;需维护者在 GitHub 侧操作 | 流程 |
| P2-7 | Linux/macOS L2 与 Sysmon 并用 | AUDIT-L2.md §3.2/§3.3:Linux 需预装 auditd;macOS ESF 受 entitlement 限制;Sysmon 为第三方驱动 | 后续版本按「部署侧前置条件」成熟度逐端评估;不写「假装能用」的代码 | L0(后续) |
### 3.2 UI 设计草案(仅本轮新增部分)
- **审计页 L2 区**(控制台,`apps/console`):在既有「审计」页内增加分区,不新增顶级路由。
- 上部:L2 状态卡(策略是否启用、`SCENoApplyLegacyAuditPolicy`、`ProcessCreationIncludeCmdLine_Enabled` 两个关键开关的✓/✗与一句提示);
- 下部:操作行(collect:since/limit 输入 + 「采集」按钮;verify:「校验 L2 链」按钮)与结果表(归一化条目:ts/kind/subject/object/event_id);
- 非 Windows 被控端:整区置灰 + 文案说明(复用既有平台门控模式,与软件页/服务页一致);
- 界面纪律:纯文字导航不用图标;配色只吃 `--na-*` 变量;`emit`/`listen` 事件名双向一致(契约守卫自动覆盖)。
- **CLI**:`nodeagent rollback [--dry-run]` 输出三段式:LKG 记录(hash/bytes/recorded_at)→ 与当前指纹比对 → 执行结果与复核提示;复用既有 `deploy` 的输出风格。
- **MCP**:`na_audit_l2` 工具描述写清信任边界(本地副本、防篡改靠转发出机),避免 AI 误用为「不可篡改证据」。
### 3.3 待确认问题(需需求方确认,PRD 不擅自假设)
| # | 问题 | 背景与建议 |
|---|---|---|
| Q1 | **版本策略**:本轮含被控端变更(P0-1 selfupdate)+ 新能力面(P1-1/P1-4 L2 契约与 MCP 工具),按 `docs/VERSIONING.md` 新能力 = MINOR。建议一次性 **v2.1.0** 发版(而非 2.0.4),L2 从「已在 agent 未发版」转为正式发布能力。是否批准? | 若拆成 2.0.4(selfupdate)+ 2.1.0(L2 周边)两发,则中间态 agent 与文档会再次短期脱节 |
| Q2 | **selfupdate 代理范围**:A) 全量复用 sidecar 方案(环境变量 + 系统代理发现 + 明确错误,被控端新增出网模块);B) 最小集(仅 `NA_PROXY`/`HTTPS_PROXY` 环境变量 + 现有超时放大);C) 不改代码,仅文档引导用镜像 URL。建议 A——B 留「用户明明配了系统代理却白搭」的坑,C 把问题推给用户 | A 的代价:被控端新增约 150~250 行 + 单测;需评估 agent 包体积预算 |
| Q3 | **「契约漂移 6 项(缺 seq/session_id/risk 等字段)」的出处**:仓库内未找到该台账(protocol 源 grep 三字段 0 命中;console-contract 守卫 20 用例在役)。请提供台账来源;若指「审计条目应有 seq/session_id/risk 字段」,则给 `AuditEntry` 加字段属返回结构只增(可行),但需一并定义消费方(控制台审计页/MCP na_audit)与脱敏口径——是否纳入本轮 P1? | 在澄清前,本 PRD 只将「L2 returns_schema 空壳」(P1-4)与「文档数字漂移」(P1-3)列为实证漂移 |
| Q4 | **L2 是否本期接 MCP/控制台**(P1-1/P1-2):agent 已落地、CLI 已有入口;MCP 与控制台补齐属「只增」。建议本期做——否则 AI 接入方与图形用户完全看不到 52 项中的第 52 项 | 若砍,则 G2「三端完整」目标降级为「agent+CLI 完整」,需需求方确认接受 |
| Q5 | **na-gui 清理是否本期做**(P1-6):影响面仅历史测试机,但属「用户拿到报错后仍无法自救」的最后一公里。建议做(成本约半天) | — |
| Q6 | **P2 项是否全部出到后续版本**:P2-1(控制台自更新)是较大的独立特性,建议明确排期到 v2.2.0,不在本轮夹带 | 避免本轮范围膨胀 |
## 4. 附:本轮交付物流转(供阶段 2/3 参考)
1. 阶段 2(架构师):按需求池逐条出「修改方案 + 代码示例」,P0 先行;契约类改动(P1-4)先落 manifest 再动实现。
2. 阶段 3(开发):P0 → P1 → P2;每个 P0/P1 项完工即补「会红的守卫」(本项目铁律:没有变成断言的修复等于没有修复)。
3. 阶段 4(测试):回归 357 单测 + e2e 33+13 + Rust 18;新增 L2 e2e;发版前按 DELIVERY-PIPELINE §5 真机矩阵(Windows x64 必跑、arm64 人工必查)与 §7 发布后复核清单执行。
4. 发版:按 Q1 结论执行 `pnpm release`;被控端变更一律升号,控制台变更重新构建分发安装包(console-release.yml 已随 tag 自动挂 4 包)。
+9 -9
View File
@@ -7,7 +7,7 @@
| 文档版本 | v1.0(已认可) |
| 作者 | 司南(产品经理 Agent) |
| 日期 | 2026-09-27 |
| 客户 | 皇上(个人开发者) |
| 客户 | 个人开发者 |
| 项目名 | nodeagent |
| 状态 | ✅ 已认可,进入开发 |
| 参考文件 | 《NodeBuddy-开发文档.zip》(仅作参考,非任务书) |
@@ -16,12 +16,12 @@
## 1. 背景与痛点
**核心痛点一句话**:皇上在 MacBook 上使用 AI(WorkBuddy),AI 只能操作 Mac 本机;当需要「在 Windows 上装软件 / 查状态」时,AI 无法跨过去接管 Windows 完成。
**核心痛点一句话**:用户在 MacBook 上使用 AI(WorkBuddy),AI 只能操作 Mac 本机;当需要「在 Windows 上装软件 / 查状态」时,AI 无法跨过去接管 Windows 完成。
| # | 痛点 | 具体表现 |
|---|---|---|
| P1 | 跨机断点 | AI Agent 跑在 Mac 上,无法触达 Windows 执行操作 |
| P2 | 手动替代 | 装软件、查状态需皇上亲自切换到 Windows 操作,打断工作流 |
| P2 | 手动替代 | 装软件、查状态需用户亲自切换到 Windows 操作,打断工作流 |
| P3 | 无统一入口 | 每次都要用远程桌面/SSH 手动拼命令,无法被 AI 自动编排 |
## 2. 目标与成功指标
@@ -47,7 +47,7 @@
| 术语 | 定义 |
|---|---|
| 控制端 | 皇上的 MacBook,发起调用的 AI/CLI 所在端 |
| 控制端 | 用户的 MacBook,发起调用的 AI/CLI 所在端 |
| 被控端 | Windows 机器,执行命令/装软件/查状态的端 |
| Agent | 常驻在 Windows 上的后台服务,接收并执行控制端指令 |
| 能力 | 一个可被调用的原子操作(执行命令、装软件、查状态) |
@@ -77,7 +77,7 @@
| 3 | Hub 中枢(多设备注册/发现/路由) | 后续(预留接口) |
| 4 | Android / Linux 端 | 后续 |
| 5 | 公网穿透 | 后续(v1 仅局域网) |
| 6 | 画面串流(远程桌面级) | 属 OnlyInOne 领域,不做 |
| 6 | 画面串流(远程桌面级) | 属另一独立项目领域,不做 |
## 5. 关键决策记录
@@ -91,13 +91,13 @@
## 6. 用户角色与核心场景
**角色**:皇上(MacBook 用户,WorkBuddy 重度用户)
**角色**:MacBook 用户(WorkBuddy 重度用户)
| 场景 | 描述 |
|---|---|
| S1 装软件 | 皇上在 Mac 上对 AI 说「在 Windows 上装个 VSCode」,AI 通过框架调用 Windows 完成安装并回报结果 |
| S2 查状态 | 皇上问「Windows 现在 CPU/内存/磁盘怎么样」,AI 查询并返回 |
| S3 执行命令 | 皇上让 AI 在 Windows 上跑一条命令(如重启某服务、查日志) |
| S1 装软件 | 用户在 Mac 上对 AI 说「在 Windows 上装个 VSCode」,AI 通过框架调用 Windows 完成安装并回报结果 |
| S2 查状态 | 用户问「Windows 现在 CPU/内存/磁盘怎么样」,AI 查询并返回 |
| S3 执行命令 | 用户让 AI 在 Windows 上跑一条命令(如重启某服务、查日志) |
## 7. 功能需求(FR)
+19 -19
View File
@@ -15,7 +15,7 @@
| **改系统设置** | ⚠️ 多数可 | 注册表、服务、计划任务、防火墙规则等可用命令改 |
| **改网络配置** | ⚠️ **高风险** | 能做,但**可能把自己弄断**(详见 §2.1) |
| **操作 GUI** | ❌ 基本不可 | 鼠标键盘默认禁用,且即使开启也难判读界面状态(详见 §2.2) |
| **需要人工判断的决策** | ❌ 不可 | 选哪个代理节点、订阅是否有效 —— 臣无从判断(详见 §2.3) |
| **需要人工判断的决策** | ❌ 不可 | 选哪个代理节点、订阅是否有效 —— AI 无从判断(详见 §2.3) |
| **物理动作** | ❌ 不可 | 插拔网线、按电源键、换硬盘 |
---
@@ -51,17 +51,17 @@
### 2.2 无 GUI 交互能力
**问题**:`input.*`(鼠标键盘)出于安全考虑**默认禁用**;即使开启,臣也难以完成 GUI 任务。
**问题**:`input.*`(鼠标键盘)出于安全考虑**默认禁用**;即使开启,AI 也难以完成 GUI 任务。
| 场景 | 为什么做不到 |
|---|---|
| 代理软件换节点 / 更新订阅 | 需在 v2rayN 界面里点选,且**节点可用性关乎上游服务**,臣无法判断 |
| 代理软件换节点 / 更新订阅 | 需在代理客户端界面里点选,且**节点可用性关乎上游服务**,AI 无法判断 |
| UAC 提权弹窗 | 需人点「是」,agent 无法代点(安全设计) |
| 安装向导(下一步 → 完成) | 界面状态判读不可靠 |
| 浏览器登录 / 扫码 | 需要人的凭据与设备 |
**臣能做的替代**:用**命令行等价操作**绕过 GUI(如 `netsh`、注册表、服务命令)。
但**第三方 GUI 软件的内部状态**(如 v2rayN 里"当前选中哪个节点")通常没有命令行接口。
**AI 能做的替代**:用**命令行等价操作**绕过 GUI(如 `netsh`、注册表、服务命令)。
但**第三方 GUI 软件的内部状态**(如代理客户端里"当前选中哪个节点")通常没有命令行接口。
---
@@ -71,7 +71,7 @@
- 代理**订阅是否过期**、**节点是否可用** —— 需要人的账号与常识
- 哪个软件该装、哪个进程能杀 —— 需要业务判断
- 皇上看到的报错在**什么操作后出现** —— 臣无法感知复现路径
- 用户看到的报错在**什么操作后出现** —— AI 无法感知复现路径
---
@@ -80,8 +80,8 @@
| 限制 | 影响 |
|---|---|
| 只能看**当下快照** | 间歇性问题(如抖动、偶发超时)容易漏判 |
| 无法 7×24 盯 | 臣只在被调用时"看一眼",问题在两次调用之间发生就看不到 |
| 应用层内部状态不可见 | 如 WorkBuddy 的"当前正在等哪个请求",臣只能从日志反推 |
| 无法 7×24 盯 | AI 只在被调用时"看一眼",问题在两次调用之间发生就看不到 |
| 应用层内部状态不可见 | 如 WorkBuddy 的"当前正在等哪个请求",AI 只能从日志反推 |
**缓解(✅ 已于 v2.0.0 实现)**:`monitor.*` 定时采样并落盘,事后回看摘要 —— 正是为治
这类间歇性问题而做(原先只写"具备实现基础",现已成为能力):
@@ -140,27 +140,27 @@ nodeagent --node win monitor report probe # 直接给出:断了几次 / 最
| 网络适配器 | Ethernet Up / 1 Gbps | 链路正常 |
| 国内网络(百度) | HTTP 200 / 0.24s | 基础联网正常 |
| `copilot.tencent.com` | HTTP 200 / 0.35s(IPv4) | 后端可达 |
| v2rayN + xray 进程 | 均在运行 | 代理软件活着 |
| xray 监听 | `0.0.0.0:10808` | 代理端口在听 |
| **系统代理** | **`ProxyEnable=0`,但 `ProxyServer` 残留 `127.0.0.1:10808`** | ⚠️ 配置矛盾 |
| 走 10808 访问外网 | 超时(HTTP 000 / 20s) | ⚠️ **上游节点不通** |
| 代理客户端 + 代理内核进程 | 均在运行 | 代理软件活着 |
| 代理内核监听 | `0.0.0.0:<代理端口>` | 代理端口在听 |
| **系统代理** | **`ProxyEnable=0`,但 `ProxyServer` 残留 `127.0.0.1:<代理端口>`** | ⚠️ 配置矛盾 |
| 走该代理端口访问外网 | 超时(HTTP 000 / 20s) | ⚠️ **上游节点不通** |
| **`resolveProxy` RPC** | **耗时 1737ms(慢)** | ⚠️ 代理解析卡顿 |
| **MarketplaceInitGate** | **15s 安全超时后强放行** | ⚠️ 启动期被拖慢 |
| WorkBuddy 连接 | 4 条 IPv6 到腾讯(已建立) | 部分链路正常 |
| TCP `CloseWait` | 24 条 | 偏多,疑似连接被中途中断 |
**结论(高度可能)**:
系统存在**「代理配置残留但未启用,且 xray 上游节点实际不通」**的矛盾状态 ——
系统存在**「代理配置残留但未启用,且代理内核上游节点实际不通」**的矛盾状态 ——
WorkBuddy 解析代理慢 + 部分请求走了不通的代理链路 → `Response timed out`。
**为什么臣没有直接修**:
- 需要的动作是「在 v2rayN 界面换可用节点 / 更新订阅」→ **属 GUI + 人工判断**(§2.2 / §2.3)
- 若改为「清空系统代理残留」→ 属网络配置变更,虽有价值但**需皇上确认**(§2.1 风险)
**为什么 AI 没有直接修**:
- 需要的动作是「在代理客户端界面换可用节点 / 更新订阅」→ **属 GUI + 人工判断**(§2.2 / §2.3)
- 若改为「清空系统代理残留」→ 属网络配置变更,虽有价值但**需用户确认**(§2.1 风险)
**建议的下一步**(人工为主):
1. 打开 v2rayN → 测试节点延迟 → 换一个可用节点(或更新订阅)
2. 确认 v2rayN 的「自动配置系统代理」策略与实际需求一致
3. 观察是否仍有 `Response timed out`;若仍有,可让臣**持续采样**再定位
1. 打开代理客户端 → 测试节点延迟 → 换一个可用节点(或更新订阅)
2. 确认代理客户端的「自动配置系统代理」策略与实际需求一致
3. 观察是否仍有 `Response timed out`;若仍有,可让 AI **持续采样**再定位
---
+14 -14
View File
@@ -4,7 +4,7 @@
> 2026-10-07 逐项核查:P0 全部解决、P1 仅「定时采样」未做、P2 已补大半。
> **当前状态与下一步路线以 [`ROADMAP.md`](./ROADMAP.md) 为准**,本文保留作为「问题从哪来」的历史记录。
> 基于 2026-09-27 ~ 09-28 的真实使用(Windows 真机部署 + v2rayN 故障排查 + 重装前信息采集)
> 基于 2026-09-27 ~ 09-28 的真实使用(Windows 真机部署 + 代理客户端故障排查 + 重装前信息采集)
> 所有问题均来自**实际踩坑**,非理论推演。
---
@@ -12,7 +12,7 @@
## 一、最痛的问题:GUI 语义能力缺失(P0)
### 场景还原
排查 v2rayN 节点问题时,臣需要**切换活动节点**:
排查代理客户端节点问题时,AI 需要**切换活动节点**:
| 尝试 | 结果 |
|---|---|
@@ -20,7 +20,7 @@
| 重新算坐标再点 | ❌ 又点到第 3 行 |
| 改用键盘 `↑`/`↓`/`Enter` | ✅ 成功 |
**问题本质**:臣有 `screen.capture`(能**看**)和 `input.mouse.*`(能**点**),但**没有把「看到的界面元素」映射为「可点击坐标」的能力**。
**问题本质**:AI 有 `screen.capture`(能**看**)和 `input.mouse.*`(能**点**),但**没有把「看到的界面元素」映射为「可点击坐标」的能力**。
每次都要:截图 → 人眼估算像素 → 换算缩放的 region 偏移 → 点击 → 再截图验证。**误差极大,反复试错**。
@@ -33,7 +33,7 @@
2. **截图附带坐标网格**
- `screen.capture` 可选叠加坐标标尺(每 100px 标线)
- 臣看图即可精确推算坐标,无需盲试
- AI 看图即可精确推算坐标,无需盲试
3. **鼠标相对定位**
- `input.mouse.move` 支持 `--relative dx dy`(当前只有绝对坐标)
@@ -69,7 +69,7 @@
- 让「写配置文件」这类高频操作**默认安全**
3. **结构化返回**(不靠文本传递)
- 现在 `exec` 的输出是**纯文本 + 尾部附加 `[退出码 0 · 410ms]`**,导致臣解析 JSON 时被污染
- 现在 `exec` 的输出是**纯文本 + 尾部附加 `[退出码 0 · 410ms]`**,导致 AI 解析 JSON 时被污染
- 应改为**结构化字段**:`{ stdout, stderr, exit_code, duration_ms }` —— 后端本来就有,只是 CLI 拼接成文本了
---
@@ -77,13 +77,13 @@
## 三、进程与生命周期:自断生路(P0)
### 场景还原
臣用 `system.shell.exec` 执行「杀旧 agent + 启新 agent」来让配置生效:
AI 用 `system.shell.exec` 执行「杀旧 agent + 启新 agent」来让配置生效:
```
Start-Process powershell -ArgumentList '... Start-Sleep 3; Stop-Process ...; Start-Process node.exe ...'
```
**结果**:**分离的 PowerShell 被 agent 的进程树清理逻辑一起杀掉了** → 新 agent 从未启动 → 端口僵死 → **臣彻底失联**,只能请皇上手动恢复。
**结果**:**分离的 PowerShell 被 agent 的进程树清理逻辑一起杀掉了** → 新 agent 从未启动 → 端口僵死 → **AI 彻底失联**,只能请用户手动恢复。
### 改进方向
@@ -93,7 +93,7 @@ Start-Process powershell -ArgumentList '... Start-Sleep 3; Stop-Process ...; Sta
```
- 由 agent **自己**实现:fork 一个「延时重启器」(脱离进程组),或注册一次性计划任务
- 重启后**主动回连/上报**,让控制端知道「我回来了」
- 这样 90% 的配置变更新场景都不再需要皇上介入
- 这样 90% 的配置变更新场景都不再需要用户介入
2. **健康检查与自愈**
- agent 内置看门狗:若 N 秒内未响应,自我重启
@@ -104,16 +104,16 @@ Start-Process powershell -ArgumentList '... Start-Sleep 3; Stop-Process ...; Sta
- 或者:`exec` 增加 `detach: true` 参数
4. **改端口/改配置前先"预演"**
- 支持用一个临时配置在**旁路端口**试启动,成功再切换 —— 这正是这次出事后臣总结的正确做法
- 支持用一个临时配置在**旁路端口**试启动,成功再切换 —— 这正是这次出事后 AI 总结的正确做法
---
## 四、可观测性与长时任务(P1)
### 场景还原
- 代理不通问题**反复发作**,但臣只能看**当下快照**(`netstat`、`Get-Process`),**无法看到 5 分钟前的状态**
- 代理不通问题**反复发作**,但 AI 只能看**当下快照**(`netstat`、`Get-Process`),**无法看到 5 分钟前的状态**
- 想跑「持续 5 分钟、每 2 秒记录一次连接状态」的采样 → **`exec` 默认 30 秒超时**,长命令直接被砍
- 排查 360 拦截时,需要「退出 360 前后对比」→ **臣无法安排这个时序**
- 排查第三方杀软拦截时,需要「退出杀软前后对比」→ **AI 无法安排这个时序**
### 改进方向
@@ -132,12 +132,12 @@ Start-Process powershell -ArgumentList '... Start-Sleep 3; Stop-Process ...; Sta
- 专治「间歇性问题」—— 这次代理不通就是典型的间歇性/条件性问题
3. **事件订阅(push 模型)**
- 现在全是**拉取式**(臣问一次,答一次)
- 现在全是**拉取式**(AI 问一次,答一次)
- 应该支持:`subscribe { events: ['process.crash','net.change','file.change'] }`
- 让被控端**主动推**异常,而不是等臣去查
- 让被控端**主动推**异常,而不是等 AI 去查
4. **日志聚合查询**
- 这次查 v2rayN 日志,臣只能 `Get-Content -Tail N` 手工翻
- 这次查代理客户端日志,AI 只能 `Get-Content -Tail N` 手工翻
- 应提供 `log.query { path, since, level, pattern, limit }`,由被控端侧做高效过滤
---
+48 -12
View File
@@ -168,7 +168,7 @@ await gui.await(condition:"control", text:"完成", timeout_ms:30000)
| 方案 | 代价 | 效果 |
|---|---|---|
| **A. 只清未来**(最低) | 0 | 新增代码与提交信息一律脱敏,历史不动。仓库公开仍有痕迹 |
| **B. 清注释 + 清未来**(臣建议) | 半小时 | 源码注释泛化为「主流播放器」「全屏游戏」,不动历史 |
| **B. 清注释 + 清未来**(建议) | 半小时 | 源码注释泛化为「主流播放器」「全屏游戏」,不动历史 |
| **C. B + 改写历史** | 需 `git filter-repo` + force push | 连历史一并清,但**旧 blob 已被匿名抓取过,改写不等于收回** |
> ⚠️ 关键认知:**已推送到公开仓库并被匿名抓取过的内容,改写历史无法收回**。旧 blob 只能靠 GitHub Support GC,而 Support GC 本身也非保证。因此现实目标应是「**止血 + 不再恶化**」,即方案 B。
@@ -206,7 +206,7 @@ node scripts/verify.mjs --host <地址> --key <密钥> --report <报告.md>
### 4.3 大前端方案(`docs/console-plan.md`,已出文档待评审)
盘点期间新增了一份前端方案文档,臣不在此展开(详见该文档),仅提示它引入了**一个新包 `crates/nodeagent-client`(Rust)**〔**2026-10 已退役**:改为 Node sidecar 方案,见 `docs/console-plan.md` 文末「实施决策」〕,与现有 TypeScript 的 `@nodeagent/client` 并存 —— 意味着 client 层有两套实现。这本身是个需要皇上拍板的架构决策(详见该文档的待确认项)。
盘点期间新增了一份前端方案文档,本文不在此展开(详见该文档),仅提示它引入了**一个新包 `crates/nodeagent-client`(Rust)**〔**2026-10 已退役**:改为 Node sidecar 方案,见 `docs/console-plan.md` 文末「实施决策」〕,与现有 TypeScript 的 `@nodeagent/client` 并存 —— 意味着 client 层有两套实现。这本身是个需要用户拍板的架构决策(详见该文档的待确认项)。
---
@@ -270,7 +270,7 @@ node scripts/verify.mjs --host <地址> --key <密钥> --report <报告.md>
### 方向二:状态 —— 治间歇性问题
**判断**:复盘 §四那条判断仍成立 —— 代理不通、杀软拦截都是**间歇性**的,而臣只能看当下快照。`event.watch` 解决「有事件时通知」,没解决「持续记录并回看」。
**判断**:复盘 §四那条判断仍成立 —— 代理不通、杀软拦截都是**间歇性**的,而 AI 只能看当下快照。`event.watch` 解决「有事件时通知」,没解决「持续记录并回看」。
| 能力 | 设计 | 验收标准 |
|---|---|---|
@@ -292,7 +292,7 @@ node scripts/verify.mjs --host <地址> --key <密钥> --report <报告.md>
| **安装期密钥传递** | `install.cmd` 用命令行 `-Key` 传参,**同机其他用户 `ps` 可见** | 改走 stdin 或临时文件(600 权限,读后即删)。这是唯一还没堵住的密钥暴露面 |
| **审计链外部锚定** | 哈希链可被「有 root 的攻击者」整链重写 | 定期把链头哈希同步到独立位置(如 Hub 或另一台机器),重写即暴露 |
**臣另建议一项**:**ed25519 默认化**。`SECURITY.md` §2 已写明 PSK 模式的固有限制(`client_id` 自报 → ACL 身份维度形同虚设)。v22 的每客户端独立 PSK 只是缓解 —— 持有共享 key 的人仍可冒充未登记的 client_id。新装默认应走 ed25519,PSK 降级为「兼容存量」并在安装时明确告警。
**另建议一项**:**ed25519 默认化**。`SECURITY.md` §2 已写明 PSK 模式的固有限制(`client_id` 自报 → ACL 身份维度形同虚设)。v22 的每客户端独立 PSK 只是缓解 —— 持有共享 key 的人仍可冒充未登记的 client_id。新装默认应走 ed25519,PSK 降级为「兼容存量」并在安装时明确告警。
---
@@ -338,18 +338,18 @@ node scripts/verify.mjs --host <地址> --key <密钥> --report <报告.md>
| 5 | 方向三:`install.cmd` 密钥改 stdin + 握手封禁 + 会话上限 | 1~2 天 | 无(安全项建议提前插队) |
| 6 | 方向二:`monitor.*` + `log.query` + `fs.watch` | 2~3 天 | 无 |
| 7 | `.gitignore` 补密钥规则 + 源码真机常量注入化 | 分散 | 无 |
| 8 | PII blob 报 Support GC / 决定是否重建仓库 | — | **需皇上决策** |
| 8 | PII blob 报 Support GC / 决定是否重建仓库 | — | **需用户决策** |
| 9 | `ed25519` 默认化 | 1~2 天 | 需确认向后兼容策略 |
| 10 | `console-plan.md` 评审:Rust client 与 TS client 双实现取舍 | — | **需皇上拍板** |
| 10 | `console-plan.md` 评审:Rust client 与 TS client 双实现取舍 | — | **需用户拍板** |
**臣的建议**:
**建议**:
- **序 0 立刻做**。5 分钟的事,但不做则单测红、CI 必红。注意建议顺手把断言改成「每个 `CapabilityNames` 键都在 manifest 中」——否则每加一能力都要改数字,这类「硬编码计数」会反复咬人。
- **序 0.5 紧接着做**。只改注释,20 分钟,零风险,但能阻止「设备软件清单」继续扩散。`gui.await` 也正好是新代码,此时立脱敏规矩最自然。
- **序 1 是当前主线**。`gui.await` 实现已完成(170 行 + 57 行契约 + 注册),设计也站得住(超时正常返回、轮询期异常不中断、复用既有能力零新增探测代码)。差的只是收尾四件事:单测、CLI/MCP 入口、版本号、真机验证。**建议一次做完再提交,不要分批**。
- **序 3 优先级高于新功能**。v1.5.0 是**未经真机验证就发的版**,其中「后台定向投递」只在媒体键通道验证过、等价推断未经实测。
- **序 5 可提前**。`install.cmd` 密钥经命令行传参(同机 `ps` 可见)是当下唯一未堵住的密钥暴露路径,改动局限在一个脚本内。
- **序 8、10 需皇上决策**:前者关系到公开历史能否部分收回,后者关系到是否长期维护两套 client 实现。
- **序 8、10 需用户决策**:前者关系到公开历史能否部分收回,后者关系到是否长期维护两套 client 实现。
> **一条经验**:`gui.await` 一次加能力就让单测红,原因是能力数被硬编码成 `42`。这类「计数断言」在持续加能力的项目里必然反复失效。**改为集合断言**(每个能力名都在 manifest 中)比每次改数字更省事,也更不容易漏检。
@@ -392,7 +392,7 @@ node scripts/verify.mjs --host <地址> --key <密钥> --report <报告.md>
| **`SECURITY.md` §5「还没做的」清单** | ✅ **全部完成** | 见 §4.5~§4.8 |
| **发版 v2.0.0** | ✅ 2026-10-10 | 37 个提交一次发出;资产 agent.mjs(sha256 `7164ea44…`) / win-x64.zip / latest.json / SHA256SUMS |
| v1.5.0 四项 + v2.0.0 清理策略**真机验证** | ✅ **已完成**(VM · Win11 ARM) | A 组 15/0/1 · B1 四项全过 · B2 三防护全过 · B3 清理**验证并改进** · B4 锚点端到端 · B5 结论:x64 包在 ARM 不可用→已出 arm64 包。**共发现并修复 5 个真问题**(详见 `VM-VERIFICATION.md`) |
| ↑ **可用 VM 补验证** | 📋 见 [`VM-VERIFICATION.md`](VM-VERIFICATION.md) | Parallels 27 + Win11 **ARM**:协议层(v23/v26/锚点)与输入层(v1.5 四项)**可直接验**;杀软/显示/性能**仍需真机** |
| ↑ **可用 VM 补验证** | 📋 见 [`VM-VERIFICATION.md`](VM-VERIFICATION.md) | 宿主虚拟化平台 + Win11 **ARM**:协议层(v23/v26/锚点)与输入层(v1.5 四项)**可直接验**;杀软/显示/性能**仍需真机** |
### 真机验证过程中抓到的真 bug(8 个,全部只有真机能发现)
@@ -411,7 +411,7 @@ node scripts/verify.mjs --host <地址> --key <密钥> --report <报告.md>
| 事项 | 状态 | 说明 |
|---|---|---|
| `gui.await` 收尾(断言/单测/入口/版本号/本地真机/发版) | ✅ | v1.6.0 |
| CI 工作流推送(需皇上授权 `workflow` 作用域) | ✅ | 皇上授权后推送成功 |
| CI 工作流推送(需用户授权 `workflow` 作用域) | ✅ | 用户授权后推送成功 |
| **Windows CI 上线后修 6 个真 bug** | ✅ | 见下表 |
| 源码注释脱敏(序 0.5) | ✅ | `input.ts` / `input-script.ts` 4 处软件名泛化 |
| `.gitignore` 补 `keys/ *.key *.pem`(§6.2) | ✅ | `*.pub` 放行 |
@@ -420,9 +420,9 @@ node scripts/verify.mjs --host <地址> --key <密钥> --report <报告.md>
| 方向一第二步:`screen.find` 属性化 + 条件组合 | ✅ **已完成**(v2.0.0) | 见 §五 的对应行 |
| 方向二:`monitor.*` / `log.query` / `fs.watch` | ✅ **已完成**(v2.0.0) | `monitor.*` 5 项 + `log.query` 已实现;`fs.watch` 由 `event.watch` 覆盖 |
| 方向三:`install.cmd` 密钥 stdin、握手封禁、会话上限、审计锚定 | ✅ **已完成**(v2.0.0,2026-10-11 真机验证) | 三防护三场景全过;锚定端到端 + 云盘链外留存已跑通 |
| PII blob GC / 仓库重建(序 8) | ⏳ 需皇上决策 | — |
| PII blob GC / 仓库重建(序 8) | ⏳ 需用户决策 | — |
| `ed25519` 默认化 | ✅ **已完成**(v2.0.0) | 「零信任优先安装」:有 client-acl.json 即走 ed25519(default_effect=deny) |
| console-plan 评审(Rust client 取舍) | ⏳ 需皇上拍板 | — |
| console-plan 评审(Rust client 取舍) | ⏳ 需用户拍板 | — |
### Windows CI 扒出的 6 个真 bug(均已修复,v1.6.x)
@@ -445,3 +445,39 @@ node scripts/verify.mjs --host <地址> --key <密钥> --report <报告.md>
6. 发版/打包脚本不得假设 unix 命令存在(`zip`/`ls`/`du`/`node_modules/.bin/*`)
7. CI 断言会随属性变化过期,且方向可能反了
8. 工作区有他人未提交文件时禁用 `git add -A` / `commit -am`
---
## 十、v2.1.0「全面完善」:已落地与遗留(2026-10-26 收口)
本轮(`docs/ARCH-2026-10-25` / `docs/PRD-2026-10-25`)范围的全部落地情况:
计划项从路线划掉,未做的按 P2 登记在案 —— 不塞进 v2.1.0。
### 已落地(从计划划掉)
| 项 | 状态 | 落点 |
|---|---|---|
| 版本统一 v2.1.0 + CHANGELOG 骨架 | ✅ | `a75c5c9` |
| selfupdate 代理出网(NA_PROXY→env→系统代理) | ✅ | `apps/agent/src/net/proxy.ts` |
| LKG 内容指针(`hash_full` / `backup_glob`) | ✅ | `apps/agent/src/lkg.ts` |
| 控制端 rollback(ARCH T03) | ✅ | `apps/cli/src/cmd/rollback.ts` |
| 看门狗启动自动恢复(决策 R-1) | ✅ | `scripts/startup-guard.mjs` + 三端 supervisor 接入 |
| L2 三端暴露(MCP `na_audit_l2` + 控制台 + `agent.l2`) | ✅ | `5a2c5f9` |
| na-gui 陈旧计划任务白名单清理 | ✅ | `scripts/lib/scheduled-tasks.mjs` + `control.ps1 cleanup` |
| L2 e2e + 文档数字守卫 | ✅ | `tests/e2e/*`、`tests/unit/docs-numbers.test.mjs` |
| 架构审查三 P0(validate 原型链 / 限速桶 key / describeArgs) | ✅ | `68ea3cd`、`560b3c4` |
### 遗留(P2 登记,明确不塞进 v2.1.0)
| 项 | 优先级 | 为什么现在不做 |
|---|---|---|
| 控制台自更新(检查更新/自升级) | P2 · v2.2.0 | 控制台手动重装成本低;且需先定「更新通道与回滚」策略,不与本轮被控端通道混 |
| Linux L2(auditd) | P2 | 需预装 auditd + 规则管理,属被控端环境依赖,超出「零依赖立起来」原则 |
| macOS L2(ESF) | P2 | Apple entitlement 硬限,个人自签证书无法启用;除非走 Developer ID + 公证分发 |
| macOS 被控端安装器 | P2 | 当前无安装器(launchd 手工接入见 `docs/DEVELOPMENT.md` §7.4);macOS 被控端场景少,先文档兜住 |
| 看门狗日志轮换 | P2 | `startup-guard.log`(JSONL)无轮换;长期运行设备需运维关注,先以告警文案兜住 |
### 门禁数字(2026-10-26,Mac 实测)
52 项能力 · 42 个 MCP 工具 · 467 项单元测试 · 50 项端到端(34 通用 + 16 Windows 专属)·
23 项 Rust 壳单测 —— README 数字由 `tests/unit/docs-numbers.test.mjs` 钉死,腐烂即红。
+41 -39
View File
@@ -1,6 +1,6 @@
# VM 验证清单(Parallels Desktop 27 + Windows 11 ARM)
# VM 验证清单(宿主虚拟化平台 + Windows 11 ARM)
> 用途:Windows 真机不可用时,用 **Parallels 27 的 Windows 11 ARM 虚拟机**提前跑掉
> 用途:Windows 真机不可用时,用**虚拟化平台上的 Windows 11 ARM 虚拟机**提前跑掉
> **架构无关**的验证项,把"等机器"的时间省下来。
>
> ⚠️ **本清单的结论必须按 §5 的规则标注**:VM 验过的项写「VM 已验」,并注明
@@ -16,7 +16,7 @@
| **输入注入层** | v1.5 四项(字符串热键/预设别名/长按/后台定向投递) | ✅ 可验(走 Win32 `SendInput`/`WM_APPCOMMAND`,ARM Windows 同样完整) |
| **安装与自持** | `install.cmd` 自提权、计划任务注册、受控重启 | ✅ 可验(VM 里也是真 Windows,机制一致) |
| **系统能力** | 进程/服务/文件/剪贴板/命令执行/`winget` | ✅ 可验 |
| **刻意不可验** | 第三方杀软拦截(火绒等) | ❌ VM 里只有 Defender |
| **刻意不可验** | 第三方杀软拦截 | ❌ VM 里只有 Defender |
| | 显示 / DPI / 多显示器 / 坐标校准 | ❌ VM 显示栈不同 |
| | 性能指标(P95 时延等) | ❌ VM 数字不可比 |
| **需注意** | `win-x64` 包在 ARM Windows 上走 **Prism 模拟** | ❌ **实测结论(2026-10-11):能启动、能 listen,但 TLS 握手不完成**(控制端只见 `E_TIMEOUT`)→ 被控端实际不可用。ARM Windows **必须**用 `nodeagent-win-arm64.zip`(见 B5) |
@@ -26,7 +26,7 @@
## 2. 前置准备
```
① Parallels 27 装 Windows 11 ARM(Pro/Enterprise)
① 虚拟化软件里装 Windows 11 ARM(Pro/Enterprise)
└ Apple silicon 只能跑 ARM 版 Windows(x86_64 仅 Pro/Business 的早期预览,性能显著更低)
② 网络 → 桥接(Bridged),不要用默认「共享网络」
@@ -153,7 +153,7 @@ node scripts/anchor-audit.mjs verify --anchors /tmp/vm-anchor.jsonl --node vm
```bash
# ① VM 内,用包内自带 node 起被控端(务必 arm64 包)
C:\nodeagent-test\node.exe C:\nodeagent-test\agent.mjs
C:\na-test\node.exe C:\na-test\agent.mjs
# 注意:以 SYSTEM(会话 0)启动时 screen.capture 等图形能力必失败(会话 0 无交互桌面),
# 正式安装路径不受影响 —— install.ps1 的计划任务用登录用户 + LogonType Interactive。
@@ -188,7 +188,7 @@ A 基础验收(verify.mjs)
| 杀软交互 / 显示 / 性能 | `⏳ 待真机`(VM 不可替代,**不得**因 VM 通过就标已验) |
| 分发包架构适配(B5) | `✅ 已验证(VM · Windows 11 ARM):arm64 包原生可用;win-x64 包 TLS 握手不完成、不可用` |
> **一条纪律**:VM 与真机**架构不同**(真机 i7-12650H = x86_64)。任何"VM 通过"
> **一条纪律**:VM 与真机**架构不同**(真机 = x86_64)。任何"VM 通过"
> 都不能自动推出"真机通过";涉及**原生二进制、驱动、杀软、显示栈**的部分尤其如此。
> 宁可多留一条"待真机复验",也不要让台账出现无法追溯来源的"已验证"。
@@ -214,18 +214,19 @@ A 基础验收(verify.mjs)
|---|---|---|---|
| 2026-10-11 | **B5 架构适配** | ✅ arm64 包原生可用;❌ win-x64 包不可用 | 同机同参对照:arm64 握手成功 / x64 `E_TIMEOUT` |
| 2026-10-11 | 基础能力(A 子集) | ✅ | `system.info`(`arch=arm64`、能力 51 项两端一致)/ `status` / `process.list` / `service.list` / `shell.exec`(退出码 0)/ `audit.list` / `audit verify`(链完整)均正常 |
| 2026-10-11 | **图形能力** | ✅ **登录态下全通** | 以登录用户 + `LogonType Interactive` 启动后:`screen.capture` 返回**真实桌面** 1800×1010(含中文,渲染正常)/`window.list` 返回窗口列表(前台窗口 PID 与实测一致)/`screen` 枚举显示器/`audit verify` 链完整。**证实会话 0 的失败是身份问题,非产品缺陷** |
| 2026-10-11 | **图形能力** | ✅ **登录态下全通** | 以登录用户 + `LogonType Interactive` 启动后:`screen.capture` 返回**真实桌面** 1280×720(含中文,渲染正常)/`window.list` 返回窗口列表(前台窗口 PID 与实测一致)/`screen` 枚举显示器/`audit verify` 链完整。**证实会话 0 的失败是身份问题,非产品缺陷** |
> 本轮**尚未执行**:B1(v1.5 四项输入)/ B2(v23 三防护)/ B3(v26 重启清理)/ B4(审计锚点端到端)。
> 另注:VM 的 `prlctl exec` 默认以 **SYSTEM(会话 0)** 身份执行,图形类能力须换登录用户会话方可复验。
> 另注:宿主虚拟机 CLI 的 `exec` 子命令默认以 **SYSTEM(会话 0)** 身份执行,图形类能力须换登录用户会话方可复验。
> **图形能力复验配方(本轮实证可通过)**:见 §8 末节。
---
## 8. 实际验证结果(2026-10-11,Parallels 27 + Win11 ARM)
## 8. 实际验证结果(2026-10-11,虚拟化平台 + Win11 ARM)
> 本节是**实测记录**,不是计划。环境:Parallels 共享网络,VM `10.211.55.9`,
> Mac 侧 `bridge100=10.211.55.2`;被控端 `-NodeId win-arm -AllowInput`。
> 本节是**实测记录**,不是计划。环境:虚拟化平台的共享网络,VM `192.0.2.9`,
> Mac 侧 `bridge100=192.0.2.2`;被控端 `-NodeId win-arm -AllowInput`。
> (上列地址为文档保留段示例,非真实环境值。)
### B5 结论(重要,与预期不同)
@@ -241,7 +242,7 @@ A 基础验收(verify.mjs)
node scripts/pack.mjs --arch arm64 # → release/nodeagent-win-arm64.zip(内含 ARM64 node.exe)
node scripts/pack.mjs # 默认仍出 x64 包(向后兼容)
```
ARM Windows(含 Parallels Win11 ARM)请用 **arm64 包**;用 x64 包会卡在 TLS 握手。
ARM Windows(含虚拟化平台上的 Win11 ARM)请用 **arm64 包**;用 x64 包会卡在 TLS 握手。
(临时替代:手工把包内 `node.exe` 换成 ARM64 版同样可行,重启后需再换 —— 故推荐用 arm64 包。)
### 各项结果
@@ -288,11 +289,11 @@ VM 无人登录时停在 LogonUI。可脚本化的路径只有 **autologon**,
**配方**:新建一个**本地账户**再 autologon:
```powershell
$pw = ConvertTo-SecureString '<password>' -AsPlainText -Force
New-LocalUser -Name pdverify -Password $pw -PasswordNeverExpires -AccountNeverExpires
Add-LocalGroupMember -Group Administrators -Member pdverify
New-LocalUser -Name na-test -Password $pw -PasswordNeverExpires -AccountNeverExpires
Add-LocalGroupMember -Group Administrators -Member na-test
$k = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon'
Set-ItemProperty $k AutoAdminLogon '1'
Set-ItemProperty $k DefaultUserName 'pdverify'
Set-ItemProperty $k DefaultUserName 'na-test'
Set-ItemProperty $k DefaultDomainName $env:COMPUTERNAME
Set-ItemProperty $k DefaultPassword '<password>' # 明文,验完务必清理
Restart-Computer -Force
@@ -301,8 +302,8 @@ Restart-Computer -Force
### ② 以登录用户身份启动被控端(这一步才是图形能力的关键)
```powershell
$act = New-ScheduledTaskAction -Execute 'C:\nodeagent-test\node.exe' -Argument 'C:\nodeagent-test\agent.mjs'
$pri = New-ScheduledTaskPrincipal -UserId ($env:COMPUTERNAME + '\pdverify') -LogonType Interactive -RunLevel Highest
$act = New-ScheduledTaskAction -Execute 'C:\na-test\node.exe' -Argument 'C:\na-test\agent.mjs'
$pri = New-ScheduledTaskPrincipal -UserId ($env:COMPUTERNAME + '\na-test') -LogonType Interactive -RunLevel Highest
Register-ScheduledTask -TaskName 'na-gui' -Action $act -Principal $pri -Force
Start-ScheduledTask -TaskName 'na-gui'
```
@@ -315,7 +316,7 @@ Start-ScheduledTask -TaskName 'na-gui'
|---|---|
| `Get-Process node \| Select Id,SessionId` | **SessionId = 1**(不是 0) |
| `Get-Process explorer \| Select SessionId` | 1(桌面已就绪) |
| `Get-LocalUser pdverify \| Select LastLogon` | 有值(确实登录过) |
| `Get-LocalUser na-test \| Select LastLogon` | 有值(确实登录过) |
### ④ 证书指纹会变(这是钉住机制在正常工作)
@@ -332,7 +333,7 @@ $k = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon'
Remove-ItemProperty $k DefaultPassword -ErrorAction SilentlyContinue
Set-ItemProperty $k AutoAdminLogon '0'
Unregister-ScheduledTask -TaskName 'na-gui' -Confirm:$false
Remove-LocalUser -Name pdverify # 临时账户一并删除
Remove-LocalUser -Name na-test # 临时账户一并删除
```
> 若需**保留**该环境供后续验证(重启后仍自动恢复图形会话),可暂不清理 ——
@@ -342,7 +343,7 @@ Remove-LocalUser -Name pdverify # 临时账户一并删除
## 10. 200% 缩放下的坐标口径,与 VM 输入通道的真机真相(2026-10-12)
全部在 **Parallels 27 + Win11 ARM64(200% 缩放)** 上实测。
全部在 **虚拟化平台 + Win11 ARM64(200% 缩放)** 上实测。
§9 讲「怎么让图形能力跑起来」,本节讲「跑起来之后坐标为什么还是错的」以及「被控端自己不动了怎么救」。
### 10.1 缺陷本体:一个进程里并存两套坐标系
@@ -351,7 +352,7 @@ Remove-LocalUser -Name pdverify # 临时账户一并删除
| 调用 | 不感知时的结果 | 危害 |
|---|---|---|
| `Screen.Bounds` / `SystemInformation.VirtualScreen` | **逻辑**尺寸 1800×1010 | `screen.info` 报错尺寸;`screen.capture` 缺省 region 一并错 |
| `Screen.Bounds` / `SystemInformation.VirtualScreen` | **逻辑**尺寸 1280×720 | `screen.info` 报错尺寸;`screen.capture` 缺省 region 一并错 |
| `CopyFromScreen` | 按**物理**像素裁剪 | `screen.capture` 缺省**只截到左上 1/4** |
| `SetCursorPos` | 被系统 **×2** | `input.mouse.*` 喂 100,100 → 光标落到 200,200 |
| UIA(`screen.find`) | **物理**坐标(与进程是否感知无关) | 与上面三项**口径不一致** |
@@ -363,7 +364,7 @@ Remove-LocalUser -Name pdverify # 临时账户一并删除
| 断言 | 怎么取 | 修复前 | 修复后应为 |
|---|---|---|---|
| 屏幕尺寸口径 | `screen.info` | `1800x1010` | **`3600x2020`**,且与**裸 Win32** `GetSystemMetrics(0/1)`(感知后)一致 |
| 屏幕尺寸口径 | `screen.info` | `1280x720` | **`2560x1440`**,且与**裸 Win32** `GetSystemMetrics(0/1)`(感知后)一致 |
| 缺省整屏截图 | `screen.capture`(**不传** `region`) | 只有左上 1/4 | 整屏(按 `scale` 缩放后返回) |
| 光标落点 | `input.mouse.move(x,y)` 后**用同样感知的助手**读回 `GetCursorPos` | 落到 (2x,2y) | **精确等于** `(x,y)` |
| 端到端零换算 | `screen.find` 的物理矩形中心**直接**喂 `input.mouse.click` | 点不中 | 命中(可用主题单选框的 UIA `selected` 翻转验证) |
@@ -381,10 +382,10 @@ Remove-LocalUser -Name pdverify # 临时账户一并删除
| 会话 / 窗口站 / 桌面 | `session=1` / `WinSta0` / `Default` ✅ |
| `UserInteractive` | `True` ✅ |
| `OpenInputDesktop` | 成功 → `Default`;`SetThreadDesktop(它)` 报 `ERROR_BUSY(170)`(本就在该桌面)✅ |
| `ClipCursor` | 整屏 `0,0,3600,2020`,无裁剪 ✅ |
| 指点设备 | `HID-compliant mouse` + `Parallels USB Mouse Synchronization Device` 均 `Status OK` ✅ |
| `prl_mouf` 驱动 | `STATE: 4 RUNNING` ✅ |
| **进程来源** | 从 **node 子树** 与 **`prlctl enter`**(Parallels Tools 侧)两条**互不相干**的来源跑同一段探针,**结果完全一致** ❌ |
| `ClipCursor` | 整屏 `0,0,2560,1440`,无裁剪 ✅ |
| 指点设备 | `HID-compliant mouse` + 虚拟化平台的 USB 鼠标同步设备 均 `Status OK` ✅ |
| 虚拟化平台的鼠标过滤驱动 | `STATE: 4 RUNNING` ✅ |
| **进程来源** | 从 **node 子树** 与 **宿主虚拟机 CLI 的 `enter` 子命令**(Guest Tools 侧)两条**互不相干**的来源跑同一段探针,**结果完全一致** ❌ |
判决性证据(**纯 Win32 探针,不含任何本产品代码**):
@@ -404,8 +405,8 @@ GetAsyncKeyState(按键) = 0x0000 ← 连键盘注入也不被系统
| 手段 | 结果 |
|---|---|
| `--smart-mouse-optimize` 三档(`off` / `auto` / `on`) | ❌ 无效 |
| `prlctl suspend` + `start`(挂起 / 恢复) | ❌ 无效 —— resume 会**完整还原 win32k 状态**,卡住的输入态当然一起被还原 |
| **`prlctl stop --acpi` + `start`(真正冷启动 Windows)** | ✅ **立即恢复** |
| 虚拟机「挂起 / 恢复」 | ❌ 无效 —— resume 会**完整还原 win32k 状态**,卡住的输入态当然一起被还原 |
| **虚拟机「ACPI 关机 + 启动」(真正冷启动 Windows)** | ✅ **立即恢复** |
> 结论:VM 输入栈瞬时卡死,**只有冷启动能复位**。
> 开工前先让宿主机把 VM 窗口真正当前台窗口交互一次(宿主机侧绝对鼠标集成会把 guest 指针从属过去;
@@ -416,10 +417,10 @@ GetAsyncKeyState(按键) = 0x0000 ← 连键盘注入也不被系统
**① 计划任务以 `RunLevel Highest` 拉起时,普通权限 `taskkill` 会「拒绝访问」。**
```text
na-gui user=pdverify logon=Interactive runlevel=Highest
na-gui user=na-test logon=Interactive runlevel=Highest
```
→ 必须从**更高权限通道**杀:`prlctl enter "Windows 11"`(**不带** `--current-user`)
→ 必须从**更高权限通道**杀:宿主虚拟机 CLI 进入 guest(**不带** `--current-user`)
拿到 `nt authority\system` 的 shell,`taskkill /F /PID <pid>` 才会成功。
另外:旧进程仍占着 8765 时,`schtasks /run /tn na-gui` 会**报「执行成功」但新实例立即退出**
@@ -429,7 +430,7 @@ na-gui user=pdverify logon=Interactive runlevel=Highest
```text
netstat -ano | findstr 8765
TCP 10.211.55.9:8765 10.211.55.2:57182 CLOSE_WAIT 9396
TCP 192.0.2.9:8765 192.0.2.2:57182 CLOSE_WAIT 9396
```
此时新连接**能完成 TCP 三次握手,但 TLS 握手永不完成**(控制端只见 `E_TIMEOUT`),
@@ -440,19 +441,20 @@ netstat -ano | findstr 8765
> 记此供后续专门排查。若成因是「`console.log` 写满无人读取的管道 → 事件循环阻塞」,
> 那对「被控端常驻、控制端随时可能硬断开」这种使用模式是个真问题。
### 10.5 两条 `prlctl` 通道的语义差异(本轮踩出来的)
### 10.5 两条虚拟机 CLI 通道的语义差异(本轮踩出来的)
> 下表 `<vm-cli>` 泛指所用虚拟化平台提供的 guest 控制命令(具体命令名见该平台文档)。
| 命令 | 实测行为 |
|---|---|
| `prlctl exec <VM> -- <cmd>` | **返回 exit=0 但完全空转**:无输出、无副作用(用「guest 反连 Mac 监听端口」判决)→ **不可用** |
| `prlctl enter <VM>` | 默认 `nt authority\system`(会话 0,无交互桌面) |
| `prlctl enter <VM> --current-user` | 登录用户的**真实交互** shell ✅ |
| `<vm-cli> exec <VM> -- <cmd>` | **返回 exit=0 但完全空转**:无输出、无副作用(用「guest 反连 Mac 监听端口」判决)→ **不可用** |
| `<vm-cli> enter <VM>` | 默认 `nt authority\system`(会话 0,无交互桌面) |
| `<vm-cli> enter <VM> --current-user` | 登录用户的**真实交互** shell ✅ |
另两条:
- `prlctl enter` **没有执行参数**(加 `--` 报 `Unrecognized option`),它是交互式 shell,
命令要**经 stdin 喂**:`printf 'cmd\r\n...\r\nexit\r\n' | prlctl enter ...`
- **`prlctl` 的参数里带逗号 / `&&` / 重定向会报 `Invalid argument`** —— 复杂逻辑一律先落成 `.cmd` / `.ps1` 再调用。
- `<vm-cli> enter` **没有执行参数**(加 `--` 报 `Unrecognized option`),它是交互式 shell,
命令要**经 stdin 喂**:`printf 'cmd\r\n...\r\nexit\r\n' | <vm-cli> enter ...`
- **该 CLI 的参数里带逗号 / `&&` / 重定向会报 `Invalid argument`** —— 复杂逻辑一律先落成 `.cmd` / `.ps1` 再调用。
> 仅供技术参考,不构成任何投资建议。
+68
View File
@@ -0,0 +1,68 @@
classDiagram
class ProxyModule {
<<module apps/agent/src/net/proxy.ts>>
+normalizeProxyUrl(raw) URL|null
+isBypassed(targetUrl, noProxy) boolean
+pickEnvProxy(env, targetUrl) EnvPick
+parseScutilProxy(text) SysProxy|null
+parseRegQueryProxy(text) SysProxy|null
+detectSystemProxy(platform, run) Promise~SysProxy|null~
+resolveProxy(opts) Promise~ProxyDecision~
+httpGet(url, opts) Promise~HttpResponse~
+tunnelConnection(proxy, target, signal) Function
}
class SelfUpdateCapability {
<<module apps/agent/src/capabilities/selfupdate.ts>>
+agentUpdate(args) Promise~unknown~
-download(url, timeoutMs) Promise~DownloadResult~
-planBackupCleanup(files, keep) string[]
}
class LkgRecord {
<<interface apps/agent/src/lkg.ts>>
+hash string
+hash_full string?
+bytes number
+script_path string
+backup_glob string?
+recorded_at number
+node_version string
+platform string
}
class LkgModule {
<<module apps/agent/src/lkg.ts>>
+recordLKG() LkgRecord|null
+readLKG() LkgRecord|null
+lkgStatus() LkgStatus
+lkgFilePath() string
}
class RollbackCommand {
<<module apps/cli/src/cmd/rollback.ts>>
+cmdRollback(opts) Promise~void~
+planRollback(info) RollbackPlan
+pickBackupByHash(candidates, wantHash) Candidate|null
+hashRemoteFile(client, path) Promise~FileHash~
}
class AuditL2Capability {
<<module apps/agent/src/capabilities/audit-l2.ts>>
+auditL2(args) Promise~unknown~
+verifyL2() L2VerifyResult
+readL2(limit) L2Entry[]
+l2FilePath() string
}
class ManifestSystem {
<<module packages/protocol/src/capabilities/manifest/system.ts>>
+SYSTEM_CAPABILITIES CapabilityDescriptor[]
}
SelfUpdateCapability --> ProxyModule : 单向依赖
SelfUpdateCapability --> LkgModule : 读 LKG(回滚提示)
LkgModule --> LkgRecord : 读写
RollbackCommand --> LkgModule : 经 system.info.lkg 消费
RollbackCommand ..> AuditL2Capability : 无直接依赖(仅同库)
ManifestSystem ..> AuditL2Capability : returns_schema 契约真源
+52 -13
View File
@@ -5,8 +5,8 @@
> 保留原文是为了可回溯决策过程;实现以「实施决策」节与代码为准。
> 版本 v1.0 | 日期 2026-10-07 | 状态:待皇上评审
> 定位:为「Mac 控 Windows」补上**人肉可视化操作台**——CLI 是脚本底座、MCP 是 AI 落点、本方案是**皇上自己点按钮**的桌面界面。
> 版本 v1.0 | 日期 2026-10-07 | 状态:待用户评审
> 定位:为「Mac 控 Windows」补上**人肉可视化操作台**——CLI 是脚本底座、MCP 是 AI 落点、本方案是**用户自己点按钮**的桌面界面。
---
@@ -20,8 +20,8 @@
| 层 | 选型 | 版本 | 选型理由 |
|---|---|---|---|
| 桌面壳 | **Tauri 2** | 2.x | 跨端(Mac/Win)+ 性能好 + 体积小;Rust 后端契合皇上「Rust 加速」技术栈 |
| 前端框架 | **Vue 3** | 3.5+ | 皇上既有 Vue2/Vue3 经验(reader、FinDataHub),迁移成本最低 |
| 桌面壳 | **Tauri 2** | 2.x | 跨端(Mac/Win)+ 性能好 + 体积小;Rust 后端契合用户「Rust 加速」技术栈 |
| 前端框架 | **Vue 3** | 3.5+ | 既有 Vue 2 / Vue 3 实战经验,迁移成本最低 |
| 语言 | **TypeScript** | 5.x | 与 monorepo 既有 TS 生态对齐,复用协议类型定义 |
| 构建 | **Vite** | 6.x | Tauri 官方模板默认,HMR 快 |
| 状态管理 | **Pinia** | 2.x | Vue3 官方推荐,模块化 store 天然契合多面板 |
@@ -41,7 +41,7 @@
| 性能 | 好(Rust 后端) | 中 | 受浏览器沙箱限制 |
| 直接复用 Node SDK | ❌(需 Rust 版 client) | ✅(主进程可 import) | ❌(需浏览器版) |
**取舍**:Tauri 需要新写一份 Rust 版 client(协议本身是 JSON-RPC over WebSocket,语言无关,逻辑对齐已跑通的 client.ts 即可),换取体积、内存、性能上的长期优势——与皇上「性能好 + 跨端」的要求一致。
**取舍**:Tauri 需要新写一份 Rust 版 client(协议本身是 JSON-RPC over WebSocket,语言无关,逻辑对齐已跑通的 client.ts 即可),换取体积、内存、性能上的长期优势——与用户「性能好 + 跨端」的要求一致。
---
@@ -323,16 +323,15 @@ cargo run --example probe -- ws://127.0.0.1:8765 <key> /tmp/nodeagent-e2e
---
## 11. 待确认项(请皇上评审时一并拍板)
## 11. 待确认项(请用户评审时一并拍板)
| # | 事项 | 臣的建议 |
| # | 事项 | 建议 |
|---|---|---|
| C1 | Windows 端桌面应用的定位 | 被控端本地面板(默认侧重)+ 对称可作控制端 |
| C2 | 组件库 | Naive UI(暗色友好、TS 完整);若皇上惯用 Element Plus 可换 |
| C2 | 组件库 | Naive UI(暗色友好、TS 完整);若用户惯用 Element Plus 可换 |
| C3 | 图表库 | ECharts(资源趋势/磁盘);确认后纳入 M2 |
| C4 | 是否同步做图形接管 UI | 建议 v2 再做,首期聚焦闭环 |
> 仅供参考,本方案为技术规划,不构成任何投资建议。
---
@@ -580,6 +579,47 @@ node 去跑打包内的 sidecar,并走一次真实 JSON-RPC 握手 —— 源
> 整个模式不匹配 —— 于是「写错的变量名」反而逃过检查。放宽到 `[^)\s,;]+` 后才真正生效。
> 教训:**注入故障不只是验证守卫有没有,还要验证它是否覆盖了目标形态**。
### 出网代理:让「检查更新」在境内真的能用(2026-10-13)
「检查更新 / 下载更新包」是控制台**唯一**两条需要出公网(GitHub)的路径。原实现直接用
Node 内建 `fetch`,于是境内**必然**失败,且失败原因永远是笼统的 `fetch failed`。三条根因:
1. `fetch`(undici)**默认不读代理环境变量**(`NODE_USE_ENV_PROXY=1` 能开,但只认环境变量);
2. **系统代理读不到** —— Windows 用户配的是 WinINET(第三方代理客户端的「系统代理」),macOS 用户配的是系统设置里的代理,Node 两者都不读;
3. `fetch(url,{dispatcher})` 也走不通:`dispatcher` 必须是 undici 的实例,而 Node 未把 undici 暴露成可导入模块。
因此 `apps/console/sidecar/proxy.mjs` **自持出网**(零新依赖):
| 目标 | 经代理的做法 |
|---|---|
| `https://` | 先向代理发 `CONNECT host:443` 建隧道,再在隧道 socket 上叠 TLS;socket 交 `http.request`(**不用 `https.request`**,否则二次握手) |
| `http://` | 按 HTTP 代理规范发**绝对形态**请求行 |
| 无代理 | 直连(与改动前行为一致) |
代理来源优先级:`NA_PROXY` → 目标协议对应的环境变量 → **系统代理**(Windows 读注册表 WinINET / macOS 读 `scutil --proxy`)。
`NO_PROXY` 命中则直连。只支持 HTTP 代理;遇到 `socks5://` 或**系统代理是 PAC 脚本**时
**明确把原因带给用户**(本机 AdGuard 正是 PAC 模式)—— 静默降级是这类缺陷最坏的地方。
**超时成对定义**(`sidecar.rs`):内层出网 120s / 外层 RPC 150s,且**内层必须小于外层** ——
这条关系写成**编译期不变式**,因为一旦破坏,用户只会看到「check_update 超时」这种**丢掉真实原因**的假失败。
120s 的依据是真机实测的重定向链耗时分布(2.0 / 10.0 / 39.7 / 68.6 / >109 秒),不是拍脑袋。
守卫:`tests/unit/sidecar-proxy.test.mjs`(16 用例)= 纯函数解析(夹具取自真机)+ 离线真发请求
(直连/绝对形态/CONNECT/**隧道+TLS 阶段 abort 必须立即生效**)+ 真 spawn sidecar 走 JSON-RPC 的端到端
+ 「sidecar 源码不得再出现裸 `fetch(`」形态守卫 + 「连接被中途重置不得报成请求超时」的错误映射守卫。
全部做过注入故障实测(含还原后逐字节一致)。
**真机已闭环**(Win11 ARM64 VM,用随包 `node.exe` 直跑 `dist/sidecar.mjs`,两侧 sha256 一致):
| 组 | 配置 | 结果 |
|---|---|---|
| 对照 | 未配系统代理、**不给任何环境变量**(直连) | **30877ms 失败**,但报的是真实原因 `socket hang up`,`data` 带 `via=直连`/`timeout_ms` 与可操作提示 |
| 实验 | 只设 **WinINET 系统代理**指向转发端点、**仍不给任何环境变量** | **成功 28883ms** → `v2.0.3`、`via_proxy=system`,日志 `(来源 system)`,退出码 0 |
→ 实验组走通的正是本次新增的**系统代理发现**(`reg query` → `来源 system`);
其 28.9s 耗时同时反证旧实现固定 20s 的内部超时对这条链路**必然**失败。
复验后 VM 的 `ProxyEnable`/`ProxyServer` 已**原样还原**。
## Windows 端控制台(2026-10-11)
### 起因
@@ -802,7 +842,7 @@ msiexec /a 失败(退出码 ):nodeagent-console_2.0.2_x64_en-US.msi
## ARM64 真机安装冒烟与界面实点(2026-10-12)
上一节那张表里的条目,本轮在 **Parallels 27 + Win11 ARM64** 上逐条做完;
上一节那张表里的条目,本轮在 **虚拟化平台 + Win11 ARM64** 上逐条做完;
过程中还挖出并修掉一个**只在真机上才暴露**的缺陷(见本节末「真机暴露的缺陷」)。
### 怎么把包送进去
@@ -831,7 +871,7 @@ msiexec /a 失败(退出码 ):nodeagent-console_2.0.2_x64_en-US.msi
| **无黑窗** | ✅ 结构性(PE subsystem=2)+ 视觉确认 | PE 头 + 截图 |
| 输入默认禁 | ✅ `input.mouse.move` → `E_CAPABILITY_DISABLED` | 错误名 |
| 开机自启 | ✅ 计划任务 `cn_jkooe.nodeagent-console` **不存在**,与 UI 开关=关 一致 | `schtasks` |
| 数据目录显示 | ✅ UI 显示 `C:\Users\pdverify\.nodeagent`,与真实生效目录一致 | 配置比对 |
| 数据目录显示 | ✅ UI 显示 `C:\Users\<user>\.nodeagent`,与真实生效目录一致 | 配置比对 |
| 「最近日志」 | ✅ 正确记下「端口 8765 已被占用」并给出降级提示 | 截图 |
### ⚠️ 本轮新发现(**未修**):控制台没有单实例保护
@@ -852,7 +892,7 @@ msiexec /a 失败(退出码 ):nodeagent-console_2.0.2_x64_en-US.msi
| 调用 | 不感知时的结果 |
|---|---|
| `Screen.Bounds` / `SystemInformation.VirtualScreen` | 报**逻辑**尺寸 1800×1010 |
| `Screen.Bounds` / `SystemInformation.VirtualScreen` | 报**逻辑**尺寸 1280×720 |
| `CopyFromScreen` | 按**物理**像素裁剪 → `screen.capture` 缺省只截到左上 1/4 |
| `SetCursorPos` | 被系统 ×2(喂 100,100 → 光标到 200,200) |
| UIA(`screen.find`) | 报**物理**坐标(与进程是否感知无关) |
@@ -863,4 +903,3 @@ msiexec /a 失败(退出码 ):nodeagent-console_2.0.2_x64_en-US.msi
修法:新增 `apps/agent/src/util/dpi.ts`,把 `SetProcessDPIAware()` 注入到**全部坐标相关脚本的最前面**
(5 个文件),并配 8 条单测守卫。详见证见 `CHANGELOG.md` 与 `docs/VM-VERIFICATION.md` §10。
> 仅供技术参考,不构成任何投资建议。
+21
View File
@@ -0,0 +1,21 @@
sequenceDiagram
participant CLI as 控制端 (CLI/MCP/console)
participant S as agent server.ts
participant SU as selfupdate.ts
participant PX as net/proxy.ts
participant NET as node:http/https
CLI->>S: invoke system.agent.update {url, sha256, timeout_ms}
S->>SU: agentUpdate(args)
SU->>PX: resolveProxy({targetUrl: url})
PX->>PX: NA_PROXY → env → 系统代理(reg/scutil)
PX-->>SU: {proxy, source, notes}
SU->>PX: httpGet(url, {proxy, timeoutMs})
PX->>NET: CONNECT 隧道 / 绝对形态 / 直连
NET-->>PX: 响应字节流(链共享 AbortController)
PX-->>SU: {status, body}
SU->>SU: sha256 校验(不符→丢弃,不落盘)
SU->>SU: 备份 → 就地覆写 → 写后校验(不符→回滚)
SU->>S: agentRestart({delay_ms:1500})
SU-->>S: {updated, via, proxy, proxy_source, incoming_hash, ...}
S-->>CLI: {status:'ok', data}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "nodeagent",
"private": true,
"version": "2.0.3",
"version": "2.1.0",
"description": "跨机 AI 接管框架 —— 让 Mac 上的 AI 无缝接管 Windows,装软件、查状态",
"type": "module",
"engines": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/client",
"version": "2.0.3",
"version": "2.1.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/protocol",
"version": "2.0.3",
"version": "2.1.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
+8
View File
@@ -36,6 +36,14 @@ export const AUDIT_TYPES = [
'net.deny',
/** v19:agent 自更新(拉取式) */
'agent.update',
/**
* v2.1.0:**L2 系统级审计事件**(Windows Security 日志归一化副本,audit-l2.ts 写入)。
*
* 为什么单独一个类型而不复用 `agent.start`:L2 采集/启用与 agent 进程生命周期
* 毫无关系,混在一起会让「agent 重启过几次」的统计混入「L2 策略动过几次」。
* 且控制台筛 `agent` 前缀时会把两者混在一起——L2 事件应可被单独筛出。
*/
'agent.l2',
/** v2.0.0:连接空闲被主动断开 */
'session.idle_close',
/** v2.0.3:被拒的请求统一留痕(畸形报文 / 未认证 / 参数非法 / 能力不存在) */
@@ -53,6 +53,14 @@ export const SYSTEM_CAPABILITIES: CapabilityDescriptor[] = [
'v18 构建指纹:hash(运行中 agent 脚本的 sha256 前 12 位,部署校验用)、bytes、mtime_ms、' +
'node(运行时版本)、started_at、uptime_ms',
},
lkg: {
type: 'object',
description:
'最后已知可用版本 LKG(交付流水线 §6 回滚锚点):lkg(记录,含 hash/bytes/script_path/' +
'recorded_at)、current_hash、matches、byte_delta。' +
'matches=false 表示当前版本**尚未被证明能启动**(刚更新完、还没完成一次成功启动),' +
'控制端应据此暂缓向其余设备放量。仅在「本次启动真正起来了」之后才写入。',
},
},
},
},
@@ -285,6 +293,68 @@ export const SYSTEM_CAPABILITIES: CapabilityDescriptor[] = [
},
},
},
{
name: CapabilityNames.AuditL2,
version: '1.0',
description:
'L2 **系统级审计兜底**:采集操作系统自己的审计事件(Windows Security 日志),补 L1 结构上记不到的那块 —— ' +
'L1 只看得见经过 agent RPC 的调用,攻击者绕过 agent 直接操作、或把 agent 停掉时 L1 全盲。' +
'action: status=查策略是否启用 / enable=开启(需管理员)/ collect=拉取并归一化落盘 / verify=校验 L2 链。' +
'⚠️ 信任边界:采集器与 agent 同权限,本地 audit-l2.log 只是**归一化副本**,' +
'真正的防篡改依赖把记录**转发出机**(与 SECURITY.md §4.8 锚定同一思路)。' +
'当前仅落地 Windows;Linux(auditd)需预装、macOS(ESF)受 Apple entitlement 限制,均未实现。',
risk: 'medium',
params_schema: {
type: 'object',
properties: {
action: {
type: 'string',
enum: ['status', 'enable', 'collect', 'verify'],
default: 'status',
description: 'status / enable / collect / verify',
},
since: { type: 'integer', description: 'collect 的起始时间(Unix ms),默认 24 小时内' },
limit: { type: 'integer', minimum: 1, maximum: 5000, default: 1000, description: 'collect 条数上限' },
},
additionalProperties: false,
},
returns_schema: {
type: 'object',
description: '按 action 返回(四种 action 的顶层属性并集;未出现的属性即本次不返回)',
properties: {
// status:策略状态
platform: { type: 'string' },
supported: { type: 'boolean' },
auditpol: { type: 'string', description: 'auditpol /get 的原始输出(trim 后)' },
legacy_policy_disabled: { type: 'boolean', description: 'SCENoApplyLegacyAuditPolicy 是否已置 1' },
cmdline_logging: { type: 'boolean', description: 'ProcessCreationIncludeCmdLine_Enabled 是否已置 1' },
sysmon_installed: { type: 'boolean', description: '是否检测到 Sysmon(装了则并用,没装不尝试下载)' },
file: { type: 'string', description: 'L2 归一化副本路径(audit-l2.log)' },
file_exists: { type: 'boolean' },
note: { type: 'string' },
// enable:启用结果
enabled: { type: 'boolean' },
policies: { type: 'array', items: { type: 'string' }, description: '已开启的子类(人类可读)' },
hint: { type: 'string' },
// collect:采集结果
collected: { type: 'integer', description: '本次采集到的条目数' },
written: { type: 'integer', description: '实际追加落盘的条目数' },
since: { type: 'integer', description: '本次采集的起始时间(Unix ms)' },
limit: { type: 'integer', description: '本次采集的条数上限' },
// verify:链校验结果
ok: { type: 'boolean' },
checked: { type: 'integer' },
broken_at: {
type: 'object',
description: '首个异常位置(ok=false 时存在)',
properties: {
line: { type: 'integer' },
reason: { type: 'string' },
},
},
},
},
},
{
name: CapabilityNames.Metrics,
version: '1.0',
@@ -396,6 +466,11 @@ export const SYSTEM_CAPABILITIES: CapabilityDescriptor[] = [
incoming_hash: { type: 'string' },
backup_path: { type: 'string', description: '回滚点:覆盖回入口路径即可' },
restarted: { type: 'boolean' },
// v2.1.0 新增(只增字段):出网诊断 —— 境内/代理环境排查「为什么拉不到」的关键信息
via: { type: 'string', description: '出网路径(直连 / 经代理 <url>)' },
proxy: { type: 'string', description: '实际使用的代理地址(直连为 null)' },
proxy_source: { type: 'string', description: '代理来源(NA_PROXY / 环境变量名 / system)' },
outbound_notes: { type: 'array', items: { type: 'string' }, description: '出网决策备注(PAC/socks 不可用等)' },
},
},
},
@@ -49,6 +49,8 @@ export const CapabilityNames = {
AuditVerify: 'system.audit.verify',
AuditHead: 'system.audit.head',
AuditAnchor: 'system.audit.anchor',
/** L2 系统级审计兜底(2026-10-17,见 docs/AUDIT-L2.md) */
AuditL2: 'system.audit.l2',
Metrics: 'system.metrics',
// v19 拉取式自更新
AgentUpdate: 'system.agent.update',
+7 -1
View File
@@ -66,7 +66,13 @@ export function validate(value: unknown, schema: JsonSchema, path = '$'): string
}
if (schema.additionalProperties === false) {
for (const key of Object.keys(value)) {
if (!(key in schema.properties)) {
// ⚠️ A-P0-1(2026-10-25):必须用 **own-property** 判断,不能用 `key in schema.properties`。
// `in` 沿原型链查找,而 `schema.properties` 是普通对象字面量(继承 Object.prototype)——
// 调用方传 `{"constructor":1}` / `{"toString":1}` / `{"valueOf":1}` 时
// `key in properties` 为 true → **不判 additional property** → 校验放行(fail-open)。
// 与 server.ts B-03(`client_id:"constructor"` 沿原型链取到函数当密钥)是
// 同一错误模式在不同文件复发;此处修的是「守卫方向」的失效。
if (!Object.prototype.hasOwnProperty.call(schema.properties, key)) {
errors.push(`${path}.${key}: additional property not allowed`);
}
}
+473 -74
View File
File diff suppressed because it is too large. Load diff
+18
View File
@@ -3,3 +3,21 @@ packages:
- 'apps/*'
allowBuilds:
esbuild: true
# 依赖漏洞门禁(Quality Gate 的 pnpm audit --audit-level=high)用的显式覆盖。
#
# 为什么必须钉:`@modelcontextprotocol/sdk` 的传递依赖落后于安全版本,
# 审计会稳定报 3 个 high(zod <4.6.6、body-parser <2.4.0),而它们都不由
# 本仓直接控制 —— 不覆盖就永远是红门禁(等于没有门禁)。
# 均为同 major 的 patch 级抬升,语义风险低;升级 SDK 时若已自带修复版,可移除。
#
# ⚠️ pnpm 12 起 `package.json` 的 `pnpm` 字段不再被读取(设置迁移到本文件),
# 写在那里会被静默忽略 —— 与「一直红所以等于没有」是同一类失败形态。
overrides:
zod: ^4.6.6
body-parser: ^2.4.0
# 2026-10-27:MCP SDK 传递链的剩余中低危,一并抬到修复版(同 major)。
# 对应 dependabot #3/#6/#7,覆盖后那三个 PR 已无增量,随同一提交关停。
hono: ^4.13.14
ip-address: ^10.7.5
fast-uri: ^3.1.9
+1 -1
View File
@@ -64,7 +64,7 @@ function fetchHead(node, headFile) {
return j.data ?? j; // 兼容「CLI 信封」与「裸 data」两种写法
}
// ⚠️ CLI 没有 `audit head` 子命令(只有 `audit` 列表与 `audit verify`)——
// system.audit.head 只能走通用 `invoke`。2026-10-11 在 Parallels VM 上真跑时才发现:
// system.audit.head 只能走通用 `invoke`。2026-10-11 在虚拟化平台的 VM 上真跑时才发现:
// 不实际跑一次,这种"能力存在但 CLI 无子命令"的错配根本暴露不出来。
const args = ['invoke', 'system.audit.head', '--json'];
if (node) args.push('--node', node);
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env node
/**
* 提交信息规范校验(交付流水线 §8 / 附录 B)。
*
* ## 背景
* 项目**实际早已在用** Conventional Commits(`git log` 可验证:feat(linux): / fix(console): …),
* 但一直没有强制 —— 靠自觉。本次把它变成会红的门禁。
*
* ## 为什么不引 commitlint + husky
* - husky 依赖 git hooks,CI 上不生效,还得额外装依赖;
* - commitlint 需要配置文件与 `@commitlint/config-conventional` 一整套;
* - 本项目只需「类型白名单 + 有 subject」这两条,几十行即可,且 CI 与本地都能跑。
* 与覆盖率门禁同理:不引冗余依赖。
*
* ## 用法
* node scripts/ci/check-commit-msg.mjs # 校验 HEAD 这一条
* node scripts/ci/check-commit-msg.mjs --base origin/main # 校验该区间所有提交
* echo "feat(x): y" | node scripts/ci/check-commit-msg.mjs --stdin
*/
import { execFileSync } from 'node:child_process';
const TYPES = ['feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'chore', 'ci'];
/** 允许 Revert / Merge / 版本提交等自动化形态,但类型必须合规。 */
const HEADER_RE = new RegExp(`^(${TYPES.join('|')})(\\([^)]+\\))?!?: .+`);
const ALLOWED_EXEMPT = [
/^Merge (branch|pull request|remote-tracking branch)/i,
/^Revert "/,
];
function parseArgs(argv) {
const out = { base: '', stdin: false };
for (let i = 0; i < argv.length; i += 1) {
if (argv[i] === '--base') out.base = argv[++i];
else if (argv[i] === '--stdin') out.stdin = true;
else if (argv[i] === '--help' || argv[i] === '-h') {
console.log(
`用法: node scripts/ci/check-commit-msg.mjs [--base <git-ref>] [--stdin]\n` +
` 类型白名单: ${TYPES.join(', ')}\n` +
` 格式: <type>(<scope>): <subject>`,
);
process.exit(0);
}
}
return out;
}
function readStdin() {
try {
return execFileSync('cat', [], { encoding: 'utf8', stdio: ['inherit', 'pipe', 'pipe'] });
} catch {
return '';
}
}
function commitsInRange(base) {
const range = base ? `${base}..HEAD` : 'HEAD~1..HEAD';
try {
return execFileSync('git', ['log', '--format=%H%x09%s', range], { encoding: 'utf8' })
.split('\n')
.filter((l) => l.trim())
.map((l) => {
const idx = l.indexOf('\t');
return { sha: l.slice(0, idx), subject: l.slice(idx + 1) };
});
} catch {
return [];
}
}
const args = parseArgs(process.argv.slice(2));
let items = [];
if (args.stdin) {
const msg = readStdin().trim();
if (!msg) {
console.error('\n✗ stdin 为空,没有可校验的提交信息');
process.exit(1);
}
items = [{ sha: '(stdin)', subject: msg.split('\n')[0] }];
} else {
items = commitsInRange(args.base);
if (items.length === 0) {
console.log(`(区间内没有提交,跳过)${args.base ? `base=${args.base}` : ''}`);
process.exit(0);
}
}
const bad = [];
for (const it of items) {
const s = it.subject.trim();
if (ALLOWED_EXEMPT.some((re) => re.test(s))) continue;
if (!HEADER_RE.test(s)) bad.push(it);
}
if (bad.length > 0) {
console.error(`\n✗ ${bad.length} 条提交信息不符合 Conventional Commits:`);
for (const b of bad) console.error(` ${b.sha.slice(0, 8)} ${b.subject}`);
console.error(`\n要求格式: <type>(<scope>): <subject>`);
console.error(`type 取值: ${TYPES.join(', ')}`);
console.error(`示例: fix(console): sidecar 补 CREATE_NO_WINDOW(不再弹常驻黑窗)`);
process.exit(1);
}
console.log(`✓ 提交信息规范校验通过(${items.length} 条)`);
+246
View File
@@ -0,0 +1,246 @@
#!/usr/bin/env node
/**
* 覆盖率门禁(交付流水线 §2 / §8)。
*
* ## 为什么自己解析 lcov 而不装 c8/nyc
* 单测用的是 Node 内置 `node --test --experimental-test-coverage`,它原生出 lcov。
* 再引一套覆盖率工具只为「读一下阈值」属于重复建设 —— 本项目铁律之一就是
* 「重复功能优先引用已有模块,不新建冗余内容」。
*
* ## 口径(重要,别照搬通用 80%)
* 原方案(面向 Web 新项目)要求全量 ≥80%。本项目存量 341 个单测集中在
* 协议/编码/安全守卫,而 GUI 注入、平台脚本等模块**无法在 CI 上无头验证**。
* 直接套 80% 全量阈值只会逼出两种结果:长期 `--skip`,或为 GUI 代码写假断言。
*
* 故采用:
* - **全量:只观测,不阻断**(出数字,建基线)
* - **变更文件:行覆盖率 ≥ THRESHOLD 才阻断**(默认 70)
*
* ## 一个必须说清的映射
* 单测 import 的是 `dist/*.js`(编译产物),不是 `src/*.ts`。
* 所以「本次改了哪个源文件」要映射成「dist 里的哪个文件」才能查到覆盖率:
* apps/agent/src/audit.ts → apps/agent/dist/audit.js
* 规则:`/src/` → `/dist/`,`.ts` → `.js`。
*
* ## 无覆盖数据怎么办
* 改动的源文件若在 lcov 里查不到对应条目(例如纯类型改动、文档、或压根没被测试碰过),
* **不阻断**,只列进「无覆盖数据」清单。理由:把它算作 0% 会让任何重构都无法合入;
* 而如实列出来,比一个会被绕过的门禁更有用。
*/
import { readFileSync, existsSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { relative, resolve } from 'node:path';
const REPO = resolve(process.cwd());
function fail(msg) {
console.error(`\n✗ ${msg}`);
process.exit(1);
}
// ---------------- 参数 ----------------
function parseArgs(argv) {
const out = {
lcov: 'coverage/lcov.info',
threshold: 70,
base: '',
// 全量不达标是否阻断(默认否:只观测)
enforceTotal: false,
totalThreshold: 80,
};
for (let i = 0; i < argv.length; i += 1) {
const a = argv[i];
if (a === '--lcov') out.lcov = argv[++i];
else if (a === '--threshold') out.threshold = Number(argv[++i]);
else if (a === '--total-threshold') out.totalThreshold = Number(argv[++i]);
else if (a === '--base') out.base = argv[++i];
else if (a === '--enforce-total') out.enforceTotal = true;
else if (a === '--help' || a === '-h') {
console.log(
`用法: node scripts/ci/check-coverage.mjs [选项]\n` +
` --lcov <path> lcov 路径(默认 coverage/lcov.info)\n` +
` --threshold <n> 变更文件行覆盖率阈值(默认 70)\n` +
` --base <git-ref> 比对基线(如 origin/main);为空则只看全量\n` +
` --enforce-total 全量不达标也阻断(默认只观测)\n` +
` --total-threshold <n> 全量阈值(默认 80,仅在 --enforce-total 时生效)`,
);
process.exit(0);
}
}
return out;
}
// ---------------- lcov 解析 ----------------
/**
* 解析 lcov,返回「项目自有文件」的覆盖率。
*
* 过滤掉三类噪声:
* 1. node_modules
* 2. 仓库外路径(运行时注入的 shim,实测会混进 WorkBuddy/Application 路径)
* 3. .test.mjs 自身(测试文件不该计入被测代码)
*/
function parseLcov(text) {
const out = new Map(); // repoRelPath -> {found, hit}
let cur = null;
let found = 0;
let hit = 0;
const flush = () => {
if (!cur) return;
const rel = toRepoRelative(cur);
if (rel && isProjectFile(rel)) {
const prev = out.get(rel);
if (prev) {
prev.found += found;
prev.hit += hit;
} else {
out.set(rel, { found, hit });
}
}
cur = null;
found = 0;
hit = 0;
};
for (const raw of text.split('\n')) {
const line = raw.trimEnd();
if (line.startsWith('SF:')) {
flush();
cur = line.slice(3).trim();
} else if (line === 'end_of_record') {
flush();
} else if (cur) {
if (line.startsWith('LF:')) found = Number(line.slice(3)) || 0;
else if (line.startsWith('LH:')) hit = Number(line.slice(3)) || 0;
}
}
flush();
return out;
}
function toRepoRelative(absPath) {
const p = resolve(absPath);
if (!p.startsWith(REPO)) return null;
const rel = relative(REPO, p);
if (rel.startsWith('..')) return null;
return rel;
}
function isProjectFile(rel) {
if (rel.includes('node_modules/')) return false;
if (rel.startsWith('node_modules/')) return false;
// 只统计本仓库的 apps / packages / scripts
if (!/^(apps|packages|scripts)\//.test(rel)) return false;
// 测试自身不计入
if (/\.test\.mjs$/.test(rel)) return false;
return true;
}
/** 源文件(src/*.ts)→ 单测实际 import 的产物路径(dist/*.js)。 */
function mapSourceToArtifact(srcRel) {
if (srcRel.includes('/src/')) return srcRel.replace('/src/', '/dist/').replace(/\.ts$/, '.js');
return srcRel;
}
// ---------------- 变更文件 ----------------
function changedFiles(base) {
if (!base) return [];
const range = `${base}...HEAD`;
let out = '';
try {
out = execFileSync('git', ['diff', '--name-only', range], { encoding: 'utf8' });
} catch {
// 基线不可达(例如浅克隆)→ 退化为与 HEAD~1 比对
try {
out = execFileSync('git', ['diff', '--name-only', 'HEAD~1', 'HEAD'], { encoding: 'utf8' });
} catch {
return [];
}
}
return out
.split('\n')
.map((s) => s.trim())
.filter(Boolean);
}
// ---------------- 主流程 ----------------
const args = parseArgs(process.argv.slice(2));
if (!existsSync(args.lcov)) {
fail(`找不到 lcov 文件: ${args.lcov}\n 请先跑:node --test --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=${args.lcov} "tests/unit/**/*.test.mjs"`);
}
const cov = parseLcov(readFileSync(args.lcov, 'utf8'));
if (cov.size === 0) {
fail(`lcov 里没有任何项目文件(${args.lcov})—— 覆盖率采集可能失败,请检查是否加了 --experimental-test-coverage`);
}
// --- 全量 ---
let totalFound = 0;
let totalHit = 0;
for (const v of cov.values()) {
totalFound += v.found;
totalHit += v.hit;
}
const totalPct = totalFound > 0 ? (totalHit / totalFound) * 100 : 0;
console.log(`全量覆盖率(观测) : ${totalPct.toFixed(1)}% (${totalHit}/${totalFound} 行, ${cov.size} 个文件)`);
// --- 变更文件 ---
const changed = changedFiles(args.base);
let blocked = false;
const noData = [];
if (changed.length > 0) {
const candidates = new Map(); // artifactRel -> srcRel
for (const f of changed) {
if (!/^(apps|packages|scripts)\//.test(f)) continue;
if (/\.(md|json|ya?ml|txt)$/.test(f)) continue; // 文档/配置不参与
const art = mapSourceToArtifact(f);
candidates.set(art, f);
}
const rows = [];
for (const [art, src] of candidates) {
const v = cov.get(art);
if (!v) {
noData.push(src);
continue;
}
const pct = v.found > 0 ? (v.hit / v.found) * 100 : 100;
rows.push({ src, art, pct, hit: v.hit, found: v.found });
}
rows.sort((a, b) => a.pct - b.pct);
console.log(`\n变更文件覆盖率(阈值 ${args.threshold}%,阻断):`);
if (rows.length === 0) console.log(' (无具备覆盖数据的变更文件)');
for (const r of rows) {
const mark = r.pct < args.threshold ? '✗' : '✓';
console.log(` ${mark} ${r.pct.toFixed(1).padStart(5)}% ${r.src} (${r.hit}/${r.found} 行)`);
if (r.pct < args.threshold) blocked = true;
}
if (noData.length > 0) {
console.log(`\n无覆盖数据的变更文件(不阻断,供人工判断):`);
for (const f of noData) console.log(` - ${f}`);
}
} else {
console.log('\n(未提供 --base,跳过变更文件判定)');
}
if (args.enforceTotal && totalPct < args.totalThreshold) {
console.error(`\n✗ 全量覆盖率 ${totalPct.toFixed(1)}% < 阈值 ${args.totalThreshold}%`);
blocked = true;
}
if (blocked) {
console.error('\n✗ 覆盖率门禁未通过');
process.exit(1);
}
console.log('\n✓ 覆盖率门禁通过');
+3
View File
@@ -9,6 +9,7 @@ REM 3) stop - stop the scheduled task
REM 4) restart - stop then start
REM 5) logs - tail the agent's audit log
REM 6) uninstall - remove task + firewall rule (7) purge config and exit
REM 8) cleanup - remove stale nodeagent tasks (whitelist only)
REM
REM Usage: control.cmd [port]
REM port defaults to 8765; pass it only if the agent uses another.
@@ -43,6 +44,7 @@ echo 4) Restart agent
echo 5) Show recent audit log
echo 6) Uninstall ^(keep config^)
echo 7) Uninstall and purge config
echo 8) Cleanup stale tasks
echo Q) Quit
echo ==========================================================
echo.
@@ -67,6 +69,7 @@ if "%CHOICE%"=="4" call :RUN restart
if "%CHOICE%"=="5" call :RUN logs
if "%CHOICE%"=="6" call :RUN uninstall
if "%CHOICE%"=="7" call :RUN uninstall-purge
if "%CHOICE%"=="8" call :RUN cleanup
goto :MENU
:QUIT
+42 -5
View File
@@ -10,7 +10,7 @@
Chinese comments then break string boundaries -> bogus syntax errors.
.PARAMETER Action
status | start | stop | restart | logs | uninstall | uninstall-purge
status | start | stop | restart | logs | cleanup | uninstall | uninstall-purge
.EXAMPLE
.\control.ps1 -Action status
@@ -18,7 +18,7 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[ValidateSet('status', 'start', 'stop', 'restart', 'logs', 'uninstall', 'uninstall-purge')]
[ValidateSet('status', 'start', 'stop', 'restart', 'logs', 'cleanup', 'uninstall', 'uninstall-purge')]
[string]$Action,
[int]$Port = 8765
)
@@ -32,6 +32,13 @@ function Write-Err($m) { Write-Host "[x] $m" -ForegroundColor Red }
$taskName = "nodeagent"
# B-P1-6 / T05:计划任务白名单。历史版本注册过 na-gui 等旧名任务,漏删会留下
# 「端口被占、新版本起不来」的幽灵任务。铁律:只删白名单内**且实际存在**的
# 任务,禁通配删除 —— 误删用户自己的任务是比漏删严重得多的生产事故。
# 与 scripts/lib/scheduled-tasks.mjs 的 TASK_WHITELIST 同口径
# (tests/unit/na-gui-cleanup.test.mjs 有跨端一致性守卫,白名单不许两边漂移)。
$taskWhitelist = @("nodeagent", "na-gui", "nodeagent-console")
# 数据目录:必须与 agent 侧一致 —— apps/agent/src/config.ts 的 agentDir() 是
# process.env.NODEAGENT_HOME ?? join(homedir(), '.nodeagent')
# 计划任务里若设了 NODEAGENT_HOME,只查 %USERPROFILE%\.nodeagent 会读错配置。
@@ -205,15 +212,44 @@ function Show-Logs {
Write-Host ""
}
# ── cleanup(白名单清理陈旧计划任务)────────────────────────────────────────────
function Clear-StaleTasks {
if (-not (Test-Admin)) {
Write-Err "清理计划任务需要管理员权限,请右键 control.cmd → 以管理员身份运行"
return
}
Write-Step "清理 nodeagent 历史计划任务(仅白名单内且存在的)..."
# -ccontains 为大小写敏感包含,与 scheduled-tasks.mjs 的精确匹配同口径
$existing = Get-ScheduledTask | Select-Object -ExpandProperty TaskName
foreach ($name in $taskWhitelist) {
if ($existing -ccontains $name) {
Stop-ScheduledTask -TaskName $name -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName $name -Confirm:$false -ErrorAction SilentlyContinue
Write-Ok "已删除计划任务: $name"
} else {
Write-Host " - $name (不存在,跳过)"
}
}
Write-Host ""
Write-Host " 提示:白名单外的任务一律不碰(误删他人任务是生产事故)。" -ForegroundColor DarkGray
Write-Host " 若端口仍被占用,用 status 子命令查看占用进程 PID 后手动处理。" -ForegroundColor DarkGray
}
# ── uninstall ─────────────────────────────────────────────────────────────────
function Uninstall-Agent([bool]$Purge) {
if (-not (Test-Admin)) {
Write-Err "卸载需要管理员权限,请右键 control.cmd → 以管理员身份运行"
return
}
Write-Step "停止并注销计划任务..."
Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
Write-Step "停止并注销计划任务(白名单内且存在的)..."
$existing = Get-ScheduledTask | Select-Object -ExpandProperty TaskName
foreach ($name in $taskWhitelist) {
if ($existing -ccontains $name) {
Stop-ScheduledTask -TaskName $name -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName $name -Confirm:$false -ErrorAction SilentlyContinue
Write-Ok "已删除计划任务: $name"
}
}
Write-Step "移除防火墙规则..."
Get-NetFirewallRule -DisplayName "nodeagent (TCP $Port)" -ErrorAction SilentlyContinue |
@@ -235,6 +271,7 @@ switch ($Action) {
'stop' { Stop-Agent }
'restart' { Stop-Agent; Start-Sleep -Seconds 1; Start-Agent }
'logs' { Show-Logs }
'cleanup' { Clear-StaleTasks }
'uninstall' { Uninstall-Agent -Purge $false }
'uninstall-purge' { Uninstall-Agent -Purge $true }
}
+428
View File
@@ -0,0 +1,428 @@
#!/usr/bin/env bash
#
# nodeagent 被控端 —— Linux 安装器(Debian 12 / Ubuntu / NAS 系统等 systemd 系统)
#
# 用法:
# sudo bash scripts/install-linux.sh # 系统级(/opt/nodeagent + 系统 systemd)
# bash scripts/install-linux.sh --user # 用户级(~/.local/share + user systemd + linger)
# sudo bash scripts/install-linux.sh --node-id nas-01 --port 8765
# sudo bash scripts/install-linux.sh --no-service # 只装程序+配置,不注册 systemd
# sudo bash scripts/install-linux.sh --uninstall # 卸载(保留配置)
# sudo bash scripts/install-linux.sh --uninstall --purge # 卸载并删配置
#
# 这套脚本要解决的实际问题(与 Windows 的 install.ps1 同语义):
# 1) 目标机未必有 Node → 自带运行时(下载 linux-<arch> 到安装目录)
# 2) 要开机自启 → 用 systemd(对齐 Windows 的「计划任务」角色)
# 3) 密钥要生成/复用 → 写进 <数据目录>/agent.json,并在屏幕上打印(供控制端连接)
# 4) 幂等 → 重复执行不丢配置、不产生重复服务
#
# ⚠️ 本平台没有 GUI 能力(截图/键鼠/窗口/剪贴板/音量均为 Windows/macOS 实现),
# 故 --allow-input 即使打开也只影响「是否允许输入类能力注册」,
# **在 Linux 上不会有实际输入能力可用**。默认关闭。
#
set -euo pipefail
# ---------------- 常量 ----------------
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
NODE_VERSION="${NODEAGENT_NODE_VERSION:-v22.20.0}"
NODE_MIRROR="${NODEAGENT_NODE_MIRROR:-https://npmmirror.com/mirrors/node}"
DEFAULT_PORT=8765
DEFAULT_NODE_ID="$(hostname 2>/dev/null | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9_-' || echo linux_01)"
[ -z "$DEFAULT_NODE_ID" ] && DEFAULT_NODE_ID="linux_01"
UNIT_NAME="nodeagent"
# ---------------- 输出 ----------------
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
C_OK=$'\033[32m'; C_WARN=$'\033[33m'; C_ERR=$'\033[31m'; C_DIM=$'\033[2m'; C_RST=$'\033[0m'
else
C_OK=''; C_WARN=''; C_ERR=''; C_DIM=''; C_RST=''
fi
ok() { printf '%s✓%s %s\n' "$C_OK" "$C_RST" "$*"; }
warn() { printf '%s!%s %s\n' "$C_WARN" "$C_RST" "$*"; }
err() { printf '%s✗%s %s\n' "$C_ERR" "$C_RST" "$*" >&2; }
info() { printf '%s·%s %s\n' "$C_DIM" "$C_RST" "$*"; }
die() { err "$*"; exit 1; }
# ---------------- 参数 ----------------
MODE="system" # system | user
PORT="$DEFAULT_PORT"
NODE_ID="$DEFAULT_NODE_ID"
ALLOW_INPUT=0
UNINSTALL=0
PURGE=0
NO_SERVICE=0
BIND_HOST="0.0.0.0"
while [ $# -gt 0 ]; do
case "$1" in
--user) MODE="user"; shift ;;
--system) MODE="system"; shift ;;
--port) PORT="${2:-}"; shift 2 ;;
--node-id) NODE_ID="${2:-}"; shift 2 ;;
--allow-input) ALLOW_INPUT=1; shift ;;
--host) BIND_HOST="${2:-}"; shift 2 ;;
--no-service) NO_SERVICE=1; shift ;;
--uninstall) UNINSTALL=1; shift ;;
--purge) PURGE=1; shift ;;
-h|--help)
sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'
exit 0 ;;
*) die "未知参数:$1(用 --help 查看用法)" ;;
esac
done
case "$PORT" in
''|*[!0-9]*) die "--port 必须是数字,收到:$PORT" ;;
esac
[ "$PORT" -ge 1 ] && [ "$PORT" -le 65535 ] || die "--port 需在 1-65535,收到:$PORT"
# ---------------- 路径规划 ----------------
ARCH_RAW="$(uname -m)"
case "$ARCH_RAW" in
x86_64|amd64) NODE_ARCH="x64" ;;
aarch64|arm64) NODE_ARCH="arm64" ;;
*) die "不支持的架构:$ARCH_RAW(仅 x86_64 / aarch64)" ;;
esac
if [ "$MODE" = "user" ]; then
INSTALL_DIR="${NODEAGENT_INSTALL_DIR:-$HOME/.local/share/nodeagent}"
UNIT_DIR="$HOME/.config/systemd/user"
DATA_DIR="${NODEAGENT_HOME:-$HOME/.nodeagent}"
SUDO=""
else
INSTALL_DIR="${NODEAGENT_INSTALL_DIR:-/opt/nodeagent}"
UNIT_DIR="/etc/systemd/system"
DATA_DIR="${NODEAGENT_HOME:-$HOME/.nodeagent}" # 数据目录始终在「执行安装的用户」家目录下
[ "$(id -u)" -eq 0 ] || die "系统级安装需要 root:请用 sudo,或改用 --user"
SUDO=""
fi
AGENT_JS_SRC="$PROJECT_ROOT/release/nodeagent-linux/agent.mjs"
[ -f "$AGENT_JS_SRC" ] || AGENT_JS_SRC="$PROJECT_ROOT/release/agent.mjs"
[ -f "$AGENT_JS_SRC" ] || die "找不到 agent.mjs —— 请先执行:node scripts/pack-linux.mjs(或 pnpm pack:linux)"
# ---------------- 卸载 ----------------
if [ "$UNINSTALL" -eq 1 ]; then
echo "=== 卸载 nodeagent ==="
if [ "$MODE" = "user" ]; then
systemctl --user disable --now "$UNIT_NAME" >/dev/null 2>&1 || true
rm -f "$UNIT_DIR/$UNIT_NAME.service"
systemctl --user daemon-reload >/dev/null 2>&1 || true
else
systemctl disable --now "$UNIT_NAME" >/dev/null 2>&1 || true
rm -f "$UNIT_DIR/$UNIT_NAME.service"
systemctl daemon-reload >/dev/null 2>&1 || true
fi
rm -rf "$INSTALL_DIR"
ok "已移除服务与程序目录:$INSTALL_DIR"
if [ "$PURGE" -eq 1 ]; then
rm -rf "$DATA_DIR"
ok "已删除数据目录(含密钥与审计日志):$DATA_DIR"
else
info "已保留数据目录:$DATA_DIR(含密钥与审计日志;加 --purge 可一并删除)"
fi
exit 0
fi
# ---------------- 环境检查 ----------------
echo "=== nodeagent 被控端安装(Linux · $MODE 模式)==="
echo ""
[ "$(uname -s)" = "Linux" ] || die "本脚本仅用于 Linux(当前:$(uname -s))"
if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then
ok "systemd 可用"
elif [ "$NO_SERVICE" -eq 1 ]; then
warn "未检测到 systemd,但已指定 --no-service → 只装程序与配置,不注册服务"
MODE_LABEL="no-service"
else
die "未检测到 systemd(本脚本用 systemd 做开机自启)。
可选项:
a) 加 --no-service 只装程序与配置,自启由你另行安排(手动/nohup/supervisor/cron @reboot)
b) 手工启动:NODEAGENT_HOME=$DATA_DIR $INSTALL_DIR/runtime/bin/node $INSTALL_DIR/agent.mjs"
fi
# 发行版信息(部分 NAS 系统基于 Debian 12,此处只作展示)
if [ -r /etc/os-release ]; then
# shellcheck disable=SC1091
. /etc/os-release
info "系统:${PRETTY_NAME:-unknown}(内核 $(uname -r))"
fi
ok "架构:$ARCH_RAW → node-$NODE_ARCH"
# ---------------- Node 运行时 ----------------
RUNTIME_DIR="$INSTALL_DIR/runtime"
NODE_BIN=""
# ① 系统 node ≥ 22 就直接用(省一次下载;NAS 上通常没有,走 ②)
if command -v node >/dev/null 2>&1; then
SYS_VER="$(node -v 2>/dev/null || echo '')"
SYS_MAJOR="${SYS_VER#v}"; SYS_MAJOR="${SYS_MAJOR%%.*}"
if [ -n "$SYS_MAJOR" ] && [ "$SYS_MAJOR" -ge 22 ] 2>/dev/null; then
NODE_BIN="$(command -v node)"
ok "使用系统 Node:$SYS_VER($NODE_BIN)"
else
info "系统 Node 版本过低(${SYS_VER:-无}),改用自带运行时"
fi
fi
if [ -z "$NODE_BIN" ]; then
if [ -x "$RUNTIME_DIR/bin/node" ]; then
NODE_BIN="$RUNTIME_DIR/bin/node"
ok "复用已下载的运行时:$("$NODE_BIN" -v)"
else
TARBALL="node-$NODE_VERSION-linux-$NODE_ARCH.tar.xz"
URL="$NODE_MIRROR/$NODE_VERSION/$TARBALL"
TMPDIR_DL="$(mktemp -d)"
trap 'rm -rf "$TMPDIR_DL"' EXIT
info "下载 Node 运行时:$URL"
if command -v curl >/dev/null 2>&1; then
curl -fsSL --retry 3 --connect-timeout 20 -o "$TMPDIR_DL/$TARBALL" "$URL" \
|| die "下载失败(可设 NODEAGENT_NODE_MIRROR 换源,或预先把 tar.xz 放到 $TMPDIR_DL)"
elif command -v wget >/dev/null 2>&1; then
wget -q -T 20 -t 3 -O "$TMPDIR_DL/$TARBALL" "$URL" || die "下载失败:$URL"
else
die "需要 curl 或 wget 之一来下载 Node 运行时"
fi
mkdir -p "$INSTALL_DIR"
info "解压到 $RUNTIME_DIR"
mkdir -p "$RUNTIME_DIR"
tar -xJf "$TMPDIR_DL/$TARBALL" -C "$RUNTIME_DIR" --strip-components=1 || die "解压失败:$TARBALL"
NODE_BIN="$RUNTIME_DIR/bin/node"
ok "运行时就绪:$("$NODE_BIN" -v)"
fi
fi
"$NODE_BIN" -e 'process.exit(Number(process.versions.node.split(".")[0]) >= 22 ? 0 : 1)' \
|| die "Node 版本需 ≥ 22(当前 $("$NODE_BIN" -v))"
# ---------------- 安装程序文件 ----------------
mkdir -p "$INSTALL_DIR" "$DATA_DIR"
install -m 0644 "$AGENT_JS_SRC" "$INSTALL_DIR/agent.mjs"
ok "已安装:$INSTALL_DIR/agent.mjs($(wc -c < "$INSTALL_DIR/agent.mjs" | tr -d ' ') 字节)"
# ---------------- 看门狗(v2.1.0,决策 R-1) ----------------
#
# 启动连续失败 N 次自动从 LKG 恢复入口文件。
# 为什么需要:nodeagent rollback 是 RPC 命令,被控端「完全起不来」时无监听可连,
# CLI 救不了(ARCH A-P0-6);看门狗跑在 supervisor 链上,不要求被控端可达。
# ⚠️ 两个文件必须保持相对位置一起安装:guard 同目录的 lib/ 下是它的核心纯函数。
GUARD_MJS="$PROJECT_ROOT/scripts/startup-guard.mjs"
GUARD_LIB="$PROJECT_ROOT/scripts/lib/startup-guard-core.mjs"
if [ -f "$GUARD_MJS" ] && [ -f "$GUARD_LIB" ]; then
mkdir -p "$INSTALL_DIR/scripts/lib"
install -m 0644 "$GUARD_MJS" "$INSTALL_DIR/scripts/startup-guard.mjs"
install -m 0644 "$GUARD_LIB" "$INSTALL_DIR/scripts/lib/startup-guard-core.mjs"
# 被看守的是「完整启动命令」(-- 之后原样 spawn):node agent.mjs
AGENT_CMD="$NODE_BIN $INSTALL_DIR/scripts/startup-guard.mjs --data-dir $DATA_DIR --lkg-dir $INSTALL_DIR --install-dir $INSTALL_DIR --threshold 3 -- $NODE_BIN $INSTALL_DIR/agent.mjs"
ok "看门狗就绪:启动连续失败 3 次自动从 LKG 恢复(日志 $DATA_DIR/logs/startup-guard.log)"
else
AGENT_CMD="$NODE_BIN $INSTALL_DIR/agent.mjs"
warn "未找到看门狗脚本($GUARD_MJS)—— 按原样直启 agent(无自动恢复)"
fi
# ---------------- 配置:复用既有密钥,否则生成 ----------------
CFG="$DATA_DIR/agent.json"
KEY=""
if [ -f "$CFG" ]; then
# 备份一次(保留最近一次即可,避免堆积)
cp -f "$CFG" "$CFG.bak" 2>/dev/null || true
KEY="$(sed -n 's/.*"key"[[:space:]]*:[[:space:]]*"\([0-9a-fA-F]\{16,\}\)".*/\1/p' "$CFG" | head -1)"
if [ -n "$KEY" ]; then
ok "复用既有配置:$CFG(已备份为 agent.json.bak)"
else
info "既有配置里没有可用密钥,将生成新密钥"
fi
fi
if [ -z "$KEY" ]; then
if command -v openssl >/dev/null 2>&1; then
KEY="$(openssl rand -hex 32)"
else
KEY="$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')"
fi
case "$KEY" in
*[!0-9a-fA-F]*|'') die "密钥生成异常(非 hex)" ;;
esac
[ ${#KEY} -ge 32 ] || die "密钥长度不足(${#KEY})"
fi
cat > "$CFG" <<JSON
{
"node_id": "$NODE_ID",
"host": "$BIND_HOST",
"port": $PORT,
"tls": true,
"key": "$KEY",
"log_level": "info",
"allow_input": $([ "$ALLOW_INPUT" -eq 1 ] && echo true || echo false),
"auth_mode": "psk"
}
JSON
chmod 600 "$CFG"
ok "配置已写入:$CFG(权限 600)"
[ "$ALLOW_INPUT" -eq 1 ] && warn "已开启 allow_input —— 但 **Linux 上并无输入类能力实现**,此项仅作配置留存"
# ---------------- systemd 服务 ----------------
if [ "$NO_SERVICE" -eq 1 ]; then
info "跳过 systemd 注册(--no-service)"
else
if [ "$MODE" = "user" ]; then
mkdir -p "$UNIT_DIR"
UNIT_PATH="$UNIT_DIR/$UNIT_NAME.service"
else
UNIT_PATH="$UNIT_DIR/$UNIT_NAME.service"
fi
cat > "$UNIT_PATH" <<UNIT
[Unit]
Description=nodeagent 被控端(跨机 AI 接管)
Documentation=https://github.com/jkooe/nodeagent
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
# 数据目录(含密钥与审计日志)—— 与 agent 侧 agentDir() 一致
Environment=NODEAGENT_HOME=$DATA_DIR
# v2.1.0:ExecStart 包一层看门狗($AGENT_CMD,见上文「看门狗」节)。
# 崩溃自拉起由 Restart=always 负责;看门狗负责「连续失败 N 次从 LKG 恢复」。
ExecStart=$AGENT_CMD
WorkingDirectory=$INSTALL_DIR
# 崩溃自拉起(对齐 Windows 计划任务的重启语义)
Restart=always
RestartSec=3
# 优雅停止:先 SIGTERM(agent 会关掉监听与审计写入),超时再 SIGKILL。
# 看门狗收到 SIGTERM 会转发给子进程后退出,不会被误记成「启动失败」。
KillSignal=SIGTERM
TimeoutStopSec=15
# 日志进 journald:journalctl -u $UNIT_NAME -f
StandardOutput=journal
StandardError=journal
# 退出码 0 视为「非异常」(如 --uninstall 后的正常停止)
SuccessExitStatus=0 143
[Install]
WantedBy=$([ "$MODE" = "user" ] && echo default.target || echo multi-user.target)
UNIT
ok "已写入服务单元:$UNIT_PATH"
if [ "$MODE" = "user" ]; then
systemctl --user daemon-reload
systemctl --user enable --now "$UNIT_NAME" || die "启动失败,查看:systemctl --user status $UNIT_NAME"
# 让用户级服务在「未登录」时也能跑(NAS 重启后无需登录即自启)
if command -v loginctl >/dev/null 2>&1; then
if loginctl enable-linger "$USER" 2>/dev/null; then
ok "已启用 linger(未登录也自启)"
else
warn "未能启用 linger(可能需要 root 执行一次:loginctl enable-linger $USER)"
warn " 未启用 linger 时,用户级服务只在登录会话中运行"
fi
fi
else
systemctl daemon-reload
systemctl enable --now "$UNIT_NAME" || die "启动失败,查看:systemctl status $UNIT_NAME -l"
fi
fi # end: NO_SERVICE
# ---------------- 启动确认 ----------------
if [ "$NO_SERVICE" -eq 1 ]; then
echo ""
warn "未注册服务 —— 手工启动:"
printf ' NODEAGENT_HOME=%s %s %s/agent.mjs\n' "$DATA_DIR" "$NODE_BIN" "$INSTALL_DIR"
MODE_LABEL="no-service"
else
sleep 2
STATE="$(systemctl is-active "$UNIT_NAME" 2>/dev/null || true)"
[ "$MODE" = "user" ] && STATE="$(systemctl --user is-active "$UNIT_NAME" 2>/dev/null || true)"
if [ "$STATE" = "active" ]; then
ok "服务运行中($UNIT_NAME)"
else
warn "服务状态:${STATE:-unknown}"
warn "查看日志:$([ "$MODE" = "user" ] && echo 'journalctl --user -u' || echo 'journalctl -u') $UNIT_NAME -n 50 --no-pager"
fi
fi # end: NO_SERVICE 启动确认
# 端口监听检查(不强依赖 ss/netstat)
if command -v ss >/dev/null 2>&1 && ss -ltn 2>/dev/null | grep -q ":$PORT "; then
ok "端口 $PORT 已监听"
else
info "端口 $PORT 监听状态未确认(可用 ss -ltnp | grep $PORT 自查)"
fi
# ---------------- 打印连接信息 ----------------
# ⚠️ 取本机 IP 要容错:`ip` 在精简系统(含部分 NAS 系统)上可能不存在,
# 而 `set -e` 下「命令替换失败」会**直接终止脚本**(且 2>/dev/null 把原因吞了,
# 表现为「脚本跑到这里就没了」——2026-10-13 在 Debian 容器里踩到)。
LAN_IP=""
if command -v ip >/dev/null 2>&1; then
LAN_IP="$(ip -4 route get 1.1.1.1 2>/dev/null | sed -n 's/.* src \([0-9.]*\).*/\1/p' | head -1)" || true
fi
if [ -z "$LAN_IP" ]; then
LAN_IP="$(hostname -I 2>/dev/null | awk '{print $1}')" || true
fi
if [ -z "$LAN_IP" ] && command -v ifconfig >/dev/null 2>&1; then
LAN_IP="$(ifconfig 2>/dev/null | sed -n 's/.*inet \(addr:\)\?\([0-9.]*\).*/\2/p' | grep -v '^127\.' | head -1)" || true
fi
[ -z "$LAN_IP" ] && LAN_IP="<本机IP>"
echo ""
echo "══════════════════════════════════════════════════════"
echo " nodeagent 被控端已就绪"
echo "──────────────────────────────────────────────────────"
printf ' 节点 ID : %s\n' "$NODE_ID"
printf ' 监听 : %s:%s (wss / TLS)\n' "$BIND_HOST" "$PORT"
printf ' 本机 IP : %s\n' "$LAN_IP"
printf ' 认证模式 : psk\n'
printf ' 输入控制 : %s\n' "$([ "$ALLOW_INPUT" -eq 1 ] && echo 已开启 || echo 已禁用(Linux 无 GUI 能力))"
printf ' 安装目录 : %s\n' "$INSTALL_DIR"
printf ' 数据目录 : %s\n' "$DATA_DIR"
if [ "$NO_SERVICE" -eq 1 ]; then
printf ' 服务 : 未注册(--no-service,需自行安排自启)\n'
else
printf ' 服务名 : %s(%s systemd)\n' "$UNIT_NAME" "$MODE"
fi
echo ""
echo " 密钥(请妥善保存,控制端连接需要):"
echo ""
printf ' %s\n' "$KEY"
echo ""
echo " 控制端连接命令:"
printf ' nodeagent connect %s --port %s --key %s --insecure\n' "$LAN_IP" "$PORT" "$KEY"
echo "══════════════════════════════════════════════════════"
echo ""
echo " 常用管理命令($MODE 级):"
if [ "$MODE" = "user" ]; then
echo " systemctl --user status $UNIT_NAME"
echo " journalctl --user -u $UNIT_NAME -f # 实时日志"
echo " systemctl --user restart $UNIT_NAME"
echo " bash $0 --user --uninstall # 卸载(保留配置)"
else
echo " systemctl status $UNIT_NAME"
echo " journalctl -u $UNIT_NAME -f # 实时日志"
echo " systemctl restart $UNIT_NAME"
echo " sudo bash $0 --uninstall # 卸载(保留配置)"
fi
echo ""
echo " 注意:审计链外部锚定建议尽快启用(把链头存到链外)——"
echo " node scripts/anchor-audit.mjs collect --node $NODE_ID"
echo ""
+26 -3
View File
@@ -206,7 +206,27 @@ Write-Ok "Inbound rule allowed on TCP $Port"
# 6. Scheduled task
Write-Step "Registering scheduled task..."
$taskName = "nodeagent"
$action = New-ScheduledTaskAction -Execute $nodeExe -Argument "`"$agentJs`"" -WorkingDirectory $ProjectRoot
# v2.1.0 看门狗(决策 R-1):启动动作包一层 scripts/startup-guard.mjs ——
# 被控端「启动连续失败 N 次」时自动从 LKG 恢复入口文件。
# 为什么需要:nodeagent rollback 是 RPC 命令,进程完全起不来时无监听可连,
# CLI 救不了(ARCH A-P0-6);看门狗跑在 supervisor 链上,不需要被控端可达。
# 脚本位置:便携包内随 scripts/ 一起分发;开发模式用仓库 scripts/。
# 缺文件时**兜底直启 agent**(不因少一个脚本而装不上),行为与旧版一致。
$guardScript = Join-Path $PSScriptRoot "scripts\startup-guard.mjs"
if (-not (Test-Path $guardScript)) { $guardScript = Join-Path $ProjectRoot "scripts\startup-guard.mjs" }
$guardLib = Join-Path (Split-Path -Parent $guardScript) "lib\startup-guard-core.mjs"
if ((Test-Path $guardScript) -and (Test-Path $guardLib)) {
$agentHome = Split-Path -Parent $agentJs
# 被看守的是「完整启动命令」(-- 之后原样 spawn):node <agent.mjs>
# 注意:计划任务动作不带环境变量,NODEAGENT_HOME 由看门狗 spawn 时注入
# (--data-dir 即数据目录,见 startup-guard.mjs 的 spawnEnv)
$guardArgs = "`"$guardScript`" --data-dir `"$cfgDir`" --lkg-dir `"$agentHome`" --install-dir `"$agentHome`" --threshold 3 -- `"$nodeExe`" `"$agentJs`""
$action = New-ScheduledTaskAction -Execute $nodeExe -Argument $guardArgs -WorkingDirectory $ProjectRoot
Write-Ok "启动动作已包一层看门狗(连续失败 3 次自动从 LKG 恢复)"
} else {
Write-Warn "未找到看门狗脚本($guardScript)—— 按原样直启 agent(无自动恢复)"
$action = New-ScheduledTaskAction -Execute $nodeExe -Argument "`"$agentJs`"" -WorkingDirectory $ProjectRoot
}
# 触发器:登录时(默认)+ 可选开机时;无人值守模式下开机触发器才真正有意义
$triggers = @(New-ScheduledTaskTrigger -AtLogOn)
@@ -214,9 +234,12 @@ if ($AtStartup -or $Unattended) {
$triggers += New-ScheduledTaskTrigger -AtStartup
}
# 自愈:异常退出后自动重启(最多 3 次,间隔 1 分钟),且不设执行时长上限
# 自愈:异常退出后自动重启(间隔 1 分钟),且不设执行时长上限。
# v2.1.0:RestartCount 3 → 10 —— 看门狗要「连续失败 3 次」才触发 LKG 恢复,
# 加上恢复本身可能需要再起一次,3 次不够用(会在恢复前就放弃)。
# 良性撞车(端口已被别的实例服务)时看门狗直接退 0,不消耗重启次数。
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
-RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero) `
-RestartCount 10 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero) `
-StartWhenAvailable
# 登录类型决定「能干什么」:
+67
View File
@@ -0,0 +1,67 @@
/**
* Windows 计划任务清理白名单(B-P1-6 / T05)。
*
* ## 为什么需要
* nodeagent 历史版本/缺陷修复在被控端上注册过计划任务(na-gui 等旧名),
* 卸载时若漏删,会留下「端口被占、起不来新版本」的幽灵任务。
* 但清理动作**只许按白名单删**——误删用户自己的任务是比漏删严重得多的
* 生产事故(可能停掉业务服务),故本文件是纯函数 + 白名单,禁通配删除。
*
* ## 铁律
* - 零依赖、零 import:与 startup-guard-core 同风格,任何 Node ≥22 可加载;
* - 白名单**只增不改**:发现新的历史任务名时追加,不从清单里移除旧名
* (老被控端上旧名任务可能还在);
* - 匹配是精确相等(大小写敏感):Windows 任务名实际大小写不敏感,
* 但这里宁可漏删也不模糊匹配——模糊匹配是误删之门。
*/
/** nodeagent 自己注册过的计划任务名(含历史遗留)。只增不改。 */
export const TASK_WHITELIST = ['nodeagent', 'na-gui', 'nodeagent-console'];
/**
* 自重启任务前缀 —— `system.agent.restart` 每次重启都注册
* `nodeagent-selfrestart-<毫秒时间戳>`(动作是「杀旧 PID → 前台拉起新进程」)。
*
* 2026-10-27 真机发现:这类任务不在上面的白名单里,于是**卸载时漏删**——
* 留下的任务会继续尝试拉起已被删除的 agent.mjs,形成每次登录都失败一次的僵尸。
* 但它也不能被当成垃圾随手清:**正在 Running 的那一个往往就是当前 agent 的
* 父任务**(删除会连带杀掉 agent)。故只把它纳入「卸载清理」的判定集合,
* 由调用方在「已经停掉 agent」之后使用。
*/
export const SELF_RESTART_PREFIX = 'nodeagent-selfrestart-';
/** `nodeagent-selfrestart-<纯数字>` —— 严格到毫秒时间戳形态,不接受任何通配。 */
const SELF_RESTART_RE = /^nodeagent-selfrestart-\d{6,}$/;
/** 是否是本框架的自重启任务(严格命名空间,非模糊匹配)。 */
export function isSelfRestartTask(name) {
return typeof name === 'string' && SELF_RESTART_RE.test(name);
}
/**
* 计划删除名单:白名单内**且实际存在**的任务,按白名单固定顺序返回。
*
* 纯函数:查询结果由调用方注入(PowerShell 侧 Get-ScheduledTask),
* 本函数不碰文件系统、不碰注册表 —— 判断可单测,误删风险可证明。
*
* @param {string[]} existing 被控端上实际存在的任务名(可含重复/空串/非字符串)
* @returns {string[]} 需要删除的任务名(每个最多一次;白名单外一律不出现)
*/
export function planTaskCleanup(existing) {
if (!Array.isArray(existing)) return [];
const present = new Set();
for (const n of existing) {
if (typeof n === 'string' && n.length > 0) present.add(n);
}
const out = [];
for (const name of TASK_WHITELIST) {
if (present.has(name)) out.push(name);
}
// 自重启任务:仅接受严格命名空间(`nodeagent-selfrestart-<数字>`),
// 按名字排序保证输出稳定;与白名单重名时去重(实际不会发生,防御性)。
const selfRestart = [...present].filter(isSelfRestartTask).sort();
for (const name of selfRestart) {
if (!out.includes(name)) out.push(name);
}
return out;
}
+200
View File
@@ -0,0 +1,200 @@
/**
* 看门狗核心(startup-guard-core.mjs)—— 纯函数 + 零依赖 + 零 IO。
*
* 本文件是 scripts/startup-guard.mjs 的「大脑」:所有**判断**都在这里,
* 且全部是可单测的纯函数;文件系统、子进程、网络都在 startup-guard.mjs 里
* 以薄封装注入。铁律:本文件**不 import 任何东西**(连 node: 内置都不用),
* 保证它在任何 Node ≥22 环境里都能被加载、被测试。
*
* ## 这个看门狗解决什么问题(决策 R-1)
* `nodeagent rollback` 是 RPC 命令,**被控端进程完全起不来时无监听可连**,
* CLI 救不了(ARCH A-P0-6)。故加一层启动看护:启动连续失败 N 次,
* 自动从 LKG(最后已知可用版本)恢复入口文件。
*
* ## 与 DELIVERY-PIPELINE §6 的关系
* §6 原取舍「不做自动回滚」的三问(无法归因/回滚循环/失控)在这里的答复:
* - 无法归因 → 只看**启动期**死亡(存活 < STARTUP_GRACE_MS),
* 运行期死亡不计(计划内重启杀的是长命进程,见 decideAfterExit);
* - 回滚循环 → 恢复后计数归零,且恢复目标只有一个(LKG),不滚动;
* - 失控 → 恢复动作全量记 JSONL 日志,且**无 LKG/无匹配时绝不擦除 install-dir**。
*/
/**
* 启动宽限期(ms):子进程存活超过它再死,视为**运行期事件**而非启动失败。
*
* 为什么需要:本项目的受控端重启机制(system.agent.restart)是
* 「detached 进程杀旧起新」——旧进程是被 kill -9/taskkill 打死的**长命**进程。
* 若不区分启动期/运行期,一次计划内重启就会被记成「启动失败」,
* 累积 N 次后触发**误回滚**(把好版本回滚掉,比坏版本停摆更糟)。
* 30s 的依据:agent 正常启动(含首次生成证书)在秒级完成;
* 坏版本(语法错/缺模块/端口冲突)在 1-2s 内死亡。
*/
export const STARTUP_GRACE_MS = 30_000;
/**
* 看门狗日志轮换阈值(字节):超过即滚存一份。
*
* 为什么需要:日志是 `appendFileSync` 无界追加的 —— 每次启动失败/恢复各写一行,
* 长期运行(尤其「坏版本 + supervisor 反复重启」)会把磁盘写满,而看门狗自己
* 就常驻在这台机器上,磁盘满会连带把 agent 的审计写入一起拖死。
* 1MB × 4 份(当前 + 3 份历史)≈ 4MB 上限,对排障足够(最近几百次启动)。
*/
export const LOG_MAX_BYTES = 1_048_576;
/** 保留的历史份数(`.1` 最新 … `.N` 最旧)。 */
export const LOG_KEEP = 3;
/** 是否需要轮换(纯函数):严格大于阈值才轮换,避免恰好等于阈值时抖动着反复滚。 */
export function shouldRotate(sizeBytes, maxBytes = LOG_MAX_BYTES) {
return Number.isFinite(sizeBytes) && sizeBytes > maxBytes;
}
/** 轮换目标文件名(从新到旧):`<log>.1` … `<log>.N`。 */
export function rotationTargets(logPath, keep = LOG_KEEP) {
const out = [];
for (let i = 1; i <= keep; i += 1) out.push(`${logPath}.${i}`);
return out;
}
/** 全新状态(文件缺失时的起点)。 */
export function initialState(nowIso = '') {
return { failures: 0, updated_at: nowIso };
}
/** 启动成功后的状态:计数归零(与 agent 侧 listen 成功后写状态同口径)。 */
export function zeroFailures(nowIso = '') {
return { failures: 0, updated_at: nowIso };
}
/**
* 解析状态文件文本 → `{failures, updated_at}`。
*
* 缺失/损坏/字段非法一律按 0 —— **不猜、但也不因此拒绝服务**:
* 状态文件只是计数,坏了的代价从「重新数」开始,而不是「不启动」。
* nowIso 仅在文本合法时保留原 updated_at;调用方需要时自行覆盖。
*/
export function readState(rawText) {
if (typeof rawText !== 'string' || rawText.trim() === '') return initialState();
let obj;
try {
obj = JSON.parse(rawText);
} catch {
return initialState();
}
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return initialState();
// 只认 number 型的非负整数:字符串 "3"、布尔、null 一律按 0(不猜)
const n = obj.failures;
const failures = typeof n === 'number' && Number.isInteger(n) && n >= 0 ? n : 0;
const updatedAt = typeof obj.updated_at === 'string' ? obj.updated_at : '';
return { failures, updated_at: updatedAt };
}
/** 失败一次后的新状态(nowIso 由调用方注入,保证可测)。 */
export function nextFailures(state, nowIso = '') {
const prev = Number.isInteger(state?.failures) && state.failures >= 0 ? state.failures : 0;
return { failures: prev + 1, updated_at: nowIso };
}
/**
* 是否达到恢复阈值:连续失败 **>= threshold** 才恢复。
*
* 阈值非法(非正整数)→ 不恢复(放行启动)—— 守卫自身配置错误时,
* 宁可多试几次,也不能乱动入口文件。
*/
export function decideRestore(state, threshold) {
const t = Number(threshold);
if (!Number.isInteger(t) || t < 1) return false;
return (state?.failures ?? 0) >= t;
}
/**
* 子进程退出后的处置决策(纯函数,本模块最重要的判断)。
*
* @param {object} input
* @param {number|null} [input.code] 子进程退出码(被信号打死时为 null)
* @param {string|null} [input.signal] 打死子进程的信号名(正常退出为 null)
* @param {number} input.livedMs 子进程存活时长
* @param {boolean} input.stopping 本 guard 是否正在被要求停止
* (systemctl stop / 任务停止 → 已转发信号给子进程)
* @param {boolean} input.serviceUp 端口是否已被(别的实例)服务
* @param {object} input.state 进入本次决策前的状态
* @param {string} input.nowIso 当前时间(注入)
* @returns {{state: object, counted: boolean, persist: boolean, exitCode: number, reason: string}}
*
* 优先级(从高到低):
* 1. serviceUp —— 服务已由别的实例提供(撞车/接管):使命达成,归零退出 0;
* 2. stopping —— 本 guard 被要求停:不计数,按子进程退出码退出;
* 3. 干净退出 0 —— 成功,归零退出 0;
* 4. 运行期死亡(livedMs >= 宽限期)—— 不计「启动失败」,退出码交回外层 supervisor;
* 5. 启动期死亡且无人接管 —— 连续启动失败 +1,退出码交回外层 supervisor。
*
* persist 表示「本次决策是否要落盘」(stopping / runtime-exit 不写:
* 状态没变,写了反而把 updated_at 搅乱)。
*/
export function decideAfterExit(input) {
const {
code = 0,
signal = null,
livedMs = 0,
stopping = false,
serviceUp = false,
state = initialState(),
nowIso = '',
} = input ?? {};
if (serviceUp) {
return { state: zeroFailures(nowIso), counted: false, persist: true, exitCode: 0, reason: 'service-up' };
}
if (stopping) {
return { state, counted: false, persist: false, exitCode: code ?? 1, reason: 'stopping' };
}
if (!signal && code === 0) {
return { state: zeroFailures(nowIso), counted: false, persist: true, exitCode: 0, reason: 'clean-exit' };
}
const lived = Number.isFinite(livedMs) ? livedMs : 0;
if (lived >= STARTUP_GRACE_MS) {
return { state, counted: false, persist: false, exitCode: code ?? 1, reason: 'runtime-exit' };
}
return { state: nextFailures(state, nowIso), counted: true, persist: true, exitCode: code ?? 1, reason: 'startup-failure' };
}
/**
* 备份文件名匹配:`<entry>.bak`(deploy 固定名)与 `<entry>.bak-<ts>`(selfupdate 时间戳名)。
*
* 与控制端 `apps/cli/src/cmd/rollback.ts` 的 `backupNameRegex` **同口径** ——
* 两边认不出同一批备份 = 看门狗恢复的文件与 rollback 枚举的文件不一致。
* (tests/unit/startup-guard.test.mjs 有跨端一致性守卫。)
* 文件名里的正则元字符一律转义;Windows 反斜杠不当分隔符处理(文件名内不出现)。
*/
export function backupNameRegex(entryName) {
const base = String(entryName ?? '');
const esc = base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`^${esc}\\.bak(-.*)?$`);
}
/**
* 指纹比对:全量相等,或老 LKG(12 位)按前缀比。
*
* 与 CLI 的 `hashMatches` 同口径:LkgRecord 老数据只有 12 位 hash
* (ARCH A-P0-3/R-2),不得要求被控端先升级才能被看门狗恢复。
*/
export function hashMatches(got, want) {
if (typeof got !== 'string' || typeof want !== 'string' || !got || !want) return false;
if (got === want) return true;
return want.length < got.length && got.startsWith(want);
}
/**
* 从「已算好指纹」的备份候选里挑恢复源(纯函数;mtime 新→早的排序在外层做)。
*
* @param {Array<{path: string, hash: string, mtime?: number}>} cands
* @param {string} wantHash LKG 指纹(hash_full ?? hash)
* @returns {{path: string, hash: string}|null} 第一个匹配者;无匹配返回 null
*/
export function planRestore(cands, wantHash) {
if (!wantHash || !Array.isArray(cands)) return null;
for (const c of cands) {
if (c && hashMatches(c.hash, wantHash)) return { path: c.path, hash: c.hash };
}
return null;
}
+74 -18
View File
@@ -11,6 +11,17 @@
* 铁律「守卫要落在**最终产物字节**上」。若版本写入逻辑埋在 `release.mjs` 里,
* 单测只能断言「源码里出现过某个字符串」—— 那种守卫注入故障都不会变红。
* 抽成纯函数后,测试可以对**临时目录里的真实文件**跑一遍再断言文件内容。
*
* ⚠️ 换行符纪律(2026-10-13 Windows CI 实证后补):
* Windows 上 `actions/checkout` 默认 `core.autocrlf=true`,检出的文本是 **CRLF**。
* 因此本模块内**一律不得**对原文跑把 `\n` 写死的正则 —— 那种写法在 CRLF 检出上会
* **静默返回 `undefined`**,而调用方普遍把它当成「没这个字段」直接跳过:不报错、
* 只是悄悄没改(正是本模块要消灭的那类漂移)。
* 统一做法:
* ① **读**值一律 `split('\n')` 后对 `line.trim()` 匹配(`\r` 被 `trim()` 吃掉);
* ② **写**值先用 `splitLineEol()` 把行尾拆下来、改完正文再拼回去。
* (不能偷懒写 `(".*)$` 兜行尾 —— JS 的 `.` **不匹配 `\r`**,那样照样失配。)
* 两条合起来保证:LF / CRLF 两种检出**结果一致**,且写回**逐字节保真**。
*/
import { execFileSync } from 'node:child_process';
@@ -47,6 +58,18 @@ export function versionTargets(root = process.cwd()) {
};
}
/**
* 把一行拆成「正文 + 行尾」—— 行尾可能是 `\r`(CRLF 检出)或空串。
*
* ⚠️ 为什么必须拆:JS 里 `.` **不匹配 `\r`**(`\r` 与 `\n` 同属行终止符),
* 所以 `^(\s*version\s*=\s*")[^"]*(".*)$` 这类写法在 `version = "2.0.3"\r`
* 上会**直接失配** —— 行没改成、`done` 也没置位,全程不报错。
* 2026-10-13 本地复现(CRLF 文本喂给纯函数)即命中此处。
*/
function splitLineEol(line) {
return line.endsWith('\r') ? [line.slice(0, -1), '\r'] : [line, ''];
}
/**
* 改 `Cargo.toml` 的版本号 —— **只在 `[package]` 段内**改。
*
@@ -67,16 +90,33 @@ export function setCargoTomlVersion(text, version, label = 'Cargo.toml') {
}
if (!inPackage || done) continue;
if (/^version\s*=/.test(trimmed)) {
lines[i] = lines[i].replace(/^(\s*version\s*=\s*")[^"]*(".*)$/, `$1${version}$2`);
done = true;
const [body, eol] = splitLineEol(lines[i]);
const m = /^(\s*version\s*=\s*")([^"]*)(".*)$/.exec(body);
if (m) {
lines[i] = `${m[1]}${version}${m[3]}${eol}`;
done = true;
}
}
}
if (!done) throw new Error(`${label} 的 [package] 段里找不到 version 行`);
return lines.join('\n');
}
/** 从 `Cargo.toml` 的 `[package]` 段读 crate 名(用于去 `Cargo.lock` 里定位同一个包)。 */
export function readCargoPackageName(text) {
/** 从 `Cargo.toml` 的 `[package]` 段读版本号(读不到返回 `undefined`)。 */
export function readCargoTomlVersion(text) {
return packageSectionValue(text, 'version');
}
/**
* 在 `[package]` 段内取某个 `key = "值"` 的值。
*
* ⚠️ 一律走 `line.trim()` 再匹配(而不是对原文跑带 `$` / `\n` 的正则)——
* **这样换行符就与结果无关**:CRLF 的 `\r` 被 `trim()` 吃掉,
* LF / CRLF 两种检出得到同一结果。2026-10-13 Windows CI 实证:
* 写死 `\n` 的提取在 CRLF 检出上**静默返回 undefined**,
* 而调用方把它当成「没有这个字段」直接跳过 —— 不报错、只是悄悄没改。
*/
function packageSectionValue(text, key) {
const lines = text.split('\n');
let inPackage = false;
for (const line of lines) {
@@ -86,10 +126,15 @@ export function readCargoPackageName(text) {
continue;
}
if (!inPackage) continue;
const m = /^name\s*=\s*"([^"]*)"$/.exec(trimmed);
const m = new RegExp(`^${key}\\s*=\\s*"([^"]*)"$`).exec(trimmed);
if (m) return m[1];
}
return null;
return undefined;
}
/** 从 `Cargo.toml` 的 `[package]` 段读 crate 名(用于去 `Cargo.lock` 里定位同一个包)。 */
export function readCargoPackageName(text) {
return packageSectionValue(text, 'name') ?? null;
}
/**
@@ -104,6 +149,7 @@ export function setCargoLockVersion(text, pkgName, version) {
const lines = text.split('\n');
let currentName = null;
let done = false;
let before;
for (let i = 0; i < lines.length; i += 1) {
const trimmed = lines[i].trim();
if (trimmed === '[[package]]') {
@@ -116,11 +162,18 @@ export function setCargoLockVersion(text, pkgName, version) {
continue;
}
if (currentName === pkgName && !done && /^version\s*=/.test(trimmed)) {
lines[i] = lines[i].replace(/^(\s*version\s*=\s*")[^"]*(".*)$/, `$1${version}$2`);
done = true;
// 先拆掉行尾再改正文,改完把原行尾接回去 → CRLF 检出下换行符**逐字节保真**,
// 不会把 Windows 工作区悄悄转成 LF(那会污染整个文件的 diff)。
const [body, eol] = splitLineEol(lines[i]);
const m = /^(\s*version\s*=\s*")([^"]*)(".*)$/.exec(body);
if (m) {
before = m[2];
lines[i] = `${m[1]}${version}${m[3]}${eol}`;
done = true;
}
}
}
return { text: lines.join('\n'), changed: done };
return { text: lines.join('\n'), changed: done, before };
}
/**
@@ -154,28 +207,31 @@ export function writeVersionEverywhere(root, version, { dryRun = false } = {}) {
}
// ---- Cargo.toml + Cargo.lock:成对处理(lock 里要按 crate 名定位) ----
//
// ⚠️ 「读旧值」与「写新值」**必须共用同一份实现**(`readCargoTomlVersion` /
// `setCargoLockVersion`)。早先这里另写了一条 `name…\nversion…` 的正则读旧值,
// 那与写入实现是**两份会各自漂移的解析**:2026-10-13 Windows CI 实证,
// CRLF 检出上那条正则返回 `undefined` → 本函数把它当成「没找到该 crate」记入
// `skipped` 并**跳过 Cargo.lock**,发版后该文件滞留旧版本 ——
// 正是本模块要消灭的那类分叉,而且**不报错**。
for (const rel of targets.cargoTomlFiles) {
const abs = join(root, rel);
const text = readFileSync(abs, 'utf8');
const before = /\[package\][\s\S]*?^version\s*=\s*"([^"]*)"/m.exec(text)?.[1];
const crateName = readCargoPackageName(text);
changed.push({ file: rel, before: before ?? '?', kind: 'cargo-toml' });
changed.push({ file: rel, before: readCargoTomlVersion(text) ?? '?', kind: 'cargo-toml' });
if (!dryRun) writeFileSync(abs, setCargoTomlVersion(text, version, rel));
if (!crateName) continue;
const lockRel = targets.cargoLockFiles.find((f) => f.startsWith(rel.slice(0, rel.length - 'Cargo.toml'.length)));
if (!lockRel) continue;
const lockAbs = join(root, lockRel);
const lockText = readFileSync(lockAbs, 'utf8');
const lockBefore = new RegExp(`name\\s*=\\s*"${crateName}"\\nversion\\s*=\\s*"([^"]*)"`).exec(lockText)?.[1];
if (lockBefore === undefined) {
const lockRes = setCargoLockVersion(readFileSync(lockAbs, 'utf8'), crateName, version);
if (lockRes.before === undefined) {
skipped.push(`${lockRel}(未找到 crate ${crateName},可能尚未构建)`);
continue;
}
changed.push({ file: lockRel, before: lockBefore, kind: 'cargo-lock' });
if (dryRun) continue;
const result = setCargoLockVersion(lockText, crateName, version);
if (result.changed) writeFileSync(lockAbs, result.text);
changed.push({ file: lockRel, before: lockRes.before, kind: 'cargo-lock' });
if (!dryRun && lockRes.changed) writeFileSync(lockAbs, lockRes.text);
}
return { changed, skipped };
+12 -1
View File
@@ -37,7 +37,7 @@ const getArg = (k, d) => {
};
const NODE_VERSION = getArg('--node', 'v22.20.0');
// 2026-10-11:Windows 也分架构。x64 包在 **ARM Windows(含 Parallels 的 Win11 ARM)** 上
// 2026-10-11:Windows 也分架构。x64 包在 **ARM Windows(含虚拟化平台的 Win11 ARM)** 上
// 能启动、能 listen,但 **TLS 握手不完成**(Prism 模拟下协议层异常)→ 必须按架构出包。
// 用法:node scripts/pack.mjs --arch arm64 → nodeagent-win-arm64.zip
const NODE_ARCH = getArg('--arch', 'x64');
@@ -122,6 +122,17 @@ async function main() {
copyFileSync(join(root, 'scripts', 'control.ps1'), join(PKG_DIR, 'control.ps1'));
copyFileSync(join(root, 'scripts', 'uninstall.ps1'), join(PKG_DIR, 'uninstall.ps1'));
// 看门狗(v2.1.0,决策 R-1):install.ps1 把计划任务的启动动作包一层它 ——
// 被控端「启动连续失败 3 次」时自动从 LKG 恢复入口文件。
// ⚠️ 必须保持 scripts/lib/ 的相对位置:startup-guard.mjs 以 './lib/startup-guard-core.mjs'
// 相对导入其核心纯函数(裸环境零依赖,见该文件头)。
mkdirSync(join(PKG_DIR, 'scripts', 'lib'), { recursive: true });
copyFileSync(join(root, 'scripts', 'startup-guard.mjs'), join(PKG_DIR, 'scripts', 'startup-guard.mjs'));
copyFileSync(
join(root, 'scripts', 'lib', 'startup-guard-core.mjs'),
join(PKG_DIR, 'scripts', 'lib', 'startup-guard-core.mjs'),
);
// 一键入口:.cmd 必须**纯 ASCII**。CMD 的代码页(中文 Windows = 936/GBK)
// 无法可靠往返 UTF-8,中文提示一律交给带 BOM 的 .ps1 去输出。
for (const cmd of ['install.cmd', 'control.cmd', 'run-portable.cmd']) {
+18 -4
View File
@@ -8,7 +8,7 @@
* node scripts/release.mjs 1.0.0 --dry-run # 只演练:构建+生成资产,不改 git、不发布
* node scripts/release.mjs 1.0.0 --no-publish # 提交打标签,但不发 GitHub Release
*
* 为什么要有它(对齐皇上的要求「每次更新,GitHub 上也要同步资产」):
* 为什么要有它(发版纪律:「每次更新,GitHub 上也要同步资产」):
* 手工发版要记住「改几处 package.json、打哪个包、算哪些哈希、传什么资产」——
* 必然漏项(尤其是给人用的 zip 与给自更新用的 agent.mjs 是**两个不同的东西**)。
* 本脚本把两者都产出,并把 sha256 写进 latest.json,供被控端 `system.agent.update` 直接使用。
@@ -178,7 +178,7 @@ mkdirSync(distDir, { recursive: true });
const assets = [
{ name: 'agent.mjs', path: agentPath, purpose: '自更新载荷(被控端 system.agent.update 直接使用)' },
{ name: 'nodeagent-win-x64.zip', path: zipX64, purpose: '人工安装包 · x64 Windows(自包含 node.exe)' },
{ name: 'nodeagent-win-arm64.zip', path: zipArm64, purpose: '人工安装包 · ARM Windows(Parallels Win11 ARM 等)' },
{ name: 'nodeagent-win-arm64.zip', path: zipArm64, purpose: '人工安装包 · ARM Windows(虚拟机里的 Win11 ARM 等)' },
];
for (const a of assets) {
copyFileSync(a.path, join(distDir, a.name));
@@ -267,7 +267,21 @@ if (unexpectedDirty.length > 0) {
}
sh('git', ['add', '-A', '--', '.', ':(exclude).github']);
sh('git', ['commit', '-m', `chore(release): v${version}`]);
// 版本号可能**早已在树中钉好**(v2.1.0 的流程:先 chore(release) 提交版本号,
// 之后再发版)。此时 add 之后暂存区为空,`git commit` 会以 "nothing to commit"
// 非零退出,把整个发版卡在最后一步 —— 且报错指向 git 而非发布逻辑,极易误判。
// 故:暂存区为空则跳过 commit(标签与 Release 照常创建)。
let hasStaged = false;
try {
sh('git', ['diff', '--cached', '--quiet']);
} catch {
hasStaged = true; // diff --quiet 有差异时退出码 1 → execFileSync 抛错
}
if (hasStaged) {
sh('git', ['commit', '-m', `chore(release): v${version}`]);
} else {
console.log('(版本号已是最新,无需额外提交;直接打标签)');
}
sh('git', ['tag', '-a', `v${version}`, '-m', `nodeagent v${version}`]);
netRetry('push main', () => sh('git', ['push', 'origin', 'main']));
netRetry('push tag', () => sh('git', ['push', 'origin', `v${version}`]));
@@ -302,7 +316,7 @@ writeFileSync(
`## 人工安装(首次)`,
`\`\`\`powershell`,
`Expand-Archive nodeagent-win-x64.zip C:\\nodeagent -Force`,
`# ARM Windows(如 Parallels 的 Win11 ARM)请改用 nodeagent-win-arm64.zip —— x64 包在其上 TLS 握手不通`,
`# ARM Windows(如虚拟机里的 Win11 ARM)请改用 nodeagent-win-arm64.zip —— x64 包在其上 TLS 握手不通`,
`# 先把 C:\\nodeagent 加入杀软信任区(主流杀软)`,
`powershell -ExecutionPolicy Bypass -File C:\\nodeagent\\install.ps1 -NodeId win -AllowInput`,
'```',
+503
View File
@@ -0,0 +1,503 @@
#!/usr/bin/env node
/**
* 启动看门狗(startup-guard.mjs)—— 被控端「启动连续失败 N 次」自动从 LKG 恢复。
*
* ## 为什么需要(决策 R-1,ARCH A-P0-6)
* `nodeagent rollback` 是 RPC 命令:被控端**完全起不来时没有监听可连**,CLI 无能为力。
* 本脚本补上这条链路:外层 supervisor(Windows 计划任务 / Linux systemd /
* macOS launchd)每次拉起被控端都先过本守卫;启动连续失败达到阈值时,
* 自动用 LKG 记录 + 备份指纹匹配恢复入口文件,再放行启动。
*
* ## 裸环境铁律
* **零项目依赖、不 import 项目内模块**(连 @nodeagent/* 都不碰)——
* 它要在被控端的裸环境里跑:只依赖 Node ≥22 内置模块 + 同目录的
* `lib/startup-guard-core.mjs`(纯函数,也无依赖)。分发时两个文件必须
* 保持相对位置一起拷贝(见 install-linux.sh / install.ps1 / pack.mjs)。
*
* ## 用法
* node scripts/startup-guard.mjs \
* --data-dir <数据目录> --lkg-dir <备份所在目录> --install-dir <安装目录> \
* [--entry agent.mjs] [--threshold 3] [--port 8765] \
* -- <被看守的完整启动命令...>
*
* ## 行为契约(与 supervisor 的退出码约定)
* - 退出 0:服务已由别的实例提供 / 干净停止 / 本守卫被要求停;
* - 退出非 0:启动失败(已计数)或运行期退出 —— supervisor 据此重启,
* 重启后本守卫重新读计数,达到阈值即执行 LKG 恢复。
*
* ## 安全边界(写进 DELIVERY-PIPELINE §6 的明示)
* - **无 LKG / 无指纹匹配的备份 → 记日志后放行,绝不擦除 install-dir**;
* - agent.mjs 自身损坏到 Node 都无法解析、或 Node 运行时缺失时,
* 本守卫同样无法运行 —— 那种场景只能带外恢复(附录 C)。
*/
import { createHash } from 'node:crypto';
import { spawn } from 'node:child_process';
import {
closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, readSync, renameSync, statSync,
unlinkSync, writeFileSync,
} from 'node:fs';
import { basename, join, resolve } from 'node:path';
import net from 'node:net';
import { fileURLToPath } from 'node:url';
import {
STARTUP_GRACE_MS,
backupNameRegex,
decideAfterExit,
decideRestore,
initialState,
planRestore,
readState,
rotationTargets,
shouldRotate,
zeroFailures,
} from './lib/startup-guard-core.mjs';
// ---------- 路径与状态 ----------
const LKG_FILE = 'agent-lkg.json';
const HASH_CHUNK = 512 * 1024;
/** 状态文件路径:<data-dir>/state/startup-state.json */
export function statePath(dataDir) {
return join(dataDir, 'state', 'startup-state.json');
}
/** 读失败计数;文件缺失/损坏按 0(不猜、不拒绝服务)。 */
export function readStateFile(dataDir) {
try {
const p = statePath(dataDir);
if (!existsSync(p)) return initialState();
return readState(readFileSync(p, 'utf8'));
} catch {
return initialState();
}
}
/** 写失败计数(含目录创建);失败返回 false(不抛——计数丢了下次重新数)。 */
export function writeState(dataDir, state) {
try {
mkdirSync(join(dataDir, 'state'), { recursive: true });
writeFileSync(statePath(dataDir), `${JSON.stringify(state, null, 2)}\n`, 'utf8');
return true;
} catch {
return false;
}
}
/**
* 追加一行 JSONL 日志:<data-dir>/logs/startup-guard.log(失败静默——日志不拦路)。
*
* 超过 LOG_MAX_BYTES 时滚存:`.log.1`(次新)… `.log.N`(最旧),`N = LOG_KEEP`。
* 轮换本身也**失败静默**:日志出问题绝不能反过来拦住被控端启动。
*/
export function appendGuardLog(dataDir, obj, opts = {}) {
try {
mkdirSync(join(dataDir, 'logs'), { recursive: true });
const logPath = join(dataDir, 'logs', 'startup-guard.log');
try {
const size = existsSync(logPath) ? statSync(logPath).size : 0;
if (shouldRotate(size, opts.maxBytes)) {
const targets = rotationTargets(logPath, opts.keep);
const oldest = targets[targets.length - 1];
if (existsSync(oldest)) unlinkSync(oldest);
for (let i = targets.length - 1; i > 0; i -= 1) {
if (existsSync(targets[i - 1])) renameSync(targets[i - 1], targets[i]);
}
renameSync(logPath, targets[0]);
}
} catch {
/* 轮换失败不影响写入 */
}
const line = `${JSON.stringify({ ts: new Date().toISOString(), ...obj })}\n`;
writeFileSync(logPath, line, { flag: 'a' });
} catch {
/* 日志失败不影响启动 */
}
}
// ---------- LKG 与备份 ----------
/** 读 LKG 记录(<data-dir>/agent-lkg.json);缺失/损坏返回 null。 */
export function readLkgRecord(dataDir) {
try {
const p = join(dataDir, LKG_FILE);
if (!existsSync(p)) return null;
const rec = JSON.parse(readFileSync(p, 'utf8'));
if (!rec || typeof rec.hash !== 'string' || !rec.hash) return null;
return rec;
} catch {
return null;
}
}
/** 流式计算文件 sha256(分块读,不吃内存)。 */
export function hashFileSync(p) {
const fd = openSync(p, 'r');
try {
const h = createHash('sha256');
const buf = Buffer.alloc(HASH_CHUNK);
for (;;) {
const n = readSync(fd, buf, 0, buf.length, null);
if (n === 0) break;
h.update(buf.subarray(0, n));
}
return h.digest('hex');
} finally {
closeSync(fd);
}
}
/**
* 枚举备份候选:<lkg-dir> 下 `<entry>.bak` / `<entry>.bak-<ts>`,按 mtime 新→早。
*
* 与 CLI rollback 的 backupNameRegex 同口径(tests 有跨端一致性守卫):
* deploy 固定名 + selfupdate 时间戳名两套命名都要认。
*/
export function listBackupCandidates(lkgDir, entryName) {
const re = backupNameRegex(entryName);
let items;
try {
items = readdirSync(lkgDir, { withFileTypes: true });
} catch {
return [];
}
const out = [];
for (const it of items) {
if (!it.isFile() || !re.test(it.name)) continue;
try {
const st = statSync(join(lkgDir, it.name));
out.push({ path: join(lkgDir, it.name), name: it.name, mtime: st.mtimeMs, size: st.size });
} catch {
/* 单个文件 stat 失败就跳过 */
}
}
out.sort((a, b) => b.mtime - a.mtime);
return out;
}
/**
* 从 LKG 恢复入口文件(达到阈值时调用)。
*
* 流程:读 LKG → 枚举备份 → 流式指纹匹配(hash_full 全量优先,老记录 12 位前缀回落)
* → 命中则**就地覆写** install-dir/<entry>(同 selfupdate 姿势:不 rename,
* Windows 上 rename 会被占用的文件/杀软挡下,真机教训)→ 写后校验。
*
* 安全边界:无 LKG / 无候选 / 无匹配 / 读失败 → 原样返回不恢复,
* **绝不清空或部分擦写 install-dir**。任何异常都吞掉返回 error ——
* 守卫自己坏掉时也要放行启动,不能把「恢复」变成「致人死亡」。
*
* @returns {{restored: boolean, backup: string|null, reason: string, entry?: string,
* hash?: string, candidates?: string[]}}
*/
export function restoreFromLkg({ dataDir, lkgDir, installDir, entry }) {
try {
const lkg = readLkgRecord(dataDir);
if (!lkg) return { restored: false, backup: null, reason: 'no-lkg' };
const want = lkg.hash_full ?? lkg.hash;
const entryName = entry || basename(lkg.script_path || '') || 'agent.mjs';
const cands = listBackupCandidates(lkgDir, entryName);
if (cands.length === 0) return { restored: false, backup: null, reason: 'no-candidates', entry: entryName };
const hashed = [];
for (const c of cands) {
try {
hashed.push({ path: c.path, hash: hashFileSync(c.path), mtime: c.mtime });
} catch {
/* 跳过读不了的候选 */
}
}
const picked = planRestore(hashed, want);
if (!picked) {
return {
restored: false,
backup: null,
reason: 'no-match',
entry: entryName,
candidates: hashed.map((h) => `${h.path} ${h.hash.slice(0, 12)}`),
};
}
const target = join(installDir, entryName);
const buf = readFileSync(picked.path);
writeFileSync(target, buf);
// 写后校验:目标内容必须等于备份内容(防写一半/被杀软改坏)
const after = hashFileSync(target);
if (after !== picked.hash) {
return { restored: false, backup: picked.path, reason: 'verify-failed', entry: entryName };
}
return { restored: true, backup: picked.path, hash: picked.hash, entry: entryName, bytes: buf.length };
} catch (err) {
return { restored: false, backup: null, reason: `error: ${err instanceof Error ? err.message : String(err)}` };
}
}
// ---------- 被看守进程的环境 ----------
/**
* 被看守进程(agent)的运行环境:**强制注入 NODEAGENT_HOME = data-dir**。
*
* 为什么强制:agent 的 `agentDir()`(数据目录、LKG 文件、归零状态文件所在地)
* 由环境变量 NODEAGENT_HOME 决定。而 Windows 计划任务的
* `New-ScheduledTaskAction` **无法携带环境变量**,若安装进程恰好持有
* **进程级** NODEAGENT_HOME,agent 运行时的 agentDir() 就会与守卫的
* --data-dir 漂移:守卫读不到 LKG/状态文件(安全退化:no-lkg 放行,
* 但看门狗白养)。由守卫把自己认定的 data-dir 显式传给被看守进程,
* 三端行为即与 supervisor 环境解耦且一致(Linux unit / macOS plist 的
* EnvironmentVariables 本就是同值,双保险,幂等无害)。
*/
export function spawnEnv(dataDir, baseEnv = process.env) {
return { ...baseEnv, NODEAGENT_HOME: dataDir };
}
// ---------- 端口探活 ----------
/**
* 解析被控端端口:--port 参数优先,否则读 <data-dir>/agent.json 的 port 字段。
* 都没有(如 Hub 模式不监听本地端口)→ null,此时跳过所有端口判断。
*/
export function resolvePort(dataDir, portArg) {
if (Number.isInteger(portArg) && portArg > 0 && portArg < 65536) return portArg;
try {
const p = join(dataDir, 'agent.json');
if (existsSync(p)) {
const cfg = JSON.parse(readFileSync(p, 'utf8'));
const n = Number(cfg.port);
if (Number.isInteger(n) && n > 0 && n < 65536) return n;
}
} catch {
/* 忽略:端口未知就不做端口判断 */
}
return null;
}
/**
* 端口是否已被服务(TCP 能连上即算,不做协议握手 —— 只要「有人在听」)。
*
* 用途有两个,都指向同一目的「别添乱」:
* ① 启动前:服务已由别的实例提供 → 本守卫直接退出 0,不再 spawn(避免撞车);
* ② 子进程死后:服务已由别的实例接管 → 不算启动失败(撞车输的那次不算)。
*/
export function serviceUp(port, host = '127.0.0.1', timeoutMs = 500) {
if (!port) return Promise.resolve(false);
return new Promise((done) => {
const s = net.connect({ host, port });
const finish = (v) => {
s.destroy();
done(v);
};
s.setTimeout(timeoutMs, () => finish(false));
s.once('connect', () => finish(true));
s.once('error', () => finish(false));
});
}
// ---------- 参数解析 ----------
/**
* 解析守卫自己的参数(纯函数,可单测)。
* `--` 之后的所有 token 原样作为「被看守的完整启动命令」。
*/
export function parseGuardArgs(argv) {
const sep = argv.indexOf('--');
const own = sep >= 0 ? argv.slice(0, sep) : argv.slice();
const out = {
dataDir: '',
lkgDir: '',
installDir: '',
entry: '',
threshold: 3,
port: null,
cmd: sep >= 0 ? argv.slice(sep + 1) : [],
help: false,
};
for (let i = 0; i < own.length; i += 1) {
const a = own[i];
const take = () => {
const v = own[i + 1];
if (v === undefined || v.startsWith('--')) throw new Error(`参数 ${a} 缺少取值`);
i += 1;
return v;
};
switch (a) {
case '--data-dir': out.dataDir = take(); break;
case '--lkg-dir': out.lkgDir = take(); break;
case '--install-dir': out.installDir = take(); break;
case '--entry': out.entry = take(); break;
case '--threshold': out.threshold = Number(take()); break;
case '--port': out.port = Number(take()); break;
case '-h': case '--help': out.help = true; break;
default: throw new Error(`未知参数: ${a}`);
}
}
return out;
}
const USAGE = '用法: node startup-guard.mjs --data-dir <目录> --install-dir <目录> [--lkg-dir <目录>] ' +
'[--entry agent.mjs] [--threshold 3] [--port 8765] -- <启动命令...>';
// ---------- 主流程 ----------
/**
* 看守一个子进程直到它退出,并按 decideAfterExit 的决策处理计数与退出码。
* (spawn / 信号转发都在这里;IO 之外的判断全部在 core 纯函数里。)
*/
async function supervise(opts, port) {
const [cmdName, ...cmdArgs] = opts.cmd;
// NODEAGENT_HOME 强制为 data-dir:agentDir()(LKG/状态文件所在地)必须与
// 守卫读的 --data-dir 是同一个目录,否则恢复链路静默失效(见 spawnEnv 注释)
const child = spawn(cmdName, cmdArgs, {
stdio: 'inherit', cwd: opts.installDir, env: spawnEnv(opts.dataDir),
});
let stopping = false;
const forwardSignal = (sig) => {
stopping = true;
if (child.exitCode === null && child.signalCode === null) {
try {
child.kill(sig);
} catch {
/* 已退出 */
}
// 兜底:子进程不理信号就强杀(守卫不能陪着赖着不走)
setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) {
try {
child.kill('SIGKILL');
} catch {
/* 已退出 */
}
}
}, 10_000).unref();
}
};
process.on('SIGTERM', () => forwardSignal('SIGTERM'));
process.on('SIGINT', () => forwardSignal('SIGINT'));
const startedAt = Date.now();
const exit = await new Promise((done) => {
child.on('exit', (code, signal) => done({ code, signal }));
child.on('error', (err) => {
// spawn 本身失败(命令不存在等)= 启动期失败,按 code=1 计数
appendGuardLog(opts.dataDir, { action: 'spawn-error', error: err.message });
done({ code: 1, signal: null });
});
});
const livedMs = Date.now() - startedAt;
const up = await serviceUp(port);
const decision = decideAfterExit({
code: exit.code,
signal: exit.signal,
livedMs,
stopping,
serviceUp: up,
state: readStateFile(opts.dataDir),
nowIso: new Date().toISOString(),
});
appendGuardLog(opts.dataDir, {
action: 'child-exit',
code: exit.code,
signal: exit.signal,
lived_ms: livedMs,
counted: decision.counted,
persist: decision.persist,
reason: decision.reason,
service_up: up,
failures: decision.state.failures,
grace_ms: STARTUP_GRACE_MS,
});
if (decision.persist) writeState(opts.dataDir, decision.state);
console.error(
`[startup-guard] 子进程退出(code=${exit.code ?? 'null'} signal=${exit.signal ?? 'null'},` +
`存活 ${Math.round(livedMs / 1000)}s,${decision.reason},失败计数 ${decision.state.failures})`,
);
return decision.exitCode;
}
async function main() {
let opts;
try {
opts = parseGuardArgs(process.argv.slice(2));
} catch (err) {
console.error(`[startup-guard] ${err instanceof Error ? err.message : String(err)}\n${USAGE}`);
return 2;
}
if (opts.help) {
console.log(USAGE);
return 0;
}
if (!opts.dataDir || !opts.installDir || opts.cmd.length === 0) {
console.error(`[startup-guard] 缺少必要参数(--data-dir / --install-dir / -- 启动命令)\n${USAGE}`);
return 2;
}
if (!Number.isInteger(opts.threshold) || opts.threshold < 1) {
console.error(`[startup-guard] --threshold 需为正整数,收到: ${opts.threshold}`);
return 2;
}
const lkgDir = opts.lkgDir || opts.installDir;
const port = resolvePort(opts.dataDir, opts.port);
const dataDir = opts.dataDir;
// ① 服务已由别的实例提供(撞车/接管场景)→ 使命达成,不再 spawn
if (await serviceUp(port)) {
appendGuardLog(dataDir, { action: 'already-up', port, reason: 'service-up' });
console.error(`[startup-guard] 端口 ${port} 已被服务,本实例不重复启动`);
return 0;
}
// ② 读失败计数;达到阈值 → 尝试从 LKG 恢复(无 LKG/无匹配 → 放行,不动 install-dir)
let state = readStateFile(dataDir);
if (decideRestore(state, opts.threshold)) {
const r = restoreFromLkg({ dataDir, lkgDir, installDir: opts.installDir, entry: opts.entry });
appendGuardLog(dataDir, {
action: 'restore',
failures: state.failures,
threshold: opts.threshold,
restored: r.restored,
backup: r.backup,
entry: r.entry,
reason: r.reason,
candidates: r.candidates,
});
if (r.restored) {
state = zeroFailures(new Date().toISOString());
writeState(dataDir, state);
console.error(`[startup-guard] 已从 LKG 恢复入口: ${r.backup} → ${join(opts.installDir, r.entry)}(失败计数归零)`);
} else {
console.error(`[startup-guard] 未执行恢复(${r.reason})—— 按原状尝试启动`);
}
}
// ③ 看守启动命令(退出码契约见文件头)
console.error(`[startup-guard] 看守中: ${opts.cmd.join(' ')}(阈值 ${opts.threshold},当前失败 ${state.failures} 次)`);
appendGuardLog(dataDir, {
action: 'spawn',
cmd: opts.cmd,
failures: state.failures,
threshold: opts.threshold,
port,
});
return supervise(opts, port);
}
// ---------- 入口 ----------
const isMain = (() => {
try {
const self = resolve(fileURLToPath(import.meta.url));
const argv1 = process.argv[1] ? resolve(process.argv[1]) : '';
return !!argv1 && self === argv1;
} catch {
return false;
}
})();
if (isMain) {
main()
.then((code) => process.exit(code))
.catch((err) => {
// 守卫自身崩溃:放行一次(不让守卫成为启动瓶颈),但留下证据
console.error(`[startup-guard] 守卫自身异常(已放行): ${err instanceof Error ? err.message : String(err)}`);
process.exit(0);
});
}
+18 -3
View File
@@ -19,9 +19,24 @@ $isAdmin = ([Security.Principal.WindowsPrincipal] `
).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not $isAdmin) { Write-Host "[x] Please run as Administrator."; exit 1 }
Write-Host "[*] Stopping and removing scheduled task..."
Stop-ScheduledTask -TaskName "nodeagent"
Unregister-ScheduledTask -TaskName "nodeagent" -Confirm:$false
Write-Host "[*] Stopping and removing scheduled tasks (whitelist only)..."
# B-P1-6 / T05:白名单清理。历史版本在被控端上注册过 na-gui 等旧名任务,
# 漏删会留下「端口被占、新版本起不来」的幽灵任务。
# 铁律:只删白名单内**且实际存在**的任务,禁通配删除 —— 误删用户自己的
# 任务是比漏删严重得多的生产事故。-ccontains 为大小写敏感包含,与
# scripts/lib/scheduled-tasks.mjs 的 TASK_WHITELIST 精确匹配同口径
# (tests/unit/na-gui-cleanup.test.mjs 有跨端一致性守卫,白名单不许两边漂移)。
$taskWhitelist = @("nodeagent", "na-gui", "nodeagent-console")
$existingTasks = Get-ScheduledTask | Select-Object -ExpandProperty TaskName
foreach ($name in $taskWhitelist) {
if ($existingTasks -ccontains $name) {
Stop-ScheduledTask -TaskName $name
Unregister-ScheduledTask -TaskName $name -Confirm:$false
Write-Host " removed task: $name"
} else {
Write-Host " task not present: $name"
}
}
Write-Host "[*] Removing firewall rule..."
Get-NetFirewallRule -DisplayName "nodeagent (TCP $Port)" | Remove-NetFirewallRule
+6 -1
View File
@@ -14,8 +14,13 @@
// 用相对路径:从仓库根跑时解析不到 workspace 包(根 package.json 未声明它)
import { NodeAgentClient } from '../packages/client/dist/index.js';
const URL = process.env.NA_URL ?? 'wss://10.211.55.9:8765' // ⚠️ 必须 wss:被控端 tls=true(写成 ws 会 socket hang up);
// ⚠️ 必须 wss:被控端 tls=true(写成 ws 会 socket hang up)
const URL = process.env.NA_URL;
const KEY = process.env.NA_KEY;
if (!URL) {
console.error('需要 NA_URL 环境变量,例如 wss://<被控端地址>:8765');
process.exit(1);
}
if (!KEY) {
console.error('需要 NA_KEY 环境变量');
process.exit(1);
+3
View File
@@ -268,6 +268,8 @@ async function main() {
const c = await connect();
const r = await c.invoke('screen.info');
c.close();
// 平台未实现(如 Linux 无 GUI 后端)→ 跳过,不算失败
if (r.status !== 'ok' && r.error?.name === 'E_UNSUPPORTED_PLATFORM') return 'skip';
assertOk(r, 'screen.info');
if (!Array.isArray(r.data.displays) || r.data.displays.length === 0) throw new Error('未返回显示器');
const d = r.data.displays[0];
@@ -290,6 +292,7 @@ async function main() {
c.close();
if (r.status === 'ok') return '已开启输入控制';
if (r.error?.name === 'E_CAPABILITY_DISABLED') return '默认禁用(安全)';
if (r.error?.name === 'E_UNSUPPORTED_PLATFORM') return 'skip'; // 该平台无输入实现
throw new Error(`异常响应: ${JSON.stringify(r.error)}`);
});
+68 -5
View File
@@ -6,7 +6,7 @@
*
* 运行:node tests/e2e/run.mjs
*/
import { spawn } from 'node:child_process';
import { spawn as rawSpawn } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { createRequire } from 'node:module';
@@ -25,6 +25,34 @@ const RawWebSocket = requireFromClient('ws');
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, '../..');
/**
* 子进程登记表 + 兜底清理。
*
* 为什么需要:本套件有 8 处 spawn(主 agent / TLS / ed25519 / hub / fs / monitor…),
* 各自的 finally 只做 `child.kill()`(SIGTERM),随后立刻 process.exit —— 被控端
* 可能还没来得及退出就被父进程带走,**下一轮连跑时固定端口仍被占用**,表现为
* 「连跑第二次偶发失败」(历史真机现象,重跑又绿,最难查)。
*
* 做法:包一层 spawn,把所有子进程登记下来;进程退出时统一 SIGKILL。
* 调用点无需改动(同名函数遮蔽),单点收口也不会漏掉将来新增的 spawn。
*/
const liveChildren = new Set();
function spawn(...args) {
const child = rawSpawn(...args);
liveChildren.add(child);
child.once('exit', () => liveChildren.delete(child));
return child;
}
process.on('exit', () => {
for (const child of liveChildren) {
try {
child.kill('SIGKILL');
} catch {
/* 已退出或不可杀:忽略 */
}
}
});
const PORT = 18765;
const TLS_PORT = 18772;
const ED25519_PORT = 18781;
@@ -335,11 +363,13 @@ async function testEd25519Acl() {
deny: [],
},
{
// 用于验证限速:每分钟仅 2 次
// 用于验证限速:每分钟仅 2 次。
// A-P0-2:两个能力配**相同**限速值 2 —— 旧实现把限速值当桶名
// (key=`limited|2`)会共桶互挤;修正后各能力独占一桶。
client_id: 'limited',
pubkey: kp.publicKey,
allow: ['system.info'],
max_calls_per_min: 2,
allow: ['system.info', 'system.audit.list'],
rate_limits: { 'system.info': 2, 'system.audit.list': 2 },
},
],
},
@@ -407,7 +437,7 @@ async function testEd25519Acl() {
assert.ok(withPreview, '开启 log_args 后应有参数预览');
c.close();
// 4. 限速:limited 配额 2/分钟,第 3 次应被拒
// 4. 限速:limited 每个能力 2/分钟,第 3 次应被拒
const lim = mk('limited', kp.privateKey);
await lim.connect();
const r1 = await lim.invoke('system.info');
@@ -421,6 +451,19 @@ async function testEd25519Acl() {
return true;
},
);
// A-P0-2 关键断言:system.info 已打满 2/分钟,但同限速值的 system.audit.list
// 必须仍有完整配额(旧实现共桶 → 此处第 1 次即被 -32407 误伤)。
const a1 = await lim.invoke('system.audit.list', { limit: 5 });
assert.equal(a1.status, 'ok', '同限速值的另一能力不得被挤占(A-P0-2)');
const a2 = await lim.invoke('system.audit.list', { limit: 5 });
assert.equal(a2.status, 'ok');
await assert.rejects(
() => lim.invoke('system.audit.list', { limit: 5 }),
(e) => {
assert.equal(e.code, -32407, `期望 -32407(限速),实际 ${e.code}`);
return true;
},
);
lim.close();
// 5. 未注册的调用方 → 握手失败
@@ -1796,6 +1839,26 @@ async function main() {
// ---------- 契约形状(agent 实现 ↔ 协议契约)----------
await test('契约形状:只读能力返回字段与 manifest returns_schema 一致', testContractShapes);
// ---------- v2.1.0 L2 平台门控(T04)----------
// 当前 e2e 跑在 macOS/Linux:system.audit.l2 必须明确报不支持,
// 而不是静默走到别的分支、或报成误导性错误(T04 起门控走统一 assertWindows)。
await test('v2.1.0 L2 平台门控:非 Windows 明确报不支持 / Windows 上 status 可用', async () => {
const c = await connect();
try {
if (process.platform === 'win32') {
const r = await c.invoke('system.audit.l2', { action: 'status' });
assert.equal(r.status, 'ok', 'Windows 上 L2 status 应可用');
} else {
// 能力执行失败走 result.status=failed(协议层才 reject);按 name 断言
const r = await c.invoke('system.audit.l2', { action: 'status' });
assert.equal(r.status, 'failed', '非 Windows 上必须明确失败,不得静默走别的分支');
assert.equal(r.error?.name, 'E_UNSUPPORTED_PLATFORM', `错误名应为平台不支持,实际 ${r.error?.name}`);
}
} finally {
c.close();
}
});
// ---------- v2.0.3 硬化回归(2026-10-13 实测缺陷)----------
await test('v2.0.3 畸形报文不得打死被控端(B-01/B-02 + 崩溃链 + U-01 审计)', () =>
testMalformedFrames(agent),
+52
View File
@@ -223,6 +223,58 @@ async function main() {
assert.equal(r.status, 'failed');
assert.equal(r.error.name, 'E_CAPABILITY_DISABLED');
});
// ---------- v2.1.0 L2 系统级审计(T05,Windows 真机)----------
// 字段与 packages/protocol/src/capabilities/manifest/system.ts 的
// system.audit.l2 returns_schema(四种 action 的属性并集)逐字对齐:
// status → platform/supported/auditpol/legacy_policy_disabled/
// cmdline_logging/sysmon_installed/file/file_exists(/note)
// collect → collected/written/since/limit/file(/note)
// verify → ok/checked(/broken_at)
// 信任边界(不夸大):本地 audit-l2.log 只是归一化副本,防篡改依赖转发出机。
await test('v2.1.0 L2 status:Windows 审计策略状态(returns_schema 全字段)', async () => {
const c = await connect();
const r = await c.invoke('system.audit.l2', { action: 'status' }, 90_000);
c.close();
assert.equal(r.status, 'ok', `L2 status 失败: ${JSON.stringify(r.error)}`);
assert.equal(r.data.platform, 'windows', 'platform 必须回 windows');
assert.equal(r.data.supported, true, 'Windows 上必须 supported=true');
assert.equal(typeof r.data.auditpol, 'string', 'auditpol 原始输出应为字符串');
assert.equal(typeof r.data.legacy_policy_disabled, 'boolean', 'SCENoApplyLegacyAuditPolicy 判定应为布尔');
assert.equal(typeof r.data.cmdline_logging, 'boolean', '命令行记录开关应为布尔');
assert.equal(typeof r.data.sysmon_installed, 'boolean', 'Sysmon 检测结果应为布尔');
assert.equal(typeof r.data.file, 'string', 'L2 副本路径应为字符串');
assert.equal(typeof r.data.file_exists, 'boolean', 'file_exists 应为布尔');
});
await test('v2.1.0 L2 collect:拉取 Security 事件归一化落盘(since/limit 回显)', async () => {
const c = await connect();
const since = Date.now() - 60 * 60 * 1000; // 近 1 小时:缩小扫描窗口,CI 上更快
const r = await c.invoke('system.audit.l2', { action: 'collect', since, limit: 100 }, 120_000);
c.close();
// 读 Security 日志需要管理员;非提权环境明确跳过(与 winget 缺失同一容错哲学,
// 不把「环境没条件」伪装成「功能坏了」)
if (r.status === 'failed' && r.error?.name === 'E_EXECUTION_FAILED') return 'skip';
assert.equal(r.status, 'ok', `L2 collect 失败: ${JSON.stringify(r.error)}`);
assert.equal(r.data.since, since, 'since 必须原样回显(默认 24h 的参数契约)');
assert.equal(r.data.limit, 100, 'limit 必须原样回显');
assert.ok(Number.isInteger(r.data.collected) && r.data.collected >= 0, 'collected 应为非负整数');
assert.ok(
Number.isInteger(r.data.written) && r.data.written >= 0 && r.data.written <= r.data.collected,
'written 应为非负整数且不超过 collected',
);
assert.equal(typeof r.data.file, 'string', '落盘路径应为字符串');
assert.equal(typeof r.data.note, 'string', '信任边界提示必须随 collect 返回(不得假装本地副本不可篡改)');
});
await test('v2.1.0 L2 verify:归一化链完整性校验(空链亦为 ok)', async () => {
const c = await connect();
const r = await c.invoke('system.audit.l2', { action: 'verify' }, 60_000);
c.close();
assert.equal(r.status, 'ok', `L2 verify 失败: ${JSON.stringify(r.error)}`);
assert.equal(r.data.ok, true, `L2 链应完整${r.data.ok ? '' : `: ${JSON.stringify(r.data.broken_at)}`}`);
assert.ok(Number.isInteger(r.data.checked) && r.data.checked >= 0, 'checked 应为非负整数');
});
} finally {
agent.kill();
await new Promise((r) => setTimeout(r, 300));
+12 -12
View File
@@ -12,13 +12,13 @@ import {
// ---------- B3:对端地址规范化(真机踩坑回归) ----------
test('normalizeIp:IPv4-mapped IPv6 归一为 IPv4(真机坑)', () => {
assert.equal(normalizeIp('::ffff:192.168.1.85'), '192.168.1.85');
assert.equal(normalizeIp('::FFFF:192.168.1.85'), '192.168.1.85');
assert.equal(normalizeIp('::ffff:192.168.1.10'), '192.168.1.10');
assert.equal(normalizeIp('::FFFF:192.168.1.10'), '192.168.1.10');
});
test('normalizeIp:IPv4:port 去端口;纯 IP 原样', () => {
assert.equal(normalizeIp('192.168.1.85:51234'), '192.168.1.85');
assert.equal(normalizeIp('192.168.1.85'), '192.168.1.85');
assert.equal(normalizeIp('192.168.1.10:51234'), '192.168.1.10');
assert.equal(normalizeIp('192.168.1.10'), '192.168.1.10');
});
test('normalizeIp:真 IPv6 原样保留(不误当 IPv4)', () => {
@@ -29,20 +29,20 @@ test('normalizeIp:真 IPv6 原样保留(不误当 IPv4)', () => {
});
test('normalizeIp + deny_cidr:mapped 地址也能命中黑名单(端到端复现)', () => {
const ip = normalizeIp('::ffff:192.168.1.85');
const client = { client_id: 'mac_01', allow: [], deny_cidr: ['192.168.1.85'] };
const ip = normalizeIp('::ffff:192.168.1.10');
const client = { client_id: 'mac_01', allow: [], deny_cidr: ['192.168.1.10'] };
assert.equal(ipAllowed(client, ip).allowed, false);
});
// ---------- B3:CIDR ----------
test('CIDR:单 IP 精确匹配', () => {
assert.equal(ipMatchesCidr('192.168.1.85', '192.168.1.85'), true);
assert.equal(ipMatchesCidr('192.168.1.86', '192.168.1.85'), false);
assert.equal(ipMatchesCidr('192.168.1.10', '192.168.1.10'), true);
assert.equal(ipMatchesCidr('192.168.1.11', '192.168.1.10'), false);
});
test('CIDR:/24 网段匹配', () => {
assert.equal(ipMatchesCidr('192.168.1.85', '192.168.1.0/24'), true);
assert.equal(ipMatchesCidr('192.168.1.10', '192.168.1.0/24'), true);
assert.equal(ipMatchesCidr('192.168.1.255', '192.168.1.0/24'), true);
// ⚠️ 这条测的是「**跨网段不匹配**」—— 必须用不同网段的地址。
// (2026-10-10 一次机械 IP 替换曾把它改成同段,导致断言必红 —— 语义测试别被批量替换破坏)
@@ -91,9 +91,9 @@ test('IP 白名单:deny 优先、白名单为空则放行', () => {
ipAllowed({ client_id: 'a', allow: [], allow_cidr: ['192.168.1.0/24'] }, '10.0.0.1').allowed,
false,
);
const both = { client_id: 'a', allow: [], allow_cidr: ['192.168.1.0/24'], deny_cidr: ['192.168.1.85'] };
assert.equal(ipAllowed(both, '192.168.1.85').allowed, false, '黑名单优先');
assert.equal(ipAllowed(both, '192.168.1.86').allowed, true);
const both = { client_id: 'a', allow: [], allow_cidr: ['192.168.1.0/24'], deny_cidr: ['192.168.1.10'] };
assert.equal(ipAllowed(both, '192.168.1.10').allowed, false, '黑名单优先');
assert.equal(ipAllowed(both, '192.168.1.11').allowed, true);
});
// ---------- B3:生效时段 ----------
Loaded 100 of 122 files, more files were not shown because too many files have changed in this diff. Show more