@anusornneal/chat-relay 0.6.0 → 0.7.0

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.
@@ -1,157 +1,212 @@
1
- import WebSocket from "ws";
2
- import { FileManager } from "./file-manager.mjs";
3
- import { ProcessManager } from "./process-manager.mjs";
4
- import { TerminalManager } from "./terminal-manager.mjs";
5
-
6
- const relayUrl = process.env.RELAY_URL;
7
- const agentToken = process.env.AGENT_TOKEN;
8
- const agentId = process.env.AGENT_ID || "default";
9
- const agentName = process.env.AGENT_NAME || agentId;
10
- const reconnectMs = Number(process.env.RECONNECT_MS ?? 2000);
11
- const terminalEnabled = process.env.TERMINAL_ENABLED === "1";
12
- const terminals = new TerminalManager();
13
- const files = new FileManager(process.env.ALLOWED_ROOTS);
14
- const processes = new ProcessManager();
15
- const recentCalls = [];
16
-
17
- if (!relayUrl || !agentToken) {
18
- console.error("RELAY_URL and AGENT_TOKEN are required");
19
- process.exit(1);
20
- }
21
-
22
- const wsUrl = relayUrl.replace(/^http/, "ws").replace(/\/$/, "") +
23
- `/agent?agentId=${encodeURIComponent(agentId)}`;
24
-
25
- async function handlePayload(payload) {
26
- if (!payload || typeof payload !== "object") return payload;
27
-
28
- switch (payload.action) {
29
- case "ping":
30
- return { ok: true, action: "pong", at: new Date().toISOString(), agentId, agentName };
31
-
32
- case "agent.config":
33
- return processes.info({
34
- agentId,
35
- agentName,
36
- terminalEnabled,
37
- allowedRoots: files.getRoots(),
38
- reconnectMs,
39
- });
40
- case "agent.recentCalls":
41
- return recentCalls.slice(-(Math.min(Math.max(Number(payload.limit) || 50, 1), 100)));
42
-
43
- case "fs.stat":
44
- return files.stat(payload.path);
45
- case "fs.list":
46
- return files.list(payload.path, payload.depth);
47
- case "fs.read":
48
- return files.read(payload.path, payload.offset, payload.length);
49
- case "fs.readMany":
50
- return files.readMany(payload.paths);
51
- case "fs.write":
52
- return files.write(payload.path, payload.content, payload.mode);
53
- case "fs.edit":
54
- return files.edit(payload.path, payload.oldText, payload.newText, payload.expectedReplacements);
55
- case "fs.mkdir":
56
- return files.mkdir(payload.path);
57
- case "fs.move":
58
- return files.move(payload.source, payload.destination);
59
- case "fs.delete":
60
- return files.remove(payload.path, payload.recursive);
61
- case "fs.search.start":
62
- return files.startSearch(payload.path, payload.pattern, payload.searchType, payload.maxResults);
63
- case "fs.search.results":
64
- return files.getSearchResults(payload.sessionId, payload.offset, payload.length);
65
-
66
- case "process.list":
67
- return processes.list(payload.filter);
68
- case "process.kill":
69
- return processes.kill(payload.pid);
70
-
71
- case "terminal.exec":
72
- if (!terminalEnabled) return { ok: false, error: "terminal_disabled" };
73
- return terminals.exec(payload.command, payload.cwd, payload.timeoutMs);
74
- case "terminal.start":
75
- if (!terminalEnabled) return { ok: false, error: "terminal_disabled" };
76
- return terminals.start(payload.command, payload.cwd);
77
- case "terminal.shell.start":
78
- if (!terminalEnabled) return { ok: false, error: "terminal_disabled" };
79
- return terminals.startShell(payload.cwd);
80
- case "terminal.read":
81
- if (!terminalEnabled) return { ok: false, error: "terminal_disabled" };
82
- return terminals.read(payload.sessionId, payload.afterSeq, payload.maxChars);
83
- case "terminal.write":
84
- if (!terminalEnabled) return { ok: false, error: "terminal_disabled" };
85
- return terminals.write(payload.sessionId, payload.input, payload.appendNewline !== false);
86
- case "terminal.kill":
87
- if (!terminalEnabled) return { ok: false, error: "terminal_disabled" };
88
- return terminals.kill(payload.sessionId);
89
- case "terminal.list":
90
- if (!terminalEnabled) return { ok: false, error: "terminal_disabled" };
91
- return terminals.list();
92
-
93
- default:
94
- return { ok: false, error: "unknown_action" };
95
- }
96
- }
97
-
98
- function connect() {
99
- console.log(`Connecting to ${wsUrl}`);
100
- console.log(`Terminal access: ${terminalEnabled ? "enabled" : "disabled"}`);
101
- const socket = new WebSocket(wsUrl, {
102
- headers: { Authorization: `Bearer ${agentToken}` },
103
- });
104
-
105
- socket.on("open", () => console.log("Agent connected"));
106
-
107
- socket.on("message", async (raw) => {
108
- let message;
109
- try {
110
- message = JSON.parse(raw.toString());
111
- } catch {
112
- return;
113
- }
114
-
115
- if (!message || typeof message.requestId !== "string") return;
116
-
117
- const startedAt = Date.now();
118
- const action = message.payload && typeof message.payload === "object"
119
- ? String(message.payload.action ?? "unknown")
120
- : "unknown";
121
- try {
122
- const result = await handlePayload(message.payload);
123
- recentCalls.push({
124
- at: new Date().toISOString(),
125
- action,
126
- durationMs: Date.now() - startedAt,
127
- ok: !(result && typeof result === "object" && result.ok === false),
128
- });
129
- if (recentCalls.length > 100) recentCalls.shift();
130
- socket.send(JSON.stringify({ requestId: message.requestId, payload: result }));
131
- } catch (error) {
132
- recentCalls.push({
133
- at: new Date().toISOString(),
134
- action,
135
- durationMs: Date.now() - startedAt,
136
- ok: false,
137
- error: error instanceof Error ? error.message : "agent_error",
138
- });
139
- if (recentCalls.length > 100) recentCalls.shift();
140
- socket.send(JSON.stringify({
141
- requestId: message.requestId,
142
- payload: { ok: false, error: error instanceof Error ? error.message : "agent_error" },
143
- }));
144
- }
145
- });
146
-
147
- socket.on("close", (code, reason) => {
148
- console.log(`Agent disconnected (${code}) ${reason.toString()}`);
149
- setTimeout(connect, reconnectMs);
150
- });
151
-
152
- socket.on("error", (error) => {
153
- console.error("WebSocket error:", error.message);
154
- });
155
- }
156
-
157
- connect();
1
+ import WebSocket from "ws";
2
+ import { DesktopManager } from "./desktop-manager.mjs";
3
+ import { FileManager } from "./file-manager.mjs";
4
+ import { ProcessManager } from "./process-manager.mjs";
5
+ import { TerminalManager } from "./terminal-manager.mjs";
6
+
7
+ const relayUrl = process.env.RELAY_URL;
8
+ const agentToken = process.env.AGENT_TOKEN;
9
+ const agentId = process.env.AGENT_ID || "default";
10
+ const agentName = process.env.AGENT_NAME || agentId;
11
+ const reconnectMs = Number(process.env.RECONNECT_MS ?? 2000);
12
+ const terminalEnabled = process.env.TERMINAL_ENABLED === "1";
13
+ const desktopEnabled = process.env.DESKTOP_ENABLED === "1";
14
+ const MAX_RESPONSE_BYTES = 60 * 1024;
15
+ const terminals = new TerminalManager();
16
+ const files = new FileManager(process.env.ALLOWED_ROOTS);
17
+ const processes = new ProcessManager();
18
+ const desktop = new DesktopManager({ enabled: desktopEnabled });
19
+ const recentCalls = [];
20
+ let reauthorizationRequired = false;
21
+
22
+ if (!relayUrl || !agentToken) {
23
+ console.error("RELAY_URL and AGENT_TOKEN are required");
24
+ process.exit(1);
25
+ }
26
+
27
+ const wsUrl = relayUrl.replace(/^http/, "ws").replace(/\/$/, "") +
28
+ `/agent?agentId=${encodeURIComponent(agentId)}`;
29
+
30
+ function serializeResponse(requestId, payload) {
31
+ const response = JSON.stringify({ requestId, payload });
32
+ if (Buffer.byteLength(response, "utf8") <= MAX_RESPONSE_BYTES) return response;
33
+ return JSON.stringify({
34
+ requestId,
35
+ payload: {
36
+ ok: false,
37
+ error: "response_too_large",
38
+ maxBytes: MAX_RESPONSE_BYTES,
39
+ },
40
+ });
41
+ }
42
+
43
+ async function handlePayload(payload) {
44
+ if (!payload || typeof payload !== "object") return payload;
45
+
46
+ switch (payload.action) {
47
+ case "ping":
48
+ return { ok: true, action: "pong", at: new Date().toISOString(), agentId, agentName };
49
+
50
+ case "agent.config":
51
+ return processes.info({
52
+ agentId,
53
+ agentName,
54
+ terminalEnabled,
55
+ desktop: desktop.getConfig(),
56
+ allowedRoots: files.getRoots(),
57
+ reconnectMs,
58
+ });
59
+ case "agent.recentCalls":
60
+ return recentCalls.slice(-(Math.min(Math.max(Number(payload.limit) || 50, 1), 100)));
61
+
62
+ case "fs.stat":
63
+ return files.stat(payload.path);
64
+ case "fs.list":
65
+ return files.list(payload.path, payload.depth);
66
+ case "fs.read":
67
+ return files.read(payload.path, payload.offset, payload.length, payload.maxBytes);
68
+ case "fs.readMany":
69
+ return files.readMany(payload.paths, payload.maxTotalBytes);
70
+ case "fs.write":
71
+ return files.write(payload.path, payload.content, payload.mode);
72
+ case "fs.edit":
73
+ return files.edit(payload.path, payload.oldText, payload.newText, payload.expectedReplacements);
74
+ case "fs.mkdir":
75
+ return files.mkdir(payload.path);
76
+ case "fs.move":
77
+ return files.move(payload.source, payload.destination);
78
+ case "fs.delete":
79
+ return files.remove(payload.path, payload.recursive);
80
+ case "fs.search.start":
81
+ return files.startSearch(payload.path, payload.pattern, payload.searchType, payload.maxResults);
82
+ case "fs.search.results":
83
+ return files.getSearchResults(payload.sessionId, payload.offset, payload.length);
84
+
85
+ case "process.list":
86
+ return processes.list(payload.filter);
87
+ case "process.kill":
88
+ return processes.kill(payload.pid);
89
+
90
+ case "desktop.screenshot":
91
+ return desktop.screenshot();
92
+ case "desktop.mouse.click":
93
+ return desktop.mouseClick(payload);
94
+ case "desktop.keyboard.input":
95
+ return desktop.keyboardInput(payload);
96
+
97
+ case "terminal.exec":
98
+ if (!terminalEnabled) return { ok: false, error: "terminal_disabled" };
99
+ return terminals.exec(payload.command, payload.cwd, payload.timeoutMs);
100
+ case "terminal.start":
101
+ if (!terminalEnabled) return { ok: false, error: "terminal_disabled" };
102
+ return terminals.start(payload.command, payload.cwd);
103
+ case "terminal.shell.start":
104
+ if (!terminalEnabled) return { ok: false, error: "terminal_disabled" };
105
+ return terminals.startShell(payload.cwd);
106
+ case "terminal.read":
107
+ if (!terminalEnabled) return { ok: false, error: "terminal_disabled" };
108
+ return terminals.read(payload.sessionId, payload.afterSeq, payload.maxChars);
109
+ case "terminal.write":
110
+ if (!terminalEnabled) return { ok: false, error: "terminal_disabled" };
111
+ return terminals.write(payload.sessionId, payload.input, payload.appendNewline !== false);
112
+ case "terminal.kill":
113
+ if (!terminalEnabled) return { ok: false, error: "terminal_disabled" };
114
+ return terminals.kill(payload.sessionId);
115
+ case "terminal.list":
116
+ if (!terminalEnabled) return { ok: false, error: "terminal_disabled" };
117
+ return terminals.list();
118
+
119
+ default:
120
+ return { ok: false, error: "unknown_action" };
121
+ }
122
+ }
123
+
124
+ function requireReauthorization() {
125
+ if (reauthorizationRequired) return;
126
+ reauthorizationRequired = true;
127
+ process.exitCode = 2;
128
+ console.error("Agent credential was revoked or rejected.");
129
+ console.error('Recovery: run "chat-relay login --force", then "chat-relay remote".');
130
+ }
131
+
132
+ function connect() {
133
+ if (reauthorizationRequired) return;
134
+ console.log(`Connecting to ${wsUrl}`);
135
+ console.log(`Terminal access: ${terminalEnabled ? "enabled" : "disabled"}`);
136
+ console.log("Desktop access: " + (desktopEnabled ? "enabled" : "disabled"));
137
+ const socket = new WebSocket(wsUrl, {
138
+ headers: { Authorization: `Bearer ${agentToken}` },
139
+ });
140
+
141
+ socket.on("open", () => console.log("Agent connected"));
142
+
143
+ socket.on("message", async (raw) => {
144
+ let message;
145
+ try {
146
+ message = JSON.parse(raw.toString());
147
+ } catch {
148
+ return;
149
+ }
150
+
151
+ if (message?.control === "credential_revoked") {
152
+ requireReauthorization();
153
+ try { socket.close(4001, "credential_revoked"); } catch {}
154
+ return;
155
+ }
156
+
157
+ if (!message || typeof message.requestId !== "string") return;
158
+
159
+ const startedAt = Date.now();
160
+ const action = message.payload && typeof message.payload === "object"
161
+ ? String(message.payload.action ?? "unknown")
162
+ : "unknown";
163
+ try {
164
+ const result = await handlePayload(message.payload);
165
+ recentCalls.push({
166
+ at: new Date().toISOString(),
167
+ action,
168
+ durationMs: Date.now() - startedAt,
169
+ ok: !(result && typeof result === "object" && result.ok === false),
170
+ });
171
+ if (recentCalls.length > 100) recentCalls.shift();
172
+ socket.send(serializeResponse(message.requestId, result));
173
+ } catch (error) {
174
+ recentCalls.push({
175
+ at: new Date().toISOString(),
176
+ action,
177
+ durationMs: Date.now() - startedAt,
178
+ ok: false,
179
+ error: error instanceof Error ? error.message : "agent_error",
180
+ });
181
+ if (recentCalls.length > 100) recentCalls.shift();
182
+ socket.send(serializeResponse(message.requestId, {
183
+ ok: false,
184
+ error: error instanceof Error ? error.message : "agent_error",
185
+ }));
186
+ }
187
+ });
188
+
189
+ socket.on("unexpected-response", (_request, response) => {
190
+ if (response.statusCode === 401 || response.statusCode === 403) {
191
+ response.resume();
192
+ requireReauthorization();
193
+ socket.terminate();
194
+ }
195
+ });
196
+
197
+ socket.on("close", (code, reason) => {
198
+ const reasonText = reason.toString();
199
+ console.log("Agent disconnected (" + code + ") " + reasonText);
200
+ if (code === 4001 || reasonText === "credential_revoked") {
201
+ requireReauthorization();
202
+ return;
203
+ }
204
+ if (!reauthorizationRequired) setTimeout(connect, reconnectMs);
205
+ });
206
+
207
+ socket.on("error", (error) => {
208
+ if (!reauthorizationRequired) console.error("WebSocket error:", error.message);
209
+ });
210
+ }
211
+
212
+ connect();
@@ -0,0 +1,263 @@
1
+ param(
2
+ [Parameter(Mandatory=$true)][string]$Operation,
3
+ [Parameter(Mandatory=$true)][string]$InputBase64
4
+ )
5
+
6
+ $ErrorActionPreference = "Stop"
7
+ $ProgressPreference = "SilentlyContinue"
8
+
9
+ function Write-Result($Value) {
10
+ $Value | ConvertTo-Json -Compress -Depth 8
11
+ }
12
+
13
+ function Fail($Code) {
14
+ Write-Result ([pscustomobject]@{ ok = $false; error = $Code })
15
+ exit 0
16
+ }
17
+
18
+ try {
19
+ $inputJson = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($InputBase64))
20
+ $payload = if ($inputJson) { $inputJson | ConvertFrom-Json } else { [pscustomobject]@{} }
21
+
22
+ Add-Type -AssemblyName System.Windows.Forms
23
+ Add-Type -AssemblyName System.Drawing
24
+ Add-Type @"
25
+ using System;
26
+ using System.Runtime.InteropServices;
27
+
28
+ public static class ChatRelayNative {
29
+ public const uint INPUT_MOUSE = 0;
30
+ public const uint INPUT_KEYBOARD = 1;
31
+ public const uint KEYEVENTF_KEYUP = 0x0002;
32
+ public const uint KEYEVENTF_UNICODE = 0x0004;
33
+
34
+ [StructLayout(LayoutKind.Sequential)]
35
+ public struct MOUSEINPUT {
36
+ public int dx;
37
+ public int dy;
38
+ public uint mouseData;
39
+ public uint dwFlags;
40
+ public uint time;
41
+ public UIntPtr dwExtraInfo;
42
+ }
43
+
44
+ [StructLayout(LayoutKind.Sequential)]
45
+ public struct KEYBDINPUT {
46
+ public ushort wVk;
47
+ public ushort wScan;
48
+ public uint dwFlags;
49
+ public uint time;
50
+ public UIntPtr dwExtraInfo;
51
+ }
52
+
53
+ [StructLayout(LayoutKind.Explicit)]
54
+ public struct INPUTUNION {
55
+ [FieldOffset(0)] public MOUSEINPUT mi;
56
+ [FieldOffset(0)] public KEYBDINPUT ki;
57
+ }
58
+
59
+ [StructLayout(LayoutKind.Sequential)]
60
+ public struct INPUT {
61
+ public uint type;
62
+ public INPUTUNION U;
63
+ }
64
+
65
+ [DllImport("user32.dll")] public static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize);
66
+ [DllImport("user32.dll")] public static extern bool SetCursorPos(int X, int Y);
67
+ [DllImport("user32.dll")] public static extern bool SetProcessDPIAware();
68
+ [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
69
+
70
+ public static bool MouseButton(uint downFlag, uint upFlag) {
71
+ var inputs = new INPUT[2];
72
+ inputs[0].type = INPUT_MOUSE;
73
+ inputs[0].U.mi.dwFlags = downFlag;
74
+ inputs[1].type = INPUT_MOUSE;
75
+ inputs[1].U.mi.dwFlags = upFlag;
76
+ return SendInput(2, inputs, Marshal.SizeOf(typeof(INPUT))) == 2;
77
+ }
78
+
79
+ public static bool Key(ushort virtualKey, bool down) {
80
+ var input = new INPUT[1];
81
+ input[0].type = INPUT_KEYBOARD;
82
+ input[0].U.ki.wVk = virtualKey;
83
+ input[0].U.ki.dwFlags = down ? 0u : KEYEVENTF_KEYUP;
84
+ return SendInput(1, input, Marshal.SizeOf(typeof(INPUT))) == 1;
85
+ }
86
+
87
+ public static bool UnicodeText(string text) {
88
+ foreach (char c in text) {
89
+ var inputs = new INPUT[2];
90
+ inputs[0].type = INPUT_KEYBOARD;
91
+ inputs[0].U.ki.wScan = c;
92
+ inputs[0].U.ki.dwFlags = KEYEVENTF_UNICODE;
93
+ inputs[1].type = INPUT_KEYBOARD;
94
+ inputs[1].U.ki.wScan = c;
95
+ inputs[1].U.ki.dwFlags = KEYEVENTF_UNICODE | KEYEVENTF_KEYUP;
96
+ if (SendInput(2, inputs, Marshal.SizeOf(typeof(INPUT))) != 2) return false;
97
+ }
98
+ return true;
99
+ }
100
+ }
101
+ "@
102
+
103
+ [ChatRelayNative]::SetProcessDPIAware() | Out-Null
104
+ if (-not [System.Windows.Forms.SystemInformation]::UserInteractive) {
105
+ Fail "session_unavailable"
106
+ }
107
+
108
+ if ($Operation -eq "screenshot") {
109
+ $screen = [System.Windows.Forms.Screen]::PrimaryScreen
110
+ if ($null -eq $screen) { Fail "session_unavailable" }
111
+ $bounds = $screen.Bounds
112
+ if ($bounds.Width -le 0 -or $bounds.Height -le 0) { Fail "session_unavailable" }
113
+
114
+ $source = New-Object System.Drawing.Bitmap $bounds.Width, $bounds.Height
115
+ $graphics = [System.Drawing.Graphics]::FromImage($source)
116
+ try {
117
+ $graphics.CopyFromScreen($bounds.Left, $bounds.Top, 0, 0, $bounds.Size)
118
+ } finally {
119
+ $graphics.Dispose()
120
+ }
121
+
122
+ $codec = [System.Drawing.Imaging.ImageCodecInfo]::GetImageEncoders() |
123
+ Where-Object { $_.MimeType -eq "image/jpeg" } |
124
+ Select-Object -First 1
125
+ if ($null -eq $codec) {
126
+ $source.Dispose()
127
+ Fail "capture_failed"
128
+ }
129
+
130
+ $maxBytes = 32768
131
+ $targetWidth = [Math]::Min($bounds.Width, 960)
132
+ $scale = $targetWidth / [double]$bounds.Width
133
+ $targetHeight = [Math]::Max(1, [int][Math]::Round($bounds.Height * $scale))
134
+ $qualities = @(70L, 55L, 42L, 32L, 24L)
135
+ $bytes = $null
136
+ $finalWidth = $targetWidth
137
+ $finalHeight = $targetHeight
138
+
139
+ for ($sizeAttempt = 0; $sizeAttempt -lt 4 -and $null -eq $bytes; $sizeAttempt++) {
140
+ $resized = New-Object System.Drawing.Bitmap $targetWidth, $targetHeight
141
+ $draw = [System.Drawing.Graphics]::FromImage($resized)
142
+ try {
143
+ $draw.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
144
+ $draw.DrawImage($source, 0, 0, $targetWidth, $targetHeight)
145
+ } finally {
146
+ $draw.Dispose()
147
+ }
148
+
149
+ foreach ($quality in $qualities) {
150
+ $stream = New-Object System.IO.MemoryStream
151
+ $parameters = New-Object System.Drawing.Imaging.EncoderParameters 1
152
+ $parameters.Param[0] = New-Object System.Drawing.Imaging.EncoderParameter(
153
+ [System.Drawing.Imaging.Encoder]::Quality,
154
+ $quality
155
+ )
156
+ try {
157
+ $resized.Save($stream, $codec, $parameters)
158
+ if ($stream.Length -le $maxBytes) {
159
+ $bytes = $stream.ToArray()
160
+ $finalWidth = $targetWidth
161
+ $finalHeight = $targetHeight
162
+ break
163
+ }
164
+ } finally {
165
+ $parameters.Dispose()
166
+ $stream.Dispose()
167
+ }
168
+ }
169
+
170
+ $resized.Dispose()
171
+ if ($null -eq $bytes) {
172
+ $targetWidth = [Math]::Max(320, [int][Math]::Floor($targetWidth * 0.78))
173
+ $targetHeight = [Math]::Max(180, [int][Math]::Round($bounds.Height * ($targetWidth / [double]$bounds.Width)))
174
+ }
175
+ }
176
+
177
+ $source.Dispose()
178
+ if ($null -eq $bytes) { Fail "screenshot_too_large" }
179
+
180
+ Write-Result ([pscustomobject]@{
181
+ ok = $true
182
+ mimeType = "image/jpeg"
183
+ data = [Convert]::ToBase64String($bytes)
184
+ width = $finalWidth
185
+ height = $finalHeight
186
+ desktopOriginX = $bounds.Left
187
+ desktopOriginY = $bounds.Top
188
+ desktopWidth = $bounds.Width
189
+ desktopHeight = $bounds.Height
190
+ scaleX = $bounds.Width / [double]$finalWidth
191
+ scaleY = $bounds.Height / [double]$finalHeight
192
+ byteLength = $bytes.Length
193
+ })
194
+ exit 0
195
+ }
196
+
197
+ if ([ChatRelayNative]::GetForegroundWindow() -eq [IntPtr]::Zero) {
198
+ Fail "session_unavailable"
199
+ }
200
+
201
+ if ($Operation -eq "mouse_click") {
202
+ $screen = [System.Windows.Forms.Screen]::PrimaryScreen
203
+ if ($null -eq $screen) { Fail "session_unavailable" }
204
+ $bounds = $screen.Bounds
205
+ $x = [int]$payload.x
206
+ $y = [int]$payload.y
207
+ if ($x -lt $bounds.Left -or $x -ge $bounds.Right -or $y -lt $bounds.Top -or $y -ge $bounds.Bottom) {
208
+ Fail "invalid_coordinates"
209
+ }
210
+
211
+ switch ([string]$payload.button) {
212
+ "left" { $down = 0x0002; $up = 0x0004 }
213
+ "right" { $down = 0x0008; $up = 0x0010 }
214
+ "middle" { $down = 0x0020; $up = 0x0040 }
215
+ default { Fail "invalid_button" }
216
+ }
217
+
218
+ if (-not [ChatRelayNative]::SetCursorPos($x, $y)) { Fail "input_failed" }
219
+ $clicks = [int]$payload.clicks
220
+ for ($i = 0; $i -lt $clicks; $i++) {
221
+ if (-not [ChatRelayNative]::MouseButton($down, $up)) { Fail "input_failed" }
222
+ if ($i + 1 -lt $clicks) { Start-Sleep -Milliseconds 100 }
223
+ }
224
+
225
+ Write-Result ([pscustomobject]@{ ok = $true; action = "mouse_click" })
226
+ exit 0
227
+ }
228
+
229
+ if ($Operation -eq "keyboard_input") {
230
+ if ($null -ne $payload.text) {
231
+ if (-not [ChatRelayNative]::UnicodeText([string]$payload.text)) { Fail "input_failed" }
232
+ } else {
233
+ $mods = New-Object System.Collections.Generic.List[uint16]
234
+ if ($payload.ctrl) { $mods.Add(0x11) }
235
+ if ($payload.alt) { $mods.Add(0x12) }
236
+ if ($payload.shift) { $mods.Add(0x10) }
237
+ if ($payload.win) { $mods.Add(0x5B) }
238
+
239
+ foreach ($vk in $mods) {
240
+ if (-not [ChatRelayNative]::Key($vk, $true)) { Fail "input_failed" }
241
+ }
242
+ try {
243
+ $keyCode = [uint16]$payload.keyCode
244
+ if (-not [ChatRelayNative]::Key($keyCode, $true)) { Fail "input_failed" }
245
+ if (-not [ChatRelayNative]::Key($keyCode, $false)) { Fail "input_failed" }
246
+ } finally {
247
+ for ($i = $mods.Count - 1; $i -ge 0; $i--) {
248
+ [ChatRelayNative]::Key($mods[$i], $false) | Out-Null
249
+ }
250
+ }
251
+ }
252
+
253
+ Write-Result ([pscustomobject]@{ ok = $true; action = "keyboard_input" })
254
+ exit 0
255
+ }
256
+
257
+ Fail "unknown_desktop_operation"
258
+ } catch {
259
+ $message = [string]$_.Exception.Message
260
+ if ($message -match "session_unavailable") { Fail "session_unavailable" }
261
+ if ($Operation -eq "screenshot") { Fail "capture_failed" }
262
+ Fail "input_failed"
263
+ }