diff --git a/.gitignore b/.gitignore index 1f4dc0d..6cb42d2 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,10 @@ pnpm-debug.log* .env.* !.env.example +# 预共享密钥:install.cmd 在包内生成,绝不入库 +PSK.txt +**/PSK.txt + # OS .DS_Store Thumbs.db diff --git a/README.md b/README.md index 7ce4ccb..b77fa67 100644 --- a/README.md +++ b/README.md @@ -61,16 +61,35 @@ pnpm build 产物:`release/nodeagent-win-x64.zip`(约 32MB) -2. 把 zip 拷到 Windows 并解压 -3. 右键以**管理员**运行其中的 `install.ps1` +2. 把 zip 拷到 Windows 并解压到**固定目录**(如 `D:\nodeagent`,装完不要移动或改名) +3. **双击 `install.cmd`** —— 会自动弹 UAC 提权,然后一路装完并打印连接信息 -安装脚本会使用**包内自带的 Node.js 运行时**,自动:生成预共享密钥 → 写配置 → 放行防火墙 → 注册开机自启 → 启动 Agent,并打印**密钥**与**连接命令**。 +> 不需要记任何命令行。`install.cmd` 会自己提权、自己读包内 `PSK.txt` 的密钥(没有就生成一把), +> 再调用底层的 `install.ps1`。日后管理双击 **`control.cmd`**(启动 / 停止 / 重启 / 状态 / 日志 / 卸载)。 + +安装脚本会使用**包内自带的 Node.js 运行时**,自动:读/生成预共享密钥 → 写配置 → 放行防火墙 → 注册开机自启 → 启动 Agent,并打印**密钥**与**连接命令**。 + +
+需要指定端口 / 无人值守模式?展开命令行用法 + +```powershell +# 指定端口(默认 8765) +install.cmd 18770 + +# 等价的底层调用(install.cmd 就是这行 + 提权 + 读 PSK) +powershell -ExecutionPolicy Bypass -File .\install.ps1 -NodeId my-pc -Key -AllowInput + +# 无人值守:注销/重启后仍运行,但**没有 GUI**(Session 0 无桌面) +powershell -ExecutionPolicy Bypass -File .\install.ps1 -Unattended -AtStartup +``` + +
**两种运行模式(重要)** | 模式 | 命令 | 登录/注销 | 能力范围 | |---|---|---|---| -| 交互(默认) | `install.ps1` | 登录时启动;**注销即停** | 全部能力,**含 GUI**(截屏 / UIA 找元素 / 键鼠注入) | +| 交互(默认) | `install.cmd` | 登录时启动;**注销即停** | 全部能力,**含 GUI**(截屏 / UIA 找元素 / 键鼠注入) | | 无人值守 | `install.ps1 -Unattended -AtStartup` | **注销、重启后仍运行**(S4U,无需存密码) | 无 GUI —— 截屏/输入类能力不可用(进程在非交互会话,没有桌面) | > 这是 Windows 的固有限制:**图形操作必须有交互桌面**。需要 GUI 就保持登录(可锁屏); @@ -379,7 +398,7 @@ node scripts/verify-mcp-events.mjs # 验证 MCP 事件双通路(推送 + 拉 | 层次 | 方式 | 覆盖 | |---|---|---| -| **CI(推荐)** | GitHub Actions `windows-latest` | 真实 Windows 上跑协议 E2E、专属能力(`Get-Service` / 注册表 / winget 装软件)、`install.ps1` 链路、CLI 连入、MCP 工具列表 | +| **CI(推荐)** | GitHub Actions `windows-latest` | 真实 Windows 上跑协议 E2E、专属能力(`Get-Service` / 注册表 / winget 装软件)、`install.ps1` 链路、`install.cmd`/`control.cmd` 双击链路、**Windows PowerShell 5.1 解析守卫**、CLI 连入、MCP 工具列表 | | **远程验收** | `node scripts/verify.mjs --host --key <密钥> [--insecure]` | 逐条验收并输出终端报告(`--report report.md`) | | **本地测试** | `pnpm test:e2e` / `pnpm test:windows` | 单机自举:本机起 Agent 当被控端自测 | @@ -405,7 +424,7 @@ nodeagent/ │ ├── hub/ # 中转节点:设备注册、控制端配对、字节透传(跨网段/公网) │ ├── cli/ # macOS 控制端 CLI │ └── mcp/ # MCP server(接入 WorkBuddy) -├── scripts/ # install.ps1 / uninstall.ps1 / verify.mjs +├── scripts/ # install.cmd / control.cmd(一键入口)/ install.ps1 / control.ps1 / verify.mjs ├── tests/e2e/ # 端到端测试 ├── docs/ # 开发文档 ├── PRD.md # 产品需求文档 @@ -441,6 +460,9 @@ nodeagent/ ## 文档 +- [`docs/VERSIONING.md`](docs/VERSIONING.md) —— **版本与兼容性契约**(升级顺序、什么算破坏性、发版流程、更新方式) +- [`docs/REMOTE-LIMITS.md`](docs/REMOTE-LIMITS.md) —— 远程能力边界与真实故障案例(含杀软误拦) + - [产品需求文档(PRD)](./PRD.md) - [开发文档(DEVELOPMENT)](./docs/DEVELOPMENT.md) —— 协议细节、能力 schema、安全模型、各端实现指南 - [**跨机接管的边界与局限**](./docs/REMOTE-LIMITS.md) —— 哪些问题能远程修、哪些必须人工,附真实故障诊断案例 @@ -454,5 +476,5 @@ nodeagent/ | 安全演进 | 先简化(预共享密钥 + HMAC)→ 后强化(Ed25519 + 能力级 ACL + 审计) | | AI 接入 | 核心能力 → CLI(调试底座)→ MCP(AI 落点) | | 装软件 | 优先 winget 静默安装 | -| Windows 部署 | 一键 `install.ps1` + 开机自启 | +| Windows 部署 | 一键 `install.cmd`(双击即装,自提权)+ 开机自启;`control.cmd` 管理 | | 依赖策略 | 被控端零原生编译;能力全部基于系统原生 API 实现 | diff --git a/apps/agent/src/capabilities/system.ts b/apps/agent/src/capabilities/system.ts index 25f507f..bfe2f4a 100644 --- a/apps/agent/src/capabilities/system.ts +++ b/apps/agent/src/capabilities/system.ts @@ -47,7 +47,19 @@ async function isAdmin(): Promise { // ---------------- system.info ---------------- -// ---------- 构建指纹(v18)---------- +// ---------- 构建指纹(v18 / v20)---------- + +// 构建时由 esbuild --define 注入(见 scripts/pack.mjs);开发态(直接跑 dist)下未定义, +// 故一律用 typeof 探测,绝不能直接引用(否则 dev 启动就 ReferenceError)。 +declare const __AGENT_VERSION__: string | undefined; +declare const __BUILD_COMMIT__: string | undefined; +declare const __BUILD_TIME__: string | undefined; + +function injected(name: 'version' | 'commit' | 'time'): string { + if (name === 'version') return typeof __AGENT_VERSION__ === 'undefined' ? 'dev' : __AGENT_VERSION__; + if (name === 'commit') return typeof __BUILD_COMMIT__ === 'undefined' ? 'dev' : __BUILD_COMMIT__; + return typeof __BUILD_TIME__ === 'undefined' ? 'dev' : __BUILD_TIME__; +} const STARTED_AT = Date.now(); let buildCache: { hash: string; bytes: number; mtime_ms: number } | null = null; @@ -59,7 +71,17 @@ let buildCache: { hash: string; bytes: number; mtime_ms: number } | null = null; * (旧版可能恰好也是同样的能力数);内容哈希是唯一可靠的判据。 * 懒计算 + 缓存:文件约 1MB,只在首次 system.info 时读一次。 */ -function buildInfo(): { hash: string; bytes: number; mtime_ms: number; node: string; started_at: number; uptime_ms: number } { +export function buildInfo(): { + hash: string; + bytes: number; + mtime_ms: number; + node: string; + started_at: number; + uptime_ms: number; + version: string; + commit: string; + built_at: string; +} { if (!buildCache) { let hash = 'unknown'; let bytes = 0; @@ -80,6 +102,10 @@ function buildInfo(): { hash: string; bytes: number; mtime_ms: number; node: str node: process.version, started_at: STARTED_AT, uptime_ms: Date.now() - STARTED_AT, + // v20:语义化版本 + 提交 + 构建时间(来自构建期注入) + version: injected('version'), + commit: injected('commit'), + built_at: injected('time'), }; } diff --git a/apps/agent/src/server.ts b/apps/agent/src/server.ts index 1e6987a..d8aca25 100644 --- a/apps/agent/src/server.ts +++ b/apps/agent/src/server.ts @@ -26,6 +26,7 @@ import { type AuthParams, type InvokeParams, } from '@nodeagent/protocol'; +import { buildInfo } from './capabilities/system.js'; import { createCapabilityRegistry } from './capabilities/index.js'; import { audit, describeArgs } from './audit.js'; import { disposeWatchesByOwner } from './events.js'; @@ -179,7 +180,16 @@ export function createAgentCore(cfg: AgentConfig): AgentCore { remote: state.remote, reason: `mode=${authMode}` + (authorized ? `, authorized=${authorized.length}` : ''), }); - sendResult(ws, req.id, { capabilities: CAPABILITY_MANIFEST, authorized, auth_mode: authMode }); + const bi = buildInfo(); + sendResult(ws, req.id, { + capabilities: CAPABILITY_MANIFEST, + authorized, + auth_mode: authMode, + // v20:把版本/构建信息交给控制端,便于做新旧兼容提示 + agent_version: bi.version, + build: { hash: bi.hash, commit: bi.commit, built_at: bi.built_at }, + protocol: PROTOCOL_VERSION, + }); } async function handleInvoke(ws: WebSocket, state: ConnState, req: RpcRequest): Promise { diff --git a/apps/cli/src/cmd/conn.ts b/apps/cli/src/cmd/conn.ts index 814c590..f8fbd0c 100644 --- a/apps/cli/src/cmd/conn.ts +++ b/apps/cli/src/cmd/conn.ts @@ -32,6 +32,7 @@ import { } from '@nodeagent/protocol'; import { callAndPrint, + reportCompat, fail, getClientConfig, humanSize, diff --git a/apps/cli/src/cmd/system.ts b/apps/cli/src/cmd/system.ts index 7ec3eed..a3ba609 100644 --- a/apps/cli/src/cmd/system.ts +++ b/apps/cli/src/cmd/system.ts @@ -32,6 +32,7 @@ import { } from '@nodeagent/protocol'; import { callAndPrint, + reportCompat, fail, getClientConfig, humanSize, @@ -104,6 +105,11 @@ function renderInfoValue(v: unknown): string { } export async function cmdInfo(opts: Options): Promise { + // 先打版本与兼容性(v20)——info 是最自然的「体检」命令 + await withClient((c) => { + reportCompat(c); + return Promise.resolve(); + }); await withClient((c) => callAndPrint(c, CapabilityNames.SystemInfo, {}, opts.json, (data) => { const d = data as Record; diff --git a/apps/cli/src/core.ts b/apps/cli/src/core.ts index 3c7363f..34ce43f 100644 --- a/apps/cli/src/core.ts +++ b/apps/cli/src/core.ts @@ -24,7 +24,9 @@ import { type NodeProfile, } from '@nodeagent/client'; import { + CAPABILITY_MANIFEST, CapabilityNames, + capabilityDiff, matchPattern, DEFAULT_DISCOVERY_PORT, type CapabilityDescriptor, @@ -256,3 +258,42 @@ export async function probeOnce(nodeName?: string): Promise<{ return { ok: false, caps: 0, error: err instanceof Error ? err.message : String(err) }; } } + +/** + * 新旧版本兼容提示(v20)。 + * + * 背景:agent 与 CLI 是**分开更新**的(CLI 随 Mac 装,agent 推/拉到被控端), + * 必然出现新旧混用。等到调用时才抛 E_CAPABILITY_NOT_FOUND 会让人摸不着头脑, + * 所以在握手后就明确告诉用户「谁旧、缺什么、怎么升」。 + * + * 兼容性依据(见 docs/VERSIONING.md):**只增不改** —— + * 新 agent 向后兼容旧 CLI(新增能力/可选参数),因此升级顺序建议先被控端。 + */ +export function reportCompat(client: NodeAgentClient): void { + const remoteNames = client.listCapabilities().map((c) => c.name); + const localNames = CAPABILITY_MANIFEST.map((c) => c.name); + const diff = capabilityDiff(localNames, remoteNames); + const v = client.getAgentVersion(); + const b = client.getAgentBuild(); + + if (v) { + console.log(`被控端版本 : v${v}${b?.commit ? ` @ ${b.commit}` : ''}${b?.hash ? ` 构建 ${b.hash}` : ''}`); + } else { + console.log('被控端版本 : 未上报(多为 v20 之前的版本,建议更新一次)'); + } + + if (diff.missingOnRemote.length > 0) { + console.warn(`⚠️ 被控端缺少 ${diff.missingOnRemote.length} 项本控制端已知的能力 —— 远端较旧:`); + console.warn(` ${diff.missingOnRemote.join(', ')}`); + console.warn(' → 建议更新被控端:nodeagent update --url <包地址> --sha256 <哈希>'); + } + if (diff.unknownLocally.length > 0) { + console.warn(`ℹ️ 被控端还有 ${diff.unknownLocally.length} 项本控制端不认识的能力 —— 本 CLI 较旧:`); + console.warn(` ${diff.unknownLocally.join(', ')}`); + console.warn(' → 不影响既有功能;要用到这些新能力需更新控制端(git pull 后重跑 pack:mac + install-macos.sh)'); + } + if (diff.missingOnRemote.length === 0 && diff.unknownLocally.length === 0) { + console.log(`能力对齐 : ${localNames.length} 项,两端一致 ✓`); + } +} + diff --git a/docs/VERSIONING.md b/docs/VERSIONING.md new file mode 100644 index 0000000..44463c1 --- /dev/null +++ b/docs/VERSIONING.md @@ -0,0 +1,97 @@ +# 版本与兼容性契约 + +> 为什么需要这份文档:agent 与 CLI/MCP 是**分开更新的**(CLI 随控制端装,agent 推/拉到被控端), +> 新旧混用是常态而非例外。没有明文契约,升级顺序、什么算破坏性改动、出问题怎么定位全靠猜。 + +## 1. 版本号 + +- 单一来源:**根 `package.json` 的 `version`**,构建时经 esbuild `--define` 注入 agent。 +- 运行时可通过 `system.info` 的 `build` 字段读到:`version` / `commit`(git sha)/ `built_at` / + `hash`(运行中脚本的 sha256 前 12 位 —— 部署校验用)。 +- 控制端在握手(`auth_ok`)即拿到被控端版本,`nodeagent info` 会显示: + ``` + 被控端版本 : v1.0.0 @ ef9ad97 构建 740d51953fd0 + 能力对齐 : 42 项,两端一致 ✓ + ``` + +## 2. 语义化版本的含义 + +| 变更类型 | 版本位 | 例子 | +|---|---|---| +| **破坏性**:删除能力、给既有能力**新增必填参数**、改变既有能力的返回结构、修改认证/握手流程 | MAJOR | 删掉 `screen.find`;把 `fs.read` 的 `path` 改成必填 `remote_path` | +| **新增**:新能力、既有能力的**可选**参数、新增返回字段、新增审计事件类型 | MINOR | 加 `system.audio.*`、给 `deploy` 加 `--check` | +| **修复/内部**:bug 修复、性能、文档、不影响协议的重构 | PATCH | 修备份编码、拆分文件 | + +## 3. 兼容性契约(硬规则) + +1. **只增不改**:既有能力的**参数名与语义不得变更**;要改就新增能力(如 `screen.find` 与未来的 + `screen.find2` 并存一代),旧能力至少在下一个 MAJOR 才移除。 +2. **不新增必填参数**:既有能力只能加**可选**参数 —— 否则旧控制端一调就 `E_PARAM_INVALID`。 +3. **返回结构只加不减**:新增字段必须可选,旧控制端忽略即可。 +4. **`auth_ok` 的字段只增不减**:v20 新增的 `agent_version`/`build`/`protocol` 均为可选, + 旧控制端不认也不报错。 +5. **协议主版本**(`PROTOCOL_VERSION` 的主号)不一致时被控端直接拒绝连接并返回 + `E_PROTOCOL_MISMATCH` —— 这是唯一的硬闸门,用于承载**无法兼容**的协议变更。 +6. **能力清单是唯一事实源**:控制端不比版本号,而是比**能力集合** + (`capabilityDiff`)—— 版本号可能相同而内容不同(本地 dev 构建),能力集合不会骗人。 + +## 4. 升级顺序建议 + +**先升级被控端,再升级控制端**: + +| 组合 | 结果 | +|---|---| +| 新被控端 + 旧控制端 | ✅ 安全。被控端只做加法,旧控制端不认识的新能力会被忽略 | +| 旧被控端 + 新控制端 | ⚠️ 可用,但新功能不可用。`nodeagent info` 会列出**远端缺少**的能力并提示升级 | + +> 若顺序反了也不会坏:控制端调用缺失能力时返回 `E_CAPABILITY_NOT_FOUND`, +> 而 `nodeagent info` / `connect` 会提前把它变成一句人话提示。 + +## 5. 更新方式(两条路,按可达性选) + +| 方式 | 命令 | 前提 | +|---|---|---| +| 推送式 | `pnpm update:win <设备名>` | 控制端↔被控端**可达**;自动打包含校验与指纹复核 | +| **拉取式** | `nodeagent update --url <地址> --sha256 <哈希>` | **只要被控端能上网**(含 GitHub Release) | + +先预检再更新(不动任何文件): + +```bash +nodeagent --node win update --url <地址> --sha256 <哈希> --dry-run +``` + +`--sha256` 是**安全底线**:不校验哈希等于开放远程代码执行。 + +## 6. 发版流程 + +```bash +pnpm release patch|minor|major| # 改版本 → 构建 → 打包 → 校验和/清单 → 提交打标签 → GitHub Release +pnpm release 1.0.0 --dry-run # 演练(无任何副作用) +``` + +产物(GitHub Release 资产): + +| 资产 | 用途 | +|---|---| +| `agent.mjs` | **自更新载荷** —— 被控端 `system.agent.update` 直接用它(配 `--sha256`) | +| `nodeagent-win-x64.zip` | 人工安装包(自包含 `node.exe`,免装 Node.js) | +| `latest.json` | 版本清单:version/commit/built_at/capabilities + 两个资产的 url 与 sha256 | +| `SHA256SUMS` | 校验和 | + +> ⚠️ **首次仍需一次性安装**:自更新能力本身得先在机器上(鸡生蛋无解)。 +> ⚠️ **`node.exe`(Node 运行时)不在自更新范围**:只换 `agent.mjs`;需升级 Node 时重跑 `install.ps1`。 +> ⚠️ 本仓库的 `.github/` 改动需要 `workflow` 权限才能推送(`gh auth refresh -h github.com -s workflow`), +> 发版脚本刻意跳过 `.github/`,避免发版流程被它卡住。 + +## 7. 出问题怎么排查 + +```bash +nodeagent --node win info # 版本 + 能力对齐情况(先看这个) +nodeagent --node win invoke system.info --args '{"fields":["build"]}' # 构建指纹 +nodeagent --node win deploy <包> --check # 本地包 vs 远端是否一致(不改动) +``` + +- **远端过旧**:`info` 会列出缺失能力 → `nodeagent update ...` +- **本控制端过旧**:`info` 会列出"不认识的新能力" → 更新控制端(`git pull` → `pack:mac` → `install-macos.sh`) +- **同版本但指纹不同**:属正常(内容相同才叫同版本);用 `--check` 对比实际内容 +- **部署后没生效**:先查杀软是否处置了被替换的 `agent.mjs`(见 `REMOTE-LIMITS.md` 的「杀软误拦」) diff --git a/package.json b/package.json index 94fdde3..f62ed41 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "verify": "node scripts/verify.mjs", "pack:win": "node scripts/pack.mjs", "pack:mac": "node scripts/pack-macos.mjs", - "update:win": "node scripts/update-win.mjs" + "update:win": "node scripts/update-win.mjs", + "release": "node scripts/release.mjs" }, "devDependencies": { "@types/node": "^22.10.2", diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 8f78f7d..451520f 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -76,6 +76,10 @@ export class NodeAgentClient { private capabilities: CapabilityDescriptor[] = []; /** ed25519 模式:本次被授权的能力;psk 模式为 null */ private authorized: string[] | null = null; + /** v20:被控端语义化版本(旧被控端不返回则为 undefined) */ + private agentVersion?: string; + /** v20:被控端构建信息 { hash, commit, built_at } */ + private agentBuild?: { hash?: string; commit?: string; built_at?: string }; private closed = false; /** v4:重连状态 */ private reconnectAttempts = 0; @@ -155,6 +159,9 @@ export class NodeAgentClient { this.capabilities = authOk.capabilities ?? []; this.authorized = authOk.authorized ?? null; + // v20:远端版本/构建信息(旧被控端不返回 → 保持 undefined,不报错) + this.agentVersion = authOk.agent_version; + this.agentBuild = authOk.build; const label = authOk.auth_mode === 'ed25519' ? `ed25519,授权 ${this.authorized?.length ?? 0}/${this.capabilities.length} 项能力` @@ -183,6 +190,16 @@ export class NodeAgentClient { return this.authorized; } + /** v20:被控端版本(旧被控端未上报则为 undefined)。 */ + getAgentVersion(): string | undefined { + return this.agentVersion; + } + + /** v20:被控端构建信息(指纹/提交/构建时间)。 */ + getAgentBuild(): { hash?: string; commit?: string; built_at?: string } | undefined { + return this.agentBuild; + } + /** 底层请求(带超时与 id 匹配)。 */ private request(method: string, params: unknown, timeoutMs?: number): Promise { const ws = this.ws; diff --git a/packages/protocol/src/capabilities.ts b/packages/protocol/src/capabilities.ts index 50d31d4..f9edd19 100644 --- a/packages/protocol/src/capabilities.ts +++ b/packages/protocol/src/capabilities.ts @@ -10,3 +10,24 @@ import type { CapabilityDescriptor } from './messages.js'; export function findCapability(name: string): CapabilityDescriptor | undefined { return MANIFEST.find((c) => c.name === name); } + +/** + * 比对「本地声明的能力」与「远端实际提供的能力」(v20)。 + * + * 用途:新旧版本混用时给出**可执行的提示**,而不是等到调用时抛 + * `E_CAPABILITY_NOT_FOUND` 让人摸不着头脑。 + * - `missingOnRemote`:本地有、远端没有 → **远端较旧**,需要更新被控端 + * - `unknownLocally`:远端有、本地没有 → **本控制端较旧**,可正常用(远端是加法演进) + */ +export function capabilityDiff( + localNames: string[], + remoteNames: string[], +): { missingOnRemote: string[]; unknownLocally: string[] } { + const local = new Set(localNames); + const remote = new Set(remoteNames); + return { + missingOnRemote: localNames.filter((n) => !remote.has(n)), + unknownLocally: remoteNames.filter((n) => !local.has(n)), + }; +} + diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 211b3a9..e5a82d6 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -57,6 +57,12 @@ export interface AuthOkParams { authorized?: string[]; /** 本次连接采用的认证模式 */ auth_mode?: 'psk' | 'ed25519'; + /** v20:被控端语义化版本(新增字段,旧控制端忽略即可 → 向后兼容) */ + agent_version?: string; + /** v20:被控端构建信息(指纹/提交/构建时间),供控制端做版本比对与提示 */ + build?: { hash?: string; commit?: string; built_at?: string }; + /** v20:被控端支持的协议版本(与 HelloParams.protocol 比对) */ + protocol?: string; } // ---------- 调用 ---------- diff --git a/scripts/control.cmd b/scripts/control.cmd new file mode 100644 index 0000000..836d66d --- /dev/null +++ b/scripts/control.cmd @@ -0,0 +1,83 @@ +@echo off +REM ============================================================================ +REM nodeagent - control menu (Windows) +REM +REM Double-click this file to manage an already-installed agent: +REM 1) status - task / process / port / config at a glance +REM 2) start - start the scheduled task +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 +REM Usage: control.cmd [port] +REM port defaults to 8765; pass it only if the agent uses another. +REM +REM Pure ASCII on purpose - see the note in install.cmd. The heavy lifting +REM (and all Chinese output) lives in control.ps1, which carries a UTF-8 BOM +REM so Windows PowerShell 5.1 parses it correctly. +REM ============================================================================ +setlocal EnableExtensions + +set "HERE=%~dp0" +set "HELPER=%HERE%control.ps1" +set "PORT=%~1" +if not defined PORT set "PORT=8765" + +if not exist "%HELPER%" ( + echo [x] control.ps1 not found next to this file. + echo Folder: "%HERE%" + if not defined CI pause + exit /b 1 +) + +:MENU +cls +echo ========================================================== +echo nodeagent control console +echo ========================================================== +echo 1) Status +echo 2) Start agent +echo 3) Stop agent +echo 4) Restart agent +echo 5) Show recent audit log +echo 6) Uninstall ^(keep config^) +echo 7) Uninstall and purge config +echo Q) Quit +echo ========================================================== +echo. + +set "CHOICE=" +REM CHOICE stays empty. Combined with +REM CI (see below) that exits instead of looping forever on piped/empty stdin. +set /p "CHOICE=Select: " + +if not defined CHOICE ( + if defined CI goto :QUIT + echo. + echo (no input - exiting) + goto :QUIT +) + +if /i "%CHOICE%"=="Q" goto :QUIT +if "%CHOICE%"=="1" call :RUN status +if "%CHOICE%"=="2" call :RUN start +if "%CHOICE%"=="3" call :RUN stop +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 +goto :MENU + +:QUIT +endlocal +exit /b 0 + +:RUN +REM %1 = subcommand forwarded to control.ps1 +powershell -NoProfile -ExecutionPolicy Bypass -File "%HELPER%" -Action %1 -Port %PORT% +echo. +if not defined CI pause +goto :MENU +REM unreachable; keeps `call :RUN` from falling through if the goto is removed +exit /b 0 diff --git a/scripts/control.ps1 b/scripts/control.ps1 new file mode 100644 index 0000000..68eb408 --- /dev/null +++ b/scripts/control.ps1 @@ -0,0 +1,237 @@ +<# +.SYNOPSIS + nodeagent - control operations for an installed Windows agent. + +.DESCRIPTION + Backs control.cmd. Everything Chinese-facing lives here (rather than in + the .cmd) because CMD's code page cannot round-trip UTF-8, while this + file is saved as UTF-8 **with BOM** so Windows PowerShell 5.1 reads it + correctly. PowerShell 5.1 parses BOM-less .ps1 as ANSI/GBK and the + Chinese comments then break string boundaries -> bogus syntax errors. + +.PARAMETER Action + status | start | stop | restart | logs | uninstall | uninstall-purge + +.EXAMPLE + .\control.ps1 -Action status +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidateSet('status', 'start', 'stop', 'restart', 'logs', 'uninstall', 'uninstall-purge')] + [string]$Action, + [int]$Port = 8765 +) + +$ErrorActionPreference = "Stop" + +function Write-Step($m) { Write-Host "[*] $m" -ForegroundColor Cyan } +function Write-Ok($m) { Write-Host "[+] $m" -ForegroundColor Green } +function Write-Warn($m) { Write-Host "[!] $m" -ForegroundColor Yellow } +function Write-Err($m) { Write-Host "[x] $m" -ForegroundColor Red } + +$taskName = "nodeagent" + +# 数据目录:必须与 agent 侧一致 —— apps/agent/src/config.ts 的 agentDir() 是 +# process.env.NODEAGENT_HOME ?? join(homedir(), '.nodeagent') +# 计划任务里若设了 NODEAGENT_HOME,只查 %USERPROFILE%\.nodeagent 会读错配置。 +$cfgDir = if ($env:NODEAGENT_HOME) { $env:NODEAGENT_HOME } else { Join-Path $env:USERPROFILE ".nodeagent" } +$cfgPath = Join-Path $cfgDir "agent.json" +$auditLog = Join-Path $cfgDir "audit.log" + +function Test-Admin { + ([Security.Principal.WindowsPrincipal] ` + [Security.Principal.WindowsIdentity]::GetCurrent() + ).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +function Get-TaskState { + try { (Get-ScheduledTask -TaskName $taskName -ErrorAction Stop).State.ToString() } + catch { "NotInstalled" } +} + +# ── status ──────────────────────────────────────────────────────────────────── +function Show-Status { + Write-Host "" + Write-Host " nodeagent 状态" -ForegroundColor White + Write-Host " --------------" -ForegroundColor DarkGray + + # 计划任务 + $state = Get-TaskState + if ($state -eq "NotInstalled") { + Write-Warn "计划任务 '$taskName' 未安装(请先双击 install.cmd)" + } else { + $color = if ($state -eq "Running") { "Green" } else { "Yellow" } + Write-Host " 计划任务 : " -NoNewline; Write-Host $state -ForegroundColor $color + $info = Get-ScheduledTaskInfo -TaskName $taskName -ErrorAction SilentlyContinue + if ($info) { + Write-Host " 上次运行 : $($info.LastRunTime) 结果码 $($info.LastTaskResult)" + } + } + + # 进程 + $procs = @(Get-Process node -ErrorAction SilentlyContinue) + if ($procs.Count -gt 0) { + Write-Host " 进程 : " -NoNewline + Write-Host "$($procs.Count) 个 node 进程 (PID $($procs.Id -join ', '))" -ForegroundColor Green + } else { + Write-Host " 进程 : " -NoNewline; Write-Host "无" -ForegroundColor DarkGray + } + + # 端口 + $listen = @(Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue) + if ($listen.Count -gt 0) { + Write-Host " 端口 : " -NoNewline + Write-Host "$Port 正在监听 (PID $($listen[0].OwningProcess))" -ForegroundColor Green + } else { + Write-Host " 端口 : " -NoNewline + Write-Host "$Port 未监听" -ForegroundColor Yellow + } + + # 配置 + if (Test-Path $cfgPath) { + try { + $cfg = Get-Content $cfgPath -Raw | ConvertFrom-Json + Write-Host " 节点 ID : $($cfg.node_id)" + Write-Host " 鉴权模式 : $($cfg.auth_mode)" + $inp = if ($cfg.allow_input) { "已启用" } else { "已禁用" } + Write-Host " 输入控制 : $inp" + $scheme = if ($cfg.tls) { "wss" } else { "ws" } + Write-Host " 协议 : $scheme://0.0.0.0:$Port" + } catch { + Write-Warn "配置文件解析失败: $cfgPath" + } + } else { + Write-Warn "未找到配置文件: $cfgPath" + } + + # 本机 IP + $ips = @() + try { + $ips = @(Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue | + Where-Object { $_.IPAddress -notlike '127.*' -and $_.PrefixOrigin -ne 'WellKnown' } | + Select-Object -ExpandProperty IPAddress) + } catch { } + Write-Host " 本机 IP : $($ips -join ', ')" + Write-Host "" + + if ($state -eq "NotInstalled") { + Write-Warn "尚未安装 —— 请双击 install.cmd 一键部署" + } elseif ($listen.Count -eq 0) { + Write-Warn "端口未监听 —— 试试菜单里的「重启」,或双击 install.cmd 重新部署" + } +} + +# ── start / stop / restart ──────────────────────────────────────────────────── +function Start-Agent { + if ((Get-TaskState) -eq "NotInstalled") { + Write-Err "计划任务未安装,请先双击 install.cmd" + return + } + Write-Step "启动计划任务..." + Start-ScheduledTask -TaskName $taskName + # 轮询端口真正起来(最多 15s),而不是盲等 + for ($i = 0; $i -lt 15; $i++) { + Start-Sleep -Seconds 1 + if (Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue) { + Write-Ok "agent 已启动并在 $Port 监听" + return + } + } + Write-Warn "任务已触发,但 $Port 仍未监听" + $info = Get-ScheduledTaskInfo -TaskName $taskName -ErrorAction SilentlyContinue + if ($info) { Write-Warn "上次运行结果码: $($info.LastTaskResult)(0 = 成功,267009 = 任务已在运行)" } +} + +function Stop-Agent { + if ((Get-TaskState) -eq "NotInstalled") { + Write-Err "计划任务未安装" + return + } + Write-Step "停止计划任务..." + Stop-ScheduledTask -TaskName $taskName + Start-Sleep -Seconds 1 + # 兜底:计划任务偶尔留孤儿子进程 + $left = @(Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue) + if ($left.Count -gt 0) { + Write-Warn "端口 $Port 仍被占用 (PID $($left[0].OwningProcess)),可能有残留进程" + Write-Warn "可手动结束: Stop-Process -Id $($left[0].OwningProcess) -Force" + } else { + Write-Ok "agent 已停止" + } +} + +# ── logs ────────────────────────────────────────────────────────────────────── +function Show-Logs { + if (-not (Test-Path $auditLog)) { + Write-Warn "未找到审计日志: $auditLog" + Write-Warn "(审计日志需在 agent 配置中开启 audit.enabled)" + return + } + Write-Step "审计日志末尾 40 条: $auditLog" + Write-Host "" + # 只取尾部若干行,不把整个日志(默认上限 10MB x 5 份)读进内存。 + # -Encoding UTF8 在 5.1 下能正确识别 UTF-8;解析前仍剥一次 BOM。 + $lines = @(Get-Content -LiteralPath $auditLog -Tail 40 -Encoding UTF8 -ErrorAction SilentlyContinue | + Where-Object { $_.Trim() }) + if ($lines.Count -eq 0) { Write-Warn "日志为空"; return } + + foreach ($line in $lines) { + if ($line[0] -eq [char]0xFEFF) { $line = $line.Substring(1) } + try { + $e = $line | ConvertFrom-Json + # ts 是 Unix 毫秒(见 apps/agent/src/audit.ts 的 AuditEntry) + $ts = "?" + if ($e.ts) { + try { $ts = ([DateTimeOffset]::FromUnixTimeMilliseconds([int64]$e.ts)).LocalDateTime.ToString("MM-dd HH:mm:ss") } + catch { $ts = "$($e.ts)" } + } + $ty = if ($e.type) { $e.type } else { "?" } + $ci = if ($e.client_id) { $e.client_id } else { "-" } + $extra = "" + if ($e.capability) { $extra += " $($e.capability)" } + if ($e.status) { $extra += " [$($e.status)]" } + if ($e.duration_ms) { $extra += " $($e.duration_ms)ms" } + if ($e.error) { $extra += " ERROR=$($e.error)" } + if ($e.reason) { $extra += " reason=$($e.reason)" } + Write-Host (" {0,-15} {1,-20} {2,-14}{3}" -f $ts, $ty, $ci, $extra) -ForegroundColor DarkGray + } catch { + Write-Host " $line" -ForegroundColor DarkGray + } + } + Write-Host "" +} + +# ── 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 "移除防火墙规则..." + Get-NetFirewallRule -DisplayName "nodeagent (TCP $Port)" -ErrorAction SilentlyContinue | + Remove-NetFirewallRule -ErrorAction SilentlyContinue + + if ($Purge) { + Write-Step "清除配置目录..." + Remove-Item -Recurse -Force $cfgDir -ErrorAction SilentlyContinue + Write-Ok "已彻底卸载(含配置与审计日志)" + } else { + Write-Ok "已卸载(配置保留在 $cfgDir)" + } +} + +# ── dispatch ────────────────────────────────────────────────────────────────── +switch ($Action) { + 'status' { Show-Status } + 'start' { Start-Agent } + 'stop' { Stop-Agent } + 'restart' { Stop-Agent; Start-Sleep -Seconds 1; Start-Agent } + 'logs' { Show-Logs } + 'uninstall' { Uninstall-Agent -Purge $false } + 'uninstall-purge' { Uninstall-Agent -Purge $true } +} diff --git a/scripts/install.cmd b/scripts/install.cmd new file mode 100644 index 0000000..5726255 --- /dev/null +++ b/scripts/install.cmd @@ -0,0 +1,110 @@ +@echo off +REM ============================================================================ +REM nodeagent - one-click installer launcher (Windows) +REM +REM Double-click this file. It will: +REM 1) self-elevate to Administrator (UAC prompt) if not already elevated +REM 2) make sure a pre-shared key exists in PSK.txt (generate one if absent) +REM 3) run install.ps1, which registers the scheduled task, opens the +REM firewall and starts the agent +REM +REM Usage: install.cmd [port] +REM port defaults to 8765. Pass a port only when the default is taken. +REM +REM IMPORTANT - why this file is pure ASCII: +REM The CMD.exe code page (936/GBK on Chinese Windows) can NOT round-trip +REM UTF-8 Chinese reliably. Any non-ASCII byte here risks mojibake or a +REM broken command. All Chinese-facing text is delegated to install.ps1, +REM which is saved as UTF-8 **with BOM** so Windows PowerShell 5.1 reads +REM it correctly. Keep this launcher ASCII-only. +REM +REM NOTE - install.ps1 MUST keep its UTF-8 BOM. PowerShell 5.1 parses +REM BOM-less .ps1 as ANSI/GBK, and the Chinese comments then break the +REM string boundaries -> a cascade of bogus syntax errors. +REM ============================================================================ +setlocal EnableExtensions + +set "HERE=%~dp0" +set "PS1=%HERE%install.ps1" +set "PSKFILE=%HERE%PSK.txt" +set "PORT=%~1" +if not defined PORT set "PORT=8765" + +if not exist "%PS1%" ( + echo [x] install.ps1 not found next to this file. + echo Folder: "%HERE%" + echo Keep install.ps1, node.exe, agent.mjs and this launcher together. + if not defined CI pause + exit /b 1 +) + +REM --- 1) elevate to Administrator ------------------------------------------- +REM fltmc is used instead of `net session` because the latter depends on the +REM Server service, which is disabled on some builds - that would make an +REM already-elevated shell look unelevated and trigger a pointless UAC loop. +fltmc >nul 2>&1 +if errorlevel 1 ( + echo [*] Requesting Administrator rights... + echo A new window will open. Continue there; this one closes by itself. + REM Start-Process re-launches this .cmd elevated. %PORT% (already resolved to + REM its default) is forwarded so "install.cmd 18770" survives the elevation. + powershell -NoProfile -ExecutionPolicy Bypass -Command ^ + "Start-Process -FilePath '%~f0' -ArgumentList '%PORT%' -Verb RunAs" >nul 2>&1 + if errorlevel 1 ( + echo [x] Elevation was declined - cannot register a scheduled task. + if not defined CI pause + exit /b 1 + ) + exit /b 0 +) + +REM --- 2) make sure the pre-shared key exists --------------------------------- +set "PSK=" +if exist "%PSKFILE%" ( + REM Read + Trim in one shot: a stray CR from CRLF or an editor-added trailing + REM space would otherwise end up inside -Key and break authentication. + for /f "delims=" %%K in ('powershell -NoProfile -ExecutionPolicy Bypass -Command ^ + "if (Test-Path -LiteralPath '%PSKFILE%') { (Get-Content -LiteralPath '%PSKFILE%' -Raw).Trim() }"') do set "PSK=%%K" +) + +if not defined PSK ( + echo [*] No usable PSK.txt found - generating a new pre-shared key... + REM 32 random bytes via .NET, hex-encoded to 64 chars. + for /f "delims=" %%K in ('powershell -NoProfile -ExecutionPolicy Bypass -Command ^ + "$b=New-Object byte[] 32; [System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($b); ($b ^| ForEach-Object { $_.ToString('x2') }) -join ''"') do set "PSK=%%K" + + if not defined PSK ( + echo [x] Failed to generate a key. Aborting. + if not defined CI pause + exit /b 1 + ) + REM Plain hex, no BOM: the file is read with `set /p` / Get-Content, and a + REM leading U+FEFF would corrupt the first byte of the key. + >"%PSKFILE%" echo %PSK% + echo [+] New key written to "%PSKFILE%" +) else ( + echo [+] Using key from PSK.txt +) + +REM --- 3) install ------------------------------------------------------------- +echo. +echo [*] Installing nodeagent ^(node: %COMPUTERNAME%, port %PORT%, input control ON^)... +echo. + +powershell -NoProfile -ExecutionPolicy Bypass -File "%PS1%" -NodeId "%COMPUTERNAME%" -Key "%PSK%" -AllowInput -Port %PORT% +set "RC=%ERRORLEVEL%" + +echo. +if "%RC%"=="0" ( + echo [+] Done. The agent now starts automatically at every logon. + echo To manage it later, double-click control.cmd in this folder. +) else ( + echo [x] install.ps1 exited with code %RC%. + echo If the error mentions a missing file, re-extract the whole package. + echo If Windows Firewall or a security suite blocked something, allow it and retry. +) +echo. +REM Keep the window open so the user can read the output. CI sets CI=true, +REM where `pause` would hang forever waiting for a keypress. +if not defined CI pause +exit /b %RC% diff --git a/scripts/install.ps1 b/scripts/install.ps1 index ae7fe07..421d516 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -2,6 +2,12 @@ .SYNOPSIS nodeagent - Windows agent installer. + ⚠️ 本文件必须以 **UTF-8 BOM** 保存(文件头那个看不见的 U+FEFF 就是 BOM)。 + Windows PowerShell 5.1 读**无 BOM** 的 .ps1 会按系统 ANSI(中文 Windows = GBK) + 解析,本文件里的中文注释会解成乱码并被误认为引号 → 整份脚本 ParseError + (报错位置彼此无关,真正的病灶在靠前处)。真机踩过:2026-10-04。 + 另:第 4 步写 agent.json 时反而必须**无 BOM**(BOM 对 .ps1 必需、对 JSON 有害)。 + .DESCRIPTION Registers the nodeagent agent as a scheduled task (auto-start on logon), opens the inbound firewall port, and prints connection info for the Mac side. @@ -97,7 +103,11 @@ Write-Ok "Node.js v$ver" Write-Ok "Agent entry: $agentJs" # 4. Config + pre-shared key -$cfgDir = Join-Path $env:USERPROFILE ".nodeagent" +# 数据目录必须与 agent 侧一致 —— apps/agent/src/config.ts 的 agentDir() 是 +# process.env.NODEAGENT_HOME ?? join(homedir(), '.nodeagent') +# 若计划任务环境里设了 NODEAGENT_HOME 而这里写死 USERPROFILE\.nodeagent, +# 就会「配置写A、读取B」,表现为装完连不上(E_AUTH_FAILED)。 +$cfgDir = if ($env:NODEAGENT_HOME) { $env:NODEAGENT_HOME } else { Join-Path $env:USERPROFILE ".nodeagent" } $cfgPath = Join-Path $cfgDir "agent.json" New-Item -ItemType Directory -Force -Path $cfgDir | Out-Null diff --git a/scripts/pack.mjs b/scripts/pack.mjs index 5a26676..cc7ad97 100644 --- a/scripts/pack.mjs +++ b/scripts/pack.mjs @@ -7,12 +7,20 @@ * 产物: /nodeagent-win-x64.zip * ├── node.exe Node.js 运行时(免装 Node.js) * ├── agent.mjs esbuild 打包的被控端(含全部依赖) - * ├── install.ps1 一键安装 - * └── uninstall.ps1 一键卸载 + * ├── install.cmd 一键安装(双击即用,自提权 + 自动生成 PSK) + * ├── control.cmd 控制台菜单(启动/停止/状态/日志/卸载) + * ├── install.ps1 底层安装脚本(由 install.cmd 调用) + * ├── control.ps1 底层控制脚本(由 control.cmd 调用) + * ├── uninstall.ps1 底层卸载脚本 + * └── PSK.txt 预共享密钥(首次运行时由 install.cmd 生成) */ import { execFileSync, execSync } from 'node:child_process'; -import { copyFileSync, createWriteStream, existsSync, mkdirSync, rmSync } from 'node:fs'; +import { + copyFileSync, createWriteStream, existsSync, mkdirSync, + readFileSync, rmSync, writeFileSync, +} from 'node:fs'; import { get } from 'node:https'; +import { randomBytes } from 'node:crypto'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -62,6 +70,18 @@ async function main() { execSync('pnpm -r build', { cwd: root, stdio: 'inherit' }); log('\n=== ② esbuild 打包被控端 ==='); + // 单一版本源:根 package.json 的 version + git sha + 构建时间, + // 经 esbuild --define 注入,避免「版本号写在多处必然不同步」。 + const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); + const commit = (() => { + try { + return execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: root }).toString().trim(); + } catch { + return 'unknown'; + } + })(); + const builtAt = new Date().toISOString(); + log(` 版本 ${pkg.version} @ ${commit}(${builtAt})`); mkdirSync(OUT_DIR, { recursive: true }); const bundleOut = join(OUT_DIR, 'agent.mjs'); const esbuild = join(root, 'node_modules', '.bin', 'esbuild'); @@ -75,6 +95,9 @@ async function main() { '--target=node22', '--format=esm', '--banner:js=import { createRequire } from "node:module"; const require = createRequire(import.meta.url);', + `--define:__AGENT_VERSION__='"${pkg.version}"'`, + `--define:__BUILD_COMMIT__='"${commit}"'`, + `--define:__BUILD_TIME__='"${builtAt}"'`, `--outfile=${bundleOut}`, ], { stdio: 'inherit' }, @@ -94,9 +117,76 @@ async function main() { execSync(`unzip -o -j "${tmpZip}" "*/node.exe" -d "${PKG_DIR}"`, { stdio: 'inherit' }); copyFileSync(bundleOut, join(PKG_DIR, 'agent.mjs')); copyFileSync(join(root, 'scripts', 'install.ps1'), join(PKG_DIR, 'install.ps1')); + copyFileSync(join(root, 'scripts', 'control.ps1'), join(PKG_DIR, 'control.ps1')); copyFileSync(join(root, 'scripts', 'uninstall.ps1'), join(PKG_DIR, 'uninstall.ps1')); + + // 一键入口:.cmd 必须**纯 ASCII**。CMD 的代码页(中文 Windows = 936/GBK) + // 无法可靠往返 UTF-8,中文提示一律交给带 BOM 的 .ps1 去输出。 + for (const cmd of ['install.cmd', 'control.cmd']) { + copyFileSync(join(root, 'scripts', cmd), join(PKG_DIR, cmd)); + } + + // 防呆 A:产物里的 .ps1 必须带 UTF-8 BOM,否则被控端的 **Windows PowerShell 5.1** + // 会按 ANSI/GBK 解析无 BOM 脚本,中文注释破坏字符串边界 → 整份脚本 ParseError。 + // 真机踩过(2026-10-04):CI 全绿(冒烟用 pwsh/PS7),5.1 真机直接报 + // 「","后面缺少表达式」。此处宁可在打包阶段失败,也不要把坏包发出去。 + for (const ps1 of ['install.ps1', 'control.ps1', 'uninstall.ps1']) { + const p = join(PKG_DIR, ps1); + const head = readFileSync(p).subarray(0, 3); + if (!(head[0] === 0xef && head[1] === 0xbb && head[2] === 0xbf)) { + fail(`${ps1} 缺少 UTF-8 BOM —— Windows PowerShell 5.1 会解析失败。` + + `请先给 scripts/${ps1} 加上 BOM 再打包。`); + } + } + log(' .ps1 BOM 检查通过(兼容 Windows PowerShell 5.1)'); + + // 防呆 B:.cmd 里出现非 ASCII 字节 = 中文乱码/命令截断的隐患。 + for (const cmd of ['install.cmd', 'control.cmd']) { + const buf = readFileSync(join(PKG_DIR, cmd)); + const bad = [...buf].findIndex((b) => b > 0x7f); + if (bad >= 0) { + fail(`${cmd} 含非 ASCII 字节(偏移 ${bad})—— CMD 代码页无法可靠处理,` + + `请把该文件改为纯 ASCII,中文提示交给 .ps1。`); + } + } + log(' .cmd 纯 ASCII 检查通过(CMD 代码页安全)'); + + // PSK.txt:预置一把随机密钥,让 install.cmd 真正「零输入」。 + // 用 --psk 传入指定密钥;不传则随机生成 32 字节 hex。 + // 文件是纯 hex 文本,无需 BOM(install.cmd 用 `set /p` 读)。 + const pskArg = getArg('--psk', ''); + const psk = pskArg || randomBytes(32).toString('hex'); + if (!/^[0-9a-fA-F]{32,128}$/.test(psk)) { + fail(`--psk 格式非法(需 32-128 位十六进制): ${psk}`); + } + writeFileSync(join(PKG_DIR, 'PSK.txt'), `${psk}\n`, 'utf8'); + log(` PSK.txt 已生成(${psk.length} 位${pskArg ? ',来自 --psk' : ',随机'})`); + + // README:给第一次上手的人看一眼该双击哪个文件 + writeFileSync(join(PKG_DIR, 'README.txt'), [ + 'nodeagent - Windows 被控端', + '==========================', + '', + '【怎么装】双击 install.cmd', + ' 1) 会弹一次「用户账户控制」→ 点「是」(必须管理员,装计划任务要用)', + ' 2) 等它跑完,最后会打印连接信息', + ' 3) 把那行 nodeagent connect ... 拿到 Mac 上执行', + '', + '【日常管理】双击 control.cmd', + ' 1 状态 / 2 启动 / 3 停止 / 4 重启 / 5 日志 / 6 卸载 / 7 彻底卸载', + '', + '【密钥】PSK.txt 里是预共享密钥(= Mac 端 --key 的值)', + ' 丢了可以删掉 PSK.txt 重装,会自动生成新的', + '', + '【注意】', + ' * 整个文件夹可以固定放在 D:\\nodeagent,但**装完不要移动或改名**', + ' * agent 每次开机登录后自动启动,无需再双击任何东西', + ' * 首次运行若被杀软拦截,把本文件夹加入信任区后重试', + '', + ].join('\n'), 'utf8'); + const files = execSync(`ls -la "${PKG_DIR}"`, { encoding: 'utf8' }); - log(files.split('\n').slice(0, 8).join('\n')); + log(files.split('\n').slice(0, 12).join('\n')); log('\n=== ⑤ 打 zip ==='); const zipOut = join(OUT_DIR, 'nodeagent-win-x64.zip'); @@ -105,7 +195,8 @@ async function main() { const size = execSync(`du -sh "${zipOut}"`, { encoding: 'utf8' }).split('\t')[0]; log(`\n✓ 完成: ${zipOut}(${size.trim()})`); - log(' 分发方式:解压后右键以管理员运行 install.ps1 即可'); + log(' 分发方式:解压到固定目录 → 双击 install.cmd(自提权,零命令行知识)'); + log(' 日后管理:双击 control.cmd'); } try { diff --git a/scripts/release.mjs b/scripts/release.mjs new file mode 100644 index 0000000..100620a --- /dev/null +++ b/scripts/release.mjs @@ -0,0 +1,225 @@ +#!/usr/bin/env node +/** + * 发版脚本:一条命令完成「改版本 → 构建 → 打包 → 生成校验和与清单 → 提交打标签 → 发布 GitHub Release」。 + * + * 用法: + * node scripts/release.mjs 1.0.0 # 指定版本 + * node scripts/release.mjs patch|minor|major # 按当前版本自增 + * node scripts/release.mjs 1.0.0 --dry-run # 只演练:构建+生成资产,不改 git、不发布 + * node scripts/release.mjs 1.0.0 --no-publish # 提交打标签,但不发 GitHub Release + * + * 为什么要有它(对齐皇上的要求「每次更新,GitHub 上也要同步资产」): + * 手工发版要记住「改几处 package.json、打哪个包、算哪些哈希、传什么资产」—— + * 必然漏项(尤其是给人用的 zip 与给自更新用的 agent.mjs 是**两个不同的东西**)。 + * 本脚本把两者都产出,并把 sha256 写进 latest.json,供被控端 `system.agent.update` 直接使用。 + */ +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const argv = process.argv.slice(2); +const flags = new Set(argv.filter((a) => a.startsWith('--'))); +const arg = argv.find((a) => !a.startsWith('--')); + +const dryRun = flags.has('--dry-run'); +const publish = !flags.has('--no-publish'); + +const sh = (cmd, args, opts = {}) => + execFileSync(cmd, args, { cwd: root, encoding: 'utf8', ...opts }).toString().trim(); + +function fail(msg) { + console.error(`✗ ${msg}`); + process.exit(1); +} + +function bump(cur, kind) { + const [a, b, c] = cur.split('.').map((n) => Number.parseInt(n, 10)); + if ([a, b, c].some((n) => Number.isNaN(n))) fail(`现有版本号无法解析: ${cur}`); + if (kind === 'patch') return `${a}.${b}.${c + 1}`; + if (kind === 'minor') return `${a}.${b + 1}.0`; + if (kind === 'major') return `${a + 1}.0.0`; + return null; +} + +const pkgPath = join(root, 'package.json'); +const cur = JSON.parse(readFileSync(pkgPath, 'utf8')).version; +const version = ['patch', 'minor', 'major'].includes(arg) ? bump(cur, arg) : (arg ?? ''); +if (!version) fail('用法: node scripts/release.mjs <1.2.3|patch|minor|major> [--dry-run] [--no-publish]'); +if (!/^\d+\.\d+\.\d+$/.test(version)) fail(`版本号格式应为 x.y.z,收到 ${version}`); + +console.log(`=== nodeagent 发版 v${version}(当前 ${cur})${dryRun ? ' [dry-run]' : ''} ===`); + +// ---------- ① 前置检查 ---------- +if (!dryRun) { + // 刻意排除 .github/:其改动需要 workflow 权限才能推送, + // 若把它算进「干净检查」会让发版永远卡住(见 docs/VERSIONING.md §6) + const dirty = sh('git', ['status', '--porcelain', '--', '.', ':(exclude).github']) + .split('\n') + .filter(Boolean) + .join('\n'); + if (dirty) fail(`工作区不干净(已忽略 .github/),请先提交:\n${dirty}`); + const branch = sh('git', ['rev-parse', '--abbrev-ref', 'HEAD']); + if (branch !== 'main') fail(`当前分支是 ${branch},发版请在 main 上`); + sh('git', ['fetch', 'origin', 'main']); + if (sh('git', ['rev-parse', 'HEAD']) !== sh('git', ['rev-parse', 'origin/main'])) { + fail('本地 main 与远端不一致,请先 pull/push(避免发版后才发现分叉)'); + } +} + +const remote = sh('git', ['remote', 'get-url', 'origin']) + .replace(/^git@github\.com:/, 'https://github.com/') + .replace(/\.git$/, ''); +const repoPath = remote.replace(/^https:\/\/github\.com\//, ''); +if (!/^[\w.-]+\/[\w.-]+$/.test(repoPath)) fail(`无法从 remote 推断 GitHub 仓库: ${remote}`); + +// ---------- ② 改版本(单一版本源:根 + 各 workspace)---------- +const pkgFiles = ['package.json']; +for (const f of sh('git', ['ls-files', '*package.json']).split('\n').filter(Boolean)) { + if (f === 'package.json' || f.includes('node_modules')) continue; + pkgFiles.push(f); +} +if (dryRun) { + // dry-run 绝不改文件 —— 演练就该是无副作用的 + console.log(`(dry-run)将把 ${pkgFiles.length} 个 package.json 的 version 改为 ${version}`); + console.log('(dry-run)将执行:构建 → pack:win → pack:mac → 生成资产 → 提交打标签 → gh release create'); + process.exit(0); +} +for (const f of pkgFiles) { + const p = join(root, f); + const j = JSON.parse(readFileSync(p, 'utf8')); + if (!j.version) continue; + j.version = version; + writeFileSync(p, JSON.stringify(j, null, 2) + '\n'); +} +console.log(`✓ 版本号已写入 ${pkgFiles.length} 个 package.json`); + +// ---------- ③ 构建 + 打包 ---------- +console.log('=== 构建 ==='); +sh('pnpm', ['-r', 'build'], { stdio: 'inherit' }); +console.log('=== 打包 ==='); +sh('pnpm', ['pack:win'], { stdio: 'inherit' }); +try { + sh('pnpm', ['pack:mac'], { stdio: 'inherit' }); +} catch { + console.warn('(pack:mac 跳过:非 macOS 或未安装依赖)'); +} + +const agentPath = join(root, 'release', 'nodeagent-win-x64', 'agent.mjs'); +const zipPath = join(root, 'release', 'nodeagent-win-x64.zip'); +if (!existsSync(agentPath)) fail(`找不到 agent 产物: ${agentPath}`); +if (!existsSync(zipPath)) fail(`找不到 zip 产物: ${zipPath}`); + +// ---------- ④ 生成资产(校验和 + latest.json)---------- +const sha256 = (p) => createHash('sha256').update(readFileSync(p)).digest('hex'); +const distDir = join(root, 'release', 'dist'); +mkdirSync(distDir, { recursive: true }); + +const assets = [ + { name: 'agent.mjs', path: agentPath, purpose: '自更新载荷(被控端 system.agent.update 直接使用)' }, + { name: 'nodeagent-win-x64.zip', path: zipPath, purpose: '人工安装包(自包含 node.exe,免装 Node.js)' }, +]; +for (const a of assets) { + copyFileSync(a.path, join(distDir, a.name)); + a.bytes = readFileSync(a.path).length; + a.sha256 = sha256(a.path); +} + +const commit = sh('git', ['rev-parse', '--short', 'HEAD']); +const builtAt = new Date().toISOString(); +// 能力数从清单动态取,避免写死后漂移 +let capabilityCount = 0; +try { + const mod = await import(join(root, 'packages', 'protocol', 'dist', 'index.js')); + capabilityCount = mod.CAPABILITY_MANIFEST?.length ?? 0; +} catch { + console.warn('(未能从 dist 读取能力数,latest.json 里记为 0)'); +} +const base = `https://github.com/${repoPath}/releases/download/v${version}`; +const latest = { + version, + commit, + built_at: builtAt, + protocol: '1.0', + capabilities: capabilityCount, + agent: { + url: `${base}/agent.mjs`, + sha256: assets[0].sha256, + bytes: assets[0].bytes, + note: '自更新用:nodeagent update --url --sha256 ', + }, + zip: { url: `${base}/nodeagent-win-x64.zip`, sha256: assets[1].sha256, bytes: assets[1].bytes }, +}; +writeFileSync(join(distDir, 'latest.json'), JSON.stringify(latest, null, 2) + '\n'); + +const sums = assets.map((a) => `${a.sha256} ${a.name}`).join('\n') + `\n`; +writeFileSync(join(distDir, 'SHA256SUMS'), sums); + +console.log('=== 资产 ==='); +for (const a of assets) { + console.log(` ${a.name.padEnd(26)} ${(a.bytes / 1024 / 1024).toFixed(1)}MB sha256=${a.sha256.slice(0, 12)}…`); +} +console.log(` latest.json / SHA256SUMS`); + +// ---------- ⑤ 提交 + 标签 + 推送 ---------- +sh('git', ['add', '-A', '--', '.', ':(exclude).github']); +sh('git', ['commit', '-m', `chore(release): v${version}`]); +sh('git', ['tag', '-a', `v${version}`, '-m', `nodeagent v${version}`]); +sh('git', ['push', 'origin', 'main']); +sh('git', ['push', 'origin', `v${version}`]); +console.log(`✓ 已提交并推送标签 v${version}`); + +// ---------- ⑥ 发布 GitHub Release ---------- +if (!publish) { + console.log('(--no-publish:已跳过 GitHub Release)'); + process.exit(0); +} +const notes = join(distDir, 'RELEASE_NOTES.md'); +writeFileSync( + notes, + [ + `# nodeagent v${version}`, + ``, + `- 构建: \`${commit}\` @ ${builtAt}`, + `- 能力: ${latest.capabilities} 项`, + ``, + `## 资产`, + ...assets.map((a) => `- \`${a.name}\` — ${a.purpose}`), + `- \`latest.json\` — 版本清单(含 sha256),供被控端自更新校验`, + `- \`SHA256SUMS\` — 校验和`, + ``, + `## 更新被控端(拉取式,只需它能上网)`, + '```bash', + `nodeagent --node <设备名> update \\`, + ` --url ${latest.agent.url} \\`, + ` --sha256 ${latest.agent.sha256}`, + '```', + ``, + `## 人工安装(首次)`, + `\`\`\`powershell`, + `Expand-Archive nodeagent-win-x64.zip C:\\nodeagent -Force`, + `# 先把 C:\\nodeagent 加入杀软信任区(主流杀软/杀软)`, + `powershell -ExecutionPolicy Bypass -File C:\\nodeagent\\install.ps1 -NodeId win -AllowInput`, + '```', + ].join('\n') + '\n', +); + +sh('gh', [ + 'release', + 'create', + `v${version}`, + '--title', + `nodeagent v${version}`, + '--notes-file', + notes, + join(distDir, 'agent.mjs'), + join(distDir, 'nodeagent-win-x64.zip'), + join(distDir, 'latest.json'), + join(distDir, 'SHA256SUMS'), +]); +console.log(`✓ GitHub Release v${version} 已发布`); +console.log(''); +console.log('被控端更新命令:'); +console.log(` nodeagent --node <设备名> update --url ${latest.agent.url} --sha256 ${latest.agent.sha256}`); diff --git a/tests/unit/capabilities.test.mjs b/tests/unit/capabilities.test.mjs index 23ef27e..63c24f9 100644 --- a/tests/unit/capabilities.test.mjs +++ b/tests/unit/capabilities.test.mjs @@ -1,3 +1,4 @@ +import { capabilityDiff } from '../../packages/protocol/dist/index.js'; import { test } from 'node:test'; import assert from 'node:assert/strict'; import { @@ -229,3 +230,27 @@ test('applyDefaults:新能力默认值正确填充', () => { assert.equal(exec.async, false); assert.equal(exec.timeout_ms, 30000); }); + +// ---------- v20 兼容性:能力差集 ---------- +test('capabilityDiff:识别「远端较旧」与「本控制端较旧」', () => { + const local = ['system.info', 'system.audio.get', 'system.agent.update']; + const remoteOld = ['system.info']; + const d1 = capabilityDiff(local, remoteOld); + assert.deepEqual(d1.missingOnRemote, ['system.audio.get', 'system.agent.update'], '远端缺的新能力'); + assert.deepEqual(d1.unknownLocally, []); + + const remoteNew = ['system.info', 'system.audio.get', 'system.agent.update', 'future.thing']; + const d2 = capabilityDiff(local, remoteNew); + assert.deepEqual(d2.missingOnRemote, [], '远端不缺'); + assert.deepEqual(d2.unknownLocally, ['future.thing'], '本控制端不认识的新能力'); + + const same = capabilityDiff(local, local); + assert.deepEqual(same, { missingOnRemote: [], unknownLocally: [] }, '完全一致应无差异'); +}); + +test('capabilityDiff:真实清单与自身比对应无差异(防手滑改坏清单)', () => { + const names = CAPABILITY_MANIFEST.map((c) => c.name); + const d = capabilityDiff(names, names); + assert.equal(d.missingOnRemote.length, 0); + assert.equal(d.unknownLocally.length, 0); +}); diff --git a/tests/unit/ps1-encoding.test.mjs b/tests/unit/ps1-encoding.test.mjs new file mode 100644 index 0000000..65a5189 --- /dev/null +++ b/tests/unit/ps1-encoding.test.mjs @@ -0,0 +1,212 @@ +/** + * 编码守卫:仓库里的手写 .ps1 **必须带 UTF-8 BOM**。 + * + * ⚠️ 背景(真机踩过,2026-10-04):`install.ps1` 一度是无 BOM 的 UTF-8, + * 在被控端用 **Windows PowerShell 5.1** 执行时直接解析失败: + * 所在位置 D:\Nodeagent\install.ps1:31 字符: 31 + * + [string]$ProjectRoot = "", + * “,”后面缺少表达式。 + * 原因:5.1 读**无 BOM** 的 .ps1 会按系统 ANSI(中文 Windows = GBK)解码, + * 脚本里的中文注释/字符串被解成乱码,其中某些字节序列被误认成引号, + * 于是整份脚本的字符串边界全乱 —— 报出的一串语法错误彼此无关, + * 真正的病灶在文件靠前的位置。 + * + * **CI 之前全绿是因为冒烟步骤用 `shell: pwsh`(PowerShell 7,认无 BOM UTF-8)**, + * 只有真机的 5.1 会炸。故此用例 + CI 的 5.1 解析冒烟一起守。 + * + * 对照:运行时**动态生成**的 .ps1 走 `apps/agent/src/capabilities/network.ts` + * 的 `withBom()`,已有同样约束。 + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; + +const ROOT = new URL('../../', import.meta.url).pathname; +const PS_DIR = join(ROOT, 'scripts'); + +/** 收集仓库里所有需要分发的 .ps1(含子目录) */ +function collectPs1(dir) { + return collectByExt(dir, '.ps1'); +} + +/** 收集仓库里所有 .cmd(双击入口) */ +function collectCmd(dir) { + return collectByExt(dir, '.cmd'); +} + +function collectByExt(dir, ext) { + const out = []; + for (const name of readdirSync(dir, { withFileTypes: true })) { + if (name.name === 'node_modules' || name.name.startsWith('.')) continue; + const full = join(dir, name.name); + if (name.isDirectory()) out.push(...collectByExt(full, ext)); + else if (name.name.endsWith(ext)) out.push(full); + } + return out; +} + +const PS1_FILES = collectPs1(PS_DIR); + +test('仓库中至少存在 3 个待分发的 .ps1(防止 glob 失效导致守卫空转)', () => { + assert.ok( + PS1_FILES.length >= 3, + `只找到 ${PS1_FILES.length} 个 .ps1,守卫可能已失效(目录:${PS_DIR})`, + ); +}); + +for (const file of PS1_FILES) { + const rel = file.slice(ROOT.length); + + test(`${rel}:以 UTF-8 BOM 开头(PowerShell 5.1 兼容底线)`, () => { + const buf = readFileSync(file); + const hasBom = buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf; + assert.ok( + hasBom, + `${rel} 缺少 UTF-8 BOM。5.1 会按 ANSI/GBK 解析无 BOM 脚本,` + + `中文注释将破坏字符串边界并导致 ParseError。` + + `修法:把 U+FEFF 写进文件头(BOM 对 .ps1 是必需,对 JSON 则相反)。`, + ); + }); + + test(`${rel}:内容是合法 UTF-8(无 GBK 混入的二次编码痕迹)`, () => { + const text = readFileSync(file, 'utf8'); + // U+FFFD = 解码失败替换符;出现即说明混入过非 UTF-8 字节 + assert.ok(!text.includes('\uFFFD'), `${rel} 含非法 UTF-8 字节`); + // 去掉 BOM 再校验,避免把 BOM 当成正文 + assert.ok(text.replace(/^\uFEFF/, '').length > 0, `${rel} 去掉 BOM 后为空文件`); + }); +} + +test('install.ps1:关键参数与提示文案完好(防打包时被截断/改写)', () => { + const text = readFileSync(join(PS_DIR, 'install.ps1'), 'utf8'); + for (const token of [ + 'param(', + '$AllowInput', + 'New-ScheduledTask', + 'New-NetFirewallRule', + 'UTF8Encoding($false)', // 写 agent.json 必须无 BOM —— 与 .ps1 自身要 BOM 恰好相反 + ]) { + assert.ok(text.includes(token), `install.ps1 丢失关键片段:${token}`); + } +}); + +// ── .cmd 侧守卫 ─────────────────────────────────────────────────────────────── +// 编码方向与 .ps1 **相反**:.cmd 必须纯 ASCII。 +// CMD.exe 的代码页(中文 Windows = 936/GBK)无法可靠往返 UTF-8, +// .cmd 里的中文会变乱码,甚至把后续命令截断(真机 2026-10-04 的教训同源)。 +// 中文提示一律交给带 BOM 的 .ps1 输出。 + +const CMD_FILES = collectCmd(PS_DIR); + +test('仓库中至少存在 2 个 .cmd(防止 glob 失效导致守卫空转)', () => { + assert.ok(CMD_FILES.length >= 2, `只找到 ${CMD_FILES.length} 个 .cmd,守卫可能已失效`); +}); + +for (const file of CMD_FILES) { + const rel = file.slice(ROOT.length); + test(`${rel}:纯 ASCII(CMD 代码页安全)`, () => { + const buf = readFileSync(file); + const idx = [...buf].findIndex((b) => b > 0x7f); + assert.equal( + idx, -1, + `${rel} 第 ${idx} 字节起含非 ASCII —— CMD 代码页(936/GBK)无法可靠处理,` + + `中文提示请交给 .ps1(它带 BOM,5.1 能正确读)。`, + ); + }); + + test(`${rel}:行尾是 CRLF(CMD 在部分环境下对 LF 行为不一致)`, () => { + const text = readFileSync(file, 'latin1'); + // 允许文件内完全没有换行(极短脚本),但只要有换行就必须是 CRLF + const lfOnly = text.replace(/\r\n/g, '').includes('\n'); + assert.equal(lfOnly, false, `${rel} 存在裸 LF 行尾,请统一为 CRLF`); + }); +} + +test('install.cmd:自提权 + 读 PSK.txt + 调 install.ps1 三段齐全', () => { + const text = readFileSync(join(PS_DIR, 'install.cmd'), 'latin1'); + for (const token of [ + 'net session', // 探测是否已管理员 + 'Start-Process', // 自提权(UAC) + 'PSK.txt', // 读包内密钥 + 'install.ps1', // 调底层安装 + '-ExecutionPolicy Bypass', + '-AllowInput', + ]) { + assert.ok(text.includes(token), `install.cmd 丢失关键片段:${token}`); + } + // 提权后必须 exit,不能继续用非管理员权限往下跑 + assert.ok(/Verb RunAs[\s\S]{0,200}exit \/b/.test(text), '提权后应立即退出本进程'); +}); + +test('control.cmd:菜单项与 control.ps1 的 ValidateSet 一一对应', () => { + const cmd = readFileSync(join(PS_DIR, 'control.cmd'), 'latin1'); + const ps1 = readFileSync(join(PS_DIR, 'control.ps1'), 'utf8'); + + // 从 control.ps1 的 ValidateSet 里取出合法 action + const m = ps1.match(/ValidateSet\(([^)]*)\)/); + assert.ok(m, 'control.ps1 必须用 ValidateSet 约束 -Action'); + const actions = m[1].split(',').map((s) => s.trim().replace(/^'|'$/g, '')).filter(Boolean); + + // control.cmd 里 call :RUN 的每个 action 都必须在 ValidateSet 内 + const called = [...cmd.matchAll(/call :RUN (\S+)/g)].map((x) => x[1]); + assert.ok(called.length >= 5, `control.cmd 只发现 ${called.length} 个菜单项,可能正则失配`); + for (const a of called) { + assert.ok(actions.includes(a), `control.cmd 调用了 control.ps1 不支持的 action: ${a}`); + } + // 反向:ps1 支持的 action 都应在菜单里可达 + for (const a of actions) { + assert.ok(called.includes(a), `control.ps1 支持的 ${a} 在 control.cmd 菜单里没有入口`); + } +}); + +test('control.ps1:状态检查覆盖任务/进程/端口/配置四项', () => { + const text = readFileSync(join(PS_DIR, 'control.ps1'), 'utf8'); + for (const token of [ + 'Get-ScheduledTask', + 'Get-Process', + 'Get-NetTCPConnection', + 'agent.json', + ]) { + assert.ok(text.includes(token), `control.ps1 状态检查缺少:${token}`); + } + // 启动后必须轮询端口,而不是盲等固定秒数 + assert.ok( + /for \(\$i = 0; \$i -lt 15/.test(text), + '启动后应轮询端口真正监听(最多 15s),而非盲等', + ); +}); + +test('数据目录:install.ps1 与 control.ps1 都认 NODEAGENT_HOME(须与 agent 侧一致)', () => { + // apps/agent/src/config.ts: agentDir() = NODEAGENT_HOME ?? ~/.nodeagent + // 若 ps1 写死 USERPROFILE 而计划任务环境设了 NODEAGENT_HOME, + // 就会「配置写 A、读取 B」→ 装完连不上(E_AUTH_FAILED)。 + for (const f of ['install.ps1', 'control.ps1']) { + const text = readFileSync(join(PS_DIR, f), 'utf8'); + assert.ok( + text.includes('NODEAGENT_HOME'), + `${f} 未处理 NODEAGENT_HOME —— 必须与 apps/agent/src/config.ts 的 agentDir() 保持一致`, + ); + } +}); + +test('控制台脚本:pause 均被 CI 守卫(否则 CI 会永久挂起)', () => { + for (const f of ['install.cmd', 'control.cmd']) { + const text = readFileSync(join(PS_DIR, f), 'latin1'); + const bare = text.split('\r\n').filter((l) => /^\s*pause\s*$/i.test(l)); + assert.equal( + bare.length, 0, + `${f} 有 ${bare.length} 处裸 pause,CI(CI=true)会卡死。统一用 "if not defined CI pause"。`, + ); + } +}); + +test('install.cmd:管理员探测用 fltmc 而非 net session(不依赖 Server 服务)', () => { + const text = readFileSync(join(PS_DIR, 'install.cmd'), 'latin1'); + // net session 依赖 Server 服务,部分 Windows 版本该服务停用 → 已提权却被判为未提权 → 死循环 UAC + assert.ok(/^\s*fltmc\s*>nul/m.test(text), 'install.cmd 应用 fltmc 探测管理员权限'); + assert.ok( + !/^\s*net\s+session\s*>nul/m.test(text), + 'install.cmd 不应再用 net session 探测(依赖 Server 服务,可能误判)', + ); +});