feat(D): 生产化 —— 无人值守模式 / 一键升级 / 文档口径更新

## D1 无人值守(install.ps1)
- 新增 -Unattended(S4U 登录类型):**无需保存密码,注销/重启后仍自动运行**
- 新增 -AtStartup:追加开机触发器;任务设置补 StartWhenAvailable(错过触发后补跑)
- **诚实标注能力边界**:S4U 运行在非交互会话,GUI 能力(截屏/UIA/键鼠)不可用;
  -Unattended 与 -AllowInput 冲突时自动关闭输入控制并回写配置
- 说明文档给出两种模式的对照表(交互模式支持 GUI,但注销即停)

## D2 一键升级(nodeagent deploy)
- system.info 新增自描述字段:pid / agent_script / agent_home / node_path
- deploy 流程:读取被控端入口路径 → 备份旧文件 → 分块上传新 agent.mjs
  → 调 system.agent.restart 受控重启 → 等待恢复并复验能力数
- 真机验证:
  * 首次(旧版 agent 无自描述):--path 指定 → 上传 853KB → PID 16492 / 31 项能力
  * 二次:自动识别路径 + 自动备份 → PID 34120 / 31 项能力(免 --path)

## D3 CI(待推送)
- windows-e2e.yml 增加单元测试步骤(3 行);因 OAuth token 缺 workflow scope,
  该改动暂留本地未推送,需用户授权后推送

## D4 文档口径更新(README)
- 能力总览补齐 31 项(新增:自持/异步任务/窗口/录屏/剪贴板/拖拽/审计校验)
- 统计数字更正:19 项能力 -> 31,15 个 MCP 工具 -> 26,补 61 项单元测试
- 路线图补 v7~v11+;安全章节补密钥库/Audit 链/ACL 细化
- 命令速查新增:restart / deploy / daemon / fanout / bg / tasks / clip / record /
  window.* / screen.find / mouse drag
- MCP 工具表按新工具重排;安装章节新增两种运行模式对照表

单测 61/61、e2e 21/21 全绿
This commit is contained in:
jkooe committed 2026-09-29 11:47:15 +08:00
1 parent ad88e0bb87
commit 2dccec9a5c
5 files changed
+203 -18

No files matched your search

+71 -10
View File
@@ -20,13 +20,20 @@ nodeagent 把一台 Windows 机器的能力**标准化成一组可授权、可
| 分组 | 能力 | 说明 |
|---|---|---|
| **系统** | `system.info` · `system.status` · `system.process.list` · `system.service.list` · `system.shell.exec` · `system.audit.list` | 信息 / 资源 / 进程 / 服务 / 命令 / 审计 |
| **系统** | `system.info` · `system.status` · `system.process.list` · `system.service.list` · `system.shell.exec` · `system.audit.list` · `system.audit.verify` | 信息 / 资源 / 进程 / 服务 / 命令 / 审计 / **审计防篡改校验** |
| **自持** | `system.agent.restart` | **受控重启自身**(配置变更后让它生效,不会失联) |
| **异步任务** | `system.task.list` · `system.task.get` · `system.task.kill` | 长命令后台执行 + 增量续读 + 终止(`exec` 加 `async:true`) |
| **软件** | `app.list` · `app.install` | 已装软件(注册表 + winget)、winget 静默安装 |
| **屏幕** | `screen.info` · `screen.capture` | 显示器信息、截屏(支持区域与缩放) |
| **屏幕** | `screen.info` · `screen.capture` · `screen.record` · `screen.find` | 显示器 / 截屏 / **录屏为帧序列** / **按名字找元素取坐标** |
| **窗口** | `window.list` · `window.focus` | 枚举可见窗口(精确矩形)/ 置前聚焦 |
| **文件** | `fs.list` · `fs.stat` · `fs.read` · `fs.write` | 列目录 / 元信息 / 分块读 / 原子写 |
| **输入** | `input.mouse.move` · `input.mouse.click` · `input.mouse.scroll` · `input.key.type` · `input.key.press` | 键鼠控制(🔒 **默认禁用**) |
| **输入** | `input.mouse.move` · `input.mouse.click` · `input.mouse.scroll` · `input.mouse.drag` · `input.key.type` · `input.key.press` | 键鼠控制(🔒 **默认禁用**),含**拖拽**与**按键序列/连按** |
| **剪贴板** | `clip.get` · `clip.set` | 读写文本**或图片**(PNG Base64) |
**19 项能力** · **15 个 MCP 工具** · **21 项端到端用例**(CI 在真实 Windows 上验证)
**31 项能力** · **26 个 MCP 工具** · **61 项单元测试** · **21 项端到端用例**(CI 在真实 Windows 上验证)
> 关键里程碑:**GUI 语义**(`window.list` + `screen.find`,UIA 找不到自动降级 OCR)
> 让 AI 从「看得到画面但读不懂界面」变成「按名字取坐标点下去」。
## 快速开始
@@ -58,6 +65,16 @@ pnpm build
安装脚本会使用**包内自带的 Node.js 运行时**,自动:生成预共享密钥 → 写配置 → 放行防火墙 → 注册开机自启 → 启动 Agent,并打印**密钥**与**连接命令**。
**两种运行模式(重要)**
| 模式 | 命令 | 登录/注销 | 能力范围 |
|---|---|---|---|
| 交互(默认) | `install.ps1` | 登录时启动;**注销即停** | 全部能力,**含 GUI**(截屏 / UIA 找元素 / 键鼠注入) |
| 无人值守 | `install.ps1 -Unattended -AtStartup` | **注销、重启后仍运行**(S4U,无需存密码) | 无 GUI —— 截屏/输入类能力不可用(进程在非交互会话,没有桌面) |
> 这是 Windows 的固有限制:**图形操作必须有交互桌面**。需要 GUI 就保持登录(可锁屏);
> 只需跑命令/传文件/查状态这类无人值守场景,用 `-Unattended` 更稳。
**方式 B:开发模式(本仓库)**
```powershell
@@ -175,6 +192,10 @@ node apps/cli/dist/index.js connect 192.168.1.100 --port 8765 --auth-mode ed2551
### 其他安全默认
- 输入控制(键鼠)默认禁用,需被控端显式 `"allow_input": true`
- 私钥不落明文:macOS 存 Keychain、Windows 存 DPAPI(老版本明文密钥首次加载自动迁移)
- 审计日志**链式哈希**(`prev`/`hash`),`nodeagent audit verify` 可检出篡改与删除
- ACL 支持 IP 白/黑名单(CIDR)、生效时段(含跨零点)、**按能力限速**(精确 > glob > 全局)
- `input.*`(键鼠控制)**默认禁用**,需被控端 `"allow_input": true` 或 `install.ps1 -AllowInput`
- 键盘文本经 Base64 传参、PowerShell 侧解码,**杜绝内容注入**;按键名走白名单映射
- 文件能力支持 `fs_roots` 路径白名单,越界返回 `E_ACL_DENIED`
@@ -222,7 +243,35 @@ nodeagent push <本地文件> <远端路径> [--create-dirs] # 上传(自动
```bash
nodeagent audit [--limit 20] [--type invoke|auth|acl|agent] [--client-id X] [--since <ms>]
nodeagent keygen [--id mac_01] # 生成 Ed25519 密钥 + ACL 配置片段
nodeagent audit verify # 校验审计链完整性(防篡改检测)
nodeagent keygen [--id mac_01] # 生成 Ed25519 密钥 + ACL 配置片段(私钥入系统钥匙串)
```
### 自持与运维
```bash
nodeagent restart [--delay 2000] # 受控重启被控端(配置变更后让它生效)
nodeagent deploy <agent.mjs> [--path <远端路径>] # 一键升级(备份 → 上传 → 重启 → 复验)
nodeagent daemon [start|stop|status] # 常驻连接池(批量操作省去每次握手)
nodeagent fanout <能力名> [--nodes a,b] [--args JSON] # 多设备并发下发并汇总
```
### 长任务与剪贴板
```bash
nodeagent bg "<命令>" [--timeout-ms N] # 后台执行,立即返回 task_id
nodeagent tasks | nodeagent task <id> [--offset N] [--kill] # 列出 / 增量读 / 终止
nodeagent clip [--set "文本"] [--out <路径>] [--image-file <路径>] # 剪贴板文本或图片
nodeagent record [--duration 5000] [--fps 2] [--region x,y,w,h] # 录屏为帧序列
```
### GUI 语义(找到元素再点,不猜坐标)
```bash
nodeagent invoke window.list --args '{"title_pattern":"记事本"}'
nodeagent invoke window.focus --args '{"title":"记事本"}'
nodeagent invoke screen.find --args '{"text":"一键加速","method":"auto"}' # UIA → OCR 兜底
nodeagent mouse drag 300 200 700 500 # 拖拽(拖文件/框选)
```
## MCP 工具
@@ -230,11 +279,15 @@ nodeagent keygen [--id mac_01] # 生成 Ed25519 密钥 + AC
| 工具 | 说明 |
|---|---|
| `na_system_info` · `na_status` · `na_process_list` · `na_service_list` | 系统信息与状态 |
| `na_exec` · `na_install` · `na_app_list` | 执行命令、装软件、列软件 |
| `na_screenshot` | 截屏,**直接返回图片**给 AI 看 |
| `na_mouse` · `na_key` | 键鼠控制(需被控端开启) |
| `na_fs_list` · `na_fs_read` · `na_fs_write` | 读目录 / 读文件 / 写文件 |
| `na_audit` · `na_discover` | 查审计、发现设备 |
| `na_exec` · `na_bg` · `na_task` | 执行命令、后台长任务(启动/续读/终止) |
| `na_install` · `na_app_list` | 装软件、列软件 |
| `na_screenshot` · `na_record` | 截屏(直接返回图片给 AI 看)、录屏为帧序列 |
| `na_window_list` · `na_window_focus` · `na_screen_find` | **GUI 语义**:列窗口 / 聚焦 / 按名字取坐标(UIA+OCR) |
| `na_mouse` · `na_key` | 键鼠控制(含拖拽、按键序列;需被控端开启) |
| `na_clip` | 读写剪贴板文本或图片 |
| `na_fs_list` · `na_fs_read` · `na_fs_write` · `na_fs_stat` | 读目录 / 读文件 / 写文件 / 元信息 |
| `na_restart` | 受控重启被控端 |
| `na_audit` · `na_discover` · `na_nodes` · `na_use` | 查审计、发现设备、多设备切换 |
## Hub 中转(跨网段 / 公网)
@@ -264,6 +317,7 @@ node apps/cli/dist/index.js connect hub.example.com --port 443 --hub-token <Hub
```bash
pnpm build # 构建全部包
pnpm typecheck # 类型检查
pnpm test:unit # 单元测试(61 项:协议纯函数 / 清单守护 / 审计链 / ACL v11)
pnpm test:e2e # 端到端测试(21 项,含 TLS / 零信任 / 发现 / 文件 / Hub)
pnpm test:windows # Windows 专属能力(服务 / 软件 / winget 真实装软件)
pnpm verify # 对已配置的被控端跑全套验收并输出报告
@@ -319,6 +373,13 @@ nodeagent/
| **v4** | 无感体验:局域网自动发现 + 断线自动重连 | ✅ |
| **v5** | 多设备管理 + 文件传输(分块 / 大文件 / 原子写) | ✅ |
| **v6** | Hub 中转:跨网段 / 公网接入(被控端主动外连,穿 NAT) | ✅ |
| **v7** | **自持**:受控重启(配置变更后让自身生效,不再丢连接) | ✅ |
| **v8** | **GUI 语义**:`window.list` / `window.focus` / `screen.find`(UIA 按名取坐标) | ✅ |
| **v9** | `screen.find` **OCR 兜底**(自绘 UI 也能定位)+ MCP 工具同步 | ✅ |
| **v10** | **长任务异步化**(`system.task.*`)+ 剪贴板 + CLI 常驻连接池 daemon | ✅ |
| **v11** | **安全加固**:私钥入系统密钥库(Keychain/DPAPI)+ 审计链防篡改 + ACL 细化(IP/时段/按能力限速) | ✅ |
| **v11+** | **操作面补完**:鼠标拖拽 / 录屏 / 剪贴板图片 / 多设备并发 / 一键升级 | ✅ |
| **待办** | 事件订阅(push)、v7~v11 能力入 CI、macOS 端 GUI 能力对齐 | 🚧 |
## 文档
+5
View File
@@ -54,6 +54,11 @@ export async function systemInfo(args: Args): Promise<unknown> {
memory_total: os.totalmem(),
uptime_sec: Math.round(os.uptime()),
is_admin: await isAdmin(),
// v11:自描述信息,供控制端「一键部署/升级」定位目标文件与数据目录
pid: process.pid,
agent_script: process.argv[1] ?? '',
agent_home: process.env['NODEAGENT_HOME'] ?? '',
node_path: process.execPath,
};
if (fields && fields.length > 0) {
+76
View File
@@ -68,6 +68,8 @@ const HELP = `nodeagent —— 跨机 AI 接管框架(控制端 CLI)
读/写剪贴板文本或图片(--out 保存图片)(v11)
nodeagent record [--duration 5000] [--fps 2] [--scale 0.5] [--region x,y,w,h]
录屏为帧序列(有 ffmpeg 则封装 mp4)(v11)
nodeagent deploy <agent.mjs> [--path <远端路径>]
一键升级被控端(备份 → 上传 → 重启 → 复验)(v11)
nodeagent fanout <能力名> [--nodes a,b] [--args JSON]
多设备并发调用并汇总 (v11)
nodeagent daemon [start|stop|status] 常驻连接池(批量操作提速 5~10 倍)(v10)
@@ -142,6 +144,8 @@ interface Options {
fps?: string;
/** v11 多设备并发 */
nodes?: string;
/** v11 部署目标路径 */
path?: string;
}
function getClientConfig(): ClientConfig {
@@ -702,6 +706,70 @@ async function cmdRecord(opts: Options): Promise<void> {
);
}
/**
* v11 / D2:一键部署 / 升级被控端 agent。
* 流程:被控端自报入口路径(system.info)→ 上传新 agent.mjs → 受控重启 → 复验能力数。
* 全程无需手工拷文件或起计划任务。
*/
async function cmdDeploy(localFile: string, opts: Options): Promise<void> {
if (!existsSync(localFile)) fail(`本地文件不存在: ${localFile}`);
const buf = readFileSync(localFile);
if (buf.length < 10_000) fail(`文件过小,疑似不是 agent 包: ${localFile}`);
await withClient(async (c) => {
const info = await c.invoke<Record<string, unknown>>(CapabilityNames.SystemInfo, {});
if (info.status === 'failed') fail('无法读取被控端信息');
const d = info.data as {
agent_script?: string;
pid?: number;
node_path?: string;
agent_home?: string;
};
const target = opts.path ?? d.agent_script;
if (!target) fail('被控端未上报入口路径,请用 --path <远端路径> 指定');
console.log(`目标: ${target}(当前 PID ${d.pid ?? '?'})`);
// 备份现有文件(便于回滚)
const backup = `${target}.bak-${Date.now()}`;
if (d.node_path) {
const cp = await c.invoke(CapabilityNames.ShellExec, {
command: `Copy-Item '${target}' '${backup}' -Force -ErrorAction SilentlyContinue; Write-Output 'ok'`,
timeout_ms: 15_000,
});
if (cp.status === 'ok') console.log(`已备份: ${backup}`);
}
// 上传(fs.write 分块;由 agent 内部处理,无需本地→远端路径映射)
const CHUNK = 512 * 1024;
let offset = 0;
while (offset < buf.length) {
const part = buf.subarray(offset, Math.min(offset + CHUNK, buf.length));
const w = await c.invoke(CapabilityNames.FsWrite, {
path: target,
data: part.toString('base64'),
encoding: 'base64',
append: offset > 0,
});
if (w.status === 'failed') fail(`上传失败于 offset ${offset}: ${w.error?.message}`);
offset += part.length;
}
console.log(`✓ 已上传 ${(buf.length / 1024).toFixed(0)} KB`);
// 受控重启(复用 v7 能力,无需外部计划任务)
const r = await c.invoke(CapabilityNames.AgentRestart, { delay_ms: 1500, reason: 'deploy' });
if (r.status === 'failed') fail(`重启失败: ${r.error?.message}`);
console.log('✓ 已触发重启,等待恢复…');
});
// 等待新版本上线并复验
await new Promise((t) => setTimeout(t, 9000));
await withClient(async (c) => {
const info = await c.invoke<{ capabilities?: unknown[] }>(CapabilityNames.SystemInfo, {});
const caps = c.listCapabilities();
console.log(`✓ 新版本已上线:PID ${(info.data as { pid?: number })?.pid ?? '?'},能力 ${caps.length} 项`);
});
}
async function cmdClip(opts: Options): Promise<void> {
// 图片上传:从本地文件读入(避免超长命令行参数)
if (opts.imageFile) {
@@ -1204,6 +1272,7 @@ function parseOptions(rest: string[]): { opts: Options; positionals: string[] }
'image-file': { type: 'string' },
fps: { type: 'string' },
nodes: { type: 'string' },
path: { type: 'string' },
},
allowPositionals: true,
strict: false,
@@ -1239,6 +1308,7 @@ function parseOptions(rest: string[]): { opts: Options; positionals: string[] }
imageFile: values['image-file'] as string | undefined,
fps: values['fps'] as string | undefined,
nodes: values['nodes'] as string | undefined,
path: values['path'] as string | undefined,
name: values['name'] as string | undefined,
note: values['note'] as string | undefined,
recursive: Boolean(values['recursive']),
@@ -1383,6 +1453,12 @@ async function main(): Promise<void> {
await cmdFanout(cap, opts);
return;
}
case 'deploy': {
const f = positionals[0];
if (!f) fail('用法: nodeagent deploy <agent.mjs 路径> [--path <远端路径>]');
await cmdDeploy(f, opts);
return;
}
case 'install': {
const pkg = positionals[0];
if (!pkg) fail('用法: nodeagent install <包名|ID>');
+4
View File
@@ -89,6 +89,10 @@ export const CAPABILITY_MANIFEST: CapabilityDescriptor[] = [
memory_total: { type: 'integer', description: '字节' },
uptime_sec: { type: 'integer' },
is_admin: { type: 'boolean' },
pid: { type: 'integer', description: '被控端进程 PID(v11)' },
agent_script: { type: 'string', description: '被控端入口脚本路径,供一键升级定位(v11)' },
agent_home: { type: 'string', description: '被控端数据目录 NODEAGENT_HOME(v11)' },
node_path: { type: 'string', description: '被控端 Node 可执行文件路径(v11)' },
},
},
},
+47 -8
View File
@@ -17,6 +17,10 @@
.EXAMPLE
.\install.ps1 -Port 8765 -NodeId my-pc
.EXAMPLE
# 无人值守:重启/注销后仍运行(无图形界面能力)
.\install.ps1 -Unattended -AtStartup -AllowInput:$false
#>
[CmdletBinding()]
param(
@@ -25,7 +29,13 @@ param(
[string]$Key = "",
[switch]$NoTls,
[switch]$AllowInput,
[string]$ProjectRoot = ""
[string]$ProjectRoot = "",
# 无人值守模式:用 S4U 登录类型注册任务 —— 无需保存密码、注销/重启后仍自动运行。
# 代价:进程运行在非交互会话(Session 0),**图形能力不可用**
# (截屏 / UIA 找元素 / 鼠标键盘注入都依赖交互桌面)。
[switch]$Unattended,
# 追加「开机即启动」触发器(不依赖用户登录)
[switch]$AtStartup
)
$ErrorActionPreference = "Stop"
@@ -129,21 +139,50 @@ New-NetFirewallRule -DisplayName $ruleName -Direction Inbound -Action Allow `
-Protocol TCP -LocalPort $Port -Profile Any | Out-Null
Write-Ok "Inbound rule allowed on TCP $Port"
# 6. Scheduled task (auto start on logon, highest privileges)
# 6. Scheduled task
Write-Step "Registering scheduled task..."
$taskName = "nodeagent"
$action = New-ScheduledTaskAction -Execute $nodeExe -Argument "`"$agentJs`"" -WorkingDirectory $ProjectRoot
$trigger = New-ScheduledTaskTrigger -AtLogOn
# 触发器:登录时(默认)+ 可选开机时;无人值守模式下开机触发器才真正有意义
$triggers = @(New-ScheduledTaskTrigger -AtLogOn)
if ($AtStartup -or $Unattended) {
$triggers += New-ScheduledTaskTrigger -AtStartup
}
# 自愈:异常退出后自动重启(最多 3 次,间隔 1 分钟),且不设执行时长上限
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
-RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero)
$principal = New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\$env:USERNAME" `
-RunLevel Highest -LogonType Interactive
-RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero) `
-StartWhenAvailable
# 登录类型决定「能干什么」:
# Interactive —— 运行在交互会话,**图形能力可用**,但注销后停
# S4U —— 无需密码、注销/重启后仍运行,但**图形能力不可用**(见 -Unattended 说明)
if ($Unattended) {
if ($AllowInput) {
Write-Warn "-Unattended 与 -AllowInput 冲突:Session 0 无法注入输入,已强制关闭输入控制"
$AllowInput = $false
# 回写配置,避免配置与运行模式不一致
$cfg = Get-Content $cfgPath -Raw -Encoding UTF8 | ConvertFrom-Json
$cfg.allow_input = $false
[System.IO.File]::WriteAllText($cfgPath, ($cfg | ConvertTo-Json), (New-Object System.Text.UTF8Encoding($false)))
}
$principal = New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\$env:USERNAME" `
-RunLevel Highest -LogonType S4U
Write-Warn "无人值守模式:agent 将在非交互会话运行"
Write-Warn " → 可用:exec / 文件传输 / 进程与服务 / 审计 / 重启 / 异步任务 / 剪贴板"
Write-Warn " → 不可用:截屏、UIA 找元素、鼠标键盘注入(需交互桌面)"
} else {
$principal = New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\$env:USERNAME" `
-RunLevel Highest -LogonType Interactive
}
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger `
Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $triggers `
-Settings $settings -Principal $principal `
-Description "nodeagent - cross-machine AI takeover agent" | Out-Null
Write-Ok "Scheduled task '$taskName' registered (auto-start on logon)"
$modeLabel = if ($Unattended) { "unattended (S4U, survives logoff)" } else { "interactive (logon)" }
Write-Ok "Scheduled task '$taskName' registered — mode: $modeLabel"
# 7. Start now
Write-Step "Starting agent..."