@livedesk/hub 0.1.59 → 0.1.64

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/src/remote-hub.js CHANGED
@@ -9,7 +9,10 @@ import {
9
9
  isSecureDirectHandshakeStart
10
10
  } from '../../runtime-core/src/direct-secure-transport.js';
11
11
  import { createHubRelayControl } from './transport/relay-hub-control.js';
12
- import { parseExactLiveStreamMonitorIndex } from './live-stream-monitor-contract.js';
12
+ import {
13
+ parseExactLiveStreamMonitorIndex,
14
+ resolveReadOnlyControlPresentationBorrow
15
+ } from './live-stream-monitor-contract.js';
13
16
  import {
14
17
  BoundedSegmentedBuffer,
15
18
  createBoundedAgentBinaryIngressLane
@@ -53,9 +56,13 @@ const MAX_AGENT_SOCKET_ACCUMULATOR_BYTES = MAX_LINE_CHARS
53
56
  const HTTP_HEADER_TERMINATOR = Buffer.from('\r\n\r\n', 'ascii');
54
57
  const MAX_AGENT_TASK_CHARS = 4000;
55
58
  const MAX_AGENT_TASK_RESULT_CHARS = 3000;
56
- const MAX_AGENT_TASK_DATA_CHARS = 64 * 1024;
57
- const RECENT_TASK_LIMIT = 12;
58
- const RECENT_TASK_BATCH_LIMIT = 16;
59
+ const MAX_AGENT_TASK_DATA_BYTES = 64 * 1024;
60
+ const MAX_AGENT_TASK_PREVIEW_BYTES = 16 * 1024;
61
+ const MAX_AGENT_TOOL_ARGUMENTS_BYTES = 24 * 1024;
62
+ const RECENT_TASK_LIMIT = 12;
63
+ const MAX_PENDING_AGENT_TASKS_PER_DEVICE = RECENT_TASK_LIMIT;
64
+ const RECENT_TASK_BATCH_LIMIT = 16;
65
+ const MAX_AGENT_SOCKET_WRITE_BUFFER_BYTES = MAX_LINE_CHARS + (256 * 1024);
59
66
  const RECENT_FRAME_CACHE_TTL_MS = 4000;
60
67
  const RECENT_THUMBNAIL_FRAME_CACHE_LIMIT = 2;
61
68
  const RECENT_LIVE_FRAME_CACHE_LIMIT = 1;
@@ -63,9 +70,10 @@ const RECENT_THUMBNAIL_FRAME_CACHE_MAX_BYTES = 2 * 1024 * 1024;
63
70
  const RECENT_LIVE_FRAME_CACHE_MAX_BYTES = 8 * 1024 * 1024;
64
71
  const LIVE_STREAM_PENDING_REUSE_MS = 5000;
65
72
  const LIVE_STREAM_REPLACEMENT_PENDING_MS = 7000;
66
- const LIVE_STREAM_MIN_FRESH_MS = 3000;
67
- const LIVE_STREAM_MAX_FRESH_MS = 12000;
68
- const DEFAULT_REMOTE_INPUT_ACK_TIMEOUT_MS = 5000;
73
+ const LIVE_STREAM_MIN_FRESH_MS = 3000;
74
+ const LIVE_STREAM_MAX_FRESH_MS = 12000;
75
+ const TASK_BATCH_OWNER = Symbol('remote-hub-task-batch-owner');
76
+ const DEFAULT_REMOTE_INPUT_ACK_TIMEOUT_MS = 5000;
69
77
  // RemoteFast owns a shared 7s native stop deadline. Keep four seconds for a
70
78
  // priority command.result to cross either Direct or encrypted Relay transport.
71
79
  const DEFAULT_LIVE_CAPTURE_STOP_ACK_TIMEOUT_MS = 11000;
@@ -302,9 +310,17 @@ function normalizePort(value) {
302
310
  return clampNumber(value, 0, 65535, DEFAULT_REMOTE_HUB_PORT);
303
311
  }
304
312
 
305
- function safeString(value, maxLength = 200) {
306
- return String(value ?? '').replace(/[\r\n\t]/g, ' ').trim().slice(0, maxLength);
307
- }
313
+ function safeString(value, maxLength = 200) {
314
+ return String(value ?? '').replace(/[\r\n\t]/g, ' ').trim().slice(0, maxLength);
315
+ }
316
+
317
+ export function normalizeMediaBinaryKind(value) {
318
+ if (typeof value !== 'string' || value.length > 64) {
319
+ return '';
320
+ }
321
+ const normalized = value.trim().toLowerCase();
322
+ return normalized.length <= 40 ? normalized : '';
323
+ }
308
324
 
309
325
  function remoteInputPayloadType(payload) {
310
326
  return safeString(payload?.payload?.type ?? payload?.type, 48).toLowerCase();
@@ -1221,16 +1237,131 @@ function normalizeRemoteKeyboardText(value) {
1221
1237
  return raw.slice(0, 256);
1222
1238
  }
1223
1239
 
1224
- function safeTaskData(value) {
1225
- if (value === undefined || value === null) return undefined;
1226
- try {
1227
- const serialized = JSON.stringify(value);
1228
- if (serialized.length <= MAX_AGENT_TASK_DATA_CHARS) return value;
1229
- return { truncated: true, byteLength: Buffer.byteLength(serialized, 'utf8'), preview: serialized.slice(0, MAX_AGENT_TASK_DATA_CHARS) };
1230
- } catch {
1231
- return { truncated: true };
1232
- }
1233
- }
1240
+ function utf8Prefix(value, maxBytes) {
1241
+ const text = String(value ?? '');
1242
+ const encoded = Buffer.from(text, 'utf8');
1243
+ if (encoded.length <= maxBytes) {
1244
+ return text;
1245
+ }
1246
+ let end = Math.max(0, Math.min(encoded.length, Math.floor(maxBytes)));
1247
+ while (end > 0 && end < encoded.length && (encoded[end] & 0xc0) === 0x80) {
1248
+ end -= 1;
1249
+ }
1250
+ return encoded.subarray(0, end).toString('utf8');
1251
+ }
1252
+
1253
+ function boundedSerializedTaskData(serialized) {
1254
+ const byteLength = Buffer.byteLength(serialized, 'utf8');
1255
+ if (byteLength <= MAX_AGENT_TASK_DATA_BYTES) {
1256
+ return JSON.parse(serialized);
1257
+ }
1258
+ return {
1259
+ truncated: true,
1260
+ byteLength,
1261
+ preview: utf8Prefix(serialized, MAX_AGENT_TASK_PREVIEW_BYTES)
1262
+ };
1263
+ }
1264
+
1265
+ function safeTaskData(value) {
1266
+ if (value === undefined || value === null) return undefined;
1267
+ try {
1268
+ const serialized = JSON.stringify(value);
1269
+ return serialized === undefined ? undefined : boundedSerializedTaskData(serialized);
1270
+ } catch {
1271
+ return { truncated: true };
1272
+ }
1273
+ }
1274
+
1275
+ function isPlainObject(value) {
1276
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
1277
+ return false;
1278
+ }
1279
+ const prototype = Object.getPrototypeOf(value);
1280
+ return prototype === Object.prototype || prototype === null;
1281
+ }
1282
+
1283
+ const DIAGNOSTIC_AGENT_OPERATIONS = new Set(['logs.collect', 'diagnostics.collect']);
1284
+ const DIAGNOSTIC_LOG_SOURCES = new Set(['vuvodesk', 'system']);
1285
+ const DIAGNOSTIC_SENSITIVE_KEY = /(?:token|secret|password|passwd|api[-_]?key|authorization|private[-_]?key|connection[-_]?string|access[-_]?key|credential|cookie|pair)/i;
1286
+ const DIAGNOSTIC_STANDALONE_SECRET_VALUE = /\b(?:sk-(?:proj-|ant-(?:api\d{2}-)?)?[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|(?:AKIA|ASIA)[A-Z0-9]{16}|AIza[A-Za-z0-9_-]{20,}|npm_[A-Za-z0-9]{20,}|xox[A-Za-z]-[A-Za-z0-9-]{10,}|[sr]k_(?:live|test)_[A-Za-z0-9]{10,})\b/g;
1287
+
1288
+ function redactDiagnosticText(value) {
1289
+ let text = String(value ?? '').replace(/\0/g, '');
1290
+ text = text.replace(
1291
+ /-----BEGIN(?: [A-Z0-9]+)* PRIVATE KEY-----[\s\S]*?-----END(?: [A-Z0-9]+)* PRIVATE KEY-----/gi,
1292
+ '[redacted-private-key]');
1293
+ text = text.replace(/\b(?:Proxy-)?Authorization\s*[:=]\s*[^\r\n]*/gi, 'Authorization: [redacted]');
1294
+ text = text.replace(/\b(?:Set-)?Cookie\s*[:=]\s*[^\r\n]*/gi, 'Cookie: [redacted]');
1295
+ text = text.replace(/\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, '[redacted-authorization]');
1296
+ text = text.replace(DIAGNOSTIC_STANDALONE_SECRET_VALUE, '[redacted-secret]');
1297
+ text = text.replace(/\b(?:eyJ)?[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, '[redacted-jwt]');
1298
+ text = text.replace(
1299
+ /(^|[\s,;?&])([A-Za-z0-9_.-]*(?:token|secret|password|passwd|api[-_]?key|authorization|private[-_]?key|connection[-_]?string|access[-_]?key|credential|cookie|pair)[A-Za-z0-9_.-]*)\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s,;&#]+)/gi,
1300
+ '$1$2=[redacted]');
1301
+ return text;
1302
+ }
1303
+
1304
+ function diagnosticJsonReplacer(key, value) {
1305
+ if (key && DIAGNOSTIC_SENSITIVE_KEY.test(key)) {
1306
+ return '[redacted]';
1307
+ }
1308
+ return typeof value === 'string' ? redactDiagnosticText(value) : value;
1309
+ }
1310
+
1311
+ function safeDiagnosticText(value, maxBytes) {
1312
+ return utf8Prefix(redactDiagnosticText(value), maxBytes).trim();
1313
+ }
1314
+
1315
+ function safeDiagnosticTaskData(value) {
1316
+ if (value === undefined || value === null) return undefined;
1317
+ try {
1318
+ const serialized = JSON.stringify(value, diagnosticJsonReplacer);
1319
+ return serialized === undefined ? undefined : boundedSerializedTaskData(serialized);
1320
+ } catch {
1321
+ return { truncated: true };
1322
+ }
1323
+ }
1324
+
1325
+ function normalizeAgentToolArguments(operation, value) {
1326
+ const candidate = value === undefined || value === null ? {} : value;
1327
+ if (!isPlainObject(candidate)) {
1328
+ return { ok: false, error: 'invalid-tool-arguments' };
1329
+ }
1330
+ try {
1331
+ const serialized = JSON.stringify(candidate);
1332
+ if (serialized === undefined || Buffer.byteLength(serialized, 'utf8') > MAX_AGENT_TOOL_ARGUMENTS_BYTES) {
1333
+ return { ok: false, error: 'tool-arguments-too-large' };
1334
+ }
1335
+ const cloned = JSON.parse(serialized);
1336
+ if (!isPlainObject(cloned)) {
1337
+ return { ok: false, error: 'invalid-tool-arguments' };
1338
+ }
1339
+ if (DIAGNOSTIC_AGENT_OPERATIONS.has(operation)) {
1340
+ const requestedSource = safeString(cloned.source, 40).toLowerCase();
1341
+ return {
1342
+ ok: true,
1343
+ value: {
1344
+ source: DIAGNOSTIC_LOG_SOURCES.has(requestedSource) ? requestedSource : 'vuvodesk',
1345
+ maxLines: clampNumber(cloned.maxLines, 1, 500, 100)
1346
+ }
1347
+ };
1348
+ }
1349
+ return { ok: true, value: cloned };
1350
+ } catch {
1351
+ return { ok: false, error: 'invalid-tool-arguments' };
1352
+ }
1353
+ }
1354
+
1355
+ function safeRetryToolArguments(operation, value) {
1356
+ const normalized = normalizeAgentToolArguments(operation, value);
1357
+ return normalized.ok ? normalized.value : {};
1358
+ }
1359
+
1360
+ function normalizeAgentPermissionMode(operation, value) {
1361
+ return DIAGNOSTIC_AGENT_OPERATIONS.has(operation)
1362
+ ? 'safe-auto'
1363
+ : safeString(value, 40) || 'ask';
1364
+ }
1234
1365
 
1235
1366
  function normalizeApprovalLevel(value) {
1236
1367
  const level = safeString(value, 80).toLowerCase();
@@ -1279,10 +1410,20 @@ const SUPPORTED_AGENT_OPERATIONS = new Set([
1279
1410
  'logs.collect'
1280
1411
  ]);
1281
1412
 
1282
- function normalizeAgentOperation(value) {
1413
+ function normalizeAgentOperation(value) {
1283
1414
  const operation = safeString(value, 80).toLowerCase();
1284
1415
  return SUPPORTED_AGENT_OPERATIONS.has(operation) ? operation : '';
1285
- }
1416
+ }
1417
+
1418
+ function deviceSupportsAgentOperation(device, operation) {
1419
+ const advertised = device?.capabilities?.agentTools;
1420
+ if (!Array.isArray(advertised)) {
1421
+ // Older Clients did not advertise the list. Keep their established
1422
+ // behavior, but fail closed whenever a Client provides an exact list.
1423
+ return true;
1424
+ }
1425
+ return advertised.some(value => safeString(value, 80) === operation);
1426
+ }
1286
1427
 
1287
1428
  const REMOTE_INPUT_MONITOR_KEYS = Object.freeze([
1288
1429
  'monitorIndex',
@@ -1658,7 +1799,23 @@ function findRecentFramePayload(device, frameKind, options = {}) {
1658
1799
  return entry?.frame || null;
1659
1800
  }
1660
1801
 
1661
- function serializeDevice(device, options = {}) {
1802
+ function serializeDeviceTask(task) {
1803
+ if (!task) {
1804
+ return null;
1805
+ }
1806
+ const publicTask = { ...task };
1807
+ const resultData = publicTask.resultData;
1808
+ delete publicTask.resultData;
1809
+ delete publicTask.toolArguments;
1810
+ delete publicTask.retryToolArguments;
1811
+ delete publicTask.retryPermissionMode;
1812
+ return {
1813
+ ...publicTask,
1814
+ hasResultData: resultData !== undefined && resultData !== null
1815
+ };
1816
+ }
1817
+
1818
+ function serializeDevice(device, options = {}) {
1662
1819
  if (!device) {
1663
1820
  return null;
1664
1821
  }
@@ -1733,17 +1890,17 @@ function serializeDevice(device, options = {}) {
1733
1890
  latestAudioStatus: device.latestAudioStatus ? { ...device.latestAudioStatus } : null,
1734
1891
  clientUpdateProof: device.clientUpdateProof ? { ...device.clientUpdateProof } : null,
1735
1892
  udp: device.udp ? { ...device.udp } : { enabled: false, state: 'tcp-only', ready: false },
1736
- latestTask: device.latestTask ? { ...device.latestTask } : null,
1893
+ latestTask: serializeDeviceTask(device.latestTask),
1737
1894
  synthetic: device.synthetic === true,
1738
1895
  recentTasks: Array.isArray(device.recentTasks)
1739
- ? device.recentTasks.map(task => ({ ...task }))
1896
+ ? device.recentTasks.map(serializeDeviceTask).filter(Boolean)
1740
1897
  : [],
1741
1898
  status: { ...device.status },
1742
1899
  counters: { ...device.counters }
1743
1900
  };
1744
1901
  }
1745
1902
 
1746
- function serializeTaskBatch(batch) {
1903
+ function serializeTaskBatch(batch, options = {}) {
1747
1904
  if (!batch) {
1748
1905
  return null;
1749
1906
  }
@@ -1765,24 +1922,51 @@ function serializeTaskBatch(batch) {
1765
1922
  failed: batch.failed,
1766
1923
  cancelled: batch.cancelled,
1767
1924
  timedOut: batch.timedOut,
1768
- results: Array.isArray(batch.results)
1769
- ? batch.results.map(item => ({ ...item }))
1770
- : []
1925
+ results: Array.isArray(batch.results)
1926
+ ? batch.results.map(item => {
1927
+ const { data, ...publicItem } = item;
1928
+ return options.includeData === true
1929
+ ? {
1930
+ ...publicItem,
1931
+ hasResultData: data !== undefined && data !== null,
1932
+ data: safeTaskData(data)
1933
+ }
1934
+ : {
1935
+ ...publicItem,
1936
+ hasResultData: data !== undefined && data !== null
1937
+ };
1938
+ })
1939
+ : []
1771
1940
  };
1772
1941
  }
1773
1942
 
1774
- function writeJsonLine(socket, payload) {
1943
+ function writeJsonLine(socket, payload) {
1775
1944
  if (!socket || socket.destroyed) {
1776
1945
  return false;
1777
1946
  }
1778
1947
 
1779
- if (socket.__remoteHubWebSocket === true && typeof socket.sendJson === 'function') {
1780
- return socket.sendJson(payload);
1781
- }
1782
-
1783
- socket.write(`${JSON.stringify(payload)}\n`);
1784
- return true;
1785
- }
1948
+ if (socket.__remoteHubWebSocket === true && typeof socket.sendJson === 'function') {
1949
+ return socket.sendJson(payload);
1950
+ }
1951
+
1952
+ try {
1953
+ const line = Buffer.from(`${JSON.stringify(payload)}\n`, 'utf8');
1954
+ const bufferedBytes = Math.max(0, Number(socket.writableLength || 0));
1955
+ if (bufferedBytes + line.length > MAX_AGENT_SOCKET_WRITE_BUFFER_BYTES) {
1956
+ socket.destroy();
1957
+ return false;
1958
+ }
1959
+
1960
+ // net.Socket.write(false) means the bytes were accepted into Node's
1961
+ // writable queue. Keep that accepted write successful, but never allow
1962
+ // later writes to grow the queue past the fixed byte cap above.
1963
+ socket.write(line);
1964
+ return true;
1965
+ } catch {
1966
+ socket.destroy();
1967
+ return false;
1968
+ }
1969
+ }
1786
1970
 
1787
1971
  function writeJsonLineAndClose(socket, payload) {
1788
1972
  if (!socket || socket.destroyed) {
@@ -2047,16 +2231,26 @@ export class RemoteHubWebSocketAgentSocket {
2047
2231
  return this.sendFrame(0x1, Buffer.from(String(text || ''), 'utf8'));
2048
2232
  }
2049
2233
 
2050
- sendFrame(opcode, payload) {
2051
- if (this.destroyed || this.socket.destroyed) {
2052
- return false;
2053
- }
2054
-
2055
- try {
2056
- return this.socket.write(buildRemoteHubWebSocketFrame(opcode, payload)) !== false;
2057
- } catch {
2058
- this.destroy();
2059
- return false;
2234
+ sendFrame(opcode, payload) {
2235
+ if (this.destroyed || this.socket.destroyed) {
2236
+ return false;
2237
+ }
2238
+
2239
+ try {
2240
+ const frame = buildRemoteHubWebSocketFrame(opcode, payload);
2241
+ const bufferedBytes = Math.max(0, Number(this.socket?.writableLength || 0));
2242
+ if (bufferedBytes + frame.length > MAX_AGENT_SOCKET_WRITE_BUFFER_BYTES) {
2243
+ this.destroy();
2244
+ return false;
2245
+ }
2246
+
2247
+ // The raw socket owns a single bounded queue for every WebSocket
2248
+ // message. A false return still means this exact frame was queued.
2249
+ this.socket.write(frame);
2250
+ return true;
2251
+ } catch {
2252
+ this.destroy();
2253
+ return false;
2060
2254
  }
2061
2255
  }
2062
2256
 
@@ -2303,7 +2497,7 @@ export class RemoteHubWebSocketAgentSocket {
2303
2497
  }
2304
2498
  }
2305
2499
 
2306
- export function createRemoteHub(options = {}) {
2500
+ export function createRemoteHub(options = {}) {
2307
2501
  const env = options.env || process.env;
2308
2502
  const transportDiagnosticCommandTimeoutMs = clampNumber(
2309
2503
  options.transportDiagnosticCommandTimeoutMs,
@@ -2367,13 +2561,18 @@ export function createRemoteHub(options = {}) {
2367
2561
  defaultDeviceConnectionStaleMs,
2368
2562
  10 * 60 * 1000,
2369
2563
  defaultDeviceConnectionStaleMs);
2370
- const deviceConnectionSweepMs = Number.isFinite(Number(options.deviceConnectionSweepMs))
2371
- ? clampNumber(options.deviceConnectionSweepMs, 25, 60000, DEFAULT_DEVICE_CONNECTION_SWEEP_MS)
2372
- : clampNumber(
2373
- env.REMOTE_HUB_DEVICE_CONNECTION_SWEEP_MS,
2374
- 1000,
2375
- 60000,
2376
- Math.min(DEFAULT_DEVICE_CONNECTION_SWEEP_MS, heartbeatMs));
2564
+ const deviceConnectionSweepMs = Number.isFinite(Number(options.deviceConnectionSweepMs))
2565
+ ? clampNumber(options.deviceConnectionSweepMs, 25, 60000, DEFAULT_DEVICE_CONNECTION_SWEEP_MS)
2566
+ : clampNumber(
2567
+ env.REMOTE_HUB_DEVICE_CONNECTION_SWEEP_MS,
2568
+ 1000,
2569
+ 60000,
2570
+ Math.min(DEFAULT_DEVICE_CONNECTION_SWEEP_MS, heartbeatMs));
2571
+ const udpStartRetryInitialMs = clampNumber(
2572
+ options.udpStartRetryMs ?? env.LIVEDESK_UDP_START_RETRY_MS,
2573
+ 250,
2574
+ 60000,
2575
+ 5000);
2377
2576
  const taskTimeoutMs = clampNumber(
2378
2577
  options.taskTimeoutMs ?? env.REMOTE_HUB_TASK_TIMEOUT_MS,
2379
2578
  50,
@@ -2412,13 +2611,14 @@ export function createRemoteHub(options = {}) {
2412
2611
  false);
2413
2612
  const relayControl = options.relayControl && typeof options.relayControl === 'object'
2414
2613
  ? options.relayControl
2415
- : createHubRelayControl({
2416
- env,
2417
- pairToken,
2418
- logEvent,
2419
- logWarn,
2420
- onPeerSocket: socket => handleTcpAgentSocket(socket)
2421
- });
2614
+ : createHubRelayControl({
2615
+ env,
2616
+ pairToken,
2617
+ logEvent,
2618
+ logWarn,
2619
+ retiredPayloadRelayTestOnly: options.retiredPayloadRelayTestOnly === true,
2620
+ onPeerSocket: socket => handleTcpAgentSocket(socket)
2621
+ });
2422
2622
  let pairingPin = /^\d{6}$/.test(String(options.pairingPin || env.LIVEDESK_PAIRING_PIN || '').trim())
2423
2623
  ? String(options.pairingPin || env.LIVEDESK_PAIRING_PIN).trim()
2424
2624
  : generatePairingPin();
@@ -2447,11 +2647,15 @@ export function createRemoteHub(options = {}) {
2447
2647
  const clipboardOperations = new Map();
2448
2648
  const transportDiagnostics = new Map();
2449
2649
  const transportDiagnosticStartingDevices = new Set();
2450
- const duplicateDeviceLogAt = new Map();
2451
- let server = null;
2452
- let started = false;
2453
- let deviceConnectionSweepTimer = null;
2454
- let boundPort = requestedPort;
2650
+ const duplicateDeviceLogAt = new Map();
2651
+ let server = null;
2652
+ let started = false;
2653
+ let closing = false;
2654
+ let deviceConnectionSweepTimer = null;
2655
+ let udpStartRetryTimer = null;
2656
+ let udpStartRetryDelayMs = udpStartRetryInitialMs;
2657
+ let udpStartPromise = null;
2658
+ let boundPort = requestedPort;
2455
2659
  let lastError = '';
2456
2660
  let hostTarget = null;
2457
2661
  let publicIPv4Cache = {
@@ -2760,12 +2964,20 @@ export function createRemoteHub(options = {}) {
2760
2964
  const activeHostTarget = serializeHostTarget();
2761
2965
  const routeInfo = getAgentEndpointRouteInfo();
2762
2966
  const diagnostics = sampleHubRuntimeDiagnostics();
2763
- diagnostics.network = {
2764
- lanDeviceCount: connectedRoutes.length - externalDeviceCount,
2765
- externalDeviceCount,
2766
- route: externalDeviceCount > 0 ? 'external' : connectedRoutes.length > 0 ? 'lan' : 'idle'
2767
- };
2768
- return {
2967
+ diagnostics.network = {
2968
+ lanDeviceCount: connectedRoutes.length - externalDeviceCount,
2969
+ externalDeviceCount,
2970
+ route: externalDeviceCount > 0 ? 'external' : connectedRoutes.length > 0 ? 'lan' : 'idle'
2971
+ };
2972
+ const udpStatus = udpTransport?.getStatus?.() || {
2973
+ enabled: false,
2974
+ preferred: false,
2975
+ started: false,
2976
+ port: 0,
2977
+ sessionCount: 0,
2978
+ readySessionCount: 0
2979
+ };
2980
+ return {
2769
2981
  enabled,
2770
2982
  started,
2771
2983
  host,
@@ -2809,14 +3021,14 @@ export function createRemoteHub(options = {}) {
2809
3021
  publicIPv4Address: publicIPv4Cache.address,
2810
3022
  publicIPv4UpdatedAt: publicIPv4Cache.updatedAt ? new Date(publicIPv4Cache.updatedAt).toISOString() : '',
2811
3023
  publicIPv4Error: publicIPv4Cache.error,
2812
- udp: udpTransport?.getStatus?.() || {
2813
- enabled: false,
2814
- preferred: false,
2815
- started: false,
2816
- port: 0,
2817
- sessionCount: 0,
2818
- readySessionCount: 0
2819
- },
3024
+ udp: {
3025
+ ...udpStatus,
3026
+ startRetryTimerActive: udpStartRetryTimer !== null,
3027
+ startInFlight: udpStartPromise !== null,
3028
+ startRetryDelayMs: udpStartRetryTimer !== null
3029
+ ? udpStartRetryDelayMs
3030
+ : 0
3031
+ },
2820
3032
  relay: relayControl?.getStatus?.() || {
2821
3033
  enabled: false,
2822
3034
  started: false,
@@ -2915,8 +3127,8 @@ export function createRemoteHub(options = {}) {
2915
3127
  return listTaskBatches()[0] || null;
2916
3128
  }
2917
3129
 
2918
- function getTaskBatch(batchId) {
2919
- return serializeTaskBatch(taskBatches.get(safeString(batchId, 128)) || null);
3130
+ function getTaskBatch(batchId) {
3131
+ return serializeTaskBatch(taskBatches.get(safeString(batchId, 128)) || null, { includeData: true });
2920
3132
  }
2921
3133
 
2922
3134
  function cancelTaskBatch(batchId) {
@@ -2971,12 +3183,14 @@ export function createRemoteHub(options = {}) {
2971
3183
  if (targetIds.length === 0) {
2972
3184
  return { ok: false, error: 'no-retry-targets' };
2973
3185
  }
2974
- return requestAgentTaskBatch(targetIds, {
2975
- instruction: batch.instructionPreview,
2976
- title: batch.title,
2977
- operation: batch.operation,
2978
- targetQuery: batch.targetQuery,
2979
- approvalLevel: batch.approvalLevel
3186
+ return requestAgentTaskBatch(targetIds, {
3187
+ instruction: batch.instructionPreview,
3188
+ title: batch.title,
3189
+ operation: batch.operation,
3190
+ targetQuery: batch.targetQuery,
3191
+ approvalLevel: batch.approvalLevel,
3192
+ toolArguments: batch.retryToolArguments,
3193
+ permissionMode: batch.retryPermissionMode
2980
3194
  });
2981
3195
  }
2982
3196
 
@@ -3011,18 +3225,20 @@ export function createRemoteHub(options = {}) {
3011
3225
  }
3012
3226
  }
3013
3227
 
3014
- function settlePendingCommandResult(device, message = {}) {
3015
- const commandId = safeString(message.commandId, 128);
3228
+ function settlePendingCommandResult(device, message = {}, channel = 'control') {
3229
+ channel = safeString(channel, 24) || 'control';
3230
+ const commandId = safeString(message.commandId, 128);
3016
3231
  const waiterKey = buildPendingCommandResultWaiterKey(
3017
3232
  device?.deviceId,
3018
3233
  device?.sessionId,
3019
3234
  commandId);
3020
3235
  const waiter = pendingCommandResultWaiters.get(waiterKey);
3021
- if (!waiter
3022
- || waiter.deviceId !== device?.deviceId
3023
- || waiter.sessionId !== device?.sessionId) {
3024
- return false;
3025
- }
3236
+ if (!waiter
3237
+ || waiter.deviceId !== device?.deviceId
3238
+ || waiter.sessionId !== device?.sessionId
3239
+ || (safeString(waiter.channel, 24) || 'control') !== channel) {
3240
+ return false;
3241
+ }
3026
3242
 
3027
3243
  pendingCommandResultWaiters.delete(waiterKey);
3028
3244
  clearPendingCommandResultWaiterDeadline(waiter);
@@ -4818,7 +5034,107 @@ export function createRemoteHub(options = {}) {
4818
5034
  };
4819
5035
  }
4820
5036
 
4821
- function attachDevice(socket, hello) {
5037
+ function registerCurrentDeviceUdpSession(device) {
5038
+ const socket = device?.socket;
5039
+ if (!device?.connected
5040
+ || !socket
5041
+ || socket.destroyed
5042
+ || devices.get(device.deviceId) !== device) {
5043
+ return null;
5044
+ }
5045
+ if (device.udp?.sessionId) {
5046
+ return device.udp;
5047
+ }
5048
+ const sessionId = device.sessionId;
5049
+ const udpSession = udpTransport?.registerDevice?.({
5050
+ deviceId: device.deviceId,
5051
+ sessionId,
5052
+ sendControl: payload => {
5053
+ if (devices.get(device.deviceId) === device
5054
+ && device.sessionId === sessionId
5055
+ && device.socket === socket
5056
+ && !socket.destroyed) {
5057
+ writeJsonLine(socket, payload);
5058
+ }
5059
+ },
5060
+ capabilities: device.capabilities
5061
+ });
5062
+ if (devices.get(device.deviceId) !== device
5063
+ || device.sessionId !== sessionId
5064
+ || device.socket !== socket
5065
+ || socket.destroyed) {
5066
+ udpTransport?.unregisterDevice?.(device.deviceId, sessionId);
5067
+ return null;
5068
+ }
5069
+ if (udpSession?.sessionId) {
5070
+ device.udp = {
5071
+ ...device.udp,
5072
+ state: 'udp-session-created',
5073
+ sessionId: udpSession.sessionId
5074
+ };
5075
+ }
5076
+ return udpSession || null;
5077
+ }
5078
+
5079
+ function scheduleOptionalUdpStartRetry() {
5080
+ if (closing
5081
+ || !started
5082
+ || udpStartRetryTimer
5083
+ || udpTransport?.getStatus?.().enabled === false) {
5084
+ return;
5085
+ }
5086
+ const delayMs = udpStartRetryDelayMs;
5087
+ udpStartRetryTimer = setTimeout(() => {
5088
+ udpStartRetryTimer = null;
5089
+ udpStartRetryDelayMs = Math.min(30000, delayMs * 2);
5090
+ void startOptionalUdpTransport('retry');
5091
+ }, delayMs);
5092
+ udpStartRetryTimer.unref?.();
5093
+ }
5094
+
5095
+ function startOptionalUdpTransport(reason) {
5096
+ if (!udpTransport?.start
5097
+ || udpTransport?.getStatus?.().enabled === false
5098
+ || closing
5099
+ || !started) {
5100
+ return Promise.resolve(false);
5101
+ }
5102
+ if (udpStartPromise) {
5103
+ return udpStartPromise;
5104
+ }
5105
+
5106
+ const run = Promise.resolve().then(async () => {
5107
+ try {
5108
+ await udpTransport.start();
5109
+ if (closing || !started) {
5110
+ return false;
5111
+ }
5112
+ udpStartRetryDelayMs = udpStartRetryInitialMs;
5113
+ for (const device of devices.values()) {
5114
+ registerCurrentDeviceUdpSession(device);
5115
+ }
5116
+ return true;
5117
+ } catch (error) {
5118
+ if (!closing && started) {
5119
+ logWarn(
5120
+ 'udp',
5121
+ `UDP P2P could not start (${reason}); Direct TCP remains available: ${error?.message || error}`);
5122
+ scheduleOptionalUdpStartRetry();
5123
+ }
5124
+ return false;
5125
+ }
5126
+ });
5127
+ let owner = null;
5128
+ owner = run.finally(() => {
5129
+ if (udpStartPromise === owner) {
5130
+ udpStartPromise = null;
5131
+ }
5132
+ });
5133
+ udpStartPromise = owner;
5134
+ return owner;
5135
+ }
5136
+
5137
+ function attachDevice(socket, hello) {
4822
5138
  const nowMs = Date.now();
4823
5139
  const now = new Date(nowMs).toISOString();
4824
5140
  const sessionId = crypto.randomUUID();
@@ -4944,19 +5260,7 @@ export function createRemoteHub(options = {}) {
4944
5260
  serverTime: now
4945
5261
  });
4946
5262
 
4947
- const udpSession = udpTransport?.registerDevice?.({
4948
- deviceId,
4949
- sessionId,
4950
- sendControl: payload => writeJsonLine(socket, payload),
4951
- capabilities
4952
- });
4953
- if (udpSession?.sessionId) {
4954
- device.udp = {
4955
- ...device.udp,
4956
- state: 'udp-session-created',
4957
- sessionId: udpSession.sessionId
4958
- };
4959
- }
5263
+ registerCurrentDeviceUdpSession(device);
4960
5264
 
4961
5265
  logEvent('remote', `device connected ${device.deviceName} (${deviceId})`, 'success');
4962
5266
  emitRemoteEvent('RemoteDeviceConnected', device);
@@ -5046,16 +5350,26 @@ export function createRemoteHub(options = {}) {
5046
5350
  return device;
5047
5351
  }
5048
5352
 
5049
- function attachFrameSocket(socket, hello) {
5050
- const deviceId = normalizeDeviceId(hello.deviceId);
5051
- const device = devices.get(deviceId);
5052
- if (!device || !device.connected || !device.socket || device.socket.destroyed) {
5053
- writeJsonLine(socket, { type: 'error', error: 'device-not-connected' });
5054
- socket.destroy();
5055
- return null;
5056
- }
5057
-
5058
- closeFrameSocket(device, 'replaced-by-new-frame-socket');
5353
+ function attachFrameSocket(socket, hello) {
5354
+ const deviceId = normalizeDeviceId(hello.deviceId);
5355
+ const device = devices.get(deviceId);
5356
+ if (!device || !device.connected || !device.socket || device.socket.destroyed) {
5357
+ writeJsonLine(socket, { type: 'error', error: 'device-not-connected' });
5358
+ socket.destroy();
5359
+ return null;
5360
+ }
5361
+ const parentSessionId = safeString(hello.parentSessionId, 160);
5362
+ const requiresSessionBoundMedia = device.capabilities?.sessionBoundMediaChannels === true;
5363
+ if ((parentSessionId && parentSessionId !== safeString(device.sessionId, 160))
5364
+ || (!parentSessionId && requiresSessionBoundMedia)) {
5365
+ writeJsonLineAndClose(socket, { type: 'error', error: 'side-channel-parent-session-stale' });
5366
+ return null;
5367
+ }
5368
+ if (!parentSessionId) {
5369
+ emitRemoteEvent('RemoteLegacyMediaSideChannelAccepted', device, { channel: 'frame' });
5370
+ }
5371
+
5372
+ closeFrameSocket(device, 'replaced-by-new-frame-socket');
5059
5373
 
5060
5374
  const now = new Date().toISOString();
5061
5375
  device.frameSocket = socket;
@@ -5079,16 +5393,26 @@ export function createRemoteHub(options = {}) {
5079
5393
  return device;
5080
5394
  }
5081
5395
 
5082
- function attachAudioSocket(socket, hello) {
5083
- const deviceId = normalizeDeviceId(hello.deviceId);
5084
- const device = devices.get(deviceId);
5085
- if (!device || !device.connected || !device.socket || device.socket.destroyed) {
5086
- writeJsonLine(socket, { type: 'error', error: 'device-not-connected' });
5087
- socket.destroy();
5088
- return null;
5089
- }
5090
-
5091
- closeAudioSocket(device, 'replaced-by-new-audio-socket');
5396
+ function attachAudioSocket(socket, hello) {
5397
+ const deviceId = normalizeDeviceId(hello.deviceId);
5398
+ const device = devices.get(deviceId);
5399
+ if (!device || !device.connected || !device.socket || device.socket.destroyed) {
5400
+ writeJsonLine(socket, { type: 'error', error: 'device-not-connected' });
5401
+ socket.destroy();
5402
+ return null;
5403
+ }
5404
+ const parentSessionId = safeString(hello.parentSessionId, 160);
5405
+ const requiresSessionBoundMedia = device.capabilities?.sessionBoundMediaChannels === true;
5406
+ if ((parentSessionId && parentSessionId !== safeString(device.sessionId, 160))
5407
+ || (!parentSessionId && requiresSessionBoundMedia)) {
5408
+ writeJsonLineAndClose(socket, { type: 'error', error: 'side-channel-parent-session-stale' });
5409
+ return null;
5410
+ }
5411
+ if (!parentSessionId) {
5412
+ emitRemoteEvent('RemoteLegacyMediaSideChannelAccepted', device, { channel: 'audio' });
5413
+ }
5414
+
5415
+ closeAudioSocket(device, 'replaced-by-new-audio-socket');
5092
5416
  const now = new Date().toISOString();
5093
5417
  device.audioSocket = socket;
5094
5418
  device.audioConnectedAt = now;
@@ -5155,8 +5479,10 @@ export function createRemoteHub(options = {}) {
5155
5479
  device.socket = null;
5156
5480
  abortTransportDiagnosticsForDevice(device, reason);
5157
5481
  udpTransport?.unregisterDevice?.(deviceId, device.sessionId);
5158
- closeInputSocket(device, reason);
5159
- closeFrameSocket(device, reason);
5482
+ closeInputSocket(device, reason);
5483
+ closeFrameSocket(device, reason);
5484
+ closeAudioSocket(device, reason);
5485
+ closeFileSocket(device, reason);
5160
5486
  device.disconnectedAt = new Date().toISOString();
5161
5487
  device.lastDisconnectReason = reason;
5162
5488
  deactivateDeviceLiveStreams(device, reason, device.disconnectedAt);
@@ -5168,10 +5494,10 @@ export function createRemoteHub(options = {}) {
5168
5494
  emitRemoteEvent('RemoteDeviceDisconnected', device, { reason });
5169
5495
  }
5170
5496
 
5171
- function rememberDeviceTask(device, task) {
5172
- if (!device || !task) {
5173
- return null;
5174
- }
5497
+ function rememberDeviceTask(device, task) {
5498
+ if (!device || !task) {
5499
+ return null;
5500
+ }
5175
5501
 
5176
5502
  const existingIndex = device.recentTasks.findIndex(item =>
5177
5503
  item.taskId === task.taskId || item.commandId === task.commandId);
@@ -5184,13 +5510,85 @@ export function createRemoteHub(options = {}) {
5184
5510
  device.recentTasks.length = RECENT_TASK_LIMIT;
5185
5511
  }
5186
5512
 
5187
- device.latestTask = task;
5188
- if (task.commandId) {
5189
- device.pendingTaskCommands.set(task.commandId, task);
5190
- }
5513
+ device.latestTask = task;
5514
+ if (task.commandId) {
5515
+ device.pendingTaskCommands.set(task.commandId, task);
5516
+ }
5191
5517
 
5192
- return task;
5193
- }
5518
+ return task;
5519
+ }
5520
+
5521
+ function markOwnedTaskBatch(batch) {
5522
+ Object.defineProperty(batch, TASK_BATCH_OWNER, {
5523
+ value: true,
5524
+ enumerable: false,
5525
+ configurable: false,
5526
+ writable: false
5527
+ });
5528
+ return batch;
5529
+ }
5530
+
5531
+ function getOwnedTaskBatch(options = {}) {
5532
+ const batch = options.taskBatch;
5533
+ if (!batch
5534
+ || batch[TASK_BATCH_OWNER] !== true
5535
+ || taskBatches.get(batch.batchId) !== batch) {
5536
+ return null;
5537
+ }
5538
+ return batch;
5539
+ }
5540
+
5541
+ function createAgentTaskOwner(device, taskId, commandId, operation, batchId = '') {
5542
+ return Object.freeze({
5543
+ deviceId: safeString(device?.deviceId, 160),
5544
+ sessionId: safeString(device?.sessionId, 160),
5545
+ taskId: safeString(taskId, 128),
5546
+ commandId: safeString(commandId, 128),
5547
+ operation: safeString(operation, 80),
5548
+ batchId: safeString(batchId, 128)
5549
+ });
5550
+ }
5551
+
5552
+ function isPendingAgentTaskOwner(device, task, commandId) {
5553
+ const owner = task?.owner;
5554
+ const expectedCommandId = safeString(commandId, 128);
5555
+ return !!owner
5556
+ && device?.pendingTaskCommands?.get(expectedCommandId) === task
5557
+ && owner.deviceId === safeString(device?.deviceId, 160)
5558
+ && owner.sessionId === safeString(device?.sessionId, 160)
5559
+ && owner.commandId === expectedCommandId
5560
+ && owner.taskId === safeString(task?.taskId, 128)
5561
+ && owner.operation === safeString(task?.operation, 80)
5562
+ && owner.batchId === safeString(task?.batchId, 128);
5563
+ }
5564
+
5565
+ function taskResultMatchesOwner(device, task, commandId, result, message, channel) {
5566
+ if (channel !== 'control' || !isPendingAgentTaskOwner(device, task, commandId)) {
5567
+ return false;
5568
+ }
5569
+ const owner = task.owner;
5570
+ const resultTaskId = safeString(message?.taskId || result?.taskId, 128);
5571
+ const resultOperation = safeString(message?.operation || result?.operation, 80);
5572
+ const resultDeviceId = safeString(message?.deviceId || result?.deviceId, 160);
5573
+ const resultSessionId = safeString(message?.sessionId || result?.sessionId, 160);
5574
+ return (!resultTaskId || resultTaskId === owner.taskId)
5575
+ && (!resultOperation || resultOperation === owner.operation)
5576
+ && (!resultDeviceId || resultDeviceId === owner.deviceId)
5577
+ && (!resultSessionId || resultSessionId === owner.sessionId);
5578
+ }
5579
+
5580
+ function canQueueAgentTask(device) {
5581
+ if (!(device?.pendingTaskCommands instanceof Map)
5582
+ || !(device?.pendingTaskTimers instanceof Map)) {
5583
+ return false;
5584
+ }
5585
+ // Each pending task has one exact timer. Any mismatch is unsafe state,
5586
+ // so reject new work instead of making the retained owner unbounded.
5587
+ if (device.pendingTaskCommands.size !== device.pendingTaskTimers.size) {
5588
+ return false;
5589
+ }
5590
+ return device.pendingTaskCommands.size < MAX_PENDING_AGENT_TASKS_PER_DEVICE;
5591
+ }
5194
5592
 
5195
5593
  function trimTaskBatches() {
5196
5594
  const ordered = [...taskBatches.values()]
@@ -5209,17 +5607,19 @@ export function createRemoteHub(options = {}) {
5209
5607
  const instruction = safeText(options.instruction, MAX_AGENT_TASK_CHARS);
5210
5608
  const operation = normalizeAgentOperation(options.operation);
5211
5609
  const targetQuery = safeText(options.targetQuery, 160);
5212
- const batchId = safeString(options.batchId, 128) || crypto.randomUUID();
5610
+ const batchId = crypto.randomUUID();
5213
5611
  const title = safeString(options.title, 120)
5214
5612
  || safeString(instruction.split(/\r?\n/)[0], 120)
5215
5613
  || 'Remote task batch';
5216
- const batch = {
5614
+ const batch = {
5217
5615
  batchId,
5218
5616
  title,
5219
5617
  instructionPreview: safeText(instruction, 320),
5220
5618
  operation,
5221
5619
  targetQuery,
5222
- approvalLevel: normalizeApprovalLevel(options.approvalLevel),
5620
+ approvalLevel: normalizeApprovalLevel(options.approvalLevel),
5621
+ retryToolArguments: safeRetryToolArguments(operation, options.toolArguments),
5622
+ retryPermissionMode: normalizeAgentPermissionMode(operation, options.permissionMode),
5223
5623
  status: targets.length > 0 ? 'running' : 'failed',
5224
5624
  requestedAt: now,
5225
5625
  updatedAt: now,
@@ -5246,7 +5646,7 @@ export function createRemoteHub(options = {}) {
5246
5646
  }))
5247
5647
  };
5248
5648
 
5249
- taskBatches.set(batchId, batch);
5649
+ taskBatches.set(batchId, markOwnedTaskBatch(batch));
5250
5650
  trimTaskBatches();
5251
5651
  return batch;
5252
5652
  }
@@ -5391,11 +5791,11 @@ export function createRemoteHub(options = {}) {
5391
5791
  return null;
5392
5792
  }
5393
5793
 
5394
- const task = device.pendingTaskCommands.get(key);
5395
- if (!task) {
5396
- clearTaskTimeout(device, key);
5397
- return null;
5398
- }
5794
+ const task = device.pendingTaskCommands.get(key);
5795
+ if (!task || !isPendingAgentTaskOwner(device, task, key)) {
5796
+ clearTaskTimeout(device, key);
5797
+ return null;
5798
+ }
5399
5799
 
5400
5800
  const now = new Date().toISOString();
5401
5801
  clearTaskTimeout(device, key);
@@ -5912,62 +6312,66 @@ export function createRemoteHub(options = {}) {
5912
6312
  return;
5913
6313
  }
5914
6314
 
5915
- clearTaskTimeout(device, commandId);
5916
- const timer = setTimeout(() => {
5917
- failPendingTask(device, commandId, 'task-timeout');
5918
- }, taskTimeoutMs);
6315
+ clearTaskTimeout(device, commandId);
6316
+ const timer = setTimeout(() => {
6317
+ if (isPendingAgentTaskOwner(device, task, commandId)) {
6318
+ failPendingTask(device, commandId, 'task-timeout');
6319
+ }
6320
+ }, taskTimeoutMs);
5919
6321
  timer.unref?.();
5920
6322
  device.pendingTaskTimers.set(commandId, timer);
5921
6323
  }
5922
6324
 
5923
- function summarizeTaskResult(result) {
5924
- if (result && typeof result === 'object') {
5925
- return safeText(
5926
- result.summary
5927
- || result.message
5928
- || result.output
5929
- || result.result
5930
- || JSON.stringify(result),
5931
- MAX_AGENT_TASK_RESULT_CHARS);
5932
- }
5933
-
5934
- return safeText(result ?? '', MAX_AGENT_TASK_RESULT_CHARS);
6325
+ function summarizeTaskResult(result, diagnostic = false) {
6326
+ let summary;
6327
+ if (result && typeof result === 'object') {
6328
+ try {
6329
+ summary = result.summary
6330
+ || result.message
6331
+ || result.output
6332
+ || result.result
6333
+ || JSON.stringify(result);
6334
+ } catch {
6335
+ summary = '';
6336
+ }
6337
+ } else {
6338
+ summary = result ?? '';
6339
+ }
6340
+ return diagnostic
6341
+ ? safeDiagnosticText(summary, MAX_AGENT_TASK_RESULT_CHARS)
6342
+ : safeText(summary, MAX_AGENT_TASK_RESULT_CHARS);
5935
6343
  }
5936
6344
 
5937
- function applyTaskResult(device, commandId, result, error) {
5938
- if (!device || !commandId) {
5939
- return null;
5940
- }
5941
-
5942
- const resultTaskId = result && typeof result === 'object'
5943
- ? safeString(result.taskId, 128)
5944
- : '';
5945
- const task = resultTaskId
5946
- ? device.recentTasks.find(item => item.taskId === resultTaskId)
5947
- : device.pendingTaskCommands.get(commandId);
5948
- if (!task) {
5949
- return null;
5950
- }
5951
-
5952
- if (device.pendingTaskCommands.get(commandId) !== task) {
5953
- return null;
5954
- }
6345
+ function applyTaskResult(device, commandId, result, error, message = {}, channel = 'control') {
6346
+ if (!device || !commandId) {
6347
+ return null;
6348
+ }
6349
+ const task = device.pendingTaskCommands.get(commandId);
6350
+ if (!task || !taskResultMatchesOwner(device, task, commandId, result, message, channel)) {
6351
+ return null;
6352
+ }
5955
6353
 
5956
6354
  const now = device.lastSeenAt || new Date().toISOString();
5957
- const resultFailed = Boolean(error)
5958
- || result?.ok === false
5959
- || safeString(result?.status, 40) === 'failed';
5960
- const status = resultFailed ? 'failed' : safeString(result?.status, 40) || 'completed';
5961
- const resultError = safeString(error, 500)
5962
- || safeString(result?.error, 500)
5963
- || (resultFailed ? safeString(result?.summary, 500) || 'agent-task-failed' : '');
6355
+ const resultFailed = Boolean(error)
6356
+ || result?.ok === false
6357
+ || safeString(result?.status, 40) === 'failed';
6358
+ const status = resultFailed ? 'failed' : safeString(result?.status, 40) || 'completed';
6359
+ const diagnostic = DIAGNOSTIC_AGENT_OPERATIONS.has(task.operation);
6360
+ const safeResultError = value => diagnostic
6361
+ ? safeDiagnosticText(value, 500)
6362
+ : safeString(value, 500);
6363
+ const resultError = safeResultError(error)
6364
+ || safeResultError(result?.error)
6365
+ || (resultFailed ? safeResultError(result?.summary) || 'agent-task-failed' : '');
5964
6366
  clearTaskTimeout(device, commandId);
5965
6367
  task.status = status;
5966
6368
  task.updatedAt = now;
5967
6369
  task.completedAt = safeString(result?.completedAt, 80) || now;
5968
6370
  task.error = resultError;
5969
- task.resultSummary = resultError || summarizeTaskResult(result);
5970
- task.resultData = safeTaskData(result?.data);
6371
+ task.resultSummary = resultError || summarizeTaskResult(result, diagnostic);
6372
+ task.resultData = diagnostic
6373
+ ? safeDiagnosticTaskData(result?.data)
6374
+ : safeTaskData(result?.data);
5971
6375
  task.resultKind = safeString(result?.kind || result?.mode || 'agent-task', 80);
5972
6376
  device.latestTask = task;
5973
6377
  device.pendingTaskCommands.delete(commandId);
@@ -7686,7 +8090,7 @@ export function createRemoteHub(options = {}) {
7686
8090
  };
7687
8091
  }
7688
8092
 
7689
- function writeFrameTransportProofResponse(socket, result) {
8093
+ function writeFrameTransportProofResponse(socket, result) {
7690
8094
  const proof = result?.proof;
7691
8095
  if (!proof?.proofId) return;
7692
8096
  const accepted = result.proofAccepted === true;
@@ -7702,24 +8106,98 @@ export function createRemoteHub(options = {}) {
7702
8106
  streamPurpose: proof.streamPurpose,
7703
8107
  monitorIndex: proof.monitorIndex,
7704
8108
  ...(accepted ? {} : { error: safeString(result.error || 'frame-proof-rejected', 160) })
7705
- });
7706
- }
7707
-
7708
- function handleAgentBinaryFrame(socket, state, header, framePayload) {
8109
+ });
8110
+ }
8111
+
8112
+ function isCurrentAgentSocketOwner(socket, state, device) {
8113
+ if (!device?.connected
8114
+ || devices.get(device.deviceId) !== device
8115
+ // Both values are the same Hub-created immutable session owner
8116
+ // captured during hello. Exact comparison preserves the boundary
8117
+ // without running general-purpose text cleanup on every frame.
8118
+ || state?.sessionId !== device.sessionId) {
8119
+ return false;
8120
+ }
8121
+ if (state.inputOnly) return device.inputSocket === socket;
8122
+ if (state.frameOnly) return device.frameSocket === socket;
8123
+ if (state.audioOnly) return device.audioSocket === socket;
8124
+ if (state.fileOnly) return device.fileSocket === socket;
8125
+ return device.socket === socket;
8126
+ }
8127
+
8128
+ function getAgentMessageChannel(state) {
8129
+ if (state?.inputOnly) return 'input';
8130
+ if (state?.frameOnly) return 'frame';
8131
+ if (state?.audioOnly) return 'audio';
8132
+ if (state?.fileOnly) return 'file';
8133
+ return 'control';
8134
+ }
8135
+
8136
+ function failCloseMediaSideChannel(socket, state, reason, error = 'media-side-channel-message-not-supported') {
8137
+ const device = state?.device;
8138
+ if (device) {
8139
+ emitRemoteEvent('RemoteAgentMessageIgnored', device, {
8140
+ channel: getAgentMessageChannel(state),
8141
+ reason
8142
+ });
8143
+ }
8144
+ // Drain only this side-channel ingress. The main control owner and all
8145
+ // other side-channel owners remain untouched.
8146
+ closeAgentBinaryIngress(state, reason);
8147
+ writeJsonLineAndClose(socket, { type: 'error', error });
8148
+ }
8149
+
8150
+ function isAllowedMediaBinaryKind(state, frameKind) {
8151
+ if (state?.frameOnly === true) {
8152
+ return frameKind === 'thumbnail' || frameKind === 'stream' || frameKind === 'live';
8153
+ }
8154
+ if (state?.audioOnly === true) {
8155
+ return frameKind === 'audio' || frameKind === 'audio-status';
8156
+ }
8157
+ return true;
8158
+ }
8159
+
8160
+ function getBinaryFrameMaxBytes(frameKind) {
8161
+ if (frameKind === 'thumbnail') return MAX_THUMBNAIL_BINARY_BYTES;
8162
+ if (frameKind === 'audio' || frameKind === 'audio-status') return MAX_AUDIO_BINARY_BYTES;
8163
+ return MAX_STREAM_BINARY_BYTES;
8164
+ }
8165
+
8166
+ function handleAgentBinaryFrame(socket, state, header, framePayload) {
7709
8167
  if (!state.authenticated || !state.device) {
7710
8168
  writeJsonLine(socket, { type: 'error', error: 'hello-required' });
7711
8169
  socket.destroy();
7712
8170
  return;
7713
8171
  }
7714
-
7715
- const device = state.device;
7716
- if (state.inputOnly || state.fileOnly) {
7717
- writeJsonLine(socket, { type: 'error', error: 'input-channel-binary-not-supported' });
7718
- socket.destroy();
7719
- return;
7720
- }
7721
-
7722
- device.counters.messagesReceived += 1;
8172
+
8173
+ const device = state.device;
8174
+ if (!isCurrentAgentSocketOwner(socket, state, device)) {
8175
+ closeAgentBinaryIngress(state, 'stale-agent-socket-owner');
8176
+ socket.destroy();
8177
+ return;
8178
+ }
8179
+ if (state.inputOnly || state.fileOnly) {
8180
+ writeJsonLine(socket, { type: 'error', error: 'input-channel-binary-not-supported' });
8181
+ socket.destroy();
8182
+ return;
8183
+ }
8184
+
8185
+ const hasDeclaredFrameKind = header.frameKind !== undefined
8186
+ && header.frameKind !== null
8187
+ && header.frameKind !== '';
8188
+ const declaredFrameKind = hasDeclaredFrameKind
8189
+ ? normalizeMediaBinaryKind(header.frameKind)
8190
+ : '';
8191
+ const frameKind = hasDeclaredFrameKind
8192
+ ? declaredFrameKind
8193
+ : normalizeMediaBinaryKind(header.kind || header.frameType || '');
8194
+ if ((state.frameOnly || state.audioOnly)
8195
+ && (!frameKind || !isAllowedMediaBinaryKind(state, frameKind))) {
8196
+ failCloseMediaSideChannel(socket, state, 'media-side-channel-binary-kind-not-supported');
8197
+ return;
8198
+ }
8199
+
8200
+ device.counters.messagesReceived += 1;
7723
8201
  device.lastSeenAt = new Date().toISOString();
7724
8202
  if (state.frameOnly) {
7725
8203
  device.frameLastSeenAt = device.lastSeenAt;
@@ -7728,8 +8206,7 @@ export function createRemoteHub(options = {}) {
7728
8206
  device.audioLastSeenAt = device.lastSeenAt;
7729
8207
  }
7730
8208
 
7731
- const frameKind = safeString(header.frameKind || header.kind || header.frameType || '', 40).toLowerCase();
7732
- const binaryTransport = socket.__liveDeskRelayControl === true
8209
+ const binaryTransport = socket.__liveDeskRelayControl === true
7733
8210
  ? 'relay-binary'
7734
8211
  : state.binaryTransport === 'ws-binary'
7735
8212
  ? 'ws-binary'
@@ -7956,9 +8433,12 @@ export function createRemoteHub(options = {}) {
7956
8433
  return state.binaryIngress.enqueue(header, payload);
7957
8434
  }
7958
8435
 
7959
- function handleAgentMessage(socket, state, message) {
7960
- if (!message || typeof message !== 'object') {
7961
- return;
8436
+ function handleAgentMessage(socket, state, message) {
8437
+ if (!message || typeof message !== 'object') {
8438
+ if (state?.authenticated && (state.frameOnly || state.audioOnly)) {
8439
+ failCloseMediaSideChannel(socket, state, 'media-side-channel-json-value-not-supported', 'invalid-json');
8440
+ }
8441
+ return;
7962
8442
  }
7963
8443
 
7964
8444
  if (!state.authenticated) {
@@ -8019,9 +8499,10 @@ export function createRemoteHub(options = {}) {
8019
8499
  return;
8020
8500
  }
8021
8501
 
8022
- state.authenticated = true;
8023
- state.device = device;
8024
- state.inputOnly = true;
8502
+ state.authenticated = true;
8503
+ state.device = device;
8504
+ state.sessionId = device.sessionId;
8505
+ state.inputOnly = true;
8025
8506
  return;
8026
8507
  }
8027
8508
  if (channel === 'frame') {
@@ -8035,9 +8516,10 @@ export function createRemoteHub(options = {}) {
8035
8516
  return;
8036
8517
  }
8037
8518
 
8038
- state.authenticated = true;
8039
- state.device = device;
8040
- state.frameOnly = true;
8519
+ state.authenticated = true;
8520
+ state.device = device;
8521
+ state.sessionId = device.sessionId;
8522
+ state.frameOnly = true;
8041
8523
  return;
8042
8524
  }
8043
8525
  if (channel === 'audio') {
@@ -8046,9 +8528,10 @@ export function createRemoteHub(options = {}) {
8046
8528
  return;
8047
8529
  }
8048
8530
 
8049
- state.authenticated = true;
8050
- state.device = device;
8051
- state.audioOnly = true;
8531
+ state.authenticated = true;
8532
+ state.device = device;
8533
+ state.sessionId = device.sessionId;
8534
+ state.audioOnly = true;
8052
8535
  return;
8053
8536
  }
8054
8537
  if (channel === 'file') {
@@ -8057,9 +8540,10 @@ export function createRemoteHub(options = {}) {
8057
8540
  return;
8058
8541
  }
8059
8542
 
8060
- state.authenticated = true;
8061
- state.device = device;
8062
- state.fileOnly = true;
8543
+ state.authenticated = true;
8544
+ state.device = device;
8545
+ state.sessionId = device.sessionId;
8546
+ state.fileOnly = true;
8063
8547
  return;
8064
8548
  }
8065
8549
 
@@ -8068,21 +8552,23 @@ export function createRemoteHub(options = {}) {
8068
8552
  return;
8069
8553
  }
8070
8554
 
8071
- state.authenticated = true;
8072
- state.device = device;
8073
- return;
8074
- }
8075
-
8076
- const device = state.device;
8077
- if (!device) {
8078
- socket.destroy();
8079
- return;
8555
+ state.authenticated = true;
8556
+ state.device = device;
8557
+ state.sessionId = device.sessionId;
8558
+ return;
8080
8559
  }
8081
8560
 
8561
+ const device = state.device;
8562
+ if (!device) {
8563
+ socket.destroy();
8564
+ return;
8565
+ }
8566
+ if (!isCurrentAgentSocketOwner(socket, state, device)) {
8567
+ socket.destroy();
8568
+ return;
8569
+ }
8570
+
8082
8571
  if (state.inputOnly) {
8083
- if (device.inputSocket !== socket) {
8084
- return;
8085
- }
8086
8572
  device.inputLastSeenAt = new Date().toISOString();
8087
8573
  if (message.type === 'input.applied' || message.type === 'input.error') {
8088
8574
  acknowledgePendingDedicatedInput(device, message);
@@ -8092,21 +8578,50 @@ export function createRemoteHub(options = {}) {
8092
8578
  }, 'input');
8093
8579
  }
8094
8580
  return;
8095
- }
8096
- if (state.frameOnly) {
8097
- device.frameLastSeenAt = new Date().toISOString();
8098
- return;
8099
- }
8100
- if (state.audioOnly) {
8101
- device.audioLastSeenAt = new Date().toISOString();
8102
- return;
8103
- }
8581
+ }
8582
+ if (state.frameOnly) {
8583
+ switch (message.type) {
8584
+ case 'thumbnail.frame':
8585
+ case 'stream.open':
8586
+ case 'stream.frame': {
8587
+ const receivedAt = new Date().toISOString();
8588
+ device.counters.messagesReceived += 1;
8589
+ device.frameLastSeenAt = receivedAt;
8590
+ device.lastSeenAt = receivedAt;
8591
+ if (message.type === 'thumbnail.frame') {
8592
+ applyThumbnailFrame(device, message, message.data, 'json-base64-frame-channel');
8593
+ } else if (message.type === 'stream.open') {
8594
+ applyLiveStreamOpen(device, message, 'json-frame-channel');
8595
+ } else {
8596
+ applyLiveFrame(device, message, message.data, 'json-base64-frame-channel');
8597
+ }
8598
+ break;
8599
+ }
8600
+ default:
8601
+ failCloseMediaSideChannel(socket, state, 'frame-channel-message-not-supported', 'frame-channel-message-not-supported');
8602
+ break;
8603
+ }
8604
+ return;
8605
+ }
8606
+ if (state.audioOnly) {
8607
+ if (message.type !== 'audio.frame') {
8608
+ failCloseMediaSideChannel(socket, state, 'audio-channel-message-not-supported', 'audio-channel-message-not-supported');
8609
+ return;
8610
+ }
8611
+ const receivedAt = new Date().toISOString();
8612
+ device.counters.messagesReceived += 1;
8613
+ device.audioLastSeenAt = receivedAt;
8614
+ device.lastSeenAt = receivedAt;
8615
+ applyAudioFrame(device, message, message.data, 'json-base64-audio-channel');
8616
+ return;
8617
+ }
8104
8618
  if (state.fileOnly) {
8105
8619
  device.fileLastSeenAt = new Date().toISOString();
8106
8620
  }
8107
8621
 
8108
- device.counters.messagesReceived += 1;
8109
- device.lastSeenAt = new Date().toISOString();
8622
+ device.counters.messagesReceived += 1;
8623
+ device.lastSeenAt = new Date().toISOString();
8624
+ const channel = getAgentMessageChannel(state);
8110
8625
 
8111
8626
  switch (message.type) {
8112
8627
  case 'heartbeat':
@@ -8134,12 +8649,14 @@ export function createRemoteHub(options = {}) {
8134
8649
  message.error || (message.type === 'command.error' ? 'command-failed' : ''),
8135
8650
  500);
8136
8651
  const result = message.result ?? null;
8137
- settlePendingCommandResult(device, message);
8138
- handleInputFallbackCommandResult(device, message);
8139
- if (error || result?.ok === false || result?.status === 'failed') {
8140
- failPendingLiveStream(device, commandId, error || result?.error || 'stream-start-failed');
8141
- }
8142
- const task = applyTaskResult(device, commandId, result, error);
8652
+ settlePendingCommandResult(device, message, channel);
8653
+ if (channel === 'control') {
8654
+ handleInputFallbackCommandResult(device, message);
8655
+ if (error || result?.ok === false || result?.status === 'failed') {
8656
+ failPendingLiveStream(device, commandId, error || result?.error || 'stream-start-failed');
8657
+ }
8658
+ }
8659
+ const task = applyTaskResult(device, commandId, result, error, message, channel);
8143
8660
  if (task) {
8144
8661
  emitRemoteEvent('RemoteTaskResult', device, {
8145
8662
  commandId,
@@ -8155,8 +8672,16 @@ export function createRemoteHub(options = {}) {
8155
8672
  error: safeString(message.error, 500)
8156
8673
  });
8157
8674
  break;
8158
- case 'client.update.verified': {
8159
- const receivedAt = new Date().toISOString();
8675
+ case 'client.update.verified': {
8676
+ if (channel !== 'control') {
8677
+ emitRemoteEvent('RemoteAgentMessageIgnored', device, {
8678
+ channel,
8679
+ messageType: 'client.update.verified',
8680
+ reason: 'client-update-proof-control-channel-required'
8681
+ });
8682
+ break;
8683
+ }
8684
+ const receivedAt = new Date().toISOString();
8160
8685
  const proof = normalizeClientUpdateVerifiedProof(device, message, receivedAt);
8161
8686
  if (!proof) {
8162
8687
  logWarn(
@@ -8202,10 +8727,11 @@ export function createRemoteHub(options = {}) {
8202
8727
  socket.setNoDelay(true);
8203
8728
  socket.setKeepAlive(true, heartbeatMs);
8204
8729
 
8205
- const state = {
8206
- authenticated: false,
8207
- device: null,
8208
- binaryTransport: 'ws-binary',
8730
+ const state = {
8731
+ authenticated: false,
8732
+ device: null,
8733
+ sessionId: '',
8734
+ binaryTransport: 'ws-binary',
8209
8735
  inputOnly: false,
8210
8736
  frameOnly: false,
8211
8737
  audioOnly: false,
@@ -8225,33 +8751,56 @@ export function createRemoteHub(options = {}) {
8225
8751
  }
8226
8752
  }, 10000);
8227
8753
 
8228
- socket.onTextMessage = text => {
8229
- try {
8230
- handleAgentMessage(socket, state, parseJsonLine(String(text || '')));
8231
- } catch (err) {
8232
- writeJsonLine(socket, { type: 'error', error: 'invalid-json' });
8233
- logWarn('remote', `invalid websocket agent message: ${err?.message || err}`);
8234
- }
8754
+ socket.onTextMessage = text => {
8755
+ try {
8756
+ handleAgentMessage(socket, state, parseJsonLine(String(text || '')));
8757
+ } catch (err) {
8758
+ if (state.authenticated && (state.frameOnly || state.audioOnly)) {
8759
+ failCloseMediaSideChannel(socket, state, 'media-side-channel-malformed-json', 'invalid-json');
8760
+ return;
8761
+ }
8762
+ writeJsonLine(socket, { type: 'error', error: 'invalid-json' });
8763
+ logWarn('remote', `invalid websocket agent message: ${err?.message || err}`);
8764
+ }
8235
8765
  };
8236
8766
 
8237
8767
  socket.onBinaryMessage = payload => {
8238
8768
  try {
8239
8769
  const packet = parseRemoteHubWebSocketBinaryFrame(payload);
8240
- if (!packet?.header || !Buffer.isBuffer(packet.payload)) {
8241
- writeJsonLine(socket, { type: 'error', error: 'invalid-websocket-binary-frame' });
8242
- return;
8243
- }
8244
-
8245
- const frameKind = safeString(packet.header.frameKind || packet.header.kind || packet.header.frameType, 40).toLowerCase();
8246
- const maxBytes = frameKind === 'thumbnail'
8247
- ? MAX_THUMBNAIL_BINARY_BYTES
8248
- : MAX_STREAM_BINARY_BYTES;
8249
- const byteLength = Number(packet.header.byteLength ?? packet.header.payloadBytes ?? packet.payload.length);
8250
- if (!Number.isFinite(byteLength)
8251
- || byteLength < 1
8252
- || byteLength > maxBytes
8253
- || byteLength !== packet.payload.length) {
8254
- writeJsonLine(socket, { type: 'error', error: 'invalid-binary-frame' });
8770
+ if (!packet?.header || !Buffer.isBuffer(packet.payload)) {
8771
+ if (state.authenticated && (state.frameOnly || state.audioOnly)) {
8772
+ failCloseMediaSideChannel(socket, state, 'media-side-channel-malformed-binary', 'invalid-websocket-binary-frame');
8773
+ return;
8774
+ }
8775
+ writeJsonLine(socket, { type: 'error', error: 'invalid-websocket-binary-frame' });
8776
+ return;
8777
+ }
8778
+
8779
+ const hasDeclaredFrameKind = packet.header.frameKind !== undefined
8780
+ && packet.header.frameKind !== null
8781
+ && packet.header.frameKind !== '';
8782
+ const declaredFrameKind = hasDeclaredFrameKind
8783
+ ? normalizeMediaBinaryKind(packet.header.frameKind)
8784
+ : '';
8785
+ const frameKind = hasDeclaredFrameKind
8786
+ ? declaredFrameKind
8787
+ : normalizeMediaBinaryKind(packet.header.kind || packet.header.frameType);
8788
+ if ((state.frameOnly || state.audioOnly)
8789
+ && (!frameKind || !isAllowedMediaBinaryKind(state, frameKind))) {
8790
+ failCloseMediaSideChannel(socket, state, 'media-side-channel-binary-kind-not-supported');
8791
+ return;
8792
+ }
8793
+ const maxBytes = getBinaryFrameMaxBytes(frameKind);
8794
+ const byteLength = Number(packet.header.byteLength ?? packet.header.payloadBytes ?? packet.payload.length);
8795
+ if (!Number.isFinite(byteLength)
8796
+ || byteLength < 1
8797
+ || byteLength > maxBytes
8798
+ || byteLength !== packet.payload.length) {
8799
+ if (state.authenticated && (state.frameOnly || state.audioOnly)) {
8800
+ failCloseMediaSideChannel(socket, state, 'media-side-channel-invalid-binary-length', 'invalid-binary-frame');
8801
+ return;
8802
+ }
8803
+ writeJsonLine(socket, { type: 'error', error: 'invalid-binary-frame' });
8255
8804
  logWarn('remote', 'invalid websocket binary frame from agent.');
8256
8805
  return;
8257
8806
  }
@@ -8379,10 +8928,11 @@ export function createRemoteHub(options = {}) {
8379
8928
  socket.setNoDelay(true);
8380
8929
  socket.setKeepAlive(true, heartbeatMs);
8381
8930
 
8382
- const state = {
8383
- authenticated: false,
8384
- device: null,
8385
- binaryTransport: 'binary',
8931
+ const state = {
8932
+ authenticated: false,
8933
+ device: null,
8934
+ sessionId: '',
8935
+ binaryTransport: 'binary',
8386
8936
  inputOnly: false,
8387
8937
  frameOnly: false,
8388
8938
  audioOnly: false,
@@ -8448,24 +8998,14 @@ export function createRemoteHub(options = {}) {
8448
8998
  }
8449
8999
 
8450
9000
  if (startsWithTcpBinaryFrameMagic(state.buffer)) {
8451
- const packet = parseTcpBinaryFramePacket(state.buffer);
8452
- if (packet?.incomplete) {
8453
- return;
8454
- }
8455
- if (!packet || packet.invalid) {
8456
- const syncIndex = findTcpFrameOnlySyncIndex(state.buffer, 1);
8457
- if (syncIndex > 0) {
8458
- state.buffer.discard(syncIndex);
8459
- state.lineScanOffset = 0;
8460
- continue;
8461
- }
8462
-
8463
- const syncTail = keepTcpFrameOnlySyncTail(state.buffer);
8464
- state.buffer.clear();
8465
- state.buffer.append(syncTail);
8466
- state.lineScanOffset = 0;
8467
- return;
8468
- }
9001
+ const packet = parseTcpBinaryFramePacket(state.buffer);
9002
+ if (packet?.incomplete) {
9003
+ return;
9004
+ }
9005
+ if (!packet || packet.invalid) {
9006
+ failCloseMediaSideChannel(socket, state, 'media-side-channel-malformed-binary', 'invalid-binary-frame');
9007
+ return;
9008
+ }
8469
9009
 
8470
9010
  state.buffer.discard(packet.packetLength);
8471
9011
  state.lineScanOffset = 0;
@@ -8473,20 +9013,10 @@ export function createRemoteHub(options = {}) {
8473
9013
  continue;
8474
9014
  }
8475
9015
 
8476
- if (!isJsonObjectStartByte(state.buffer.byteAt(0))) {
8477
- const syncIndex = findTcpFrameOnlySyncIndex(state.buffer, 1);
8478
- if (syncIndex > 0) {
8479
- state.buffer.discard(syncIndex);
8480
- state.lineScanOffset = 0;
8481
- continue;
8482
- }
8483
-
8484
- const syncTail = keepTcpFrameOnlySyncTail(state.buffer);
8485
- state.buffer.clear();
8486
- state.buffer.append(syncTail);
8487
- state.lineScanOffset = 0;
8488
- return;
8489
- }
9016
+ if (!isJsonObjectStartByte(state.buffer.byteAt(0))) {
9017
+ failCloseMediaSideChannel(socket, state, 'media-side-channel-malformed-binary', 'invalid-binary-frame');
9018
+ return;
9019
+ }
8490
9020
  }
8491
9021
 
8492
9022
  const newlineIndex = state.buffer.indexOfByte(0x0a, state.lineScanOffset);
@@ -8508,18 +9038,33 @@ export function createRemoteHub(options = {}) {
8508
9038
  return;
8509
9039
  }
8510
9040
 
8511
- try {
8512
- const message = parseJsonLine(lineBuffer.toString('utf8'));
8513
- if (message?.type === 'frame.binary') {
8514
- const frameKind = safeString(message.frameKind || message.kind || message.frameType, 40).toLowerCase();
8515
- const maxBytes = frameKind === 'thumbnail'
8516
- ? MAX_THUMBNAIL_BINARY_BYTES
8517
- : MAX_STREAM_BINARY_BYTES;
8518
- const byteLength = Number(message.byteLength ?? message.payloadBytes ?? message.dataLength);
8519
- if (!Number.isFinite(byteLength) || byteLength < 1 || byteLength > maxBytes) {
8520
- writeJsonLine(socket, { type: 'error', error: 'invalid-binary-frame' });
8521
- logWarn('remote', 'invalid binary frame header from agent.');
8522
- continue;
9041
+ try {
9042
+ const message = parseJsonLine(lineBuffer.toString('utf8'));
9043
+ if (message?.type === 'frame.binary') {
9044
+ const hasDeclaredFrameKind = message.frameKind !== undefined
9045
+ && message.frameKind !== null
9046
+ && message.frameKind !== '';
9047
+ const declaredFrameKind = hasDeclaredFrameKind
9048
+ ? normalizeMediaBinaryKind(message.frameKind)
9049
+ : '';
9050
+ const frameKind = hasDeclaredFrameKind
9051
+ ? declaredFrameKind
9052
+ : normalizeMediaBinaryKind(message.kind || message.frameType);
9053
+ if ((state.frameOnly || state.audioOnly)
9054
+ && (!frameKind || !isAllowedMediaBinaryKind(state, frameKind))) {
9055
+ failCloseMediaSideChannel(socket, state, 'media-side-channel-binary-kind-not-supported');
9056
+ return;
9057
+ }
9058
+ const maxBytes = getBinaryFrameMaxBytes(frameKind);
9059
+ const byteLength = Number(message.byteLength ?? message.payloadBytes ?? message.dataLength);
9060
+ if (!Number.isFinite(byteLength) || byteLength < 1 || byteLength > maxBytes) {
9061
+ if (state.authenticated && (state.frameOnly || state.audioOnly)) {
9062
+ failCloseMediaSideChannel(socket, state, 'media-side-channel-invalid-binary-length', 'invalid-binary-frame');
9063
+ return;
9064
+ }
9065
+ writeJsonLine(socket, { type: 'error', error: 'invalid-binary-frame' });
9066
+ logWarn('remote', 'invalid binary frame header from agent.');
9067
+ continue;
8523
9068
  }
8524
9069
 
8525
9070
  state.pendingBinaryFrame = {
@@ -8529,11 +9074,12 @@ export function createRemoteHub(options = {}) {
8529
9074
  continue;
8530
9075
  }
8531
9076
 
8532
- handleAgentMessage(socket, state, message);
8533
- } catch (err) {
8534
- if (state.authenticated && (state.frameOnly || state.audioOnly)) {
8535
- continue;
8536
- }
9077
+ handleAgentMessage(socket, state, message);
9078
+ } catch (err) {
9079
+ if (state.authenticated && (state.frameOnly || state.audioOnly)) {
9080
+ failCloseMediaSideChannel(socket, state, 'media-side-channel-malformed-json', 'invalid-json');
9081
+ return;
9082
+ }
8537
9083
  writeJsonLine(socket, { type: 'error', error: 'invalid-json' });
8538
9084
  logWarn('remote', `invalid agent message: ${err?.message || err}`);
8539
9085
  }
@@ -8647,20 +9193,36 @@ export function createRemoteHub(options = {}) {
8647
9193
  });
8648
9194
  }
8649
9195
 
8650
- async function start() {
8651
- if (!enabled || started) {
8652
- return getStatus({ includeSecrets: false });
8653
- }
8654
-
8655
- try {
8656
- await udpTransport?.start?.();
8657
- await listenOnPort(requestedPort);
8658
- try {
8659
- await relayControl?.start?.();
8660
- } catch {
8661
- logWarn('relay', 'Encrypted relay control could not start; direct TCP remains available.');
8662
- }
8663
- scheduleDeviceConnectionSweep();
9196
+ async function start() {
9197
+ if (!enabled || started) {
9198
+ return getStatus({ includeSecrets: false });
9199
+ }
9200
+
9201
+ try {
9202
+ closing = false;
9203
+ await listenOnPort(requestedPort);
9204
+ // Direct TCP is the authoritative Client control path. UDP is an
9205
+ // optional frame optimization, so a temporary socket/DNS failure
9206
+ // must not keep the Hub offline after its Direct listener is ready.
9207
+ // UDP is optional and may be delayed by a sleeping adapter or a
9208
+ // transient bind failure. Its exact promise remains owned for
9209
+ // retry and shutdown, but it must never hold the Hub HTTP/PWA
9210
+ // startup path behind the already-listening Direct TCP socket.
9211
+ void startOptionalUdpTransport('startup');
9212
+ // Product relay status is permanently disabled. Do not even call
9213
+ // its dormant start owner; only explicitly injected retired-protocol
9214
+ // tests can expose enabled=true.
9215
+ if (relayControl?.getStatus?.().enabled === true) {
9216
+ try {
9217
+ await relayControl?.start?.();
9218
+ } catch {
9219
+ logWarn('relay', 'Encrypted relay control could not start; direct TCP remains available.');
9220
+ }
9221
+ }
9222
+ if (closing || !started) {
9223
+ return getStatus({ includeSecrets: false });
9224
+ }
9225
+ scheduleDeviceConnectionSweep();
8664
9226
  } catch (error) {
8665
9227
  if (error?.code === 'EADDRINUSE') {
8666
9228
  const conflict = new Error(
@@ -8677,8 +9239,14 @@ export function createRemoteHub(options = {}) {
8677
9239
  return getStatus({ includeSecrets: false });
8678
9240
  }
8679
9241
 
8680
- async function close() {
8681
- if (deviceConnectionSweepTimer) {
9242
+ async function close() {
9243
+ closing = true;
9244
+ if (udpStartRetryTimer) {
9245
+ clearTimeout(udpStartRetryTimer);
9246
+ udpStartRetryTimer = null;
9247
+ }
9248
+ const pendingUdpStart = udpStartPromise;
9249
+ if (deviceConnectionSweepTimer) {
8682
9250
  clearTimeout(deviceConnectionSweepTimer);
8683
9251
  deviceConnectionSweepTimer = null;
8684
9252
  }
@@ -8690,9 +9258,12 @@ export function createRemoteHub(options = {}) {
8690
9258
  abortTransportDiagnosticsForDevice(device, 'hub-shutdown');
8691
9259
  failAllPendingTasks(device, 'hub-shutdown');
8692
9260
  failAllPendingInputFallbacks(device, 'hub-shutdown');
8693
- failPendingCommandResultWaiters(device, 'hub-shutdown');
8694
- closeInputSocket(device, 'hub-shutdown');
8695
- if (device.socket && !device.socket.destroyed) {
9261
+ failPendingCommandResultWaiters(device, 'hub-shutdown');
9262
+ closeInputSocket(device, 'hub-shutdown');
9263
+ closeFrameSocket(device, 'hub-shutdown');
9264
+ closeAudioSocket(device, 'hub-shutdown');
9265
+ closeFileSocket(device, 'hub-shutdown');
9266
+ if (device.socket && !device.socket.destroyed) {
8696
9267
  writeJsonLine(device.socket, { type: 'disconnect', reason: 'hub-shutdown' });
8697
9268
  device.socket.destroy();
8698
9269
  }
@@ -8703,10 +9274,13 @@ export function createRemoteHub(options = {}) {
8703
9274
  socket.destroy();
8704
9275
  }
8705
9276
  }
8706
-
8707
- sockets.clear();
8708
- await relayControl?.close?.();
8709
- await udpTransport?.close?.();
9277
+
9278
+ sockets.clear();
9279
+ await relayControl?.close?.();
9280
+ if (pendingUdpStart) {
9281
+ await pendingUdpStart;
9282
+ }
9283
+ await udpTransport?.close?.();
8710
9284
  if (!server) {
8711
9285
  started = false;
8712
9286
  return;
@@ -8840,7 +9414,7 @@ export function createRemoteHub(options = {}) {
8840
9414
  : '';
8841
9415
  const denied = policyError(device, requiredPermission, commandName);
8842
9416
  if (denied) return { ok: false, error: denied };
8843
- if (device?.synthetic === true && device.connected) {
9417
+ if (device?.synthetic === true && device.connected) {
8844
9418
  const commandId = safeString(command?.commandId, 128) || crypto.randomUUID();
8845
9419
  device.counters.commandsSent += 1;
8846
9420
  device.lastSeenAt = new Date().toISOString();
@@ -8885,12 +9459,17 @@ export function createRemoteHub(options = {}) {
8885
9459
  && !device.fileSocket.destroyed
8886
9460
  ? device.fileSocket
8887
9461
  : null;
8888
- try {
8889
- writeJsonLine(dedicatedFileSocket || device.socket, payload);
8890
- } catch {
8891
- retireStaleDeviceConnection(device, 'command-channel-write-failed');
8892
- return { ok: false, error: 'device-not-connected' };
8893
- }
9462
+ let sent = false;
9463
+ try {
9464
+ sent = writeJsonLine(dedicatedFileSocket || device.socket, payload);
9465
+ } catch {
9466
+ retireStaleDeviceConnection(device, 'command-channel-write-failed');
9467
+ return { ok: false, error: 'device-not-connected' };
9468
+ }
9469
+ if (!sent) {
9470
+ retireStaleDeviceConnection(device, 'command-channel-write-failed');
9471
+ return { ok: false, error: 'device-not-connected' };
9472
+ }
8894
9473
  device.counters.commandsSent += 1;
8895
9474
  emitRemoteEvent('RemoteCommandQueued', device, {
8896
9475
  commandId,
@@ -9731,14 +10310,23 @@ export function createRemoteHub(options = {}) {
9731
10310
  }
9732
10311
  const device = devices.get(String(deviceId || ''));
9733
10312
  const requestedOperation = safeString(options.operation, 80);
9734
- const operation = normalizeAgentOperation(requestedOperation);
9735
- if (requestedOperation && !operation) {
9736
- return { ok: false, error: 'unsupported-agent-operation' };
9737
- }
9738
- if (!device) {
9739
- return { ok: false, error: 'device-not-found' };
9740
- }
9741
- const denied = policyError(device, 'allowAgent');
10313
+ const operation = normalizeAgentOperation(requestedOperation);
10314
+ if (requestedOperation && !operation) {
10315
+ return { ok: false, error: 'unsupported-agent-operation' };
10316
+ }
10317
+ const normalizedToolArguments = normalizeAgentToolArguments(operation, options.toolArguments);
10318
+ if (!normalizedToolArguments.ok) {
10319
+ return { ok: false, error: normalizedToolArguments.error };
10320
+ }
10321
+ const toolArguments = normalizedToolArguments.value;
10322
+ const permissionMode = normalizeAgentPermissionMode(operation, options.permissionMode);
10323
+ if (!device) {
10324
+ return { ok: false, error: 'device-not-found' };
10325
+ }
10326
+ if (operation && !deviceSupportsAgentOperation(device, operation)) {
10327
+ return { ok: false, error: 'agent-operation-not-advertised' };
10328
+ }
10329
+ const denied = policyError(device, 'allowAgent');
9742
10330
  if (denied) return { ok: false, error: denied };
9743
10331
 
9744
10332
  if (device?.synthetic === true && device.connected) {
@@ -9749,21 +10337,21 @@ export function createRemoteHub(options = {}) {
9749
10337
 
9750
10338
  const now = new Date().toISOString();
9751
10339
  const approvalLevel = normalizeApprovalLevel(options.approvalLevel);
9752
- const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
9753
- const taskId = safeString(options.taskId, 128) || crypto.randomUUID();
10340
+ const commandId = crypto.randomUUID();
10341
+ const taskId = crypto.randomUUID();
10342
+ const batch = getOwnedTaskBatch(options);
9754
10343
  const title = safeString(options.title, 120)
9755
10344
  || safeString(instruction.split(/\r?\n/)[0], 120)
9756
10345
  || 'Remote task';
9757
10346
  const task = {
9758
- batchId: safeString(options.batchId, 128),
10347
+ batchId: batch?.batchId || '',
9759
10348
  taskId,
9760
10349
  commandId,
9761
10350
  title,
9762
- operation,
9763
- targetQuery: safeText(options.targetQuery, 160),
9764
- toolArguments: options.toolArguments && typeof options.toolArguments === 'object' ? options.toolArguments : {},
9765
- permissionMode: safeString(options.permissionMode, 40) || 'ask',
9766
- instructionPreview: safeText(instruction, 320),
10351
+ operation,
10352
+ targetQuery: safeText(options.targetQuery, 160),
10353
+ permissionMode,
10354
+ instructionPreview: safeText(instruction, 320),
9767
10355
  status: 'completed',
9768
10356
  stage: 'Completed',
9769
10357
  approvalLevel,
@@ -9776,8 +10364,14 @@ export function createRemoteHub(options = {}) {
9776
10364
  resultSummary: operation === 'process.list'
9777
10365
  ? 'Process list collected.'
9778
10366
  : `Synthetic task accepted by ${device.deviceName}.`,
9779
- resultData: undefined
9780
- };
10367
+ resultData: undefined
10368
+ };
10369
+ Object.defineProperty(task, 'owner', {
10370
+ value: createAgentTaskOwner(device, taskId, commandId, operation, task.batchId),
10371
+ enumerable: false,
10372
+ configurable: false,
10373
+ writable: false
10374
+ });
9781
10375
 
9782
10376
  device.counters.commandsSent += 1;
9783
10377
  device.counters.commandResultsReceived += 1;
@@ -9808,9 +10402,12 @@ export function createRemoteHub(options = {}) {
9808
10402
  retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
9809
10403
  return { ok: false, error: 'device-not-connected' };
9810
10404
  }
9811
- if (!device?.socket || device.socket.destroyed || !device.connected) {
9812
- return { ok: false, error: 'device-not-connected' };
9813
- }
10405
+ if (!device?.socket || device.socket.destroyed || !device.connected) {
10406
+ return { ok: false, error: 'device-not-connected' };
10407
+ }
10408
+ if (!canQueueAgentTask(device)) {
10409
+ return { ok: false, error: 'agent-task-capacity-reached' };
10410
+ }
9814
10411
 
9815
10412
  const instruction = safeText(options.instruction, MAX_AGENT_TASK_CHARS);
9816
10413
  if (!instruction) {
@@ -9819,21 +10416,21 @@ export function createRemoteHub(options = {}) {
9819
10416
 
9820
10417
  const now = new Date().toISOString();
9821
10418
  const approvalLevel = normalizeApprovalLevel(options.approvalLevel);
9822
- const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
9823
- const taskId = safeString(options.taskId, 128) || crypto.randomUUID();
10419
+ const commandId = crypto.randomUUID();
10420
+ const taskId = crypto.randomUUID();
10421
+ const batch = getOwnedTaskBatch(options);
9824
10422
  const title = safeString(options.title, 120)
9825
10423
  || safeString(instruction.split(/\r?\n/)[0], 120)
9826
10424
  || 'Remote task';
9827
10425
  const task = {
9828
- batchId: safeString(options.batchId, 128),
10426
+ batchId: batch?.batchId || '',
9829
10427
  taskId,
9830
10428
  commandId,
9831
10429
  title,
9832
- operation,
9833
- targetQuery: safeText(options.targetQuery, 160),
9834
- toolArguments: options.toolArguments && typeof options.toolArguments === 'object' ? options.toolArguments : {},
9835
- permissionMode: safeString(options.permissionMode, 40) || 'ask',
9836
- instructionPreview: safeText(instruction, 320),
10430
+ operation,
10431
+ targetQuery: safeText(options.targetQuery, 160),
10432
+ permissionMode,
10433
+ instructionPreview: safeText(instruction, 320),
9837
10434
  status: 'queued',
9838
10435
  stage: operation === 'process.list' ? 'Checking processes' : 'Queued',
9839
10436
  approvalLevel,
@@ -9844,8 +10441,14 @@ export function createRemoteHub(options = {}) {
9844
10441
  error: '',
9845
10442
  resultKind: '',
9846
10443
  resultSummary: '',
9847
- resultData: undefined
9848
- };
10444
+ resultData: undefined
10445
+ };
10446
+ Object.defineProperty(task, 'owner', {
10447
+ value: createAgentTaskOwner(device, taskId, commandId, operation, task.batchId),
10448
+ enumerable: false,
10449
+ configurable: false,
10450
+ writable: false
10451
+ });
9849
10452
 
9850
10453
  const commandName = operation || 'agent.task';
9851
10454
  const sent = writeJsonLine(device.socket, {
@@ -9856,11 +10459,11 @@ export function createRemoteHub(options = {}) {
9856
10459
  taskId,
9857
10460
  title,
9858
10461
  instruction,
9859
- operation,
9860
- targetQuery: safeText(options.targetQuery, 160),
9861
- toolArguments: options.toolArguments && typeof options.toolArguments === 'object' ? options.toolArguments : {},
9862
- permissionMode: safeString(options.permissionMode, 40) || 'ask',
9863
- approvalLevel,
10462
+ operation,
10463
+ targetQuery: safeText(options.targetQuery, 160),
10464
+ toolArguments,
10465
+ permissionMode,
10466
+ approvalLevel,
9864
10467
  requestedAt: now
9865
10468
  },
9866
10469
  issuedAt: now
@@ -9902,23 +10505,52 @@ export function createRemoteHub(options = {}) {
9902
10505
  };
9903
10506
  }
9904
10507
 
9905
- if (targets.length === 0) {
10508
+ if (targets.length === 0) {
9906
10509
  return {
9907
10510
  ok: false,
9908
10511
  error: 'no-target-devices',
9909
10512
  total: 0,
9910
10513
  queued: 0,
9911
10514
  approvalLevel: normalizeApprovalLevel(options.approvalLevel),
9912
- results: []
9913
- };
9914
- }
9915
-
9916
- const batch = beginTaskBatch(targets, options);
9917
- const results = targets.map(deviceId => {
9918
- const result = requestAgentTask(deviceId, {
9919
- ...options,
9920
- batchId: batch.batchId
9921
- });
10515
+ results: []
10516
+ };
10517
+ }
10518
+
10519
+ const requestedOperation = safeString(options.operation, 80);
10520
+ const operation = normalizeAgentOperation(requestedOperation);
10521
+ if (requestedOperation && !operation) {
10522
+ return {
10523
+ ok: false,
10524
+ error: 'unsupported-agent-operation',
10525
+ total: targets.length,
10526
+ queued: 0,
10527
+ approvalLevel: normalizeApprovalLevel(options.approvalLevel),
10528
+ results: []
10529
+ };
10530
+ }
10531
+ const normalizedToolArguments = normalizeAgentToolArguments(operation, options.toolArguments);
10532
+ if (!normalizedToolArguments.ok) {
10533
+ return {
10534
+ ok: false,
10535
+ error: normalizedToolArguments.error,
10536
+ total: targets.length,
10537
+ queued: 0,
10538
+ approvalLevel: normalizeApprovalLevel(options.approvalLevel),
10539
+ results: []
10540
+ };
10541
+ }
10542
+ const normalizedOptions = {
10543
+ ...options,
10544
+ operation,
10545
+ toolArguments: normalizedToolArguments.value,
10546
+ permissionMode: normalizeAgentPermissionMode(operation, options.permissionMode)
10547
+ };
10548
+ const batch = beginTaskBatch(targets, normalizedOptions);
10549
+ const results = targets.map(deviceId => {
10550
+ const result = requestAgentTask(deviceId, {
10551
+ ...normalizedOptions,
10552
+ taskBatch: batch
10553
+ });
9922
10554
 
9923
10555
  if (result.ok !== true) {
9924
10556
  syncTaskBatchDispatchFailure(batch.batchId, deviceId, result.error, result);
@@ -10638,7 +11270,7 @@ export function createRemoteHub(options = {}) {
10638
11270
  === normalized.streamPurpose;
10639
11271
  }
10640
11272
 
10641
- function sharedLiveProfileResult(device, activeLiveStream, normalized, extra = {}) {
11273
+ function sharedLiveProfileResult(device, activeLiveStream, normalized, extra = {}) {
10642
11274
  return {
10643
11275
  ok: true,
10644
11276
  commandId: activeLiveStream.commandId,
@@ -10667,9 +11299,40 @@ export function createRemoteHub(options = {}) {
10667
11299
  },
10668
11300
  ...extra
10669
11301
  };
10670
- }
10671
-
10672
- function startLiveStream(deviceId, options = {}) {
11302
+ }
11303
+
11304
+ function readOnlyControlPresentationBorrowResult(device, normalized, options = {}) {
11305
+ const borrowed = resolveReadOnlyControlPresentationBorrow({
11306
+ device,
11307
+ liveOptions: {
11308
+ allowReadOnlyControlBorrow: options.allowReadOnlyControlBorrow === true,
11309
+ streamPurpose: normalized.streamPurpose,
11310
+ mode: normalized.transfer.mode,
11311
+ frameMode: normalized.transfer.frameMode,
11312
+ fps: normalized.fps,
11313
+ maxWidth: normalized.maxWidth,
11314
+ maxHeight: normalized.maxHeight,
11315
+ quality: normalized.quality
11316
+ },
11317
+ monitorIndex: normalized.monitorIndex
11318
+ });
11319
+ if (!borrowed) {
11320
+ return null;
11321
+ }
11322
+ emitRemoteEvent('RemoteLiveStreamControlPresentationBorrowed', device, {
11323
+ streamId: borrowed.streamId,
11324
+ commandId: borrowed.commandId,
11325
+ captureGeneration: borrowed.captureGeneration,
11326
+ streamPurpose: borrowed.streamPurpose,
11327
+ presentationPurpose: borrowed.presentationPurpose,
11328
+ monitorIndex: borrowed.monitorIndex,
11329
+ requestedProfile: borrowed.requestedProfile,
11330
+ effectiveProfile: borrowed.effectiveProfile
11331
+ });
11332
+ return borrowed;
11333
+ }
11334
+
11335
+ function startLiveStream(deviceId, options = {}) {
10673
11336
  const device = devices.get(String(deviceId || ''));
10674
11337
  const denied = policyError(device);
10675
11338
  if (denied) return { ok: false, error: denied };
@@ -10693,11 +11356,13 @@ export function createRemoteHub(options = {}) {
10693
11356
  }
10694
11357
 
10695
11358
  const now = new Date().toISOString();
10696
- const normalized = normalizeLiveStreamStartOptions({ fps: 2, ...options, platform: device.platform });
10697
- const modePolicyError = liveStreamModePolicyError(normalized);
10698
- if (modePolicyError) return { ok: false, error: modePolicyError };
10699
- const { fps, maxWidth, maxHeight, quality, monitorIndex, transfer, streamPurpose } = normalized;
10700
- const streamId = safeString(options.streamId, 128) || makeStableLiveStreamId(deviceId, streamPurpose);
11359
+ const normalized = normalizeLiveStreamStartOptions({ fps: 2, ...options, platform: device.platform });
11360
+ const modePolicyError = liveStreamModePolicyError(normalized);
11361
+ if (modePolicyError) return { ok: false, error: modePolicyError };
11362
+ const { fps, maxWidth, maxHeight, quality, monitorIndex, transfer, streamPurpose } = normalized;
11363
+ const borrowedControl = readOnlyControlPresentationBorrowResult(device, normalized, options);
11364
+ if (borrowedControl) return { ...borrowedControl, synthetic: true };
11365
+ const streamId = safeString(options.streamId, 128) || makeStableLiveStreamId(deviceId, streamPurpose);
10701
11366
  if (!reserveDeviceLiveStreamDescriptor(device, streamId)) {
10702
11367
  return {
10703
11368
  ok: false,
@@ -10776,12 +11441,13 @@ export function createRemoteHub(options = {}) {
10776
11441
  monitorIndex,
10777
11442
  fps
10778
11443
  });
10779
- emitRemoteEvent('RemoteLiveStreamStarted', device, {
10780
- streamId,
10781
- commandId,
10782
- fps,
10783
- mode: transfer.mode,
10784
- synthetic: true
11444
+ emitRemoteEvent('RemoteLiveStreamStarted', device, {
11445
+ streamId,
11446
+ commandId,
11447
+ streamPurpose,
11448
+ fps,
11449
+ mode: transfer.mode,
11450
+ synthetic: true
10785
11451
  });
10786
11452
  return {
10787
11453
  ok: true,
@@ -10811,14 +11477,16 @@ export function createRemoteHub(options = {}) {
10811
11477
  const modePolicyError = liveStreamModePolicyError(normalized);
10812
11478
  if (modePolicyError) return { ok: false, error: modePolicyError };
10813
11479
  const { fps, maxWidth, maxHeight, quality, monitorIndex, transfer, streamPurpose } = normalized;
10814
- if (deviceExplicitlyRejectsFrameMode(device, transfer.frameMode)) {
10815
- return {
10816
- ok: false,
10817
- error: 'device-frame-mode-unavailable',
10818
- frameMode: transfer.frameMode
10819
- };
10820
- }
10821
- const streamId = safeString(options.streamId, 128) || makeStableLiveStreamId(deviceId, streamPurpose);
11480
+ if (deviceExplicitlyRejectsFrameMode(device, transfer.frameMode)) {
11481
+ return {
11482
+ ok: false,
11483
+ error: 'device-frame-mode-unavailable',
11484
+ frameMode: transfer.frameMode
11485
+ };
11486
+ }
11487
+ const borrowedControl = readOnlyControlPresentationBorrowResult(device, normalized, options);
11488
+ if (borrowedControl) return borrowedControl;
11489
+ const streamId = safeString(options.streamId, 128) || makeStableLiveStreamId(deviceId, streamPurpose);
10822
11490
  if (!reserveDeviceLiveStreamDescriptor(device, streamId)) {
10823
11491
  return {
10824
11492
  ok: false,
@@ -10875,9 +11543,9 @@ export function createRemoteHub(options = {}) {
10875
11543
  }
10876
11544
  const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
10877
11545
  const restartToken = safeString(options.restartToken, 128);
10878
- if (activeLiveStream
10879
- && options.forceRestart === true
10880
- && restartToken
11546
+ if (activeLiveStream
11547
+ && options.forceRestart === true
11548
+ && restartToken
10881
11549
  && safeString(activeLiveStream.restartToken, 128) === restartToken
10882
11550
  && liveStreamMatchesOptions(activeLiveStream, normalized)
10883
11551
  && liveStreamIsReusable(activeLiveStream)) {
@@ -10900,11 +11568,11 @@ export function createRemoteHub(options = {}) {
10900
11568
  captureGeneration: Number(activeLiveStream.captureGeneration || 0),
10901
11569
  ready: liveStreamHasCurrentFrame(activeLiveStream),
10902
11570
  pending: activeLiveStream.open !== true,
10903
- reused: true
10904
- };
10905
- }
10906
- if (activeLiveStream
10907
- && options.forceRestart !== true
11571
+ reused: true
11572
+ };
11573
+ }
11574
+ if (activeLiveStream
11575
+ && options.forceRestart !== true
10908
11576
  && options.reuseExisting === true
10909
11577
  && liveStreamMatchesOptions(activeLiveStream, normalized)
10910
11578
  && liveStreamIsReusable(activeLiveStream)) {
@@ -11084,11 +11752,12 @@ export function createRemoteHub(options = {}) {
11084
11752
  setDeviceLiveStream(device, nextStreamState);
11085
11753
  }
11086
11754
  device.counters.liveStreamsStarted += 1;
11087
- emitRemoteEvent('RemoteLiveStreamStarted', device, {
11088
- streamId,
11089
- commandId,
11090
- previousCommandId,
11091
- pending: replacingActiveStream,
11755
+ emitRemoteEvent('RemoteLiveStreamStarted', device, {
11756
+ streamId,
11757
+ commandId,
11758
+ streamPurpose,
11759
+ previousCommandId,
11760
+ pending: replacingActiveStream,
11092
11761
  fps,
11093
11762
  mode: transfer.mode,
11094
11763
  frameMode: transfer.frameMode,
@@ -11277,11 +11946,13 @@ export function createRemoteHub(options = {}) {
11277
11946
  captureLiveStreamStopOwner(device, streamState, commandId));
11278
11947
  }
11279
11948
  device.counters.liveStreamsStopped += 1;
11280
- emitRemoteEvent('RemoteLiveStreamStopped', device, {
11281
- streamId,
11282
- commandId,
11283
- synthetic: true
11284
- });
11949
+ emitRemoteEvent('RemoteLiveStreamStopped', device, {
11950
+ streamId,
11951
+ commandId,
11952
+ streamPurpose: safeString(streamState?.streamPurpose || streamPurpose, 24).toLowerCase() || 'wall',
11953
+ captureStopConfirmed: true,
11954
+ synthetic: true
11955
+ });
11285
11956
  return { ok: true, commandId, streamId, synthetic: true };
11286
11957
  }
11287
11958
 
@@ -11316,7 +11987,7 @@ export function createRemoteHub(options = {}) {
11316
11987
  : (streamState ? [streamState] : []);
11317
11988
  const stopRequestedAt = new Date().toISOString();
11318
11989
  const targetOwners = [];
11319
- for (const targetStream of targetStreams) {
11990
+ for (const targetStream of targetStreams) {
11320
11991
  const pendingTarget = getPendingLiveStreamDescriptor(targetStream);
11321
11992
  const targetDescriptor = pendingTarget || targetStream;
11322
11993
  const targetOwnerKind = pendingTarget ? 'pending' : 'active';
@@ -11340,10 +12011,19 @@ export function createRemoteHub(options = {}) {
11340
12011
  targetOwners.push({
11341
12012
  owner: targetOwner,
11342
12013
  ownerKind: targetOwnerKind
11343
- });
11344
- }
11345
- }
11346
- const stopPromise = acknowledgementPromise.then(acknowledgement => {
12014
+ });
12015
+ }
12016
+ }
12017
+ const stoppedStreamPurposes = new Set(
12018
+ targetOwners
12019
+ .map(targetRecord => safeString(targetRecord.owner?.streamPurpose, 24).toLowerCase())
12020
+ .filter(Boolean));
12021
+ const stoppedStreamPurpose = stoppedStreamPurposes.size === 1
12022
+ ? [...stoppedStreamPurposes][0]
12023
+ : targetOwners.length === 0
12024
+ ? safeString(streamState?.streamPurpose || streamPurpose, 24).toLowerCase()
12025
+ : '';
12026
+ const stopPromise = acknowledgementPromise.then(acknowledgement => {
11347
12027
  const currentDevice = devices.get(String(deviceId || ''));
11348
12028
  const stopReported = acknowledgement.result?.stream === true
11349
12029
  && acknowledgement.result?.stopped === true;
@@ -11411,11 +12091,12 @@ export function createRemoteHub(options = {}) {
11411
12091
  if (captureStopConfirmed
11412
12092
  && (targetOwners.length === 0 || appliedOwnerCount > 0)) {
11413
12093
  currentDevice.counters.liveStreamsStopped += 1;
11414
- emitRemoteEvent('RemoteLiveStreamStopped', currentDevice, {
11415
- streamId,
11416
- commandId,
11417
- appliedOwnerCount,
11418
- captureStopConfirmed: true
12094
+ emitRemoteEvent('RemoteLiveStreamStopped', currentDevice, {
12095
+ streamId,
12096
+ commandId,
12097
+ streamPurpose: stoppedStreamPurpose,
12098
+ appliedOwnerCount,
12099
+ captureStopConfirmed: true
11419
12100
  });
11420
12101
  } else if (!captureStopConfirmed && appliedOwnerCount > 0) {
11421
12102
  emitRemoteEvent('RemoteLiveStreamStopUnconfirmed', currentDevice, {