aegis-arun 0.1.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,246 @@
1
+ #!/usr/bin/env node
2
+ // Explicitly trusted local MCP server. No dependency on another agent or CLI.
3
+ import { spawn } from 'node:child_process';
4
+ import fs from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { randomUUID } from 'node:crypto';
8
+ import { StringDecoder } from 'node:string_decoder';
9
+
10
+ const helper = fileURLToPath(new URL('./windows-host.ps1', import.meta.url));
11
+ const MAX_MESSAGE = 2 * 1024 * 1024;
12
+ const string = (description, maxLength = 32768) => ({ type: 'string', description, maxLength, minLength: 1 });
13
+ const integer = (minimum, maximum, description) => ({ type: 'integer', minimum, maximum, description });
14
+ const schema = (properties, required = []) => ({ type: 'object', properties, required, additionalProperties: false });
15
+ const definition = (name, description, properties, required, readOnly = false) => ({
16
+ name, description, inputSchema: schema(properties, required),
17
+ annotations: { readOnlyHint: readOnly, destructiveHint: !readOnly, openWorldHint: true },
18
+ });
19
+ export const tools = [
20
+ definition('file_read', 'Read a bounded UTF-8 excerpt from any regular host file. Absolute paths allowed under trusted-host authorization.', {
21
+ path: string('File path; relative paths resolve from the MCP working directory.'),
22
+ offset: integer(0, Number.MAX_SAFE_INTEGER, 'Byte offset, default 0.'),
23
+ max_bytes: integer(1, 65536, 'Maximum bytes, default 32768.'),
24
+ }, ['path'], true),
25
+ definition('file_write', 'Write UTF-8 to a host file. Existing files require overwrite:true. Creates parent directories.', {
26
+ path: string('File path.'), content: { type: 'string', maxLength: 1048576 }, overwrite: { type: 'boolean' },
27
+ }, ['path', 'content']),
28
+ definition('directory_list', 'List at most 200 directory entries, without recursion or following child links.', {
29
+ path: string('Directory path; default current directory.'), limit: integer(1, 200, 'Default 100.'),
30
+ }, [], true),
31
+ definition('powershell', 'Run native Windows PowerShell on the trusted host. This is not a sandbox. Output is bounded; all child processes terminate with the invocation. Use app_launch for an app that must remain open.', {
32
+ script: string('PowerShell script.', 65536), cwd: string('Working directory; default MCP directory.'),
33
+ timeout_seconds: integer(1, 600, 'Execution limit including startup, default 60; Aegis also applies the task command deadline.'),
34
+ max_output_bytes: integer(256, 131072, 'Bytes retained per stdout/stderr stream, default 32768.'),
35
+ }, ['script']),
36
+ definition('desktop_windows', 'List visible top-level Windows applications and foreground window, including handles, process IDs and screen rectangles.', {}, [], true),
37
+ definition('desktop_focus', 'Restore and focus an existing desktop window by its handle. Windows may refuse foreground activation.', {
38
+ handle: string('Window handle returned by desktop_windows, decimal integer string.', 32),
39
+ }, ['handle']),
40
+ definition('app_launch', 'Launch an explicitly specified executable without shell interpolation. Defaults to hidden; visible:true opens a user-visible interactive application.', {
41
+ executable: string('Absolute executable path.'), args: { type: 'array', items: { type: 'string', maxLength: 32768 }, maxItems: 64 },
42
+ cwd: string('Working directory.'), visible: { type: 'boolean' },
43
+ }, ['executable']),
44
+ definition('desktop_screenshot', 'Capture the Windows desktop or an absolute screen region as a bounded PNG image and saved local file. Return metadata maps scaled image coordinates to original screen pixels.', {
45
+ x: integer(-32768, 32768, 'Region origin X; all x/y/width/height must be supplied together.'),
46
+ y: integer(-32768, 32768, 'Region origin Y.'), width: integer(1, 16384, 'Region width.'), height: integer(1, 16384, 'Region height.'),
47
+ max_width: integer(64, 1920, 'Scaled output maximum width, default 1280.'),
48
+ max_height: integer(64, 1080, 'Scaled output maximum height, default 1024.'),
49
+ output_path: string('PNG destination; defaults to .arun/windows-host/capture-<id>.png. Never overwrites a file.'),
50
+ }, [], true),
51
+ definition('desktop_mouse', 'Move/click the cursor or scroll at absolute screen coordinates. Coordinates refer to original pixels, not scaled screenshots.', {
52
+ action: { type: 'string', enum: ['move', 'click', 'scroll'] },
53
+ x: integer(-32768, 32768, 'Screen X.'), y: integer(-32768, 32768, 'Screen Y.'),
54
+ button: { type: 'string', enum: ['left', 'right', 'middle'] },
55
+ count: integer(1, 2, 'Click count, default 1.'), delta: integer(-12000, 12000, 'Vertical scroll wheel delta; 120 is one notch.'),
56
+ }, ['action', 'x', 'y']),
57
+ definition('desktop_keyboard', 'Type Unicode text or press one simultaneous key chord in the focused app. Key names include CTRL, SHIFT, ALT, WIN, ENTER, TAB, ESC, arrows, HOME, END, DELETE, F1-F24 and ASCII letters/digits. Supply exactly one of text or keys.', {
58
+ text: string('Literal text to type, including Unicode.', 8192),
59
+ keys: { type: 'array', items: string('Key name.', 16), minItems: 1, maxItems: 8 },
60
+ }, []),
61
+ ];
62
+
63
+ function validate(name, args) {
64
+ const tool = tools.find(tool => tool.name === name);
65
+ if (!tool) throw new Error(`Unknown tool: ${name}`);
66
+ if (!args || typeof args !== 'object' || Array.isArray(args)) throw new Error('Arguments must be an object');
67
+ for (const key of Object.keys(args)) if (!(key in tool.inputSchema.properties)) throw new Error(`Unknown argument: ${key}`);
68
+ for (const key of tool.inputSchema.required) if (!(key in args)) throw new Error(`Missing argument: ${key}`);
69
+ function check(value, spec, key) {
70
+ const type = Array.isArray(value) ? 'array' : typeof value;
71
+ if (spec.type === 'integer' ? !Number.isSafeInteger(value) : type !== spec.type) throw new Error(`Invalid type: ${key}`);
72
+ if (spec.enum && !spec.enum.includes(value)) throw new Error(`Unsupported ${key}`);
73
+ if (type === 'string' && (value.includes('\0') || value.length < (spec.minLength ?? 0) || value.length > spec.maxLength)) throw new Error(`Invalid string: ${key}`);
74
+ if (spec.type === 'integer' && (value < spec.minimum || value > spec.maximum)) throw new Error(`Out of range: ${key}`);
75
+ if (type === 'array') {
76
+ if (value.length < (spec.minItems ?? 0) || value.length > spec.maxItems) throw new Error(`Invalid length: ${key}`);
77
+ value.forEach(item => check(item, spec.items, key));
78
+ }
79
+ }
80
+ for (const [key, value] of Object.entries(args)) check(value, tool.inputSchema.properties[key], key);
81
+ if (name === 'desktop_keyboard' && (('text' in args) === ('keys' in args))) throw new Error('Supply exactly one of text or keys');
82
+ if (name === 'desktop_screenshot') {
83
+ const region = ['x', 'y', 'width', 'height'].filter(key => key in args).length;
84
+ if (region !== 0 && region !== 4) throw new Error('Supply all region coordinates');
85
+ if (region === 4 && args.width * args.height > 32 * 1024 * 1024) throw new Error('Capture region exceeds pixel limit');
86
+ }
87
+ if (name === 'desktop_mouse' && args.action === 'scroll' && !('delta' in args)) throw new Error('Scroll requires delta');
88
+ return args;
89
+ }
90
+
91
+ const children = new Set();
92
+ function killTree(child) {
93
+ if (!child.pid || child.exitCode !== null) return;
94
+ const killer = spawn('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' });
95
+ killer.on('error', () => child.kill());
96
+ }
97
+
98
+ async function native(action, args, workspace, onOutput = null) {
99
+ const seconds = args.timeout_seconds ?? (action === 'powershell' ? 60 : 10);
100
+ const limit = args.max_output_bytes ?? (action === 'powershell' ? 32768 : 131072);
101
+ return new Promise((resolve, reject) => {
102
+ const executable = path.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
103
+ const child = spawn(executable, ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', helper, '-TrustedHost'], {
104
+ cwd: workspace, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'],
105
+ });
106
+ children.add(child);
107
+ let stdout = Buffer.alloc(0), stderr = Buffer.alloc(0), stdoutBytes = 0, stderrBytes = 0, timedOut = false;
108
+ const append = (current, chunk) => Buffer.concat([current, chunk.subarray(0, Math.max(0, limit - current.length))]);
109
+ const decoders = { stdout: new StringDecoder('utf8'), stderr: new StringDecoder('utf8') };
110
+ const emit = (stream, text) => {
111
+ if (action !== 'powershell' || !onOutput) return;
112
+ for (let offset = 0; offset < text.length;) {
113
+ let end = Math.min(offset + 2048, text.length);
114
+ if (end < text.length && text.charCodeAt(end - 1) >= 0xd800 && text.charCodeAt(end - 1) <= 0xdbff) end--;
115
+ onOutput(stream, text.slice(offset, end)); offset = end;
116
+ }
117
+ };
118
+ child.stdout.on('data', bytes => { stdoutBytes += bytes.length; const retained = bytes.subarray(0, Math.max(0, limit - stdout.length)); stdout = append(stdout, bytes); emit('stdout', decoders.stdout.write(retained)); });
119
+ child.stderr.on('data', bytes => { stderrBytes += bytes.length; const retained = bytes.subarray(0, Math.max(0, limit - stderr.length)); stderr = append(stderr, bytes); emit('stderr', decoders.stderr.write(retained)); });
120
+ const timer = setTimeout(() => { timedOut = true; killTree(child); }, (seconds + 2) * 1000);
121
+ child.stdin.on('error', () => {});
122
+ child.on('error', error => { clearTimeout(timer); children.delete(child); reject(error); });
123
+ child.on('close', code => {
124
+ clearTimeout(timer); children.delete(child);
125
+ emit('stdout', decoders.stdout.end()); emit('stderr', decoders.stderr.end());
126
+ if (action === 'powershell') resolve({ exit_code: code, timed_out: timedOut || code === 124,
127
+ stdout: stdout.toString('utf8'), stderr: stderr.toString('utf8'),
128
+ stdout_bytes: stdoutBytes, stderr_bytes: stderrBytes,
129
+ stdout_truncated: stdoutBytes > limit, stderr_truncated: stderrBytes > limit });
130
+ else if (code !== 0) reject(new Error(stderr.toString('utf8').trim() || `Native helper exited ${code}`));
131
+ else { try { resolve(JSON.parse(stdout.toString('utf8'))); } catch { reject(new Error('Invalid native helper response')); } }
132
+ });
133
+ child.stdin.end(JSON.stringify({ action, args, timeout_seconds: seconds }));
134
+ });
135
+ }
136
+
137
+ const textResult = result => ({ content: [{ type: 'text', text: JSON.stringify(result) }], isError: false });
138
+ export async function callTool(name, input = {}, workspace = process.cwd(), onOutput = null) {
139
+ const args = validate(name, input);
140
+ const absolute = name => path.resolve(workspace, name);
141
+ if (name === 'file_read') {
142
+ const file = await fs.open(absolute(args.path), 'r');
143
+ try {
144
+ const stat = await file.stat();
145
+ if (!stat.isFile()) throw new Error('Not a regular file');
146
+ const offset = args.offset ?? 0, buffer = Buffer.alloc(args.max_bytes ?? 32768);
147
+ const { bytesRead } = await file.read(buffer, 0, buffer.length, offset);
148
+ return textResult({ path: absolute(args.path), offset, bytes_read: bytesRead, total_bytes: stat.size,
149
+ truncated: offset + bytesRead < stat.size, text: buffer.subarray(0, bytesRead).toString('utf8') });
150
+ } finally { await file.close(); }
151
+ }
152
+ if (name === 'file_write') {
153
+ const output = absolute(args.path), bytes = Buffer.from(args.content, 'utf8');
154
+ if (bytes.length > 1048576) throw new Error('Content exceeds 1 MiB');
155
+ await fs.mkdir(path.dirname(output), { recursive: true });
156
+ await fs.writeFile(output, bytes, { flag: args.overwrite ? 'w' : 'wx' });
157
+ return textResult({ path: output, bytes: bytes.length });
158
+ }
159
+ if (name === 'directory_list') {
160
+ const directory = absolute(args.path ?? '.'), entries = [], limit = args.limit ?? 100;
161
+ let truncated = false;
162
+ const handle = await fs.opendir(directory);
163
+ for await (const entry of handle) {
164
+ if (entries.length === limit) { truncated = true; break; }
165
+ entries.push({ name: entry.name, type: entry.isSymbolicLink() ? 'link' : entry.isDirectory() ? 'directory' : entry.isFile() ? 'file' : 'other' });
166
+ }
167
+ return textResult({ path: directory, entries, truncated });
168
+ }
169
+ if (name === 'app_launch') {
170
+ if (!path.isAbsolute(args.executable)) throw new Error('Executable must be an absolute path');
171
+ if (path.extname(args.executable).toLowerCase() !== '.exe' || !(await fs.stat(args.executable)).isFile()) throw new Error('Launch requires an existing .exe file');
172
+ return textResult(await native(name, { ...args, cwd: absolute(args.cwd ?? '.') }, workspace));
173
+ }
174
+ const nativeArgs = { ...args };
175
+ if (name === 'desktop_screenshot') {
176
+ const output = absolute(args.output_path ?? `.arun/windows-host/capture-${randomUUID()}.png`);
177
+ await fs.mkdir(path.dirname(output), { recursive: true });
178
+ // Reserve exclusively so capture can never silently overwrite user files.
179
+ const reservation = await fs.open(output, 'wx'); await reservation.close();
180
+ nativeArgs.output_path = output;
181
+ try {
182
+ const metadata = await native(name, nativeArgs, workspace);
183
+ const stat = await fs.stat(output);
184
+ if (stat.size > 4 * 1024 * 1024) throw new Error('PNG exceeds 4 MiB');
185
+ const bytes = await fs.readFile(output);
186
+ return { content: [{ type: 'text', text: JSON.stringify(metadata) },
187
+ { type: 'image', mimeType: 'image/png', data: bytes.toString('base64') }], isError: false };
188
+ } catch (error) { await fs.unlink(output).catch(() => {}); throw error; }
189
+ }
190
+ const result = await native(name, nativeArgs, absolute(args.cwd ?? '.'), onOutput);
191
+ const reply = textResult(result);
192
+ if (name === 'powershell') reply.isError = result.exit_code !== 0 || result.timed_out;
193
+ return reply;
194
+ }
195
+
196
+ export async function serve() {
197
+ if (!process.argv.slice(2).includes('--trusted-host')) throw new Error('Requires explicit --trusted-host authorization (full Windows PC access)');
198
+ if (process.platform !== 'win32') throw new Error('Windows host MCP requires Windows');
199
+ let buffer = Buffer.alloc(0), queue = Promise.resolve(), pending = 0;
200
+ const send = value => process.stdout.write(`${JSON.stringify(value)}\n`);
201
+ const dispatch = async request => {
202
+ if (!request || request.jsonrpc !== '2.0' || typeof request.method !== 'string') {
203
+ send({ jsonrpc: '2.0', id: request?.id ?? null, error: { code: -32600, message: 'Invalid request' } }); return;
204
+ }
205
+ if (!('id' in request)) return;
206
+ let result;
207
+ if (request.method === 'initialize') result = { protocolVersion: request.params?.protocolVersion ?? '2024-11-05',
208
+ capabilities: { tools: { listChanged: false } }, serverInfo: { name: 'aegis-windows-host', version: '1.0.0' } };
209
+ else if (request.method === 'ping') result = {};
210
+ else if (request.method === 'tools/list') result = { tools };
211
+ else if (request.method === 'tools/call') {
212
+ const token = request.params?._meta?.progressToken;
213
+ let progress = 0;
214
+ const onOutput = typeof token === 'string' || typeof token === 'number'
215
+ ? (stream, text) => send({ jsonrpc: '2.0', method: 'notifications/progress', params: {
216
+ progressToken: token, progress: ++progress, message: stream === 'stderr' ? `[stderr] ${text}` : text,
217
+ } }) : null;
218
+ try { result = await callTool(request.params?.name, request.params?.arguments ?? {}, process.cwd(), onOutput); }
219
+ catch (error) { result = { content: [{ type: 'text', text: error.message }], isError: true }; }
220
+ } else { send({ jsonrpc: '2.0', id: request.id, error: { code: -32601, message: 'Method not found' } }); return; }
221
+ send({ jsonrpc: '2.0', id: request.id, result });
222
+ };
223
+ process.stdin.on('data', chunk => {
224
+ buffer = Buffer.concat([buffer, chunk]);
225
+ while (true) {
226
+ const end = buffer.indexOf(10);
227
+ if (end === -1) break;
228
+ if (end > MAX_MESSAGE || pending >= 16) { process.stderr.write('MCP input limits exceeded\n'); process.exit(2); }
229
+ const line = buffer.subarray(0, end).toString('utf8'); buffer = buffer.subarray(end + 1);
230
+ if (!line.trim()) continue;
231
+ pending++;
232
+ queue = queue.then(async () => {
233
+ try { await dispatch(JSON.parse(line)); }
234
+ catch { send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }); }
235
+ finally { pending--; }
236
+ });
237
+ }
238
+ if (buffer.length > MAX_MESSAGE) { process.stderr.write('MCP message too large\n'); process.exit(2); }
239
+ });
240
+ process.stdin.on('end', () => { for (const child of children) killTree(child); queue.finally(() => process.exit(0)); });
241
+ process.stdout.on('error', () => { for (const child of children) killTree(child); process.exit(0); });
242
+ }
243
+
244
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
245
+ serve().catch(error => { process.stderr.write(`${error.message}\n`); process.exitCode = 2; });
246
+ }
@@ -0,0 +1,230 @@
1
+ param([switch]$TrustedHost)
2
+ $ErrorActionPreference = 'Stop'
3
+ if (-not $TrustedHost) { throw 'Explicit -TrustedHost authorization is required.' }
4
+ [Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false)
5
+ [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
6
+ $OutputEncoding = [Console]::OutputEncoding
7
+ try {
8
+ $request = [Console]::In.ReadToEnd() | ConvertFrom-Json
9
+ if (-not $request.action -or $request.timeout_seconds -lt 1 -or $request.timeout_seconds -gt 600) {
10
+ throw 'Invalid native helper request.'
11
+ }
12
+ Add-Type -AssemblyName System.Drawing
13
+ Add-Type -AssemblyName System.Windows.Forms
14
+ Add-Type -TypeDefinition @'
15
+ using System;
16
+ using System.Collections.Generic;
17
+ using System.Diagnostics;
18
+ using System.Runtime.InteropServices;
19
+ using System.Text;
20
+ using System.Threading;
21
+
22
+ public static class AegisWindowsHost {
23
+ [StructLayout(LayoutKind.Sequential)] public struct Rect { public int Left, Top, Right, Bottom; }
24
+ [StructLayout(LayoutKind.Sequential)] struct Keyboard { public ushort Key, Scan; public uint Flags, Time; public UIntPtr Extra; }
25
+ [StructLayout(LayoutKind.Sequential)] struct Mouse { public int X, Y; public uint Data, Flags, Time; public UIntPtr Extra; }
26
+ [StructLayout(LayoutKind.Explicit)] struct InputUnion { [FieldOffset(0)] public Keyboard Keyboard; [FieldOffset(0)] public Mouse Mouse; }
27
+ [StructLayout(LayoutKind.Sequential)] struct Input { public uint Type; public InputUnion Value; }
28
+ [StructLayout(LayoutKind.Sequential)] struct JobBasic {
29
+ public long ProcessTime, JobTime; public uint Flags; public UIntPtr Minimum, Maximum; public uint Active;
30
+ public UIntPtr Affinity; public uint Priority, Scheduling;
31
+ }
32
+ [StructLayout(LayoutKind.Sequential)] struct IoCounters { public ulong ReadOps, WriteOps, OtherOps, ReadBytes, WriteBytes, OtherBytes; }
33
+ [StructLayout(LayoutKind.Sequential)] struct JobExtended {
34
+ public JobBasic Basic; public IoCounters Io; public UIntPtr ProcessMemory, JobMemory, PeakProcess, PeakJob;
35
+ }
36
+ public class Window { public string handle, title; public uint process_id; public Rect rectangle; public bool foreground; }
37
+ delegate bool EnumCallback(IntPtr window, IntPtr parameter);
38
+ [DllImport("user32.dll")] static extern bool EnumWindows(EnumCallback callback, IntPtr parameter);
39
+ [DllImport("user32.dll")] static extern bool IsWindowVisible(IntPtr window);
40
+ [DllImport("user32.dll")] static extern bool IsWindow(IntPtr window);
41
+ [DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern int GetWindowText(IntPtr window, StringBuilder text, int maximum);
42
+ [DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr window, out uint processId);
43
+ [DllImport("user32.dll")] static extern bool GetWindowRect(IntPtr window, out Rect rectangle);
44
+ [DllImport("user32.dll")] static extern IntPtr GetForegroundWindow();
45
+ [DllImport("user32.dll")] static extern bool SetForegroundWindow(IntPtr window);
46
+ [DllImport("user32.dll")] static extern bool ShowWindow(IntPtr window, int command);
47
+ [DllImport("user32.dll")] static extern bool SetCursorPos(int x, int y);
48
+ [DllImport("user32.dll", SetLastError=true)] static extern uint SendInput(uint count, Input[] input, int size);
49
+ [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] static extern IntPtr CreateJobObject(IntPtr attributes, string name);
50
+ [DllImport("kernel32.dll", SetLastError=true)] static extern bool SetInformationJobObject(IntPtr job, int kind, ref JobExtended information, uint length);
51
+ [DllImport("kernel32.dll", SetLastError=true)] static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process);
52
+ [DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr handle);
53
+ [DllImport("kernel32.dll")] static extern IntPtr GetCurrentProcess();
54
+ [DllImport("kernel32.dll")] static extern bool TerminateProcess(IntPtr process, uint exitCode);
55
+ static IntPtr ownedJob;
56
+ static Timer watchdog;
57
+ public static void BoundLifetime(int seconds, bool killChildren) {
58
+ if (killChildren) {
59
+ ownedJob = CreateJobObject(IntPtr.Zero, null);
60
+ if (ownedJob == IntPtr.Zero) throw new Exception("Cannot create execution job");
61
+ JobExtended limits = new JobExtended(); limits.Basic.Flags = 0x2000; // KILL_ON_JOB_CLOSE
62
+ if (!SetInformationJobObject(ownedJob, 9, ref limits, (uint)Marshal.SizeOf(typeof(JobExtended))) ||
63
+ !AssignProcessToJobObject(ownedJob, Process.GetCurrentProcess().Handle)) {
64
+ CloseHandle(ownedJob); ownedJob = IntPtr.Zero;
65
+ throw new Exception("Cannot contain PowerShell child lifetime; refusing execution");
66
+ }
67
+ }
68
+ // Independent of the Node parent: a killed MCP client cannot leave an
69
+ // indefinitely executing PowerShell child behind.
70
+ // A hard process termination avoids PowerShell's ProcessExit handlers
71
+ // waiting for the active pipeline. The OS closes our job handle and
72
+ // terminates native descendants even when the MCP parent has died.
73
+ watchdog = new Timer(delegate { TerminateProcess(GetCurrentProcess(), 124); }, null, seconds * 1000, Timeout.Infinite);
74
+ }
75
+ public static Window[] Windows() {
76
+ List<Window> windows = new List<Window>(); IntPtr foreground = GetForegroundWindow();
77
+ EnumWindows(delegate(IntPtr handle, IntPtr ignored) {
78
+ if (!IsWindowVisible(handle)) return true;
79
+ StringBuilder text = new StringBuilder(128); GetWindowText(handle, text, text.Capacity);
80
+ if (text.Length == 0) return true;
81
+ uint process; GetWindowThreadProcessId(handle, out process); Rect rectangle; GetWindowRect(handle, out rectangle);
82
+ windows.Add(new Window { handle=handle.ToInt64().ToString(), title=text.ToString(), process_id=process,
83
+ rectangle=rectangle, foreground=handle==foreground });
84
+ return windows.Count < 200;
85
+ }, IntPtr.Zero);
86
+ return windows.ToArray();
87
+ }
88
+ public static void Focus(string value) {
89
+ long number; if (!long.TryParse(value, out number) || number <= 0) throw new Exception("Invalid window handle");
90
+ IntPtr window = new IntPtr(number);
91
+ if (!IsWindow(window)) throw new Exception("Window no longer exists");
92
+ ShowWindow(window, 9);
93
+ if (GetForegroundWindow() == window) return;
94
+ if (!SetForegroundWindow(window)) throw new Exception("Windows refused foreground activation");
95
+ for (int attempt = 0; attempt < 50; attempt++) {
96
+ if (GetForegroundWindow() == window) return;
97
+ Thread.Sleep(10);
98
+ }
99
+ throw new Exception("Window did not become foreground");
100
+ }
101
+ static string QuoteArgument(string value) {
102
+ StringBuilder quoted = new StringBuilder("\""); int slashes = 0;
103
+ foreach (char unit in value) {
104
+ if (unit == '\\') { slashes++; continue; }
105
+ if (unit == '"') { quoted.Append('\\', slashes * 2 + 1); quoted.Append('"'); }
106
+ else { quoted.Append('\\', slashes); quoted.Append(unit); }
107
+ slashes = 0;
108
+ }
109
+ quoted.Append('\\', slashes * 2); quoted.Append('"'); return quoted.ToString();
110
+ }
111
+ public static int Launch(string executable, string[] arguments, string cwd, bool visible) {
112
+ // ShellExecute starts an executable through Windows, not cmd/PowerShell
113
+ // interpretation. It avoids inheriting Node's kill-on-close child job.
114
+ ProcessStartInfo info = new ProcessStartInfo(executable);
115
+ info.UseShellExecute = true; info.WorkingDirectory = cwd;
116
+ info.WindowStyle = visible ? ProcessWindowStyle.Normal : ProcessWindowStyle.Hidden;
117
+ List<string> quoted = new List<string>();
118
+ foreach (string argument in arguments) quoted.Add(QuoteArgument(argument));
119
+ info.Arguments = String.Join(" ", quoted.ToArray());
120
+ using (Process child = Process.Start(info)) {
121
+ if (child == null) throw new Exception("Windows did not return a launched process");
122
+ return child.Id;
123
+ }
124
+ }
125
+ static void Send(Input input) {
126
+ if (SendInput(1, new Input[] { input }, Marshal.SizeOf(typeof(Input))) != 1)
127
+ throw new Exception("Windows input injection failed (integrity level or desktop restrictions)");
128
+ }
129
+ public static void Pointer(int x, int y, string action, string button, int count, int delta) {
130
+ if (!SetCursorPos(x, y)) throw new Exception("Cannot move cursor on this desktop");
131
+ if (action == "move") return;
132
+ if (action == "scroll") { Input wheel=new Input(); wheel.Value.Mouse.Flags=0x800; wheel.Value.Mouse.Data=unchecked((uint)delta); Send(wheel); return; }
133
+ uint down=button=="right" ? 8u : button=="middle" ? 32u : 2u;
134
+ for (int index=0; index<count; index++) {
135
+ Input input=new Input(); input.Value.Mouse.Flags=down; Send(input);
136
+ input.Value.Mouse.Flags=down*2; Send(input);
137
+ if (index+1<count) Thread.Sleep(75);
138
+ }
139
+ }
140
+ static ushort Key(string value) {
141
+ string key=value.ToUpperInvariant();
142
+ Dictionary<string, ushort> names=new Dictionary<string, ushort> {
143
+ {"CTRL",17},{"SHIFT",16},{"ALT",18},{"WIN",91},{"ENTER",13},{"TAB",9},{"ESC",27},
144
+ {"SPACE",32},{"BACKSPACE",8},{"DELETE",46},{"INSERT",45},{"LEFT",37},{"UP",38},{"RIGHT",39},
145
+ {"DOWN",40},{"HOME",36},{"END",35},{"PAGEUP",33},{"PAGEDOWN",34}
146
+ };
147
+ ushort result; if (names.TryGetValue(key, out result)) return result;
148
+ int function; if (key.Length>=2 && key[0]=='F' && int.TryParse(key.Substring(1), out function) && function>=1 && function<=24)
149
+ return (ushort)(111+function);
150
+ if (key.Length==1 && ((key[0]>='A' && key[0]<='Z') || (key[0]>='0' && key[0]<='9'))) return key[0];
151
+ throw new Exception("Unsupported key: "+value);
152
+ }
153
+ static void KeyInput(ushort key, ushort scan, uint flags) {
154
+ Input input=new Input(); input.Type=1; input.Value.Keyboard.Key=key;
155
+ input.Value.Keyboard.Scan=scan; input.Value.Keyboard.Flags=flags; Send(input);
156
+ }
157
+ public static void Chord(string[] values) {
158
+ ushort[] keys=new ushort[values.Length]; for (int index=0; index<values.Length; index++) keys[index]=Key(values[index]);
159
+ int pressed=0;
160
+ try { for (; pressed<keys.Length; pressed++) KeyInput(keys[pressed],0,0); }
161
+ finally { for (int index=pressed-1; index>=0; index--) KeyInput(keys[index],0,2); }
162
+ }
163
+ public static void Text(string text) {
164
+ foreach (char unit in text) { KeyInput(0,unit,4); KeyInput(0,unit,6); }
165
+ }
166
+ }
167
+ '@
168
+ [AegisWindowsHost]::BoundLifetime([int]$request.timeout_seconds, $request.action -eq 'powershell')
169
+ $argsValue = $request.args
170
+ switch ($request.action) {
171
+ 'app_launch' {
172
+ $pidValue=[AegisWindowsHost]::Launch([string]$argsValue.executable,[string[]]@($argsValue.args),[string]$argsValue.cwd,[bool]$argsValue.visible)
173
+ $result=@{ pid=$pidValue; visible=[bool]$argsValue.visible }
174
+ }
175
+ 'powershell' {
176
+ # Stream formatting instead of collecting arbitrarily large output.
177
+ # The owning job terminates any spawned descendants when we exit.
178
+ $global:LASTEXITCODE = 0
179
+ & ([scriptblock]::Create([string]$argsValue.script)) | Out-String -Stream | ForEach-Object { [Console]::WriteLine($_) }
180
+ exit $global:LASTEXITCODE
181
+ }
182
+ 'desktop_windows' { $result = @{ windows=@([AegisWindowsHost]::Windows()) } }
183
+ 'desktop_focus' { [AegisWindowsHost]::Focus([string]$argsValue.handle); $result=@{ focused=$argsValue.handle } }
184
+ 'desktop_mouse' {
185
+ $screen=[System.Windows.Forms.SystemInformation]::VirtualScreen
186
+ if ($argsValue.x -lt $screen.Left -or $argsValue.x -ge $screen.Right -or $argsValue.y -lt $screen.Top -or $argsValue.y -ge $screen.Bottom) { throw 'Coordinates are outside the desktop.' }
187
+ $button=if ($argsValue.button) { [string]$argsValue.button } else { 'left' }
188
+ $count=if ($argsValue.count) { [int]$argsValue.count } else { 1 }
189
+ [AegisWindowsHost]::Pointer([int]$argsValue.x,[int]$argsValue.y,[string]$argsValue.action,$button,$count,[int]$argsValue.delta)
190
+ $result=@{ action=$argsValue.action; x=$argsValue.x; y=$argsValue.y }
191
+ }
192
+ 'desktop_keyboard' {
193
+ if ($null -ne $argsValue.text) { [AegisWindowsHost]::Text([string]$argsValue.text); $result=@{ typed_utf16_units=$argsValue.text.Length } }
194
+ else { [AegisWindowsHost]::Chord([string[]]$argsValue.keys); $result=@{ keys=$argsValue.keys } }
195
+ }
196
+ 'desktop_screenshot' {
197
+ $screen=[System.Windows.Forms.SystemInformation]::VirtualScreen
198
+ $region=if ($null -ne $argsValue.width) {
199
+ [System.Drawing.Rectangle]::new([int]$argsValue.x,[int]$argsValue.y,[int]$argsValue.width,[int]$argsValue.height)
200
+ } else { $screen }
201
+ if ($region.Width -lt 1 -or $region.Height -lt 1 -or ([long]$region.Width*$region.Height) -gt 33554432 -or -not $screen.Contains($region)) { throw 'Invalid capture region for this desktop.' }
202
+ $maxWidth=if ($argsValue.max_width) { [int]$argsValue.max_width } else { 1280 }
203
+ $maxHeight=if ($argsValue.max_height) { [int]$argsValue.max_height } else { 1024 }
204
+ $scale=[Math]::Min(1.0,[Math]::Min($maxWidth/[double]$region.Width,$maxHeight/[double]$region.Height))
205
+ $width=[Math]::Max(1,[int]($region.Width*$scale)); $height=[Math]::Max(1,[int]($region.Height*$scale))
206
+ $bitmap=[System.Drawing.Bitmap]::new($region.Width,$region.Height)
207
+ $graphics=$null; $resized=$null; $resizeGraphics=$null
208
+ try {
209
+ $graphics=[System.Drawing.Graphics]::FromImage($bitmap)
210
+ $graphics.CopyFromScreen($region.Location,[System.Drawing.Point]::Empty,$region.Size)
211
+ $resized=[System.Drawing.Bitmap]::new($width,$height)
212
+ $resizeGraphics=[System.Drawing.Graphics]::FromImage($resized)
213
+ $resizeGraphics.InterpolationMode=[System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
214
+ $resizeGraphics.DrawImage($bitmap,0,0,$width,$height)
215
+ $resized.Save([string]$argsValue.output_path,[System.Drawing.Imaging.ImageFormat]::Png)
216
+ } finally {
217
+ if ($resizeGraphics) { $resizeGraphics.Dispose() }; if ($resized) { $resized.Dispose() }
218
+ if ($graphics) { $graphics.Dispose() }; $bitmap.Dispose()
219
+ }
220
+ $result=@{ path=$argsValue.output_path; width=$width; height=$height;
221
+ screen_region=@{ x=$region.X; y=$region.Y; width=$region.Width; height=$region.Height };
222
+ screen_pixels_per_image_pixel=@{ x=$region.Width/[double]$width; y=$region.Height/[double]$height } }
223
+ }
224
+ default { throw 'Unsupported native action.' }
225
+ }
226
+ [Console]::WriteLine(($result | ConvertTo-Json -Depth 8 -Compress))
227
+ } catch {
228
+ [Console]::Error.WriteLine($_.Exception.Message)
229
+ exit 1
230
+ }