@livedesk/client 0.1.122 → 0.1.124
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.
- package/bin/livedesk-client-node.js +1655 -1476
- package/package.json +44 -44
|
@@ -1,1566 +1,1745 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
import net from 'net';
|
|
4
|
-
import os from 'os';
|
|
5
|
-
import path from 'path';
|
|
6
|
-
import crypto from 'crypto';
|
|
7
|
-
import { promises as fs, statfsSync } from 'fs';
|
|
8
|
-
import { spawn } from 'child_process';
|
|
9
|
-
|
|
10
|
-
const AGENT_VERSION = '0.1.24-livedesk.1';
|
|
11
|
-
const DEFAULT_MANAGER = '127.0.0.1:5197';
|
|
12
|
-
const DEFAULT_HEARTBEAT_MS = 5000;
|
|
13
|
-
const DEFAULT_RECONNECT_MS = 5000;
|
|
14
|
-
const EXIT_INVALID_PAIR_TOKEN = 23;
|
|
15
|
-
const DEFAULT_AI_MODEL = 'gpt-5.4-mini';
|
|
16
|
-
const DEFAULT_LIVE_FPS = 30;
|
|
17
|
-
const MAX_LIVE_FPS = 30;
|
|
18
|
-
const MAX_FRAME_BASE64_CHARS = 3 * 1024 * 1024;
|
|
19
|
-
const MAX_AI_OUTPUT_CHARS = 6000;
|
|
20
|
-
const MAX_FILE_TRANSFER_FILES = 24;
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import net from 'net';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import crypto from 'crypto';
|
|
7
|
+
import { promises as fs, statfsSync } from 'fs';
|
|
8
|
+
import { spawn } from 'child_process';
|
|
9
|
+
|
|
10
|
+
const AGENT_VERSION = '0.1.24-livedesk.1';
|
|
11
|
+
const DEFAULT_MANAGER = '127.0.0.1:5197';
|
|
12
|
+
const DEFAULT_HEARTBEAT_MS = 5000;
|
|
13
|
+
const DEFAULT_RECONNECT_MS = 5000;
|
|
14
|
+
const EXIT_INVALID_PAIR_TOKEN = 23;
|
|
15
|
+
const DEFAULT_AI_MODEL = 'gpt-5.4-mini';
|
|
16
|
+
const DEFAULT_LIVE_FPS = 30;
|
|
17
|
+
const MAX_LIVE_FPS = 30;
|
|
18
|
+
const MAX_FRAME_BASE64_CHARS = 3 * 1024 * 1024;
|
|
19
|
+
const MAX_AI_OUTPUT_CHARS = 6000;
|
|
20
|
+
const MAX_FILE_TRANSFER_FILES = 24;
|
|
21
21
|
const MAX_FILE_TRANSFER_BYTES = 24 * 1024 * 1024;
|
|
22
22
|
const MAX_AGENT_OUTPUT_CHARS = 32000;
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
--
|
|
34
|
-
--
|
|
35
|
-
--
|
|
36
|
-
--
|
|
37
|
-
--
|
|
38
|
-
--
|
|
39
|
-
--
|
|
40
|
-
--
|
|
41
|
-
--
|
|
42
|
-
--
|
|
43
|
-
--
|
|
44
|
-
--
|
|
45
|
-
--
|
|
46
|
-
--
|
|
47
|
-
--ai
|
|
48
|
-
--
|
|
49
|
-
--fake-
|
|
50
|
-
--
|
|
51
|
-
--exit-on-
|
|
52
|
-
--
|
|
53
|
-
--
|
|
54
|
-
--
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
result.
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
result.
|
|
164
|
-
result.
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
result.
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
case '
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
case '
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
text =
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
await fs.
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
const
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
const
|
|
317
|
-
const
|
|
318
|
-
const
|
|
319
|
-
const
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
const
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
23
|
+
const launchedProcessRegistry = new Map();
|
|
24
|
+
|
|
25
|
+
function printHelp() {
|
|
26
|
+
console.log(`
|
|
27
|
+
LiveDesk Client
|
|
28
|
+
|
|
29
|
+
Usage:
|
|
30
|
+
npx @livedesk/client connect --manager 127.0.0.1:5197 --pair <token>
|
|
31
|
+
|
|
32
|
+
Options:
|
|
33
|
+
--manager <host:port> LiveDesk Hub address. Default: ${DEFAULT_MANAGER}
|
|
34
|
+
--pair <token> LiveDesk Hub pairing token. Can also use LIVEDESK_CLIENT_PAIR_TOKEN.
|
|
35
|
+
--slot <number> Screen wall slot number for this computer.
|
|
36
|
+
--name <name> Friendly device name. Default: OS hostname.
|
|
37
|
+
--heartbeat <ms> Status heartbeat interval. Default: ${DEFAULT_HEARTBEAT_MS}
|
|
38
|
+
--device-id <id> Stable device id. Default: generated and saved per OS user.
|
|
39
|
+
--thumbnail Enable thumbnail capture when supported. Default on Windows.
|
|
40
|
+
--no-thumbnail Disable thumbnail capture capability.
|
|
41
|
+
--live Enable focused live screen streaming. Default on.
|
|
42
|
+
--no-live Disable focused live screen streaming.
|
|
43
|
+
--tasks Enable safe remote task inbox. Default on.
|
|
44
|
+
--no-tasks Disable remote task dispatch capability.
|
|
45
|
+
--files-dir <path> Default folder for received files. Default: ~/Desktop/LiveDeskFiles.
|
|
46
|
+
--ai Enable OpenAI-backed remote AI assist tasks.
|
|
47
|
+
--no-ai Disable OpenAI-backed remote AI assist tasks.
|
|
48
|
+
--ai-model <model> OpenAI model for AI assist. Default: ${DEFAULT_AI_MODEL}
|
|
49
|
+
--fake-ai Use deterministic AI assist responses for smoke tests.
|
|
50
|
+
--fake-thumbnail Use generated thumbnail frames for smoke tests.
|
|
51
|
+
--exit-on-disconnect Exit after Hub disconnect. Useful for tests.
|
|
52
|
+
--exit-on-invalid-pair Exit when the Hub rejects the pair token.
|
|
53
|
+
--once Connect, send one status packet, and exit after welcome.
|
|
54
|
+
--version Show the agent version.
|
|
55
|
+
--help Show this help.
|
|
56
|
+
`.trim());
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isTruthy(value) {
|
|
60
|
+
return /^(1|true|yes|on)$/i.test(String(value || '').trim());
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isFalsy(value) {
|
|
64
|
+
return /^(0|false|no|off)$/i.test(String(value || '').trim());
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isNativeDesktopCaptureEnabledByDefault() {
|
|
68
|
+
const platform = os.platform();
|
|
69
|
+
const arch = os.arch();
|
|
70
|
+
if (platform === 'win32' || platform === 'darwin') {
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
if (platform === 'linux') {
|
|
74
|
+
return ['x64', 'arm64', 'loong64'].includes(arch);
|
|
75
|
+
}
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function parseArgs(argv) {
|
|
80
|
+
const defaultDesktopCaptureEnabled = isNativeDesktopCaptureEnabledByDefault();
|
|
81
|
+
const result = {
|
|
82
|
+
command: 'connect',
|
|
83
|
+
manager: process.env.LIVEDESK_CLIENT_MANAGER || process.env.MINDEXEC_REMOTE_MANAGER || DEFAULT_MANAGER,
|
|
84
|
+
pair: process.env.LIVEDESK_CLIENT_PAIR_TOKEN || process.env.MINDEXEC_REMOTE_PAIR_TOKEN || '',
|
|
85
|
+
slotNumber: normalizeSlotNumber(process.env.LIVEDESK_CLIENT_SLOT || process.env.MINDEXEC_REMOTE_SLOT),
|
|
86
|
+
name: process.env.LIVEDESK_CLIENT_NAME || process.env.MINDEXEC_REMOTE_NAME || os.hostname(),
|
|
87
|
+
heartbeatMs: DEFAULT_HEARTBEAT_MS,
|
|
88
|
+
deviceId: '',
|
|
89
|
+
thumbnailEnabled: defaultDesktopCaptureEnabled && !isFalsy(process.env.LIVEDESK_CLIENT_THUMBNAIL ?? process.env.MINDEXEC_REMOTE_THUMBNAIL),
|
|
90
|
+
liveEnabled: defaultDesktopCaptureEnabled && !isFalsy(process.env.LIVEDESK_CLIENT_LIVE ?? process.env.MINDEXEC_REMOTE_LIVE),
|
|
91
|
+
taskEnabled: !isFalsy(process.env.LIVEDESK_CLIENT_TASKS ?? process.env.MINDEXEC_REMOTE_TASKS),
|
|
92
|
+
filesDir: process.env.LIVEDESK_CLIENT_FILES_DIR || process.env.MINDEXEC_REMOTE_FILES_DIR || '',
|
|
93
|
+
aiEnabled: isTruthy(process.env.LIVEDESK_CLIENT_AI || process.env.MINDEXEC_REMOTE_AI),
|
|
94
|
+
aiModel: process.env.LIVEDESK_CLIENT_AI_MODEL || process.env.MINDEXEC_REMOTE_AI_MODEL || process.env.OPENAI_MODEL || DEFAULT_AI_MODEL,
|
|
95
|
+
openAiApiKey: process.env.OPENAI_API_KEY || '',
|
|
96
|
+
fakeAi: isTruthy(process.env.LIVEDESK_CLIENT_FAKE_AI || process.env.MINDEXEC_REMOTE_FAKE_AI),
|
|
97
|
+
fakeThumbnail: isTruthy(process.env.LIVEDESK_CLIENT_FAKE_THUMBNAIL || process.env.MINDEXEC_REMOTE_FAKE_THUMBNAIL),
|
|
98
|
+
exitOnDisconnect: false,
|
|
99
|
+
exitOnInvalidPair: false,
|
|
100
|
+
once: false,
|
|
101
|
+
version: false,
|
|
102
|
+
help: false
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const args = [...argv];
|
|
106
|
+
if (args[0] && !args[0].startsWith('-')) {
|
|
107
|
+
result.command = args.shift();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
111
|
+
const arg = args[index];
|
|
112
|
+
switch (arg) {
|
|
113
|
+
case '--manager':
|
|
114
|
+
result.manager = args[++index] || result.manager;
|
|
115
|
+
break;
|
|
116
|
+
case '--pair':
|
|
117
|
+
result.pair = args[++index] || '';
|
|
118
|
+
break;
|
|
119
|
+
case '--slot':
|
|
120
|
+
result.slotNumber = normalizeSlotNumber(args[++index] || result.slotNumber);
|
|
121
|
+
break;
|
|
122
|
+
case '--name':
|
|
123
|
+
result.name = args[++index] || result.name;
|
|
124
|
+
break;
|
|
125
|
+
case '--heartbeat':
|
|
126
|
+
result.heartbeatMs = Number(args[++index] || DEFAULT_HEARTBEAT_MS);
|
|
127
|
+
break;
|
|
128
|
+
case '--device-id':
|
|
129
|
+
result.deviceId = args[++index] || '';
|
|
130
|
+
break;
|
|
131
|
+
case '--thumbnail':
|
|
132
|
+
result.thumbnailEnabled = true;
|
|
133
|
+
break;
|
|
134
|
+
case '--no-thumbnail':
|
|
135
|
+
result.thumbnailEnabled = false;
|
|
136
|
+
break;
|
|
137
|
+
case '--live':
|
|
138
|
+
result.liveEnabled = true;
|
|
139
|
+
break;
|
|
140
|
+
case '--no-live':
|
|
141
|
+
result.liveEnabled = false;
|
|
142
|
+
break;
|
|
143
|
+
case '--tasks':
|
|
144
|
+
result.taskEnabled = true;
|
|
145
|
+
break;
|
|
146
|
+
case '--no-tasks':
|
|
147
|
+
result.taskEnabled = false;
|
|
148
|
+
break;
|
|
149
|
+
case '--files-dir':
|
|
150
|
+
result.filesDir = args[++index] || result.filesDir;
|
|
151
|
+
break;
|
|
152
|
+
case '--ai':
|
|
153
|
+
result.aiEnabled = true;
|
|
154
|
+
result.taskEnabled = true;
|
|
155
|
+
break;
|
|
156
|
+
case '--no-ai':
|
|
157
|
+
result.aiEnabled = false;
|
|
158
|
+
break;
|
|
159
|
+
case '--ai-model':
|
|
160
|
+
result.aiModel = args[++index] || result.aiModel;
|
|
161
|
+
break;
|
|
162
|
+
case '--fake-ai':
|
|
163
|
+
result.fakeAi = true;
|
|
164
|
+
result.aiEnabled = true;
|
|
165
|
+
result.taskEnabled = true;
|
|
166
|
+
break;
|
|
167
|
+
case '--fake-thumbnail':
|
|
168
|
+
result.fakeThumbnail = true;
|
|
169
|
+
result.thumbnailEnabled = true;
|
|
170
|
+
break;
|
|
171
|
+
case '--exit-on-disconnect':
|
|
172
|
+
result.exitOnDisconnect = true;
|
|
173
|
+
break;
|
|
174
|
+
case '--exit-on-invalid-pair':
|
|
175
|
+
result.exitOnInvalidPair = true;
|
|
176
|
+
break;
|
|
177
|
+
case '--once':
|
|
178
|
+
result.once = true;
|
|
179
|
+
break;
|
|
180
|
+
case '--help':
|
|
181
|
+
case '-h':
|
|
182
|
+
result.help = true;
|
|
183
|
+
break;
|
|
184
|
+
case '--version':
|
|
185
|
+
case '-v':
|
|
186
|
+
result.version = true;
|
|
187
|
+
break;
|
|
188
|
+
default:
|
|
189
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (!Number.isFinite(result.heartbeatMs) || result.heartbeatMs < 1000) {
|
|
194
|
+
result.heartbeatMs = DEFAULT_HEARTBEAT_MS;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return result;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function parseManagerAddress(value) {
|
|
201
|
+
let text = String(value || DEFAULT_MANAGER).trim();
|
|
202
|
+
text = text.replace(/^tcp:\/\//i, '');
|
|
203
|
+
const separator = text.lastIndexOf(':');
|
|
204
|
+
if (separator <= 0) {
|
|
205
|
+
return {
|
|
206
|
+
host: text || '127.0.0.1',
|
|
207
|
+
port: 5197
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const host = text.slice(0, separator).trim() || '127.0.0.1';
|
|
212
|
+
const port = Number(text.slice(separator + 1));
|
|
213
|
+
return {
|
|
214
|
+
host,
|
|
215
|
+
port: Number.isFinite(port) ? port : 5197
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function normalizeDeviceId(value) {
|
|
220
|
+
return String(value || '').trim().replace(/[^a-zA-Z0-9_.:-]/g, '-').slice(0, 128);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function normalizeSlotNumber(value) {
|
|
224
|
+
const number = Number(String(value || '').trim());
|
|
225
|
+
return Number.isInteger(number) && number >= 1 && number <= 999 ? number : 0;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function getDeviceId(explicitDeviceId = '') {
|
|
229
|
+
const explicit = normalizeDeviceId(explicitDeviceId);
|
|
230
|
+
if (explicit) {
|
|
231
|
+
return explicit;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const stateDir = path.join(os.homedir(), '.livedesk-client');
|
|
235
|
+
const statePath = path.join(stateDir, 'device.json');
|
|
236
|
+
try {
|
|
237
|
+
const state = JSON.parse(await fs.readFile(statePath, 'utf8'));
|
|
238
|
+
const existing = normalizeDeviceId(state.deviceId);
|
|
239
|
+
if (existing) {
|
|
240
|
+
return existing;
|
|
241
|
+
}
|
|
242
|
+
} catch {
|
|
243
|
+
// Create a new device id below.
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const deviceId = `livedesk-${crypto.randomUUID()}`;
|
|
247
|
+
await fs.mkdir(stateDir, { recursive: true });
|
|
248
|
+
await fs.writeFile(statePath, JSON.stringify({
|
|
249
|
+
deviceId,
|
|
250
|
+
createdAt: new Date().toISOString()
|
|
251
|
+
}, null, 2));
|
|
252
|
+
return deviceId;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function getRootDiskStatus() {
|
|
256
|
+
const root = path.parse(os.homedir()).root || '/';
|
|
257
|
+
try {
|
|
258
|
+
const stats = statfsSync(root);
|
|
259
|
+
const totalBytes = Number(stats.blocks) * Number(stats.bsize);
|
|
260
|
+
const freeBytes = Number(stats.bavail) * Number(stats.bsize);
|
|
261
|
+
return {
|
|
262
|
+
root,
|
|
263
|
+
totalBytes,
|
|
264
|
+
freeBytes,
|
|
265
|
+
usedRatio: totalBytes > 0 ? Number(((totalBytes - freeBytes) / totalBytes).toFixed(4)) : 0
|
|
266
|
+
};
|
|
267
|
+
} catch {
|
|
268
|
+
return { root, totalBytes: 0, freeBytes: 0, usedRatio: 0 };
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function optionalNumber(value) {
|
|
273
|
+
const number = Number(value);
|
|
274
|
+
return Number.isFinite(number) ? number : undefined;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function optionalRatio(value) {
|
|
278
|
+
const number = optionalNumber(value);
|
|
279
|
+
if (number === undefined) {
|
|
280
|
+
return undefined;
|
|
281
|
+
}
|
|
282
|
+
return Math.max(0, Math.min(1, number > 1 ? number / 100 : number));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function getAcceleratorStatus() {
|
|
286
|
+
return {
|
|
287
|
+
gpu: {
|
|
288
|
+
name: process.env.LIVEDESK_GPU_NAME || process.env.MINDEXEC_GPU_NAME || '',
|
|
289
|
+
usageRatio: optionalRatio(process.env.LIVEDESK_GPU_USAGE_RATIO || process.env.MINDEXEC_GPU_USAGE_RATIO),
|
|
290
|
+
temperatureC: optionalNumber(process.env.LIVEDESK_GPU_TEMPERATURE_C || process.env.MINDEXEC_GPU_TEMPERATURE_C)
|
|
291
|
+
},
|
|
292
|
+
npu: {
|
|
293
|
+
name: process.env.LIVEDESK_NPU_NAME || process.env.MINDEXEC_NPU_NAME || '',
|
|
294
|
+
usageRatio: optionalRatio(process.env.LIVEDESK_NPU_USAGE_RATIO || process.env.MINDEXEC_NPU_USAGE_RATIO),
|
|
295
|
+
tops: optionalNumber(process.env.LIVEDESK_NPU_TOPS || process.env.MINDEXEC_NPU_TOPS)
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function getNetworkStatus() {
|
|
301
|
+
const interfaces = os.networkInterfaces();
|
|
302
|
+
const activeName = Object.entries(interfaces).find(([, addresses]) =>
|
|
303
|
+
Array.isArray(addresses) && addresses.some(address => !address.internal && address.family === 'IPv4')
|
|
304
|
+
)?.[0] || '';
|
|
305
|
+
return {
|
|
306
|
+
interface: activeName,
|
|
307
|
+
link: activeName || '',
|
|
308
|
+
rxMbps: optionalNumber(process.env.LIVEDESK_NETWORK_RX_MBPS || process.env.MINDEXEC_NETWORK_RX_MBPS),
|
|
309
|
+
txMbps: optionalNumber(process.env.LIVEDESK_NETWORK_TX_MBPS || process.env.MINDEXEC_NETWORK_TX_MBPS),
|
|
310
|
+
latencyMs: optionalNumber(process.env.LIVEDESK_NETWORK_LATENCY_MS || process.env.MINDEXEC_NETWORK_LATENCY_MS),
|
|
311
|
+
usageRatio: optionalRatio(process.env.LIVEDESK_NETWORK_USAGE_RATIO || process.env.MINDEXEC_NETWORK_USAGE_RATIO)
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function getStatus(options = {}) {
|
|
316
|
+
const totalMem = os.totalmem();
|
|
317
|
+
const freeMem = os.freemem();
|
|
318
|
+
const cpus = os.cpus();
|
|
319
|
+
const loadavg = os.loadavg();
|
|
320
|
+
const cpuCores = Math.max(1, cpus.length || os.availableParallelism?.() || 1);
|
|
321
|
+
const cpuUsageRatio = loadavg[0] > 0
|
|
322
|
+
? Number(Math.min(1, loadavg[0] / cpuCores).toFixed(4))
|
|
323
|
+
: 0;
|
|
324
|
+
const usedMemRatio = totalMem > 0 ? Number(((totalMem - freeMem) / totalMem).toFixed(4)) : 0;
|
|
325
|
+
const acceleratorStatus = getAcceleratorStatus();
|
|
326
|
+
const status = {
|
|
327
|
+
uptimeSec: Math.round(os.uptime()),
|
|
328
|
+
loadavg,
|
|
329
|
+
totalMem,
|
|
330
|
+
freeMem,
|
|
331
|
+
usedMemRatio,
|
|
332
|
+
platform: os.platform(),
|
|
333
|
+
release: os.release(),
|
|
334
|
+
role: options.aiEnabled ? 'Agent worker' : options.taskEnabled ? 'Task worker' : 'Local machine',
|
|
335
|
+
cpu: {
|
|
336
|
+
cores: cpuCores,
|
|
337
|
+
model: cpus[0]?.model || '',
|
|
338
|
+
speedMHz: cpus[0]?.speed || 0,
|
|
339
|
+
usageRatio: cpuUsageRatio,
|
|
340
|
+
load1: loadavg[0] || 0
|
|
341
|
+
},
|
|
342
|
+
memory: {
|
|
343
|
+
totalBytes: totalMem,
|
|
344
|
+
freeBytes: freeMem,
|
|
345
|
+
usedRatio: usedMemRatio
|
|
346
|
+
},
|
|
347
|
+
screenCount: 1,
|
|
348
|
+
monitorCount: 1,
|
|
349
|
+
screens: {
|
|
350
|
+
count: 1,
|
|
351
|
+
displays: []
|
|
352
|
+
},
|
|
353
|
+
disk: getRootDiskStatus(),
|
|
354
|
+
gpu: acceleratorStatus.gpu,
|
|
355
|
+
npu: acceleratorStatus.npu,
|
|
356
|
+
network: getNetworkStatus(),
|
|
357
|
+
workload: {
|
|
358
|
+
role: options.aiEnabled ? 'Agent worker' : options.taskEnabled ? 'Task worker' : 'Local machine',
|
|
359
|
+
status: 'idle',
|
|
360
|
+
title: 'idle'
|
|
361
|
+
},
|
|
362
|
+
timestamp: new Date().toISOString()
|
|
363
|
+
};
|
|
364
|
+
if (options.slotNumber) {
|
|
365
|
+
status.slotNumber = options.slotNumber;
|
|
366
|
+
}
|
|
367
|
+
return status;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function writeJsonLine(socket, payload) {
|
|
371
|
+
if (!socket || socket.destroyed) {
|
|
372
|
+
return false;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
socket.write(`${JSON.stringify(payload)}\n`);
|
|
376
|
+
return true;
|
|
377
|
+
}
|
|
378
|
+
|
|
378
379
|
function wait(ms) {
|
|
379
380
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
380
381
|
}
|
|
381
382
|
|
|
382
|
-
function
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
const text = String(value || '')
|
|
399
|
-
.replace(/\0/g, '')
|
|
400
|
-
.replace(/[<>:"|?*\x00-\x1f]/g, '_')
|
|
401
|
-
.replace(/[\\/]+/g, '_')
|
|
402
|
-
.trim();
|
|
403
|
-
const normalized = text && text !== '.' && text !== '..' ? text : fallback;
|
|
404
|
-
return normalized.slice(0, 160);
|
|
383
|
+
async function waitForNodeServiceState(executable, service, expectedState, timeoutMs = 15000) {
|
|
384
|
+
const deadline = Date.now() + Math.max(1000, Math.min(30000, timeoutMs));
|
|
385
|
+
while (Date.now() < deadline) {
|
|
386
|
+
const query = process.platform === 'win32'
|
|
387
|
+
? await runNodeAgentProcess(executable, ['query', service], 5000)
|
|
388
|
+
: await runNodeAgentProcess(executable, ['is-active', service], 5000);
|
|
389
|
+
const output = String(query.output || '').toLowerCase();
|
|
390
|
+
const reached = process.platform === 'win32'
|
|
391
|
+
? output.includes(String(expectedState).toLowerCase())
|
|
392
|
+
: expectedState === 'running'
|
|
393
|
+
? output.includes('active') && !output.includes('inactive')
|
|
394
|
+
: output.includes('inactive') || output.includes('dead') || output.includes('failed');
|
|
395
|
+
if (reached) return query;
|
|
396
|
+
await wait(250);
|
|
397
|
+
}
|
|
398
|
+
throw new Error(`Service ${service} did not reach ${expectedState} state in time.`);
|
|
405
399
|
}
|
|
406
|
-
|
|
407
|
-
function
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
400
|
+
|
|
401
|
+
function getDefaultFilesDir() {
|
|
402
|
+
return path.join(os.homedir(), 'Desktop', 'LiveDeskFiles');
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function normalizeDirectoryPath(value, fallback = getDefaultFilesDir()) {
|
|
406
|
+
const text = String(value || '').replace(/\0/g, '').trim();
|
|
407
|
+
if (!text) {
|
|
408
|
+
return fallback;
|
|
409
|
+
}
|
|
410
|
+
if (path.isAbsolute(text)) {
|
|
411
|
+
return path.resolve(text);
|
|
412
|
+
}
|
|
413
|
+
return path.resolve(fallback, text);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function sanitizePathSegment(value, fallback = 'file') {
|
|
417
|
+
const text = String(value || '')
|
|
418
|
+
.replace(/\0/g, '')
|
|
419
|
+
.replace(/[<>:"|?*\x00-\x1f]/g, '_')
|
|
420
|
+
.replace(/[\\/]+/g, '_')
|
|
421
|
+
.trim();
|
|
422
|
+
const normalized = text && text !== '.' && text !== '..' ? text : fallback;
|
|
423
|
+
return normalized.slice(0, 160);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function sanitizeRelativeFilePath(value, fallbackName = 'file') {
|
|
427
|
+
const parts = String(value || '')
|
|
428
|
+
.replace(/\0/g, '')
|
|
429
|
+
.split(/[\\/]+/)
|
|
430
|
+
.map(part => sanitizePathSegment(part, ''))
|
|
431
|
+
.filter(Boolean)
|
|
432
|
+
.filter(part => part !== '.' && part !== '..');
|
|
433
|
+
if (parts.length === 0) {
|
|
434
|
+
return sanitizePathSegment(fallbackName, 'file');
|
|
435
|
+
}
|
|
436
|
+
return path.join(...parts.slice(-8));
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async function handleFileTransferCommand(options, payload = {}) {
|
|
440
|
+
const files = Array.isArray(payload.files) ? payload.files.slice(0, MAX_FILE_TRANSFER_FILES) : [];
|
|
441
|
+
if (files.length === 0) {
|
|
442
|
+
throw new Error('No files were included in the transfer.');
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const baseDir = normalizeDirectoryPath(payload.remoteDirectory || options.filesDir);
|
|
446
|
+
await fs.mkdir(baseDir, { recursive: true });
|
|
447
|
+
|
|
448
|
+
let totalBytes = 0;
|
|
449
|
+
const saved = [];
|
|
450
|
+
for (const file of files) {
|
|
451
|
+
const name = sanitizePathSegment(file?.name, 'file');
|
|
452
|
+
const relativePath = sanitizeRelativeFilePath(file?.relativePath || name, name);
|
|
453
|
+
const targetPath = path.resolve(baseDir, relativePath);
|
|
454
|
+
const relativeFromBase = path.relative(baseDir, targetPath);
|
|
455
|
+
if (relativeFromBase.startsWith('..') || path.isAbsolute(relativeFromBase)) {
|
|
456
|
+
throw new Error(`Unsafe file path: ${relativePath}`);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const dataBase64 = String(file?.dataBase64 || '').replace(/^data:[^,]*,/i, '').trim();
|
|
460
|
+
if (!dataBase64) {
|
|
461
|
+
throw new Error(`Missing file data: ${name}`);
|
|
462
|
+
}
|
|
463
|
+
const buffer = Buffer.from(dataBase64, 'base64');
|
|
464
|
+
totalBytes += buffer.length;
|
|
465
|
+
if (totalBytes > MAX_FILE_TRANSFER_BYTES) {
|
|
466
|
+
throw new Error('File transfer exceeded the local size limit.');
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
470
|
+
await fs.writeFile(targetPath, buffer);
|
|
471
|
+
saved.push({
|
|
472
|
+
name,
|
|
473
|
+
relativePath,
|
|
474
|
+
path: targetPath,
|
|
475
|
+
bytes: buffer.length
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return {
|
|
480
|
+
kind: 'file.transfer',
|
|
481
|
+
transferId: String(payload.transferId || '').slice(0, 128),
|
|
482
|
+
status: 'completed',
|
|
483
|
+
directory: baseDir,
|
|
484
|
+
files: saved,
|
|
485
|
+
totalBytes,
|
|
486
|
+
completedAt: new Date().toISOString()
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async function handleFileTransferChunkCommand(options, payload = {}) {
|
|
491
|
+
const baseDir = normalizeDirectoryPath(payload.remoteDirectory || options.filesDir);
|
|
492
|
+
const name = sanitizePathSegment(payload.name, 'file');
|
|
493
|
+
const relativePath = sanitizeRelativeFilePath(payload.relativePath || name, name);
|
|
494
|
+
const targetPath = path.resolve(baseDir, relativePath);
|
|
495
|
+
const relativeFromBase = path.relative(baseDir, targetPath);
|
|
496
|
+
if (relativeFromBase.startsWith('..') || path.isAbsolute(relativeFromBase)) {
|
|
497
|
+
throw new Error(`Unsafe file path: ${relativePath}`);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const transferId = String(payload.transferId || '').replace(/[^a-zA-Z0-9_.:-]/g, '-').slice(0, 128);
|
|
501
|
+
const offset = Math.max(0, Math.floor(Number(payload.offset) || 0));
|
|
502
|
+
const totalBytes = Math.max(0, Math.floor(Number(payload.totalBytes) || 0));
|
|
503
|
+
const final = payload.final === true;
|
|
504
|
+
const buffer = payload.dataBase64 ? Buffer.from(String(payload.dataBase64), 'base64') : Buffer.alloc(0);
|
|
505
|
+
if (!transferId || offset > totalBytes || offset + buffer.length > totalBytes || buffer.length > 512 * 1024) {
|
|
506
|
+
throw new Error('Invalid file transfer chunk.');
|
|
507
|
+
}
|
|
508
|
+
if (buffer.length === 0 && !(final && totalBytes === 0 && offset === 0)) {
|
|
509
|
+
throw new Error('Empty file chunks are not allowed.');
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
513
|
+
const tempPath = `${targetPath}.livedesk-${transferId}.part`;
|
|
514
|
+
const handle = await fs.open(tempPath, offset === 0 ? 'w+' : 'r+');
|
|
515
|
+
let completedSize = 0;
|
|
516
|
+
try {
|
|
517
|
+
const before = await handle.stat();
|
|
518
|
+
if (offset > before.size) {
|
|
519
|
+
throw new Error(`File chunk gap detected at ${offset}; current length is ${before.size}.`);
|
|
520
|
+
}
|
|
521
|
+
if (buffer.length > 0) {
|
|
522
|
+
await handle.write(buffer, 0, buffer.length, offset);
|
|
523
|
+
}
|
|
524
|
+
const after = await handle.stat();
|
|
525
|
+
completedSize = after.size;
|
|
526
|
+
if (final) {
|
|
527
|
+
await handle.sync();
|
|
528
|
+
if (after.size !== totalBytes) {
|
|
529
|
+
throw new Error(`File transfer is incomplete (${after.size}/${totalBytes}).`);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
} finally {
|
|
533
|
+
await handle.close();
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (final) {
|
|
537
|
+
await fs.rm(targetPath, { force: true });
|
|
538
|
+
await fs.rename(tempPath, targetPath);
|
|
539
|
+
const lastModified = Number(payload.lastModified || 0);
|
|
540
|
+
if (lastModified > 0) {
|
|
541
|
+
const modifiedAt = new Date(lastModified);
|
|
542
|
+
await fs.utimes(targetPath, modifiedAt, modifiedAt).catch(() => undefined);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
return {
|
|
547
|
+
kind: 'file.transfer.chunk',
|
|
548
|
+
transferId,
|
|
549
|
+
status: final ? 'completed' : 'receiving',
|
|
550
|
+
relativePath: relativePath.split(path.sep).join('/'),
|
|
551
|
+
offset,
|
|
552
|
+
bytes: buffer.length,
|
|
553
|
+
totalBytes,
|
|
554
|
+
completedSize,
|
|
555
|
+
final,
|
|
556
|
+
completedAt: final ? new Date().toISOString() : null
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function clampNumber(value, min, max, fallback) {
|
|
561
|
+
const number = Number(value);
|
|
562
|
+
if (!Number.isFinite(number)) {
|
|
563
|
+
return fallback;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
return Math.max(min, Math.min(max, Math.floor(number)));
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function createFakeThumbnailFrame(options, payload, frameSeq) {
|
|
570
|
+
const width = clampNumber(payload?.maxWidth, 160, 3840, 360);
|
|
571
|
+
const height = clampNumber(payload?.maxHeight, 90, 2160, 220);
|
|
572
|
+
const now = new Date().toISOString();
|
|
573
|
+
const title = String(options.name || os.hostname()).replace(/[<>&"']/g, '_');
|
|
574
|
+
const svg = [
|
|
575
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`,
|
|
576
|
+
'<rect width="100%" height="100%" fill="#0f172a"/>',
|
|
577
|
+
'<rect x="14" y="14" width="calc(100% - 28px)" height="calc(100% - 28px)" rx="12" fill="#1e293b" stroke="#38bdf8" stroke-width="2"/>',
|
|
578
|
+
`<text x="28" y="48" fill="#e2e8f0" font-family="Arial, sans-serif" font-size="20" font-weight="700">${title}</text>`,
|
|
579
|
+
`<text x="28" y="82" fill="#93c5fd" font-family="Arial, sans-serif" font-size="14">LiveDesk Client Thumbnail</text>`,
|
|
580
|
+
`<text x="28" y="${height - 30}" fill="#94a3b8" font-family="Consolas, monospace" font-size="12">${now}</text>`,
|
|
581
|
+
'</svg>'
|
|
582
|
+
].join('');
|
|
583
|
+
|
|
584
|
+
return {
|
|
585
|
+
frameSeq,
|
|
586
|
+
width,
|
|
587
|
+
height,
|
|
588
|
+
sourceWidth: width,
|
|
589
|
+
sourceHeight: height,
|
|
590
|
+
mimeType: 'image/svg+xml',
|
|
591
|
+
data: Buffer.from(svg, 'utf8').toString('base64'),
|
|
592
|
+
capturedAt: now
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function readJpegDimensions(buffer) {
|
|
597
|
+
if (!Buffer.isBuffer(buffer) || buffer.length < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) {
|
|
598
|
+
return { width: 0, height: 0 };
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
let offset = 2;
|
|
602
|
+
while (offset + 9 < buffer.length) {
|
|
603
|
+
if (buffer[offset] !== 0xff) {
|
|
604
|
+
offset += 1;
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const marker = buffer[offset + 1];
|
|
609
|
+
const blockLength = buffer.readUInt16BE(offset + 2);
|
|
610
|
+
if (blockLength < 2) {
|
|
611
|
+
break;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const isStartOfFrame = marker >= 0xc0
|
|
615
|
+
&& marker <= 0xcf
|
|
616
|
+
&& ![0xc4, 0xc8, 0xcc].includes(marker);
|
|
617
|
+
if (isStartOfFrame) {
|
|
618
|
+
return {
|
|
619
|
+
height: buffer.readUInt16BE(offset + 5),
|
|
620
|
+
width: buffer.readUInt16BE(offset + 7)
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
offset += 2 + blockLength;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
return { width: 0, height: 0 };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function normalizeMonitorIndex(value, fallback = 0) {
|
|
631
|
+
return clampInteger(value, 0, 63, fallback);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async function captureNativeDesktopFrame(frameSeq, monitorIndex = 0) {
|
|
635
|
+
const screenshotModule = await import('node-screenshots');
|
|
636
|
+
const Monitor = screenshotModule.Monitor || screenshotModule.default?.Monitor;
|
|
637
|
+
if (!Monitor?.all) {
|
|
638
|
+
throw new Error('node-screenshots Monitor API is unavailable.');
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const monitors = Monitor.all();
|
|
642
|
+
const selectedIndex = Math.max(0, Math.min(monitors.length - 1, normalizeMonitorIndex(monitorIndex)));
|
|
643
|
+
const monitor = monitors[selectedIndex] || monitors.find(item => item?.isPrimary?.() === true) || monitors[0];
|
|
644
|
+
if (!monitor?.captureImage) {
|
|
645
|
+
throw new Error('No capturable desktop monitor was found.');
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const image = await monitor.captureImage();
|
|
649
|
+
const jpeg = await image.toJpeg();
|
|
650
|
+
const buffer = Buffer.isBuffer(jpeg) ? jpeg : Buffer.from(jpeg);
|
|
651
|
+
if (buffer.toString('base64').length > MAX_FRAME_BASE64_CHARS) {
|
|
652
|
+
throw new Error('Desktop capture exceeded the remote frame size limit.');
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const dimensions = readJpegDimensions(buffer);
|
|
656
|
+
return {
|
|
657
|
+
frameSeq,
|
|
658
|
+
width: dimensions.width,
|
|
659
|
+
height: dimensions.height,
|
|
660
|
+
sourceWidth: dimensions.width,
|
|
661
|
+
sourceHeight: dimensions.height,
|
|
662
|
+
mimeType: 'image/jpeg',
|
|
663
|
+
data: buffer.toString('base64'),
|
|
664
|
+
monitorIndex: selectedIndex,
|
|
665
|
+
monitorCount: Math.max(1, monitors.length),
|
|
666
|
+
capturedAt: new Date().toISOString()
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
async function captureDesktopThumbnailFrame(payload, frameSeq) {
|
|
671
|
+
return await captureNativeDesktopFrame(frameSeq, payload?.monitorIndex ?? payload?.screenIndex ?? payload?.displayIndex);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
async function captureScreenFrame(options, payload, frameSeq, capability = 'thumbnail') {
|
|
675
|
+
const enabled = capability === 'live' ? options.liveEnabled : options.thumbnailEnabled;
|
|
676
|
+
if (!enabled) {
|
|
677
|
+
throw new Error(`${capability} capability is disabled`);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
return options.fakeThumbnail
|
|
681
|
+
? createFakeThumbnailFrame(options, payload, frameSeq)
|
|
682
|
+
: await captureDesktopThumbnailFrame(payload, frameSeq);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
async function captureThumbnailFrame(options, payload, frameSeq) {
|
|
686
|
+
return await captureScreenFrame(options, payload, frameSeq, 'thumbnail');
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function normalizeApprovalLevel(value) {
|
|
690
|
+
const level = String(value || 'task-only').trim().toLowerCase();
|
|
691
|
+
return level === 'ai-assist' ? 'ai-assist' : 'task-only';
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function buildAiPrompt(options, payload, instruction) {
|
|
695
|
+
const title = String(payload?.title || 'Remote AI task').trim();
|
|
696
|
+
return [
|
|
697
|
+
'You are the LiveDesk client AI assistant running on a controlled remote computer.',
|
|
698
|
+
'Complete the LiveDesk Hub task as a text-only assistant.',
|
|
699
|
+
'Do not claim you used shell commands, file writes, browser automation, keyboard input, or mouse input.',
|
|
700
|
+
'If the task requires external side effects, explain what would be needed and stop.',
|
|
701
|
+
'',
|
|
702
|
+
`Device: ${options.name} (${os.hostname()}, ${os.platform()} ${os.release()}, ${os.arch()})`,
|
|
703
|
+
`Task title: ${title}`,
|
|
704
|
+
'',
|
|
705
|
+
'Hub instruction:',
|
|
706
|
+
instruction
|
|
707
|
+
].join('\n');
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function extractResponseText(response) {
|
|
711
|
+
if (typeof response?.output_text === 'string' && response.output_text.trim()) {
|
|
712
|
+
return response.output_text.trim();
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const parts = [];
|
|
716
|
+
if (Array.isArray(response?.output)) {
|
|
717
|
+
for (const item of response.output) {
|
|
718
|
+
if (!Array.isArray(item?.content)) {
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
for (const content of item.content) {
|
|
723
|
+
if (typeof content?.text === 'string') {
|
|
724
|
+
parts.push(content.text);
|
|
725
|
+
}
|
|
726
|
+
if (typeof content?.output_text === 'string') {
|
|
727
|
+
parts.push(content.output_text);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
return parts.join('\n').trim();
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
async function runAiAssistTask(options, payload, instruction) {
|
|
737
|
+
if (!options.aiEnabled) {
|
|
738
|
+
throw new Error('AI assist capability is disabled. Start the agent with --ai to enable it.');
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
if (options.fakeAi) {
|
|
742
|
+
return {
|
|
743
|
+
text: [
|
|
744
|
+
`Fake AI assist completed on ${options.name}.`,
|
|
745
|
+
'',
|
|
746
|
+
`Instruction: ${instruction.slice(0, 500)}`,
|
|
747
|
+
'',
|
|
748
|
+
'No shell, file, input, browser, or persistent side effects were performed.'
|
|
749
|
+
].join('\n'),
|
|
750
|
+
model: 'fake-ai',
|
|
751
|
+
responseId: `fake-ai-${String(payload?.taskId || crypto.randomUUID()).slice(0, 96)}`
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
if (!options.openAiApiKey) {
|
|
756
|
+
throw new Error('OPENAI_API_KEY is required for --ai remote tasks.');
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const openaiModule = await import('openai');
|
|
760
|
+
const OpenAI = openaiModule.default || openaiModule.OpenAI;
|
|
761
|
+
const client = new OpenAI({ apiKey: options.openAiApiKey });
|
|
762
|
+
const model = String(payload?.model || options.aiModel || DEFAULT_AI_MODEL).trim() || DEFAULT_AI_MODEL;
|
|
763
|
+
const response = await client.responses.create({
|
|
764
|
+
model,
|
|
765
|
+
input: buildAiPrompt(options, payload, instruction)
|
|
766
|
+
});
|
|
767
|
+
const text = extractResponseText(response);
|
|
768
|
+
if (!text) {
|
|
769
|
+
throw new Error('AI assist returned no text output.');
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
return {
|
|
773
|
+
text: text.slice(0, MAX_AI_OUTPUT_CHARS),
|
|
774
|
+
model,
|
|
775
|
+
responseId: response?.id || ''
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function clampInteger(value, min, max, fallback) {
|
|
780
|
+
const number = Number(value);
|
|
781
|
+
if (!Number.isFinite(number)) {
|
|
782
|
+
return fallback;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
return Math.max(min, Math.min(max, Math.floor(number)));
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function stopLiveStream(activeStreams, streamId = '') {
|
|
789
|
+
if (streamId) {
|
|
790
|
+
const existing = activeStreams.get(streamId);
|
|
791
|
+
if (existing?.timer) {
|
|
792
|
+
clearInterval(existing.timer);
|
|
793
|
+
}
|
|
794
|
+
activeStreams.delete(streamId);
|
|
795
|
+
return existing ? 1 : 0;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
let stopped = 0;
|
|
799
|
+
for (const stream of activeStreams.values()) {
|
|
800
|
+
if (stream?.timer) {
|
|
801
|
+
clearInterval(stream.timer);
|
|
802
|
+
stopped += 1;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
activeStreams.clear();
|
|
806
|
+
return stopped;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function startLiveStream(socket, options, message, nextFrameSeq, activeStreams) {
|
|
810
|
+
if (!options.liveEnabled) {
|
|
811
|
+
throw new Error('live stream capability is disabled');
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
const payload = message.payload || {};
|
|
815
|
+
const streamId = String(payload.streamId || `live-${Date.now()}`)
|
|
816
|
+
.replace(/[^a-zA-Z0-9_.:-]/g, '-')
|
|
817
|
+
.slice(0, 128) || `live-${Date.now()}`;
|
|
818
|
+
const fps = clampInteger(payload.fps, 1, MAX_LIVE_FPS, DEFAULT_LIVE_FPS);
|
|
819
|
+
const intervalMs = Math.max(33, Math.round(1000 / fps));
|
|
820
|
+
stopLiveStream(activeStreams, streamId);
|
|
821
|
+
|
|
822
|
+
const stream = {
|
|
823
|
+
streamId,
|
|
824
|
+
fps,
|
|
825
|
+
intervalMs,
|
|
826
|
+
inFlight: false,
|
|
827
|
+
stopped: false,
|
|
828
|
+
frameDrops: 0,
|
|
829
|
+
timer: null
|
|
830
|
+
};
|
|
831
|
+
console.log('전송모드 - mode1-jpeg (Mode 1 - Test JPEG Binary)');
|
|
832
|
+
|
|
833
|
+
const captureAndSend = async () => {
|
|
834
|
+
if (stream.stopped || stream.inFlight || socket.destroyed) {
|
|
835
|
+
if (stream.inFlight) {
|
|
836
|
+
stream.frameDrops += 1;
|
|
837
|
+
}
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
stream.inFlight = true;
|
|
842
|
+
try {
|
|
843
|
+
const frame = await captureScreenFrame(options, payload, nextFrameSeq(), 'live');
|
|
844
|
+
if (String(frame.data || '').length > MAX_FRAME_BASE64_CHARS) {
|
|
845
|
+
stream.frameDrops += 1;
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
const sent = writeJsonLine(socket, {
|
|
850
|
+
type: 'stream.frame',
|
|
851
|
+
commandId: message.commandId,
|
|
852
|
+
streamId,
|
|
853
|
+
frameSeq: frame.frameSeq,
|
|
854
|
+
width: frame.width,
|
|
855
|
+
height: frame.height,
|
|
856
|
+
sourceWidth: frame.sourceWidth || frame.width,
|
|
857
|
+
sourceHeight: frame.sourceHeight || frame.height,
|
|
858
|
+
mimeType: frame.mimeType,
|
|
859
|
+
capturedAt: frame.capturedAt,
|
|
860
|
+
fps,
|
|
861
|
+
mode: 'mode1-jpeg',
|
|
862
|
+
monitorIndex: frame.monitorIndex ?? 0,
|
|
863
|
+
monitorCount: frame.monitorCount ?? 1,
|
|
864
|
+
droppedByAgent: stream.frameDrops,
|
|
865
|
+
data: frame.data
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
if (!sent) {
|
|
869
|
+
stream.stopped = true;
|
|
870
|
+
stopLiveStream(activeStreams, streamId);
|
|
871
|
+
}
|
|
872
|
+
} catch {
|
|
873
|
+
stream.frameDrops += 1;
|
|
874
|
+
} finally {
|
|
875
|
+
stream.inFlight = false;
|
|
876
|
+
}
|
|
877
|
+
};
|
|
878
|
+
|
|
879
|
+
stream.timer = setInterval(() => {
|
|
880
|
+
captureAndSend().catch(() => {
|
|
881
|
+
stream.frameDrops += 1;
|
|
882
|
+
});
|
|
883
|
+
}, intervalMs);
|
|
884
|
+
stream.timer.unref?.();
|
|
885
|
+
activeStreams.set(streamId, stream);
|
|
886
|
+
captureAndSend().catch(() => {
|
|
887
|
+
stream.frameDrops += 1;
|
|
888
|
+
});
|
|
889
|
+
return { streamId, fps, intervalMs };
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function isSensitiveAgentPath(value) {
|
|
893
|
+
const normalized = String(value || '').replaceAll('\\', '/').toLowerCase();
|
|
894
|
+
return normalized.includes('/.codex/') || normalized.endsWith('/auth.json') || normalized.includes('/.ssh/') || normalized.includes('/id_rsa') || normalized.endsWith('.pem') || normalized.endsWith('.key') || normalized.endsWith('/.env') || normalized.includes('/credential') || normalized.includes('/secret') || normalized.includes('/password');
|
|
418
895
|
}
|
|
419
896
|
|
|
420
|
-
|
|
421
|
-
const
|
|
422
|
-
|
|
423
|
-
throw new Error('No files were included in the transfer.');
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
const baseDir = normalizeDirectoryPath(payload.remoteDirectory || options.filesDir);
|
|
427
|
-
await fs.mkdir(baseDir, { recursive: true });
|
|
428
|
-
|
|
429
|
-
let totalBytes = 0;
|
|
430
|
-
const saved = [];
|
|
431
|
-
for (const file of files) {
|
|
432
|
-
const name = sanitizePathSegment(file?.name, 'file');
|
|
433
|
-
const relativePath = sanitizeRelativeFilePath(file?.relativePath || name, name);
|
|
434
|
-
const targetPath = path.resolve(baseDir, relativePath);
|
|
435
|
-
const relativeFromBase = path.relative(baseDir, targetPath);
|
|
436
|
-
if (relativeFromBase.startsWith('..') || path.isAbsolute(relativeFromBase)) {
|
|
437
|
-
throw new Error(`Unsafe file path: ${relativePath}`);
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
const dataBase64 = String(file?.dataBase64 || '').replace(/^data:[^,]*,/i, '').trim();
|
|
441
|
-
if (!dataBase64) {
|
|
442
|
-
throw new Error(`Missing file data: ${name}`);
|
|
443
|
-
}
|
|
444
|
-
const buffer = Buffer.from(dataBase64, 'base64');
|
|
445
|
-
totalBytes += buffer.length;
|
|
446
|
-
if (totalBytes > MAX_FILE_TRANSFER_BYTES) {
|
|
447
|
-
throw new Error('File transfer exceeded the local size limit.');
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
451
|
-
await fs.writeFile(targetPath, buffer);
|
|
452
|
-
saved.push({
|
|
453
|
-
name,
|
|
454
|
-
relativePath,
|
|
455
|
-
path: targetPath,
|
|
456
|
-
bytes: buffer.length
|
|
457
|
-
});
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
return {
|
|
461
|
-
kind: 'file.transfer',
|
|
462
|
-
transferId: String(payload.transferId || '').slice(0, 128),
|
|
463
|
-
status: 'completed',
|
|
464
|
-
directory: baseDir,
|
|
465
|
-
files: saved,
|
|
466
|
-
totalBytes,
|
|
467
|
-
completedAt: new Date().toISOString()
|
|
468
|
-
};
|
|
897
|
+
function isPathWithinAgentRoot(root, candidate) {
|
|
898
|
+
const relative = path.relative(root, candidate);
|
|
899
|
+
return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
|
|
469
900
|
}
|
|
470
901
|
|
|
471
|
-
async function
|
|
472
|
-
|
|
473
|
-
const
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
const transferId = String(payload.transferId || '').replace(/[^a-zA-Z0-9_.:-]/g, '-').slice(0, 128);
|
|
482
|
-
const offset = Math.max(0, Math.floor(Number(payload.offset) || 0));
|
|
483
|
-
const totalBytes = Math.max(0, Math.floor(Number(payload.totalBytes) || 0));
|
|
484
|
-
const final = payload.final === true;
|
|
485
|
-
const buffer = payload.dataBase64 ? Buffer.from(String(payload.dataBase64), 'base64') : Buffer.alloc(0);
|
|
486
|
-
if (!transferId || offset > totalBytes || offset + buffer.length > totalBytes || buffer.length > 512 * 1024) {
|
|
487
|
-
throw new Error('Invalid file transfer chunk.');
|
|
488
|
-
}
|
|
489
|
-
if (buffer.length === 0 && !(final && totalBytes === 0 && offset === 0)) {
|
|
490
|
-
throw new Error('Empty file chunks are not allowed.');
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
494
|
-
const tempPath = `${targetPath}.livedesk-${transferId}.part`;
|
|
495
|
-
const handle = await fs.open(tempPath, offset === 0 ? 'w+' : 'r+');
|
|
496
|
-
let completedSize = 0;
|
|
497
|
-
try {
|
|
498
|
-
const before = await handle.stat();
|
|
499
|
-
if (offset > before.size) {
|
|
500
|
-
throw new Error(`File chunk gap detected at ${offset}; current length is ${before.size}.`);
|
|
501
|
-
}
|
|
502
|
-
if (buffer.length > 0) {
|
|
503
|
-
await handle.write(buffer, 0, buffer.length, offset);
|
|
504
|
-
}
|
|
505
|
-
const after = await handle.stat();
|
|
506
|
-
completedSize = after.size;
|
|
507
|
-
if (final) {
|
|
508
|
-
await handle.sync();
|
|
509
|
-
if (after.size !== totalBytes) {
|
|
510
|
-
throw new Error(`File transfer is incomplete (${after.size}/${totalBytes}).`);
|
|
902
|
+
async function assertAgentPathDoesNotTraverseLink(base, resolved) {
|
|
903
|
+
await fs.mkdir(base, { recursive: true });
|
|
904
|
+
const canonicalRoot = await fs.realpath(base);
|
|
905
|
+
let current = resolved;
|
|
906
|
+
while (true) {
|
|
907
|
+
try {
|
|
908
|
+
const stat = await fs.lstat(current);
|
|
909
|
+
if (stat.isSymbolicLink()) {
|
|
910
|
+
throw new Error('Safe Agent paths cannot traverse symbolic links or junctions.');
|
|
511
911
|
}
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
const modifiedAt = new Date(lastModified);
|
|
523
|
-
await fs.utimes(targetPath, modifiedAt, modifiedAt).catch(() => undefined);
|
|
912
|
+
const canonicalCurrent = await fs.realpath(current);
|
|
913
|
+
if (!isPathWithinAgentRoot(canonicalRoot, canonicalCurrent)) {
|
|
914
|
+
throw new Error('Safe Agent path resolves outside the LiveDesk files directory.');
|
|
915
|
+
}
|
|
916
|
+
return;
|
|
917
|
+
} catch (error) {
|
|
918
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
919
|
+
const parent = path.dirname(current);
|
|
920
|
+
if (parent === current) throw new Error('Safe Agent path could not be verified.');
|
|
921
|
+
current = parent;
|
|
524
922
|
}
|
|
525
923
|
}
|
|
526
|
-
|
|
527
|
-
return {
|
|
528
|
-
kind: 'file.transfer.chunk',
|
|
529
|
-
transferId,
|
|
530
|
-
status: final ? 'completed' : 'receiving',
|
|
531
|
-
relativePath: relativePath.split(path.sep).join('/'),
|
|
532
|
-
offset,
|
|
533
|
-
bytes: buffer.length,
|
|
534
|
-
totalBytes,
|
|
535
|
-
completedSize,
|
|
536
|
-
final,
|
|
537
|
-
completedAt: final ? new Date().toISOString() : null
|
|
538
|
-
};
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
function clampNumber(value, min, max, fallback) {
|
|
542
|
-
const number = Number(value);
|
|
543
|
-
if (!Number.isFinite(number)) {
|
|
544
|
-
return fallback;
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
return Math.max(min, Math.min(max, Math.floor(number)));
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
function createFakeThumbnailFrame(options, payload, frameSeq) {
|
|
551
|
-
const width = clampNumber(payload?.maxWidth, 160, 3840, 360);
|
|
552
|
-
const height = clampNumber(payload?.maxHeight, 90, 2160, 220);
|
|
553
|
-
const now = new Date().toISOString();
|
|
554
|
-
const title = String(options.name || os.hostname()).replace(/[<>&"']/g, '_');
|
|
555
|
-
const svg = [
|
|
556
|
-
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`,
|
|
557
|
-
'<rect width="100%" height="100%" fill="#0f172a"/>',
|
|
558
|
-
'<rect x="14" y="14" width="calc(100% - 28px)" height="calc(100% - 28px)" rx="12" fill="#1e293b" stroke="#38bdf8" stroke-width="2"/>',
|
|
559
|
-
`<text x="28" y="48" fill="#e2e8f0" font-family="Arial, sans-serif" font-size="20" font-weight="700">${title}</text>`,
|
|
560
|
-
`<text x="28" y="82" fill="#93c5fd" font-family="Arial, sans-serif" font-size="14">LiveDesk Client Thumbnail</text>`,
|
|
561
|
-
`<text x="28" y="${height - 30}" fill="#94a3b8" font-family="Consolas, monospace" font-size="12">${now}</text>`,
|
|
562
|
-
'</svg>'
|
|
563
|
-
].join('');
|
|
564
|
-
|
|
565
|
-
return {
|
|
566
|
-
frameSeq,
|
|
567
|
-
width,
|
|
568
|
-
height,
|
|
569
|
-
sourceWidth: width,
|
|
570
|
-
sourceHeight: height,
|
|
571
|
-
mimeType: 'image/svg+xml',
|
|
572
|
-
data: Buffer.from(svg, 'utf8').toString('base64'),
|
|
573
|
-
capturedAt: now
|
|
574
|
-
};
|
|
575
924
|
}
|
|
576
925
|
|
|
577
|
-
function
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
const marker = buffer[offset + 1];
|
|
590
|
-
const blockLength = buffer.readUInt16BE(offset + 2);
|
|
591
|
-
if (blockLength < 2) {
|
|
592
|
-
break;
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
const isStartOfFrame = marker >= 0xc0
|
|
596
|
-
&& marker <= 0xcf
|
|
597
|
-
&& ![0xc4, 0xc8, 0xcc].includes(marker);
|
|
598
|
-
if (isStartOfFrame) {
|
|
599
|
-
return {
|
|
600
|
-
height: buffer.readUInt16BE(offset + 5),
|
|
601
|
-
width: buffer.readUInt16BE(offset + 7)
|
|
602
|
-
};
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
offset += 2 + blockLength;
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
return { width: 0, height: 0 };
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
function normalizeMonitorIndex(value, fallback = 0) {
|
|
612
|
-
return clampInteger(value, 0, 63, fallback);
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
async function captureNativeDesktopFrame(frameSeq, monitorIndex = 0) {
|
|
616
|
-
const screenshotModule = await import('node-screenshots');
|
|
617
|
-
const Monitor = screenshotModule.Monitor || screenshotModule.default?.Monitor;
|
|
618
|
-
if (!Monitor?.all) {
|
|
619
|
-
throw new Error('node-screenshots Monitor API is unavailable.');
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
const monitors = Monitor.all();
|
|
623
|
-
const selectedIndex = Math.max(0, Math.min(monitors.length - 1, normalizeMonitorIndex(monitorIndex)));
|
|
624
|
-
const monitor = monitors[selectedIndex] || monitors.find(item => item?.isPrimary?.() === true) || monitors[0];
|
|
625
|
-
if (!monitor?.captureImage) {
|
|
626
|
-
throw new Error('No capturable desktop monitor was found.');
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
const image = await monitor.captureImage();
|
|
630
|
-
const jpeg = await image.toJpeg();
|
|
631
|
-
const buffer = Buffer.isBuffer(jpeg) ? jpeg : Buffer.from(jpeg);
|
|
632
|
-
if (buffer.toString('base64').length > MAX_FRAME_BASE64_CHARS) {
|
|
633
|
-
throw new Error('Desktop capture exceeded the remote frame size limit.');
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
const dimensions = readJpegDimensions(buffer);
|
|
637
|
-
return {
|
|
638
|
-
frameSeq,
|
|
639
|
-
width: dimensions.width,
|
|
640
|
-
height: dimensions.height,
|
|
641
|
-
sourceWidth: dimensions.width,
|
|
642
|
-
sourceHeight: dimensions.height,
|
|
643
|
-
mimeType: 'image/jpeg',
|
|
644
|
-
data: buffer.toString('base64'),
|
|
645
|
-
monitorIndex: selectedIndex,
|
|
646
|
-
monitorCount: Math.max(1, monitors.length),
|
|
647
|
-
capturedAt: new Date().toISOString()
|
|
648
|
-
};
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
async function captureDesktopThumbnailFrame(payload, frameSeq) {
|
|
652
|
-
return await captureNativeDesktopFrame(frameSeq, payload?.monitorIndex ?? payload?.screenIndex ?? payload?.displayIndex);
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
async function captureScreenFrame(options, payload, frameSeq, capability = 'thumbnail') {
|
|
656
|
-
const enabled = capability === 'live' ? options.liveEnabled : options.thumbnailEnabled;
|
|
657
|
-
if (!enabled) {
|
|
658
|
-
throw new Error(`${capability} capability is disabled`);
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
return options.fakeThumbnail
|
|
662
|
-
? createFakeThumbnailFrame(options, payload, frameSeq)
|
|
663
|
-
: await captureDesktopThumbnailFrame(payload, frameSeq);
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
async function captureThumbnailFrame(options, payload, frameSeq) {
|
|
667
|
-
return await captureScreenFrame(options, payload, frameSeq, 'thumbnail');
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
function normalizeApprovalLevel(value) {
|
|
671
|
-
const level = String(value || 'task-only').trim().toLowerCase();
|
|
672
|
-
return level === 'ai-assist' ? 'ai-assist' : 'task-only';
|
|
926
|
+
async function resolveAgentPath(options, value, permissionMode, rejectSensitive = true) {
|
|
927
|
+
const base = normalizeDirectoryPath(options.filesDir || undefined);
|
|
928
|
+
const text = String(value || '').replace(/\0/g, '').trim();
|
|
929
|
+
if (!text || text.length > 600) throw new Error('path is invalid');
|
|
930
|
+
const resolved = path.resolve(path.isAbsolute(text) ? text : path.join(base, text));
|
|
931
|
+
const relative = path.relative(base, resolved);
|
|
932
|
+
if (permissionMode !== 'full-access' && (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))) throw new Error('path is outside the LiveDesk files directory for this permission mode');
|
|
933
|
+
if (permissionMode !== 'full-access') await assertAgentPathDoesNotTraverseLink(base, resolved);
|
|
934
|
+
if (rejectSensitive && isSensitiveAgentPath(resolved)) throw new Error('credential and secret paths are not available');
|
|
935
|
+
return resolved;
|
|
673
936
|
}
|
|
674
|
-
|
|
675
|
-
function
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
'
|
|
682
|
-
'',
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
937
|
+
|
|
938
|
+
function runNodeAgentProcess(executable, args, timeoutMs = 15000, cwd = undefined) {
|
|
939
|
+
return new Promise((resolve, reject) => {
|
|
940
|
+
const child = spawn(executable, args, { cwd, windowsHide: true, shell: false, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
941
|
+
let output = '';
|
|
942
|
+
let timedOut = false;
|
|
943
|
+
const append = chunk => { output = `${output}${String(chunk || '')}`.replace(/[\0\r]/g, ' ').slice(0, MAX_AGENT_OUTPUT_CHARS); };
|
|
944
|
+
child.stdout.on('data', append);
|
|
945
|
+
child.stderr.on('data', append);
|
|
946
|
+
const timer = setTimeout(() => {
|
|
947
|
+
timedOut = true;
|
|
948
|
+
child.kill('SIGTERM');
|
|
949
|
+
setTimeout(() => child.kill('SIGKILL'), 1000).unref?.();
|
|
950
|
+
}, Math.max(1000, Math.min(30000, Number(timeoutMs) || 15000)));
|
|
951
|
+
child.once('error', error => { clearTimeout(timer); reject(error); });
|
|
952
|
+
child.once('close', code => { clearTimeout(timer); resolve({ output: redactAgentOutput(`${output}${timedOut ? '\n[timeout]' : ''}`.trim()), exitCode: timedOut ? -1 : code ?? -1, timedOut }); });
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
function redactAgentOutput(value) {
|
|
957
|
+
let output = String(value || '').replace(/[\0\r]/g, ' ');
|
|
958
|
+
output = output.replace(/\b(token|secret|password|api[-_]?key|authorization|private[-_]?key|connection[-_]?string|access[-_]?key|client[-_]?secret)\b\s*[:=]\s*("[^"]*"|'[^']*'|[^\s,;]+)/gi, '$1=[redacted]');
|
|
959
|
+
output = output.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [redacted]');
|
|
960
|
+
output = output.replace(/^(\s*(?:set\s+)?(?:[A-Z_][A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|KEY|AUTH|CREDENTIAL|CONNECTION)[A-Z0-9_]*)\s*=\s*).+$/gim, '$1[redacted]');
|
|
961
|
+
return output.slice(0, MAX_AGENT_OUTPUT_CHARS);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function normalizeNodeProcessKey(value) {
|
|
965
|
+
return path.basename(String(value || '').trim()).toLowerCase().replace(/\.(exe|bin)$/i, '');
|
|
689
966
|
}
|
|
690
967
|
|
|
691
|
-
function
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
968
|
+
function parseWindowsCommandLine(commandLine) {
|
|
969
|
+
const text = String(commandLine || '').trim();
|
|
970
|
+
const parsed = [];
|
|
971
|
+
let index = 0;
|
|
972
|
+
while (index < text.length) {
|
|
973
|
+
while (/\s/.test(text[index] || '')) index += 1;
|
|
974
|
+
if (index >= text.length) break;
|
|
975
|
+
let value = '';
|
|
976
|
+
let quoted = false;
|
|
977
|
+
while (index < text.length) {
|
|
978
|
+
const character = text[index];
|
|
979
|
+
if (character === '\\') {
|
|
980
|
+
let slashCount = 0;
|
|
981
|
+
while (text[index + slashCount] === '\\') slashCount += 1;
|
|
982
|
+
const next = text[index + slashCount];
|
|
983
|
+
if (next === '"') {
|
|
984
|
+
value += '\\'.repeat(Math.floor(slashCount / 2));
|
|
985
|
+
index += slashCount;
|
|
986
|
+
if (slashCount % 2 === 1) {
|
|
987
|
+
value += '"';
|
|
988
|
+
index += 1;
|
|
989
|
+
} else {
|
|
990
|
+
quoted = !quoted;
|
|
991
|
+
index += 1;
|
|
992
|
+
}
|
|
993
|
+
} else {
|
|
994
|
+
value += '\\'.repeat(slashCount);
|
|
995
|
+
index += slashCount;
|
|
996
|
+
}
|
|
700
997
|
continue;
|
|
701
998
|
}
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
}
|
|
707
|
-
if (typeof content?.output_text === 'string') {
|
|
708
|
-
parts.push(content.output_text);
|
|
709
|
-
}
|
|
999
|
+
if (character === '"') {
|
|
1000
|
+
quoted = !quoted;
|
|
1001
|
+
index += 1;
|
|
1002
|
+
continue;
|
|
710
1003
|
}
|
|
1004
|
+
if (/\s/.test(character) && !quoted) break;
|
|
1005
|
+
value += character;
|
|
1006
|
+
index += 1;
|
|
711
1007
|
}
|
|
1008
|
+
parsed.push(value);
|
|
1009
|
+
while (/\s/.test(text[index] || '')) index += 1;
|
|
712
1010
|
}
|
|
713
|
-
|
|
714
|
-
return parts.join('\n').trim();
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
async function runAiAssistTask(options, payload, instruction) {
|
|
718
|
-
if (!options.aiEnabled) {
|
|
719
|
-
throw new Error('AI assist capability is disabled. Start the agent with --ai to enable it.');
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
if (options.fakeAi) {
|
|
723
|
-
return {
|
|
724
|
-
text: [
|
|
725
|
-
`Fake AI assist completed on ${options.name}.`,
|
|
726
|
-
'',
|
|
727
|
-
`Instruction: ${instruction.slice(0, 500)}`,
|
|
728
|
-
'',
|
|
729
|
-
'No shell, file, input, browser, or persistent side effects were performed.'
|
|
730
|
-
].join('\n'),
|
|
731
|
-
model: 'fake-ai',
|
|
732
|
-
responseId: `fake-ai-${String(payload?.taskId || crypto.randomUUID()).slice(0, 96)}`
|
|
733
|
-
};
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
if (!options.openAiApiKey) {
|
|
737
|
-
throw new Error('OPENAI_API_KEY is required for --ai remote tasks.');
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
const openaiModule = await import('openai');
|
|
741
|
-
const OpenAI = openaiModule.default || openaiModule.OpenAI;
|
|
742
|
-
const client = new OpenAI({ apiKey: options.openAiApiKey });
|
|
743
|
-
const model = String(payload?.model || options.aiModel || DEFAULT_AI_MODEL).trim() || DEFAULT_AI_MODEL;
|
|
744
|
-
const response = await client.responses.create({
|
|
745
|
-
model,
|
|
746
|
-
input: buildAiPrompt(options, payload, instruction)
|
|
747
|
-
});
|
|
748
|
-
const text = extractResponseText(response);
|
|
749
|
-
if (!text) {
|
|
750
|
-
throw new Error('AI assist returned no text output.');
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
return {
|
|
754
|
-
text: text.slice(0, MAX_AI_OUTPUT_CHARS),
|
|
755
|
-
model,
|
|
756
|
-
responseId: response?.id || ''
|
|
757
|
-
};
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
function clampInteger(value, min, max, fallback) {
|
|
761
|
-
const number = Number(value);
|
|
762
|
-
if (!Number.isFinite(number)) {
|
|
763
|
-
return fallback;
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
return Math.max(min, Math.min(max, Math.floor(number)));
|
|
1011
|
+
return parsed;
|
|
767
1012
|
}
|
|
768
1013
|
|
|
769
|
-
function
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
}
|
|
775
|
-
activeStreams.delete(streamId);
|
|
776
|
-
return existing ? 1 : 0;
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
let stopped = 0;
|
|
780
|
-
for (const stream of activeStreams.values()) {
|
|
781
|
-
if (stream?.timer) {
|
|
782
|
-
clearInterval(stream.timer);
|
|
783
|
-
stopped += 1;
|
|
784
|
-
}
|
|
1014
|
+
function decodeUtf16Base64(value) {
|
|
1015
|
+
try {
|
|
1016
|
+
return Buffer.from(String(value || ''), 'base64').toString('utf16le');
|
|
1017
|
+
} catch {
|
|
1018
|
+
return '';
|
|
785
1019
|
}
|
|
786
|
-
activeStreams.clear();
|
|
787
|
-
return stopped;
|
|
788
1020
|
}
|
|
789
1021
|
|
|
790
|
-
function
|
|
791
|
-
|
|
792
|
-
|
|
1022
|
+
function decodeNullSeparatedBase64(value) {
|
|
1023
|
+
try {
|
|
1024
|
+
return Buffer.from(String(value || ''), 'base64').toString('utf8');
|
|
1025
|
+
} catch {
|
|
1026
|
+
return '';
|
|
793
1027
|
}
|
|
794
|
-
|
|
795
|
-
const payload = message.payload || {};
|
|
796
|
-
const streamId = String(payload.streamId || `live-${Date.now()}`)
|
|
797
|
-
.replace(/[^a-zA-Z0-9_.:-]/g, '-')
|
|
798
|
-
.slice(0, 128) || `live-${Date.now()}`;
|
|
799
|
-
const fps = clampInteger(payload.fps, 1, MAX_LIVE_FPS, DEFAULT_LIVE_FPS);
|
|
800
|
-
const intervalMs = Math.max(33, Math.round(1000 / fps));
|
|
801
|
-
stopLiveStream(activeStreams, streamId);
|
|
802
|
-
|
|
803
|
-
const stream = {
|
|
804
|
-
streamId,
|
|
805
|
-
fps,
|
|
806
|
-
intervalMs,
|
|
807
|
-
inFlight: false,
|
|
808
|
-
stopped: false,
|
|
809
|
-
frameDrops: 0,
|
|
810
|
-
timer: null
|
|
811
|
-
};
|
|
812
|
-
console.log('전송모드 - mode1-jpeg (Mode 1 - Test JPEG Binary)');
|
|
813
|
-
|
|
814
|
-
const captureAndSend = async () => {
|
|
815
|
-
if (stream.stopped || stream.inFlight || socket.destroyed) {
|
|
816
|
-
if (stream.inFlight) {
|
|
817
|
-
stream.frameDrops += 1;
|
|
818
|
-
}
|
|
819
|
-
return;
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
stream.inFlight = true;
|
|
823
|
-
try {
|
|
824
|
-
const frame = await captureScreenFrame(options, payload, nextFrameSeq(), 'live');
|
|
825
|
-
if (String(frame.data || '').length > MAX_FRAME_BASE64_CHARS) {
|
|
826
|
-
stream.frameDrops += 1;
|
|
827
|
-
return;
|
|
828
|
-
}
|
|
829
|
-
|
|
830
|
-
const sent = writeJsonLine(socket, {
|
|
831
|
-
type: 'stream.frame',
|
|
832
|
-
commandId: message.commandId,
|
|
833
|
-
streamId,
|
|
834
|
-
frameSeq: frame.frameSeq,
|
|
835
|
-
width: frame.width,
|
|
836
|
-
height: frame.height,
|
|
837
|
-
sourceWidth: frame.sourceWidth || frame.width,
|
|
838
|
-
sourceHeight: frame.sourceHeight || frame.height,
|
|
839
|
-
mimeType: frame.mimeType,
|
|
840
|
-
capturedAt: frame.capturedAt,
|
|
841
|
-
fps,
|
|
842
|
-
mode: 'mode1-jpeg',
|
|
843
|
-
monitorIndex: frame.monitorIndex ?? 0,
|
|
844
|
-
monitorCount: frame.monitorCount ?? 1,
|
|
845
|
-
droppedByAgent: stream.frameDrops,
|
|
846
|
-
data: frame.data
|
|
847
|
-
});
|
|
848
|
-
|
|
849
|
-
if (!sent) {
|
|
850
|
-
stream.stopped = true;
|
|
851
|
-
stopLiveStream(activeStreams, streamId);
|
|
852
|
-
}
|
|
853
|
-
} catch {
|
|
854
|
-
stream.frameDrops += 1;
|
|
855
|
-
} finally {
|
|
856
|
-
stream.inFlight = false;
|
|
857
|
-
}
|
|
858
|
-
};
|
|
859
|
-
|
|
860
|
-
stream.timer = setInterval(() => {
|
|
861
|
-
captureAndSend().catch(() => {
|
|
862
|
-
stream.frameDrops += 1;
|
|
863
|
-
});
|
|
864
|
-
}, intervalMs);
|
|
865
|
-
stream.timer.unref?.();
|
|
866
|
-
activeStreams.set(streamId, stream);
|
|
867
|
-
captureAndSend().catch(() => {
|
|
868
|
-
stream.frameDrops += 1;
|
|
869
|
-
});
|
|
870
|
-
return { streamId, fps, intervalMs };
|
|
871
|
-
}
|
|
872
|
-
|
|
873
|
-
function isSensitiveAgentPath(value) {
|
|
874
|
-
const normalized = String(value || '').replaceAll('\\', '/').toLowerCase();
|
|
875
|
-
return normalized.includes('/.codex/') || normalized.endsWith('/auth.json') || normalized.includes('/.ssh/') || normalized.includes('/id_rsa') || normalized.endsWith('.pem') || normalized.endsWith('.key') || normalized.endsWith('/.env') || normalized.includes('/credential') || normalized.includes('/secret') || normalized.includes('/password');
|
|
876
|
-
}
|
|
877
|
-
|
|
878
|
-
function resolveAgentPath(options, value, permissionMode, rejectSensitive = true) {
|
|
879
|
-
const base = normalizeDirectoryPath(options.filesDir || undefined);
|
|
880
|
-
const text = String(value || '').replace(/\0/g, '').trim();
|
|
881
|
-
if (!text || text.length > 600) throw new Error('path is invalid');
|
|
882
|
-
const resolved = path.resolve(path.isAbsolute(text) ? text : path.join(base, text));
|
|
883
|
-
const relative = path.relative(base, resolved);
|
|
884
|
-
if (permissionMode !== 'full-access' && (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))) throw new Error('path is outside the LiveDesk files directory for this permission mode');
|
|
885
|
-
if (rejectSensitive && isSensitiveAgentPath(resolved)) throw new Error('credential and secret paths are not available');
|
|
886
|
-
return resolved;
|
|
887
1028
|
}
|
|
888
1029
|
|
|
889
|
-
function
|
|
890
|
-
return new Promise((resolve, reject) => {
|
|
891
|
-
const child = spawn(executable, args, { cwd, windowsHide: true, shell: false, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
892
|
-
let output = '';
|
|
893
|
-
let timedOut = false;
|
|
894
|
-
const append = chunk => { output = `${output}${String(chunk || '')}`.replace(/[\0\r]/g, ' ').slice(0, MAX_AGENT_OUTPUT_CHARS); };
|
|
895
|
-
child.stdout.on('data', append);
|
|
896
|
-
child.stderr.on('data', append);
|
|
897
|
-
const timer = setTimeout(() => {
|
|
898
|
-
timedOut = true;
|
|
899
|
-
child.kill('SIGTERM');
|
|
900
|
-
setTimeout(() => child.kill('SIGKILL'), 1000).unref?.();
|
|
901
|
-
}, Math.max(1000, Math.min(30000, Number(timeoutMs) || 15000)));
|
|
902
|
-
child.once('error', error => { clearTimeout(timer); reject(error); });
|
|
903
|
-
child.once('close', code => { clearTimeout(timer); resolve({ output: redactAgentOutput(`${output}${timedOut ? '\n[timeout]' : ''}`.trim()), exitCode: timedOut ? -1 : code ?? -1, timedOut }); });
|
|
904
|
-
});
|
|
905
|
-
}
|
|
906
|
-
|
|
907
|
-
function redactAgentOutput(value) {
|
|
908
|
-
let output = String(value || '').replace(/[\0\r]/g, ' ');
|
|
909
|
-
output = output.replace(/\b(token|secret|password|api[-_]?key|authorization|private[-_]?key|connection[-_]?string|access[-_]?key|client[-_]?secret)\b\s*[:=]\s*("[^"]*"|'[^']*'|[^\s,;]+)/gi, '$1=[redacted]');
|
|
910
|
-
output = output.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [redacted]');
|
|
911
|
-
output = output.replace(/^(\s*(?:set\s+)?(?:[A-Z_][A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|KEY|AUTH|CREDENTIAL|CONNECTION)[A-Z0-9_]*)\s*=\s*).+$/gim, '$1[redacted]');
|
|
912
|
-
return output.slice(0, MAX_AGENT_OUTPUT_CHARS);
|
|
913
|
-
}
|
|
914
|
-
|
|
915
|
-
async function findNodeProcessExecutable(processName) {
|
|
1030
|
+
async function findNodeProcessDetails(processName) {
|
|
916
1031
|
const name = String(processName || '').trim();
|
|
917
|
-
if (!name) return
|
|
1032
|
+
if (!name) return null;
|
|
918
1033
|
if (process.platform === 'win32') {
|
|
919
1034
|
const result = await runNodeAgentProcess('powershell.exe', [
|
|
920
1035
|
'-NoProfile', '-NonInteractive', '-Command',
|
|
921
|
-
'$name=$args[0]; $p=Get-CimInstance Win32_Process | Where-Object { $_.Name -ieq $name } | Select-Object -First 1
|
|
1036
|
+
'$name=$args[0]; $p=Get-CimInstance Win32_Process | Where-Object { $_.Name -ieq $name } | Select-Object -First 1; if($p){ $bytes=[Text.Encoding]::Unicode.GetBytes([string]$p.CommandLine); [Console]::Out.WriteLine([string]$p.ProcessId); [Console]::Out.WriteLine([string]$p.ExecutablePath); [Console]::Out.Write([Convert]::ToBase64String($bytes)) }',
|
|
922
1037
|
name
|
|
923
1038
|
], 10000);
|
|
924
|
-
|
|
1039
|
+
if (result.exitCode !== 0) return null;
|
|
1040
|
+
const lines = String(result.output || '').trim().split(/\r?\n/);
|
|
1041
|
+
const executable = String(lines[1] || '').trim();
|
|
1042
|
+
if (!lines[0] || !executable) return null;
|
|
1043
|
+
const commandLine = decodeUtf16Base64(lines[2]);
|
|
1044
|
+
const parsed = parseWindowsCommandLine(commandLine);
|
|
1045
|
+
return { pid: Number(lines[0]) || 0, executable, args: parsed.length > 1 ? parsed.slice(1) : null, workingDirectory: null, metadataSource: 'process-command-line' };
|
|
925
1046
|
}
|
|
926
1047
|
if (process.platform === 'linux') {
|
|
927
|
-
const result = await runNodeAgentProcess('/bin/sh', ['-lc', 'pid=$(pgrep -xo -- "$1" || true); if [ -n "$pid" ] && [ -e "/proc/$pid/exe" ]; then readlink -f "/proc/$pid/exe"; fi', 'livedesk-process-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
if (process.platform === '
|
|
937
|
-
const result = await runNodeAgentProcess('
|
|
938
|
-
if (result.exitCode !== 0
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
const lines = profile.split(/\r?\n/).filter(line => !line.includes(marker));
|
|
948
|
-
while (lines.length && lines[lines.length - 1] === '') lines.pop();
|
|
949
|
-
lines.push(marker, `export ${variableName}=${quoted}`);
|
|
950
|
-
await fs.writeFile(profilePath, `${lines.join('\n')}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
951
|
-
process.env[variableName] = variableValue;
|
|
952
|
-
return { persisted: true, scope: 'user-profile', path: profilePath };
|
|
1048
|
+
const result = await runNodeAgentProcess('/bin/sh', ['-lc', 'pid=$(pgrep -xo -- "$1" || true); if [ -n "$pid" ] && [ -e "/proc/$pid/exe" ]; then printf "%s\\n%s\\n%s\\n" "$pid" "$(readlink -f "/proc/$pid/exe")" "$(readlink -f "/proc/$pid/cwd")"; base64 -w0 "/proc/$pid/cmdline"; fi', 'livedesk-process-details', name], 10000);
|
|
1049
|
+
if (result.exitCode !== 0) return null;
|
|
1050
|
+
const lines = String(result.output || '').trim().split(/\r?\n/);
|
|
1051
|
+
const executable = String(lines[1] || '').trim();
|
|
1052
|
+
const workingDirectory = String(lines[2] || '').trim();
|
|
1053
|
+
if (!lines[0] || !executable) return null;
|
|
1054
|
+
const parsed = decodeNullSeparatedBase64(lines[3]).split('\0').filter(Boolean);
|
|
1055
|
+
return { pid: Number(lines[0]) || 0, executable, args: parsed.length > 1 ? parsed.slice(1) : null, workingDirectory: workingDirectory || null, metadataSource: 'proc' };
|
|
1056
|
+
}
|
|
1057
|
+
if (process.platform === 'darwin') {
|
|
1058
|
+
const result = await runNodeAgentProcess('/bin/sh', ['-lc', 'pid=$(pgrep -xo -- "$1" || true); if [ -n "$pid" ]; then exe=$(ps -p "$pid" -o comm= | sed "s/^ *//"); cwd=$(lsof -a -p "$pid" -d cwd -Fn 2>/dev/null | sed -n "s/^n//p" | head -n 1); command=$(ps -p "$pid" -o command=); printf "%s\\n%s\\n%s\\n" "$pid" "$exe" "$cwd"; printf "%s" "$command" | base64 | tr -d "\\n"; fi', 'livedesk-process-details', name], 10000);
|
|
1059
|
+
if (result.exitCode !== 0) return null;
|
|
1060
|
+
const lines = String(result.output || '').trim().split(/\r?\n/);
|
|
1061
|
+
const executable = String(lines[1] || '').trim();
|
|
1062
|
+
const workingDirectory = String(lines[2] || '').trim();
|
|
1063
|
+
if (!lines[0] || !executable) return null;
|
|
1064
|
+
const parsed = parseWindowsCommandLine(Buffer.from(String(lines[3] || ''), 'base64').toString('utf8'));
|
|
1065
|
+
return { pid: Number(lines[0]) || 0, executable, args: parsed.length > 1 ? parsed.slice(1) : null, workingDirectory: workingDirectory || null, metadataSource: 'ps-lsof' };
|
|
1066
|
+
}
|
|
1067
|
+
return null;
|
|
953
1068
|
}
|
|
954
|
-
|
|
1069
|
+
|
|
1070
|
+
async function persistNodeEnvironmentVariable(name, value) {
|
|
1071
|
+
const variableName = String(name || '').trim();
|
|
1072
|
+
const variableValue = String(value ?? '');
|
|
1073
|
+
if (process.platform === 'win32') {
|
|
1074
|
+
const result = await runNodeAgentProcess('setx.exe', [variableName, variableValue], 15000);
|
|
1075
|
+
if (result.exitCode !== 0 || result.timedOut) throw new Error(`Persistent environment update failed (exit ${result.exitCode}).`);
|
|
1076
|
+
process.env[variableName] = variableValue;
|
|
1077
|
+
return { persisted: true, scope: 'user', output: result.output };
|
|
1078
|
+
}
|
|
1079
|
+
const profilePath = path.join(os.homedir(), '.profile');
|
|
1080
|
+
const marker = `# LiveDesk managed environment: ${variableName}`;
|
|
1081
|
+
const quoted = `'${variableValue.replaceAll("'", "'\\\"'\\\"'")}'`;
|
|
1082
|
+
let profile = '';
|
|
1083
|
+
try { profile = await fs.readFile(profilePath, 'utf8'); } catch { /* create on demand */ }
|
|
1084
|
+
const lines = profile.split(/\r?\n/).filter(line => !line.includes(marker));
|
|
1085
|
+
while (lines.length && lines[lines.length - 1] === '') lines.pop();
|
|
1086
|
+
lines.push(marker, `export ${variableName}=${quoted}`);
|
|
1087
|
+
await fs.writeFile(profilePath, `${lines.join('\n')}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
1088
|
+
process.env[variableName] = variableValue;
|
|
1089
|
+
return { persisted: true, scope: 'user-profile', path: profilePath };
|
|
1090
|
+
}
|
|
1091
|
+
|
|
955
1092
|
async function executeNodeAgentOperation(options, operation, payload = {}) {
|
|
956
|
-
const permissionMode = String(payload.permissionMode || 'ask');
|
|
1093
|
+
const permissionMode = String(payload.permissionMode || 'ask');
|
|
957
1094
|
const args = payload.toolArguments && typeof payload.toolArguments === 'object' ? payload.toolArguments : payload;
|
|
958
1095
|
if (operation === 'file.read') {
|
|
959
|
-
const filePath = resolveAgentPath(options, args.path, permissionMode);
|
|
960
|
-
const info = await fs.stat(filePath);
|
|
961
|
-
const maxBytes = Math.max(1, Math.min(65536, Number(args.maxBytes) || 65536));
|
|
962
|
-
const content = (await fs.readFile(filePath)).subarray(0, maxBytes).toString('utf8');
|
|
963
|
-
return { summary: `Read ${Buffer.byteLength(content)} bytes from ${path.basename(filePath)}.`, data: { path: filePath, sizeBytes: info.size, returnedBytes: Buffer.byteLength(content), truncated: info.size > Buffer.byteLength(content), content } };
|
|
1096
|
+
const filePath = await resolveAgentPath(options, args.path, permissionMode);
|
|
1097
|
+
const info = await fs.stat(filePath);
|
|
1098
|
+
const maxBytes = Math.max(1, Math.min(65536, Number(args.maxBytes) || 65536));
|
|
1099
|
+
const content = (await fs.readFile(filePath)).subarray(0, maxBytes).toString('utf8');
|
|
1100
|
+
return { summary: `Read ${Buffer.byteLength(content)} bytes from ${path.basename(filePath)}.`, data: { path: filePath, sizeBytes: info.size, returnedBytes: Buffer.byteLength(content), truncated: info.size > Buffer.byteLength(content), content } };
|
|
964
1101
|
}
|
|
965
1102
|
if (operation === 'file.write') {
|
|
966
|
-
|
|
1103
|
+
let filePath = await resolveAgentPath(options, args.path, permissionMode);
|
|
967
1104
|
const content = String(args.content || '');
|
|
968
1105
|
if (Buffer.byteLength(content) > 1048576) throw new Error('file content exceeds the 1 MiB limit');
|
|
969
1106
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
1107
|
+
// Re-check after creating missing parents so a newly introduced link
|
|
1108
|
+
// cannot turn the write into an outside-root operation.
|
|
1109
|
+
filePath = await resolveAgentPath(options, args.path, permissionMode);
|
|
970
1110
|
if (args.append === true) await fs.appendFile(filePath, content, 'utf8');
|
|
971
|
-
else await fs.writeFile(filePath, content, 'utf8');
|
|
972
|
-
return { summary: `Wrote ${Buffer.byteLength(content)} bytes to ${path.basename(filePath)}.`, data: { path: filePath, sizeBytes: (await fs.stat(filePath)).size, append: args.append === true } };
|
|
973
|
-
}
|
|
1111
|
+
else await fs.writeFile(filePath, content, 'utf8');
|
|
1112
|
+
return { summary: `Wrote ${Buffer.byteLength(content)} bytes to ${path.basename(filePath)}.`, data: { path: filePath, sizeBytes: (await fs.stat(filePath)).size, append: args.append === true } };
|
|
1113
|
+
}
|
|
974
1114
|
if (operation === 'file.delete') {
|
|
975
|
-
const filePath = resolveAgentPath(options, args.path, permissionMode);
|
|
976
|
-
if (args.recursive === true) await fs.rm(filePath, { recursive: true, force: false });
|
|
977
|
-
else await fs.unlink(filePath);
|
|
978
|
-
return { summary: `Deleted ${path.basename(filePath)}.`, data: { path: filePath, recursive: args.recursive === true } };
|
|
979
|
-
}
|
|
1115
|
+
const filePath = await resolveAgentPath(options, args.path, permissionMode);
|
|
1116
|
+
if (args.recursive === true) await fs.rm(filePath, { recursive: true, force: false });
|
|
1117
|
+
else await fs.unlink(filePath);
|
|
1118
|
+
return { summary: `Deleted ${path.basename(filePath)}.`, data: { path: filePath, recursive: args.recursive === true } };
|
|
1119
|
+
}
|
|
980
1120
|
if (operation === 'file.list') {
|
|
981
|
-
const directory = resolveAgentPath(options, args.path, permissionMode);
|
|
982
|
-
const entries = await fs.readdir(directory, { withFileTypes: true });
|
|
983
|
-
const maxEntries = Math.max(1, Math.min(500, Number(args.maxEntries) || 200));
|
|
984
|
-
const data = [];
|
|
985
|
-
for (const entry of entries.slice(0, maxEntries)) {
|
|
986
|
-
const entryPath = path.join(directory, entry.name);
|
|
987
|
-
if (isSensitiveAgentPath(entryPath)) continue;
|
|
988
|
-
data.push({ name: entry.name, path: entryPath, type: entry.isDirectory() ? 'directory' : 'file', sizeBytes: entry.isDirectory() ? 0 : (await fs.stat(entryPath)).size });
|
|
989
|
-
}
|
|
990
|
-
return { summary: `Listed ${data.length} entries.`, data: { path: directory, entries: data, truncated: entries.length > maxEntries } };
|
|
1121
|
+
const directory = await resolveAgentPath(options, args.path, permissionMode);
|
|
1122
|
+
const entries = await fs.readdir(directory, { withFileTypes: true });
|
|
1123
|
+
const maxEntries = Math.max(1, Math.min(500, Number(args.maxEntries) || 200));
|
|
1124
|
+
const data = [];
|
|
1125
|
+
for (const entry of entries.slice(0, maxEntries)) {
|
|
1126
|
+
const entryPath = path.join(directory, entry.name);
|
|
1127
|
+
if (isSensitiveAgentPath(entryPath)) continue;
|
|
1128
|
+
data.push({ name: entry.name, path: entryPath, type: entry.isDirectory() ? 'directory' : 'file', sizeBytes: entry.isDirectory() ? 0 : (await fs.stat(entryPath)).size });
|
|
1129
|
+
}
|
|
1130
|
+
return { summary: `Listed ${data.length} entries.`, data: { path: directory, entries: data, truncated: entries.length > maxEntries } };
|
|
991
1131
|
}
|
|
992
1132
|
if (operation === 'application.launch') {
|
|
993
1133
|
const executable = String(args.executable || '').trim();
|
|
994
1134
|
if (!executable || executable.length > 400 || /[\0\r\n]/.test(executable)) throw new Error('executable is invalid');
|
|
995
|
-
const
|
|
1135
|
+
const launchArgs = Array.isArray(args.args) ? args.args.slice(0, 32).map(String) : [];
|
|
1136
|
+
const workingDirectory = args.workingDirectory ? await resolveAgentPath(options, args.workingDirectory, permissionMode, false) : process.cwd();
|
|
1137
|
+
const child = spawn(executable, launchArgs, { cwd: workingDirectory, detached: true, windowsHide: true, stdio: 'ignore' });
|
|
996
1138
|
child.unref();
|
|
997
|
-
|
|
1139
|
+
launchedProcessRegistry.set(normalizeNodeProcessKey(executable), { executable, args: launchArgs, workingDirectory, metadataSource: 'launch_application' });
|
|
1140
|
+
return { summary: `Started ${path.basename(executable)}.`, data: { executable, args: launchArgs, workingDirectory, pid: child.pid } };
|
|
998
1141
|
}
|
|
999
1142
|
if (operation === 'process.control') {
|
|
1000
1143
|
const action = String(args.action || 'stop');
|
|
1001
1144
|
const processName = String(args.processName || '').trim();
|
|
1145
|
+
const processDetails = await findNodeProcessDetails(processName);
|
|
1146
|
+
if (!processDetails) {
|
|
1147
|
+
if (action === 'restart') throw new Error(`process-not-found: ${processName}`);
|
|
1148
|
+
return { summary: `${processName} is already stopped.`, data: { action, processName, status: 'already-stopped', ok: true } };
|
|
1149
|
+
}
|
|
1002
1150
|
if (action === 'restart') {
|
|
1003
|
-
const
|
|
1004
|
-
if (!executable
|
|
1151
|
+
const restartMetadata = launchedProcessRegistry.get(normalizeNodeProcessKey(processName)) || processDetails;
|
|
1152
|
+
if (!restartMetadata.executable || !Array.isArray(restartMetadata.args) || !restartMetadata.workingDirectory) {
|
|
1153
|
+
throw new Error(`process-restart-metadata-unavailable: ${processName}`);
|
|
1154
|
+
}
|
|
1005
1155
|
const stop = await runNodeAgentProcess(process.platform === 'win32' ? 'taskkill' : 'pkill', process.platform === 'win32' ? ['/IM', processName, '/T', '/F'] : ['-TERM', processName]);
|
|
1006
|
-
if (stop.exitCode !== 0
|
|
1007
|
-
const child = spawn(executable,
|
|
1156
|
+
if (stop.exitCode !== 0 || stop.timedOut) throw new Error(`Process stop failed before restart (exit ${stop.exitCode}).`);
|
|
1157
|
+
const child = spawn(restartMetadata.executable, restartMetadata.args, { cwd: restartMetadata.workingDirectory, detached: true, windowsHide: true, stdio: 'ignore' });
|
|
1008
1158
|
child.unref();
|
|
1009
|
-
return { summary: `Restarted ${processName}.`, data: { action, processName, stop, restarted: true, pid: child.pid, executable } };
|
|
1159
|
+
return { summary: `Restarted ${processName}.`, data: { action, processName, stop, restarted: true, pid: child.pid, executable: restartMetadata.executable, args: restartMetadata.args, workingDirectory: restartMetadata.workingDirectory, metadataSource: restartMetadata.metadataSource } };
|
|
1010
1160
|
}
|
|
1011
|
-
const result = await runNodeAgentProcess(process.platform === 'win32' ? 'taskkill' : 'pkill', process.platform === 'win32' ? ['/IM', processName, '/T'] : ['-TERM', processName]);
|
|
1012
|
-
return { summary: `${action} requested for ${processName}.`, data: { action, processName, ...result } };
|
|
1013
|
-
}
|
|
1014
|
-
if (operation === 'application.close') return executeNodeAgentOperation(options, 'process.control', { ...payload, toolArguments: { ...args, action: 'stop' } });
|
|
1015
|
-
if (operation === 'service.control') {
|
|
1016
|
-
const service = String(args.serviceName || '');
|
|
1017
|
-
const action = String(args.action || 'status');
|
|
1018
|
-
const executable = process.platform === 'win32' ? 'sc.exe' : 'systemctl';
|
|
1161
|
+
const result = await runNodeAgentProcess(process.platform === 'win32' ? 'taskkill' : 'pkill', process.platform === 'win32' ? ['/IM', processName, '/T'] : ['-TERM', processName]);
|
|
1162
|
+
return { summary: `${action} requested for ${processName}.`, data: { action, processName, ...result } };
|
|
1163
|
+
}
|
|
1164
|
+
if (operation === 'application.close') return executeNodeAgentOperation(options, 'process.control', { ...payload, toolArguments: { ...args, action: 'stop' } });
|
|
1165
|
+
if (operation === 'service.control') {
|
|
1166
|
+
const service = String(args.serviceName || '');
|
|
1167
|
+
const action = String(args.action || 'status');
|
|
1168
|
+
const executable = process.platform === 'win32' ? 'sc.exe' : 'systemctl';
|
|
1019
1169
|
const actions = action === 'restart' ? ['stop', 'start'] : [action];
|
|
1020
1170
|
const results = [];
|
|
1021
1171
|
for (const step of actions) {
|
|
1022
1172
|
const result = await runNodeAgentProcess(executable, [step, service]);
|
|
1023
1173
|
results.push({ action: step, ...result });
|
|
1024
1174
|
if (result.exitCode !== 0 || result.timedOut) break;
|
|
1175
|
+
if (step === 'stop') {
|
|
1176
|
+
await waitForNodeServiceState(executable, service, 'stopped');
|
|
1177
|
+
} else if (step === 'start') {
|
|
1178
|
+
await waitForNodeServiceState(executable, service, 'running');
|
|
1179
|
+
}
|
|
1025
1180
|
}
|
|
1026
|
-
const ok = results.length === actions.length && results.every(result => result.exitCode === 0 && !result.timedOut);
|
|
1027
|
-
if (!ok) throw new Error(`Service ${action} failed for ${service}.`);
|
|
1028
|
-
return { summary: `Service ${action} completed for ${service}.`, data: { service, action, results } };
|
|
1029
|
-
}
|
|
1030
|
-
if (operation === 'command.run') {
|
|
1031
|
-
const command = String(args.command || '');
|
|
1032
|
-
const executable = process.platform === 'win32' ? 'cmd.exe' : '/bin/sh';
|
|
1033
|
-
const commandArgs = process.platform === 'win32' ? ['/d', '/s', '/c', command] : ['-lc', command];
|
|
1034
|
-
const result = await runNodeAgentProcess(executable, commandArgs, args.timeoutMs, args.workingDirectory ? resolveAgentPath(options, args.workingDirectory, permissionMode, false) : undefined);
|
|
1035
|
-
return { summary: result.timedOut ? 'Command timed out.' : `Command exited with code ${result.exitCode}.`, data: { ...result, output: redactAgentOutput(result.output) } };
|
|
1036
|
-
}
|
|
1037
|
-
if (operation === 'script.run') {
|
|
1038
|
-
const scriptPath = resolveAgentPath(options, args.path, permissionMode);
|
|
1039
|
-
const executable = process.platform === 'win32' ? 'powershell.exe' : '/bin/sh';
|
|
1040
|
-
const scriptArgs = process.platform === 'win32' ? ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...(Array.isArray(args.args) ? args.args.slice(0, 32).map(String) : [])] : [scriptPath, ...(Array.isArray(args.args) ? args.args.slice(0, 32).map(String) : [])];
|
|
1041
|
-
const result = await runNodeAgentProcess(executable, scriptArgs, args.timeoutMs);
|
|
1042
|
-
return { summary: result.timedOut ? 'Script timed out.' : `Script exited with code ${result.exitCode}.`, data: { ...result, output: redactAgentOutput(result.output) } };
|
|
1043
|
-
}
|
|
1044
|
-
if (operation === 'software.install') {
|
|
1045
|
-
const manager = String(args.manager || '').toLowerCase();
|
|
1046
|
-
const packageName = String(args.packageName || '');
|
|
1047
|
-
if (!/^[A-Za-z0-9._@:+/-]{1,200}$/.test(packageName)) throw new Error('packageName contains unsupported characters');
|
|
1048
|
-
const commands = { winget: ['winget.exe', ['install', '--id', packageName, '--silent', '--accept-source-agreements', '--accept-package-agreements']], brew: ['brew', ['install', packageName]], apt: ['apt-get', ['install', '-y', packageName]], npm: ['npm', ['install', '--global', args.version ? `${packageName}@${args.version}` : packageName]] };
|
|
1049
|
-
if (!commands[manager]) throw new Error('unsupported package manager');
|
|
1050
|
-
const [executable, commandArgs] = commands[manager];
|
|
1051
|
-
const result = await runNodeAgentProcess(executable, commandArgs, 30000);
|
|
1052
|
-
return { summary: `Package install exited with code ${result.exitCode}.`, data: { manager, packageName, ...result } };
|
|
1053
|
-
}
|
|
1054
|
-
if (operation === 'network.status') return { summary: 'Network status collected.', data: Object.fromEntries(Object.entries(os.networkInterfaces()).map(([name, values]) => [name, (values || []).map(value => ({ address: value.address, family: value.family, internal: value.internal }))])) };
|
|
1055
|
-
if (operation === 'system.power') {
|
|
1056
|
-
const action = String(args.action || '');
|
|
1057
|
-
const delaySec = String(Math.max(0, Math.min(3600, Number(args.delaySec) || 0)));
|
|
1058
|
-
let executable;
|
|
1059
|
-
let powerArgs;
|
|
1060
|
-
if (process.platform === 'win32') {
|
|
1061
|
-
({ executable, args: powerArgs } = {
|
|
1062
|
-
lock: { executable: 'rundll32.exe', args: ['user32.dll,LockWorkStation'] },
|
|
1063
|
-
sleep: { executable: 'rundll32.exe', args: ['powrprof.dll,SetSuspendState', '0,1,0'] },
|
|
1064
|
-
logoff: { executable: 'shutdown.exe', args: ['/l'] },
|
|
1065
|
-
restart: { executable: 'shutdown.exe', args: ['/r', '/t', delaySec] },
|
|
1066
|
-
shutdown: { executable: 'shutdown.exe', args: ['/s', '/t', delaySec] }
|
|
1067
|
-
}[action] || {});
|
|
1068
|
-
} else {
|
|
1069
|
-
({ executable, args: powerArgs } = {
|
|
1070
|
-
lock: { executable: 'loginctl', args: ['lock-session'] },
|
|
1071
|
-
sleep: { executable: 'systemctl', args: ['suspend'] },
|
|
1072
|
-
logoff: { executable: 'loginctl', args: ['terminate-user', os.userInfo().username] },
|
|
1073
|
-
restart: { executable: 'systemctl', args: ['reboot'] },
|
|
1074
|
-
shutdown: { executable: 'systemctl', args: ['poweroff'] }
|
|
1075
|
-
}[action] || {});
|
|
1076
|
-
}
|
|
1077
|
-
if (!executable) throw new Error(`Unsupported power action: ${action}.`);
|
|
1078
|
-
const result = await runNodeAgentProcess(executable, powerArgs, 15000);
|
|
1079
|
-
return { summary: `Power action ${action} requested.`, data: result };
|
|
1080
|
-
}
|
|
1081
|
-
if (operation === 'system.configure') {
|
|
1082
|
-
if (args.action === 'set-environment-variable') {
|
|
1083
|
-
if (!/^[A-Za-z_][A-Za-z0-9_]{0,119}$/.test(String(args.name || '')) || /^(PATH|PATHEXT|SYSTEMROOT|WINDIR|COMSPEC)$/i.test(String(args.name))) throw new Error('environment variable name is not allowed');
|
|
1084
|
-
const persistence = await persistNodeEnvironmentVariable(String(args.name), String(args.value ?? ''));
|
|
1085
|
-
return { summary: `Environment variable ${args.name} was persisted.`, data: { action: args.action, name: args.name, changed: true, ...persistence } };
|
|
1086
|
-
}
|
|
1087
|
-
const result = await runNodeAgentProcess(process.platform === 'win32' ? 'tzutil.exe' : 'timedatectl', process.platform === 'win32' ? ['/s', String(args.value || '')] : ['set-timezone', String(args.value || '')]);
|
|
1088
|
-
return { summary: `Timezone update exited with code ${result.exitCode}.`, data: result };
|
|
1089
|
-
}
|
|
1090
|
-
if (operation === 'logs.collect') {
|
|
1091
|
-
const maxLines = Math.max(1, Math.min(500, Number(args.maxLines) || 100));
|
|
1092
|
-
const result = await runNodeAgentProcess(process.platform === 'win32' ? 'powershell.exe' : 'journalctl', process.platform === 'win32' ? ['-NoProfile', '-NonInteractive', '-Command', `Get-WinEvent -LogName System -MaxEvents ${maxLines} | Format-List`] : ['-n', String(maxLines), '--no-pager', '-o', 'short']);
|
|
1093
|
-
return { summary: 'Recent logs collected.', data: { source: args.source || 'system', ...result, output: result.output.replace(/(token|password|secret|api[-_]?key|authorization)\s*[:=]\s*[^\s]+/gi, '$1=[redacted]') } };
|
|
1094
|
-
}
|
|
1095
|
-
throw new Error(`Unsupported command: ${operation}`);
|
|
1181
|
+
const ok = results.length === actions.length && results.every(result => result.exitCode === 0 && !result.timedOut);
|
|
1182
|
+
if (!ok) throw new Error(`Service ${action} failed for ${service}.`);
|
|
1183
|
+
return { summary: `Service ${action} completed for ${service}.`, data: { service, action, results } };
|
|
1184
|
+
}
|
|
1185
|
+
if (operation === 'command.run') {
|
|
1186
|
+
const command = String(args.command || '');
|
|
1187
|
+
const executable = process.platform === 'win32' ? 'cmd.exe' : '/bin/sh';
|
|
1188
|
+
const commandArgs = process.platform === 'win32' ? ['/d', '/s', '/c', command] : ['-lc', command];
|
|
1189
|
+
const result = await runNodeAgentProcess(executable, commandArgs, args.timeoutMs, args.workingDirectory ? await resolveAgentPath(options, args.workingDirectory, permissionMode, false) : undefined);
|
|
1190
|
+
return { summary: result.timedOut ? 'Command timed out.' : `Command exited with code ${result.exitCode}.`, data: { ...result, output: redactAgentOutput(result.output) } };
|
|
1191
|
+
}
|
|
1192
|
+
if (operation === 'script.run') {
|
|
1193
|
+
const scriptPath = await resolveAgentPath(options, args.path, permissionMode);
|
|
1194
|
+
const executable = process.platform === 'win32' ? 'powershell.exe' : '/bin/sh';
|
|
1195
|
+
const scriptArgs = process.platform === 'win32' ? ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...(Array.isArray(args.args) ? args.args.slice(0, 32).map(String) : [])] : [scriptPath, ...(Array.isArray(args.args) ? args.args.slice(0, 32).map(String) : [])];
|
|
1196
|
+
const result = await runNodeAgentProcess(executable, scriptArgs, args.timeoutMs);
|
|
1197
|
+
return { summary: result.timedOut ? 'Script timed out.' : `Script exited with code ${result.exitCode}.`, data: { ...result, output: redactAgentOutput(result.output) } };
|
|
1198
|
+
}
|
|
1199
|
+
if (operation === 'software.install') {
|
|
1200
|
+
const manager = String(args.manager || '').toLowerCase();
|
|
1201
|
+
const packageName = String(args.packageName || '');
|
|
1202
|
+
if (!/^[A-Za-z0-9._@:+/-]{1,200}$/.test(packageName)) throw new Error('packageName contains unsupported characters');
|
|
1203
|
+
const commands = { winget: ['winget.exe', ['install', '--id', packageName, '--silent', '--accept-source-agreements', '--accept-package-agreements']], brew: ['brew', ['install', packageName]], apt: ['apt-get', ['install', '-y', packageName]], npm: ['npm', ['install', '--global', args.version ? `${packageName}@${args.version}` : packageName]] };
|
|
1204
|
+
if (!commands[manager]) throw new Error('unsupported package manager');
|
|
1205
|
+
const [executable, commandArgs] = commands[manager];
|
|
1206
|
+
const result = await runNodeAgentProcess(executable, commandArgs, 30000);
|
|
1207
|
+
return { summary: `Package install exited with code ${result.exitCode}.`, data: { manager, packageName, ...result } };
|
|
1208
|
+
}
|
|
1209
|
+
if (operation === 'network.status') return { summary: 'Network status collected.', data: Object.fromEntries(Object.entries(os.networkInterfaces()).map(([name, values]) => [name, (values || []).map(value => ({ address: value.address, family: value.family, internal: value.internal }))])) };
|
|
1210
|
+
if (operation === 'system.power') {
|
|
1211
|
+
const action = String(args.action || '');
|
|
1212
|
+
const delaySec = String(Math.max(0, Math.min(3600, Number(args.delaySec) || 0)));
|
|
1213
|
+
let executable;
|
|
1214
|
+
let powerArgs;
|
|
1215
|
+
if (process.platform === 'win32') {
|
|
1216
|
+
({ executable, args: powerArgs } = {
|
|
1217
|
+
lock: { executable: 'rundll32.exe', args: ['user32.dll,LockWorkStation'] },
|
|
1218
|
+
sleep: { executable: 'rundll32.exe', args: ['powrprof.dll,SetSuspendState', '0,1,0'] },
|
|
1219
|
+
logoff: { executable: 'shutdown.exe', args: ['/l'] },
|
|
1220
|
+
restart: { executable: 'shutdown.exe', args: ['/r', '/t', delaySec] },
|
|
1221
|
+
shutdown: { executable: 'shutdown.exe', args: ['/s', '/t', delaySec] }
|
|
1222
|
+
}[action] || {});
|
|
1223
|
+
} else {
|
|
1224
|
+
({ executable, args: powerArgs } = {
|
|
1225
|
+
lock: { executable: 'loginctl', args: ['lock-session'] },
|
|
1226
|
+
sleep: { executable: 'systemctl', args: ['suspend'] },
|
|
1227
|
+
logoff: { executable: 'loginctl', args: ['terminate-user', os.userInfo().username] },
|
|
1228
|
+
restart: { executable: 'systemctl', args: ['reboot'] },
|
|
1229
|
+
shutdown: { executable: 'systemctl', args: ['poweroff'] }
|
|
1230
|
+
}[action] || {});
|
|
1231
|
+
}
|
|
1232
|
+
if (!executable) throw new Error(`Unsupported power action: ${action}.`);
|
|
1233
|
+
const result = await runNodeAgentProcess(executable, powerArgs, 15000);
|
|
1234
|
+
return { summary: `Power action ${action} requested.`, data: result };
|
|
1235
|
+
}
|
|
1236
|
+
if (operation === 'system.configure') {
|
|
1237
|
+
if (args.action === 'set-environment-variable') {
|
|
1238
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]{0,119}$/.test(String(args.name || '')) || /^(PATH|PATHEXT|SYSTEMROOT|WINDIR|COMSPEC)$/i.test(String(args.name))) throw new Error('environment variable name is not allowed');
|
|
1239
|
+
const persistence = await persistNodeEnvironmentVariable(String(args.name), String(args.value ?? ''));
|
|
1240
|
+
return { summary: `Environment variable ${args.name} was persisted.`, data: { action: args.action, name: args.name, changed: true, ...persistence } };
|
|
1241
|
+
}
|
|
1242
|
+
const result = await runNodeAgentProcess(process.platform === 'win32' ? 'tzutil.exe' : 'timedatectl', process.platform === 'win32' ? ['/s', String(args.value || '')] : ['set-timezone', String(args.value || '')]);
|
|
1243
|
+
return { summary: `Timezone update exited with code ${result.exitCode}.`, data: result };
|
|
1244
|
+
}
|
|
1245
|
+
if (operation === 'logs.collect') {
|
|
1246
|
+
const maxLines = Math.max(1, Math.min(500, Number(args.maxLines) || 100));
|
|
1247
|
+
const result = await runNodeAgentProcess(process.platform === 'win32' ? 'powershell.exe' : 'journalctl', process.platform === 'win32' ? ['-NoProfile', '-NonInteractive', '-Command', `Get-WinEvent -LogName System -MaxEvents ${maxLines} | Format-List`] : ['-n', String(maxLines), '--no-pager', '-o', 'short']);
|
|
1248
|
+
return { summary: 'Recent logs collected.', data: { source: args.source || 'system', ...result, output: result.output.replace(/(token|password|secret|api[-_]?key|authorization)\s*[:=]\s*[^\s]+/gi, '$1=[redacted]') } };
|
|
1249
|
+
}
|
|
1250
|
+
throw new Error(`Unsupported command: ${operation}`);
|
|
1096
1251
|
}
|
|
1097
1252
|
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
const
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1253
|
+
function normalizeNodeAgentTaskResult(result) {
|
|
1254
|
+
const data = result?.data;
|
|
1255
|
+
const candidates = [result, data, ...(Array.isArray(data?.results) ? data.results : [])];
|
|
1256
|
+
const failedCandidate = candidates.find(candidate => candidate && typeof candidate === 'object' && (
|
|
1257
|
+
candidate.ok === false
|
|
1258
|
+
|| candidate.status === 'failed'
|
|
1259
|
+
|| candidate.timedOut === true
|
|
1260
|
+
|| (Number.isFinite(Number(candidate.exitCode)) && Number(candidate.exitCode) !== 0)
|
|
1261
|
+
));
|
|
1262
|
+
const ok = result?.ok !== false && !failedCandidate;
|
|
1263
|
+
const error = ok
|
|
1264
|
+
? ''
|
|
1265
|
+
: String(result?.error || failedCandidate?.error || result?.summary || 'Agent operation failed.').slice(0, 500);
|
|
1266
|
+
return { ...result, ok, status: ok ? 'completed' : 'failed', error };
|
|
1267
|
+
}
|
|
1113
1268
|
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1269
|
+
const NODE_AGENT_OPERATIONS = new Set(['process.control', 'service.control', 'application.launch', 'application.close', 'file.read', 'file.write', 'file.delete', 'file.list', 'command.run', 'script.run', 'software.install', 'network.status', 'system.power', 'system.configure', 'logs.collect']);
|
|
1270
|
+
|
|
1271
|
+
async function handleRemoteCommand(socket, options, message, nextFrameSeq, activeStreams) {
|
|
1272
|
+
const command = String(message.command || '');
|
|
1273
|
+
if (command === 'ping') {
|
|
1274
|
+
writeJsonLine(socket, {
|
|
1275
|
+
type: 'command.result',
|
|
1276
|
+
commandId: message.commandId,
|
|
1277
|
+
result: {
|
|
1278
|
+
pong: true,
|
|
1279
|
+
at: new Date().toISOString()
|
|
1280
|
+
}
|
|
1281
|
+
});
|
|
1282
|
+
return;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
if (NODE_AGENT_OPERATIONS.has(command)) {
|
|
1286
|
+
if (!options.taskEnabled) {
|
|
1287
|
+
writeJsonLine(socket, { type: 'command.result', commandId: message.commandId, error: 'remote task capability is disabled' });
|
|
1288
|
+
return;
|
|
1118
1289
|
}
|
|
1119
1290
|
try {
|
|
1120
|
-
const result = await executeNodeAgentOperation(options, command, message.payload || {});
|
|
1291
|
+
const result = normalizeNodeAgentTaskResult(await executeNodeAgentOperation(options, command, message.payload || {}));
|
|
1121
1292
|
writeJsonLine(socket, {
|
|
1122
1293
|
type: 'command.result',
|
|
1123
1294
|
commandId: message.commandId,
|
|
1124
|
-
result: {
|
|
1295
|
+
result: {
|
|
1125
1296
|
kind: command,
|
|
1126
1297
|
mode: String(message.payload?.permissionMode || 'ask'),
|
|
1127
1298
|
taskId: String(message.payload?.taskId || '').slice(0, 128),
|
|
1128
|
-
|
|
1299
|
+
ok: result.ok,
|
|
1300
|
+
status: result.status,
|
|
1129
1301
|
summary: result.summary,
|
|
1302
|
+
error: result.error || undefined,
|
|
1130
1303
|
data: result.data,
|
|
1131
|
-
sideEffects: ['file.read', 'file.list', 'network.status', 'logs.collect'].includes(command) ? 'none' : 'audited',
|
|
1132
|
-
completedAt: new Date().toISOString()
|
|
1133
|
-
}
|
|
1134
|
-
});
|
|
1304
|
+
sideEffects: ['file.read', 'file.list', 'network.status', 'logs.collect'].includes(command) ? 'none' : 'audited',
|
|
1305
|
+
completedAt: new Date().toISOString()
|
|
1306
|
+
}
|
|
1307
|
+
});
|
|
1135
1308
|
} catch (error) {
|
|
1136
|
-
|
|
1137
|
-
}
|
|
1138
|
-
return;
|
|
1139
|
-
}
|
|
1140
|
-
|
|
1141
|
-
if (command === 'agent.task') {
|
|
1142
|
-
if (!options.taskEnabled) {
|
|
1309
|
+
const messageText = error?.message || String(error);
|
|
1143
1310
|
writeJsonLine(socket, {
|
|
1144
1311
|
type: 'command.result',
|
|
1145
1312
|
commandId: message.commandId,
|
|
1146
|
-
error:
|
|
1313
|
+
error: messageText,
|
|
1314
|
+
result: { ok: false, status: 'failed', error: messageText, completedAt: new Date().toISOString() }
|
|
1147
1315
|
});
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
if (!
|
|
1154
|
-
writeJsonLine(socket, {
|
|
1155
|
-
type: 'command.result',
|
|
1156
|
-
commandId: message.commandId,
|
|
1157
|
-
error: '
|
|
1158
|
-
});
|
|
1159
|
-
return;
|
|
1160
|
-
}
|
|
1161
|
-
|
|
1162
|
-
const
|
|
1163
|
-
const
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
});
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
});
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
}
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
});
|
|
1488
|
-
});
|
|
1489
|
-
} else if (message.type === '
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1316
|
+
}
|
|
1317
|
+
return;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
if (command === 'agent.task') {
|
|
1321
|
+
if (!options.taskEnabled) {
|
|
1322
|
+
writeJsonLine(socket, {
|
|
1323
|
+
type: 'command.result',
|
|
1324
|
+
commandId: message.commandId,
|
|
1325
|
+
error: 'remote task capability is disabled'
|
|
1326
|
+
});
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
const payload = message.payload || {};
|
|
1331
|
+
const instruction = String(payload.instruction || '').replace(/\0/g, '').trim().slice(0, 4000);
|
|
1332
|
+
if (!instruction) {
|
|
1333
|
+
writeJsonLine(socket, {
|
|
1334
|
+
type: 'command.result',
|
|
1335
|
+
commandId: message.commandId,
|
|
1336
|
+
error: 'missing task instruction'
|
|
1337
|
+
});
|
|
1338
|
+
return;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
const approvalLevel = normalizeApprovalLevel(payload.approvalLevel);
|
|
1342
|
+
const completedAt = new Date().toISOString();
|
|
1343
|
+
const title = String(payload.title || instruction.split(/\r?\n/)[0] || 'Remote task')
|
|
1344
|
+
.replace(/[\r\n\t]/g, ' ')
|
|
1345
|
+
.trim()
|
|
1346
|
+
.slice(0, 120);
|
|
1347
|
+
if (approvalLevel === 'ai-assist') {
|
|
1348
|
+
try {
|
|
1349
|
+
const aiResult = await runAiAssistTask(options, payload, instruction);
|
|
1350
|
+
writeJsonLine(socket, {
|
|
1351
|
+
type: 'command.result',
|
|
1352
|
+
commandId: message.commandId,
|
|
1353
|
+
result: {
|
|
1354
|
+
kind: 'agent.task',
|
|
1355
|
+
mode: 'ai-assist',
|
|
1356
|
+
taskId: String(payload.taskId || '').slice(0, 128),
|
|
1357
|
+
title,
|
|
1358
|
+
status: 'completed',
|
|
1359
|
+
summary: aiResult.text,
|
|
1360
|
+
sideEffects: 'none',
|
|
1361
|
+
model: aiResult.model,
|
|
1362
|
+
responseId: aiResult.responseId,
|
|
1363
|
+
instructionPreview: instruction.slice(0, 320),
|
|
1364
|
+
completedAt: new Date().toISOString()
|
|
1365
|
+
}
|
|
1366
|
+
});
|
|
1367
|
+
} catch (error) {
|
|
1368
|
+
writeJsonLine(socket, {
|
|
1369
|
+
type: 'command.result',
|
|
1370
|
+
commandId: message.commandId,
|
|
1371
|
+
error: error?.message || String(error)
|
|
1372
|
+
});
|
|
1373
|
+
}
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
writeJsonLine(socket, {
|
|
1378
|
+
type: 'command.result',
|
|
1379
|
+
commandId: message.commandId,
|
|
1380
|
+
result: {
|
|
1381
|
+
kind: 'agent.task',
|
|
1382
|
+
mode: 'task-only',
|
|
1383
|
+
taskId: String(payload.taskId || '').slice(0, 128),
|
|
1384
|
+
title,
|
|
1385
|
+
status: 'completed',
|
|
1386
|
+
summary: `Task received by ${options.name}. Safe task-only mode confirmed receipt without shell, file, input, or browser side effects.`,
|
|
1387
|
+
sideEffects: 'none',
|
|
1388
|
+
instructionPreview: instruction.slice(0, 320),
|
|
1389
|
+
device: {
|
|
1390
|
+
hostname: os.hostname(),
|
|
1391
|
+
platform: os.platform(),
|
|
1392
|
+
release: os.release(),
|
|
1393
|
+
arch: os.arch()
|
|
1394
|
+
},
|
|
1395
|
+
completedAt
|
|
1396
|
+
}
|
|
1397
|
+
});
|
|
1398
|
+
return;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
if (command === 'thumbnail.capture') {
|
|
1402
|
+
try {
|
|
1403
|
+
const frameSeq = nextFrameSeq();
|
|
1404
|
+
const frame = await captureThumbnailFrame(options, message.payload || {}, frameSeq);
|
|
1405
|
+
writeJsonLine(socket, {
|
|
1406
|
+
type: 'thumbnail.frame',
|
|
1407
|
+
commandId: message.commandId,
|
|
1408
|
+
streamId: message.payload?.streamId || 'thumbnail',
|
|
1409
|
+
frameSeq: frame.frameSeq,
|
|
1410
|
+
width: frame.width,
|
|
1411
|
+
height: frame.height,
|
|
1412
|
+
sourceWidth: frame.sourceWidth || frame.width,
|
|
1413
|
+
sourceHeight: frame.sourceHeight || frame.height,
|
|
1414
|
+
mimeType: frame.mimeType,
|
|
1415
|
+
capturedAt: frame.capturedAt,
|
|
1416
|
+
data: frame.data
|
|
1417
|
+
});
|
|
1418
|
+
writeJsonLine(socket, {
|
|
1419
|
+
type: 'command.result',
|
|
1420
|
+
commandId: message.commandId,
|
|
1421
|
+
result: {
|
|
1422
|
+
thumbnail: true,
|
|
1423
|
+
frameSeq: frame.frameSeq,
|
|
1424
|
+
width: frame.width,
|
|
1425
|
+
height: frame.height,
|
|
1426
|
+
sourceWidth: frame.sourceWidth || frame.width,
|
|
1427
|
+
sourceHeight: frame.sourceHeight || frame.height,
|
|
1428
|
+
capturedAt: frame.capturedAt
|
|
1429
|
+
}
|
|
1430
|
+
});
|
|
1431
|
+
} catch (error) {
|
|
1432
|
+
writeJsonLine(socket, {
|
|
1433
|
+
type: 'command.result',
|
|
1434
|
+
commandId: message.commandId,
|
|
1435
|
+
error: error?.message || String(error)
|
|
1436
|
+
});
|
|
1437
|
+
}
|
|
1438
|
+
return;
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
if (command === 'stream.start') {
|
|
1442
|
+
try {
|
|
1443
|
+
const started = startLiveStream(socket, options, message, nextFrameSeq, activeStreams);
|
|
1444
|
+
writeJsonLine(socket, {
|
|
1445
|
+
type: 'command.result',
|
|
1446
|
+
commandId: message.commandId,
|
|
1447
|
+
result: {
|
|
1448
|
+
stream: true,
|
|
1449
|
+
started: true,
|
|
1450
|
+
streamId: started.streamId,
|
|
1451
|
+
fps: started.fps,
|
|
1452
|
+
intervalMs: started.intervalMs,
|
|
1453
|
+
mode: 'mode1-jpeg',
|
|
1454
|
+
viewOnly: true,
|
|
1455
|
+
inputControl: false,
|
|
1456
|
+
startedAt: new Date().toISOString()
|
|
1457
|
+
}
|
|
1458
|
+
});
|
|
1459
|
+
} catch (error) {
|
|
1460
|
+
writeJsonLine(socket, {
|
|
1461
|
+
type: 'command.result',
|
|
1462
|
+
commandId: message.commandId,
|
|
1463
|
+
error: error?.message || String(error)
|
|
1464
|
+
});
|
|
1465
|
+
}
|
|
1466
|
+
return;
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
if (command === 'stream.stop') {
|
|
1470
|
+
const streamId = String(message.payload?.streamId || '').slice(0, 128);
|
|
1471
|
+
const stopped = stopLiveStream(activeStreams, streamId);
|
|
1472
|
+
writeJsonLine(socket, {
|
|
1473
|
+
type: 'command.result',
|
|
1474
|
+
commandId: message.commandId,
|
|
1475
|
+
result: {
|
|
1476
|
+
stream: true,
|
|
1477
|
+
stopped: true,
|
|
1478
|
+
streamId,
|
|
1479
|
+
stoppedCount: stopped,
|
|
1480
|
+
stoppedAt: new Date().toISOString()
|
|
1481
|
+
}
|
|
1482
|
+
});
|
|
1483
|
+
return;
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
if (command === 'file.transfer') {
|
|
1487
|
+
try {
|
|
1488
|
+
const result = await handleFileTransferCommand(options, message.payload || {});
|
|
1489
|
+
writeJsonLine(socket, {
|
|
1490
|
+
type: 'command.result',
|
|
1491
|
+
commandId: message.commandId,
|
|
1492
|
+
result
|
|
1493
|
+
});
|
|
1494
|
+
} catch (error) {
|
|
1495
|
+
writeJsonLine(socket, {
|
|
1496
|
+
type: 'command.result',
|
|
1497
|
+
commandId: message.commandId,
|
|
1498
|
+
error: error?.message || String(error)
|
|
1499
|
+
});
|
|
1500
|
+
}
|
|
1501
|
+
return;
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
if (command === 'file.transfer.chunk') {
|
|
1505
|
+
try {
|
|
1506
|
+
const result = await handleFileTransferChunkCommand(options, message.payload || {});
|
|
1507
|
+
writeJsonLine(socket, {
|
|
1508
|
+
type: 'command.result',
|
|
1509
|
+
commandId: message.commandId,
|
|
1510
|
+
result
|
|
1511
|
+
});
|
|
1512
|
+
} catch (error) {
|
|
1513
|
+
writeJsonLine(socket, {
|
|
1514
|
+
type: 'command.result',
|
|
1515
|
+
commandId: message.commandId,
|
|
1516
|
+
error: error?.message || String(error)
|
|
1517
|
+
});
|
|
1518
|
+
}
|
|
1519
|
+
return;
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
if (command === 'audio.start' || command === 'audio.stop') {
|
|
1523
|
+
writeJsonLine(socket, {
|
|
1524
|
+
type: 'command.result',
|
|
1525
|
+
commandId: message.commandId,
|
|
1526
|
+
error: 'remote audio is only available in a RemoteFast engine with audio support'
|
|
1527
|
+
});
|
|
1528
|
+
return;
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
if (command === 'input.control') {
|
|
1532
|
+
writeJsonLine(socket, {
|
|
1533
|
+
type: 'command.result',
|
|
1534
|
+
commandId: message.commandId,
|
|
1535
|
+
error: 'input control is only available in the C# RemoteFast engine'
|
|
1536
|
+
});
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
writeJsonLine(socket, {
|
|
1541
|
+
type: 'command.result',
|
|
1542
|
+
commandId: message.commandId,
|
|
1543
|
+
error: `Unsupported command: ${command}`
|
|
1544
|
+
});
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
function connectOnce(options, deviceId) {
|
|
1548
|
+
const manager = parseManagerAddress(options.manager);
|
|
1549
|
+
return new Promise((resolve, reject) => {
|
|
1550
|
+
const socket = net.createConnection({
|
|
1551
|
+
host: manager.host,
|
|
1552
|
+
port: manager.port
|
|
1553
|
+
});
|
|
1554
|
+
|
|
1555
|
+
let buffer = '';
|
|
1556
|
+
let heartbeatTimer = null;
|
|
1557
|
+
let resolved = false;
|
|
1558
|
+
let frameSeq = 0;
|
|
1559
|
+
const activeStreams = new Map();
|
|
1560
|
+
|
|
1561
|
+
function cleanup() {
|
|
1562
|
+
if (heartbeatTimer) {
|
|
1563
|
+
clearInterval(heartbeatTimer);
|
|
1564
|
+
heartbeatTimer = null;
|
|
1565
|
+
}
|
|
1566
|
+
stopLiveStream(activeStreams);
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
function finish(error = null) {
|
|
1570
|
+
if (resolved) {
|
|
1571
|
+
return;
|
|
1572
|
+
}
|
|
1573
|
+
resolved = true;
|
|
1574
|
+
cleanup();
|
|
1575
|
+
if (!socket.destroyed) {
|
|
1576
|
+
socket.destroy();
|
|
1577
|
+
}
|
|
1578
|
+
if (error) {
|
|
1579
|
+
reject(error);
|
|
1580
|
+
} else {
|
|
1581
|
+
resolve();
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
socket.setEncoding('utf8');
|
|
1586
|
+
socket.setNoDelay(true);
|
|
1587
|
+
socket.setKeepAlive(true, options.heartbeatMs);
|
|
1588
|
+
|
|
1589
|
+
socket.once('connect', () => {
|
|
1590
|
+
writeJsonLine(socket, {
|
|
1591
|
+
type: 'hello',
|
|
1592
|
+
pairToken: options.pair,
|
|
1593
|
+
deviceId,
|
|
1594
|
+
deviceName: options.name,
|
|
1595
|
+
slotNumber: options.slotNumber || undefined,
|
|
1596
|
+
hostname: os.hostname(),
|
|
1597
|
+
platform: os.platform(),
|
|
1598
|
+
arch: os.arch(),
|
|
1599
|
+
pid: process.pid,
|
|
1600
|
+
agentVersion: AGENT_VERSION,
|
|
1601
|
+
capabilities: {
|
|
1602
|
+
status: true,
|
|
1603
|
+
thumbnail: options.thumbnailEnabled,
|
|
1604
|
+
liveStream: options.liveEnabled,
|
|
1605
|
+
monitorSelection: true,
|
|
1606
|
+
screenCount: 1,
|
|
1607
|
+
monitorCount: 1,
|
|
1608
|
+
control: false,
|
|
1609
|
+
audio: false,
|
|
1610
|
+
remoteAudio: false,
|
|
1611
|
+
fileTransfer: true,
|
|
1612
|
+
remoteFiles: true,
|
|
1613
|
+
fileTransferMaxBytes: MAX_FILE_TRANSFER_BYTES,
|
|
1614
|
+
computerAgent: options.taskEnabled,
|
|
1615
|
+
taskDispatch: options.taskEnabled,
|
|
1616
|
+
agentApproval: options.taskEnabled,
|
|
1617
|
+
agentAudit: options.taskEnabled,
|
|
1618
|
+
agentTools: [...NODE_AGENT_OPERATIONS],
|
|
1619
|
+
elevation: typeof process.getuid === 'function' ? process.getuid() === 0 : false,
|
|
1620
|
+
aiAssist: options.taskEnabled && options.aiEnabled && (options.fakeAi || !!options.openAiApiKey),
|
|
1621
|
+
aiModel: options.aiModel,
|
|
1622
|
+
aiProvider: options.fakeAi ? 'fake' : (options.openAiApiKey ? 'openai' : ''),
|
|
1623
|
+
externalEffects: options.taskEnabled
|
|
1624
|
+
}
|
|
1625
|
+
});
|
|
1626
|
+
});
|
|
1627
|
+
|
|
1628
|
+
socket.on('data', chunk => {
|
|
1629
|
+
buffer += chunk;
|
|
1630
|
+
let newlineIndex = buffer.indexOf('\n');
|
|
1631
|
+
while (newlineIndex >= 0) {
|
|
1632
|
+
const line = buffer.slice(0, newlineIndex).trim();
|
|
1633
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
1634
|
+
newlineIndex = buffer.indexOf('\n');
|
|
1635
|
+
if (!line) {
|
|
1636
|
+
continue;
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
const message = JSON.parse(line);
|
|
1640
|
+
if (message.type === 'welcome') {
|
|
1641
|
+
console.log(`Connected to LiveDesk Hub as ${options.name} (${deviceId})`);
|
|
1642
|
+
writeJsonLine(socket, {
|
|
1643
|
+
type: 'status',
|
|
1644
|
+
status: getStatus(options)
|
|
1645
|
+
});
|
|
1646
|
+
if (options.once) {
|
|
1647
|
+
finish();
|
|
1648
|
+
return;
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
heartbeatTimer = setInterval(() => {
|
|
1652
|
+
writeJsonLine(socket, {
|
|
1653
|
+
type: 'status',
|
|
1654
|
+
status: getStatus(options)
|
|
1655
|
+
});
|
|
1656
|
+
}, options.heartbeatMs);
|
|
1657
|
+
} else if (message.type === 'command') {
|
|
1658
|
+
handleRemoteCommand(socket, options, message, () => {
|
|
1659
|
+
frameSeq += 1;
|
|
1660
|
+
return frameSeq;
|
|
1661
|
+
}, activeStreams).catch(error => {
|
|
1662
|
+
writeJsonLine(socket, {
|
|
1663
|
+
type: 'command.result',
|
|
1664
|
+
commandId: message.commandId,
|
|
1665
|
+
error: error?.message || String(error)
|
|
1666
|
+
});
|
|
1667
|
+
});
|
|
1668
|
+
} else if (message.type === 'disconnect') {
|
|
1669
|
+
finish();
|
|
1670
|
+
return;
|
|
1671
|
+
} else if (message.type === 'error') {
|
|
1672
|
+
const remoteError = new Error(message.error || 'LiveDesk Hub rejected the connection.');
|
|
1673
|
+
if (message.error === 'invalid-pair-token' && options.exitOnInvalidPair) {
|
|
1674
|
+
remoteError.exitCode = EXIT_INVALID_PAIR_TOKEN;
|
|
1675
|
+
}
|
|
1676
|
+
finish(remoteError);
|
|
1677
|
+
return;
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
});
|
|
1681
|
+
|
|
1682
|
+
socket.once('error', err => finish(err));
|
|
1683
|
+
socket.once('close', () => finish());
|
|
1684
|
+
});
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
async function connectWithRetry(options) {
|
|
1688
|
+
if (!options.pair) {
|
|
1689
|
+
throw new Error('Missing --pair token. Sign in with Google or get it from LiveDesk Hub status.');
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
const deviceId = await getDeviceId(options.deviceId);
|
|
1693
|
+
let attempt = 0;
|
|
1694
|
+
let stopping = false;
|
|
1695
|
+
|
|
1696
|
+
process.once('SIGINT', () => {
|
|
1697
|
+
stopping = true;
|
|
1698
|
+
console.log('\nStopping LiveDesk Client...');
|
|
1699
|
+
});
|
|
1700
|
+
process.once('SIGTERM', () => {
|
|
1701
|
+
stopping = true;
|
|
1702
|
+
});
|
|
1703
|
+
|
|
1704
|
+
while (!stopping) {
|
|
1705
|
+
try {
|
|
1706
|
+
await connectOnce(options, deviceId);
|
|
1707
|
+
if (options.once || options.exitOnDisconnect || stopping) {
|
|
1708
|
+
return;
|
|
1709
|
+
}
|
|
1710
|
+
attempt = 0;
|
|
1711
|
+
} catch (err) {
|
|
1712
|
+
attempt += 1;
|
|
1713
|
+
if (err?.exitCode === EXIT_INVALID_PAIR_TOKEN) {
|
|
1714
|
+
throw err;
|
|
1715
|
+
}
|
|
1716
|
+
const delayMs = DEFAULT_RECONNECT_MS;
|
|
1717
|
+
console.error(`LiveDesk Hub connection failed: ${err?.message || err}. Retrying in ${delayMs}ms.`);
|
|
1718
|
+
await wait(delayMs);
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
async function main() {
|
|
1724
|
+
const options = parseArgs(process.argv.slice(2));
|
|
1725
|
+
if (options.version || options.command === 'version') {
|
|
1726
|
+
console.log(AGENT_VERSION);
|
|
1727
|
+
return;
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
if (options.help || options.command === 'help') {
|
|
1731
|
+
printHelp();
|
|
1732
|
+
return;
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
if (options.command !== 'connect') {
|
|
1736
|
+
throw new Error(`Unknown command: ${options.command}`);
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
await connectWithRetry(options);
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
main().catch(err => {
|
|
1743
|
+
console.error(err?.message || err);
|
|
1744
|
+
process.exit(Number.isInteger(err?.exitCode) ? err.exitCode : 1);
|
|
1745
|
+
});
|