From 2dccec9a5c41f4b6980e80a268d7697088624d53 Mon Sep 17 00:00:00 2001 From: jkooe <62494645+jkooe@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:47:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(D):=20=E7=94=9F=E4=BA=A7=E5=8C=96=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E6=97=A0=E4=BA=BA=E5=80=BC=E5=AE=88?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=20/=20=E4=B8=80=E9=94=AE=E5=8D=87=E7=BA=A7?= =?UTF-8?q?=20/=20=E6=96=87=E6=A1=A3=E5=8F=A3=E5=BE=84=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 全绿 --- README.md | 81 +++++++++++++++++++++++---- apps/agent/src/capabilities/system.ts | 5 ++ apps/cli/src/index.ts | 76 +++++++++++++++++++++++++ packages/protocol/src/capabilities.ts | 4 ++ scripts/install.ps1 | 55 +++++++++++++++--- 5 files changed, 203 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 958be67..20b9293 100644 --- a/README.md +++ b/README.md @@ -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 ] -nodeagent keygen [--id mac_01] # 生成 Ed25519 密钥 + ACL 配置片段 +nodeagent audit verify # 校验审计链完整性(防篡改检测) +nodeagent keygen [--id mac_01] # 生成 Ed25519 密钥 + ACL 配置片段(私钥入系统钥匙串) +``` + +### 自持与运维 + +```bash +nodeagent restart [--delay 2000] # 受控重启被控端(配置变更后让它生效) +nodeagent deploy [--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 [--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 { 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) { diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 4c6323b..e060532 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -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 [--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 { ); } +/** + * v11 / D2:一键部署 / 升级被控端 agent。 + * 流程:被控端自报入口路径(system.info)→ 上传新 agent.mjs → 受控重启 → 复验能力数。 + * 全程无需手工拷文件或起计划任务。 + */ +async function cmdDeploy(localFile: string, opts: Options): Promise { + 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>(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 { // 图片上传:从本地文件读入(避免超长命令行参数) 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 { await cmdFanout(cap, opts); return; } + case 'deploy': { + const f = positionals[0]; + if (!f) fail('用法: nodeagent deploy [--path <远端路径>]'); + await cmdDeploy(f, opts); + return; + } case 'install': { const pkg = positionals[0]; if (!pkg) fail('用法: nodeagent install <包名|ID>'); diff --git a/packages/protocol/src/capabilities.ts b/packages/protocol/src/capabilities.ts index ce62c1d..e38233d 100644 --- a/packages/protocol/src/capabilities.ts +++ b/packages/protocol/src/capabilities.ts @@ -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)' }, }, }, }, diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 4f7cdb2..084be69 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -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..."