@elontools/runner 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +444 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1241 -0
- package/dist/index.js.map +1 -0
- package/package.json +47 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1241 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Runner v3 - Core Entry Point (Node.js/TypeScript)
|
|
4
|
+
* Executa comandos enviados pelo backend via API
|
|
5
|
+
*/
|
|
6
|
+
import * as fs from 'fs';
|
|
7
|
+
import * as path from 'path';
|
|
8
|
+
import * as os from 'os';
|
|
9
|
+
import { spawn } from 'child_process';
|
|
10
|
+
import * as crypto from 'crypto';
|
|
11
|
+
// ============================================================================
|
|
12
|
+
// CONFIGURATION
|
|
13
|
+
// ============================================================================
|
|
14
|
+
let config = {
|
|
15
|
+
serverUrl: 'https://elontools.com',
|
|
16
|
+
mode: 'headless',
|
|
17
|
+
};
|
|
18
|
+
const startTime = Date.now();
|
|
19
|
+
// Parse CLI arguments + fallback to config.json
|
|
20
|
+
function parseArgs() {
|
|
21
|
+
const args = process.argv.slice(2);
|
|
22
|
+
for (const arg of args) {
|
|
23
|
+
if (arg.startsWith('--server-url=')) {
|
|
24
|
+
config.serverUrl = arg.split('=')[1];
|
|
25
|
+
}
|
|
26
|
+
else if (arg.startsWith('--pairing-token=')) {
|
|
27
|
+
config.pairingToken = arg.split('=')[1];
|
|
28
|
+
}
|
|
29
|
+
else if (arg.startsWith('--runner-id=')) {
|
|
30
|
+
config.runnerId = arg.split('=')[1];
|
|
31
|
+
}
|
|
32
|
+
else if (arg.startsWith('--runner-token=')) {
|
|
33
|
+
config.runnerToken = arg.split('=')[1];
|
|
34
|
+
}
|
|
35
|
+
else if (arg.startsWith('--mode=')) {
|
|
36
|
+
config.mode = arg.split('=')[1] || 'headless';
|
|
37
|
+
}
|
|
38
|
+
else if (arg.startsWith('--config-path=')) {
|
|
39
|
+
config.configPath = arg.split('=')[1];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// Fallback: read ~/.elon-runner/config.json if missing runner credentials
|
|
43
|
+
if (!config.runnerId || !config.runnerToken) {
|
|
44
|
+
try {
|
|
45
|
+
const homeDir = os.homedir();
|
|
46
|
+
const configPath = config.configPath || path.join(homeDir, '.elon-runner', 'config.json');
|
|
47
|
+
if (fs.existsSync(configPath)) {
|
|
48
|
+
const saved = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
49
|
+
if (!config.runnerId && saved.runnerId)
|
|
50
|
+
config.runnerId = saved.runnerId;
|
|
51
|
+
if (!config.runnerToken && saved.runnerToken)
|
|
52
|
+
config.runnerToken = saved.runnerToken;
|
|
53
|
+
if (!config.serverUrl && saved.serverUrl)
|
|
54
|
+
config.serverUrl = saved.serverUrl;
|
|
55
|
+
if (saved.mode)
|
|
56
|
+
config.mode = saved.mode;
|
|
57
|
+
logger.info('📂 Config loaded from ' + configPath, {
|
|
58
|
+
hasRunnerId: !!config.runnerId,
|
|
59
|
+
hasRunnerToken: !!config.runnerToken,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
logger.warn('Failed to read config.json fallback', err);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
logger.info('✅ Runner v3 iniciado', {
|
|
68
|
+
serverUrl: config.serverUrl,
|
|
69
|
+
mode: config.mode,
|
|
70
|
+
hasPairingToken: !!config.pairingToken,
|
|
71
|
+
hasRunnerId: !!config.runnerId,
|
|
72
|
+
hasRunnerToken: !!config.runnerToken,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
// ============================================================================
|
|
76
|
+
// LOGGER
|
|
77
|
+
// ============================================================================
|
|
78
|
+
const logger = {
|
|
79
|
+
info: (msg, data) => {
|
|
80
|
+
console.log(`[${new Date().toISOString()}] ℹ️ ${msg}`, data ? JSON.stringify(data) : '');
|
|
81
|
+
},
|
|
82
|
+
success: (msg, data) => {
|
|
83
|
+
console.log(`[${new Date().toISOString()}] ✅ ${msg}`, data ? JSON.stringify(data) : '');
|
|
84
|
+
},
|
|
85
|
+
warn: (msg, data) => {
|
|
86
|
+
console.warn(`[${new Date().toISOString()}] ⚠️ ${msg}`, data ? JSON.stringify(data) : '');
|
|
87
|
+
},
|
|
88
|
+
error: (msg, err) => {
|
|
89
|
+
console.error(`[${new Date().toISOString()}] ❌ ${msg}`, err ? JSON.stringify(err) : '');
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
// ============================================================================
|
|
93
|
+
// API INTEGRATION
|
|
94
|
+
// ============================================================================
|
|
95
|
+
async function registerRunner() {
|
|
96
|
+
if (!config.pairingToken) {
|
|
97
|
+
logger.error('Pairing token não fornecido');
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
logger.info('📝 Registrando runner com API...');
|
|
102
|
+
const os = require('os');
|
|
103
|
+
const platform = os.platform() === 'darwin' ? 'macos' : os.platform() === 'win32' ? 'windows' : 'linux';
|
|
104
|
+
const arch = os.arch() === 'arm64' ? 'arm64' : 'x64';
|
|
105
|
+
const hostname = os.hostname() || 'elon-runner';
|
|
106
|
+
const response = await fetch(`${config.serverUrl}/api/v1/runner-v3/register`, {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
headers: {
|
|
109
|
+
'Content-Type': 'application/json',
|
|
110
|
+
},
|
|
111
|
+
body: JSON.stringify({
|
|
112
|
+
pairing_token: config.pairingToken,
|
|
113
|
+
name: hostname,
|
|
114
|
+
os: platform,
|
|
115
|
+
arch: arch,
|
|
116
|
+
mode: config.mode || 'ui',
|
|
117
|
+
version: process.env.ELON_VERSION || '3.1.1',
|
|
118
|
+
}),
|
|
119
|
+
});
|
|
120
|
+
if (!response.ok) {
|
|
121
|
+
logger.error(`Falha ao registrar: ${response.status}`, await response.text());
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
const json = (await response.json());
|
|
125
|
+
logger.info('Register response: ' + JSON.stringify(json));
|
|
126
|
+
// API returns { success, data: { success, data: { runner_id, runner_token } } }
|
|
127
|
+
const data = json?.data?.data || json?.data || json;
|
|
128
|
+
logger.info('Parsed data: runner_id=' + data?.runner_id + ' runner_token=' + (data?.runner_token ? 'rt3_***' : 'MISSING'));
|
|
129
|
+
config.runnerId = data.runner_id;
|
|
130
|
+
config.runnerToken = data.runner_token;
|
|
131
|
+
logger.info('Config after register: runnerId=' + config.runnerId + ' hasToken=' + !!config.runnerToken);
|
|
132
|
+
config.pairingToken = undefined; // Remove após registrar
|
|
133
|
+
logger.success(`✅ Runner registrado — Runner ID: ${config.runnerId}`);
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
logger.error('Erro ao registrar runner', error);
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// Cache permissions — detect ONCE at startup, never re-trigger dialogs
|
|
142
|
+
let cachedPermissions = null;
|
|
143
|
+
let permissionsDetectedAt = 0;
|
|
144
|
+
const PERM_CACHE_MS = 10 * 60 * 1000; // re-check every 10 min (file perms only, not screen)
|
|
145
|
+
function detectPermissions() {
|
|
146
|
+
// Return cache if fresh
|
|
147
|
+
if (cachedPermissions && (Date.now() - permissionsDetectedAt < PERM_CACHE_MS)) {
|
|
148
|
+
return cachedPermissions;
|
|
149
|
+
}
|
|
150
|
+
const home = os.homedir();
|
|
151
|
+
const perms = {
|
|
152
|
+
desktop: false,
|
|
153
|
+
documents: false,
|
|
154
|
+
downloads: false,
|
|
155
|
+
screen_capture: cachedPermissions?.screen_capture ?? false, // never re-test screen capture
|
|
156
|
+
accessibility: cachedPermissions?.accessibility ?? false,
|
|
157
|
+
microphone: cachedPermissions?.microphone ?? false,
|
|
158
|
+
};
|
|
159
|
+
if (process.platform !== 'darwin') {
|
|
160
|
+
cachedPermissions = { desktop: true, documents: true, downloads: true, screen_capture: true, accessibility: true, microphone: true };
|
|
161
|
+
permissionsDetectedAt = Date.now();
|
|
162
|
+
return cachedPermissions;
|
|
163
|
+
}
|
|
164
|
+
// File access: try readdirSync on protected folders (safe — no dialog if already granted/denied)
|
|
165
|
+
const folderMap = {
|
|
166
|
+
'Desktop': 'desktop',
|
|
167
|
+
'Documents': 'documents',
|
|
168
|
+
'Downloads': 'downloads',
|
|
169
|
+
};
|
|
170
|
+
for (const [folder, key] of Object.entries(folderMap)) {
|
|
171
|
+
try {
|
|
172
|
+
const p = path.join(home, folder);
|
|
173
|
+
if (fs.existsSync(p)) {
|
|
174
|
+
fs.readdirSync(p);
|
|
175
|
+
perms[key] = true;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
perms[key] = false;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// Screen capture & accessibility: ONLY detect ONCE at first call
|
|
183
|
+
// These trigger macOS permission dialogs, so never re-run
|
|
184
|
+
if (!cachedPermissions) {
|
|
185
|
+
// Accessibility: check via AppleScript
|
|
186
|
+
try {
|
|
187
|
+
const { execSync } = require('child_process');
|
|
188
|
+
execSync('osascript -e \'tell application "System Events" to get name of first process\'', { timeout: 3000, stdio: 'pipe' });
|
|
189
|
+
perms.accessibility = true;
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
perms.accessibility = false;
|
|
193
|
+
}
|
|
194
|
+
// Screen capture: DO NOT run screencapture — it triggers dialog every time if denied
|
|
195
|
+
// Instead just mark as unknown/false; actual status detected when user enables capture via dashboard
|
|
196
|
+
perms.screen_capture = false;
|
|
197
|
+
// Microphone: basic check (no dialog trigger)
|
|
198
|
+
try {
|
|
199
|
+
const { execSync } = require('child_process');
|
|
200
|
+
execSync('system_profiler SPAudioDataType 2>/dev/null | head -1', { timeout: 3000, stdio: 'pipe' });
|
|
201
|
+
perms.microphone = true;
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
perms.microphone = false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
cachedPermissions = perms;
|
|
208
|
+
permissionsDetectedAt = Date.now();
|
|
209
|
+
return perms;
|
|
210
|
+
}
|
|
211
|
+
async function sendHeartbeat() {
|
|
212
|
+
if (!config.runnerId || !config.runnerToken) {
|
|
213
|
+
logger.warn('Sem runner_id/token para heartbeat');
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
const openclawBin = findOpenClawBinary();
|
|
218
|
+
const permissions = detectPermissions();
|
|
219
|
+
const payload = {
|
|
220
|
+
status: 'online',
|
|
221
|
+
has_openclaw: !!openclawBin,
|
|
222
|
+
permissions,
|
|
223
|
+
};
|
|
224
|
+
const response = await fetch(`${config.serverUrl}/api/v1/runner-v3/poll/${config.runnerId}/heartbeat`, {
|
|
225
|
+
method: 'POST',
|
|
226
|
+
headers: {
|
|
227
|
+
'Content-Type': 'application/json',
|
|
228
|
+
'Authorization': `Bearer ${config.runnerToken}`,
|
|
229
|
+
},
|
|
230
|
+
body: JSON.stringify(payload),
|
|
231
|
+
});
|
|
232
|
+
if (!response.ok) {
|
|
233
|
+
const errText = await response.text();
|
|
234
|
+
logger.error(`Heartbeat falhou (${response.status}): ${errText}`);
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
logger.info('💓 Heartbeat enviado', { uptime: Math.round((Date.now() - startTime) / 1000) });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
logger.warn('Erro ao enviar heartbeat', error instanceof Error ? error.message : error);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
async function pullJobs() {
|
|
245
|
+
if (!config.runnerId || !config.runnerToken) {
|
|
246
|
+
return [];
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
const response = await fetch(`${config.serverUrl}/api/v1/runner-v3/poll/${config.runnerId}/pull`, {
|
|
250
|
+
method: 'GET',
|
|
251
|
+
headers: {
|
|
252
|
+
'Authorization': `Bearer ${config.runnerToken}`,
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
if (!response.ok) {
|
|
256
|
+
return [];
|
|
257
|
+
}
|
|
258
|
+
return (await response.json()).jobs || [];
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
logger.warn('Erro ao pull jobs', error);
|
|
262
|
+
return [];
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
async function reportJobResult(jobId, result) {
|
|
266
|
+
if (!config.runnerId || !config.runnerToken) {
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
try {
|
|
270
|
+
await fetch(`${config.serverUrl}/api/v1/runner-v3/poll/${config.runnerId}/report`, {
|
|
271
|
+
method: 'POST',
|
|
272
|
+
headers: {
|
|
273
|
+
'Content-Type': 'application/json',
|
|
274
|
+
'Authorization': `Bearer ${config.runnerToken}`,
|
|
275
|
+
},
|
|
276
|
+
body: JSON.stringify({
|
|
277
|
+
job_id: jobId,
|
|
278
|
+
status: result.status,
|
|
279
|
+
output: result.output,
|
|
280
|
+
error: result.error,
|
|
281
|
+
}),
|
|
282
|
+
});
|
|
283
|
+
logger.success('📤 Job result reportado', { jobId, status: result.status });
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
logger.error('Erro ao reportar job result', error);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
// ============================================================================
|
|
290
|
+
// JOB EXECUTION
|
|
291
|
+
// ============================================================================
|
|
292
|
+
async function executeJob(job) {
|
|
293
|
+
logger.info('🚀 Executando job', { jobId: job.id, command: job.command });
|
|
294
|
+
try {
|
|
295
|
+
const result = await executeCommand(job.command, job.args || []);
|
|
296
|
+
return {
|
|
297
|
+
status: 'completed',
|
|
298
|
+
output: result.stdout,
|
|
299
|
+
error: null,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
return {
|
|
304
|
+
status: 'failed',
|
|
305
|
+
output: '',
|
|
306
|
+
error: error.message || 'Unknown error',
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
function executeCommand(command, args) {
|
|
311
|
+
return new Promise((resolve, reject) => {
|
|
312
|
+
const proc = spawn(command, args, {
|
|
313
|
+
shell: process.env.SHELL || true,
|
|
314
|
+
stdio: 'pipe',
|
|
315
|
+
env: getShellEnv(),
|
|
316
|
+
cwd: os.homedir(),
|
|
317
|
+
});
|
|
318
|
+
let stdout = '';
|
|
319
|
+
let stderr = '';
|
|
320
|
+
proc.stdout?.on('data', (data) => {
|
|
321
|
+
stdout += data.toString();
|
|
322
|
+
process.stdout.write(data);
|
|
323
|
+
});
|
|
324
|
+
proc.stderr?.on('data', (data) => {
|
|
325
|
+
stderr += data.toString();
|
|
326
|
+
process.stderr.write(data);
|
|
327
|
+
});
|
|
328
|
+
proc.on('close', (code) => {
|
|
329
|
+
if (code === 0) {
|
|
330
|
+
resolve({ stdout, stderr });
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
reject(new Error(`Command failed with code ${code}: ${stderr}`));
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
proc.on('error', (error) => {
|
|
337
|
+
reject(error);
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
// ============================================================================
|
|
342
|
+
// SYSTEM INFO
|
|
343
|
+
// ============================================================================
|
|
344
|
+
function getPlatform() {
|
|
345
|
+
return process.platform; // 'darwin' (macOS), 'linux', 'win32' (Windows)
|
|
346
|
+
}
|
|
347
|
+
function getArch() {
|
|
348
|
+
return process.arch; // 'arm64', 'x64', etc
|
|
349
|
+
}
|
|
350
|
+
// ============================================================================
|
|
351
|
+
// FILE SYNC
|
|
352
|
+
// ============================================================================
|
|
353
|
+
const SYNC_INTERVAL_MS = 60000; // every 60s
|
|
354
|
+
const MAX_FILE_SIZE = 512 * 1024; // 512KB max per file
|
|
355
|
+
const MAX_FILES_PER_PUSH = 100;
|
|
356
|
+
let lastFileHashes = {};
|
|
357
|
+
const IGNORE_PATTERNS = [
|
|
358
|
+
'node_modules', '.git', '.DS_Store', 'dist', 'build', '.next',
|
|
359
|
+
'__pycache__', '.venv', 'venv', '.env', 'secrets',
|
|
360
|
+
'.elon-runner', 'runner.log',
|
|
361
|
+
];
|
|
362
|
+
const TEXT_EXTENSIONS = new Set([
|
|
363
|
+
'.md', '.txt', '.ts', '.tsx', '.js', '.jsx', '.json', '.yaml', '.yml',
|
|
364
|
+
'.toml', '.html', '.css', '.scss', '.sh', '.bash', '.zsh', '.py',
|
|
365
|
+
'.rb', '.go', '.rs', '.sql', '.env.example', '.gitignore', '.cfg',
|
|
366
|
+
'.ini', '.conf', '.xml', '.csv', '.log', '.dockerfile', '.makefile',
|
|
367
|
+
]);
|
|
368
|
+
function shouldIgnore(name) {
|
|
369
|
+
return IGNORE_PATTERNS.some(p => name === p || name.startsWith('.'));
|
|
370
|
+
}
|
|
371
|
+
function isTextFile(filePath) {
|
|
372
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
373
|
+
const base = path.basename(filePath).toLowerCase();
|
|
374
|
+
return TEXT_EXTENSIONS.has(ext) || base === 'dockerfile' || base === 'makefile' || base === '.gitignore';
|
|
375
|
+
}
|
|
376
|
+
function scanDir(dir, prefix = '') {
|
|
377
|
+
const results = [];
|
|
378
|
+
try {
|
|
379
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
380
|
+
for (const entry of entries) {
|
|
381
|
+
if (shouldIgnore(entry.name))
|
|
382
|
+
continue;
|
|
383
|
+
const fullPath = path.join(dir, entry.name);
|
|
384
|
+
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
385
|
+
if (entry.isDirectory()) {
|
|
386
|
+
results.push(...scanDir(fullPath, relPath));
|
|
387
|
+
}
|
|
388
|
+
else if (entry.isFile() && isTextFile(entry.name)) {
|
|
389
|
+
try {
|
|
390
|
+
const stat = fs.statSync(fullPath);
|
|
391
|
+
if (stat.size > MAX_FILE_SIZE)
|
|
392
|
+
continue;
|
|
393
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
394
|
+
results.push({ file_path: relPath, content, size_bytes: stat.size });
|
|
395
|
+
}
|
|
396
|
+
catch { }
|
|
397
|
+
}
|
|
398
|
+
if (results.length >= MAX_FILES_PER_PUSH * 2)
|
|
399
|
+
break;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
catch { }
|
|
403
|
+
return results;
|
|
404
|
+
}
|
|
405
|
+
// ============================================================================
|
|
406
|
+
// FILE ACCESS PERMISSIONS (macOS protected folders)
|
|
407
|
+
// ============================================================================
|
|
408
|
+
// Folders that require macOS TCC consent (triggers dialog once)
|
|
409
|
+
const PROTECTED_FOLDERS = ['Desktop', 'Documents', 'Downloads'];
|
|
410
|
+
// Non-protected folders — never trigger dialogs
|
|
411
|
+
const SAFE_FOLDERS = ['Projects', 'Developer', 'dev', '.elon-runner'];
|
|
412
|
+
// Cache: which protected folders the user granted access to
|
|
413
|
+
const grantedFolders = new Set();
|
|
414
|
+
let permissionsProbed = false;
|
|
415
|
+
/**
|
|
416
|
+
* Probe protected folders ONCE at startup.
|
|
417
|
+
* Each folder triggers a macOS dialog if not yet consented.
|
|
418
|
+
* We try fs.readdirSync — if it works, user allowed it. If it throws, denied.
|
|
419
|
+
* This runs ONCE so the user sees each dialog only once (not on every sync).
|
|
420
|
+
*/
|
|
421
|
+
function probeFilePermissions() {
|
|
422
|
+
if (permissionsProbed)
|
|
423
|
+
return;
|
|
424
|
+
permissionsProbed = true;
|
|
425
|
+
const homeDir = os.homedir();
|
|
426
|
+
logger.info('📂 Solicitando permissões de arquivo (macOS)...');
|
|
427
|
+
for (const folder of PROTECTED_FOLDERS) {
|
|
428
|
+
const fullPath = path.join(homeDir, folder);
|
|
429
|
+
try {
|
|
430
|
+
if (!fs.existsSync(fullPath))
|
|
431
|
+
continue;
|
|
432
|
+
// This triggers the macOS permission dialog if not yet granted
|
|
433
|
+
fs.readdirSync(fullPath);
|
|
434
|
+
grantedFolders.add(fullPath);
|
|
435
|
+
logger.success(`📂 Acesso permitido: ~/${folder}`);
|
|
436
|
+
}
|
|
437
|
+
catch (err) {
|
|
438
|
+
logger.warn(`📂 Acesso negado ou inexistente: ~/${folder} — ${err.code || err.message}`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
// Safe folders — always add if they exist
|
|
442
|
+
for (const folder of SAFE_FOLDERS) {
|
|
443
|
+
const fullPath = path.join(homeDir, folder);
|
|
444
|
+
try {
|
|
445
|
+
if (fs.existsSync(fullPath) && fs.statSync(fullPath).isDirectory()) {
|
|
446
|
+
grantedFolders.add(fullPath);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
catch { }
|
|
450
|
+
}
|
|
451
|
+
logger.info(`📂 Pastas acessíveis: ${grantedFolders.size}`, [...grantedFolders].map(p => path.basename(p)));
|
|
452
|
+
}
|
|
453
|
+
async function syncFiles() {
|
|
454
|
+
if (!config.runnerId || !config.runnerToken)
|
|
455
|
+
return;
|
|
456
|
+
try {
|
|
457
|
+
// Use only folders that were granted access during startup probe
|
|
458
|
+
const scanDirs = [...grantedFolders];
|
|
459
|
+
let allFiles = [];
|
|
460
|
+
for (const dir of scanDirs) {
|
|
461
|
+
const dirName = path.basename(dir);
|
|
462
|
+
const files = scanDir(dir, dirName);
|
|
463
|
+
allFiles.push(...files);
|
|
464
|
+
if (allFiles.length >= MAX_FILES_PER_PUSH * 2)
|
|
465
|
+
break;
|
|
466
|
+
}
|
|
467
|
+
// Filter to only changed files (hash check)
|
|
468
|
+
const changedFiles = [];
|
|
469
|
+
const newHashes = {};
|
|
470
|
+
for (const f of allFiles) {
|
|
471
|
+
const hash = crypto.createHash('md5').update(f.content).digest('hex');
|
|
472
|
+
newHashes[f.file_path] = hash;
|
|
473
|
+
if (lastFileHashes[f.file_path] !== hash) {
|
|
474
|
+
changedFiles.push(f);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
lastFileHashes = newHashes;
|
|
478
|
+
if (changedFiles.length === 0)
|
|
479
|
+
return;
|
|
480
|
+
// Push in batches
|
|
481
|
+
const batch = changedFiles.slice(0, MAX_FILES_PER_PUSH);
|
|
482
|
+
const response = await fetch(`${config.serverUrl}/api/v1/runner-v3/poll/${config.runnerId}/push-files`, {
|
|
483
|
+
method: 'POST',
|
|
484
|
+
headers: {
|
|
485
|
+
'Content-Type': 'application/json',
|
|
486
|
+
'Authorization': `Bearer ${config.runnerToken}`,
|
|
487
|
+
},
|
|
488
|
+
body: JSON.stringify({ files: batch }),
|
|
489
|
+
});
|
|
490
|
+
if (response.ok) {
|
|
491
|
+
const json = (await response.json());
|
|
492
|
+
logger.info(`📁 Files synced: ${json?.data?.files_synced || batch.length} files`);
|
|
493
|
+
}
|
|
494
|
+
else {
|
|
495
|
+
logger.warn(`File sync failed: ${response.status}`);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
catch (error) {
|
|
499
|
+
logger.warn('File sync error', error instanceof Error ? error.message : error);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
// ============================================================================
|
|
503
|
+
// TERMINAL COMMAND EXECUTION
|
|
504
|
+
// ============================================================================
|
|
505
|
+
const TERMINAL_TIMEOUT_MS = 300000; // 5 min max per command
|
|
506
|
+
// Build full PATH for macOS (Homebrew, nvm, etc)
|
|
507
|
+
function getShellEnv() {
|
|
508
|
+
const home = os.homedir();
|
|
509
|
+
const existing = process.env.PATH || '/usr/bin:/bin';
|
|
510
|
+
const extraPaths = [
|
|
511
|
+
'/opt/homebrew/bin',
|
|
512
|
+
'/opt/homebrew/sbin',
|
|
513
|
+
'/usr/local/bin',
|
|
514
|
+
'/usr/local/sbin',
|
|
515
|
+
`${home}/.nvm/versions/node/current/bin`,
|
|
516
|
+
`${home}/.volta/bin`,
|
|
517
|
+
`${home}/.bun/bin`,
|
|
518
|
+
];
|
|
519
|
+
return {
|
|
520
|
+
...process.env,
|
|
521
|
+
PATH: [...extraPaths, existing].join(':'),
|
|
522
|
+
HOME: home,
|
|
523
|
+
USER: os.userInfo().username,
|
|
524
|
+
LANG: 'en_US.UTF-8',
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
async function executeTerminalCommand(cmd) {
|
|
528
|
+
logger.info(`🖥️ Executando comando terminal [${cmd.id}]`, { command: cmd.command });
|
|
529
|
+
const shellEnv = getShellEnv();
|
|
530
|
+
// Use user's default shell on macOS, fallback to /bin/sh
|
|
531
|
+
const userShell = process.env.SHELL || '/bin/zsh';
|
|
532
|
+
const isZsh = userShell.includes('zsh');
|
|
533
|
+
// Wrap command to source profile for full env
|
|
534
|
+
const wrappedCmd = isZsh
|
|
535
|
+
? `source ~/.zshrc 2>/dev/null; ${cmd.command}`
|
|
536
|
+
: cmd.command;
|
|
537
|
+
const proc = spawn(wrappedCmd, [], {
|
|
538
|
+
shell: userShell,
|
|
539
|
+
cwd: cmd.cwd || os.homedir(),
|
|
540
|
+
stdio: 'pipe',
|
|
541
|
+
env: shellEnv,
|
|
542
|
+
});
|
|
543
|
+
let buffer = '';
|
|
544
|
+
let flushTimeout = null;
|
|
545
|
+
let done = false;
|
|
546
|
+
const flushBuffer = async (status, exitCode) => {
|
|
547
|
+
if (flushTimeout) {
|
|
548
|
+
clearTimeout(flushTimeout);
|
|
549
|
+
flushTimeout = null;
|
|
550
|
+
}
|
|
551
|
+
const chunk = buffer;
|
|
552
|
+
buffer = '';
|
|
553
|
+
try {
|
|
554
|
+
await fetch(`${config.serverUrl}/api/v1/runner-v3/poll/${config.runnerId}/terminal/output`, {
|
|
555
|
+
method: 'POST',
|
|
556
|
+
headers: {
|
|
557
|
+
'Content-Type': 'application/json',
|
|
558
|
+
'Authorization': `Bearer ${config.runnerToken}`,
|
|
559
|
+
},
|
|
560
|
+
body: JSON.stringify({
|
|
561
|
+
command_id: cmd.id,
|
|
562
|
+
output_chunk: chunk,
|
|
563
|
+
status,
|
|
564
|
+
exit_code: exitCode,
|
|
565
|
+
}),
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
catch { }
|
|
569
|
+
};
|
|
570
|
+
const scheduleFlush = () => {
|
|
571
|
+
if (flushTimeout)
|
|
572
|
+
return;
|
|
573
|
+
flushTimeout = setTimeout(async () => {
|
|
574
|
+
flushTimeout = null;
|
|
575
|
+
if (buffer.length > 0 && !done) {
|
|
576
|
+
await flushBuffer('running');
|
|
577
|
+
}
|
|
578
|
+
}, 500);
|
|
579
|
+
};
|
|
580
|
+
proc.stdout?.on('data', (data) => {
|
|
581
|
+
buffer += data.toString();
|
|
582
|
+
scheduleFlush();
|
|
583
|
+
});
|
|
584
|
+
proc.stderr?.on('data', (data) => {
|
|
585
|
+
buffer += data.toString();
|
|
586
|
+
scheduleFlush();
|
|
587
|
+
});
|
|
588
|
+
// Timeout kill
|
|
589
|
+
const killTimer = setTimeout(() => {
|
|
590
|
+
if (!done) {
|
|
591
|
+
logger.warn(`⏱️ Comando terminal atingiu timeout (${TERMINAL_TIMEOUT_MS}ms) [${cmd.id}]`);
|
|
592
|
+
proc.kill('SIGKILL');
|
|
593
|
+
}
|
|
594
|
+
}, TERMINAL_TIMEOUT_MS);
|
|
595
|
+
proc.on('close', async (code) => {
|
|
596
|
+
done = true;
|
|
597
|
+
clearTimeout(killTimer);
|
|
598
|
+
const exitCode = code ?? -1;
|
|
599
|
+
const status = exitCode === 0 ? 'completed' : 'error';
|
|
600
|
+
await flushBuffer(status, exitCode);
|
|
601
|
+
logger.info(`✅ Comando terminal finalizado [${cmd.id}]`, { exitCode, status });
|
|
602
|
+
});
|
|
603
|
+
proc.on('error', async (err) => {
|
|
604
|
+
done = true;
|
|
605
|
+
clearTimeout(killTimer);
|
|
606
|
+
buffer += `\n[Erro ao executar: ${err.message}]`;
|
|
607
|
+
await flushBuffer('error', -1);
|
|
608
|
+
logger.error(`❌ Erro ao executar comando terminal [${cmd.id}]`, err.message);
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
async function pollTerminalCommands() {
|
|
612
|
+
if (!config.runnerId || !config.runnerToken) {
|
|
613
|
+
logger.warn('🖥️ Terminal poll skipped: no runnerId/token');
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
try {
|
|
617
|
+
const res = await fetch(`${config.serverUrl}/api/v1/runner-v3/poll/${config.runnerId}/terminal/pending`, {
|
|
618
|
+
headers: { 'Authorization': `Bearer ${config.runnerToken}` },
|
|
619
|
+
});
|
|
620
|
+
if (!res.ok)
|
|
621
|
+
return;
|
|
622
|
+
const data = (await res.json());
|
|
623
|
+
// API returns { success, data: { success, data: { commands } } } due to nested success() wrapper
|
|
624
|
+
const cmds = data?.data?.data?.commands || data?.data?.commands || data?.commands || [];
|
|
625
|
+
if (cmds.length > 0) {
|
|
626
|
+
logger.info(`🖥️ ${cmds.length} comando(s) terminal pendente(s)`);
|
|
627
|
+
}
|
|
628
|
+
for (const cmd of cmds) {
|
|
629
|
+
executeTerminalCommand(cmd).catch(() => { });
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
catch { }
|
|
633
|
+
}
|
|
634
|
+
// ============================================================================
|
|
635
|
+
// SCREEN CAPTURE & UI CONTROL (macOS)
|
|
636
|
+
// ============================================================================
|
|
637
|
+
const SCREEN_CAPTURE_INTERVAL_MS = 2000; // Capture every 2s
|
|
638
|
+
let screenCaptureActive = false;
|
|
639
|
+
let screenCaptureInterval = null;
|
|
640
|
+
let isCapturing = false;
|
|
641
|
+
/**
|
|
642
|
+
* Capture screen on macOS using native screencapture command
|
|
643
|
+
*/
|
|
644
|
+
let screenPermissionGranted = false;
|
|
645
|
+
let screenPermissionCheckedAt = 0;
|
|
646
|
+
const SCREEN_PERMISSION_RECHECK_MS = 120000; // Re-check every 2 min
|
|
647
|
+
/**
|
|
648
|
+
* Check screen recording permission WITHOUT triggering the system dialog.
|
|
649
|
+
* Uses CGPreflightScreenCaptureAccess() via Python — returns true/false silently.
|
|
650
|
+
*/
|
|
651
|
+
async function checkScreenPermission() {
|
|
652
|
+
try {
|
|
653
|
+
const { execSync } = await import('child_process');
|
|
654
|
+
const result = execSync(`python3 -c "import Quartz; print('1' if Quartz.CGPreflightScreenCaptureAccess() else '0')" 2>/dev/null`, { timeout: 5000, encoding: 'utf-8' }).trim();
|
|
655
|
+
return result === '1';
|
|
656
|
+
}
|
|
657
|
+
catch {
|
|
658
|
+
// Fallback: assume not granted
|
|
659
|
+
return false;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
async function captureScreen() {
|
|
663
|
+
if (process.platform !== 'darwin')
|
|
664
|
+
return null;
|
|
665
|
+
// Check permission silently (no dialog) — cache result, re-check periodically
|
|
666
|
+
const now = Date.now();
|
|
667
|
+
if (!screenPermissionGranted || (now - screenPermissionCheckedAt > SCREEN_PERMISSION_RECHECK_MS)) {
|
|
668
|
+
screenPermissionGranted = await checkScreenPermission();
|
|
669
|
+
screenPermissionCheckedAt = now;
|
|
670
|
+
if (!screenPermissionGranted) {
|
|
671
|
+
// Only log once per check cycle
|
|
672
|
+
if (now - screenPermissionCheckedAt < 1000) {
|
|
673
|
+
logger.warn('📸 Gravação de tela não autorizada. Conceda em: Ajustes do Sistema → Privacidade → Gravação de Tela → Elon Tools');
|
|
674
|
+
}
|
|
675
|
+
return null;
|
|
676
|
+
}
|
|
677
|
+
else {
|
|
678
|
+
logger.success('📸 Permissão de gravação de tela OK');
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
if (!screenPermissionGranted)
|
|
682
|
+
return null;
|
|
683
|
+
const tmpFile = `/tmp/elon-screen-${Date.now()}.jpg`;
|
|
684
|
+
try {
|
|
685
|
+
const { execSync } = await import('child_process');
|
|
686
|
+
execSync(`screencapture -x -t jpg -C "${tmpFile}"`, { timeout: 5000, stdio: 'pipe' });
|
|
687
|
+
if (!fs.existsSync(tmpFile))
|
|
688
|
+
return null;
|
|
689
|
+
const data = fs.readFileSync(tmpFile);
|
|
690
|
+
fs.unlinkSync(tmpFile);
|
|
691
|
+
return data.length > 500 ? data : null;
|
|
692
|
+
}
|
|
693
|
+
catch (err) {
|
|
694
|
+
logger.warn('📸 Screen capture failed:', err.message);
|
|
695
|
+
// Permission might have been revoked
|
|
696
|
+
screenPermissionGranted = false;
|
|
697
|
+
try {
|
|
698
|
+
fs.unlinkSync(tmpFile);
|
|
699
|
+
}
|
|
700
|
+
catch { }
|
|
701
|
+
return null;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* Upload captured frame to backend
|
|
706
|
+
*/
|
|
707
|
+
async function uploadFrame(frameData) {
|
|
708
|
+
try {
|
|
709
|
+
await fetch(`${config.serverUrl}/api/v1/runner-v3-ui/${config.runnerId}/ui/frame`, {
|
|
710
|
+
method: 'POST',
|
|
711
|
+
headers: {
|
|
712
|
+
'Authorization': `Bearer ${config.runnerToken}`,
|
|
713
|
+
'Content-Type': 'image/jpeg',
|
|
714
|
+
},
|
|
715
|
+
body: frameData,
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
catch { }
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Screen capture loop — runs every 2s when in UI mode
|
|
722
|
+
*/
|
|
723
|
+
async function screenCaptureLoop() {
|
|
724
|
+
if (!config.runnerId || !config.runnerToken)
|
|
725
|
+
return;
|
|
726
|
+
if (isCapturing)
|
|
727
|
+
return;
|
|
728
|
+
isCapturing = true;
|
|
729
|
+
try {
|
|
730
|
+
const frame = await captureScreen();
|
|
731
|
+
if (frame && frame.length > 0) {
|
|
732
|
+
await uploadFrame(frame);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
catch { }
|
|
736
|
+
isCapturing = false;
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* Execute a UI command (mouse/keyboard) on macOS
|
|
740
|
+
*/
|
|
741
|
+
async function executeUICommand(cmd) {
|
|
742
|
+
if (process.platform !== 'darwin') {
|
|
743
|
+
logger.warn(`🖱️ UI commands not supported on ${process.platform}`);
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
const { execSync } = await import('child_process');
|
|
747
|
+
const d = cmd.data;
|
|
748
|
+
try {
|
|
749
|
+
switch (cmd.type) {
|
|
750
|
+
case 'mouse_move': {
|
|
751
|
+
const x = Number(d.x), y = Number(d.y);
|
|
752
|
+
// Use AppleScript for mouse move
|
|
753
|
+
execSync(`osascript -e 'tell application "System Events" to set position of mouse to {${x}, ${y}}'`, { timeout: 3000 });
|
|
754
|
+
break;
|
|
755
|
+
}
|
|
756
|
+
case 'click': {
|
|
757
|
+
const x = Number(d.x), y = Number(d.y);
|
|
758
|
+
execSync(`osascript -e '
|
|
759
|
+
do shell script "python3 -c \\"
|
|
760
|
+
import Quartz
|
|
761
|
+
evt = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventMouseMoved, (${x}, ${y}), Quartz.kCGMouseButtonLeft)
|
|
762
|
+
Quartz.CGEventPost(Quartz.kCGHIDEventTap, evt)
|
|
763
|
+
import time; time.sleep(0.05)
|
|
764
|
+
evt = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventLeftMouseDown, (${x}, ${y}), Quartz.kCGMouseButtonLeft)
|
|
765
|
+
Quartz.CGEventPost(Quartz.kCGHIDEventTap, evt)
|
|
766
|
+
evt = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventLeftMouseUp, (${x}, ${y}), Quartz.kCGMouseButtonLeft)
|
|
767
|
+
Quartz.CGEventPost(Quartz.kCGHIDEventTap, evt)
|
|
768
|
+
\\"'`, { timeout: 5000 });
|
|
769
|
+
break;
|
|
770
|
+
}
|
|
771
|
+
case 'double_click': {
|
|
772
|
+
const x = Number(d.x), y = Number(d.y);
|
|
773
|
+
execSync(`osascript -e '
|
|
774
|
+
do shell script "python3 -c \\"
|
|
775
|
+
import Quartz
|
|
776
|
+
for i in range(2):
|
|
777
|
+
evt = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventLeftMouseDown, (${x}, ${y}), Quartz.kCGMouseButtonLeft)
|
|
778
|
+
Quartz.CGEventSetIntegerValueField(evt, Quartz.kCGMouseEventClickState, i+1)
|
|
779
|
+
Quartz.CGEventPost(Quartz.kCGHIDEventTap, evt)
|
|
780
|
+
evt = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventLeftMouseUp, (${x}, ${y}), Quartz.kCGMouseButtonLeft)
|
|
781
|
+
Quartz.CGEventSetIntegerValueField(evt, Quartz.kCGMouseEventClickState, i+1)
|
|
782
|
+
Quartz.CGEventPost(Quartz.kCGHIDEventTap, evt)
|
|
783
|
+
\\"'`, { timeout: 5000 });
|
|
784
|
+
break;
|
|
785
|
+
}
|
|
786
|
+
case 'right_click': {
|
|
787
|
+
const x = Number(d.x), y = Number(d.y);
|
|
788
|
+
execSync(`osascript -e '
|
|
789
|
+
do shell script "python3 -c \\"
|
|
790
|
+
import Quartz
|
|
791
|
+
evt = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventMouseMoved, (${x}, ${y}), Quartz.kCGMouseButtonRight)
|
|
792
|
+
Quartz.CGEventPost(Quartz.kCGHIDEventTap, evt)
|
|
793
|
+
import time; time.sleep(0.05)
|
|
794
|
+
evt = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventRightMouseDown, (${x}, ${y}), Quartz.kCGMouseButtonRight)
|
|
795
|
+
Quartz.CGEventPost(Quartz.kCGHIDEventTap, evt)
|
|
796
|
+
evt = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventRightMouseUp, (${x}, ${y}), Quartz.kCGMouseButtonRight)
|
|
797
|
+
Quartz.CGEventPost(Quartz.kCGHIDEventTap, evt)
|
|
798
|
+
\\"'`, { timeout: 5000 });
|
|
799
|
+
break;
|
|
800
|
+
}
|
|
801
|
+
case 'key_press': {
|
|
802
|
+
const key = String(d.key);
|
|
803
|
+
execSync(`osascript -e 'tell application "System Events" to keystroke "${key.replace(/"/g, '\\"')}"'`, { timeout: 3000 });
|
|
804
|
+
break;
|
|
805
|
+
}
|
|
806
|
+
case 'key_combo': {
|
|
807
|
+
const keys = d.keys || [];
|
|
808
|
+
// Map modifier names to AppleScript
|
|
809
|
+
const modMap = {
|
|
810
|
+
'cmd': 'command down', 'command': 'command down',
|
|
811
|
+
'ctrl': 'control down', 'control': 'control down',
|
|
812
|
+
'alt': 'option down', 'option': 'option down',
|
|
813
|
+
'shift': 'shift down',
|
|
814
|
+
};
|
|
815
|
+
const modifiers = keys.slice(0, -1).map(k => modMap[k.toLowerCase()] || '').filter(Boolean);
|
|
816
|
+
const finalKey = keys[keys.length - 1] || '';
|
|
817
|
+
if (modifiers.length > 0 && finalKey) {
|
|
818
|
+
execSync(`osascript -e 'tell application "System Events" to keystroke "${finalKey}" using {${modifiers.join(', ')}}'`, { timeout: 3000 });
|
|
819
|
+
}
|
|
820
|
+
else if (finalKey) {
|
|
821
|
+
execSync(`osascript -e 'tell application "System Events" to keystroke "${finalKey}"'`, { timeout: 3000 });
|
|
822
|
+
}
|
|
823
|
+
break;
|
|
824
|
+
}
|
|
825
|
+
case 'type_text': {
|
|
826
|
+
const text = String(d.text || '');
|
|
827
|
+
// Type text character by character via AppleScript
|
|
828
|
+
execSync(`osascript -e 'tell application "System Events" to keystroke "${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"'`, { timeout: 10000 });
|
|
829
|
+
break;
|
|
830
|
+
}
|
|
831
|
+
case 'scroll': {
|
|
832
|
+
const x = Number(d.x), y = Number(d.y), deltaY = Number(d.deltaY);
|
|
833
|
+
execSync(`osascript -e '
|
|
834
|
+
do shell script "python3 -c \\"
|
|
835
|
+
import Quartz
|
|
836
|
+
evt = Quartz.CGEventCreateScrollWheelEvent(None, Quartz.kCGScrollEventUnitLine, 1, ${deltaY > 0 ? -3 : 3})
|
|
837
|
+
Quartz.CGEventPost(Quartz.kCGHIDEventTap, evt)
|
|
838
|
+
\\"'`, { timeout: 3000 });
|
|
839
|
+
break;
|
|
840
|
+
}
|
|
841
|
+
case 'drag': {
|
|
842
|
+
const fromX = Number(d.fromX), fromY = Number(d.fromY);
|
|
843
|
+
const toX = Number(d.toX), toY = Number(d.toY);
|
|
844
|
+
execSync(`osascript -e '
|
|
845
|
+
do shell script "python3 -c \\"
|
|
846
|
+
import Quartz, time
|
|
847
|
+
evt = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventLeftMouseDown, (${fromX}, ${fromY}), Quartz.kCGMouseButtonLeft)
|
|
848
|
+
Quartz.CGEventPost(Quartz.kCGHIDEventTap, evt)
|
|
849
|
+
time.sleep(0.1)
|
|
850
|
+
steps = 10
|
|
851
|
+
for i in range(1, steps+1):
|
|
852
|
+
x = ${fromX} + (${toX}-${fromX})*i/steps
|
|
853
|
+
y = ${fromY} + (${toY}-${fromY})*i/steps
|
|
854
|
+
evt = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventLeftMouseDragged, (x, y), Quartz.kCGMouseButtonLeft)
|
|
855
|
+
Quartz.CGEventPost(Quartz.kCGHIDEventTap, evt)
|
|
856
|
+
time.sleep(0.02)
|
|
857
|
+
evt = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventLeftMouseUp, (${toX}, ${toY}), Quartz.kCGMouseButtonLeft)
|
|
858
|
+
Quartz.CGEventPost(Quartz.kCGHIDEventTap, evt)
|
|
859
|
+
\\"'`, { timeout: 10000 });
|
|
860
|
+
break;
|
|
861
|
+
}
|
|
862
|
+
case 'enable_screen_capture': {
|
|
863
|
+
if (!screenCaptureActive) {
|
|
864
|
+
const hasPermission = await checkScreenPermission();
|
|
865
|
+
if (hasPermission) {
|
|
866
|
+
screenCaptureActive = true;
|
|
867
|
+
screenCaptureInterval = setInterval(() => screenCaptureLoop().catch(() => { }), SCREEN_CAPTURE_INTERVAL_MS);
|
|
868
|
+
logger.success('📸 Screen capture ATIVADO pelo dashboard');
|
|
869
|
+
}
|
|
870
|
+
else {
|
|
871
|
+
logger.warn('📸 Permissão de gravação de tela não concedida. Conceda em: Ajustes do Sistema → Privacidade → Gravação de Tela → Elon Tools');
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
break;
|
|
875
|
+
}
|
|
876
|
+
case 'disable_screen_capture': {
|
|
877
|
+
if (screenCaptureActive && screenCaptureInterval) {
|
|
878
|
+
clearInterval(screenCaptureInterval);
|
|
879
|
+
screenCaptureInterval = null;
|
|
880
|
+
screenCaptureActive = false;
|
|
881
|
+
logger.info('📸 Screen capture DESATIVADO pelo dashboard');
|
|
882
|
+
}
|
|
883
|
+
break;
|
|
884
|
+
}
|
|
885
|
+
default:
|
|
886
|
+
logger.warn(`🖱️ Unknown UI command type: ${cmd.type}`);
|
|
887
|
+
}
|
|
888
|
+
logger.info(`🖱️ UI command executed: ${cmd.type} [${cmd.id}]`);
|
|
889
|
+
}
|
|
890
|
+
catch (err) {
|
|
891
|
+
logger.error(`🖱️ UI command failed: ${cmd.type} [${cmd.id}]`, err.message);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* Poll and execute UI commands from backend
|
|
896
|
+
*/
|
|
897
|
+
async function pollUICommands() {
|
|
898
|
+
if (!config.runnerId || !config.runnerToken)
|
|
899
|
+
return;
|
|
900
|
+
try {
|
|
901
|
+
const res = await fetch(`${config.serverUrl}/api/v1/runner-v3-ui/${config.runnerId}/ui/pull`, {
|
|
902
|
+
headers: { 'Authorization': `Bearer ${config.runnerToken}` },
|
|
903
|
+
});
|
|
904
|
+
if (!res.ok)
|
|
905
|
+
return;
|
|
906
|
+
const data = (await res.json());
|
|
907
|
+
const cmds = data?.commands || [];
|
|
908
|
+
for (const cmd of cmds) {
|
|
909
|
+
await executeUICommand(cmd);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
catch { }
|
|
913
|
+
}
|
|
914
|
+
// ============================================================================
|
|
915
|
+
// CHAT SYNC — Read OpenClaw JSONL + push to backend
|
|
916
|
+
// ============================================================================
|
|
917
|
+
const OPENCLAW_DATA_DIR = path.join(os.homedir(), '.openclaw');
|
|
918
|
+
const CHAT_SYNC_INTERVAL_MS = 3000;
|
|
919
|
+
let lastSyncedTimestamp = {}; // per session_key
|
|
920
|
+
let chatSyncInitialDone = false;
|
|
921
|
+
/**
|
|
922
|
+
* Find OpenClaw session JSONL files
|
|
923
|
+
*/
|
|
924
|
+
function findOpenClawSessions() {
|
|
925
|
+
const sessions = [];
|
|
926
|
+
// Standard OpenClaw paths:
|
|
927
|
+
// ~/.openclaw/agents/<agentId>/sessions/<sessionKey>/transcript.jsonl
|
|
928
|
+
// ~/.openclaw/agents/<agentId>/agent/config.yaml (for agent name)
|
|
929
|
+
const agentsDir = path.join(OPENCLAW_DATA_DIR, 'agents');
|
|
930
|
+
if (!fs.existsSync(agentsDir))
|
|
931
|
+
return sessions;
|
|
932
|
+
try {
|
|
933
|
+
const agentIds = fs.readdirSync(agentsDir).filter(d => {
|
|
934
|
+
try {
|
|
935
|
+
return fs.statSync(path.join(agentsDir, d)).isDirectory();
|
|
936
|
+
}
|
|
937
|
+
catch {
|
|
938
|
+
return false;
|
|
939
|
+
}
|
|
940
|
+
});
|
|
941
|
+
for (const agentId of agentIds) {
|
|
942
|
+
// Try to read agent name from config
|
|
943
|
+
let agentName = agentId;
|
|
944
|
+
try {
|
|
945
|
+
const configPath = path.join(agentsDir, agentId, 'agent', 'config.yaml');
|
|
946
|
+
if (fs.existsSync(configPath)) {
|
|
947
|
+
const configContent = fs.readFileSync(configPath, 'utf-8');
|
|
948
|
+
const nameMatch = configContent.match(/^name:\s*["']?([^"'\n]+)/m);
|
|
949
|
+
if (nameMatch)
|
|
950
|
+
agentName = nameMatch[1].trim();
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
catch { }
|
|
954
|
+
const sessionsDir = path.join(agentsDir, agentId, 'sessions');
|
|
955
|
+
if (!fs.existsSync(sessionsDir))
|
|
956
|
+
continue;
|
|
957
|
+
try {
|
|
958
|
+
const sessionDirs = fs.readdirSync(sessionsDir).filter(d => {
|
|
959
|
+
try {
|
|
960
|
+
return fs.statSync(path.join(sessionsDir, d)).isDirectory();
|
|
961
|
+
}
|
|
962
|
+
catch {
|
|
963
|
+
return false;
|
|
964
|
+
}
|
|
965
|
+
});
|
|
966
|
+
for (const sessionDir of sessionDirs) {
|
|
967
|
+
// Look for transcript.jsonl or messages.jsonl
|
|
968
|
+
const transcriptPath = path.join(sessionsDir, sessionDir, 'transcript.jsonl');
|
|
969
|
+
const messagesPath = path.join(sessionsDir, sessionDir, 'messages.jsonl');
|
|
970
|
+
const filePath = fs.existsSync(transcriptPath) ? transcriptPath : fs.existsSync(messagesPath) ? messagesPath : null;
|
|
971
|
+
if (filePath) {
|
|
972
|
+
const sessionKey = `agent:${agentId}:${sessionDir}`;
|
|
973
|
+
sessions.push({ sessionKey, filePath, agentName });
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
catch { }
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
catch { }
|
|
981
|
+
return sessions;
|
|
982
|
+
}
|
|
983
|
+
/**
|
|
984
|
+
* Parse JSONL file and extract messages
|
|
985
|
+
*/
|
|
986
|
+
function parseJSONLMessages(filePath, afterTimestamp) {
|
|
987
|
+
const messages = [];
|
|
988
|
+
try {
|
|
989
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
990
|
+
const lines = content.split('\n').filter(l => l.trim());
|
|
991
|
+
for (const line of lines) {
|
|
992
|
+
try {
|
|
993
|
+
const entry = JSON.parse(line);
|
|
994
|
+
// OpenClaw JSONL format: { role, content, timestamp, ... }
|
|
995
|
+
// Or: { type: "message", role, content, ... }
|
|
996
|
+
const role = entry.role || (entry.type === 'assistant' ? 'assistant' : entry.type === 'user' ? 'user' : null);
|
|
997
|
+
const content = entry.content || entry.text || entry.message || '';
|
|
998
|
+
const timestamp = entry.timestamp || entry.ts || entry.created_at || new Date().toISOString();
|
|
999
|
+
if (!role || !content)
|
|
1000
|
+
continue;
|
|
1001
|
+
// Skip system/tool messages for chat display
|
|
1002
|
+
if (role === 'system' || role === 'tool' || role === 'tool_call')
|
|
1003
|
+
continue;
|
|
1004
|
+
// Filter by timestamp
|
|
1005
|
+
if (afterTimestamp && timestamp <= afterTimestamp)
|
|
1006
|
+
continue;
|
|
1007
|
+
messages.push({
|
|
1008
|
+
id: entry.id || `cm_${crypto.createHash('md5').update(line).digest('hex').slice(0, 16)}`,
|
|
1009
|
+
role,
|
|
1010
|
+
content: typeof content === 'string' ? content : JSON.stringify(content),
|
|
1011
|
+
timestamp,
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
catch { }
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
catch { }
|
|
1018
|
+
return messages;
|
|
1019
|
+
}
|
|
1020
|
+
/**
|
|
1021
|
+
* Sync chat messages from OpenClaw to backend
|
|
1022
|
+
*/
|
|
1023
|
+
async function syncChatMessages() {
|
|
1024
|
+
if (!config.runnerId || !config.runnerToken)
|
|
1025
|
+
return;
|
|
1026
|
+
const sessions = findOpenClawSessions();
|
|
1027
|
+
if (sessions.length === 0)
|
|
1028
|
+
return;
|
|
1029
|
+
for (const session of sessions) {
|
|
1030
|
+
try {
|
|
1031
|
+
const isFullSync = !chatSyncInitialDone;
|
|
1032
|
+
const afterTs = lastSyncedTimestamp[session.sessionKey];
|
|
1033
|
+
const messages = parseJSONLMessages(session.filePath, isFullSync ? undefined : afterTs);
|
|
1034
|
+
if (messages.length === 0)
|
|
1035
|
+
continue;
|
|
1036
|
+
const response = await fetch(`${config.serverUrl}/api/v1/runner-v3/poll/${config.runnerId}/chat-sync`, {
|
|
1037
|
+
method: 'POST',
|
|
1038
|
+
headers: {
|
|
1039
|
+
'Content-Type': 'application/json',
|
|
1040
|
+
'Authorization': `Bearer ${config.runnerToken}`,
|
|
1041
|
+
},
|
|
1042
|
+
body: JSON.stringify({
|
|
1043
|
+
sessionKey: session.sessionKey,
|
|
1044
|
+
sessionId: session.sessionKey,
|
|
1045
|
+
agentName: session.agentName,
|
|
1046
|
+
messages,
|
|
1047
|
+
isFullSync,
|
|
1048
|
+
}),
|
|
1049
|
+
});
|
|
1050
|
+
if (response.ok) {
|
|
1051
|
+
// Update last synced timestamp
|
|
1052
|
+
const lastMsg = messages[messages.length - 1];
|
|
1053
|
+
if (lastMsg) {
|
|
1054
|
+
lastSyncedTimestamp[session.sessionKey] = lastMsg.timestamp;
|
|
1055
|
+
}
|
|
1056
|
+
if (messages.length > 0) {
|
|
1057
|
+
logger.info(`💬 Chat synced: ${session.sessionKey} (${messages.length} msgs, agent: ${session.agentName})`);
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
else {
|
|
1061
|
+
logger.warn(`Chat sync failed for ${session.sessionKey}: ${response.status}`);
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
catch (err) {
|
|
1065
|
+
logger.warn('Chat sync error', err instanceof Error ? err.message : err);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
chatSyncInitialDone = true;
|
|
1069
|
+
}
|
|
1070
|
+
/**
|
|
1071
|
+
* Poll pending chat messages from user → inject into OpenClaw
|
|
1072
|
+
*/
|
|
1073
|
+
async function pollChatPending() {
|
|
1074
|
+
if (!config.runnerId || !config.runnerToken)
|
|
1075
|
+
return;
|
|
1076
|
+
try {
|
|
1077
|
+
const response = await fetch(`${config.serverUrl}/api/v1/runner-v3/poll/${config.runnerId}/chat-pending`, {
|
|
1078
|
+
headers: { 'Authorization': `Bearer ${config.runnerToken}` },
|
|
1079
|
+
});
|
|
1080
|
+
if (!response.ok)
|
|
1081
|
+
return;
|
|
1082
|
+
const json = (await response.json());
|
|
1083
|
+
const messages = json?.data?.messages || [];
|
|
1084
|
+
for (const msg of messages) {
|
|
1085
|
+
try {
|
|
1086
|
+
// Inject message into OpenClaw via CLI
|
|
1087
|
+
// openclaw chat send --session <sessionKey> --message <content>
|
|
1088
|
+
const sessionKey = msg.session_key || 'agent:main:main';
|
|
1089
|
+
const content = msg.content || '';
|
|
1090
|
+
if (!content)
|
|
1091
|
+
continue;
|
|
1092
|
+
// Use openclaw CLI to inject the message
|
|
1093
|
+
const openclawBin = findOpenClawBinary();
|
|
1094
|
+
if (openclawBin) {
|
|
1095
|
+
const proc = spawn(openclawBin, ['chat', 'send', '--session', sessionKey, '--message', content], {
|
|
1096
|
+
env: getShellEnv(),
|
|
1097
|
+
timeout: 30000,
|
|
1098
|
+
});
|
|
1099
|
+
await new Promise((resolve) => {
|
|
1100
|
+
proc.on('close', () => resolve());
|
|
1101
|
+
proc.on('error', () => resolve());
|
|
1102
|
+
});
|
|
1103
|
+
}
|
|
1104
|
+
// Ack the message
|
|
1105
|
+
await fetch(`${config.serverUrl}/api/v1/runner-v3/poll/${config.runnerId}/chat-ack`, {
|
|
1106
|
+
method: 'POST',
|
|
1107
|
+
headers: {
|
|
1108
|
+
'Content-Type': 'application/json',
|
|
1109
|
+
'Authorization': `Bearer ${config.runnerToken}`,
|
|
1110
|
+
},
|
|
1111
|
+
body: JSON.stringify({ message_id: msg.id }),
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
catch { }
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
catch { }
|
|
1118
|
+
}
|
|
1119
|
+
/**
|
|
1120
|
+
* Find openclaw binary in common paths
|
|
1121
|
+
*/
|
|
1122
|
+
function findOpenClawBinary() {
|
|
1123
|
+
const candidates = [
|
|
1124
|
+
'/opt/homebrew/bin/openclaw',
|
|
1125
|
+
'/usr/local/bin/openclaw',
|
|
1126
|
+
path.join(os.homedir(), '.nvm/versions/node', 'openclaw'),
|
|
1127
|
+
path.join(os.homedir(), '.volta/bin/openclaw'),
|
|
1128
|
+
];
|
|
1129
|
+
// Also check PATH
|
|
1130
|
+
const pathDirs = (process.env.PATH || '').split(':');
|
|
1131
|
+
for (const dir of pathDirs) {
|
|
1132
|
+
candidates.push(path.join(dir, 'openclaw'));
|
|
1133
|
+
}
|
|
1134
|
+
for (const candidate of candidates) {
|
|
1135
|
+
try {
|
|
1136
|
+
if (fs.existsSync(candidate))
|
|
1137
|
+
return candidate;
|
|
1138
|
+
}
|
|
1139
|
+
catch { }
|
|
1140
|
+
}
|
|
1141
|
+
return null;
|
|
1142
|
+
}
|
|
1143
|
+
// ============================================================================
|
|
1144
|
+
// MAIN LOOP
|
|
1145
|
+
// ============================================================================
|
|
1146
|
+
async function main() {
|
|
1147
|
+
try {
|
|
1148
|
+
parseArgs();
|
|
1149
|
+
// 1. Register if needed
|
|
1150
|
+
if (config.pairingToken && !config.runnerId) {
|
|
1151
|
+
const registered = await registerRunner();
|
|
1152
|
+
if (!registered) {
|
|
1153
|
+
logger.error('Falha ao registrar runner, encerrando');
|
|
1154
|
+
process.exit(1);
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
// 1.5. Probe file permissions (macOS protected folders — triggers dialogs ONCE)
|
|
1158
|
+
if (process.platform === 'darwin') {
|
|
1159
|
+
probeFilePermissions();
|
|
1160
|
+
}
|
|
1161
|
+
// 2. Start heartbeat loop (every 30s)
|
|
1162
|
+
setInterval(() => {
|
|
1163
|
+
sendHeartbeat().catch(() => { }); // Silent fail
|
|
1164
|
+
}, 30000);
|
|
1165
|
+
// Send first heartbeat immediately
|
|
1166
|
+
await sendHeartbeat();
|
|
1167
|
+
// 3. Start job polling loop (every 5s)
|
|
1168
|
+
setInterval(async () => {
|
|
1169
|
+
try {
|
|
1170
|
+
const jobs = await pullJobs();
|
|
1171
|
+
for (const job of jobs) {
|
|
1172
|
+
const result = await executeJob(job);
|
|
1173
|
+
await reportJobResult(job.id, result);
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
catch (error) {
|
|
1177
|
+
logger.error('Erro no job loop', error);
|
|
1178
|
+
}
|
|
1179
|
+
}, 5000);
|
|
1180
|
+
// Poll jobs immediately
|
|
1181
|
+
const initialJobs = await pullJobs();
|
|
1182
|
+
for (const job of initialJobs) {
|
|
1183
|
+
const result = await executeJob(job);
|
|
1184
|
+
await reportJobResult(job.id, result);
|
|
1185
|
+
}
|
|
1186
|
+
// 4. Start file sync (every 60s)
|
|
1187
|
+
setTimeout(() => syncFiles().catch(() => { }), 5000); // first sync after 5s
|
|
1188
|
+
setInterval(() => syncFiles().catch(() => { }), SYNC_INTERVAL_MS);
|
|
1189
|
+
// 5. Terminal command polling (every 2s)
|
|
1190
|
+
logger.info('🖥️ Iniciando terminal polling (2s interval)...');
|
|
1191
|
+
setInterval(() => {
|
|
1192
|
+
pollTerminalCommands().catch((err) => {
|
|
1193
|
+
logger.error('Terminal poll error', err?.message || err);
|
|
1194
|
+
});
|
|
1195
|
+
}, 2000);
|
|
1196
|
+
// First poll immediately
|
|
1197
|
+
pollTerminalCommands().catch((err) => {
|
|
1198
|
+
logger.error('Terminal first poll error', err?.message || err);
|
|
1199
|
+
});
|
|
1200
|
+
// 6. UI commands polling (UI mode only, macOS) — screen capture is OFF by default
|
|
1201
|
+
// Screen capture only starts when user explicitly enables it via dashboard
|
|
1202
|
+
if (config.mode === 'ui' && process.platform === 'darwin') {
|
|
1203
|
+
logger.info('🖱️ Iniciando UI command polling (500ms interval)...');
|
|
1204
|
+
logger.info('📸 Screen capture DESATIVADO por padrão — ative pelo dashboard quando necessário');
|
|
1205
|
+
setInterval(() => pollUICommands().catch(() => { }), 500);
|
|
1206
|
+
}
|
|
1207
|
+
// 7. Chat sync — sync OpenClaw JSONL messages + poll pending (every 3s)
|
|
1208
|
+
logger.info('💬 Iniciando chat sync (3s interval)...');
|
|
1209
|
+
setInterval(() => {
|
|
1210
|
+
syncChatMessages().catch(() => { });
|
|
1211
|
+
pollChatPending().catch(() => { });
|
|
1212
|
+
}, 3000);
|
|
1213
|
+
// First sync after 2s
|
|
1214
|
+
setTimeout(() => {
|
|
1215
|
+
syncChatMessages().catch(() => { });
|
|
1216
|
+
pollChatPending().catch(() => { });
|
|
1217
|
+
}, 2000);
|
|
1218
|
+
logger.success('🎯 Runner v3 operacional (com terminal + chat)');
|
|
1219
|
+
// Keep process alive
|
|
1220
|
+
process.on('SIGINT', () => {
|
|
1221
|
+
logger.info('🛑 Encerrando runner...');
|
|
1222
|
+
process.exit(0);
|
|
1223
|
+
});
|
|
1224
|
+
process.on('SIGTERM', () => {
|
|
1225
|
+
logger.info('🛑 Encerrando runner (SIGTERM)...');
|
|
1226
|
+
process.exit(0);
|
|
1227
|
+
});
|
|
1228
|
+
}
|
|
1229
|
+
catch (error) {
|
|
1230
|
+
logger.error('Erro fatal', error);
|
|
1231
|
+
process.exit(1);
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
// ============================================================================
|
|
1235
|
+
// START
|
|
1236
|
+
// ============================================================================
|
|
1237
|
+
main().catch((error) => {
|
|
1238
|
+
logger.error('Erro ao iniciar main', error);
|
|
1239
|
+
process.exit(1);
|
|
1240
|
+
});
|
|
1241
|
+
//# sourceMappingURL=index.js.map
|