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。
This commit is contained in:
jkooe committed 2026-10-13 22:35:40 +08:00
1 parent 00992e5c1c
commit b9236dfaf1
25 files changed
+302 -163

No files matched your search

+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
+109 -8
View File
@@ -50,7 +50,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 用例)
@@ -206,13 +206,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 +237,7 @@
| 断言 | 结果 |
|---|---|
| `screen.info` | 报 **3600×2020**,与裸 `GetSystemMetrics`(感知后)一致 ✅ |
| `screen.info` | 报 **2560×1440**,与裸 `GetSystemMetrics`(感知后)一致 ✅ |
| `input.mouse.move(x,y)` → 读回光标(物理) | 四次抽样**精确等于** (x,y) ✅ |
| `screen.find` 物理矩形 → `input.mouse.click` **直接点击** | 移动后光标 = 物理中心;主题单选框 `selected` 翻转且**可逆** ✅ |
@@ -535,11 +535,112 @@ sidecar 这条路径一直没被覆盖。
> **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** 才失败。
**守卫**(`tests/unit/sidecar-proxy.test.mjs`,15 用例)+ 注入验证:
| # | 守什么 | 注入故障实测 |
|---|---|---|
| 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(`(防止修复被无声改回去) | 同上 |
**真机复验**(重打 sidecar 产物 248KB,用**打包后的 `dist/sidecar.mjs`** 直跑):
| 组 | 配置 | 结果 |
|---|---|---|
| C | 无代理 | **成功 2169ms** → 最新版本 `v2.0.3`、已是最新;日志正确报出「系统代理使用 PAC 自动配置脚本…已按无代理处理」(macOS `scutil` 探测真实生效) |
| A | `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 | `NA_PROXY=http://127.0.0.1:<代理端口>` | 代理路径被正确选中(日志 `经代理 …(来源 NA_PROXY)`);该次宿主代理线路拥塞未在预算内完成 —— **线路本身的问题**,错误信息如实说明「代理已生效但仍失败」 |
> **未覆盖(如实记档)**:**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。
@@ -600,7 +701,7 @@ sidecar 这条路径一直没被覆盖。
## [v2.0.1] —— 2026-10-11
> 一轮**真机验证**(Parallels 27 + Win11 ARM)带来的修复批次:在补齐 6 项待验证项的过程中,
> 一轮**真机验证**(虚拟化平台 + Win11 ARM)带来的修复批次:在补齐 6 项待验证项的过程中,
> **抓到并修掉 5 个真问题** —— 全部只有真机能发现(macOS 上 100% 测不出)。
### 修复
@@ -623,7 +724,7 @@ sidecar 这条路径一直没被覆盖。
(**只增不减**,旧控制端忽略即可)。
- **`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 条排查项)。
### 真机验证状态(本版实测)
@@ -648,7 +749,7 @@ sidecar 这条路径一直没被覆盖。
> **验证状态**:本版发布时被控端整机离线,`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 检测等)。
+3 -3
View File
@@ -195,7 +195,7 @@ pnpm test:rust # Rust 控制端客户端(需 cargo)
1. 在控制端构建安装包(**需在项目目录执行**):
```bash
cd <项目目录> # 例如 ~/WorkBuddy/NodeAgent/nodeagent
cd <项目目录> # 例如 ~/projects/nodeagent
pnpm pack:win
# 或从任意目录调用:
@@ -678,8 +678,8 @@ nodeagent/
- [`docs/SECURITY.md`](docs/SECURITY.md) —— **安全模型与加固指南**(钥匙=root、证书指纹钉住、网段白名单、应急处置)
- [`docs/VERSIONING.md`](docs/VERSIONING.md) —— **版本与兼容性契约**(升级顺序、什么算破坏性、发版流程、更新方式)
- [`docs/REMOTE-LIMITS.md`](docs/REMOTE-LIMITS.md) —— 远程能力边界与真实故障案例(含杀软误拦)
- [`docs/DEPLOY-LINUX.md`](docs/DEPLOY-LINUX.md) —— **Linux 被控端部署**(Debian/fnOS/Ubuntu;含实测能力清单与排查)
- [`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 基线:待办收尾 / 语义可断言 / 状态采样 / 安全残余)
+1 -1
View File
@@ -228,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,
+1 -1
View File
@@ -39,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',
+1 -1
View File
@@ -537,7 +537,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)的宿主**」——
+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`(同一坐标一次点中)
*
* ⚠️ 不要删。这不是「锦上添花」的兼容代码,而是坐标口径统一的**前提**。
+3 -3
View File
@@ -1,4 +1,4 @@
# Linux 被控端部署(Debian 12 / fnOS / Ubuntu 等 systemd 系统)
# Linux 被控端部署(Debian 12 / Ubuntu / NAS 系统等 systemd 系统)
> 适用:把一台 Linux 机器作为**被控端**,让 Mac/Linux 控制端接管。
> 本文的能力清单是 **2026-10-13 在 Debian 12 容器里实测**得出的,不是推测。
@@ -13,7 +13,7 @@ Linux 端未实现 —— 调用会明确返回 `E_UNSUPPORTED_PLATFORM`,不
---
## 1. 快速开始(以绿联 DXP2800 / fnOS 为例)
## 1. 快速开始(以 NAS 设备为例)
```bash
# ① 把仓库放到 NAS 上(任选其一)
@@ -25,7 +25,7 @@ node scripts/pack.mjs # → release/agent.mjs
# ③ 在 NAS 上执行安装(需要 sudo;会自动注册 systemd 服务并开机自启)
cd nodeagent
sudo bash scripts/install-linux.sh --node-id dxp2800
sudo bash scripts/install-linux.sh --node-id nas-01
# ④ 抄下屏幕上打印的「密钥」与「连接命令」,回控制端执行:
# nodeagent connect <NAS的IP> --port 8765 --key <密钥> --insecure
+3 -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 演进路径
+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 }`,由被控端侧做高效过滤
---
+12 -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)
+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` 再调用。
> 仅供技术参考,不构成任何投资建议。
+40 -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,35 @@ 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`(15 用例)= 纯函数解析(夹具取自真机)+ 离线真发请求
(直连/绝对形态/CONNECT/**隧道+TLS 阶段 abort 必须立即生效**)+ 真 spawn sidecar 走 JSON-RPC 的端到端
+ 「sidecar 源码不得再出现裸 `fetch(`」形态守卫。全部做过注入故障实测。
## Windows 端控制台(2026-10-11)
### 起因
@@ -802,7 +830,7 @@ msiexec /a 失败(退出码 ):nodeagent-console_2.0.2_x64_en-US.msi
## ARM64 真机安装冒烟与界面实点(2026-10-12)
上一节那张表里的条目,本轮在 **Parallels 27 + Win11 ARM64** 上逐条做完;
上一节那张表里的条目,本轮在 **虚拟化平台 + Win11 ARM64** 上逐条做完;
过程中还挖出并修掉一个**只在真机上才暴露**的缺陷(见本节末「真机暴露的缺陷」)。
### 怎么把包送进去
@@ -831,7 +859,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 +880,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 +891,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。
> 仅供技术参考,不构成任何投资建议。
+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);
+3 -3
View File
@@ -1,11 +1,11 @@
#!/usr/bin/env bash
#
# nodeagent 被控端 —— Linux 安装器(Debian 12 / Ubuntu / fnOS 等 systemd 系统)
# 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 dxp2800 --port 8765
# 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 # 卸载并删配置
@@ -152,7 +152,7 @@ else
b) 手工启动:NODEAGENT_HOME=$DATA_DIR $INSTALL_DIR/runtime/bin/node $INSTALL_DIR/agent.mjs"
fi
# 发行版信息(fnOS 基于 Debian 12,此处只作展示)
# 发行版信息(部分 NAS 系统基于 Debian 12,此处只作展示)
if [ -r /etc/os-release ]; then
# shellcheck disable=SC1091
. /etc/os-release
+1 -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');
+3 -3
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));
@@ -302,7 +302,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`,
'```',
+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);
+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:生效时段 ----------
+2 -2
View File
@@ -12,10 +12,10 @@ import { PS_DPI_AWARE } from '../../apps/agent/dist/util/dpi.js';
/**
* 回归守卫:**屏幕坐标口径必须统一为物理像素**。
*
* ## 背景(2026-10-12 真机实证,Parallels Win11 ARM / 主机 200% 缩放 / 物理 3600×2020)
* ## 背景(2026-10-12 真机实证,虚拟化平台的 Win11 ARM / 宿主 200% 缩放 / 物理 2560×1440)
*
* PowerShell 默认 DPI 不感知,于是**同一个进程里并存两套坐标系**:
* - `Screen.Bounds` / `SystemInformation.VirtualScreen` → **逻辑**(1800×1010)
* - `Screen.Bounds` / `SystemInformation.VirtualScreen` → **逻辑**(1280×720)
* - `CopyFromScreen` / `SetCursorPos` → **物理**
* (缺省整屏只截到左上 1/4;(100,100) 实际落到 (200,200))
* 而 UIA(`screen.find`)返回的**一直是物理坐标**。结果:
+5 -5
View File
@@ -10,11 +10,11 @@ import {
const snap = {
alias: 'Ethernet',
dhcp: false,
ipv4: [{ ip: '192.168.1.159', prefix: 24 }],
ipv4: [{ ip: '192.168.1.20', prefix: 24 }],
gateway: ['192.168.1.1'],
dns: ['223.5.5.5'],
};
const args = { mode: 'static', ip: '192.168.1.88', mask: '255.255.255.0', gateway: '192.168.1.1' };
const args = { mode: 'static', ip: '192.168.1.21', mask: '255.255.255.0', gateway: '192.168.1.1' };
test('掩码换算:常见掩码与前缀长度互转', () => {
assert.equal(maskToPrefix('255.255.255.0'), 24);
@@ -72,9 +72,9 @@ test('apply(dhcp):切 DHCP 用原生 cmdlet', () => {
});
test('自检:失败时自动执行回滚脚本(自愈)', () => {
const chk = buildSelfCheck('Ethernet', '192.168.1.88', 'C:/rb.ps1');
const chk = buildSelfCheck('Ethernet', '192.168.1.21', 'C:/rb.ps1');
assert.match(chk, /Get-NetIPAddress/);
assert.match(chk, /192\.168\.1\.88/, '应校验目标地址是否真的生效');
assert.match(chk, /192\.168\.1\.21/, '应校验目标地址是否真的生效');
assert.match(chk, /selfcheck FAILED -> auto rollback/);
// 同上:路径改为 PS 单引号字面量(原为 `& "C:/rb.ps1"`)
assert.match(chk, /& 'C:\/rb\.ps1'/, '失败必须回滚,而不是把机器留在无地址状态');
@@ -88,7 +88,7 @@ test('rollback:同样先加后清 + 先清路由(否则回滚自己也会失
const iAddRoute = rb.indexOf('New-NetRoute');
assert.ok(iAdd < iRemove, '回滚也必须先加后清');
assert.ok(iRmRoute < iAddRoute, '回滚也必须先清路由');
assert.match(rb, /192\.168\.1\.159/, '必须恢复快照里的原地址');
assert.match(rb, /192\.168\.1\.20/, '必须恢复快照里的原地址');
assert.match(rb, /192\.168\.1\.1/, '必须恢复原网关');
assert.match(rb, /223\.5\.5\.5/, '必须恢复原 DNS');
assert.ok(!/netsh -f/.test(rb), '不得用 netsh -f 回滚(备份编码 + set address 双重问题)');
+1 -1
View File
@@ -78,7 +78,7 @@ test('install.ps1 的 stdin 读取:校验规则与防阻塞', () => {
// ---------- v2.0.0 自重启任务清理的保守判定(源码断言) ----------
//
// 用 includes 而非正则:这里断言的是源码字面片段,正则转义层数容易写错
// (臣本轮就在这上面栽过两次)。
// (本项目就在这上面栽过两次)。
const restartSrc = readFileSync(
new URL('../../apps/agent/src/capabilities/system.ts', import.meta.url),