@mindexec/cli 0.2.116 → 0.2.117

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.116",
3
+ "version": "0.2.117",
4
4
  "description": "MindExec local runtime and bridge CLI",
5
5
  "main": "server.js",
6
6
  "type": "module",
@@ -20,7 +20,7 @@
20
20
  "scripts": {
21
21
  "start": "node launch-bridge.cjs",
22
22
  "dev": "node launch-bridge.cjs --watch",
23
- "test:syntax": "node --check server.js && node --check remote-hub.js && node --check codex-runtime.js && node --check launch-bridge.cjs && node --check port-guard.cjs && node --check scripts/setup-tree-sitter-grammars.mjs && node --check scripts/auth-session-smoke.mjs && node --check scripts/remote-hub-smoke.mjs && node --check scripts/remote-hub-scale-smoke.mjs && node --check scripts/remote-fleet-render-smoke.mjs && node --check scripts/remote-http-smoke.mjs && node --check scripts/remote-frame-ws-smoke.mjs",
23
+ "test:syntax": "node --check server.js && node --check remote-hub.js && node --check codex-runtime.js && node --check launch-bridge.cjs && node --check port-guard.cjs && node --check scripts/setup-tree-sitter-grammars.mjs && node --check scripts/auth-session-smoke.mjs && node --check scripts/remote-hub-smoke.mjs && node --check scripts/remote-hub-scale-smoke.mjs && node --check scripts/remote-fleet-render-smoke.mjs && node --check scripts/remote-http-smoke.mjs && node --check scripts/remote-frame-ws-smoke.mjs && node --check scripts/remote-agent-ws-smoke.mjs",
24
24
  "test:auth": "node scripts/auth-session-smoke.mjs",
25
25
  "test:remote": "node scripts/remote-hub-smoke.mjs",
26
26
  "test:remote:scale": "node scripts/remote-hub-scale-smoke.mjs",
@@ -47,7 +47,7 @@
47
47
  "node": ">=20"
48
48
  },
49
49
  "dependencies": {
50
- "@mindexec/remote": "^0.1.16",
50
+ "@mindexec/remote": "^0.1.17",
51
51
  "@openai/codex-sdk": "^0.137.0",
52
52
  "chokidar": "^3.6.0",
53
53
  "cors": "^2.8.5",
package/remote-hub.js CHANGED
@@ -592,10 +592,266 @@ function writeJsonLine(socket, payload) {
592
592
  return false;
593
593
  }
594
594
 
595
+ if (socket.__remoteHubWebSocket === true && typeof socket.sendJson === 'function') {
596
+ return socket.sendJson(payload);
597
+ }
598
+
595
599
  socket.write(`${JSON.stringify(payload)}\n`);
596
600
  return true;
597
601
  }
598
602
 
603
+ function isRemoteHubWebSocketUpgradeStart(buffer) {
604
+ if (!Buffer.isBuffer(buffer) || buffer.length < 4) {
605
+ return false;
606
+ }
607
+
608
+ return buffer.subarray(0, Math.min(buffer.length, 16)).toString('latin1').startsWith('GET ');
609
+ }
610
+
611
+ function parseRemoteHubWebSocketUpgrade(buffer) {
612
+ const headerEnd = buffer.indexOf('\r\n\r\n');
613
+ if (headerEnd < 0) {
614
+ return null;
615
+ }
616
+
617
+ const headerText = buffer.subarray(0, headerEnd).toString('latin1');
618
+ const lines = headerText.split('\r\n');
619
+ const requestLine = lines.shift() || '';
620
+ const [method, path] = requestLine.split(/\s+/);
621
+ const headers = {};
622
+ for (const line of lines) {
623
+ const separator = line.indexOf(':');
624
+ if (separator <= 0) {
625
+ continue;
626
+ }
627
+
628
+ headers[line.slice(0, separator).trim().toLowerCase()] = line.slice(separator + 1).trim();
629
+ }
630
+
631
+ return {
632
+ method,
633
+ path,
634
+ headers,
635
+ head: buffer.subarray(headerEnd + 4)
636
+ };
637
+ }
638
+
639
+ function buildRemoteHubWebSocketAcceptKey(key) {
640
+ return crypto
641
+ .createHash('sha1')
642
+ .update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
643
+ .digest('base64');
644
+ }
645
+
646
+ function buildRemoteHubWebSocketFrame(opcode, payload) {
647
+ const body = Buffer.isBuffer(payload) ? payload : Buffer.from(String(payload || ''), 'utf8');
648
+ let header = null;
649
+ if (body.length < 126) {
650
+ header = Buffer.allocUnsafe(2);
651
+ header[0] = 0x80 | opcode;
652
+ header[1] = body.length;
653
+ } else if (body.length <= 0xffff) {
654
+ header = Buffer.allocUnsafe(4);
655
+ header[0] = 0x80 | opcode;
656
+ header[1] = 126;
657
+ header.writeUInt16BE(body.length, 2);
658
+ } else {
659
+ header = Buffer.allocUnsafe(10);
660
+ header[0] = 0x80 | opcode;
661
+ header[1] = 127;
662
+ header.writeBigUInt64BE(BigInt(body.length), 2);
663
+ }
664
+
665
+ return Buffer.concat([header, body], header.length + body.length);
666
+ }
667
+
668
+ function parseRemoteHubWebSocketBinaryFrame(buffer) {
669
+ if (!Buffer.isBuffer(buffer) || buffer.length < 5) {
670
+ return null;
671
+ }
672
+
673
+ const metaLength = buffer.readUInt32BE(0);
674
+ if (!Number.isFinite(metaLength)
675
+ || metaLength <= 0
676
+ || metaLength > 64 * 1024
677
+ || 4 + metaLength >= buffer.length) {
678
+ return null;
679
+ }
680
+
681
+ const header = JSON.parse(buffer.subarray(4, 4 + metaLength).toString('utf8'));
682
+ const payload = buffer.subarray(4 + metaLength);
683
+ return { header, payload };
684
+ }
685
+
686
+ class RemoteHubWebSocketAgentSocket {
687
+ constructor(socket) {
688
+ this.__remoteHubWebSocket = true;
689
+ this.socket = socket;
690
+ this.remoteAddress = socket.remoteAddress;
691
+ this.remotePort = socket.remotePort;
692
+ this.destroyed = false;
693
+ this.buffer = Buffer.alloc(0);
694
+ this.fragmentOpcode = 0;
695
+ this.fragments = [];
696
+ this.onTextMessage = () => {};
697
+ this.onBinaryMessage = () => {};
698
+ this.onCloseMessage = () => {};
699
+ }
700
+
701
+ setNoDelay() {}
702
+
703
+ setKeepAlive() {}
704
+
705
+ sendJson(payload) {
706
+ return this.sendText(JSON.stringify(payload));
707
+ }
708
+
709
+ write(payload) {
710
+ if (this.destroyed) {
711
+ return false;
712
+ }
713
+
714
+ const text = (Buffer.isBuffer(payload) ? payload.toString('utf8') : String(payload || '')).replace(/\n$/, '');
715
+ return this.sendText(text);
716
+ }
717
+
718
+ sendText(text) {
719
+ return this.sendFrame(0x1, Buffer.from(String(text || ''), 'utf8'));
720
+ }
721
+
722
+ sendFrame(opcode, payload) {
723
+ if (this.destroyed || this.socket.destroyed) {
724
+ return false;
725
+ }
726
+
727
+ try {
728
+ this.socket.write(buildRemoteHubWebSocketFrame(opcode, payload));
729
+ return true;
730
+ } catch {
731
+ this.destroy();
732
+ return false;
733
+ }
734
+ }
735
+
736
+ destroy() {
737
+ if (this.destroyed) {
738
+ return;
739
+ }
740
+
741
+ this.destroyed = true;
742
+ try {
743
+ if (!this.socket.destroyed) {
744
+ this.socket.write(buildRemoteHubWebSocketFrame(0x8, Buffer.alloc(0)));
745
+ }
746
+ } catch {
747
+ // Ignore close-frame failures.
748
+ }
749
+ try {
750
+ this.socket.destroy();
751
+ } catch {
752
+ // Ignore socket destroy failures.
753
+ }
754
+ }
755
+
756
+ handleData(chunk) {
757
+ if (this.destroyed) {
758
+ return;
759
+ }
760
+
761
+ this.buffer = this.buffer.length > 0
762
+ ? Buffer.concat([this.buffer, chunk])
763
+ : chunk;
764
+
765
+ while (!this.destroyed) {
766
+ if (this.buffer.length < 2) {
767
+ return;
768
+ }
769
+
770
+ const first = this.buffer[0];
771
+ const second = this.buffer[1];
772
+ const fin = (first & 0x80) !== 0;
773
+ const opcode = first & 0x0f;
774
+ const masked = (second & 0x80) !== 0;
775
+ let payloadLength = second & 0x7f;
776
+ let offset = 2;
777
+
778
+ if (payloadLength === 126) {
779
+ if (this.buffer.length < offset + 2) return;
780
+ payloadLength = this.buffer.readUInt16BE(offset);
781
+ offset += 2;
782
+ } else if (payloadLength === 127) {
783
+ if (this.buffer.length < offset + 8) return;
784
+ const bigLength = this.buffer.readBigUInt64BE(offset);
785
+ if (bigLength > BigInt(Number.MAX_SAFE_INTEGER)) {
786
+ this.destroy();
787
+ return;
788
+ }
789
+ payloadLength = Number(bigLength);
790
+ offset += 8;
791
+ }
792
+
793
+ if (!masked) {
794
+ this.destroy();
795
+ return;
796
+ }
797
+
798
+ if (this.buffer.length < offset + 4 + payloadLength) {
799
+ return;
800
+ }
801
+
802
+ const mask = this.buffer.subarray(offset, offset + 4);
803
+ offset += 4;
804
+ const payload = Buffer.from(this.buffer.subarray(offset, offset + payloadLength));
805
+ this.buffer = this.buffer.subarray(offset + payloadLength);
806
+ for (let index = 0; index < payload.length; index += 1) {
807
+ payload[index] ^= mask[index & 3];
808
+ }
809
+
810
+ if (opcode === 0x8) {
811
+ this.destroyed = true;
812
+ this.onCloseMessage('websocket-close');
813
+ try {
814
+ this.socket.destroy();
815
+ } catch {
816
+ // Ignore socket destroy failures.
817
+ }
818
+ return;
819
+ }
820
+
821
+ if (opcode === 0x9) {
822
+ this.sendFrame(0xA, payload);
823
+ continue;
824
+ }
825
+
826
+ if (opcode === 0xA) {
827
+ continue;
828
+ }
829
+
830
+ let messageOpcode = opcode;
831
+ let messagePayload = payload;
832
+ if (!fin) {
833
+ this.fragmentOpcode = opcode;
834
+ this.fragments = [payload];
835
+ continue;
836
+ }
837
+
838
+ if (opcode === 0x0) {
839
+ this.fragments.push(payload);
840
+ messageOpcode = this.fragmentOpcode;
841
+ messagePayload = Buffer.concat(this.fragments);
842
+ this.fragmentOpcode = 0;
843
+ this.fragments = [];
844
+ }
845
+
846
+ if (messageOpcode === 0x1) {
847
+ this.onTextMessage(messagePayload.toString('utf8'));
848
+ } else if (messageOpcode === 0x2) {
849
+ this.onBinaryMessage(messagePayload);
850
+ }
851
+ }
852
+ }
853
+ }
854
+
599
855
  export function createRemoteHub(options = {}) {
600
856
  const env = options.env || process.env;
601
857
  const logEvent = options.logEvent || (() => {});
@@ -1921,7 +2177,146 @@ export function createRemoteHub(options = {}) {
1921
2177
  }
1922
2178
  }
1923
2179
 
1924
- function handleSocket(socket) {
2180
+ function handleWebSocketAgentSocket(socket) {
2181
+ allSockets.add(socket);
2182
+ socket.setNoDelay(true);
2183
+ socket.setKeepAlive(true, heartbeatMs);
2184
+
2185
+ const state = {
2186
+ authenticated: false,
2187
+ device: null
2188
+ };
2189
+
2190
+ const helloTimer = setTimeout(() => {
2191
+ if (!state.authenticated) {
2192
+ writeJsonLine(socket, { type: 'error', error: 'hello-timeout' });
2193
+ socket.destroy();
2194
+ }
2195
+ }, 10000);
2196
+
2197
+ socket.onTextMessage = text => {
2198
+ try {
2199
+ handleAgentMessage(socket, state, parseJsonLine(String(text || '')));
2200
+ } catch (err) {
2201
+ writeJsonLine(socket, { type: 'error', error: 'invalid-json' });
2202
+ logWarn('remote', `invalid websocket agent message: ${err?.message || err}`);
2203
+ }
2204
+ };
2205
+
2206
+ socket.onBinaryMessage = payload => {
2207
+ try {
2208
+ const packet = parseRemoteHubWebSocketBinaryFrame(payload);
2209
+ if (!packet?.header || !Buffer.isBuffer(packet.payload)) {
2210
+ writeJsonLine(socket, { type: 'error', error: 'invalid-websocket-binary-frame' });
2211
+ return;
2212
+ }
2213
+
2214
+ const frameKind = safeString(packet.header.frameKind || packet.header.kind || packet.header.frameType, 40).toLowerCase();
2215
+ const maxBytes = frameKind === 'thumbnail'
2216
+ ? MAX_THUMBNAIL_BINARY_BYTES
2217
+ : MAX_STREAM_BINARY_BYTES;
2218
+ const byteLength = Number(packet.header.byteLength ?? packet.header.payloadBytes ?? packet.payload.length);
2219
+ if (!Number.isFinite(byteLength)
2220
+ || byteLength < 1
2221
+ || byteLength > maxBytes
2222
+ || byteLength !== packet.payload.length) {
2223
+ writeJsonLine(socket, { type: 'error', error: 'invalid-binary-frame' });
2224
+ logWarn('remote', 'invalid websocket binary frame from agent.');
2225
+ return;
2226
+ }
2227
+
2228
+ handleAgentBinaryFrame(socket, state, packet.header, packet.payload);
2229
+ } catch (err) {
2230
+ writeJsonLine(socket, { type: 'error', error: 'invalid-websocket-binary-frame' });
2231
+ logWarn('remote', `invalid websocket binary frame: ${err?.message || err}`);
2232
+ }
2233
+ };
2234
+
2235
+ const detach = reason => {
2236
+ allSockets.delete(socket);
2237
+ clearTimeout(helloTimer);
2238
+ detachSocket(socket, reason);
2239
+ };
2240
+
2241
+ socket.onCloseMessage = reason => detach(reason || 'websocket-closed');
2242
+ socket.socket.on('close', () => detach('websocket-closed'));
2243
+ socket.socket.on('error', err => detach(err?.message || 'websocket-error'));
2244
+ }
2245
+
2246
+ function handleWebSocketUpgradeSocket(socket, firstChunk) {
2247
+ let upgradeBuffer = Buffer.isBuffer(firstChunk) ? firstChunk : Buffer.from(firstChunk);
2248
+ let onUpgradeData = null;
2249
+ const fail = (status, message) => {
2250
+ try {
2251
+ socket.write(`HTTP/1.1 ${status} ${message}\r\nConnection: close\r\n\r\n`);
2252
+ } catch {
2253
+ // Ignore write failures while rejecting the upgrade.
2254
+ }
2255
+ socket.destroy();
2256
+ };
2257
+
2258
+ const completeUpgrade = () => {
2259
+ const request = parseRemoteHubWebSocketUpgrade(upgradeBuffer);
2260
+ if (!request) {
2261
+ return false;
2262
+ }
2263
+
2264
+ const upgrade = String(request.headers.upgrade || '').toLowerCase();
2265
+ const connection = String(request.headers.connection || '').toLowerCase();
2266
+ const key = safeString(request.headers['sec-websocket-key'], 256);
2267
+ const pathName = safeString(String(request.path || '').split('?')[0], 128) || '/';
2268
+ const allowedPath = pathName === '/'
2269
+ || pathName === '/remote-agent'
2270
+ || pathName === '/api/remote/agent/ws';
2271
+ if (request.method !== 'GET'
2272
+ || upgrade !== 'websocket'
2273
+ || !connection.includes('upgrade')
2274
+ || !key
2275
+ || !allowedPath) {
2276
+ fail(400, 'Bad Request');
2277
+ return true;
2278
+ }
2279
+
2280
+ const acceptKey = buildRemoteHubWebSocketAcceptKey(key);
2281
+ socket.write([
2282
+ 'HTTP/1.1 101 Switching Protocols',
2283
+ 'Upgrade: websocket',
2284
+ 'Connection: Upgrade',
2285
+ `Sec-WebSocket-Accept: ${acceptKey}`,
2286
+ '\r\n'
2287
+ ].join('\r\n'));
2288
+
2289
+ if (onUpgradeData) {
2290
+ socket.removeListener('data', onUpgradeData);
2291
+ }
2292
+ const wsSocket = new RemoteHubWebSocketAgentSocket(socket);
2293
+ const onFrameData = chunk => wsSocket.handleData(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
2294
+ socket.on('data', onFrameData);
2295
+ socket.once('close', () => {
2296
+ socket.removeListener('data', onFrameData);
2297
+ });
2298
+ handleWebSocketAgentSocket(wsSocket);
2299
+ if (request.head.length > 0) {
2300
+ wsSocket.handleData(request.head);
2301
+ }
2302
+ return true;
2303
+ };
2304
+
2305
+ onUpgradeData = chunk => {
2306
+ upgradeBuffer = Buffer.concat([upgradeBuffer, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]);
2307
+ if (upgradeBuffer.length > 16 * 1024) {
2308
+ fail(431, 'Request Header Fields Too Large');
2309
+ return;
2310
+ }
2311
+ completeUpgrade();
2312
+ };
2313
+
2314
+ if (!completeUpgrade()) {
2315
+ socket.on('data', onUpgradeData);
2316
+ }
2317
+ }
2318
+
2319
+ function handleTcpAgentSocket(socket, firstChunk = null) {
1925
2320
  allSockets.add(socket);
1926
2321
  socket.setNoDelay(true);
1927
2322
  socket.setKeepAlive(true, heartbeatMs);
@@ -1940,7 +2335,7 @@ export function createRemoteHub(options = {}) {
1940
2335
  }
1941
2336
  }, 10000);
1942
2337
 
1943
- socket.on('data', chunk => {
2338
+ const processData = chunk => {
1944
2339
  const incoming = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
1945
2340
  state.buffer = state.buffer.length > 0
1946
2341
  ? Buffer.concat([state.buffer, incoming])
@@ -1999,7 +2394,12 @@ export function createRemoteHub(options = {}) {
1999
2394
  logWarn('remote', `invalid agent message: ${err?.message || err}`);
2000
2395
  }
2001
2396
  }
2002
- });
2397
+ };
2398
+
2399
+ socket.on('data', processData);
2400
+ if (firstChunk) {
2401
+ processData(firstChunk);
2402
+ }
2003
2403
 
2004
2404
  socket.on('close', () => {
2005
2405
  allSockets.delete(socket);
@@ -2013,6 +2413,22 @@ export function createRemoteHub(options = {}) {
2013
2413
  });
2014
2414
  }
2015
2415
 
2416
+ function handleSocket(socket) {
2417
+ socket.once('data', chunk => {
2418
+ const firstChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
2419
+ if (isRemoteHubWebSocketUpgradeStart(firstChunk)) {
2420
+ handleWebSocketUpgradeSocket(socket, firstChunk);
2421
+ return;
2422
+ }
2423
+
2424
+ handleTcpAgentSocket(socket, firstChunk);
2425
+ });
2426
+
2427
+ socket.once('error', () => {
2428
+ // The transport-specific handler owns logging after the first byte.
2429
+ });
2430
+ }
2431
+
2016
2432
  async function start() {
2017
2433
  if (!enabled || started) {
2018
2434
  return getStatus({ includeSecrets: false });
@@ -0,0 +1,202 @@
1
+ #!/usr/bin/env node
2
+
3
+ import assert from 'node:assert/strict';
4
+ import { WebSocket } from 'ws';
5
+ import { createRemoteHub } from '../remote-hub.js';
6
+
7
+ const PAIR_TOKEN = 'remote-agent-ws-smoke-token';
8
+ const DEVICE_ID = 'remote-agent-ws-smoke-device';
9
+ const SMOKE_PNG = Buffer.from(
10
+ 'iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAYAAAD0In+KAAAADElEQVR42mP8z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC',
11
+ 'base64');
12
+
13
+ function wait(ms) {
14
+ return new Promise(resolve => setTimeout(resolve, ms));
15
+ }
16
+
17
+ async function waitFor(predicate, timeoutMs = 5000, label = 'condition') {
18
+ const startedAt = Date.now();
19
+ while (Date.now() - startedAt < timeoutMs) {
20
+ const value = predicate();
21
+ if (value) {
22
+ return value;
23
+ }
24
+ await wait(25);
25
+ }
26
+
27
+ throw new Error(`Timed out waiting for ${label}.`);
28
+ }
29
+
30
+ function writeAgentBinaryFrame(ws, header, payload) {
31
+ const framePayload = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
32
+ const metadata = Buffer.from(JSON.stringify({
33
+ ...header,
34
+ type: 'frame.binary',
35
+ byteLength: framePayload.length
36
+ }), 'utf8');
37
+ const packet = Buffer.allocUnsafe(4 + metadata.length + framePayload.length);
38
+ packet.writeUInt32BE(metadata.length, 0);
39
+ metadata.copy(packet, 4);
40
+ framePayload.copy(packet, 4 + metadata.length);
41
+ ws.send(packet);
42
+ }
43
+
44
+ const hub = createRemoteHub({
45
+ env: {
46
+ MINDEXEC_REMOTE_HUB: '1',
47
+ REMOTE_HUB_HOST: '127.0.0.1',
48
+ REMOTE_HUB_PORT: '0',
49
+ REMOTE_HUB_PAIR_TOKEN: PAIR_TOKEN
50
+ }
51
+ });
52
+
53
+ let ws = null;
54
+
55
+ try {
56
+ await hub.start();
57
+ const status = hub.getStatus({ includeSecrets: true });
58
+ assert.equal(status.started, true);
59
+
60
+ ws = new WebSocket(`ws://127.0.0.1:${status.port}/remote-agent`);
61
+ const received = [];
62
+ ws.on('message', data => {
63
+ if (typeof data !== 'string' && !Buffer.isBuffer(data)) {
64
+ return;
65
+ }
66
+
67
+ const text = Buffer.isBuffer(data) ? data.toString('utf8') : data;
68
+ if (!text.trim().startsWith('{')) {
69
+ return;
70
+ }
71
+
72
+ received.push(JSON.parse(text));
73
+ });
74
+
75
+ await new Promise((resolve, reject) => {
76
+ ws.once('open', resolve);
77
+ ws.once('error', reject);
78
+ });
79
+
80
+ ws.send(JSON.stringify({
81
+ type: 'hello',
82
+ pairToken: PAIR_TOKEN,
83
+ deviceId: DEVICE_ID,
84
+ deviceName: 'Remote Agent WS Smoke',
85
+ hostname: 'remote-agent-ws-smoke',
86
+ platform: process.platform,
87
+ arch: process.arch,
88
+ pid: process.pid,
89
+ agentVersion: '0.0.0-ws-smoke',
90
+ runtime: 'node-smoke',
91
+ capabilities: {
92
+ status: true,
93
+ thumbnail: true,
94
+ liveStream: true,
95
+ binaryFrames: true,
96
+ control: true,
97
+ computerAgent: true,
98
+ taskDispatch: true,
99
+ aiAssist: false
100
+ }
101
+ }));
102
+ ws.send(JSON.stringify({
103
+ type: 'status',
104
+ status: {
105
+ uptimeSec: 1,
106
+ totalMem: 2,
107
+ freeMem: 1,
108
+ timestamp: new Date().toISOString()
109
+ }
110
+ }));
111
+
112
+ await waitFor(() => received.find(item => item.type === 'welcome'), 5000, 'welcome');
113
+ const device = await waitFor(() => {
114
+ const current = hub.listDevices();
115
+ return current.length === 1 && current[0].deviceId === DEVICE_ID ? current[0] : null;
116
+ }, 5000, 'device registration');
117
+ assert.equal(device.connected, true);
118
+ assert.equal(device.capabilities.binaryFrames, true);
119
+
120
+ const inputCommand = hub.sendInputControl(DEVICE_ID, { type: 'noop' });
121
+ assert.equal(inputCommand.ok, true);
122
+ const inputMessage = await waitFor(
123
+ () => received.find(item => item.commandId === inputCommand.commandId),
124
+ 5000,
125
+ 'input command');
126
+ assert.equal(inputMessage.command, 'input.control');
127
+ ws.send(JSON.stringify({
128
+ type: 'command.result',
129
+ commandId: inputCommand.commandId,
130
+ result: {
131
+ input: true,
132
+ handled: true
133
+ }
134
+ }));
135
+ await waitFor(() => hub.listDevices()[0]?.counters?.commandResultsReceived === 1, 5000, 'input result');
136
+
137
+ const thumbnailCommand = hub.requestThumbnail(DEVICE_ID, {
138
+ streamId: 'remote-agent-ws-thumb',
139
+ maxWidth: 320,
140
+ maxHeight: 180,
141
+ quality: 50
142
+ });
143
+ assert.equal(thumbnailCommand.ok, true);
144
+ await waitFor(
145
+ () => received.find(item => item.commandId === thumbnailCommand.commandId),
146
+ 5000,
147
+ 'thumbnail command');
148
+ writeAgentBinaryFrame(ws, {
149
+ frameKind: 'thumbnail',
150
+ commandId: thumbnailCommand.commandId,
151
+ streamId: 'remote-agent-ws-thumb',
152
+ frameSeq: 11,
153
+ width: 2,
154
+ height: 1,
155
+ mimeType: 'image/png',
156
+ capturedAt: new Date().toISOString()
157
+ }, SMOKE_PNG);
158
+ const thumbnailDevice = await waitFor(() => {
159
+ const current = hub.listDevices()[0];
160
+ return current?.latestThumbnail?.frameSeq === 11 ? current : null;
161
+ }, 5000, 'websocket binary thumbnail');
162
+ assert.equal(thumbnailDevice.latestThumbnail.transport, 'binary');
163
+ assert.equal(thumbnailDevice.latestThumbnail.byteLength, SMOKE_PNG.length);
164
+
165
+ const liveCommand = hub.startLiveStream(DEVICE_ID, {
166
+ streamId: 'remote-agent-ws-live',
167
+ fps: 10,
168
+ maxWidth: 320,
169
+ maxHeight: 180,
170
+ quality: 50
171
+ });
172
+ assert.equal(liveCommand.ok, true);
173
+ await waitFor(
174
+ () => received.find(item => item.commandId === liveCommand.commandId),
175
+ 5000,
176
+ 'live command');
177
+ writeAgentBinaryFrame(ws, {
178
+ frameKind: 'stream',
179
+ commandId: liveCommand.commandId,
180
+ streamId: 'remote-agent-ws-live',
181
+ frameSeq: 12,
182
+ width: 2,
183
+ height: 1,
184
+ mimeType: 'image/png',
185
+ fps: 10,
186
+ capturedAt: new Date().toISOString()
187
+ }, SMOKE_PNG);
188
+ const liveDevice = await waitFor(() => {
189
+ const current = hub.listDevices()[0];
190
+ return current?.latestLiveFrame?.frameSeq === 12 ? current : null;
191
+ }, 5000, 'websocket binary live frame');
192
+ assert.equal(liveDevice.latestLiveFrame.transport, 'binary');
193
+ assert.equal(liveDevice.latestLiveFrame.streamId, 'remote-agent-ws-live');
194
+
195
+ ws.close();
196
+ console.log('RemoteAgent WebSocket smoke OK');
197
+ } finally {
198
+ if (ws && ws.readyState === WebSocket.OPEN) {
199
+ ws.close();
200
+ }
201
+ await hub.close();
202
+ }