@mindexec/cli 0.2.393 → 0.2.394

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mindexec/cli",
3
- "version": "0.2.393",
3
+ "version": "0.2.394",
4
4
  "description": "MindExec local runtime and bridge CLI",
5
5
  "main": "server.js",
6
6
  "type": "module",
package/remote-hub.js CHANGED
@@ -23,6 +23,8 @@ const RECENT_LIVE_FRAME_CACHE_MAX_BYTES = 24 * 1024 * 1024;
23
23
  const REMOTE_PROTOCOL_VERSION = 1;
24
24
  const MAX_SYNTHETIC_DEVICES = 1000;
25
25
  const DEFAULT_HOST_TARGET_LEASE_MS = 30000;
26
+ const DUPLICATE_DEVICE_ACTIVE_REJECT_MS = 15000;
27
+ const DUPLICATE_DEVICE_LOG_THROTTLE_MS = 5000;
26
28
  const SYNTHETIC_FRAME_DATA_URL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAYAAAD0In+KAAAADElEQVR42mP8z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC';
27
29
  const SYNTHETIC_FRAME_PAYLOAD = Buffer.from(SYNTHETIC_FRAME_DATA_URL.split(',')[1], 'base64');
28
30
  const SYNTHETIC_FRAME_HASH = crypto.createHash('sha256').update(SYNTHETIC_FRAME_PAYLOAD).digest('hex').slice(0, 16);
@@ -882,6 +884,7 @@ export function createRemoteHub(options = {}) {
882
884
  const taskBatches = new Map();
883
885
  const sockets = new Map();
884
886
  const allSockets = new Set();
887
+ const duplicateDeviceLogAt = new Map();
885
888
  let server = null;
886
889
  let started = false;
887
890
  let boundPort = requestedPort;
@@ -1281,6 +1284,63 @@ export function createRemoteHub(options = {}) {
1281
1284
  existing.socket.destroy();
1282
1285
  }
1283
1286
 
1287
+ function getDeviceActivityMs(device) {
1288
+ return Math.max(
1289
+ Date.parse(device?.lastSeenAt || '') || 0,
1290
+ Date.parse(device?.lastStatusAt || '') || 0,
1291
+ Date.parse(device?.connectedAt || '') || 0
1292
+ );
1293
+ }
1294
+
1295
+ function isDeviceConnectionFresh(device, nowMs = Date.now()) {
1296
+ if (!device?.connected || !device.socket || device.socket.destroyed) {
1297
+ return false;
1298
+ }
1299
+
1300
+ const lastActivityMs = getDeviceActivityMs(device);
1301
+ if (!lastActivityMs) {
1302
+ return true;
1303
+ }
1304
+
1305
+ const freshnessMs = Math.max(DUPLICATE_DEVICE_ACTIVE_REJECT_MS, heartbeatMs * 3);
1306
+ return nowMs - lastActivityMs <= freshnessMs;
1307
+ }
1308
+
1309
+ function rejectDuplicateActiveDevice(socket, existing, attemptedSessionId) {
1310
+ const retryAfterMs = Math.max(DUPLICATE_DEVICE_LOG_THROTTLE_MS, Math.min(30000, heartbeatMs));
1311
+ writeJsonLine(socket, {
1312
+ type: 'disconnect',
1313
+ reason: 'duplicate-device-active',
1314
+ retryAfterMs,
1315
+ activeSessionId: existing.sessionId,
1316
+ attemptedSessionId
1317
+ });
1318
+
1319
+ existing.counters.duplicateConnectionsRejected = (existing.counters.duplicateConnectionsRejected || 0) + 1;
1320
+ const nowMs = Date.now();
1321
+ const lastLoggedAt = duplicateDeviceLogAt.get(existing.deviceId) || 0;
1322
+ if (nowMs - lastLoggedAt >= DUPLICATE_DEVICE_LOG_THROTTLE_MS) {
1323
+ duplicateDeviceLogAt.set(existing.deviceId, nowMs);
1324
+ logWarn(
1325
+ 'remote',
1326
+ `duplicate device connection suppressed ${existing.deviceName} (${existing.deviceId}); keeping active session ${existing.sessionId}`);
1327
+ }
1328
+
1329
+ try {
1330
+ socket.end?.();
1331
+ } catch {
1332
+ // Best-effort graceful close before hard destroy.
1333
+ }
1334
+
1335
+ setTimeout(() => {
1336
+ try {
1337
+ socket.destroy?.();
1338
+ } catch {
1339
+ // Ignore destroy failures.
1340
+ }
1341
+ }, 25).unref?.();
1342
+ }
1343
+
1284
1344
  function clearSyntheticFleet() {
1285
1345
  let removed = 0;
1286
1346
  for (const [deviceId, device] of devices.entries()) {
@@ -1470,9 +1530,16 @@ export function createRemoteHub(options = {}) {
1470
1530
  }
1471
1531
 
1472
1532
  function attachDevice(socket, hello) {
1473
- const now = new Date().toISOString();
1533
+ const nowMs = Date.now();
1534
+ const now = new Date(nowMs).toISOString();
1474
1535
  const sessionId = crypto.randomUUID();
1475
1536
  const deviceId = normalizeDeviceId(hello.deviceId);
1537
+ const existing = devices.get(deviceId);
1538
+
1539
+ if (isDeviceConnectionFresh(existing, nowMs)) {
1540
+ rejectDuplicateActiveDevice(socket, existing, sessionId);
1541
+ return null;
1542
+ }
1476
1543
 
1477
1544
  closeExistingDeviceSocket(deviceId, sessionId);
1478
1545
 
@@ -2116,8 +2183,13 @@ export function createRemoteHub(options = {}) {
2116
2183
  return;
2117
2184
  }
2118
2185
 
2186
+ const device = attachDevice(socket, message);
2187
+ if (!device) {
2188
+ return;
2189
+ }
2190
+
2119
2191
  state.authenticated = true;
2120
- state.device = attachDevice(socket, message);
2192
+ state.device = device;
2121
2193
  return;
2122
2194
  }
2123
2195
 
@@ -85,6 +85,9 @@ function spawnBridge({ bridgePort, remoteHubPort, workspacePath, label, traceFra
85
85
  BRIDGE_REQUIRE_TOKEN: '1',
86
86
  MINDEXEC_REMOTE_HUB: '1',
87
87
  MINDEXEC_REMOTE_TRACE_FRAMES: traceFrames ? '1' : '',
88
+ MINDEXEC_REMOTE_REGISTRY_FOLLOWER: '0',
89
+ MINDEXEC_REMOTE_REGISTRY_REALTIME: '0',
90
+ MINDEXEC_REMOTE_HOST_TARGET_AUTO_RENEW: '0',
88
91
  REMOTE_HUB_HOST: '127.0.0.1',
89
92
  REMOTE_HUB_PORT: String(remoteHubPort),
90
93
  REMOTE_HUB_PAIR_TOKEN: PAIR_TOKEN,
@@ -515,6 +515,78 @@ try {
515
515
  });
516
516
  assert.equal(staleSessionTask.ok, true);
517
517
  const oldSessionId = lateTimedOutDevice.sessionId;
518
+
519
+ const duplicateSocket = net.createConnection({ host: '127.0.0.1', port: status.port });
520
+ duplicateSocket.setEncoding('utf8');
521
+ const duplicateMessages = [];
522
+ let duplicateBuffer = '';
523
+ await new Promise((resolve, reject) => {
524
+ duplicateSocket.once('connect', resolve);
525
+ duplicateSocket.once('error', reject);
526
+ });
527
+ duplicateSocket.on('error', () => { });
528
+ duplicateSocket.on('data', chunk => {
529
+ duplicateBuffer += chunk;
530
+ let lineBreakIndex = duplicateBuffer.indexOf('\n');
531
+ while (lineBreakIndex >= 0) {
532
+ const line = duplicateBuffer.slice(0, lineBreakIndex).trim();
533
+ duplicateBuffer = duplicateBuffer.slice(lineBreakIndex + 1);
534
+ if (line) {
535
+ duplicateMessages.push(JSON.parse(line));
536
+ }
537
+ lineBreakIndex = duplicateBuffer.indexOf('\n');
538
+ }
539
+ });
540
+ writeJsonLine(duplicateSocket, {
541
+ type: 'hello',
542
+ pairToken: 'smoke-token',
543
+ deviceId: 'smoke-device',
544
+ deviceName: 'Smoke Device Duplicate',
545
+ hostname: 'smoke-host-duplicate',
546
+ platform: process.platform,
547
+ arch: process.arch,
548
+ pid: process.pid,
549
+ agentVersion: '0.0.0-smoke-duplicate',
550
+ capabilities: {
551
+ status: true,
552
+ thumbnail: false,
553
+ control: false,
554
+ liveStream: true,
555
+ computerAgent: true,
556
+ taskDispatch: true,
557
+ aiAssist: false
558
+ }
559
+ });
560
+
561
+ const duplicateDisconnect = await waitFor(() => (
562
+ duplicateMessages.find(message => message?.type === 'disconnect')
563
+ ), 1000);
564
+ assert.equal(duplicateDisconnect.reason, 'duplicate-device-active');
565
+ assert.equal(duplicateDisconnect.activeSessionId, oldSessionId);
566
+ assert.equal(duplicateDisconnect.retryAfterMs >= 1000, true);
567
+ await wait(100);
568
+ const duplicateRejectedDevice = hub.listDevices()[0];
569
+ assert.equal(duplicateRejectedDevice.sessionId, oldSessionId);
570
+ assert.equal(duplicateRejectedDevice.deviceName, 'Smoke Device');
571
+ assert.equal(duplicateRejectedDevice.connected, true);
572
+ assert.equal(duplicateRejectedDevice.counters.duplicateConnectionsRejected, 1);
573
+ assert.equal(duplicateRejectedDevice.latestTask.taskId, staleSessionTask.taskId);
574
+ duplicateSocket.destroy();
575
+
576
+ socket.destroy();
577
+ const disconnectedDevice = await waitFor(() => {
578
+ const current = hub.listDevices();
579
+ return current.length === 1
580
+ && current[0]?.sessionId === oldSessionId
581
+ && current[0]?.connected === false
582
+ ? current[0]
583
+ : null;
584
+ });
585
+ assert.equal(disconnectedDevice.latestTask.status, 'failed');
586
+ assert.equal(
587
+ ['device-disconnected', 'task-timeout'].includes(disconnectedDevice.latestTask.error),
588
+ true);
589
+
518
590
  const replacementSocket = net.createConnection({ host: '127.0.0.1', port: status.port });
519
591
  replacementSocket.setEncoding('utf8');
520
592
  await new Promise((resolve, reject) => {
@@ -565,25 +637,6 @@ try {
565
637
  assert.equal(reconnectedDevice.latestTask, null);
566
638
  assert.equal(reconnectedDevice.counters.taskResultsReceived, 0);
567
639
 
568
- writeJsonLine(socket, {
569
- type: 'status',
570
- status: {
571
- uptimeSec: 999,
572
- totalMem: 999,
573
- freeMem: 0
574
- }
575
- });
576
- writeJsonLine(socket, {
577
- type: 'command.result',
578
- commandId: staleSessionTask.commandId,
579
- result: {
580
- kind: 'agent.task',
581
- taskId: staleSessionTask.taskId,
582
- status: 'completed',
583
- summary: 'This old-session result must not mutate the reconnected device.',
584
- completedAt: new Date().toISOString()
585
- }
586
- });
587
640
  await wait(100);
588
641
  const staleSessionIgnoredDevice = hub.listDevices()[0];
589
642
  assert.equal(staleSessionIgnoredDevice.sessionId, reconnectedDevice.sessionId);
@@ -592,7 +645,6 @@ try {
592
645
  assert.equal(staleSessionIgnoredDevice.latestTask, null);
593
646
  assert.equal(staleSessionIgnoredDevice.counters.taskResultsReceived, 0);
594
647
 
595
- socket.destroy();
596
648
  replacementSocket.destroy();
597
649
  await waitFor(() => hub.listDevices()[0]?.connected === false);
598
650
  console.log('RemoteHub smoke OK');