返回 CodeWhale
win32-native-contract.test.mjs
根目录 / crates / tui / plugins / computer-use / tests / win32-native-contract.test.mjs
1 // Windows executes the real generated PowerShell and compiles its real INPUT
2 // structs. Every native import is replaced before execution with managed stubs:
3 // no test in this file can send keyboard/mouse input to the user's desktop.
4 import assert from "node:assert/strict";
5 import { spawnSync } from "node:child_process";
6 import path from "node:path";
7 import { test } from "node:test";
8 import win32 from "../src/backends/win32.mjs";
9
10 const WINDOWS = { skip: process.platform !== "win32" && "Requires Windows PowerShell; managed stubs only, never live OS input", timeout: 60_000 };
11 const FRAME = "CU_MANAGED_FIXTURE:";
12 const NATIVE_IMPORT = /\[DllImport\("user32\.dll"(?:,\s*SetLastError\s*=\s*true)?\)\]\s+(?:public|private)\s+static\s+extern\s+[^;]+;/gu;
13 const FORBIDDEN = /DllImport|LibraryImport|\bextern\b|user32\.dll|GetDelegateForFunctionPointer|NativeLibrary|SendKeys/iu;
14
15 function decodedCommand(command, args) {
16 assert.equal(command, "powershell.exe");
17 assert.deepEqual(args.slice(0, 3), ["-NoProfile", "-NonInteractive", "-EncodedCommand"]);
18 assert.equal(args.length, 4);
19 return Buffer.from(args[3], "base64").toString("utf16le");
20 }
21
22 function managedScript(original, { returns = [], mutate = (value) => value } = {}) {
23 assert.ok(returns.every((value) => Number.isInteger(value) && value >= 0 && value <= 0xffffffff));
24 const stubs = {
25 SetCursorPos: "public static bool SetCursorPos(int X, int Y) { Calls.Add(new Call { kind = \"SetCursorPos\", x = X, y = Y }); return true; }",
26 mouse_event: "public static void mouse_event(uint dwFlags, int dx, int dy, uint dwData, UIntPtr dwExtraInfo) { Calls.Add(new Call { kind = \"mouse_event\", flags = dwFlags, x = dx, y = dy, data = dwData, extra = dwExtraInfo.ToUInt64() }); }",
27 GetCursorPos: "public static bool GetCursorPos(out POINT lpPoint) { lpPoint = new POINT(); Calls.Add(new Call { kind = \"GetCursorPos\" }); return true; }",
28 SetForegroundWindow: "public static bool SetForegroundWindow(IntPtr hWnd) { Calls.Add(new Call { kind = \"SetForegroundWindow\" }); return true; }",
29 SendInput: `private static uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize) {
30 uint inserted = nextReturn < ReturnPlan.Length ? ReturnPlan[nextReturn++] : nInputs;
31 var events = new KeyEvent[pInputs.Length];
32 for (int i = 0; i < pInputs.Length; i++) {
33 events[i] = new KeyEvent { type = pInputs[i].type, vk = pInputs[i].u.ki.wVk, scan = pInputs[i].u.ki.wScan, flags = pInputs[i].u.ki.dwFlags };
34 }
35 Calls.Add(new Call { kind = "SendInput", count = nInputs, size = cbSize, arrayType = pInputs.GetType().FullName, events = events, inserted = inserted });
36 return inserted;
37 }`,
38 };
39 const replaced = new Set();
40 let script = original.replace(NATIVE_IMPORT, (declaration) => {
41 const name = declaration.match(/\b(\w+)\s*\(/gu)?.at(-1)?.replace(/\s*\($/u, "");
42 assert.ok(Object.hasOwn(stubs, name), `Refusing unknown native declaration: ${name}`);
43 assert.equal(replaced.has(name), false, `Duplicate native declaration: ${name}`);
44 replaced.add(name);
45 return stubs[name].replace(/^(?:public|private)/u, declaration.match(/\]\s+(public|private)\b/u)[1]);
46 });
47 assert.deepEqual([...replaced].sort(), Object.keys(stubs).sort(), "Every expected native import must be replaced");
48 assert.match(script, /public static class User32 \{/u);
49 script = script.replace("public static class User32 {", `public static class User32 {
50 public sealed class KeyEvent { public uint type; public ushort vk; public ushort scan; public uint flags; }
51 public sealed class Call {
52 public string kind; public uint count; public int size; public string arrayType; public KeyEvent[] events;
53 public uint inserted; public uint flags; public uint data; public int x; public int y; public ulong extra;
54 }
55 public static readonly System.Collections.Generic.List<Call> Calls = new System.Collections.Generic.List<Call>();
56 private static readonly uint[] ReturnPlan = new uint[] { ${returns.map((value) => `${value}u`).join(", ")} };
57 private static int nextReturn;
58 `);
59 script = mutate(script);
60 assert.doesNotMatch(script, FORBIDDEN, "Refusing to execute a fixture containing a native import or alternate input sink");
61 return script;
62 }
63
64 function encodedFixture(script) {
65 const wrapped = `[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false);
66 $fixtureOutput = New-Object 'System.Collections.Generic.List[string]'; $fixtureError = $null;
67 try {
68 & {
69 ${script}
70 } | ForEach-Object { [void]$fixtureOutput.Add([string]$_); };
71 } catch { $fixtureError = $_.Exception.ToString(); }
72 $fixtureCalls = @(); $fixtureLayout = $null;
73 if ('User32' -as [type]) {
74 $fixtureCalls = @([User32]::Calls);
75 $fixtureLayout = @{
76 pointerSize = [IntPtr]::Size;
77 inputSize = [Runtime.InteropServices.Marshal]::SizeOf([type]'User32+INPUT');
78 mouseSize = [Runtime.InteropServices.Marshal]::SizeOf([type]'User32+MOUSEINPUT');
79 keySize = [Runtime.InteropServices.Marshal]::SizeOf([type]'User32+KEYBDINPUT');
80 unionOffset = [Runtime.InteropServices.Marshal]::OffsetOf([type]'User32+INPUT', 'u').ToInt32();
81 keyExtraOffset = [Runtime.InteropServices.Marshal]::OffsetOf([type]'User32+KEYBDINPUT', 'dwExtraInfo').ToInt32();
82 keyScanOffset = [Runtime.InteropServices.Marshal]::OffsetOf([type]'User32+KEYBDINPUT', 'wScan').ToInt32();
83 keyFlagsOffset = [Runtime.InteropServices.Marshal]::OffsetOf([type]'User32+KEYBDINPUT', 'dwFlags').ToInt32();
84 };
85 }
86 Write-Output ('${FRAME}' + (@{ output = @($fixtureOutput.ToArray()); error = $fixtureError; calls = $fixtureCalls; layout = $fixtureLayout } | ConvertTo-Json -Depth 10 -Compress));
87 if ($null -ne $fixtureError) { exit 86; }
88 `;
89 assert.doesNotMatch(wrapped, FORBIDDEN, "Final executable fixture must contain no native import");
90 const encoded = Buffer.from(wrapped, "utf16le").toString("base64");
91 assert.ok(encoded.length < 30_000, "Keep the encoded fixture under Windows' command-line limit");
92 return encoded;
93 }
94
95 function fixture({ returns, mutate } = {}) {
96 const invocations = [];
97 const backend = win32.create({ exec: { persistentInputOwner: true, run: async (command, args) => {
98 // This assertion is inside the only spawn path, not just in test options.
99 assert.equal(process.platform, "win32", "Real PowerShell is Windows-only");
100 const encoded = encodedFixture(managedScript(decodedCommand(command, args), { returns, mutate }));
101 const executable = path.join(process.env.SystemRoot || "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
102 const result = spawnSync(executable, ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded], {
103 encoding: "utf8", windowsHide: true, timeout: 25_000, maxBuffer: 4 * 1024 * 1024,
104 });
105 assert.ifError(result.error);
106 assert.equal(result.signal, null);
107 const frames = result.stdout.split(/\r?\n/u).filter((line) => line.startsWith(FRAME));
108 assert.equal(frames.length, 1, `Fixture must produce one trace, even on failure: ${result.stderr}`);
109 const trace = JSON.parse(frames[0].slice(FRAME.length));
110 invocations.push({ ...trace, code: result.status });
111 // Forward actual script output and actual process failure. Do not return a
112 // fabricated success JSON: that would conceal PowerShell conversion errors.
113 return { code: result.status, stdout: trace.output.join("\n"), stderr: trace.error || result.stderr };
114 } } });
115 return { backend, invocations };
116 }
117
118 async function sampleScript() {
119 let script;
120 await win32.create({ exec: { run: async (command, args) => {
121 script = decodedCommand(command, args);
122 return { code: 0, stdout: "", stderr: "" };
123 } } }).type({ text: "A" });
124 return script;
125 }
126
127 function assertLayout(layout) {
128 assert.ok([4, 8].includes(layout.pointerSize));
129 // Windows ABI expectations, independent of the structs being compiled.
130 const is64 = layout.pointerSize === 8;
131 assert.equal(layout.inputSize, is64 ? 40 : 28);
132 assert.equal(layout.mouseSize, is64 ? 32 : 24);
133 assert.equal(layout.keySize, is64 ? 24 : 16);
134 assert.equal(layout.unionOffset, is64 ? 8 : 4);
135 assert.equal(layout.keyExtraOffset, is64 ? 16 : 12);
136 assert.equal(layout.keyScanOffset, 2);
137 assert.equal(layout.keyFlagsOffset, 4);
138 }
139
140 function onlyTrace(invocations) {
141 assert.equal(invocations.length, 1, "An input action must not replay its PowerShell script");
142 return invocations[0];
143 }
144
145 function noSuccess(trace) {
146 assert.notEqual(trace.code, 0);
147 assert.ok(trace.error);
148 assert.equal(trace.output.some((line) => /"ok"\s*:\s*true/u.test(line)), false, "No success output after a compiler, conversion, or delivery error");
149 }
150
151 test("win32 native fixture: all generated imports become managed methods before execution", async () => {
152 const script = managedScript(await sampleScript());
153 assert.doesNotMatch(script, FORBIDDEN);
154 assert.match(script, /public static void SendString\(string text\)/u);
155 assert.ok(encodedFixture(script).length < 30_000, "The actual executable envelope fits Windows' command-line limit");
156 });
157
158 test("win32 native fixture: unknown and reintroduced imports fail closed", async () => {
159 const script = await sampleScript();
160 for (const unsafe of [
161 '[DllImport("kernel32.dll")] public static extern void Unsafe();',
162 '[DllImport("user32.dll")] public static extern void Unsafe();',
163 "public static extern void Unsafe();",
164 '[LibraryImport("user32.dll")] public static partial void Unsafe();',
165 ]) {
166 assert.throws(() => managedScript(`${script}\n${unsafe}`), /Refusing/);
167 assert.throws(() => managedScript(script, { mutate: (safe) => `${safe}\n${unsafe}` }), /Refusing/);
168 }
169 });
170
171 test("win32 native contract: typed chord arrays compile and obey INPUT ABI", WINDOWS, async () => {
172 const { backend, invocations } = fixture();
173 assert.equal((await backend.key({ text: "ctrl+shift+a", repeat: 2 })).action_sent, true);
174 const trace = onlyTrace(invocations);
175 assertLayout(trace.layout);
176 assert.equal(trace.calls.length, 1);
177 const call = trace.calls[0];
178 assert.equal(call.kind, "SendInput");
179 assert.equal(call.arrayType, "User32+INPUT[]");
180 assert.equal(call.count, 8);
181 assert.equal(call.size, trace.layout.pointerSize === 8 ? 40 : 28);
182 assert.deepEqual(call.events, [
183 [17, 0], [16, 0], [65, 0], [65, 2], [65, 0], [65, 2], [16, 2], [17, 2],
184 ].map(([vk, flags]) => ({ type: 1, vk, scan: 0, flags })));
185 });
186
187 test("win32 native contract: Unicode A, CJK and a surrogate pair use complete down/up pairs", WINDOWS, async () => {
188 const { backend, invocations } = fixture();
189 const result = await backend.type({ text: "A漢😀" });
190 assert.equal(result.action_sent, true);
191 assert.equal(result.chars, 4);
192 const trace = onlyTrace(invocations);
193 assertLayout(trace.layout);
194 assert.equal(trace.calls.length, 4);
195 for (const [index, scan] of [0x0041, 0x6f22, 0xd83d, 0xde00].entries()) {
196 const call = trace.calls[index];
197 assert.equal(call.kind, "SendInput");
198 assert.equal(call.count, 2);
199 assert.equal(call.inserted, 2);
200 assert.equal(call.size, trace.layout.pointerSize === 8 ? 40 : 28);
201 assert.deepEqual(call.events, [{ type: 1, vk: 0, scan, flags: 4 }, { type: 1, vk: 0, scan, flags: 6 }]);
202 }
203 });
204
205 test("win32 native contract: held chords bind SendKey and release in reverse order", WINDOWS, async () => {
206 const { backend, invocations } = fixture();
207 assert.equal((await backend.hold_key({ text: "ctrl+a", duration: 0.05 })).action_sent, true);
208 const trace = onlyTrace(invocations);
209 assertLayout(trace.layout);
210 assert.deepEqual(trace.calls.map((call) => [call.kind, call.count, call.inserted]), Array(4).fill(["SendInput", 1, 1]));
211 assert.deepEqual(trace.calls.flatMap((call) => call.events), [
212 [17, 0], [65, 0], [65, 2], [17, 2],
213 ].map(([vk, flags]) => ({ type: 1, vk, scan: 0, flags })));
214 assert.ok(trace.calls.every((call) => call.arrayType === "User32+INPUT[]" && call.size === trace.layout.inputSize));
215 });
216
217 test("win32 native contract: zero delivery rejects before later text or success", WINDOWS, async () => {
218 const { backend, invocations } = fixture({ returns: [0] });
219 await assert.rejects(backend.type({ text: "AB" }), /SendInput inserted 0 of 2/u);
220 const trace = onlyTrace(invocations);
221 noSuccess(trace);
222 assert.deepEqual(trace.calls.map((call) => call.inserted), [0]);
223 assert.deepEqual(trace.calls[0].events.map((event) => event.scan), [65, 65]);
224 });
225
226 for (const cleanupResult of [1, 0]) {
227 test(`win32 native contract: partial Unicode delivery 1 -> ${cleanupResult} releases only its matching up`, WINDOWS, async () => {
228 const { backend, invocations } = fixture({ returns: [1, cleanupResult] });
229 await assert.rejects(backend.type({ text: "AB" }), /SendInput inserted 1 of 2/u);
230 const trace = onlyTrace(invocations);
231 noSuccess(trace);
232 assert.deepEqual(trace.calls.map((call) => call.count), [2, 1]);
233 assert.deepEqual(trace.calls.map((call) => call.inserted), [1, cleanupResult]);
234 assert.deepEqual(trace.calls[0].events, [{ type: 1, vk: 0, scan: 65, flags: 4 }, { type: 1, vk: 0, scan: 65, flags: 6 }]);
235 assert.deepEqual(trace.calls[1].events, [{ type: 1, vk: 0, scan: 65, flags: 6 }]);
236 if (cleanupResult === 0) {
237 assert.match(trace.error, /key-up recovery failed/u);
238 assert.match(trace.error, /SendInput inserted 0 of 1/u);
239 } else assert.doesNotMatch(trace.error, /key-up recovery failed/u);
240 });
241 }
242
243 test("win32 native contract: C# compiler errors stop before script success", WINDOWS, async () => {
244 const { backend, invocations } = fixture({ mutate: (script) => script.replace("public static class User32 {", "public static class User32 { invalid C# declaration;") });
245 await assert.rejects(backend.type({ text: "A" }));
246 const trace = onlyTrace(invocations);
247 noSuccess(trace);
248 assert.equal(trace.layout, null);
249 assert.deepEqual(trace.calls, []);
250 });
251
252 test("win32 native contract: PowerShell argument conversion errors stop before script success", WINDOWS, async () => {
253 const { backend, invocations } = fixture({ mutate: (script) => {
254 assert.match(script, /\[User32\]::SendString\(\$text\);/u);
255 return script.replace("[User32]::SendString($text);", "[User32]::SendKey('not-a-number', 0);");
256 } });
257 await assert.rejects(backend.type({ text: "A" }));
258 const trace = onlyTrace(invocations);
259 noSuccess(trace);
260 assertLayout(trace.layout);
261 assert.deepEqual(trace.calls, []);
262 });
263
264 for (const [direction, vertical, horizontal] of [
265 ["down", 0xfffffe98, 0], ["up", 360, 0], ["left", 0, 0xfffffe98], ["right", 0, 360],
266 ]) {
267 test(`win32 native contract: ${direction} wheel binds the correct signed DWORD bits`, WINDOWS, async () => {
268 const { backend, invocations } = fixture();
269 assert.equal((await backend.scroll({ target: { x: 12, y: 34 }, direction, amount: 3 })).action_sent, true);
270 const trace = onlyTrace(invocations);
271 assert.equal(trace.calls.length, 3);
272 assert.deepEqual([trace.calls[0].kind, trace.calls[0].x, trace.calls[0].y], ["SetCursorPos", 12, 34]);
273 assert.deepEqual(trace.calls.slice(1).map(({ kind, flags, data, extra }) => ({ kind, flags, data, extra })), [
274 { kind: "mouse_event", flags: 0x0800, data: vertical, extra: 0 },
275 { kind: "mouse_event", flags: 0x1000, data: horizontal, extra: 0 },
276 ]);
277 });
278 }
279
279 lines Plain Text