Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
451d44af72 | ||
|
|
2d5be044ee | ||
|
|
9baf3b0443 | ||
|
|
dadd861fdd | ||
|
|
a7827ba986 | ||
|
|
0074d09f08 | ||
|
|
b7f44f7a64 | ||
|
|
654d694ff6 | ||
|
|
6152a68b4a | ||
|
|
fbd42e1c3b | ||
|
|
5a2c5f9c55 | ||
|
|
aad137f04a | ||
|
|
560b3c44e4 | ||
|
|
68ea3cd20e | ||
|
|
cb468df7da | ||
|
|
1249c90725 | ||
|
|
6b2fa29710 | ||
|
|
a75c5c9bc5 | ||
|
|
3680218010 | ||
|
|
141f479e4a | ||
|
|
9f95560268 | ||
|
|
240c52c44f | ||
|
|
0782d901a3 | ||
|
|
9af6e0f73a | ||
|
|
00659165b1 | ||
|
|
704fb8cfc9 | ||
|
|
618ebc986f | ||
|
|
c7d596e087 | ||
|
|
630ea7dd7a | ||
|
|
7d781d132e | ||
|
|
2bf45fdf98 | ||
|
|
590f62e968 | ||
|
|
b34e8c73e0 | ||
|
|
b9236dfaf1 | ||
|
|
00992e5c1c | ||
|
|
c9e34b93a2 | ||
|
|
9384b64c42 | ||
|
|
2437b0e863 | ||
|
|
56f64e1cc1 | ||
|
|
eec76ed91e | ||
|
|
6adf11c85a | ||
|
|
73ed06f5f1 |
No files matched your search
@@ -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
|
||||
@@ -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,58 @@ 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
|
||||
find dist -type f \( -name '*.exe' -o -name '*.msi' \) -printf '%10s %p\n' | sort -k2
|
||||
n=$(find dist -type f \( -name '*.exe' -o -name '*.msi' \) | wc -l)
|
||||
echo "共 $n 个安装包"
|
||||
# 两架构各一套(exe+msi)= 4 个;少一个说明产物不全,宁可红也不要静默少发
|
||||
if [ "$n" -lt 4 ]; then echo "::error::安装包数量不足($n < 4),架构可能缺失"; exit 1; fi
|
||||
|
||||
- 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'
|
||||
@@ -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 }}
|
||||
@@ -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 架构,
|
||||
|
||||
@@ -54,3 +54,7 @@ keys/
|
||||
|
||||
# 本地 agent 工作数据(memory / automations 等,属本机状态,不入库)
|
||||
.workbuddy/
|
||||
|
||||
# 被控端 / 控制端本机运行配置(含真实密钥与节点地址,绝不入库)
|
||||
config.json
|
||||
agent.json
|
||||
@@ -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
|
||||
+540
-9
@@ -7,6 +7,225 @@
|
||||
|
||||
## [未发布]
|
||||
|
||||
> 本段为 **v2.1.0** 定稿段(版本统一见 `efe4744`):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 +269,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 +332,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 +427,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 +458,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 +561,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 +936,7 @@ e2e 新增的 ACL / 畸形报文用例有个必须记住的姿势:**每个场
|
||||
|
||||
## [v2.0.1] —— 2026-10-11
|
||||
|
||||
> 一轮**真机验证**(Parallels 27 + Win11 ARM)带来的修复批次:在补齐 6 项待验证项的过程中,
|
||||
> 一轮**真机验证**(虚拟化平台 + Win11 ARM)带来的修复批次:在补齐 6 项待验证项的过程中,
|
||||
> **抓到并修掉 5 个真问题** —— 全部只有真机能发现(macOS 上 100% 测不出)。
|
||||
|
||||
### 修复
|
||||
@@ -428,7 +959,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 +984,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 检测等)。
|
||||
|
||||
@@ -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 工具** · **477 项单元测试** · **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 基线:待办收尾 / 语义可断言 / 状态采样 / 安全残余)
|
||||
|
||||
|
||||
@@ -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
@@ -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)) };
|
||||
}
|
||||
|
||||
/** 写入一条审计记录。任何异常都被吞掉 —— 审计绝不阻断主流程。 */
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 文件传输
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,198 @@ 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}(域名解析失败或目标拒绝连接;若配了代理,请核对代理地址与端口)`;
|
||||
}
|
||||
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 +258,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 +268,7 @@ export async function agentUpdate(args: Args): Promise<unknown> {
|
||||
expected: expect,
|
||||
actual: got.slice(0, 12),
|
||||
bytes: buf.length,
|
||||
...outboundHint(outbound, timeoutMs),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -146,7 +296,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 +305,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 +355,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 +387,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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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(' ─────────────────────────────────────────────');
|
||||
|
||||
@@ -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 };
|
||||
@@ -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';
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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`(同一坐标一次点中)
|
||||
*
|
||||
* ⚠️ 不要删。这不是「锦上添花」的兼容代码,而是坐标口径统一的**前提**。
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/cli",
|
||||
"version": "2.0.3",
|
||||
"version": "2.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -96,6 +96,8 @@ export interface Options {
|
||||
/** v18 部署校验 */
|
||||
check?: boolean;
|
||||
force?: boolean;
|
||||
/** v2.1.0 rollback 逃生门:直接指定远端备份路径为回滚源 */
|
||||
backup?: string;
|
||||
/** v17 音频 */
|
||||
mute?: string;
|
||||
volume?: string;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "nodeagent-console",
|
||||
"private": true,
|
||||
"version": "2.0.3",
|
||||
"version": "2.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+109
-24
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Generated
+1
-1
@@ -2101,7 +2101,7 @@ checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nodeagent-console"
|
||||
version = "2.0.3"
|
||||
version = "2.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "nodeagent-console"
|
||||
version = "2.0.3"
|
||||
version = "2.1.0"
|
||||
description = "nodeagent 桌面控制台(主控 + 被控一体,含本机服务托管)"
|
||||
authors = ["jkooe"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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[]>([]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,83 @@ 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>
|
||||
|
||||
<n-space v-if="l2Rows.length" vertical size="small">
|
||||
<n-space align="center">
|
||||
<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-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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/hub",
|
||||
"version": "2.0.3",
|
||||
"version": "2.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -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:*"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 → 成功
|
||||
```
|
||||
@@ -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 未自动安装** —— 检测到则并用,未检测到不尝试下载(不在本仓库职责内)。
|
||||
@@ -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 同步。 |
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 骨架 | ✅ | `efe4744` |
|
||||
| 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`) | ✅ | `b09ca68` |
|
||||
| na-gui 陈旧计划任务白名单清理 | ✅ | `scripts/lib/scheduled-tasks.mjs` + `control.ps1 cleanup` |
|
||||
| L2 e2e + 文档数字守卫 | ✅ | `tests/e2e/*`、`tests/unit/docs-numbers.test.mjs` |
|
||||
| 架构审查三 P0(validate 原型链 / 限速桶 key / describeArgs) | ✅ | `e864486`、`d106214` |
|
||||
|
||||
### 遗留(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
@@ -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` 再调用。
|
||||
|
||||
> 仅供技术参考,不构成任何投资建议。
|
||||
|
||||
@@ -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
@@ -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。
|
||||
|
||||
> 仅供技术参考,不构成任何投资建议。
|
||||
@@ -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
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/client",
|
||||
"version": "2.0.3",
|
||||
"version": "2.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/protocol",
|
||||
"version": "2.0.3",
|
||||
"version": "2.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+225
-26
@@ -24,8 +24,8 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/protocol
|
||||
selfsigned:
|
||||
specifier: ^2.4.1
|
||||
version: 2.4.1
|
||||
specifier: ^5.5.0
|
||||
version: 5.5.0
|
||||
ws:
|
||||
specifier: ^8.18.0
|
||||
version: 8.21.3
|
||||
@@ -105,8 +105,8 @@ importers:
|
||||
apps/mcp:
|
||||
dependencies:
|
||||
'@modelcontextprotocol/sdk':
|
||||
specifier: ^1.12.0
|
||||
version: 1.30.1(zod@4.6.5)
|
||||
specifier: ^1.32.1
|
||||
version: 1.32.1(zod@4.6.5)
|
||||
'@nodeagent/client':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/client
|
||||
@@ -342,8 +342,8 @@ packages:
|
||||
'@juggle/resize-observer@3.4.0':
|
||||
resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==}
|
||||
|
||||
'@modelcontextprotocol/sdk@1.30.1':
|
||||
resolution: {integrity: sha512-H2HxLvC3HDNybePJaLdSrU1hhUK5iQw+WvV1b01myFyI7sdVGe1u/IPTE5D9fGCiJDVtgMV/lmFkQXLmQyIFYA==}
|
||||
'@modelcontextprotocol/sdk@1.32.1':
|
||||
resolution: {integrity: sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
'@cfworker/json-schema': ^4.1.1
|
||||
@@ -352,9 +352,60 @@ packages:
|
||||
'@cfworker/json-schema':
|
||||
optional: true
|
||||
|
||||
'@noble/hashes@1.8.0':
|
||||
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
|
||||
engines: {node: ^14.21.3 || >=16}
|
||||
|
||||
'@oxc-project/types@0.152.0':
|
||||
resolution: {integrity: sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==}
|
||||
|
||||
'@peculiar/asn1-cms@2.11.0':
|
||||
resolution: {integrity: sha512-SlAiDoT/Cd8qaNmbTt78gqyaEvu2fQd6ML6KWJkdjXGrgDKnHHrusVkfQUelkfinL2p665IIADb0JBixtT8W1g==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
'@peculiar/asn1-csr@2.11.0':
|
||||
resolution: {integrity: sha512-M/ED0yJ05qMK+UADPKnIa8vhAeewOJALSxTAZB7RQ/batRHhIJZkwznCcWAbFO5saHJ1yEQ+m4lncEcG9RnQSg==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
'@peculiar/asn1-ecc@2.11.0':
|
||||
resolution: {integrity: sha512-jj1alGEKev7/un+cI+exyVK9m31lmP7cCBMCZuCb0wkFQH2zmKmJzNiG4wqXFNzlcP8timHLnzf3lV3OxBpb/Q==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
'@peculiar/asn1-pfx@2.11.0':
|
||||
resolution: {integrity: sha512-ykSAWRu8KkmzU8LRpk9ZQC3zuLmBEtoSMjIv9XHVamD5W7amqreV5wttKqnvtjWOzzSJQxA/HVT8/tHwpEEGSQ==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
'@peculiar/asn1-pkcs8@2.11.0':
|
||||
resolution: {integrity: sha512-lragjkjQGnQ5luxC+sxoAfAO/jBiQ7hOEGlDKJUk7MC5zY3AzjB2DU0GyPUH1Sswdv10t7DurY0zylOYMLb88w==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
'@peculiar/asn1-pkcs9@2.11.0':
|
||||
resolution: {integrity: sha512-gDdFjpqV0weJjgNUvueUVf4D8uVbpq+VbG0TsxLAY2QGljAkJyL9qwQ7avol9j0jaYSJ7Ey7v7RZp0M3GTcQSA==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
'@peculiar/asn1-rsa@2.11.0':
|
||||
resolution: {integrity: sha512-S2sBJmbfI09Kzdt9cEhE6pDNjHpVTkURs+811+PxS8AvsTLxC4woMNRhxHYaIbClkkNfAVt0tZxJdINVfUmzuA==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
'@peculiar/asn1-schema@2.11.0':
|
||||
resolution: {integrity: sha512-8xh4avo53/vXO6UEv9yaoJ83hY581geaLTygf61NPIXP5AeIZCPjJhpTaYU23IZkG+NpLN/C9FYzWp7hphIT9A==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
'@peculiar/asn1-x509-attr@2.11.0':
|
||||
resolution: {integrity: sha512-Fgvnd9REmLxEj/4cbDP54dNUNUXkzUksXLqEHqs1mHBuSRjSTCUSPM/WQIZYSvrdG7ND9CX1AQtPoIBekhgQSA==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
'@peculiar/asn1-x509@2.11.0':
|
||||
resolution: {integrity: sha512-J1sHAwGNlk+8J+FeR1eLVc6Gq0T9f9ITc3HWyPYgJraoW6U8AJuQiWVJ3SCEpltfTdpOY7fHdHT5oKAsbK/jww==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
'@peculiar/utils@2.0.3':
|
||||
resolution: {integrity: sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==}
|
||||
|
||||
'@peculiar/x509@1.14.3':
|
||||
resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@rolldown/binding-android-arm-eabi@1.2.12':
|
||||
resolution: {integrity: sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -542,9 +593,6 @@ packages:
|
||||
'@types/lodash@4.17.25':
|
||||
resolution: {integrity: sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==}
|
||||
|
||||
'@types/node-forge@1.3.14':
|
||||
resolution: {integrity: sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==}
|
||||
|
||||
'@types/node@22.20.4':
|
||||
resolution: {integrity: sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==}
|
||||
|
||||
@@ -643,6 +691,10 @@ packages:
|
||||
alien-signals@3.2.1:
|
||||
resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==}
|
||||
|
||||
asn1js@3.1.0:
|
||||
resolution: {integrity: sha512-HWYi1THkOqguCiO+vwj09ZkQFD2kKEsejYHAYCtX0D80WVdoDnvRc9IukXX/jeb0T9NRWJnRtqwdbHltnFjwXA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
ast-kit@2.2.0:
|
||||
resolution: {integrity: sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
@@ -665,6 +717,10 @@ packages:
|
||||
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
bytestreamjs@2.0.1:
|
||||
resolution: {integrity: sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
call-bind-apply-helpers@1.0.2:
|
||||
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1052,10 +1108,6 @@ packages:
|
||||
resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
node-forge@1.4.0:
|
||||
resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==}
|
||||
engines: {node: '>= 6.13.0'}
|
||||
|
||||
nostics@1.3.0:
|
||||
resolution: {integrity: sha512-wzRsrJwNlFbqIRQt8aCeL2WE8USip/uuvGSOZV1D02CxX8ABTMbw8IywKjH7yWHETMXbWJI4OBpihg9rmz2Vyg==}
|
||||
|
||||
@@ -1121,6 +1173,10 @@ packages:
|
||||
pkg-types@2.3.3:
|
||||
resolution: {integrity: sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w==}
|
||||
|
||||
pkijs@3.4.1:
|
||||
resolution: {integrity: sha512-Oo/NZcSWccq8KyoG7gLE9fnltgHns+pNCjCAp/WmjsUySi+sX7y4z4Xqu4fVb42CDHzRPl33fjzT15V1wvcyhA==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
||||
postcss@8.5.29:
|
||||
resolution: {integrity: sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
@@ -1129,6 +1185,13 @@ packages:
|
||||
resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
pvtsutils@1.3.6:
|
||||
resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==}
|
||||
|
||||
pvutils@1.2.0:
|
||||
resolution: {integrity: sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
||||
qs@6.16.0:
|
||||
resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==}
|
||||
engines: {node: '>=0.6'}
|
||||
@@ -1148,6 +1211,9 @@ packages:
|
||||
resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
reflect-metadata@0.2.2:
|
||||
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
|
||||
|
||||
require-from-string@2.0.2:
|
||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -1170,9 +1236,9 @@ packages:
|
||||
seemly@0.3.10:
|
||||
resolution: {integrity: sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==}
|
||||
|
||||
selfsigned@2.4.1:
|
||||
resolution: {integrity: sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==}
|
||||
engines: {node: '>=10'}
|
||||
selfsigned@5.5.0:
|
||||
resolution: {integrity: sha512-ftnu3TW4+3eBfLRFnDEkzGxSF/10BJBkaLJuBHZX0kiPS7bRdlpZGu6YGt4KngMkdTwJE6MbjavFpqHvqVt+Ew==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
send@1.2.1:
|
||||
resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
|
||||
@@ -1228,9 +1294,19 @@ packages:
|
||||
treemate@0.3.11:
|
||||
resolution: {integrity: sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg==}
|
||||
|
||||
tslib@1.14.1:
|
||||
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
|
||||
|
||||
tslib@2.3.0:
|
||||
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
tsyringe@4.10.0:
|
||||
resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==}
|
||||
engines: {node: '>= 6.0.0'}
|
||||
|
||||
type-is@2.1.0:
|
||||
resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==}
|
||||
engines: {node: '>= 18'}
|
||||
@@ -1554,7 +1630,7 @@ snapshots:
|
||||
|
||||
'@juggle/resize-observer@3.4.0': {}
|
||||
|
||||
'@modelcontextprotocol/sdk@1.30.1(zod@4.6.5)':
|
||||
'@modelcontextprotocol/sdk@1.32.1(zod@4.6.5)':
|
||||
dependencies:
|
||||
'@hono/node-server': 2.1.1(hono@4.13.9)
|
||||
ajv: 8.20.0
|
||||
@@ -1576,8 +1652,104 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@noble/hashes@1.8.0': {}
|
||||
|
||||
'@oxc-project/types@0.152.0': {}
|
||||
|
||||
'@peculiar/asn1-cms@2.11.0':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.11.0
|
||||
'@peculiar/asn1-x509': 2.11.0
|
||||
'@peculiar/asn1-x509-attr': 2.11.0
|
||||
asn1js: 3.1.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-csr@2.11.0':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.11.0
|
||||
'@peculiar/asn1-x509': 2.11.0
|
||||
asn1js: 3.1.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-ecc@2.11.0':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.11.0
|
||||
'@peculiar/asn1-x509': 2.11.0
|
||||
asn1js: 3.1.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-pfx@2.11.0':
|
||||
dependencies:
|
||||
'@peculiar/asn1-cms': 2.11.0
|
||||
'@peculiar/asn1-pkcs8': 2.11.0
|
||||
'@peculiar/asn1-rsa': 2.11.0
|
||||
'@peculiar/asn1-schema': 2.11.0
|
||||
asn1js: 3.1.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-pkcs8@2.11.0':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.11.0
|
||||
'@peculiar/asn1-x509': 2.11.0
|
||||
asn1js: 3.1.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-pkcs9@2.11.0':
|
||||
dependencies:
|
||||
'@peculiar/asn1-cms': 2.11.0
|
||||
'@peculiar/asn1-pfx': 2.11.0
|
||||
'@peculiar/asn1-pkcs8': 2.11.0
|
||||
'@peculiar/asn1-schema': 2.11.0
|
||||
'@peculiar/asn1-x509': 2.11.0
|
||||
'@peculiar/asn1-x509-attr': 2.11.0
|
||||
asn1js: 3.1.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-rsa@2.11.0':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.11.0
|
||||
'@peculiar/asn1-x509': 2.11.0
|
||||
asn1js: 3.1.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-schema@2.11.0':
|
||||
dependencies:
|
||||
'@peculiar/utils': 2.0.3
|
||||
asn1js: 3.1.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-x509-attr@2.11.0':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.11.0
|
||||
'@peculiar/asn1-x509': 2.11.0
|
||||
asn1js: 3.1.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-x509@2.11.0':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.11.0
|
||||
'@peculiar/utils': 2.0.3
|
||||
asn1js: 3.1.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/utils@2.0.3':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/x509@1.14.3':
|
||||
dependencies:
|
||||
'@peculiar/asn1-cms': 2.11.0
|
||||
'@peculiar/asn1-csr': 2.11.0
|
||||
'@peculiar/asn1-ecc': 2.11.0
|
||||
'@peculiar/asn1-pkcs9': 2.11.0
|
||||
'@peculiar/asn1-rsa': 2.11.0
|
||||
'@peculiar/asn1-schema': 2.11.0
|
||||
'@peculiar/asn1-x509': 2.11.0
|
||||
pvtsutils: 1.3.6
|
||||
reflect-metadata: 0.2.2
|
||||
tslib: 2.8.1
|
||||
tsyringe: 4.10.0
|
||||
|
||||
'@rolldown/binding-android-arm-eabi@1.2.12':
|
||||
optional: true
|
||||
|
||||
@@ -1684,10 +1856,6 @@ snapshots:
|
||||
|
||||
'@types/lodash@4.17.25': {}
|
||||
|
||||
'@types/node-forge@1.3.14':
|
||||
dependencies:
|
||||
'@types/node': 22.20.4
|
||||
|
||||
'@types/node@22.20.4':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
@@ -1823,6 +1991,12 @@ snapshots:
|
||||
|
||||
alien-signals@3.2.1: {}
|
||||
|
||||
asn1js@3.1.0:
|
||||
dependencies:
|
||||
pvtsutils: 1.3.6
|
||||
pvutils: 1.2.0
|
||||
tslib: 2.8.1
|
||||
|
||||
ast-kit@2.2.0:
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.9
|
||||
@@ -1854,6 +2028,8 @@ snapshots:
|
||||
|
||||
bytes@3.1.2: {}
|
||||
|
||||
bytestreamjs@2.0.1: {}
|
||||
|
||||
call-bind-apply-helpers@1.0.2:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
@@ -2235,8 +2411,6 @@ snapshots:
|
||||
dependencies:
|
||||
content-type: 2.1.0
|
||||
|
||||
node-forge@1.4.0: {}
|
||||
|
||||
nostics@1.3.0: {}
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
@@ -2289,6 +2463,15 @@ snapshots:
|
||||
exsolve: 1.1.3
|
||||
pathe: 2.0.3
|
||||
|
||||
pkijs@3.4.1:
|
||||
dependencies:
|
||||
'@noble/hashes': 1.8.0
|
||||
asn1js: 3.1.0
|
||||
bytestreamjs: 2.0.1
|
||||
pvtsutils: 1.3.6
|
||||
pvutils: 1.2.0
|
||||
tslib: 2.8.1
|
||||
|
||||
postcss@8.5.29:
|
||||
dependencies:
|
||||
nanoid: 3.3.20
|
||||
@@ -2300,6 +2483,12 @@ snapshots:
|
||||
forwarded: 0.2.0
|
||||
ipaddr.js: 1.9.1
|
||||
|
||||
pvtsutils@1.3.6:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
pvutils@1.2.0: {}
|
||||
|
||||
qs@6.16.0:
|
||||
dependencies:
|
||||
es-define-property: 1.0.1
|
||||
@@ -2318,6 +2507,8 @@ snapshots:
|
||||
|
||||
readdirp@5.1.1: {}
|
||||
|
||||
reflect-metadata@0.2.2: {}
|
||||
|
||||
require-from-string@2.0.2: {}
|
||||
|
||||
rolldown@1.2.12:
|
||||
@@ -2357,10 +2548,10 @@ snapshots:
|
||||
|
||||
seemly@0.3.10: {}
|
||||
|
||||
selfsigned@2.4.1:
|
||||
selfsigned@5.5.0:
|
||||
dependencies:
|
||||
'@types/node-forge': 1.3.14
|
||||
node-forge: 1.4.0
|
||||
'@peculiar/x509': 1.14.3
|
||||
pkijs: 3.4.1
|
||||
|
||||
send@1.2.1:
|
||||
dependencies:
|
||||
@@ -2436,8 +2627,16 @@ snapshots:
|
||||
|
||||
treemate@0.3.11: {}
|
||||
|
||||
tslib@1.14.1: {}
|
||||
|
||||
tslib@2.3.0: {}
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
tsyringe@4.10.0:
|
||||
dependencies:
|
||||
tslib: 1.14.1
|
||||
|
||||
type-is@2.1.0:
|
||||
dependencies:
|
||||
content-type: 2.1.0
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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} 条)`);
|
||||
@@ -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✓ 覆盖率门禁通过');
|
||||
@@ -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
@@ -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 }
|
||||
}
|
||||
Executable
+428
@@ -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
@@ -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
|
||||
|
||||
# 登录类型决定「能干什么」:
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 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'];
|
||||
|
||||
/**
|
||||
* 计划删除名单:白名单内**且实际存在**的任务,按白名单固定顺序返回。
|
||||
*
|
||||
* 纯函数:查询结果由调用方注入(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);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* 看门狗核心(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;
|
||||
|
||||
/** 全新状态(文件缺失时的起点)。 */
|
||||
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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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`,
|
||||
'```',
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
#!/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, statSync, 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,
|
||||
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(失败静默——日志不拦路)。 */
|
||||
export function appendGuardLog(dataDir, obj) {
|
||||
try {
|
||||
mkdirSync(join(dataDir, 'logs'), { recursive: true });
|
||||
const line = `${JSON.stringify({ ts: new Date().toISOString(), ...obj })}\n`;
|
||||
writeFileSync(join(dataDir, 'logs', 'startup-guard.log'), 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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)}`);
|
||||
});
|
||||
|
||||
|
||||
+39
-4
@@ -335,11 +335,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 +409,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 +423,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 +1811,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),
|
||||
|
||||
@@ -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
@@ -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:生效时段 ----------
|
||||
|
||||
@@ -0,0 +1,561 @@
|
||||
/**
|
||||
* 被控端出站代理守卫(`apps/agent/src/net/proxy.ts`,v2.1.0)。
|
||||
*
|
||||
* ⚠️ 背景(真机缺陷,与控制台 sidecar 同源)
|
||||
* 被控端拉取式自更新 `system.agent.update` 原为裸 `fetch` —— Node 内置 fetch
|
||||
* 默认不读任何代理环境变量,也读不到 Windows/macOS 系统代理,而中文用户配的
|
||||
* 恰恰是「系统代理」。结果:境内机器即使代理一切正常,自更新也**必然**失败
|
||||
* (`fetch failed`,零诊断信息)。修复 = 把 sidecar 已验证的出网方案
|
||||
* (`apps/console/sidecar/proxy.mjs`,16 用例守卫)同款移植为 TS。
|
||||
*
|
||||
* 本文件守四件事(口径逐条对标 `tests/unit/sidecar-proxy.test.mjs`):
|
||||
* ① **决议逻辑**(纯函数):代理来源优先级、NO_PROXY、系统代理形态解析;
|
||||
* ② **真发请求**(本地服务器,离线可跑):直连 / 绝对形态 / CONNECT 隧道 /
|
||||
* 重定向链共享预算 / maxBytes 双闸;
|
||||
* ③ **错误分类**(selfupdate.ts 同款移植):ECONNRESET 不得报成「请求超时」;
|
||||
* ④ **备份清理纯函数**:保留最近 3 个。
|
||||
*
|
||||
* 夹具同样取自真机:macOS 的是 `scutil --proxy` 原样输出形态;Windows 的是
|
||||
* `reg query …\Internet Settings` 原样输出形态(含「值不存在时 reg 走 stderr」)。
|
||||
*/
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import net from 'node:net';
|
||||
import {
|
||||
detectSystemProxy,
|
||||
httpGet,
|
||||
isBypassed,
|
||||
isLoopbackTarget,
|
||||
normalizeProxyUrl,
|
||||
parseRegQueryProxy,
|
||||
parseScutilProxy,
|
||||
pickEnvProxy,
|
||||
resolveProxy,
|
||||
tunnelConnection,
|
||||
} from '../../apps/agent/dist/net/proxy.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 夹具(真机原样形态)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 本机(macOS)真实输出形态:透明代理扩展接管 → PAC 模式,HTTP/HTTPS 开关都是 0。 */
|
||||
const SCUTIL_PAC = `<dictionary> {
|
||||
ExceptionsList : <array> {
|
||||
0 : localhost
|
||||
1 : 127.0.0.0/8
|
||||
2 : ::1
|
||||
}
|
||||
FTPPassive : 1
|
||||
HTTPEnable : 0
|
||||
HTTPSEnable : 0
|
||||
ProxyAutoConfigEnable : 1
|
||||
ProxyAutoConfigURLString : http://localhost:51431/proxy.pac
|
||||
ProxyAutoDiscoveryEnable : 0
|
||||
SOCKSEnable : 0
|
||||
}
|
||||
`;
|
||||
|
||||
/** 手工开系统代理时的形态(字段顺序与本机实测输出一致)。 */
|
||||
const SCUTIL_ON = `<dictionary> {
|
||||
ExceptionsList : <array> {
|
||||
0 : *.local
|
||||
1 : 169.254/16
|
||||
}
|
||||
HTTPEnable : 1
|
||||
HTTPPort : 7890
|
||||
HTTPProxy : 127.0.0.1
|
||||
HTTPSEnable : 1
|
||||
HTTPSPort : 7891
|
||||
HTTPSProxy : 127.0.0.1
|
||||
ProxyAutoConfigEnable : 0
|
||||
SOCKSEnable : 0
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* 真机(Windows 11 ARM64 虚拟机)上 `reg query … /v ProxyEnable` 的真实输出形态(值为 0x0)。
|
||||
*
|
||||
* 注:本文件里的地址一律用**文档保留段**(RFC 5737 的 `192.0.2.0/24`)或回环,
|
||||
* 不写任何真机的内网地址 —— 公开仓库的隐私审查会扫内网 IP。
|
||||
*/
|
||||
const REG_OFF = `
|
||||
HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings
|
||||
ProxyEnable REG_DWORD 0x0
|
||||
|
||||
`;
|
||||
|
||||
/** 开启系统代理的形态(与真机同一列宽:4 空格缩进 + 类型名 + 值)。 */
|
||||
const REG_ON = `
|
||||
HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings
|
||||
ProxyEnable REG_DWORD 0x1
|
||||
ProxyOverride REG_SZ *.local;<local>
|
||||
ProxyServer REG_SZ 192.0.2.10:7890
|
||||
`;
|
||||
|
||||
const REG_PER_PROTOCOL = `
|
||||
HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings
|
||||
ProxyEnable REG_DWORD 0x1
|
||||
ProxyServer REG_SZ http=127.0.0.1:7890;https=127.0.0.1:7891
|
||||
`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ① 纯函数
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('代理 URL 归一化:补 scheme、拒 socks、拒垃圾', () => {
|
||||
assert.equal(normalizeProxyUrl('http://127.0.0.1:7890')?.href, 'http://127.0.0.1:7890/');
|
||||
assert.equal(normalizeProxyUrl('127.0.0.1:7890')?.hostname, '127.0.0.1', '缺 scheme 应补 http://');
|
||||
assert.equal(normalizeProxyUrl(' https://proxy.local:8443 ')?.protocol, 'https:');
|
||||
assert.equal(normalizeProxyUrl('socks5://127.0.0.1:1080'), null, 'SOCKS 尚不支持 → 必须返回 null(上层据此出声)');
|
||||
assert.equal(normalizeProxyUrl(''), null);
|
||||
assert.equal(normalizeProxyUrl(' '), null);
|
||||
assert.equal(normalizeProxyUrl(undefined), null);
|
||||
assert.equal(normalizeProxyUrl('http://'), null, '没有主机名不算代理');
|
||||
});
|
||||
|
||||
test('NO_PROXY 命中判定:精确 / 后缀 / 端口 / 通配 / 不误伤 / CIDR 不猜', () => {
|
||||
const U = 'https://github.com/x';
|
||||
assert.equal(isBypassed(U, ''), false);
|
||||
assert.equal(isBypassed(U, ' '), false);
|
||||
assert.equal(isBypassed(U, 'github.com'), true);
|
||||
assert.equal(isBypassed(U, '.github.com'), true, '前导点等价于后缀匹配');
|
||||
assert.equal(isBypassed('https://api.github.com/x', 'github.com'), true, '条目应覆盖其子域');
|
||||
assert.equal(isBypassed(U, 'api.github.com'), false, '条目比目标更窄时不得反向豁免父域');
|
||||
assert.equal(isBypassed(U, 'github.io'), false, '不同后缀不得误伤');
|
||||
assert.equal(isBypassed(U, 'evilgithub.com'), false, '拼尾不得误伤');
|
||||
assert.equal(isBypassed(U, 'github.com:443'), true, '端口相符应命中');
|
||||
assert.equal(isBypassed(U, 'github.com:8080'), false, '端口不符不命中');
|
||||
assert.equal(isBypassed(U, ' example.com , github.com '), true, '空白与多条目应容错');
|
||||
assert.equal(isBypassed(U, '*'), true);
|
||||
// CIDR 条目(scutil 的 ExceptionsList 里就有 169.254/16)——不猜:解析不了就当不命中,
|
||||
// 由调用方记 note,绝不自作主张按网段放行(猜错 = 该走代理的没走)
|
||||
assert.equal(isBypassed('https://169.254.1.1/x', '169.254/16'), false, 'CIDR 不做网段猜测');
|
||||
assert.equal(isBypassed('https://127.0.0.1/x', '127.0.0.0/8'), false, 'CIDR 不做网段猜测(回环同理)');
|
||||
});
|
||||
|
||||
test('代理来源优先级:NA_PROXY > 协议环境变量 > ALL_PROXY', () => {
|
||||
const U = 'https://github.com/x';
|
||||
assert.equal(pickEnvProxy({ NA_PROXY: 'http://a:1', HTTPS_PROXY: 'http://b:2' }, U).source, 'NA_PROXY');
|
||||
assert.equal(pickEnvProxy({ HTTPS_PROXY: 'http://b:2', ALL_PROXY: 'http://c:3' }, U).source, 'HTTPS_PROXY');
|
||||
assert.equal(pickEnvProxy({ https_proxy: 'http://b:2' }, U).source, 'https_proxy', '小写形态同样生效');
|
||||
assert.equal(pickEnvProxy({ ALL_PROXY: 'http://c:3' }, U).source, 'ALL_PROXY');
|
||||
assert.equal(pickEnvProxy({}, U).url, null);
|
||||
// 只看 http 目标时不认 HTTPS_PROXY
|
||||
assert.equal(pickEnvProxy({ HTTPS_PROXY: 'http://b:2' }, 'http://x/y').url, null);
|
||||
assert.equal(pickEnvProxy({ HTTP_PROXY: 'http://d:4' }, 'http://x/y').source, 'HTTP_PROXY');
|
||||
});
|
||||
|
||||
test('环境变量写了 SOCKS 时必须出声,而不是静默直连', () => {
|
||||
const r = pickEnvProxy({ HTTPS_PROXY: 'socks5://127.0.0.1:1080' }, 'https://github.com/x');
|
||||
assert.equal(r.url, null);
|
||||
assert.equal(r.notes.length, 1);
|
||||
assert.match(r.notes[0], /socks5/, 'note 要带上原值,便于用户自查');
|
||||
assert.match(r.notes[0], /已忽略/);
|
||||
// 下一个可用来源仍应被采纳(不能因为前一个没看懂就整个放弃)
|
||||
const r2 = pickEnvProxy({ HTTPS_PROXY: 'socks5://x:1', ALL_PROXY: 'http://ok:8080' }, 'https://github.com/x');
|
||||
assert.equal(r2.source, 'ALL_PROXY');
|
||||
});
|
||||
|
||||
test('解析 scutil:HTTPS 优先、PAC 模式出声、全关则无代理', () => {
|
||||
const on = parseScutilProxy(SCUTIL_ON);
|
||||
assert.equal(on.url, '127.0.0.1:7891', 'HTTPS 优先于 HTTP');
|
||||
assert.equal(on.bypass, '*.local,169.254/16', 'ExceptionsList 应被收集成绕过列表');
|
||||
assert.deepEqual(on.notes, []);
|
||||
|
||||
const pac = parseScutilProxy(SCUTIL_PAC);
|
||||
assert.equal(pac.url, null);
|
||||
assert.match(pac.notes.join(''), /PAC/, 'PAC 不可解析必须明说,否则用户只会看到 fetch failed');
|
||||
|
||||
const off = parseScutilProxy('<dictionary> {\n HTTPEnable : 0\n HTTPSEnable : 0\n}\n');
|
||||
assert.equal(off.url, null);
|
||||
assert.deepEqual(off.notes, [], '既没开代理也不是 PAC,就不该报噪音 note');
|
||||
|
||||
assert.equal(parseScutilProxy('not a dict'), null);
|
||||
assert.equal(parseScutilProxy(''), null);
|
||||
});
|
||||
|
||||
test('解析 reg query:0x0 即显式关闭、分协议形态取 https、<local> 剔除', () => {
|
||||
const off = parseRegQueryProxy(REG_OFF);
|
||||
assert.equal(off.url, null, 'ProxyEnable=0x0 是显式关闭,哪怕别的值有内容也不能启用');
|
||||
|
||||
const on = parseRegQueryProxy(REG_ON);
|
||||
assert.equal(on.url, '192.0.2.10:7890');
|
||||
assert.equal(on.bypass, '*.local', '<local> 是占位符,不是域名,必须剔除');
|
||||
|
||||
const per = parseRegQueryProxy(REG_PER_PROTOCOL);
|
||||
assert.equal(per.url, '127.0.0.1:7891', '分协议形态应取 https(我们没有明文 http 目标)');
|
||||
|
||||
// 值不存在时 reg 把错误写在 stderr,stdout 只剩表头 —— 不该崩,也不该瞎猜
|
||||
const missing = parseRegQueryProxy(
|
||||
'\nHKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings\n\n',
|
||||
);
|
||||
assert.equal(missing, null);
|
||||
assert.equal(parseRegQueryProxy('ERROR: The system was unable to find…'), null);
|
||||
});
|
||||
|
||||
test('系统代理探测:命令失败/平台不支持一律视为「没有系统代理」,且 run 可注入', async () => {
|
||||
// 探测失败绝不能让自更新本身报错
|
||||
assert.equal(await detectSystemProxy('win32', async () => null), null);
|
||||
assert.equal(await detectSystemProxy('darwin', async () => null), null);
|
||||
assert.equal(await detectSystemProxy('linux', async () => 'whatever'), null, 'Linux 不做猜测');
|
||||
const boom = await detectSystemProxy('win32', async () => {
|
||||
throw new Error('安全软件拦截');
|
||||
});
|
||||
assert.equal(boom, null, '抛异常也要吞掉');
|
||||
|
||||
// 真解析路径(假 runner)
|
||||
const win = await detectSystemProxy('win32', async (file, args) => {
|
||||
assert.equal(file, 'reg');
|
||||
assert.equal(args[0], 'query');
|
||||
assert.match(args[1], /Internet Settings$/, '必须查 HKCU 的 Internet Settings');
|
||||
return REG_ON;
|
||||
});
|
||||
assert.equal(win.url, '192.0.2.10:7890');
|
||||
assert.equal(
|
||||
(await detectSystemProxy('darwin', async (f) => (f === 'scutil' ? SCUTIL_ON : null))).url,
|
||||
'127.0.0.1:7891',
|
||||
);
|
||||
});
|
||||
|
||||
test('回环目标恒直连:CI/dev 机的 HTTP_PROXY 不得把「自建镜像/测试源站」误送进代理', async () => {
|
||||
// 真景(本仓库 e2e 实踩):CI 与开发机普遍设 HTTP_PROXY=http://127.0.0.1:xxxx,
|
||||
// agent 继承后把发往回环源站的请求交给代理 → 代理另一侧的 localhost 不是这台机器 → 404。
|
||||
assert.equal(isLoopbackTarget('http://127.0.0.1:8080/agent.mjs'), true);
|
||||
assert.equal(isLoopbackTarget('http://127.8.9.10/x'), true, '整个 127/8 段都算回环');
|
||||
assert.equal(isLoopbackTarget('http://localhost:9000/x'), true);
|
||||
assert.equal(isLoopbackTarget('http://[::1]:9000/x'), true);
|
||||
assert.equal(isLoopbackTarget('http://127.999.1.1/x'), false, '非法字节不算(不猜)');
|
||||
assert.equal(isLoopbackTarget('https://github.com/x'), false, '公开域名不受影响');
|
||||
|
||||
// 即便显式配了 NA_PROXY + HTTP_PROXY,回环目标仍直连(代理够不着本机服务)
|
||||
const r = await resolveProxy({
|
||||
targetUrl: 'http://127.0.0.1:1234/agent.mjs',
|
||||
env: { NA_PROXY: 'http://127.0.0.1:9999', HTTP_PROXY: 'http://127.0.0.1:8888' },
|
||||
system: { url: '192.0.2.10:7890', bypass: '', notes: [] },
|
||||
});
|
||||
assert.equal(r.proxy, null, '回环目标不得走代理');
|
||||
assert.match(r.notes.join(''), /回环/);
|
||||
});
|
||||
|
||||
test('决议代理:NO_PROXY 短路 → 环境变量 → 系统代理兜底(含 note 累积与系统 bypass)', async () => {
|
||||
const U = 'https://github.com/x';
|
||||
|
||||
// NO_PROXY 直接短路(哪怕环境里有代理)
|
||||
const bypass = await resolveProxy({ targetUrl: U, env: { HTTPS_PROXY: 'http://a:1', NO_PROXY: 'github.com' }, system: null });
|
||||
assert.equal(bypass.proxy, null);
|
||||
assert.match(bypass.notes.join(''), /NO_PROXY/);
|
||||
|
||||
// 环境变量优先于系统代理
|
||||
const envFirst = await resolveProxy({
|
||||
targetUrl: U,
|
||||
env: { HTTPS_PROXY: 'http://env:1' },
|
||||
system: { url: 'sys:2', bypass: '', notes: [] },
|
||||
});
|
||||
assert.equal(envFirst.proxy.href, 'http://env:1/');
|
||||
assert.equal(envFirst.source, 'HTTPS_PROXY');
|
||||
|
||||
// 环境变量空 → 用系统代理,并把来源标成 system
|
||||
const sys = await resolveProxy({
|
||||
targetUrl: U,
|
||||
env: {},
|
||||
system: { url: '127.0.0.1:7890', bypass: '', notes: [] },
|
||||
});
|
||||
assert.equal(sys.proxy.href, 'http://127.0.0.1:7890/', '缺 scheme 的系统代理值也应能用');
|
||||
assert.equal(sys.source, 'system');
|
||||
|
||||
// 系统代理的绕过列表命中 → 直连
|
||||
const sysBypass = await resolveProxy({
|
||||
targetUrl: U,
|
||||
env: {},
|
||||
system: { url: '127.0.0.1:7890', bypass: 'github.com', notes: [] },
|
||||
});
|
||||
assert.equal(sysBypass.proxy, null);
|
||||
assert.match(sysBypass.notes.join(''), /绕过列表/);
|
||||
|
||||
// 完全没有代理:notes 应带出「为什么没用上」(PAC / socks),便于用户自查
|
||||
const none = await resolveProxy({
|
||||
targetUrl: U,
|
||||
env: { HTTPS_PROXY: 'socks5://x:1' },
|
||||
system: { url: null, bypass: '', notes: ['PAC 无法解析'] },
|
||||
});
|
||||
assert.equal(none.proxy, null);
|
||||
assert.match(none.notes.join(';'), /socks5/);
|
||||
assert.match(none.notes.join(';'), /PAC/);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ② 真发请求(本地服务器,离线)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 起一个最小 HTTP 源站,返回 {srv, url}。 */
|
||||
async function startOrigin(handler) {
|
||||
const srv = http.createServer(handler);
|
||||
await new Promise((r) => srv.listen(0, '127.0.0.1', r));
|
||||
const port = (srv.address()).port;
|
||||
return { srv, url: (p) => `http://127.0.0.1:${port}${p}` };
|
||||
}
|
||||
|
||||
/** 起一个「真转发」的 HTTP 代理;记录它看到的请求行(用于判定是否走了绝对形态)。 */
|
||||
async function startHttpProxy() {
|
||||
const seen = [];
|
||||
const srv = http.createServer((req, res) => {
|
||||
seen.push(req.url ?? '');
|
||||
let u;
|
||||
try {
|
||||
u = new URL(req.url ?? '');
|
||||
} catch {
|
||||
res.writeHead(400);
|
||||
res.end('not absolute-form');
|
||||
return;
|
||||
}
|
||||
const up = http.request(
|
||||
{
|
||||
host: u.hostname,
|
||||
port: u.port,
|
||||
path: `${u.pathname}${u.search}`,
|
||||
method: req.method,
|
||||
headers: { ...req.headers, host: u.host },
|
||||
},
|
||||
(r) => {
|
||||
res.writeHead(r.statusCode, r.headers);
|
||||
r.pipe(res);
|
||||
},
|
||||
);
|
||||
up.on('error', () => {
|
||||
res.writeHead(502);
|
||||
res.end('upstream error');
|
||||
});
|
||||
req.pipe(up);
|
||||
});
|
||||
// CONNECT 也要支持:https 目标经代理走的就是它
|
||||
srv.on('connect', (req, clientSock, head) => {
|
||||
seen.push(`CONNECT ${req.url}`);
|
||||
const [host, port] = (req.url ?? '').split(':');
|
||||
const up = net.connect(Number(port), host, () => {
|
||||
clientSock.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
||||
if (head?.length) up.write(head);
|
||||
up.pipe(clientSock);
|
||||
clientSock.pipe(up);
|
||||
});
|
||||
up.on('error', () => clientSock.destroy());
|
||||
});
|
||||
await new Promise((r) => srv.listen(0, '127.0.0.1', r));
|
||||
return { srv, seen, url: `http://127.0.0.1:${(srv.address()).port}` };
|
||||
}
|
||||
|
||||
test('httpGet 直连:跟随 302 且返回最终响应', async () => {
|
||||
const origin = await startOrigin((req, res) => {
|
||||
if (req.url === '/hop') {
|
||||
res.writeHead(302, { location: '/final' });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'content-type': 'text/plain' });
|
||||
res.end('ARRIVED');
|
||||
});
|
||||
try {
|
||||
const r = await httpGet(origin.url('/hop'), { timeoutMs: 5000 });
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.toString(), 'ARRIVED');
|
||||
assert.equal(r.url, origin.url('/final'), '应报出最终 URL');
|
||||
} finally {
|
||||
origin.srv.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('httpGet 经代理:请求行走绝对形态(不是直连)', async () => {
|
||||
const origin = await startOrigin((_req, res) => {
|
||||
res.writeHead(200);
|
||||
res.end('VIA-PROXY');
|
||||
});
|
||||
const proxy = await startHttpProxy();
|
||||
try {
|
||||
const r = await httpGet(origin.url('/agent.mjs'), { proxy: new URL(proxy.url), timeoutMs: 5000 });
|
||||
assert.equal(r.body.toString(), 'VIA-PROXY');
|
||||
assert.match(proxy.seen[0], /^http:\/\/127\.0\.0\.1:\d+\/agent\.mjs$/, 'HTTP 代理收到的是绝对 URI');
|
||||
} finally {
|
||||
proxy.srv.close();
|
||||
origin.srv.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('httpGet 经代理:代理不通时必须报错(证明真的走了代理而非偷偷直连)', async () => {
|
||||
const origin = await startOrigin((_req, res) => {
|
||||
res.writeHead(200);
|
||||
res.end('SHOULD-NOT-BE-REACHED');
|
||||
});
|
||||
try {
|
||||
await assert.rejects(
|
||||
// 死端口:若实现「代理失败就自动直连」,这里会拿到 200 —— 那正是我们最怕的静默降级
|
||||
httpGet(origin.url('/x'), { proxy: new URL('http://127.0.0.1:1'), timeoutMs: 5000 }),
|
||||
/ECONNREFUSED|代理/,
|
||||
);
|
||||
} finally {
|
||||
origin.srv.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('httpGet:整个重定向链共享同一个超时预算(不是每跳各自计时)', async () => {
|
||||
// /a → 302 /b → 302 /hang(挂起不响应)。若每跳各自计时,总耗 = 3×timeout;
|
||||
// 共享预算下总耗 ≈ 1×timeout 后整链 abort。
|
||||
const origin = await startOrigin((req, res) => {
|
||||
if (req.url === '/a' || req.url === '/b') {
|
||||
res.writeHead(302, { location: req.url === '/a' ? '/b' : '/hang' });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
// /hang:什么都不回
|
||||
});
|
||||
const timeoutMs = 600;
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
await assert.rejects(httpGet(origin.url('/a'), { timeoutMs }), /abort|中止|超时/i);
|
||||
const dt = Date.now() - t0;
|
||||
assert.ok(
|
||||
dt < timeoutMs * 2,
|
||||
`重定向链应共享预算(总耗 ≈ ${timeoutMs}ms),实际 ${dt}ms —— 说明每跳各自计时,会穿过 RPC 超时`,
|
||||
);
|
||||
} finally {
|
||||
origin.srv.closeAllConnections();
|
||||
origin.srv.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('httpGet:maxBytes 第一闸 —— content-length 声明超限立即中断', async () => {
|
||||
const origin = await startOrigin((_req, res) => {
|
||||
res.writeHead(200, { 'content-length': '1000' });
|
||||
res.end('x'.repeat(1000));
|
||||
});
|
||||
try {
|
||||
await assert.rejects(
|
||||
httpGet(origin.url('/big'), { timeoutMs: 5000, maxBytes: 100 }),
|
||||
/超过上限/,
|
||||
'声明超限时不该开始读 body',
|
||||
);
|
||||
} finally {
|
||||
origin.srv.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('httpGet:maxBytes 第二闸(流式)—— 不报 content-length 时收到即累计、超限即断', async () => {
|
||||
// 服务端不设 content-length、持续写 —— 谎报/缺报时内存不能被无界吃爆
|
||||
const origin = await startOrigin((_req, res) => {
|
||||
res.writeHead(200);
|
||||
// 分多块写,每块之间让出事件循环(保证 data 事件真的分批到达)
|
||||
let n = 0;
|
||||
const timer = setInterval(() => {
|
||||
n += 1;
|
||||
if (n > 50 || res.writableEnded) {
|
||||
clearInterval(timer);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
res.write('x'.repeat(100));
|
||||
}, 2);
|
||||
res.on('close', () => clearInterval(timer));
|
||||
});
|
||||
try {
|
||||
await assert.rejects(
|
||||
httpGet(origin.url('/stream'), { timeoutMs: 5000, maxBytes: 200 }),
|
||||
/超过上限/,
|
||||
);
|
||||
} finally {
|
||||
origin.srv.closeAllConnections();
|
||||
origin.srv.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('abort 必须覆盖隧道与 TLS 握手:代理收下 CONNECT 却装死时,超时要立即生效', async () => {
|
||||
// ⚠️ 真机踩过(sidecar 2026-10-13):配置 75s 超时,实际挂了 **109s** 才失败。
|
||||
// 根因:`createConnection` 回调返回之前外层 `http.request` 还没建立,
|
||||
// 外层的 signal / timeout **无人受理**;若此时卡在 CONNECT 或 TLS 握手上,
|
||||
// 超时形同虚设。本用例把那个形态固定下来:
|
||||
// 代理回 200 之后就一个字都不说 → TLS 握手永远完不成。
|
||||
const srv = http.createServer();
|
||||
srv.on('connect', (_req, sock) => {
|
||||
sock.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
||||
// 装死:不再说一个字
|
||||
});
|
||||
await new Promise((r) => srv.listen(0, '127.0.0.1', r));
|
||||
const t0 = Date.now();
|
||||
let watchdog;
|
||||
try {
|
||||
await assert.rejects(
|
||||
// 看门狗:没有它,一旦 abort 失效本用例会**挂死**(把整个测试套件拖住),
|
||||
// 而不是干脆地变红。有它则 4s 后必定失败,且失败点落在下面的 dt 断言上。
|
||||
Promise.race([
|
||||
httpGet('https://github.com/x', {
|
||||
proxy: new URL(`http://127.0.0.1:${(srv.address()).port}`),
|
||||
timeoutMs: 800,
|
||||
}),
|
||||
new Promise((_never, rej) => {
|
||||
watchdog = setTimeout(() => rej(new Error('看门狗触发:abort 未生效')), 4000);
|
||||
}),
|
||||
]),
|
||||
/abort|中止|超时/i,
|
||||
);
|
||||
const dt = Date.now() - t0;
|
||||
assert.ok(dt < 4000, `超时应立即生效(配置 800ms),实际 ${dt}ms —— 说明 abort 没覆盖隧道/TLS 阶段`);
|
||||
} finally {
|
||||
clearTimeout(watchdog);
|
||||
srv.closeAllConnections();
|
||||
srv.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('CONNECT 隧道:可用,且代理拒绝(非 200)时给出明确错误', async () => {
|
||||
const origin = await startOrigin((_req, res) => {
|
||||
res.writeHead(200);
|
||||
res.end('TUNNELED');
|
||||
});
|
||||
const proxy = await startHttpProxy();
|
||||
try {
|
||||
// 直接用隧道 socket 说一句裸 HTTP/1.1(这里刻意用 http 目标:隧道层不带 TLS,
|
||||
// TLS 叠层由真机端到端验证覆盖)
|
||||
const socket = await new Promise((resolve, reject) => {
|
||||
const mk = tunnelConnection(
|
||||
new URL(proxy.url),
|
||||
{ protocol: 'http:', hostname: '127.0.0.1', port: String((origin.srv.address()).port) },
|
||||
undefined,
|
||||
);
|
||||
mk({}, (err, s) => (err ? reject(err) : resolve(s)));
|
||||
});
|
||||
const text = await new Promise((resolve) => {
|
||||
let buf = '';
|
||||
socket.on('data', (d) => {
|
||||
buf += d;
|
||||
if (buf.includes('TUNNELED')) {
|
||||
socket.destroy();
|
||||
resolve(buf);
|
||||
}
|
||||
});
|
||||
socket.write(`GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n`);
|
||||
});
|
||||
assert.match(text, /200 OK/);
|
||||
assert.match(proxy.seen.join('|'), /^CONNECT 127\.0\.0\.1:\d+$/, '代理应收到 CONNECT');
|
||||
|
||||
// 代理拒绝(非 200)→ 必须立刻报错并关掉 socket,而不是挂着
|
||||
const badProxy = http.createServer();
|
||||
badProxy.on('connect', (_req, sock) => {
|
||||
sock.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
||||
sock.destroy();
|
||||
});
|
||||
await new Promise((r) => badProxy.listen(0, '127.0.0.1', r));
|
||||
await assert.rejects(
|
||||
new Promise((resolve, reject) => {
|
||||
const mk = tunnelConnection(
|
||||
new URL(`http://127.0.0.1:${(badProxy.address()).port}`),
|
||||
{ protocol: 'http:', hostname: '127.0.0.1', port: '80' },
|
||||
undefined,
|
||||
);
|
||||
mk({}, (err, s) => (err ? reject(err) : resolve(s)));
|
||||
}),
|
||||
/代理拒绝 CONNECT/,
|
||||
);
|
||||
badProxy.close();
|
||||
} finally {
|
||||
proxy.srv.close();
|
||||
origin.srv.close();
|
||||
}
|
||||
});
|
||||
Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user