Commit Graph
141 Commits
Author SHA1 Message Date
jkooe d8f21f4e68 feat(pack): 支持 --arch(x64 / arm64)—— ARM Windows 上 x64 包不可用(真机实证)
## 背景(Parallels Win11 ARM 真机验证的结论)
x64 包(内含 x64 Node)在 ARM Windows 上:**能启动、能 listen 8765,但 TLS 握手 60s 不完成**
(Prism 模拟下协议层异常)→ 协议层完全不可用,且现象具有误导性(端口是通的)。
换同版本 ARM64 Node 后**完全正常**(握手成功、连接、51 项能力)。

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

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

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

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

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

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

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

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

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

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

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

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

## 本次改动

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## 验证
工作区残留「主流杀软/杀软/Ethernet」= **0 处**;单测 273 全绿;7 包构建通过。
2026-10-10 12:26:41 +08:00
jkooe d9e2103be7 docs(roadmap): 同步 v2.0.0 已发布状态
- 头部基线:v2.0.0(2026-10-10 / a515eb2),单测 273,能力 51,CI 双工作流全绿
- §九 台账补「发版 v2.0.0」行 + 「v1.5 四项与 v26 清理策略真机验证仍待补」的显式标记
2026-10-10 12:18:28 +08:00
jkooe a515eb2e63 chore(release): v2.0.0 v2.0.0 2026-10-10 12:14:02 +08:00
jkooe 3fcfc7d12d chore: CHANGELOG 定版 v2.0.0 + 忽略 .workbuddy/(本地 agent 数据)
- CHANGELOG:「未发布」→ v2.0.0(2026-10-10),补兼容性声明(完全向后兼容)
  与**已知限制**(发布时被控端离线 → v1.5 的四项与 v26 清理策略尚未真机验证)
- .gitignore:忽略 .workbuddy/(memory/automations 属本机状态,不应入库;
  此前未被忽略,会干扰 release.mjs 的干净工作区检查)
2026-10-10 12:12:47 +08:00
jkooe 865862ee7c refactor: 拆 mcp/index.ts(1139 → 530 行),工具清单独立成 tools/defs.ts
## 拆法
`TOOLS` 数组(610 行,占原文件过半)抽到 `apps/mcp/src/tools/defs.ts` 并 export。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

顺带:本文件的 CRLF 曾被工具写坏,已恢复并复核(ASCII 0 非 ASCII、107 CRLF、无裸 LF);
编码守卫单测 22 项通过,全量 195 单测 / 29 e2e 绿。
2026-10-07 02:23:20 +08:00
jkooe 9ee75cf694 ci: 清理步骤加 continue-on-error(前置失败不应连带掩盖真实结论) 2026-10-07 02:15:05 +08:00