From 65343da3116b1145c6af0379a747178d16e7d703 Mon Sep 17 00:00:00 2001 From: yu <1919727607@qq.com> Date: Fri, 6 Nov 2026 13:39:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(contrib):=20C#=20actuate=20=E5=8E=9F?= =?UTF-8?q?=E5=9E=8B=E8=A1=A5=E4=B8=81=E5=8C=85=EF=BC=88=E5=8D=8F=E8=AE=AE?= =?UTF-8?q?=E6=89=A9=E5=B1=95+NativeInput=20=E4=BA=94=E6=8B=9B=E5=85=9C?= =?UTF-8?q?=E5=BA=95=E7=A7=BB=E6=A4=8D+ActuateService+=E7=A7=BB=E6=A4=8D?= =?UTF-8?q?=E5=AF=B9=E7=85=A7=E8=A1=A8+=E5=90=88=E5=85=A5=E6=8C=87?= =?UTF-8?q?=E5=BC=95=EF=BC=89=EF=BC=9BV2=20R3=20=E8=A1=8C=E6=A0=87?= =?UTF-8?q?=E5=AE=9A=E5=8E=9F=E5=9E=8B=E4=BA=A7=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- contrib/windows-runner-actuate/PATCH-NOTES.md | 77 +++++ contrib/windows-runner-actuate/PORTING-MAP.md | 49 +++ contrib/windows-runner-actuate/README.md | 31 ++ .../native/ActuateProtocol.cs | 57 ++++ .../native/ActuateService.cs | 278 ++++++++++++++++++ .../native/NativeInput.cs | 212 +++++++++++++ docs/rpa-core-migration-guide-v2.md | 2 +- 7 files changed, 705 insertions(+), 1 deletion(-) create mode 100644 contrib/windows-runner-actuate/PATCH-NOTES.md create mode 100644 contrib/windows-runner-actuate/PORTING-MAP.md create mode 100644 contrib/windows-runner-actuate/README.md create mode 100644 contrib/windows-runner-actuate/native/ActuateProtocol.cs create mode 100644 contrib/windows-runner-actuate/native/ActuateService.cs create mode 100644 contrib/windows-runner-actuate/native/NativeInput.cs diff --git a/contrib/windows-runner-actuate/PATCH-NOTES.md b/contrib/windows-runner-actuate/PATCH-NOTES.md new file mode 100644 index 0000000..f29e13f --- /dev/null +++ b/contrib/windows-runner-actuate/PATCH-NOTES.md @@ -0,0 +1,77 @@ +# PATCH-NOTES:合入对面 windows-runner 的集成指引 + +本目录是 **本地原型补丁包**,不改动对面仓库。合入时按以下步骤执行。 + +## 1. 文件落点 + +| 本目录文件 | 落到对面仓库 | 说明 | +|---|---|---| +| `native/ActuateProtocol.cs` | `code/backend/services/windows-runner/native/` | 新增类型,不动现有文件 | +| `native/ActuateService.cs` | 同上 | 新增类型,不动现有文件 | +| `native/NativeInput.cs` | 同上 | 新增类型;**NativeWindows.cs 保持只读边界原样不动**(对面注释明言该层无输入接口,编译边界得以保全) | + +csproj 无需改动:`UseWindowsForms` 已启用(SendKeys/Clipboard/Screen 可用),FlaUI 引用与本补丁无关。 + +## 2. Program.cs 分发改造(最小 diff) + +现有 Main 只认 `observe`。改造为按 Operation 分发: + +```csharp +// 原:if (request.ProtocolVersion != 1 || request.Operation != "observe" || ...) +// 改为先解公共头再分发(保持 64KiB/UTF-8/UUID 校验全部不变): +if (request.ProtocolVersion != 1 || !Guid.TryParse(requestId, out _)) + throw new RunnerException("request_invalid"); +switch (request.Operation) +{ + case "observe": { /* 原 observe 路径逐字保留 */ } + case "actuate": + { + var actuate = JsonSerializer.Deserialize(line, JsonOptions) + ?? throw new RunnerException("request_invalid"); + var result = ActuateService.Actuate(actuate, evidenceRoot); + Write(new RunnerResponse(1, requestId, true, null, null, result)); + return 0; + } + default: throw new RunnerException("request_invalid"); +} +``` + +RunnerResponse 需加一个可选字段(observe 的线上形状不变,`WhenWritingNull` 已保证): + +```csharp +internal sealed record RunnerResponse(int ProtocolVersion, string RequestId, bool Ok, + ComputerObservation? Data, string? ErrorCode, ActuateResult? ActuateData = null); +``` + +## 3. TS 宿主侧(native-observer.ts → actuate 适配器,属后续工作) + +- 请求侧:actuate 前先用 observe 解析**精确**窗口标题与进程名(RPA 侧"标题包含匹配"的语义在宿主层弥合,见 PORTING-MAP 第二节); +- 响应侧:对 ActuateData.Evidence 逐项复核(realpath 归属证据根目录、SHA-256、PNG 魔数——复用 native-observer.ts 现成校验); +- 高影响审批:`rpa.windows` manifest 已是 `type=highImpact + idempotencyRequired`,capability-gateway 的精确 actionId 放行与租约语义直接适用,**无需改 gateway**; +- 超时:宿主杀进程超时须覆盖 mouse_move_smooth 最坏路径(≈4s)+ 两次截图(≈2s),建议 ≥15s。 + +## 4. 验证计划(用对面现成夹具) + +- `test/fixture/`(AgentWorker.WindowsFixture):仿照 `native-observer.integration.test.ts` 增加 actuate 集成测试: + 1. `mouse_move` → GetCursorPos 断言落点; + 2. `mouse_click`(无桌面断言,仅断言 ok + 证据 SHA 与文件存在); + 3. `keyboard_send`(chars 模式)→ UIA 读回文本; + 4. `window_activate` → GetForegroundWindow 断言; + 5. 反向用例:越权窗口 → `window_not_allowed`;Session 不匹配 → `desktop_session_mismatch`;非法 Kind → `action_invalid`。 +- 兼容性矩阵(微信/Office/UWP)按对面既有测试纪律单独排期,不在本补丁范围。 + +## 5. 门禁合规清单(对面 2026-11-06 新规,硬性) + +合入提交必须**同一提交内**包含: + +1. `项目资料/开发方案/当前事实.json`:factRevision 递增,trackedInputs 如受影响同步更新; +2. 相关 README 的机器生成区块(`node code/tooling/document-facts.mjs --write` 重新生成); +3. `code/backend/README.md` Windows 行状态更新("click/type" 从"或随本轮交付"改为已实现口径); +4. 提交前本地跑:`node code/tooling/document-facts.mjs --check` && `node code/tooling/ci-gate.mjs --mode fast` && `git diff --check`。 + +## 6. 验证状态与已知限制 + +- **语法级人工审查已完成;未编译**——本机无 .NET SDK,编译验证需在装 .NET 10 SDK 后进行(`dotnet build` native/csproj),或由对面侧首次合入时跑; +- `Clipboard.SetDataObject` 依赖 STA:对面 Main 已是 `[STAThread]`,满足; +- mouse_event → SendInput 为现代等价替换(标志位映射见 PORTING-MAP 第四节);如对面评审倾向零差异,可回退为 mouse_event P/Invoke,一行替换; +- 平滑移动公式中的伪随机抖动与 JS `%` 保号语义一致(C# `%` 对 double 同为保号),无漂移。 diff --git a/contrib/windows-runner-actuate/PORTING-MAP.md b/contrib/windows-runner-actuate/PORTING-MAP.md new file mode 100644 index 0000000..17861bf --- /dev/null +++ b/contrib/windows-runner-actuate/PORTING-MAP.md @@ -0,0 +1,49 @@ +# PowerShell → C# 移植对照表(RPA 桌面驱动 → windows-runner actuate) + +来源:`packages/rpa-core/src/driver/windows/windows-driver.ts`(269 行,内嵌 PowerShell) +目标:对面 `code/backend/services/windows-runner/native/`(原型见本目录 `native/`) + +## 一、原语级映射 + +| RPA 侧 PowerShell 原语 | 用途(驱动行号) | C# 等价物(原型落点) | 语义保真要点 | +|---|---|---|---| +| `SetCursorPos(x,y)` | moveTo / click / drag / moveSmooth(L142,150,158,173) | `NativeInput.SetCursorPos` | 物理像素;进程入口声明 Per-Monitor V2 DPI(对应 PS 每脚本的 `SetProcessDpiAwarenessContext(-4)`,一次声明进程级生效) | +| `mouse_event(flags,0,0,data,0)` down/up | click / drag(L150,158) | `SendInput`(MOUSEEVENTF_LEFTDOWN=0x0002 / LEFTUP=0x0004 / RIGHTDOWN=0x0008 / RIGHTUP=0x0010) | SendInput 是 mouse_event 的现代等价;标志位一一对应。双击=repeats 2,settleMs 逐次保留 | +| `mouse_event(0x0800,...,amount)` | scroll(L161) | `SendInput` + MOUSEEVENTF_WHEEL,mouseData=有符号 delta | 负值向下滚,`unchecked((uint)amount)` 保持位型一致 | +| `GetCursorPos` + JS `mousePath` 插值 | moveSmooth(L163-174) | `ActuateService.SmoothMovePath`(公式逐行移植) | 缓入缓出二次曲线 + 垂直抖动 `sin(i*12.9898)*43758.5453 % 1`;stepCount=clamp(distance/12, 6, 50);delay=clamp(1500/speed, 4, 80)ms。JS 与 C# 的 `%` 对负数同为保号语义,公式无漂移 | +| `Set-Clipboard` + `SendWait('^v')` | keyboard clipboard 模式(L183) | `Clipboard.SetDataObject(text, true)` + `SendKeys.SendWait("^v")` | 120ms 间隙保留;STA 已由对面 `[STAThread]` 保证 | +| `SendWait($c)` 逐字符循环 | keyboard manual 模式(L188) | 逐字符 `SendKeys.SendWait(Escape(ch))` + `Thread.Sleep(intervalMs)` | intervalMs 逐字符保留 | +| `SendWait(escaped)` 整段 | keyboard 默认模式(L192) | `SendKeys.SendWait(escaped)` | `sendKeysEscape` 字符集 `+^%~(){}[]` → `{+}` 等花括号转义,逐字符同规则移植(`ActuateService.EscapeSendKeys`) | +| `activateWindowScript` **抢前台五招兜底** | focus(L73-105,键盘与激活共用) | `NativeInput.FocusChain`(逐行移植) | 顺序与睡眠严格保留:①IsIconic→SW_RESTORE(9)+150ms ②SwitchToThisWindow+150ms ③ALT(0x12 down/up)+SetForegroundWindow+200ms ④AttachThreadInput 对线程+SetForegroundWindow+200ms ⑤SW_MINIMIZE(6)+150ms→SW_RESTORE(9)+300ms ⑥仍失败→稳定错误码 `focus_failed`(PS 版走 `RPA_ERROR::` stdout 通道) | +| `Get-Process | MainWindowTitle -like *title*` | 窗口查找(L60-68 等) | `Win32 EnumWindows` + 精确标题 + PID 回查进程名 + Session 校验(复用对面 observe 的 SelectWindow 模式) | **唯一语义变更**,见下节 1 | +| `GetWindowRect` + JSON 输出 | windowObject / foregroundPosition(L66,213) | `NativeWindows.GetWindowRect`(对面已有,直接复用) | RECT right/bottom → width/height 换算保持 | +| `ShowWindow(h, 3/6/9/cmd)` | activate/maximize / setState / 五招内部(L203,224) | `NativeInput.ShowWindow` | 命令值原样透传 | +| `SetWindowPos(h,0,x,y,w,h,4)` | setSize / setPos(L227,231) | `NativeInput.SetWindowPos` | flags=4(SWP_NOZORDER)原样保留;尺寸>0 校验保留(L230) | +| `Process.CloseMainWindow()` | close(L221) | `PostMessage(h, WM_CLOSE=0x0010)` | 等价语义(礼貌关闭,不杀进程) | +| `System.Windows.Forms.Screen.PrimaryScreen.Bounds` | screen.size(L246) | 不移植——observe op 的 CoordinateSpace 已携带窗口矩形+DPI | 避免重复采集 | + +## 二、必须知道的三处语义差异(宿主适配层负责弥合) + +1. **窗口匹配从模糊变精确**。PS 版 `Get-Process -like *title*` 是包含匹配;对面安全政策要求 WindowTitle 与 AllowedWindows **精确相等**(模糊匹配会把同名网页/提示框误当目标)。弥合方式:TS 宿主适配器先用 observe 解析出精确标题与进程名,再发起 actuate 请求——RPA 能力层(`window.findByTitle`)语义不变,只是解析下沉到宿主。 +2. **进程模型从"每命令一进程"变"单请求单响应"**。PS 版每个原语起一个 powershell.exe;C# 版一次请求一次响应、宿主超时杀进程(对面现行架构)。RPA 流程里相邻原语本来也无状态依赖(除句柄传递,改由标题精确匹配承担),语义等价。注意:宿主超时必须覆盖 moveSmooth 最坏情况(约 4s)+ 两次证据截图。 +3. **副作用证据从无到有**。PS 版动作无留痕;C# 版按对面证据体系在动作**前后各截一张**目标窗口 PNG(PrintWindow,SHA-256 落盘,默认 secret)——这是对面 capability-gateway 审计链要求的,属于增强而非等价项。 + +## 三、稳定错误码映射(PS 侧 → C# 侧) + +| PS 侧失败 | C# actuate 稳定错误码 | +|---|---| +| `找不到窗口:`(throw/RPA_ERROR) | `window_not_found`(无候选)/ `window_ambiguous`(多候选,对面已有语义)/ `window_not_allowed`(不在白名单) | +| `无法把窗口带到前台…`(五招全失败) | `focus_failed` | +| 输入注入 Win32 失败 | `input_failed` | +| 证据截图失败/空白 | `capture_unavailable`(沿用对面现有码) | +| 政策校验失败 | `policy_invalid` / `desktop_session_mismatch`(沿用对面现有码) | + +## 四、开关映射:mouse_event → SendInput 标志位 + +| mouse_event flag | SendInput MOUSEEVENTF | 用途 | +|---|---|---| +| 0x0002 | LEFTDOWN | 左键按下 | +| 0x0004 | LEFTUP | 左键抬起 | +| 0x0008 | RIGHTDOWN | 右键按下 | +| 0x0010 | RIGHTUP | 右键抬起 | +| 0x0800 | WHEEL | 滚轮(data=delta,120 的倍数) | diff --git a/contrib/windows-runner-actuate/README.md b/contrib/windows-runner-actuate/README.md new file mode 100644 index 0000000..c31ff72 --- /dev/null +++ b/contrib/windows-runner-actuate/README.md @@ -0,0 +1,31 @@ +# windows-runner-actuate 原型补丁包 + +**用途**:把 RPA 桌面驱动(`packages/rpa-core/src/driver/windows/windows-driver.ts`,PowerShell 实现)的鼠标/键盘/窗口执行能力移植为对面 Agent-Employee `windows-runner` 的 C# 原生实现——即 ADR-R3(C# 路线)的第一块实体。 + +**状态**:本地原型,未合入对面主干。对面 backend/README Windows 行已明确下一步为"click/type、视觉/OCR 或随本轮客户端交付",本补丁正是其路线图的第一步。 + +## 内容清单 + +| 文件 | 内容 | +|---|---| +| `PORTING-MAP.md` | PowerShell → C# 逐原语移植对照表:三处语义差异(模糊→精确匹配、进程模型、证据增强)、错误码映射、mouse_event→SendInput 标志位映射 | +| `native/ActuateProtocol.cs` | actuate 协议扩展类型(请求/动作/结果),复用对面 DesktopPolicy/EvidenceRef/ObservedWindow | +| `native/NativeInput.cs` | 输入类 P/Invoke + **抢前台五招兜底逐行移植**(RPA 实战参数,一行语义不改) | +| `native/ActuateService.cs` | 执行服务:安全门与 observe 同构(白名单/Session/精确匹配),10 种动作闭集分发,动作前后证据截图,平滑移动公式逐行保真 | +| `PATCH-NOTES.md` | 合入指引:Program.cs 最小 diff、TS 宿主适配要点、验证计划、**门禁合规硬性清单** | + +## 设计红线(对面安全模型的尊重项) + +1. `NativeWindows.cs` 只读编译边界不动——输入类 P/Invoke 独立成 `NativeInput.cs`; +2. 安全门一个不少:进程文件名白名单正则、窗口精确匹配、OS Session 三重核验后才允许输入; +3. 副作用证据:动作前后各一张窗口 PNG(PrintWindow + SHA-256,默认 secret),接入对面证据体系; +4. 稳定错误码进 stdout,系统路径/控件文字不回显; +5. 单请求单响应、超时杀进程,遵守对面进程模型。 + +## 交接方式 + +按 `PATCH-NOTES.md` 执行:3 个新文件 + Program.cs 最小 diff + TS 适配器后续工作。合入提交必须带事实文件与文档区块更新(对面 2026-11-06 门禁),清单见 PATCH-NOTES 第 5 节。 + +## 验证状态 + +语法级人工审查完成;**未编译**(本机无 .NET SDK)。编译验证需 .NET 10 SDK(`dotnet build`),或在对面侧首次合入时执行。集成测试用对面现成 `test/fixture`,用例清单见 PATCH-NOTES 第 4 节。 diff --git a/contrib/windows-runner-actuate/native/ActuateProtocol.cs b/contrib/windows-runner-actuate/native/ActuateProtocol.cs new file mode 100644 index 0000000..944b4d7 --- /dev/null +++ b/contrib/windows-runner-actuate/native/ActuateProtocol.cs @@ -0,0 +1,57 @@ +using System.Text.Json.Serialization; + +namespace AgentWorker.WindowsRunner; + +// actuate 协议扩展提案:在不动 observe 契约的前提下增加执行类操作。 +// 设计原则与 observe 完全一致:单请求单响应、稳定错误码、宿主固定证据根目录。 +// 本文件只新增类型;Program.cs 的分发改造见 PATCH-NOTES.md(diff 指引)。 +// +// 与 observe 的关键差异(由 RPA 桌面驱动语义决定): +// 1. Action 携带具体执行参数(坐标/文本/命令),全部字段服务端可审计; +// 2. 坐标一律物理屏幕像素,与 RPA 驱动现状及 CoordinateSpace 契约一致; +// 3. WindowTitle 必须是 AllowedWindows 的精确成员(模糊匹配在 TS 宿主 observe 阶段完成解析), +// RPA 侧"标题包含匹配"的旧语义不进入原生层。 + +// 操作闭集:mouse_move / mouse_click / mouse_drag / mouse_scroll / mouse_move_smooth / +// keyboard_send / window_activate / window_set_state / window_set_bounds / window_close。 +// 与 observe 相同:模型不能直接指定 HWND;证据目录来自宿主启动参数。 +internal sealed record ActuateRequest( + int ProtocolVersion, + string RequestId, + string Operation, + string WindowTitle, + DesktopPolicy Policy, + ActuateAction Action); + +// 每个字段都可被逐项审计;不支持的 Kind 由原生层拒绝,不静默降级为观察。 +internal sealed record ActuateAction( + string Kind, + int? X, + int? Y, + int? ToX, + int? ToY, + // 滚轮 delta(120 的倍数,负值向下)或窗口 ShowWindow 命令值。 + int? Amount, + // keyboard_send 文本;上限由 64 KiB 请求预算约束。 + string? Text, + // keyboard_send 三模式:"clipboard"(粘贴)/ "chars"(逐字符)/ "keys"(整段 SendKeys)。 + string? Mode, + int? IntervalMs, + bool? Double, + // "left"(默认)或 "right"。 + string? Button, + // mouse_move_smooth:false=匀速直线,true/缺省=缓入缓出+抖动。 + bool? Ease, + // mouse_move_smooth 速度 1-100(默认 35,与 RPA 驱动一致)。 + int? Speed, + // window_set_bounds 用(x/y 复用 X/Y,Amount 不用)。 + int? Width, + int? Height, + int? HoldMs, + int? ReleaseDelayMs, + int? SettleMs); + +// 动作结果:证据前后各一张,宿主 TS 适配器按 EvidenceRef 逐项复核。 +// Data 与 ErrorCode 互斥,与 RunnerResponse 同规则。 +internal sealed record ActuateResult(string ActionKind, string CapturedAt, + ObservedWindow ObservedWindow, IReadOnlyList<EvidenceRef> Evidence, int DurationMs); diff --git a/contrib/windows-runner-actuate/native/ActuateService.cs b/contrib/windows-runner-actuate/native/ActuateService.cs new file mode 100644 index 0000000..1e2a945 --- /dev/null +++ b/contrib/windows-runner-actuate/native/ActuateService.cs @@ -0,0 +1,278 @@ +using System.Drawing; +using System.Drawing.Imaging; +using System.Globalization; +using System.Security.Cryptography; +using System.Text.RegularExpressions; +using System.ComponentModel; + +namespace AgentWorker.WindowsRunner; + +// actuate 执行服务:把 RPA 桌面驱动(PowerShell 实现)的鼠标/键盘/窗口操作 +// 移植进对面 windows-runner 协议,安全门与 observe 完全同构。 +// 移植来源:packages/rpa-core/src/driver/windows/windows-driver.ts(对照表见 PORTING-MAP.md)。 +// +// 安全边界(与 WindowObservationService 同级): +// - 进程白名单(文件名正则)+ 窗口精确匹配 + OS Session 三重核验后才允许输入; +// - 只操作本用户交互 Session 的一个顶层窗口,不扫描其它桌面; +// - 后续动作前仍重新枚举窗口,不信任上一次请求的 HWND; +// - 副作用证据:动作前后各一张目标窗口 PNG(PrintWindow + SHA-256,默认 secret); +// - 非法 Kind 拒绝执行,不静默降级为观察; +// - 稳定错误码进 stdout,系统路径与控件文字不回显。 +internal static partial class ActuateService +{ + [GeneratedRegex("^[A-Za-z0-9._-]+\\.exe$", RegexOptions.IgnoreCase)] + private static partial Regex ProcessNamePattern(); + + private const long MaxWindowPixels = 16_000_000; + private const long MaxPngBytes = 8 * 1024 * 1024; + + private sealed record WindowCandidate(nint Handle, string ProcessName, string Title); + + // DPI 感知一次性声明:与 RPA 驱动每脚本 SetProcessDpiAwarenessContext(-4) 等价, + // 保证此后 SetCursorPos/GetCursorPos 全程物理像素语义。 + internal static ActuateResult Actuate(ActuateRequest request, string evidenceRoot) + { + NativeInput.EnsureDpiAwareness(); + ValidatePolicy(request); + var selected = SelectWindow(request.WindowTitle, request.Policy); + if (!NativeWindows.GetWindowRect(selected.Handle, out var nativeRect)) + throw new RunnerException("window_unavailable"); + var bounds = new WindowRect(nativeRect.Left, nativeRect.Top, + nativeRect.Right - nativeRect.Left, nativeRect.Bottom - nativeRect.Top); + if (bounds.Width < 1 || bounds.Height < 1 || + (long)bounds.Width * bounds.Height > MaxWindowPixels) + throw new RunnerException("window_size_unsupported"); + + var startedAt = DateTimeOffset.UtcNow; + var before = CaptureWindowEvidence(selected.Handle, bounds, evidenceRoot, startedAt); + Execute(request.Action, selected); + var finishedAt = DateTimeOffset.UtcNow; + var after = CaptureWindowEvidence(selected.Handle, bounds, evidenceRoot, finishedAt); + NativeWindows.GetWindowThreadProcessId(selected.Handle, out var pid); + var observed = new ObservedWindow(selected.ProcessName, selected.Title, + $"0x{selected.Handle.ToInt64():X}", + selected.Handle == NativeWindows.GetForegroundWindow()); + return new ActuateResult(request.Action.Kind, finishedAt.ToString("O", CultureInfo.InvariantCulture), + observed, [before, after], + (int)(finishedAt - startedAt).TotalMilliseconds); + } + + // 与 observe 的 ValidatePolicy 同构:两个白名单均非空、进程名仅接受文件名、 + // WindowTitle 精确成员、OS Session 与本进程一致。actuate 不放宽任何一项。 + private static void ValidatePolicy(ActuateRequest request) + { + var policy = request.Policy; + if (policy is null || policy.AllowedProcesses is null || policy.AllowedWindows is null || + policy.AllowedProcesses.Length == 0 || policy.AllowedWindows.Length == 0 || + policy.AllowedProcesses.Any(name => !ProcessNamePattern().IsMatch(name) || name.Contains("..")) || + policy.AllowedWindows.Any(title => string.IsNullOrWhiteSpace(title) || title.Length > 256)) + throw new RunnerException("policy_invalid"); + if (string.IsNullOrWhiteSpace(request.WindowTitle) || request.WindowTitle.Length > 256 || + !policy.AllowedWindows.Contains(request.WindowTitle, StringComparer.Ordinal)) + throw new RunnerException("window_not_allowed"); + var currentSession = Process.GetCurrentProcess().SessionId; + if (currentSession == 0 || policy.DesktopSessionId != currentSession.ToString(CultureInfo.InvariantCulture)) + throw new RunnerException("desktop_session_mismatch"); + if (request.Action is null || string.IsNullOrWhiteSpace(request.Action.Kind)) + throw new RunnerException("action_invalid"); + } + + // 与 observe 的 SelectWindow 同构:枚举本 Session 顶层窗口,精确标题 + PID 回查进程名。 + // 同名同进程窗口多于一个时报 ambiguous,不任选——副作用落错对象不可接受。 + private static WindowCandidate SelectWindow(string title, DesktopPolicy policy) + { + var candidates = new List<WindowCandidate>(); + var session = Process.GetCurrentProcess().SessionId; + _ = NativeWindows.EnumWindows((handle, _) => + { + if (!NativeWindows.IsWindowVisible(handle)) return true; + if (NativeWindows.Title(handle) != title) return true; + NativeWindows.GetWindowThreadProcessId(handle, out var pid); + if (pid == 0) return true; + try + { + using var process = Process.GetProcessById(checked((int)pid)); + if (process.SessionId != session) return true; + var name = process.ProcessName + ".exe"; + if (!policy.AllowedProcesses.Contains(name, StringComparer.OrdinalIgnoreCase)) + return true; + candidates.Add(new WindowCandidate(handle, name, title)); + } + catch (ArgumentException) { /* 进程已退出;旧 HWND 不能成为目标。 */ } + catch (InvalidOperationException) { /* 进程在枚举期间退出。 */ } + catch (Win32Exception) { /* 受保护进程不能读取。 */ } + return true; + }, nint.Zero); + if (candidates.Count == 0) throw new RunnerException("window_not_found"); + if (candidates.Count != 1) throw new RunnerException("window_ambiguous"); + return candidates[0]; + } + + // 动作闭集分发;RPA 驱动 windows-driver.ts 各方法的逐段移植(见 PORTING-MAP.md)。 + private static void Execute(ActuateAction action, WindowCandidate window) + { + switch (action.Kind) + { + case "mouse_move": + RequirePoint(action); + if (!NativeInput.SetCursorPos(action.X!.Value, action.Y!.Value)) + throw new RunnerException("input_failed"); + break; + case "mouse_click": + RequirePoint(action); + if (!NativeInput.SetCursorPos(action.X!.Value, action.Y!.Value)) + throw new RunnerException("input_failed"); + NativeInput.Click(action.Button == "right", action.Double == true ? 2 : 1, + Math.Max(0, action.SettleMs ?? 0)); + break; + case "mouse_drag": + RequirePoint(action); + if (action.ToX is null || action.ToY is null) throw new RunnerException("action_invalid"); + NativeInput.DragSequence(action.Button == "right", action.X!.Value, action.Y!.Value, + action.ToX.Value, action.ToY.Value, + Math.Max(0, action.HoldMs ?? 80), Math.Max(0, action.ReleaseDelayMs ?? 80)); + break; + case "mouse_scroll": + if (action.Amount is null || action.Amount == 0) throw new RunnerException("action_invalid"); + NativeInput.SendWheel(action.Amount.Value); + break; + case "mouse_move_smooth": + RequirePoint(action); + SmoothMove(action); + break; + case "keyboard_send": + if (string.IsNullOrEmpty(action.Text)) throw new RunnerException("action_invalid"); + // 输入必须落在已核验窗口:先抢前台(五招兜底),对应 RPA 驱动 + // activateTitle 选项 + 300ms 间隙;窗口已在前台时五招直接通过。 + NativeInput.FocusChain(window.Handle); + Thread.Sleep(300); + SendKeyboard(action); + break; + case "window_activate": + NativeInput.FocusChain(window.Handle); + break; + case "window_set_state": + if (action.Amount is null) throw new RunnerException("action_invalid"); + _ = NativeInput.ShowWindow(window.Handle, action.Amount.Value); + break; + case "window_set_bounds": + if (action.X is null || action.Y is null || + action.Width is null || action.Height is null || + action.Width < 1 || action.Height < 1) + throw new RunnerException("action_invalid"); + NativeInput.MoveWindowBounds(window.Handle, action.X.Value, action.Y.Value, + action.Width.Value, action.Height.Value); + break; + case "window_close": + NativeInput.CloseWindow(window.Handle); + break; + default: + throw new RunnerException("action_invalid"); + } + } + + private static void RequirePoint(ActuateAction action) + { + if (action.X is null || action.Y is null) throw new RunnerException("action_invalid"); + } + + // keyboard 三模式(RPA 驱动 KeyboardDriver.send 的移植): + // clipboard:原文进剪贴板 + Ctrl+V(不做 SendKeys 转义,适合长文本); + // chars:逐字符 SendWait(每个字符按 SendKeys 规则转义,intervalMs 控速); + // keys:整段 SendWait 原样发送(保留 ^+% 等组合语义,供按键组合类能力使用)。 + private static void SendKeyboard(ActuateAction action) + { + switch (action.Mode) + { + case "clipboard": + Clipboard.SetDataObject(action.Text!, true); + Thread.Sleep(120); + SendKeys.SendWait("^v"); + break; + case "chars": + var interval = Math.Max(0, action.IntervalMs ?? 30); + foreach (var character in action.Text!) + { + SendKeys.SendWait(EscapeSendKeys(character)); + if (interval > 0) Thread.Sleep(interval); + } + break; + default: // "keys" + SendKeys.SendWait(action.Text!); + break; + } + } + + // RPA 驱动 sendKeysEscape 的逐字符移植:SendKeys 特殊字符用花括号表示字面量。 + private static string EscapeSendKeys(char character) => + "+^%~(){}[]".Contains(character) ? $"{{{character}}}" : character.ToString(); + + // mouse.moveSmooth 的移植:从当前光标位置出发,按 RPA 驱动 mousePath 公式插值。 + // 公式(缓入缓出 + 垂直抖动)逐行保真;JS 与 C# 的 % 对负数同为保号语义。 + private static void SmoothMove(ActuateAction action) + { + var from = NativeInput.CursorPosition(); + var toX = action.X!.Value; + var toY = action.Y!.Value; + var dx = toX - from.X; + var dy = toY - from.Y; + var distance = Math.Sqrt((double)dx * dx + (double)dy * dy); + if (distance < 2) return; + var stepCount = Math.Max(6, Math.Min(50, (int)Math.Round(distance / 12))); + var ease = action.Ease != false; + var speed = Math.Clamp(action.Speed ?? 35, 1, 100); + var delay = Math.Clamp((int)Math.Round(1500.0 / speed), 4, 80); + for (var i = 1; i <= stepCount; i++) + { + var t = (double)i / stepCount; + var easeFactor = ease ? (t < 0.5 ? 2 * t * t : 1 - Math.Pow(-2 * t + 2, 2) / 2) : t; + var jitter = ease ? Math.Sin(t * Math.PI) * (Math.Sin(i * 12.9898) * 43758.5453 % 1) * 3 : 0; + var x = (int)Math.Round(from.X + dx * easeFactor - (dy / distance) * jitter); + var y = (int)Math.Round(from.Y + dy * easeFactor + (dx / distance) * jitter); + if (!NativeInput.SetCursorPos(x, y)) throw new RunnerException("input_failed"); + Thread.Sleep(delay); + } + if (!NativeInput.SetCursorPos(toX, toY)) throw new RunnerException("input_failed"); + } + + // 证据采集:与 observe 的 CaptureWindow 同构(PrintWindow → PNG → SHA-256)。 + // actuate 始终留证据(副作用审计需要);PrintWindow 语义与像素有效性检查逐行保真。 + private static EvidenceRef CaptureWindowEvidence(nint handle, WindowRect bounds, + string evidenceRoot, DateTimeOffset capturedAt) + { + var path = Path.Combine(evidenceRoot, $"actuate-{Guid.NewGuid():N}.png"); + var stamp = capturedAt.ToString("O", CultureInfo.InvariantCulture); + try + { + using var bitmap = new Bitmap(bounds.Width, bounds.Height); + using var graphics = Graphics.FromImage(bitmap); + var deviceContext = graphics.GetHdc(); + bool captured; + try { captured = NativeWindows.PrintWindow(handle, deviceContext, 2); } + finally { graphics.ReleaseHdc(deviceContext); } + if (!captured || IsBlank(bitmap)) throw new RunnerException("capture_unavailable"); + bitmap.Save(path, ImageFormat.Png); + var size = new FileInfo(path).Length; + if (size < 1 || size > MaxPngBytes) throw new RunnerException("capture_size_unsupported"); + using var stream = File.OpenRead(path); + var hash = Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); + return new EvidenceRef($"ev_{hash[..24]}", "window_snapshot", + new Uri(path).AbsoluteUri, hash, "secret", stamp); + } + catch + { + if (File.Exists(path)) File.Delete(path); + throw; + } + } + + private static bool IsBlank(Bitmap bitmap) + { + var first = bitmap.GetPixel(0, 0).ToArgb(); + for (var row = 0; row < 10; row++) + for (var column = 0; column < 10; column++) + if (bitmap.GetPixel(column * (bitmap.Width - 1) / 9, + row * (bitmap.Height - 1) / 9).ToArgb() != first) return false; + return true; + } +} diff --git a/contrib/windows-runner-actuate/native/NativeInput.cs b/contrib/windows-runner-actuate/native/NativeInput.cs new file mode 100644 index 0000000..f0d02a2 --- /dev/null +++ b/contrib/windows-runner-actuate/native/NativeInput.cs @@ -0,0 +1,212 @@ +using System.Runtime.InteropServices; + +namespace AgentWorker.WindowsRunner; + +// 输入类 P/Invoke 独立成类:NativeWindows 保持对面"只读编译边界"原样不动。 +// 本层没有进程启动接口;所有输入只作用于经策略核验后的前台窗口。 +// 坐标一律物理屏幕像素——进程入口(ActuateService.EnsureDpiAwareness)声明 +// Per-Monitor V2 DPI 感知,与 RPA 侧 PowerShell 驱动每脚本声明的 +// SetProcessDpiAwarenessContext(-4) 语义一致,保证 SetCursorPos 全程物理像素。 +// 抢前台五招兜底从 RPA 驱动 activateWindowScript 逐行移植: +// Windows 前台锁定策略会拒绝普通 SetForegroundWindow(应用点击"运行"后自身是前台), +// 五招顺序与睡眠时间是踩坑调出来的实战参数,一行语义都不允许改动。 +internal static class NativeInput +{ + private const uint InputMouse = 0; + private const uint MouseEventfMove = 0x0001; + private const uint MouseEventfLeftDown = 0x0002; + private const uint MouseEventfLeftUp = 0x0004; + private const uint MouseEventfRightDown = 0x0008; + private const uint MouseEventfRightUp = 0x0010; + private const uint MouseEventfWheel = 0x0800; + internal const int SwRestore = 9; + internal const int SwMinimize = 6; + internal const int SwMaximize = 3; + private const uint WmClose = 0x0010; + private const byte VkMenu = 0x12; // ALT + private const uint KeyeventfKeyup = 0x0002; + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool SetCursorPos(int x, int y); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetCursorPos(out Point point); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool SetForegroundWindow(nint handle); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool ShowWindow(nint handle, int command); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool IsIconic(nint handle); + + [DllImport("user32.dll")] + internal static extern void keybd_event(byte virtualKey, byte scanCode, uint flags, nint extraInfo); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool AttachThreadInput(uint currentThread, uint targetThread, bool attach); + + [DllImport("user32.dll")] + private static extern void SwitchToThisWindow(nint handle, bool altTab); + + [DllImport("kernel32.dll")] + private static extern uint GetCurrentThreadId(); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetProcessDpiAwarenessContext(nint value); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetWindowPos(nint handle, nint after, int x, int y, + int width, int height, uint flags); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool PostMessage(nint handle, uint message, nint wParam, nint lParam); + + [DllImport("user32.dll", SetLastError = true)] + private static extern uint SendInput(uint count, Input[] inputs, int size); + + [StructLayout(LayoutKind.Sequential)] + private struct Point { public int X; public int Y; } + + [StructLayout(LayoutKind.Sequential)] + private struct Input + { + public uint Type; + public InputUnion Union; + } + + [StructLayout(LayoutKind.Explicit)] + private struct InputUnion + { + [FieldOffset(0)] public MouseInput Mouse; + } + + [StructLayout(LayoutKind.Sequential)] + private struct MouseInput + { + public int Dx; + public int Dy; + public uint MouseData; + public uint Flags; + public uint Time; + public nint ExtraInfo; + } + + // DPI 感知声明是进程级一次性设置;失败(如已被宿主先行声明)不影响后续调用。 + internal static void EnsureDpiAwareness() + { + try { _ = SetProcessDpiAwarenessContext(new nint(-4)); } + catch { /* 旧系统无此 API 时按物理像素继续;对面 CoordinateSpace 以 GetDpiForWindow 实测为准。 */ } + } + + // SendInput 批量提交鼠标标志组合;单次失败按 input_failed 处理由上层转稳定错误码。 + internal static void SendMouse(uint flags, uint data) + { + var input = new Input + { + Type = InputMouse, + Union = new InputUnion + { + Mouse = new MouseInput { Dx = 0, Dy = 0, MouseData = data, Flags = flags } + } + }; + if (SendInput(1, [input], Marshal.SizeOf<Input>()) != 1) + throw new RunnerException("input_failed"); + } + + internal static void SendWheel(int amount) => + SendMouse(MouseEventfWheel, unchecked((uint)amount)); + + internal static void Click(bool right, int repeats, int settleMs) + { + var down = right ? MouseEventfRightDown : MouseEventfLeftDown; + var up = right ? MouseEventfRightUp : MouseEventfLeftUp; + for (var i = 0; i < repeats; i++) + { + SendMouse(down, 0); + SendMouse(up, 0); + if (settleMs > 0) Thread.Sleep(settleMs); + } + } + + internal static Point CursorPosition() + { + if (!GetCursorPos(out var point)) throw new RunnerException("input_failed"); + return point; + } + + // 窗口拖拽时序:按下→hold→移动→release→抬起,与 RPA 驱动逐段一致。 + internal static void DragSequence(bool right, int fromX, int fromY, int toX, int toY, + int holdMs, int releaseDelayMs) + { + var down = right ? MouseEventfRightDown : MouseEventfLeftDown; + var up = right ? MouseEventfRightUp : MouseEventfLeftUp; + if (!SetCursorPos(fromX, fromY)) throw new RunnerException("input_failed"); + SendMouse(down, 0); + if (holdMs > 0) Thread.Sleep(holdMs); + if (!SetCursorPos(toX, toY)) throw new RunnerException("input_failed"); + if (releaseDelayMs > 0) Thread.Sleep(releaseDelayMs); + SendMouse(up, 0); + } + + // 抢前台五招兜底(RPA 驱动 activateWindowScript 的逐行移植): + // ①最小化则恢复 +150ms ②SwitchToThisWindow +150ms ③ALT 借位 + SetForegroundWindow +200ms + // ④AttachThreadInput 挂到前台线程 + SetForegroundWindow +200ms ⑤最小化再恢复 +450ms + // 五招全失败抛 focus_failed——对应 PS 版 RPA_ERROR '无法把窗口带到前台'。 + internal static void FocusChain(nint handle) + { + if (IsIconic(handle)) + { + _ = ShowWindow(handle, SwRestore); + Thread.Sleep(150); + } + if (NativeWindows.GetForegroundWindow() != handle) + { + SwitchToThisWindow(handle, true); + Thread.Sleep(150); + } + if (NativeWindows.GetForegroundWindow() != handle) + { + keybd_event(VkMenu, 0, 0, nint.Zero); + _ = SetForegroundWindow(handle); + keybd_event(VkMenu, 0, KeyeventfKeyup, nint.Zero); + Thread.Sleep(200); + } + if (NativeWindows.GetForegroundWindow() != handle) + { + // 返回值即前台窗口的线程号;PID 出参不需要。 + var foregroundThread = NativeWindows.GetWindowThreadProcessId( + NativeWindows.GetForegroundWindow(), out _); + var currentThread = GetCurrentThreadId(); + var attached = AttachThreadInput(currentThread, foregroundThread, true); + try { _ = SetForegroundWindow(handle); } + finally { if (attached) _ = AttachThreadInput(currentThread, foregroundThread, false); } + Thread.Sleep(200); + } + if (NativeWindows.GetForegroundWindow() != handle) + { + _ = ShowWindow(handle, SwMinimize); + Thread.Sleep(150); + _ = ShowWindow(handle, SwRestore); + Thread.Sleep(300); + } + if (NativeWindows.GetForegroundWindow() != handle) + throw new RunnerException("focus_failed"); + } + + internal static void MoveWindowBounds(nint handle, int x, int y, int width, int height) => + _ = SetWindowPos(handle, nint.Zero, x, y, width, height, 4); // flags=4 SWP_NOZORDER,与 RPA 驱动一致 + + internal static void CloseWindow(nint handle) => + _ = PostMessage(handle, WmClose, nint.Zero, nint.Zero); +} diff --git a/docs/rpa-core-migration-guide-v2.md b/docs/rpa-core-migration-guide-v2.md index b1bda47..135182d 100644 --- a/docs/rpa-core-migration-guide-v2.md +++ b/docs/rpa-core-migration-guide-v2.md @@ -339,7 +339,7 @@ | ADR | 问题 | 决定 | |---|---|---| | R1 | Registry 作用域:backend 是否多租户并存 | **全局一份能力清单**(菜单所有租户共用),权限隔离完全由 capability-gateway 按租户管;长期将清单挂到 engine 实例(已从对面源码确认 WorkflowIdentity 带 tenantId,但清单本身无需按租户复制) | -| R3 | 桌面执行安全模型:RPA PowerShell 驱动 vs 对面"受信 C# 原生进程"红线 | **C# 扩展 actuate**:把 RPA 五域执行逻辑移植进 windows-runner 原生进程;PowerShell 通道仅限本 demo,不进宿主 | +| R3 | 桌面执行安全模型:RPA PowerShell 驱动 vs 对面"受信 C# 原生进程"红线 | **C# 扩展 actuate**:把 RPA 五域执行逻辑移植进 windows-runner 原生进程;PowerShell 通道仅限本 demo,不进宿主。**原型已产出(2026-11-06)**:`contrib/windows-runner-actuate/` 补丁包(协议扩展 + NativeInput + ActuateService + 逐原语移植对照表 + 合入指引含门禁合规清单);对面 README Windows 行"click/type 或随本轮交付"与该路线吻合 | | R4 | 浏览器双路径 | **按场景分工**:RPA 场景走咱们的 CDP 接管路线(复用用户登录态/真实页面——RPA 核心价值);对面 PlaywrightBrowserRunner 留给 AI 智能体场景(无头自管浏览器)。约束:①咱们 CDP 路线必须接对面 BrowserContextPolicy 白名单与证据体系,不自建;②迁移前修完 transport 超时与定位器不统一两缺陷;③需对面在插件清单给咱们的 runner 备案(rpa.browser) | | R5 | 包消费方式 | **源码并入 + 锁版本**:git subtree 进对面仓库统一编译,Bun 锁 1.4.3(与对面全家 workspace:* 源码消费现状一致) | | R6 | 部署拓扑:rpa-core 各域跑在哪 | **按能力域拆分部署**:纯 Node 域→backend plugin-worker;桌面/视觉/弹窗域→用户电脑 Electron 宿主;同一包按 PLATFORM_GROUPS 分组加载,不维护两份代码 |