@livedesk/hub 0.1.61 → 0.1.65

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
@@ -56,9 +56,13 @@ const MAX_AGENT_SOCKET_ACCUMULATOR_BYTES = MAX_LINE_CHARS
56
56
  const HTTP_HEADER_TERMINATOR = Buffer.from('\r\n\r\n', 'ascii');
57
57
  const MAX_AGENT_TASK_CHARS = 4000;
58
58
  const MAX_AGENT_TASK_RESULT_CHARS = 3000;
59
- const MAX_AGENT_TASK_DATA_CHARS = 64 * 1024;
60
- const RECENT_TASK_LIMIT = 12;
61
- 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);
62
66
  const RECENT_FRAME_CACHE_TTL_MS = 4000;
63
67
  const RECENT_THUMBNAIL_FRAME_CACHE_LIMIT = 2;
64
68
  const RECENT_LIVE_FRAME_CACHE_LIMIT = 1;
@@ -68,6 +72,7 @@ const LIVE_STREAM_PENDING_REUSE_MS = 5000;
68
72
  const LIVE_STREAM_REPLACEMENT_PENDING_MS = 7000;
69
73
  const LIVE_STREAM_MIN_FRESH_MS = 3000;
70
74
  const LIVE_STREAM_MAX_FRESH_MS = 12000;
75
+ const TASK_BATCH_OWNER = Symbol('remote-hub-task-batch-owner');
71
76
  const DEFAULT_REMOTE_INPUT_ACK_TIMEOUT_MS = 5000;
72
77
  // RemoteFast owns a shared 7s native stop deadline. Keep four seconds for a
73
78
  // priority command.result to cross either Direct or encrypted Relay transport.
@@ -305,9 +310,17 @@ function normalizePort(value) {
305
310
  return clampNumber(value, 0, 65535, DEFAULT_REMOTE_HUB_PORT);
306
311
  }
307
312
 
308
- function safeString(value, maxLength = 200) {
309
- return String(value ?? '').replace(/[\r\n\t]/g, ' ').trim().slice(0, maxLength);
310
- }
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
+ }
311
324
 
312
325
  function remoteInputPayloadType(payload) {
313
326
  return safeString(payload?.payload?.type ?? payload?.type, 48).toLowerCase();
@@ -1224,16 +1237,131 @@ function normalizeRemoteKeyboardText(value) {
1224
1237
  return raw.slice(0, 256);
1225
1238
  }
1226
1239
 
1227
- function safeTaskData(value) {
1228
- if (value === undefined || value === null) return undefined;
1229
- try {
1230
- const serialized = JSON.stringify(value);
1231
- if (serialized.length <= MAX_AGENT_TASK_DATA_CHARS) return value;
1232
- return { truncated: true, byteLength: Buffer.byteLength(serialized, 'utf8'), preview: serialized.slice(0, MAX_AGENT_TASK_DATA_CHARS) };
1233
- } catch {
1234
- return { truncated: true };
1235
- }
1236
- }
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
+ }
1237
1365
 
1238
1366
  function normalizeApprovalLevel(value) {
1239
1367
  const level = safeString(value, 80).toLowerCase();
@@ -1282,10 +1410,20 @@ const SUPPORTED_AGENT_OPERATIONS = new Set([
1282
1410
  'logs.collect'
1283
1411
  ]);
1284
1412
 
1285
- function normalizeAgentOperation(value) {
1413
+ function normalizeAgentOperation(value) {
1286
1414
  const operation = safeString(value, 80).toLowerCase();
1287
1415
  return SUPPORTED_AGENT_OPERATIONS.has(operation) ? operation : '';
1288
- }
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
+ }
1289
1427
 
1290
1428
  const REMOTE_INPUT_MONITOR_KEYS = Object.freeze([
1291
1429
  'monitorIndex',
@@ -1661,7 +1799,23 @@ function findRecentFramePayload(device, frameKind, options = {}) {
1661
1799
  return entry?.frame || null;
1662
1800
  }
1663
1801
 
1664
- 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 = {}) {
1665
1819
  if (!device) {
1666
1820
  return null;
1667
1821
  }
@@ -1736,17 +1890,17 @@ function serializeDevice(device, options = {}) {
1736
1890
  latestAudioStatus: device.latestAudioStatus ? { ...device.latestAudioStatus } : null,
1737
1891
  clientUpdateProof: device.clientUpdateProof ? { ...device.clientUpdateProof } : null,
1738
1892
  udp: device.udp ? { ...device.udp } : { enabled: false, state: 'tcp-only', ready: false },
1739
- latestTask: device.latestTask ? { ...device.latestTask } : null,
1893
+ latestTask: serializeDeviceTask(device.latestTask),
1740
1894
  synthetic: device.synthetic === true,
1741
1895
  recentTasks: Array.isArray(device.recentTasks)
1742
- ? device.recentTasks.map(task => ({ ...task }))
1896
+ ? device.recentTasks.map(serializeDeviceTask).filter(Boolean)
1743
1897
  : [],
1744
1898
  status: { ...device.status },
1745
1899
  counters: { ...device.counters }
1746
1900
  };
1747
1901
  }
1748
1902
 
1749
- function serializeTaskBatch(batch) {
1903
+ function serializeTaskBatch(batch, options = {}) {
1750
1904
  if (!batch) {
1751
1905
  return null;
1752
1906
  }
@@ -1768,24 +1922,51 @@ function serializeTaskBatch(batch) {
1768
1922
  failed: batch.failed,
1769
1923
  cancelled: batch.cancelled,
1770
1924
  timedOut: batch.timedOut,
1771
- results: Array.isArray(batch.results)
1772
- ? batch.results.map(item => ({ ...item }))
1773
- : []
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
+ : []
1774
1940
  };
1775
1941
  }
1776
1942
 
1777
- function writeJsonLine(socket, payload) {
1943
+ function writeJsonLine(socket, payload) {
1778
1944
  if (!socket || socket.destroyed) {
1779
1945
  return false;
1780
1946
  }
1781
1947
 
1782
- if (socket.__remoteHubWebSocket === true && typeof socket.sendJson === 'function') {
1783
- return socket.sendJson(payload);
1784
- }
1785
-
1786
- socket.write(`${JSON.stringify(payload)}\n`);
1787
- return true;
1788
- }
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
+ }
1789
1970
 
1790
1971
  function writeJsonLineAndClose(socket, payload) {
1791
1972
  if (!socket || socket.destroyed) {
@@ -2050,16 +2231,26 @@ export class RemoteHubWebSocketAgentSocket {
2050
2231
  return this.sendFrame(0x1, Buffer.from(String(text || ''), 'utf8'));
2051
2232
  }
2052
2233
 
2053
- sendFrame(opcode, payload) {
2054
- if (this.destroyed || this.socket.destroyed) {
2055
- return false;
2056
- }
2057
-
2058
- try {
2059
- return this.socket.write(buildRemoteHubWebSocketFrame(opcode, payload)) !== false;
2060
- } catch {
2061
- this.destroy();
2062
- 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;
2063
2254
  }
2064
2255
  }
2065
2256
 
@@ -2370,13 +2561,18 @@ export function createRemoteHub(options = {}) {
2370
2561
  defaultDeviceConnectionStaleMs,
2371
2562
  10 * 60 * 1000,
2372
2563
  defaultDeviceConnectionStaleMs);
2373
- const deviceConnectionSweepMs = Number.isFinite(Number(options.deviceConnectionSweepMs))
2374
- ? clampNumber(options.deviceConnectionSweepMs, 25, 60000, DEFAULT_DEVICE_CONNECTION_SWEEP_MS)
2375
- : clampNumber(
2376
- env.REMOTE_HUB_DEVICE_CONNECTION_SWEEP_MS,
2377
- 1000,
2378
- 60000,
2379
- 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);
2380
2576
  const taskTimeoutMs = clampNumber(
2381
2577
  options.taskTimeoutMs ?? env.REMOTE_HUB_TASK_TIMEOUT_MS,
2382
2578
  50,
@@ -2415,13 +2611,14 @@ export function createRemoteHub(options = {}) {
2415
2611
  false);
2416
2612
  const relayControl = options.relayControl && typeof options.relayControl === 'object'
2417
2613
  ? options.relayControl
2418
- : createHubRelayControl({
2419
- env,
2420
- pairToken,
2421
- logEvent,
2422
- logWarn,
2423
- onPeerSocket: socket => handleTcpAgentSocket(socket)
2424
- });
2614
+ : createHubRelayControl({
2615
+ env,
2616
+ pairToken,
2617
+ logEvent,
2618
+ logWarn,
2619
+ retiredPayloadRelayTestOnly: options.retiredPayloadRelayTestOnly === true,
2620
+ onPeerSocket: socket => handleTcpAgentSocket(socket)
2621
+ });
2425
2622
  let pairingPin = /^\d{6}$/.test(String(options.pairingPin || env.LIVEDESK_PAIRING_PIN || '').trim())
2426
2623
  ? String(options.pairingPin || env.LIVEDESK_PAIRING_PIN).trim()
2427
2624
  : generatePairingPin();
@@ -2450,11 +2647,15 @@ export function createRemoteHub(options = {}) {
2450
2647
  const clipboardOperations = new Map();
2451
2648
  const transportDiagnostics = new Map();
2452
2649
  const transportDiagnosticStartingDevices = new Set();
2453
- const duplicateDeviceLogAt = new Map();
2454
- let server = null;
2455
- let started = false;
2456
- let deviceConnectionSweepTimer = null;
2457
- 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;
2458
2659
  let lastError = '';
2459
2660
  let hostTarget = null;
2460
2661
  let publicIPv4Cache = {
@@ -2763,12 +2964,20 @@ export function createRemoteHub(options = {}) {
2763
2964
  const activeHostTarget = serializeHostTarget();
2764
2965
  const routeInfo = getAgentEndpointRouteInfo();
2765
2966
  const diagnostics = sampleHubRuntimeDiagnostics();
2766
- diagnostics.network = {
2767
- lanDeviceCount: connectedRoutes.length - externalDeviceCount,
2768
- externalDeviceCount,
2769
- route: externalDeviceCount > 0 ? 'external' : connectedRoutes.length > 0 ? 'lan' : 'idle'
2770
- };
2771
- 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 {
2772
2981
  enabled,
2773
2982
  started,
2774
2983
  host,
@@ -2812,14 +3021,14 @@ export function createRemoteHub(options = {}) {
2812
3021
  publicIPv4Address: publicIPv4Cache.address,
2813
3022
  publicIPv4UpdatedAt: publicIPv4Cache.updatedAt ? new Date(publicIPv4Cache.updatedAt).toISOString() : '',
2814
3023
  publicIPv4Error: publicIPv4Cache.error,
2815
- udp: udpTransport?.getStatus?.() || {
2816
- enabled: false,
2817
- preferred: false,
2818
- started: false,
2819
- port: 0,
2820
- sessionCount: 0,
2821
- readySessionCount: 0
2822
- },
3024
+ udp: {
3025
+ ...udpStatus,
3026
+ startRetryTimerActive: udpStartRetryTimer !== null,
3027
+ startInFlight: udpStartPromise !== null,
3028
+ startRetryDelayMs: udpStartRetryTimer !== null
3029
+ ? udpStartRetryDelayMs
3030
+ : 0
3031
+ },
2823
3032
  relay: relayControl?.getStatus?.() || {
2824
3033
  enabled: false,
2825
3034
  started: false,
@@ -2918,8 +3127,8 @@ export function createRemoteHub(options = {}) {
2918
3127
  return listTaskBatches()[0] || null;
2919
3128
  }
2920
3129
 
2921
- function getTaskBatch(batchId) {
2922
- return serializeTaskBatch(taskBatches.get(safeString(batchId, 128)) || null);
3130
+ function getTaskBatch(batchId) {
3131
+ return serializeTaskBatch(taskBatches.get(safeString(batchId, 128)) || null, { includeData: true });
2923
3132
  }
2924
3133
 
2925
3134
  function cancelTaskBatch(batchId) {
@@ -2974,12 +3183,14 @@ export function createRemoteHub(options = {}) {
2974
3183
  if (targetIds.length === 0) {
2975
3184
  return { ok: false, error: 'no-retry-targets' };
2976
3185
  }
2977
- return requestAgentTaskBatch(targetIds, {
2978
- instruction: batch.instructionPreview,
2979
- title: batch.title,
2980
- operation: batch.operation,
2981
- targetQuery: batch.targetQuery,
2982
- 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
2983
3194
  });
2984
3195
  }
2985
3196
 
@@ -3014,18 +3225,20 @@ export function createRemoteHub(options = {}) {
3014
3225
  }
3015
3226
  }
3016
3227
 
3017
- function settlePendingCommandResult(device, message = {}) {
3018
- 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);
3019
3231
  const waiterKey = buildPendingCommandResultWaiterKey(
3020
3232
  device?.deviceId,
3021
3233
  device?.sessionId,
3022
3234
  commandId);
3023
3235
  const waiter = pendingCommandResultWaiters.get(waiterKey);
3024
- if (!waiter
3025
- || waiter.deviceId !== device?.deviceId
3026
- || waiter.sessionId !== device?.sessionId) {
3027
- return false;
3028
- }
3236
+ if (!waiter
3237
+ || waiter.deviceId !== device?.deviceId
3238
+ || waiter.sessionId !== device?.sessionId
3239
+ || (safeString(waiter.channel, 24) || 'control') !== channel) {
3240
+ return false;
3241
+ }
3029
3242
 
3030
3243
  pendingCommandResultWaiters.delete(waiterKey);
3031
3244
  clearPendingCommandResultWaiterDeadline(waiter);
@@ -4821,7 +5034,107 @@ export function createRemoteHub(options = {}) {
4821
5034
  };
4822
5035
  }
4823
5036
 
4824
- 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) {
4825
5138
  const nowMs = Date.now();
4826
5139
  const now = new Date(nowMs).toISOString();
4827
5140
  const sessionId = crypto.randomUUID();
@@ -4947,19 +5260,7 @@ export function createRemoteHub(options = {}) {
4947
5260
  serverTime: now
4948
5261
  });
4949
5262
 
4950
- const udpSession = udpTransport?.registerDevice?.({
4951
- deviceId,
4952
- sessionId,
4953
- sendControl: payload => writeJsonLine(socket, payload),
4954
- capabilities
4955
- });
4956
- if (udpSession?.sessionId) {
4957
- device.udp = {
4958
- ...device.udp,
4959
- state: 'udp-session-created',
4960
- sessionId: udpSession.sessionId
4961
- };
4962
- }
5263
+ registerCurrentDeviceUdpSession(device);
4963
5264
 
4964
5265
  logEvent('remote', `device connected ${device.deviceName} (${deviceId})`, 'success');
4965
5266
  emitRemoteEvent('RemoteDeviceConnected', device);
@@ -5049,16 +5350,26 @@ export function createRemoteHub(options = {}) {
5049
5350
  return device;
5050
5351
  }
5051
5352
 
5052
- function attachFrameSocket(socket, hello) {
5053
- const deviceId = normalizeDeviceId(hello.deviceId);
5054
- const device = devices.get(deviceId);
5055
- if (!device || !device.connected || !device.socket || device.socket.destroyed) {
5056
- writeJsonLine(socket, { type: 'error', error: 'device-not-connected' });
5057
- socket.destroy();
5058
- return null;
5059
- }
5060
-
5061
- 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');
5062
5373
 
5063
5374
  const now = new Date().toISOString();
5064
5375
  device.frameSocket = socket;
@@ -5082,16 +5393,26 @@ export function createRemoteHub(options = {}) {
5082
5393
  return device;
5083
5394
  }
5084
5395
 
5085
- function attachAudioSocket(socket, hello) {
5086
- const deviceId = normalizeDeviceId(hello.deviceId);
5087
- const device = devices.get(deviceId);
5088
- if (!device || !device.connected || !device.socket || device.socket.destroyed) {
5089
- writeJsonLine(socket, { type: 'error', error: 'device-not-connected' });
5090
- socket.destroy();
5091
- return null;
5092
- }
5093
-
5094
- 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');
5095
5416
  const now = new Date().toISOString();
5096
5417
  device.audioSocket = socket;
5097
5418
  device.audioConnectedAt = now;
@@ -5158,8 +5479,10 @@ export function createRemoteHub(options = {}) {
5158
5479
  device.socket = null;
5159
5480
  abortTransportDiagnosticsForDevice(device, reason);
5160
5481
  udpTransport?.unregisterDevice?.(deviceId, device.sessionId);
5161
- closeInputSocket(device, reason);
5162
- closeFrameSocket(device, reason);
5482
+ closeInputSocket(device, reason);
5483
+ closeFrameSocket(device, reason);
5484
+ closeAudioSocket(device, reason);
5485
+ closeFileSocket(device, reason);
5163
5486
  device.disconnectedAt = new Date().toISOString();
5164
5487
  device.lastDisconnectReason = reason;
5165
5488
  deactivateDeviceLiveStreams(device, reason, device.disconnectedAt);
@@ -5171,10 +5494,10 @@ export function createRemoteHub(options = {}) {
5171
5494
  emitRemoteEvent('RemoteDeviceDisconnected', device, { reason });
5172
5495
  }
5173
5496
 
5174
- function rememberDeviceTask(device, task) {
5175
- if (!device || !task) {
5176
- return null;
5177
- }
5497
+ function rememberDeviceTask(device, task) {
5498
+ if (!device || !task) {
5499
+ return null;
5500
+ }
5178
5501
 
5179
5502
  const existingIndex = device.recentTasks.findIndex(item =>
5180
5503
  item.taskId === task.taskId || item.commandId === task.commandId);
@@ -5187,13 +5510,85 @@ export function createRemoteHub(options = {}) {
5187
5510
  device.recentTasks.length = RECENT_TASK_LIMIT;
5188
5511
  }
5189
5512
 
5190
- device.latestTask = task;
5191
- if (task.commandId) {
5192
- device.pendingTaskCommands.set(task.commandId, task);
5193
- }
5513
+ device.latestTask = task;
5514
+ if (task.commandId) {
5515
+ device.pendingTaskCommands.set(task.commandId, task);
5516
+ }
5194
5517
 
5195
- return task;
5196
- }
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
+ }
5197
5592
 
5198
5593
  function trimTaskBatches() {
5199
5594
  const ordered = [...taskBatches.values()]
@@ -5212,17 +5607,19 @@ export function createRemoteHub(options = {}) {
5212
5607
  const instruction = safeText(options.instruction, MAX_AGENT_TASK_CHARS);
5213
5608
  const operation = normalizeAgentOperation(options.operation);
5214
5609
  const targetQuery = safeText(options.targetQuery, 160);
5215
- const batchId = safeString(options.batchId, 128) || crypto.randomUUID();
5610
+ const batchId = crypto.randomUUID();
5216
5611
  const title = safeString(options.title, 120)
5217
5612
  || safeString(instruction.split(/\r?\n/)[0], 120)
5218
5613
  || 'Remote task batch';
5219
- const batch = {
5614
+ const batch = {
5220
5615
  batchId,
5221
5616
  title,
5222
5617
  instructionPreview: safeText(instruction, 320),
5223
5618
  operation,
5224
5619
  targetQuery,
5225
- approvalLevel: normalizeApprovalLevel(options.approvalLevel),
5620
+ approvalLevel: normalizeApprovalLevel(options.approvalLevel),
5621
+ retryToolArguments: safeRetryToolArguments(operation, options.toolArguments),
5622
+ retryPermissionMode: normalizeAgentPermissionMode(operation, options.permissionMode),
5226
5623
  status: targets.length > 0 ? 'running' : 'failed',
5227
5624
  requestedAt: now,
5228
5625
  updatedAt: now,
@@ -5249,7 +5646,7 @@ export function createRemoteHub(options = {}) {
5249
5646
  }))
5250
5647
  };
5251
5648
 
5252
- taskBatches.set(batchId, batch);
5649
+ taskBatches.set(batchId, markOwnedTaskBatch(batch));
5253
5650
  trimTaskBatches();
5254
5651
  return batch;
5255
5652
  }
@@ -5394,11 +5791,11 @@ export function createRemoteHub(options = {}) {
5394
5791
  return null;
5395
5792
  }
5396
5793
 
5397
- const task = device.pendingTaskCommands.get(key);
5398
- if (!task) {
5399
- clearTaskTimeout(device, key);
5400
- return null;
5401
- }
5794
+ const task = device.pendingTaskCommands.get(key);
5795
+ if (!task || !isPendingAgentTaskOwner(device, task, key)) {
5796
+ clearTaskTimeout(device, key);
5797
+ return null;
5798
+ }
5402
5799
 
5403
5800
  const now = new Date().toISOString();
5404
5801
  clearTaskTimeout(device, key);
@@ -5915,62 +6312,66 @@ export function createRemoteHub(options = {}) {
5915
6312
  return;
5916
6313
  }
5917
6314
 
5918
- clearTaskTimeout(device, commandId);
5919
- const timer = setTimeout(() => {
5920
- failPendingTask(device, commandId, 'task-timeout');
5921
- }, taskTimeoutMs);
6315
+ clearTaskTimeout(device, commandId);
6316
+ const timer = setTimeout(() => {
6317
+ if (isPendingAgentTaskOwner(device, task, commandId)) {
6318
+ failPendingTask(device, commandId, 'task-timeout');
6319
+ }
6320
+ }, taskTimeoutMs);
5922
6321
  timer.unref?.();
5923
6322
  device.pendingTaskTimers.set(commandId, timer);
5924
6323
  }
5925
6324
 
5926
- function summarizeTaskResult(result) {
5927
- if (result && typeof result === 'object') {
5928
- return safeText(
5929
- result.summary
5930
- || result.message
5931
- || result.output
5932
- || result.result
5933
- || JSON.stringify(result),
5934
- MAX_AGENT_TASK_RESULT_CHARS);
5935
- }
5936
-
5937
- 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);
5938
6343
  }
5939
6344
 
5940
- function applyTaskResult(device, commandId, result, error) {
5941
- if (!device || !commandId) {
5942
- return null;
5943
- }
5944
-
5945
- const resultTaskId = result && typeof result === 'object'
5946
- ? safeString(result.taskId, 128)
5947
- : '';
5948
- const task = resultTaskId
5949
- ? device.recentTasks.find(item => item.taskId === resultTaskId)
5950
- : device.pendingTaskCommands.get(commandId);
5951
- if (!task) {
5952
- return null;
5953
- }
5954
-
5955
- if (device.pendingTaskCommands.get(commandId) !== task) {
5956
- return null;
5957
- }
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
+ }
5958
6353
 
5959
6354
  const now = device.lastSeenAt || new Date().toISOString();
5960
- const resultFailed = Boolean(error)
5961
- || result?.ok === false
5962
- || safeString(result?.status, 40) === 'failed';
5963
- const status = resultFailed ? 'failed' : safeString(result?.status, 40) || 'completed';
5964
- const resultError = safeString(error, 500)
5965
- || safeString(result?.error, 500)
5966
- || (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' : '');
5967
6366
  clearTaskTimeout(device, commandId);
5968
6367
  task.status = status;
5969
6368
  task.updatedAt = now;
5970
6369
  task.completedAt = safeString(result?.completedAt, 80) || now;
5971
6370
  task.error = resultError;
5972
- task.resultSummary = resultError || summarizeTaskResult(result);
5973
- task.resultData = safeTaskData(result?.data);
6371
+ task.resultSummary = resultError || summarizeTaskResult(result, diagnostic);
6372
+ task.resultData = diagnostic
6373
+ ? safeDiagnosticTaskData(result?.data)
6374
+ : safeTaskData(result?.data);
5974
6375
  task.resultKind = safeString(result?.kind || result?.mode || 'agent-task', 80);
5975
6376
  device.latestTask = task;
5976
6377
  device.pendingTaskCommands.delete(commandId);
@@ -7689,7 +8090,7 @@ export function createRemoteHub(options = {}) {
7689
8090
  };
7690
8091
  }
7691
8092
 
7692
- function writeFrameTransportProofResponse(socket, result) {
8093
+ function writeFrameTransportProofResponse(socket, result) {
7693
8094
  const proof = result?.proof;
7694
8095
  if (!proof?.proofId) return;
7695
8096
  const accepted = result.proofAccepted === true;
@@ -7705,24 +8106,98 @@ export function createRemoteHub(options = {}) {
7705
8106
  streamPurpose: proof.streamPurpose,
7706
8107
  monitorIndex: proof.monitorIndex,
7707
8108
  ...(accepted ? {} : { error: safeString(result.error || 'frame-proof-rejected', 160) })
7708
- });
7709
- }
7710
-
7711
- 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) {
7712
8167
  if (!state.authenticated || !state.device) {
7713
8168
  writeJsonLine(socket, { type: 'error', error: 'hello-required' });
7714
8169
  socket.destroy();
7715
8170
  return;
7716
8171
  }
7717
-
7718
- const device = state.device;
7719
- if (state.inputOnly || state.fileOnly) {
7720
- writeJsonLine(socket, { type: 'error', error: 'input-channel-binary-not-supported' });
7721
- socket.destroy();
7722
- return;
7723
- }
7724
-
7725
- 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;
7726
8201
  device.lastSeenAt = new Date().toISOString();
7727
8202
  if (state.frameOnly) {
7728
8203
  device.frameLastSeenAt = device.lastSeenAt;
@@ -7731,8 +8206,7 @@ export function createRemoteHub(options = {}) {
7731
8206
  device.audioLastSeenAt = device.lastSeenAt;
7732
8207
  }
7733
8208
 
7734
- const frameKind = safeString(header.frameKind || header.kind || header.frameType || '', 40).toLowerCase();
7735
- const binaryTransport = socket.__liveDeskRelayControl === true
8209
+ const binaryTransport = socket.__liveDeskRelayControl === true
7736
8210
  ? 'relay-binary'
7737
8211
  : state.binaryTransport === 'ws-binary'
7738
8212
  ? 'ws-binary'
@@ -7959,9 +8433,12 @@ export function createRemoteHub(options = {}) {
7959
8433
  return state.binaryIngress.enqueue(header, payload);
7960
8434
  }
7961
8435
 
7962
- function handleAgentMessage(socket, state, message) {
7963
- if (!message || typeof message !== 'object') {
7964
- 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;
7965
8442
  }
7966
8443
 
7967
8444
  if (!state.authenticated) {
@@ -8022,9 +8499,10 @@ export function createRemoteHub(options = {}) {
8022
8499
  return;
8023
8500
  }
8024
8501
 
8025
- state.authenticated = true;
8026
- state.device = device;
8027
- state.inputOnly = true;
8502
+ state.authenticated = true;
8503
+ state.device = device;
8504
+ state.sessionId = device.sessionId;
8505
+ state.inputOnly = true;
8028
8506
  return;
8029
8507
  }
8030
8508
  if (channel === 'frame') {
@@ -8038,9 +8516,10 @@ export function createRemoteHub(options = {}) {
8038
8516
  return;
8039
8517
  }
8040
8518
 
8041
- state.authenticated = true;
8042
- state.device = device;
8043
- state.frameOnly = true;
8519
+ state.authenticated = true;
8520
+ state.device = device;
8521
+ state.sessionId = device.sessionId;
8522
+ state.frameOnly = true;
8044
8523
  return;
8045
8524
  }
8046
8525
  if (channel === 'audio') {
@@ -8049,9 +8528,10 @@ export function createRemoteHub(options = {}) {
8049
8528
  return;
8050
8529
  }
8051
8530
 
8052
- state.authenticated = true;
8053
- state.device = device;
8054
- state.audioOnly = true;
8531
+ state.authenticated = true;
8532
+ state.device = device;
8533
+ state.sessionId = device.sessionId;
8534
+ state.audioOnly = true;
8055
8535
  return;
8056
8536
  }
8057
8537
  if (channel === 'file') {
@@ -8060,9 +8540,10 @@ export function createRemoteHub(options = {}) {
8060
8540
  return;
8061
8541
  }
8062
8542
 
8063
- state.authenticated = true;
8064
- state.device = device;
8065
- state.fileOnly = true;
8543
+ state.authenticated = true;
8544
+ state.device = device;
8545
+ state.sessionId = device.sessionId;
8546
+ state.fileOnly = true;
8066
8547
  return;
8067
8548
  }
8068
8549
 
@@ -8071,21 +8552,23 @@ export function createRemoteHub(options = {}) {
8071
8552
  return;
8072
8553
  }
8073
8554
 
8074
- state.authenticated = true;
8075
- state.device = device;
8076
- return;
8077
- }
8078
-
8079
- const device = state.device;
8080
- if (!device) {
8081
- socket.destroy();
8082
- return;
8555
+ state.authenticated = true;
8556
+ state.device = device;
8557
+ state.sessionId = device.sessionId;
8558
+ return;
8083
8559
  }
8084
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
+
8085
8571
  if (state.inputOnly) {
8086
- if (device.inputSocket !== socket) {
8087
- return;
8088
- }
8089
8572
  device.inputLastSeenAt = new Date().toISOString();
8090
8573
  if (message.type === 'input.applied' || message.type === 'input.error') {
8091
8574
  acknowledgePendingDedicatedInput(device, message);
@@ -8095,21 +8578,50 @@ export function createRemoteHub(options = {}) {
8095
8578
  }, 'input');
8096
8579
  }
8097
8580
  return;
8098
- }
8099
- if (state.frameOnly) {
8100
- device.frameLastSeenAt = new Date().toISOString();
8101
- return;
8102
- }
8103
- if (state.audioOnly) {
8104
- device.audioLastSeenAt = new Date().toISOString();
8105
- return;
8106
- }
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
+ }
8107
8618
  if (state.fileOnly) {
8108
8619
  device.fileLastSeenAt = new Date().toISOString();
8109
8620
  }
8110
8621
 
8111
- device.counters.messagesReceived += 1;
8112
- device.lastSeenAt = new Date().toISOString();
8622
+ device.counters.messagesReceived += 1;
8623
+ device.lastSeenAt = new Date().toISOString();
8624
+ const channel = getAgentMessageChannel(state);
8113
8625
 
8114
8626
  switch (message.type) {
8115
8627
  case 'heartbeat':
@@ -8137,12 +8649,14 @@ export function createRemoteHub(options = {}) {
8137
8649
  message.error || (message.type === 'command.error' ? 'command-failed' : ''),
8138
8650
  500);
8139
8651
  const result = message.result ?? null;
8140
- settlePendingCommandResult(device, message);
8141
- handleInputFallbackCommandResult(device, message);
8142
- if (error || result?.ok === false || result?.status === 'failed') {
8143
- failPendingLiveStream(device, commandId, error || result?.error || 'stream-start-failed');
8144
- }
8145
- 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);
8146
8660
  if (task) {
8147
8661
  emitRemoteEvent('RemoteTaskResult', device, {
8148
8662
  commandId,
@@ -8158,8 +8672,16 @@ export function createRemoteHub(options = {}) {
8158
8672
  error: safeString(message.error, 500)
8159
8673
  });
8160
8674
  break;
8161
- case 'client.update.verified': {
8162
- 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();
8163
8685
  const proof = normalizeClientUpdateVerifiedProof(device, message, receivedAt);
8164
8686
  if (!proof) {
8165
8687
  logWarn(
@@ -8205,10 +8727,11 @@ export function createRemoteHub(options = {}) {
8205
8727
  socket.setNoDelay(true);
8206
8728
  socket.setKeepAlive(true, heartbeatMs);
8207
8729
 
8208
- const state = {
8209
- authenticated: false,
8210
- device: null,
8211
- binaryTransport: 'ws-binary',
8730
+ const state = {
8731
+ authenticated: false,
8732
+ device: null,
8733
+ sessionId: '',
8734
+ binaryTransport: 'ws-binary',
8212
8735
  inputOnly: false,
8213
8736
  frameOnly: false,
8214
8737
  audioOnly: false,
@@ -8228,33 +8751,56 @@ export function createRemoteHub(options = {}) {
8228
8751
  }
8229
8752
  }, 10000);
8230
8753
 
8231
- socket.onTextMessage = text => {
8232
- try {
8233
- handleAgentMessage(socket, state, parseJsonLine(String(text || '')));
8234
- } catch (err) {
8235
- writeJsonLine(socket, { type: 'error', error: 'invalid-json' });
8236
- logWarn('remote', `invalid websocket agent message: ${err?.message || err}`);
8237
- }
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
+ }
8238
8765
  };
8239
8766
 
8240
8767
  socket.onBinaryMessage = payload => {
8241
8768
  try {
8242
8769
  const packet = parseRemoteHubWebSocketBinaryFrame(payload);
8243
- if (!packet?.header || !Buffer.isBuffer(packet.payload)) {
8244
- writeJsonLine(socket, { type: 'error', error: 'invalid-websocket-binary-frame' });
8245
- return;
8246
- }
8247
-
8248
- const frameKind = safeString(packet.header.frameKind || packet.header.kind || packet.header.frameType, 40).toLowerCase();
8249
- const maxBytes = frameKind === 'thumbnail'
8250
- ? MAX_THUMBNAIL_BINARY_BYTES
8251
- : MAX_STREAM_BINARY_BYTES;
8252
- const byteLength = Number(packet.header.byteLength ?? packet.header.payloadBytes ?? packet.payload.length);
8253
- if (!Number.isFinite(byteLength)
8254
- || byteLength < 1
8255
- || byteLength > maxBytes
8256
- || byteLength !== packet.payload.length) {
8257
- 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' });
8258
8804
  logWarn('remote', 'invalid websocket binary frame from agent.');
8259
8805
  return;
8260
8806
  }
@@ -8382,10 +8928,11 @@ export function createRemoteHub(options = {}) {
8382
8928
  socket.setNoDelay(true);
8383
8929
  socket.setKeepAlive(true, heartbeatMs);
8384
8930
 
8385
- const state = {
8386
- authenticated: false,
8387
- device: null,
8388
- binaryTransport: 'binary',
8931
+ const state = {
8932
+ authenticated: false,
8933
+ device: null,
8934
+ sessionId: '',
8935
+ binaryTransport: 'binary',
8389
8936
  inputOnly: false,
8390
8937
  frameOnly: false,
8391
8938
  audioOnly: false,
@@ -8451,24 +8998,14 @@ export function createRemoteHub(options = {}) {
8451
8998
  }
8452
8999
 
8453
9000
  if (startsWithTcpBinaryFrameMagic(state.buffer)) {
8454
- const packet = parseTcpBinaryFramePacket(state.buffer);
8455
- if (packet?.incomplete) {
8456
- return;
8457
- }
8458
- if (!packet || packet.invalid) {
8459
- const syncIndex = findTcpFrameOnlySyncIndex(state.buffer, 1);
8460
- if (syncIndex > 0) {
8461
- state.buffer.discard(syncIndex);
8462
- state.lineScanOffset = 0;
8463
- continue;
8464
- }
8465
-
8466
- const syncTail = keepTcpFrameOnlySyncTail(state.buffer);
8467
- state.buffer.clear();
8468
- state.buffer.append(syncTail);
8469
- state.lineScanOffset = 0;
8470
- return;
8471
- }
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
+ }
8472
9009
 
8473
9010
  state.buffer.discard(packet.packetLength);
8474
9011
  state.lineScanOffset = 0;
@@ -8476,20 +9013,10 @@ export function createRemoteHub(options = {}) {
8476
9013
  continue;
8477
9014
  }
8478
9015
 
8479
- if (!isJsonObjectStartByte(state.buffer.byteAt(0))) {
8480
- const syncIndex = findTcpFrameOnlySyncIndex(state.buffer, 1);
8481
- if (syncIndex > 0) {
8482
- state.buffer.discard(syncIndex);
8483
- state.lineScanOffset = 0;
8484
- continue;
8485
- }
8486
-
8487
- const syncTail = keepTcpFrameOnlySyncTail(state.buffer);
8488
- state.buffer.clear();
8489
- state.buffer.append(syncTail);
8490
- state.lineScanOffset = 0;
8491
- return;
8492
- }
9016
+ if (!isJsonObjectStartByte(state.buffer.byteAt(0))) {
9017
+ failCloseMediaSideChannel(socket, state, 'media-side-channel-malformed-binary', 'invalid-binary-frame');
9018
+ return;
9019
+ }
8493
9020
  }
8494
9021
 
8495
9022
  const newlineIndex = state.buffer.indexOfByte(0x0a, state.lineScanOffset);
@@ -8511,18 +9038,33 @@ export function createRemoteHub(options = {}) {
8511
9038
  return;
8512
9039
  }
8513
9040
 
8514
- try {
8515
- const message = parseJsonLine(lineBuffer.toString('utf8'));
8516
- if (message?.type === 'frame.binary') {
8517
- const frameKind = safeString(message.frameKind || message.kind || message.frameType, 40).toLowerCase();
8518
- const maxBytes = frameKind === 'thumbnail'
8519
- ? MAX_THUMBNAIL_BINARY_BYTES
8520
- : MAX_STREAM_BINARY_BYTES;
8521
- const byteLength = Number(message.byteLength ?? message.payloadBytes ?? message.dataLength);
8522
- if (!Number.isFinite(byteLength) || byteLength < 1 || byteLength > maxBytes) {
8523
- writeJsonLine(socket, { type: 'error', error: 'invalid-binary-frame' });
8524
- logWarn('remote', 'invalid binary frame header from agent.');
8525
- 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;
8526
9068
  }
8527
9069
 
8528
9070
  state.pendingBinaryFrame = {
@@ -8532,11 +9074,12 @@ export function createRemoteHub(options = {}) {
8532
9074
  continue;
8533
9075
  }
8534
9076
 
8535
- handleAgentMessage(socket, state, message);
8536
- } catch (err) {
8537
- if (state.authenticated && (state.frameOnly || state.audioOnly)) {
8538
- continue;
8539
- }
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
+ }
8540
9083
  writeJsonLine(socket, { type: 'error', error: 'invalid-json' });
8541
9084
  logWarn('remote', `invalid agent message: ${err?.message || err}`);
8542
9085
  }
@@ -8650,20 +9193,36 @@ export function createRemoteHub(options = {}) {
8650
9193
  });
8651
9194
  }
8652
9195
 
8653
- async function start() {
8654
- if (!enabled || started) {
8655
- return getStatus({ includeSecrets: false });
8656
- }
8657
-
8658
- try {
8659
- await udpTransport?.start?.();
8660
- await listenOnPort(requestedPort);
8661
- try {
8662
- await relayControl?.start?.();
8663
- } catch {
8664
- logWarn('relay', 'Encrypted relay control could not start; direct TCP remains available.');
8665
- }
8666
- 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();
8667
9226
  } catch (error) {
8668
9227
  if (error?.code === 'EADDRINUSE') {
8669
9228
  const conflict = new Error(
@@ -8680,8 +9239,14 @@ export function createRemoteHub(options = {}) {
8680
9239
  return getStatus({ includeSecrets: false });
8681
9240
  }
8682
9241
 
8683
- async function close() {
8684
- 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) {
8685
9250
  clearTimeout(deviceConnectionSweepTimer);
8686
9251
  deviceConnectionSweepTimer = null;
8687
9252
  }
@@ -8693,9 +9258,12 @@ export function createRemoteHub(options = {}) {
8693
9258
  abortTransportDiagnosticsForDevice(device, 'hub-shutdown');
8694
9259
  failAllPendingTasks(device, 'hub-shutdown');
8695
9260
  failAllPendingInputFallbacks(device, 'hub-shutdown');
8696
- failPendingCommandResultWaiters(device, 'hub-shutdown');
8697
- closeInputSocket(device, 'hub-shutdown');
8698
- 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) {
8699
9267
  writeJsonLine(device.socket, { type: 'disconnect', reason: 'hub-shutdown' });
8700
9268
  device.socket.destroy();
8701
9269
  }
@@ -8706,10 +9274,13 @@ export function createRemoteHub(options = {}) {
8706
9274
  socket.destroy();
8707
9275
  }
8708
9276
  }
8709
-
8710
- sockets.clear();
8711
- await relayControl?.close?.();
8712
- await udpTransport?.close?.();
9277
+
9278
+ sockets.clear();
9279
+ await relayControl?.close?.();
9280
+ if (pendingUdpStart) {
9281
+ await pendingUdpStart;
9282
+ }
9283
+ await udpTransport?.close?.();
8713
9284
  if (!server) {
8714
9285
  started = false;
8715
9286
  return;
@@ -8843,7 +9414,7 @@ export function createRemoteHub(options = {}) {
8843
9414
  : '';
8844
9415
  const denied = policyError(device, requiredPermission, commandName);
8845
9416
  if (denied) return { ok: false, error: denied };
8846
- if (device?.synthetic === true && device.connected) {
9417
+ if (device?.synthetic === true && device.connected) {
8847
9418
  const commandId = safeString(command?.commandId, 128) || crypto.randomUUID();
8848
9419
  device.counters.commandsSent += 1;
8849
9420
  device.lastSeenAt = new Date().toISOString();
@@ -8888,12 +9459,17 @@ export function createRemoteHub(options = {}) {
8888
9459
  && !device.fileSocket.destroyed
8889
9460
  ? device.fileSocket
8890
9461
  : null;
8891
- try {
8892
- writeJsonLine(dedicatedFileSocket || device.socket, payload);
8893
- } catch {
8894
- retireStaleDeviceConnection(device, 'command-channel-write-failed');
8895
- return { ok: false, error: 'device-not-connected' };
8896
- }
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
+ }
8897
9473
  device.counters.commandsSent += 1;
8898
9474
  emitRemoteEvent('RemoteCommandQueued', device, {
8899
9475
  commandId,
@@ -9734,14 +10310,23 @@ export function createRemoteHub(options = {}) {
9734
10310
  }
9735
10311
  const device = devices.get(String(deviceId || ''));
9736
10312
  const requestedOperation = safeString(options.operation, 80);
9737
- const operation = normalizeAgentOperation(requestedOperation);
9738
- if (requestedOperation && !operation) {
9739
- return { ok: false, error: 'unsupported-agent-operation' };
9740
- }
9741
- if (!device) {
9742
- return { ok: false, error: 'device-not-found' };
9743
- }
9744
- 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');
9745
10330
  if (denied) return { ok: false, error: denied };
9746
10331
 
9747
10332
  if (device?.synthetic === true && device.connected) {
@@ -9752,21 +10337,21 @@ export function createRemoteHub(options = {}) {
9752
10337
 
9753
10338
  const now = new Date().toISOString();
9754
10339
  const approvalLevel = normalizeApprovalLevel(options.approvalLevel);
9755
- const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
9756
- const taskId = safeString(options.taskId, 128) || crypto.randomUUID();
10340
+ const commandId = crypto.randomUUID();
10341
+ const taskId = crypto.randomUUID();
10342
+ const batch = getOwnedTaskBatch(options);
9757
10343
  const title = safeString(options.title, 120)
9758
10344
  || safeString(instruction.split(/\r?\n/)[0], 120)
9759
10345
  || 'Remote task';
9760
10346
  const task = {
9761
- batchId: safeString(options.batchId, 128),
10347
+ batchId: batch?.batchId || '',
9762
10348
  taskId,
9763
10349
  commandId,
9764
10350
  title,
9765
- operation,
9766
- targetQuery: safeText(options.targetQuery, 160),
9767
- toolArguments: options.toolArguments && typeof options.toolArguments === 'object' ? options.toolArguments : {},
9768
- permissionMode: safeString(options.permissionMode, 40) || 'ask',
9769
- instructionPreview: safeText(instruction, 320),
10351
+ operation,
10352
+ targetQuery: safeText(options.targetQuery, 160),
10353
+ permissionMode,
10354
+ instructionPreview: safeText(instruction, 320),
9770
10355
  status: 'completed',
9771
10356
  stage: 'Completed',
9772
10357
  approvalLevel,
@@ -9779,8 +10364,14 @@ export function createRemoteHub(options = {}) {
9779
10364
  resultSummary: operation === 'process.list'
9780
10365
  ? 'Process list collected.'
9781
10366
  : `Synthetic task accepted by ${device.deviceName}.`,
9782
- resultData: undefined
9783
- };
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
+ });
9784
10375
 
9785
10376
  device.counters.commandsSent += 1;
9786
10377
  device.counters.commandResultsReceived += 1;
@@ -9811,9 +10402,12 @@ export function createRemoteHub(options = {}) {
9811
10402
  retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
9812
10403
  return { ok: false, error: 'device-not-connected' };
9813
10404
  }
9814
- if (!device?.socket || device.socket.destroyed || !device.connected) {
9815
- return { ok: false, error: 'device-not-connected' };
9816
- }
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
+ }
9817
10411
 
9818
10412
  const instruction = safeText(options.instruction, MAX_AGENT_TASK_CHARS);
9819
10413
  if (!instruction) {
@@ -9822,21 +10416,21 @@ export function createRemoteHub(options = {}) {
9822
10416
 
9823
10417
  const now = new Date().toISOString();
9824
10418
  const approvalLevel = normalizeApprovalLevel(options.approvalLevel);
9825
- const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
9826
- const taskId = safeString(options.taskId, 128) || crypto.randomUUID();
10419
+ const commandId = crypto.randomUUID();
10420
+ const taskId = crypto.randomUUID();
10421
+ const batch = getOwnedTaskBatch(options);
9827
10422
  const title = safeString(options.title, 120)
9828
10423
  || safeString(instruction.split(/\r?\n/)[0], 120)
9829
10424
  || 'Remote task';
9830
10425
  const task = {
9831
- batchId: safeString(options.batchId, 128),
10426
+ batchId: batch?.batchId || '',
9832
10427
  taskId,
9833
10428
  commandId,
9834
10429
  title,
9835
- operation,
9836
- targetQuery: safeText(options.targetQuery, 160),
9837
- toolArguments: options.toolArguments && typeof options.toolArguments === 'object' ? options.toolArguments : {},
9838
- permissionMode: safeString(options.permissionMode, 40) || 'ask',
9839
- instructionPreview: safeText(instruction, 320),
10430
+ operation,
10431
+ targetQuery: safeText(options.targetQuery, 160),
10432
+ permissionMode,
10433
+ instructionPreview: safeText(instruction, 320),
9840
10434
  status: 'queued',
9841
10435
  stage: operation === 'process.list' ? 'Checking processes' : 'Queued',
9842
10436
  approvalLevel,
@@ -9847,8 +10441,14 @@ export function createRemoteHub(options = {}) {
9847
10441
  error: '',
9848
10442
  resultKind: '',
9849
10443
  resultSummary: '',
9850
- resultData: undefined
9851
- };
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
+ });
9852
10452
 
9853
10453
  const commandName = operation || 'agent.task';
9854
10454
  const sent = writeJsonLine(device.socket, {
@@ -9859,11 +10459,11 @@ export function createRemoteHub(options = {}) {
9859
10459
  taskId,
9860
10460
  title,
9861
10461
  instruction,
9862
- operation,
9863
- targetQuery: safeText(options.targetQuery, 160),
9864
- toolArguments: options.toolArguments && typeof options.toolArguments === 'object' ? options.toolArguments : {},
9865
- permissionMode: safeString(options.permissionMode, 40) || 'ask',
9866
- approvalLevel,
10462
+ operation,
10463
+ targetQuery: safeText(options.targetQuery, 160),
10464
+ toolArguments,
10465
+ permissionMode,
10466
+ approvalLevel,
9867
10467
  requestedAt: now
9868
10468
  },
9869
10469
  issuedAt: now
@@ -9905,23 +10505,52 @@ export function createRemoteHub(options = {}) {
9905
10505
  };
9906
10506
  }
9907
10507
 
9908
- if (targets.length === 0) {
10508
+ if (targets.length === 0) {
9909
10509
  return {
9910
10510
  ok: false,
9911
10511
  error: 'no-target-devices',
9912
10512
  total: 0,
9913
10513
  queued: 0,
9914
10514
  approvalLevel: normalizeApprovalLevel(options.approvalLevel),
9915
- results: []
9916
- };
9917
- }
9918
-
9919
- const batch = beginTaskBatch(targets, options);
9920
- const results = targets.map(deviceId => {
9921
- const result = requestAgentTask(deviceId, {
9922
- ...options,
9923
- batchId: batch.batchId
9924
- });
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
+ });
9925
10554
 
9926
10555
  if (result.ok !== true) {
9927
10556
  syncTaskBatchDispatchFailure(batch.batchId, deviceId, result.error, result);