@livedesk/hub 0.1.47 → 0.1.48

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
@@ -3,10 +3,18 @@ import os from 'os';
3
3
  import crypto from 'crypto';
4
4
  import { createHubRelayControl } from './transport/relay-hub-control.js';
5
5
  import { parseExactLiveStreamMonitorIndex } from './live-stream-monitor-contract.js';
6
- import {
7
- BoundedSegmentedBuffer,
8
- createBoundedAgentBinaryIngressLane
9
- } from './transport/agent-binary-ingress.js';
6
+ import {
7
+ BoundedSegmentedBuffer,
8
+ createBoundedAgentBinaryIngressLane
9
+ } from './transport/agent-binary-ingress.js';
10
+ import {
11
+ REMOTE_CLIPBOARD_FILE_ACTIONS,
12
+ REMOTE_CLIPBOARD_LIMITS,
13
+ REMOTE_CLIPBOARD_PROTOCOL,
14
+ normalizeRemoteClipboardCommandResult,
15
+ normalizeRemoteClipboardManifest,
16
+ remoteClipboardContentNeedsFileTransfer
17
+ } from './remote-clipboard-contract.mjs';
10
18
 
11
19
  const DEFAULT_REMOTE_HUB_PORT = 5197;
12
20
  const DEFAULT_REMOTE_HUB_HOST = '0.0.0.0';
@@ -228,12 +236,13 @@ const REMOTE_FRAME_MODE_ALIASES = new Map([
228
236
  ]);
229
237
  const MAX_SYNTHETIC_DEVICES = 1000;
230
238
  const DEFAULT_HOST_TARGET_LEASE_MS = 30000;
231
- const DEFAULT_PUBLIC_IP_REFRESH_MS = 5 * 60 * 1000;
232
- const DEFAULT_PUBLIC_IP_TIMEOUT_MS = 1800;
233
- const DUPLICATE_DEVICE_ACTIVE_REJECT_MS = 15000;
234
- const DUPLICATE_DEVICE_LOG_THROTTLE_MS = 60000;
235
- const DUPLICATE_DEVICE_RETRY_AFTER_MS = 30000;
236
- const AGENT_BINARY_INGRESS_QUEUE_PACKETS = 16;
239
+ const DEFAULT_PUBLIC_IP_REFRESH_MS = 5 * 60 * 1000;
240
+ const DEFAULT_PUBLIC_IP_TIMEOUT_MS = 1800;
241
+ const DEFAULT_DEVICE_CONNECTION_STALE_MS = 20000;
242
+ const DEFAULT_DEVICE_CONNECTION_SWEEP_MS = 5000;
243
+ const DUPLICATE_DEVICE_LOG_THROTTLE_MS = 60000;
244
+ const DUPLICATE_DEVICE_RETRY_AFTER_MS = 30000;
245
+ const AGENT_BINARY_INGRESS_QUEUE_PACKETS = 16;
237
246
  const AGENT_BINARY_INGRESS_QUEUE_MAX_BYTES = 12 * 1024 * 1024;
238
247
  const AGENT_BINARY_INGRESS_QUEUE_MAX_AGE_MS = 120;
239
248
  const MODE5_AGENT_BINARY_INGRESS_QUEUE_PACKETS = 2;
@@ -797,6 +806,25 @@ function normalizeIncomingFrameModeProfile(value) {
797
806
  };
798
807
  }
799
808
 
809
+ function deviceExplicitlyRejectsFrameMode(device, frameMode) {
810
+ const requestedMode = safeString(frameMode, 80).toLowerCase();
811
+ if (!requestedMode) {
812
+ return false;
813
+ }
814
+
815
+ const advertisedModes = [
816
+ ...(Array.isArray(device?.frameModes) ? device.frameModes : []),
817
+ ...(Array.isArray(device?.frameProtocol?.modes) ? device.frameProtocol.modes : []),
818
+ ...(Array.isArray(device?.capabilities?.frameModes) ? device.capabilities.frameModes : []),
819
+ ...(Array.isArray(device?.capabilities?.frameProtocol?.modes)
820
+ ? device.capabilities.frameProtocol.modes
821
+ : [])
822
+ ];
823
+ return advertisedModes.some(profile =>
824
+ safeString(profile?.mode || profile?.frameMode, 80).toLowerCase() === requestedMode
825
+ && profile?.implemented === false);
826
+ }
827
+
800
828
  function isLoopbackHost(value) {
801
829
  const host = String(value || '').trim().toLowerCase();
802
830
  return host === 'localhost'
@@ -1164,12 +1192,12 @@ function normalizeRemoteKeyboardKey(value) {
1164
1192
  return raw === ' ' ? ' ' : safeString(raw, 80);
1165
1193
  }
1166
1194
 
1167
- function normalizeRemoteKeyboardText(value) {
1168
- const raw = String(value ?? '').replace(/\0/g, '');
1169
- // Remote text is input data, not a label. Preserve leading/trailing spaces
1170
- // and IME whitespace while keeping every ordered message bounded.
1171
- return raw.slice(0, 256);
1172
- }
1195
+ function normalizeRemoteKeyboardText(value) {
1196
+ const raw = String(value ?? '').replace(/\0/g, '');
1197
+ // Remote text is input data, not a label. Preserve leading/trailing spaces
1198
+ // and IME whitespace while keeping every ordered message bounded.
1199
+ return raw.slice(0, 256);
1200
+ }
1173
1201
 
1174
1202
  function safeTaskData(value) {
1175
1203
  if (value === undefined || value === null) return undefined;
@@ -1250,7 +1278,7 @@ function readRawRemoteInputMonitorIndex(input) {
1250
1278
  return undefined;
1251
1279
  }
1252
1280
 
1253
- function normalizeRemoteInputEvent(value = {}) {
1281
+ function normalizeRemoteInputEvent(value = {}) {
1254
1282
  const input = value && typeof value === 'object' ? value : {};
1255
1283
  const type = safeString(input.type || input.Type, 48);
1256
1284
  const normalizedX = Number(input.normalizedX ?? input.NormalizedX);
@@ -1271,7 +1299,14 @@ function normalizeRemoteInputEvent(value = {}) {
1271
1299
  .filter(Boolean))]
1272
1300
  .slice(0, 64)
1273
1301
  : null;
1274
- const key = normalizeRemoteKeyboardKey(input.key ?? input.Key);
1302
+ const key = normalizeRemoteKeyboardKey(input.key ?? input.Key);
1303
+ const clipboardOperationId = safeString(
1304
+ input.clipboardOperationId || input.operationId || input.ClipboardOperationId || input.OperationId,
1305
+ 128);
1306
+ const clipboardDirection = safeString(
1307
+ input.clipboardDirection || input.ClipboardDirection,
1308
+ 16).toLowerCase();
1309
+ const clipboardManifest = input.clipboardManifest || input.manifest || input.ClipboardManifest || input.Manifest;
1275
1310
  let code = safeString(input.code ?? input.Code, 80);
1276
1311
  if ((!code || code === 'Unidentified') && (key === ' ' || key === 'Spacebar' || keyCode === 32)) {
1277
1312
  code = 'Space';
@@ -1296,7 +1331,13 @@ function normalizeRemoteInputEvent(value = {}) {
1296
1331
  shiftKey: input.shiftKey === true || input.ShiftKey === true,
1297
1332
  ctrlKey: input.ctrlKey === true || input.ControlKey === true || input.CtrlKey === true,
1298
1333
  altKey: input.altKey === true || input.AltKey === true,
1299
- metaKey: input.metaKey === true || input.MetaKey === true || input.commandKey === true || input.CommandKey === true,
1334
+ metaKey: input.metaKey === true || input.MetaKey === true || input.commandKey === true || input.CommandKey === true,
1335
+ clipboardOperationId,
1336
+ operationId: clipboardOperationId,
1337
+ clipboardDirection,
1338
+ manifest: clipboardManifest && typeof clipboardManifest === 'object' && !Array.isArray(clipboardManifest)
1339
+ ? clipboardManifest
1340
+ : null,
1300
1341
  controlLeaseId: safeString(input.controlLeaseId || input.ControlLeaseId, 128),
1301
1342
  controlSessionId: safeString(input.controlSessionId || input.sessionId || input.ControlSessionId || input.SessionId, 160),
1302
1343
  controlCommandId: safeString(input.controlCommandId || input.commandId || input.ControlCommandId || input.CommandId, 128),
@@ -1718,6 +1759,26 @@ function writeJsonLine(socket, payload) {
1718
1759
  return true;
1719
1760
  }
1720
1761
 
1762
+ function writeJsonLineAndClose(socket, payload) {
1763
+ if (!socket || socket.destroyed) {
1764
+ return false;
1765
+ }
1766
+
1767
+ if (socket.__remoteHubWebSocket === true) {
1768
+ const written = writeJsonLine(socket, payload);
1769
+ socket.destroy();
1770
+ return written;
1771
+ }
1772
+
1773
+ try {
1774
+ socket.end(`${JSON.stringify(payload)}\n`);
1775
+ return true;
1776
+ } catch {
1777
+ socket.destroy();
1778
+ return false;
1779
+ }
1780
+ }
1781
+
1721
1782
  function isRemoteHubWebSocketUpgradeStart(buffer) {
1722
1783
  if (!Buffer.isBuffer(buffer) || buffer.length < 4) {
1723
1784
  return false;
@@ -2268,12 +2329,29 @@ export function createRemoteHub(options = {}) {
2268
2329
 
2269
2330
  const enabled = isEnabledValue(env.LIVEDESK_REMOTE_HUB ?? env.MINDEXEC_REMOTE_HUB ?? env.REMOTE_HUB_ENABLED, true)
2270
2331
  && !isDisabledValue(env.REMOTE_HUB_DISABLED);
2271
- const host = safeString(env.REMOTE_HUB_HOST || DEFAULT_REMOTE_HUB_HOST, 128);
2272
- const requestedPort = normalizePort(env.REMOTE_HUB_PORT || DEFAULT_REMOTE_HUB_PORT);
2273
- const heartbeatMs = clampNumber(env.REMOTE_HUB_HEARTBEAT_MS, 1000, 60000, DEFAULT_HEARTBEAT_MS);
2274
- const taskTimeoutMs = clampNumber(
2275
- options.taskTimeoutMs ?? env.REMOTE_HUB_TASK_TIMEOUT_MS,
2276
- 50,
2332
+ const host = safeString(env.REMOTE_HUB_HOST || DEFAULT_REMOTE_HUB_HOST, 128);
2333
+ const requestedPort = normalizePort(env.REMOTE_HUB_PORT || DEFAULT_REMOTE_HUB_PORT);
2334
+ const heartbeatMs = clampNumber(env.REMOTE_HUB_HEARTBEAT_MS, 1000, 60000, DEFAULT_HEARTBEAT_MS);
2335
+ const defaultDeviceConnectionStaleMs = Math.max(
2336
+ DEFAULT_DEVICE_CONNECTION_STALE_MS,
2337
+ heartbeatMs * 4);
2338
+ const deviceConnectionStaleMs = Number.isFinite(Number(options.deviceConnectionStaleMs))
2339
+ ? clampNumber(options.deviceConnectionStaleMs, 100, 10 * 60 * 1000, defaultDeviceConnectionStaleMs)
2340
+ : clampNumber(
2341
+ env.REMOTE_HUB_DEVICE_CONNECTION_STALE_MS,
2342
+ defaultDeviceConnectionStaleMs,
2343
+ 10 * 60 * 1000,
2344
+ defaultDeviceConnectionStaleMs);
2345
+ const deviceConnectionSweepMs = Number.isFinite(Number(options.deviceConnectionSweepMs))
2346
+ ? clampNumber(options.deviceConnectionSweepMs, 25, 60000, DEFAULT_DEVICE_CONNECTION_SWEEP_MS)
2347
+ : clampNumber(
2348
+ env.REMOTE_HUB_DEVICE_CONNECTION_SWEEP_MS,
2349
+ 1000,
2350
+ 60000,
2351
+ Math.min(DEFAULT_DEVICE_CONNECTION_SWEEP_MS, heartbeatMs));
2352
+ const taskTimeoutMs = clampNumber(
2353
+ options.taskTimeoutMs ?? env.REMOTE_HUB_TASK_TIMEOUT_MS,
2354
+ 50,
2277
2355
  30 * 60 * 1000,
2278
2356
  DEFAULT_AGENT_TASK_TIMEOUT_MS);
2279
2357
  const inputAckTimeoutMs = clampNumber(
@@ -2336,16 +2414,18 @@ export function createRemoteHub(options = {}) {
2336
2414
  const audioSockets = new Map();
2337
2415
  const fileSockets = new Map();
2338
2416
  const allSockets = new Set();
2339
- const agentBinaryIngressStates = new Set();
2340
- const pendingCommandResultWaiters = new Map();
2417
+ const agentBinaryIngressStates = new Set();
2418
+ const pendingCommandResultWaiters = new Map();
2419
+ const clipboardOperations = new Map();
2341
2420
  const transportDiagnostics = new Map();
2342
2421
  const transportDiagnosticStartingDevices = new Set();
2343
- const duplicateDeviceLogAt = new Map();
2344
- let server = null;
2345
- let started = false;
2346
- let boundPort = requestedPort;
2347
- let lastError = '';
2348
- let hostTarget = null;
2422
+ const duplicateDeviceLogAt = new Map();
2423
+ let server = null;
2424
+ let started = false;
2425
+ let deviceConnectionSweepTimer = null;
2426
+ let boundPort = requestedPort;
2427
+ let lastError = '';
2428
+ let hostTarget = null;
2349
2429
  let publicIPv4Cache = {
2350
2430
  address: safeString(env.LIVEDESK_REMOTE_PUBLIC_IPV4 || env.LIVEDESK_PUBLIC_IPV4 || env.REMOTE_HUB_PUBLIC_IPV4, 64),
2351
2431
  updatedAt: 0,
@@ -2666,12 +2746,14 @@ export function createRemoteHub(options = {}) {
2666
2746
  protocol: 'tcp-jsonl',
2667
2747
  protocolVersion: REMOTE_PROTOCOL_VERSION,
2668
2748
  agentProtocol: REMOTE_AGENT_PROTOCOL,
2669
- frameProtocol: buildRemoteFrameProtocolDescriptor(),
2670
- frameModes: getSupportedRemoteFrameModeProfiles(),
2671
- heartbeatMs,
2672
- taskTimeoutMs,
2673
- managerPackage,
2674
- managerVersion,
2749
+ frameProtocol: buildRemoteFrameProtocolDescriptor(),
2750
+ frameModes: getSupportedRemoteFrameModeProfiles(),
2751
+ heartbeatMs,
2752
+ deviceConnectionStaleMs,
2753
+ deviceConnectionSweepMs,
2754
+ taskTimeoutMs,
2755
+ managerPackage,
2756
+ managerVersion,
2675
2757
  hostInstanceId,
2676
2758
  agentPackage: '@livedesk/client',
2677
2759
  agentEndpoint: routeInfo.endpoint,
@@ -2934,7 +3016,7 @@ export function createRemoteHub(options = {}) {
2934
3016
  return true;
2935
3017
  }
2936
3018
 
2937
- function failPendingCommandResultWaiters(device, error = 'device-disconnected') {
3019
+ function failPendingCommandResultWaiters(device, error = 'device-disconnected') {
2938
3020
  if (!device?.deviceId) {
2939
3021
  return;
2940
3022
  }
@@ -2951,10 +3033,31 @@ export function createRemoteHub(options = {}) {
2951
3033
  result: null,
2952
3034
  error: safeString(error, 500) || 'command-result-unavailable'
2953
3035
  });
2954
- }
2955
- }
2956
-
2957
- function failPendingCommandResultWaiter(device, commandId, error = 'command-not-sent') {
3036
+ }
3037
+ }
3038
+
3039
+ function failPendingFileCommandResultWaiters(device, error = 'file-channel-disconnected') {
3040
+ if (!device?.deviceId) {
3041
+ return;
3042
+ }
3043
+ for (const [waiterKey, waiter] of pendingCommandResultWaiters) {
3044
+ if (waiter.deviceId !== device.deviceId
3045
+ || waiter.sessionId !== device.sessionId
3046
+ || waiter.channel !== 'file') {
3047
+ continue;
3048
+ }
3049
+ pendingCommandResultWaiters.delete(waiterKey);
3050
+ clearPendingCommandResultWaiterDeadline(waiter);
3051
+ waiter.resolve({
3052
+ ok: false,
3053
+ commandId: waiter.commandId,
3054
+ result: null,
3055
+ error: safeString(error, 500) || 'file-channel-disconnected'
3056
+ });
3057
+ }
3058
+ }
3059
+
3060
+ function failPendingCommandResultWaiter(device, commandId, error = 'command-not-sent') {
2958
3061
  const normalizedCommandId = safeString(commandId, 128);
2959
3062
  const waiterKey = buildPendingCommandResultWaiterKey(
2960
3063
  device?.deviceId,
@@ -2975,7 +3078,7 @@ export function createRemoteHub(options = {}) {
2975
3078
  return true;
2976
3079
  }
2977
3080
 
2978
- function waitForCommandResult(device, commandId, timeoutMs = liveCaptureStopAckTimeoutMs) {
3081
+ function waitForCommandResult(device, commandId, timeoutMs = liveCaptureStopAckTimeoutMs, metadata = {}) {
2979
3082
  const normalizedCommandId = safeString(commandId, 128);
2980
3083
  const waiterKey = buildPendingCommandResultWaiterKey(
2981
3084
  device?.deviceId,
@@ -3001,11 +3104,13 @@ export function createRemoteHub(options = {}) {
3001
3104
  error: 'command-result-wait-replaced'
3002
3105
  });
3003
3106
  }
3004
- const waiterRecord = {
3005
- deviceId: device.deviceId,
3006
- sessionId: device.sessionId,
3007
- commandId: normalizedCommandId,
3008
- timer: null,
3107
+ const waiterRecord = {
3108
+ deviceId: device.deviceId,
3109
+ sessionId: device.sessionId,
3110
+ commandId: normalizedCommandId,
3111
+ channel: safeString(metadata.channel, 24),
3112
+ operationId: safeString(metadata.operationId, 128),
3113
+ timer: null,
3009
3114
  deadlineImmediate: null,
3010
3115
  resolve
3011
3116
  };
@@ -3038,9 +3143,246 @@ export function createRemoteHub(options = {}) {
3038
3143
  });
3039
3144
  }, timeoutMs);
3040
3145
  waiterRecord.timer.unref?.();
3041
- pendingCommandResultWaiters.set(waiterKey, waiterRecord);
3042
- });
3043
- }
3146
+ pendingCommandResultWaiters.set(waiterKey, waiterRecord);
3147
+ });
3148
+ }
3149
+
3150
+ function buildClipboardOperationKey(deviceId, sessionId, operationId) {
3151
+ const normalizedDeviceId = safeString(deviceId, 160);
3152
+ const normalizedSessionId = safeString(sessionId, 160);
3153
+ const normalizedOperationId = safeString(operationId, 128);
3154
+ return normalizedDeviceId && normalizedSessionId && normalizedOperationId
3155
+ ? `${normalizedDeviceId.length}:${normalizedDeviceId}`
3156
+ + `${normalizedSessionId.length}:${normalizedSessionId}`
3157
+ + `${normalizedOperationId.length}:${normalizedOperationId}`
3158
+ : '';
3159
+ }
3160
+
3161
+ function queueClipboardAgentCleanup(device, operation, reason) {
3162
+ if (operation?.inFlight || !device?.fileSocket || device.fileSocket.destroyed) {
3163
+ return false;
3164
+ }
3165
+ const cleanupCommand = operation.direction === 'paste'
3166
+ ? 'clipboard.paste.cancel'
3167
+ : 'clipboard.copy.end';
3168
+ const commandId = crypto.randomUUID();
3169
+ try {
3170
+ writeJsonLine(device.fileSocket, {
3171
+ type: 'command',
3172
+ commandId,
3173
+ command: cleanupCommand,
3174
+ payload: {
3175
+ operationId: operation.operationId,
3176
+ reason: safeString(reason, 120) || 'clipboard-owner-closed'
3177
+ },
3178
+ issuedAt: new Date().toISOString()
3179
+ });
3180
+ device.counters.commandsSent += 1;
3181
+ emitRemoteEvent('RemoteCommandQueued', device, {
3182
+ commandId,
3183
+ command: cleanupCommand,
3184
+ channel: 'file',
3185
+ operationId: operation.operationId,
3186
+ cleanup: true
3187
+ });
3188
+ return true;
3189
+ } catch {
3190
+ // The exact side channel is already closing. Native session
3191
+ // teardown remains the authoritative cleanup.
3192
+ return false;
3193
+ }
3194
+ }
3195
+
3196
+ function clearClipboardOperationsForDevice(device, reason = 'clipboard-owner-closed', ownerConnectionId = '') {
3197
+ const requestedOwner = safeString(ownerConnectionId, 128);
3198
+ let cleared = 0;
3199
+ for (const [key, operation] of clipboardOperations) {
3200
+ if (operation.deviceId !== device?.deviceId
3201
+ || operation.sessionId !== device?.sessionId
3202
+ || (requestedOwner && operation.hubConnectionId !== requestedOwner)) {
3203
+ continue;
3204
+ }
3205
+ queueClipboardAgentCleanup(device, operation, reason);
3206
+ clipboardOperations.delete(key);
3207
+ cleared += 1;
3208
+ }
3209
+ if (cleared > 0) {
3210
+ emitRemoteEvent('RemoteClipboardOperationsCleared', device, {
3211
+ reason,
3212
+ ownerConnectionId: requestedOwner,
3213
+ count: cleared
3214
+ });
3215
+ }
3216
+ return cleared;
3217
+ }
3218
+
3219
+ function pruneClipboardOperations(nowMs = Date.now()) {
3220
+ for (const [key, operation] of clipboardOperations) {
3221
+ const currentDevice = devices.get(operation.deviceId);
3222
+ const expired = nowMs - Number(operation.touchedAt || 0)
3223
+ > REMOTE_CLIPBOARD_LIMITS.operationIdleTimeoutMs;
3224
+ const staleOwner = !currentDevice
3225
+ || currentDevice.sessionId !== operation.sessionId
3226
+ || safeString(currentDevice.inputOwnerConnectionId, 128) !== operation.hubConnectionId;
3227
+ if (expired || staleOwner) {
3228
+ if (currentDevice?.sessionId === operation.sessionId) {
3229
+ queueClipboardAgentCleanup(
3230
+ currentDevice,
3231
+ operation,
3232
+ expired ? 'clipboard-operation-expired' : 'clipboard-owner-stale');
3233
+ }
3234
+ clipboardOperations.delete(key);
3235
+ }
3236
+ }
3237
+ }
3238
+
3239
+ function clipboardCapabilityError(device, direction, contentKind = 'text') {
3240
+ const capabilities = device?.capabilities || {};
3241
+ if (capabilities.clipboardProtocol !== REMOTE_CLIPBOARD_PROTOCOL
3242
+ || capabilities.clipboardText !== true) {
3243
+ return 'device-clipboard-text-unavailable';
3244
+ }
3245
+ if (capabilities.sessionBoundSideChannels !== true) {
3246
+ return 'device-clipboard-session-bound-channel-unavailable';
3247
+ }
3248
+ if (direction === 'copy' && capabilities.clipboardCopy !== true) {
3249
+ return 'device-clipboard-copy-unavailable';
3250
+ }
3251
+ if (direction === 'paste' && capabilities.clipboardPaste !== true) {
3252
+ return 'device-clipboard-paste-unavailable';
3253
+ }
3254
+ if (contentKind === 'image' && capabilities.clipboardImage !== true) {
3255
+ return 'device-clipboard-image-unavailable';
3256
+ }
3257
+ if (contentKind === 'files' && capabilities.clipboardFiles !== true) {
3258
+ return 'device-clipboard-files-unavailable';
3259
+ }
3260
+ for (const key of ['clipboardMaxTextBytes', 'clipboardMaxTotalBytes', 'clipboardMaxChunkBytes']) {
3261
+ if (!Number.isSafeInteger(Number(capabilities[key])) || Number(capabilities[key]) <= 0) {
3262
+ return 'device-clipboard-limits-unavailable';
3263
+ }
3264
+ }
3265
+ if (Number(capabilities.clipboardMaxChunkBytes) < REMOTE_CLIPBOARD_LIMITS.maxChunkBytes) {
3266
+ return 'device-clipboard-chunk-limit-unavailable';
3267
+ }
3268
+ if (contentKind === 'image'
3269
+ && (!Number.isSafeInteger(Number(
3270
+ capabilities.clipboardMaxImageBytes ?? capabilities.clipboardMaxPngBytes))
3271
+ || Number(capabilities.clipboardMaxImageBytes ?? capabilities.clipboardMaxPngBytes) <= 0)) {
3272
+ return 'device-clipboard-limits-unavailable';
3273
+ }
3274
+ if (contentKind === 'files'
3275
+ && (!Number.isSafeInteger(Number(capabilities.clipboardMaxFiles))
3276
+ || Number(capabilities.clipboardMaxFiles) <= 0)) {
3277
+ return 'device-clipboard-limits-unavailable';
3278
+ }
3279
+ return '';
3280
+ }
3281
+
3282
+ function clipboardManifestCapabilityError(device, manifest) {
3283
+ if (!manifest) {
3284
+ return '';
3285
+ }
3286
+ const capabilities = device?.capabilities || {};
3287
+ if (manifest.totalBytes > Number(capabilities.clipboardMaxTotalBytes || 0)) {
3288
+ return 'device-clipboard-total-limit-exceeded';
3289
+ }
3290
+ if (manifest.contentKind === 'text'
3291
+ && manifest.totalBytes > Number(capabilities.clipboardMaxTextBytes || 0)) {
3292
+ return 'device-clipboard-text-limit-exceeded';
3293
+ }
3294
+ if (manifest.contentKind === 'image'
3295
+ && manifest.totalBytes > Number(
3296
+ capabilities.clipboardMaxImageBytes ?? capabilities.clipboardMaxPngBytes ?? 0)) {
3297
+ return 'device-clipboard-image-limit-exceeded';
3298
+ }
3299
+ if (manifest.contentKind === 'files'
3300
+ && manifest.items.length > Number(capabilities.clipboardMaxFiles || 0)) {
3301
+ return 'device-clipboard-file-count-limit-exceeded';
3302
+ }
3303
+ return '';
3304
+ }
3305
+
3306
+ function validateCurrentClipboardBinding(deviceId, request = {}, contentKind = 'text', options = {}) {
3307
+ const device = devices.get(String(deviceId || ''));
3308
+ if (!device) {
3309
+ return { ok: false, error: 'device-not-found' };
3310
+ }
3311
+ if (device.synthetic === true) {
3312
+ return { ok: false, error: 'device-clipboard-unavailable' };
3313
+ }
3314
+ if (device.connected && !isDeviceConnectionFresh(device)) {
3315
+ retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
3316
+ return { ok: false, error: 'device-not-connected' };
3317
+ }
3318
+ if (!device.connected || !device.socket || device.socket.destroyed) {
3319
+ return { ok: false, error: 'device-not-connected' };
3320
+ }
3321
+
3322
+ const policy = getDevicePolicy(device);
3323
+ if (policy.accessMode === 'block-remote-access') {
3324
+ return { ok: false, error: 'remote-access-blocked-by-settings' };
3325
+ }
3326
+ if (policy.allowControl !== true) {
3327
+ return { ok: false, error: 'allowControl-blocked-by-settings' };
3328
+ }
3329
+ if (policy.allowClipboardText !== true) {
3330
+ return { ok: false, error: 'allowClipboardText-blocked-by-settings' };
3331
+ }
3332
+ if (remoteClipboardContentNeedsFileTransfer(contentKind)
3333
+ && policy.allowFileTransfer !== true) {
3334
+ return { ok: false, error: 'allowFileTransfer-blocked-by-settings' };
3335
+ }
3336
+
3337
+ const direction = request.clipboardDirection
3338
+ || (String(request.action || '').includes('.paste') ? 'paste' : 'copy');
3339
+ const capabilityError = clipboardCapabilityError(device, direction, contentKind);
3340
+ if (capabilityError) {
3341
+ return { ok: false, error: capabilityError };
3342
+ }
3343
+ const manifestCapabilityError = clipboardManifestCapabilityError(
3344
+ device,
3345
+ request.payload?.manifest || request.manifest);
3346
+ if (manifestCapabilityError) {
3347
+ return { ok: false, error: manifestCapabilityError };
3348
+ }
3349
+
3350
+ const controlStream = getActiveControlStream(device);
3351
+ if (!controlStream
3352
+ || !liveStreamHasCurrentFrame(controlStream)
3353
+ || !safeString(controlStream.commandId, 128)
3354
+ || Number(controlStream.captureGeneration || 0) <= 0) {
3355
+ return { ok: false, error: 'CONTROL_NOT_READY' };
3356
+ }
3357
+ if (getPendingLiveStreamDescriptor(controlStream)) {
3358
+ return { ok: false, error: 'CAPTURE_TRANSITION_IN_PROGRESS' };
3359
+ }
3360
+ const binding = request.binding || request;
3361
+ if (safeString(binding.controlSessionId || binding.sessionId, 160)
3362
+ !== safeString(device.sessionId, 160)
3363
+ || safeString(binding.controlCommandId || binding.commandId, 128)
3364
+ !== safeString(controlStream.commandId, 128)) {
3365
+ return { ok: false, error: 'STALE_CONTROL_SESSION' };
3366
+ }
3367
+ if (Number(binding.captureGeneration || 0)
3368
+ !== Number(controlStream.captureGeneration || 0)) {
3369
+ return { ok: false, error: 'STALE_CAPTURE_GENERATION' };
3370
+ }
3371
+ const activeMonitorIndex = normalizeMonitorIndex(controlStream.monitorIndex);
3372
+ if (Number(binding.monitorIndex) !== activeMonitorIndex) {
3373
+ return { ok: false, error: 'STALE_CONTROL_MONITOR' };
3374
+ }
3375
+ const hubConnectionId = safeString(request.hubConnectionId, 128);
3376
+ if (!hubConnectionId
3377
+ || hubConnectionId !== safeString(device.inputOwnerConnectionId, 128)) {
3378
+ return { ok: false, error: 'input-owner-not-current' };
3379
+ }
3380
+ if (options.requireFileSocket === true
3381
+ && (!device.fileSocket || device.fileSocket.destroyed)) {
3382
+ return { ok: false, error: 'clipboard-file-channel-unavailable' };
3383
+ }
3384
+ return { ok: true, device, controlStream, activeMonitorIndex, policy };
3385
+ }
3044
3386
 
3045
3387
  function inspectAnnexBH264(payload) {
3046
3388
  if (!Buffer.isBuffer(payload) || payload.length < 5) {
@@ -3922,13 +4264,15 @@ export function createRemoteHub(options = {}) {
3922
4264
  }
3923
4265
  }
3924
4266
 
3925
- function closeFileSocket(device, reason = 'file-socket-closed') {
3926
- const socket = device?.fileSocket;
3927
- if (!socket) {
3928
- return;
3929
- }
3930
-
3931
- device.fileSocket = null;
4267
+ function closeFileSocket(device, reason = 'file-socket-closed') {
4268
+ const socket = device?.fileSocket;
4269
+ if (!socket) {
4270
+ return;
4271
+ }
4272
+
4273
+ clearClipboardOperationsForDevice(device, reason);
4274
+ failPendingFileCommandResultWaiters(device, 'clipboard-file-channel-disconnected');
4275
+ device.fileSocket = null;
3932
4276
  fileSockets.delete(socket);
3933
4277
  try {
3934
4278
  writeJsonLine(socket, { type: 'disconnect', channel: 'file', reason });
@@ -3942,13 +4286,14 @@ export function createRemoteHub(options = {}) {
3942
4286
  }
3943
4287
  }
3944
4288
 
3945
- function closeInputSocket(device, reason = 'input-socket-closed') {
4289
+ function closeInputSocket(device, reason = 'input-socket-closed') {
3946
4290
  const socket = device?.inputSocket;
3947
4291
  if (!socket) {
3948
4292
  return;
3949
4293
  }
3950
4294
 
3951
- const writeOwner = device.inputWriteOwner;
4295
+ const writeOwner = device.inputWriteOwner;
4296
+ clearClipboardOperationsForDevice(device, reason);
3952
4297
  device.inputSocket = null;
3953
4298
  device.inputWriteOwner = null;
3954
4299
  device.inputSocketConnectionId = '';
@@ -3968,7 +4313,7 @@ export function createRemoteHub(options = {}) {
3968
4313
  }
3969
4314
  }
3970
4315
 
3971
- function detachInputSocket(socket, reason = 'input-socket-closed') {
4316
+ function detachInputSocket(socket, reason = 'input-socket-closed') {
3972
4317
  const deviceId = inputSockets.get(socket);
3973
4318
  inputSockets.delete(socket);
3974
4319
  if (!deviceId) {
@@ -3980,7 +4325,8 @@ export function createRemoteHub(options = {}) {
3980
4325
  return;
3981
4326
  }
3982
4327
 
3983
- const writeOwner = device.inputWriteOwner;
4328
+ const writeOwner = device.inputWriteOwner;
4329
+ clearClipboardOperationsForDevice(device, reason);
3984
4330
  device.inputSocket = null;
3985
4331
  device.inputWriteOwner = null;
3986
4332
  device.inputSocketConnectionId = '';
@@ -4025,7 +4371,7 @@ export function createRemoteHub(options = {}) {
4025
4371
  emitRemoteEvent('RemoteAudioSocketDisconnected', device, { reason });
4026
4372
  }
4027
4373
 
4028
- function detachFileSocket(socket, reason = 'file-socket-closed') {
4374
+ function detachFileSocket(socket, reason = 'file-socket-closed') {
4029
4375
  const deviceId = fileSockets.get(socket);
4030
4376
  fileSockets.delete(socket);
4031
4377
  if (!deviceId) {
@@ -4033,38 +4379,107 @@ export function createRemoteHub(options = {}) {
4033
4379
  }
4034
4380
 
4035
4381
  const device = devices.get(deviceId);
4036
- if (!device || device.fileSocket !== socket) {
4037
- return;
4038
- }
4039
-
4040
- device.fileSocket = null;
4382
+ if (!device || device.fileSocket !== socket) {
4383
+ return;
4384
+ }
4385
+
4386
+ clearClipboardOperationsForDevice(device, reason);
4387
+ failPendingFileCommandResultWaiters(device, 'clipboard-file-channel-disconnected');
4388
+ device.fileSocket = null;
4041
4389
  device.fileLastSeenAt = new Date().toISOString();
4042
- emitRemoteEvent('RemoteFileSocketDisconnected', device, { reason });
4043
- }
4044
-
4045
- function getDeviceActivityMs(device) {
4046
- return Math.max(
4047
- Date.parse(device?.lastSeenAt || '') || 0,
4048
- Date.parse(device?.lastStatusAt || '') || 0,
4049
- Date.parse(device?.connectedAt || '') || 0
4050
- );
4051
- }
4052
-
4053
- function isDeviceConnectionFresh(device, nowMs = Date.now()) {
4390
+ emitRemoteEvent('RemoteFileSocketDisconnected', device, { reason });
4391
+ }
4392
+
4393
+ function getDeviceConnectionHeartbeatMs(device) {
4394
+ return Date.parse(device?.lastStatusAt || '')
4395
+ || Date.parse(device?.connectedAt || '')
4396
+ || 0;
4397
+ }
4398
+
4399
+ function isDeviceConnectionFresh(device, nowMs = Date.now()) {
4054
4400
  if (!device?.connected || !device.socket || device.socket.destroyed) {
4055
- return false;
4056
- }
4057
-
4058
- const lastActivityMs = getDeviceActivityMs(device);
4059
- if (!lastActivityMs) {
4060
- return true;
4061
- }
4062
-
4063
- const freshnessMs = Math.max(DUPLICATE_DEVICE_ACTIVE_REJECT_MS, heartbeatMs * 3);
4064
- return nowMs - lastActivityMs <= freshnessMs;
4065
- }
4066
-
4067
- function rejectDuplicateActiveDevice(socket, existing, attemptedSessionId) {
4401
+ return false;
4402
+ }
4403
+
4404
+ const lastHeartbeatMs = getDeviceConnectionHeartbeatMs(device);
4405
+ if (!lastHeartbeatMs) {
4406
+ return true;
4407
+ }
4408
+
4409
+ return nowMs - lastHeartbeatMs <= deviceConnectionStaleMs;
4410
+ }
4411
+
4412
+ function retireStaleDeviceConnection(device, reason = 'status-heartbeat-timeout') {
4413
+ const deviceId = safeString(device?.deviceId, 160);
4414
+ const sessionId = safeString(device?.sessionId, 160);
4415
+ const socket = device?.socket;
4416
+ if (!deviceId
4417
+ || !sessionId
4418
+ || !socket
4419
+ || socket.destroyed
4420
+ || devices.get(deviceId) !== device
4421
+ || device.socket !== socket
4422
+ || device.sessionId !== sessionId) {
4423
+ return false;
4424
+ }
4425
+
4426
+ closeInputSocket(device, reason);
4427
+ closeFrameSocket(device, reason);
4428
+ closeAudioSocket(device, reason);
4429
+ closeFileSocket(device, reason);
4430
+ try {
4431
+ writeJsonLine(socket, { type: 'disconnect', reason, sessionId });
4432
+ } catch {
4433
+ // The stale connection is retired below even if the final notice fails.
4434
+ }
4435
+ detachSocket(socket, reason);
4436
+ try {
4437
+ socket.destroy?.();
4438
+ } catch {
4439
+ // detachSocket already made the exact session terminal.
4440
+ }
4441
+ return true;
4442
+ }
4443
+
4444
+ function sweepStaleDeviceConnections(nowMs = Date.now()) {
4445
+ pruneClipboardOperations(nowMs);
4446
+ let retired = 0;
4447
+ for (const device of devices.values()) {
4448
+ if (device?.synthetic === true || isDeviceConnectionFresh(device, nowMs)) {
4449
+ continue;
4450
+ }
4451
+ if (!device?.connected || !device.socket || device.socket.destroyed) {
4452
+ continue;
4453
+ }
4454
+ const ageMs = Math.max(0, nowMs - getDeviceConnectionHeartbeatMs(device));
4455
+ if (retireStaleDeviceConnection(device, 'status-heartbeat-timeout')) {
4456
+ retired += 1;
4457
+ logWarn(
4458
+ 'remote',
4459
+ `retired stale device session ${device.deviceName} (${device.deviceId}) `
4460
+ + `session=${device.sessionId} heartbeatAgeMs=${ageMs} `
4461
+ + `staleAfterMs=${deviceConnectionStaleMs}`);
4462
+ }
4463
+ }
4464
+ return retired;
4465
+ }
4466
+
4467
+ function scheduleDeviceConnectionSweep() {
4468
+ if (!started || deviceConnectionSweepTimer) {
4469
+ return;
4470
+ }
4471
+ deviceConnectionSweepTimer = setTimeout(() => {
4472
+ deviceConnectionSweepTimer = null;
4473
+ if (!started) {
4474
+ return;
4475
+ }
4476
+ sweepStaleDeviceConnections();
4477
+ scheduleDeviceConnectionSweep();
4478
+ }, deviceConnectionSweepMs);
4479
+ deviceConnectionSweepTimer.unref?.();
4480
+ }
4481
+
4482
+ function rejectDuplicateActiveDevice(socket, existing, attemptedSessionId) {
4068
4483
  const retryAfterMs = Math.max(heartbeatMs, duplicateDeviceRetryAfterMs);
4069
4484
  writeJsonLine(socket, {
4070
4485
  type: 'disconnect',
@@ -4516,16 +4931,20 @@ export function createRemoteHub(options = {}) {
4516
4931
  return device;
4517
4932
  }
4518
4933
 
4519
- function attachInputSocket(socket, hello) {
4520
- const deviceId = normalizeDeviceId(hello.deviceId);
4521
- const device = devices.get(deviceId);
4934
+ function attachInputSocket(socket, hello) {
4935
+ const deviceId = normalizeDeviceId(hello.deviceId);
4936
+ const device = devices.get(deviceId);
4522
4937
  if (!device || !device.connected || !device.socket || device.socket.destroyed) {
4523
4938
  writeJsonLine(socket, { type: 'error', error: 'device-not-connected' });
4524
4939
  socket.destroy();
4525
- return null;
4526
- }
4527
-
4528
- closeInputSocket(device, 'replaced-by-new-input-socket');
4940
+ return null;
4941
+ }
4942
+ if (safeString(hello.parentSessionId, 160) !== safeString(device.sessionId, 160)) {
4943
+ writeJsonLineAndClose(socket, { type: 'error', error: 'side-channel-parent-session-stale' });
4944
+ return null;
4945
+ }
4946
+
4947
+ closeInputSocket(device, 'replaced-by-new-input-socket');
4529
4948
 
4530
4949
  const now = new Date().toISOString();
4531
4950
  const inputSocketConnectionId = crypto.randomUUID();
@@ -4639,16 +5058,20 @@ export function createRemoteHub(options = {}) {
4639
5058
  return device;
4640
5059
  }
4641
5060
 
4642
- function attachFileSocket(socket, hello) {
4643
- const deviceId = normalizeDeviceId(hello.deviceId);
4644
- const device = devices.get(deviceId);
5061
+ function attachFileSocket(socket, hello) {
5062
+ const deviceId = normalizeDeviceId(hello.deviceId);
5063
+ const device = devices.get(deviceId);
4645
5064
  if (!device || !device.connected || !device.socket || device.socket.destroyed) {
4646
5065
  writeJsonLine(socket, { type: 'error', error: 'device-not-connected' });
4647
5066
  socket.destroy();
4648
- return null;
4649
- }
4650
-
4651
- closeFileSocket(device, 'replaced-by-new-file-socket');
5067
+ return null;
5068
+ }
5069
+ if (safeString(hello.parentSessionId, 160) !== safeString(device.sessionId, 160)) {
5070
+ writeJsonLineAndClose(socket, { type: 'error', error: 'side-channel-parent-session-stale' });
5071
+ return null;
5072
+ }
5073
+
5074
+ closeFileSocket(device, 'replaced-by-new-file-socket');
4652
5075
  const now = new Date().toISOString();
4653
5076
  device.fileSocket = socket;
4654
5077
  device.fileConnectedAt = now;
@@ -7971,12 +8394,13 @@ export function createRemoteHub(options = {}) {
7971
8394
  await listenOnPort(requestedPort);
7972
8395
  try {
7973
8396
  await relayControl?.start?.();
7974
- } catch {
7975
- logWarn('relay', 'Encrypted relay control could not start; direct TCP remains available.');
7976
- }
7977
- } catch (error) {
7978
- if (error?.code === 'EADDRINUSE') {
7979
- const conflict = new Error(
8397
+ } catch {
8398
+ logWarn('relay', 'Encrypted relay control could not start; direct TCP remains available.');
8399
+ }
8400
+ scheduleDeviceConnectionSweep();
8401
+ } catch (error) {
8402
+ if (error?.code === 'EADDRINUSE') {
8403
+ const conflict = new Error(
7980
8404
  `LiveDesk Hub requires client endpoint TCP ${requestedPort}, but that port is already in use. Close the owning process or start Hub with an explicit --remote-port value.`);
7981
8405
  conflict.code = 'EADDRINUSE';
7982
8406
  conflict.port = requestedPort;
@@ -7988,12 +8412,16 @@ export function createRemoteHub(options = {}) {
7988
8412
  }
7989
8413
 
7990
8414
  return getStatus({ includeSecrets: false });
7991
- }
7992
-
7993
- async function close() {
7994
- for (const state of [...agentBinaryIngressStates]) {
7995
- closeAgentBinaryIngress(state, 'hub-shutdown');
7996
- }
8415
+ }
8416
+
8417
+ async function close() {
8418
+ if (deviceConnectionSweepTimer) {
8419
+ clearTimeout(deviceConnectionSweepTimer);
8420
+ deviceConnectionSweepTimer = null;
8421
+ }
8422
+ for (const state of [...agentBinaryIngressStates]) {
8423
+ closeAgentBinaryIngress(state, 'hub-shutdown');
8424
+ }
7997
8425
 
7998
8426
  for (const device of devices.values()) {
7999
8427
  abortTransportDiagnosticsForDevice(device, 'hub-shutdown');
@@ -8128,9 +8556,12 @@ export function createRemoteHub(options = {}) {
8128
8556
  };
8129
8557
  }
8130
8558
 
8131
- function sendCommand(deviceId, command) {
8132
- const device = devices.get(String(deviceId || ''));
8133
- const commandName = safeString(command?.command || 'ping', 80);
8559
+ function sendCommand(deviceId, command) {
8560
+ const device = devices.get(String(deviceId || ''));
8561
+ const commandName = safeString(command?.command || 'ping', 80);
8562
+ if (REMOTE_CLIPBOARD_FILE_ACTIONS.has(commandName)) {
8563
+ return { ok: false, error: 'clipboard-command-requires-awaited-file-channel' };
8564
+ }
8134
8565
  const requiredPermission = commandName === 'input.control'
8135
8566
  ? 'allowControl'
8136
8567
  : commandName === 'system.power'
@@ -8166,12 +8597,16 @@ export function createRemoteHub(options = {}) {
8166
8597
  result: { ok: true, synthetic: true },
8167
8598
  error: ''
8168
8599
  });
8169
- return { ok: true, commandId, synthetic: true };
8170
- }
8171
-
8172
- if (!device?.socket || device.socket.destroyed || !device.connected) {
8173
- return { ok: false, error: 'device-not-connected' };
8174
- }
8600
+ return { ok: true, commandId, synthetic: true };
8601
+ }
8602
+
8603
+ if (device?.connected && !isDeviceConnectionFresh(device)) {
8604
+ retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
8605
+ return { ok: false, error: 'device-not-connected' };
8606
+ }
8607
+ if (!device?.socket || device.socket.destroyed || !device.connected) {
8608
+ return { ok: false, error: 'device-not-connected' };
8609
+ }
8175
8610
 
8176
8611
  const commandId = safeString(command?.commandId, 128) || crypto.randomUUID();
8177
8612
  const payload = {
@@ -8184,29 +8619,300 @@ export function createRemoteHub(options = {}) {
8184
8619
 
8185
8620
  const dedicatedFileSocket = commandName.startsWith('file.transfer')
8186
8621
  && device.fileSocket
8187
- && !device.fileSocket.destroyed
8188
- ? device.fileSocket
8189
- : null;
8190
- writeJsonLine(dedicatedFileSocket || device.socket, payload);
8191
- device.counters.commandsSent += 1;
8192
- emitRemoteEvent('RemoteCommandQueued', device, {
8193
- commandId,
8622
+ && !device.fileSocket.destroyed
8623
+ ? device.fileSocket
8624
+ : null;
8625
+ try {
8626
+ writeJsonLine(dedicatedFileSocket || device.socket, payload);
8627
+ } catch {
8628
+ retireStaleDeviceConnection(device, 'command-channel-write-failed');
8629
+ return { ok: false, error: 'device-not-connected' };
8630
+ }
8631
+ device.counters.commandsSent += 1;
8632
+ emitRemoteEvent('RemoteCommandQueued', device, {
8633
+ commandId,
8194
8634
  command: payload.command,
8195
8635
  channel: dedicatedFileSocket ? 'file' : 'control'
8196
8636
  });
8197
- return { ok: true, commandId };
8198
- }
8199
-
8200
- function refreshDevicePolicies(deviceIds = undefined) {
8637
+ return { ok: true, commandId };
8638
+ }
8639
+
8640
+ async function sendClipboardCommand(deviceId, request = {}) {
8641
+ pruneClipboardOperations();
8642
+ const action = safeString(request.action, 80).toLowerCase();
8643
+ if (!REMOTE_CLIPBOARD_FILE_ACTIONS.has(action)) {
8644
+ return { ok: false, error: 'clipboard-file-action-invalid' };
8645
+ }
8646
+ const operationId = safeString(request.operationId, 128);
8647
+ if (!operationId) {
8648
+ return { ok: false, error: 'clipboard-operation-id-invalid' };
8649
+ }
8650
+
8651
+ const direction = action.startsWith('clipboard.paste.')
8652
+ ? 'paste'
8653
+ : action.startsWith('clipboard.copy.')
8654
+ ? 'copy'
8655
+ : '';
8656
+ const operationKey = buildClipboardOperationKey(deviceId, request.binding?.controlSessionId, operationId);
8657
+ let operation = clipboardOperations.get(operationKey);
8658
+ const beginsOperation = action === 'clipboard.copy.begin' || action === 'clipboard.paste.begin';
8659
+ const endsOperation = action === 'clipboard.copy.end' || action === 'clipboard.paste.cancel';
8660
+ const requestedManifest = request.payload?.manifest || null;
8661
+
8662
+ if (beginsOperation) {
8663
+ if (operation) {
8664
+ return { ok: false, error: 'clipboard-operation-already-exists' };
8665
+ }
8666
+ const activeForDevice = [...clipboardOperations.values()].filter(candidate =>
8667
+ candidate.deviceId === String(deviceId || '')
8668
+ && candidate.sessionId === request.binding?.controlSessionId);
8669
+ if (activeForDevice.some(candidate => candidate.direction === direction)) {
8670
+ return { ok: false, error: 'clipboard-operation-direction-busy' };
8671
+ }
8672
+ if (activeForDevice.length >= REMOTE_CLIPBOARD_LIMITS.maxOperationsPerDevice) {
8673
+ return { ok: false, error: 'clipboard-operation-capacity-reached' };
8674
+ }
8675
+ operation = {
8676
+ deviceId: String(deviceId || ''),
8677
+ sessionId: safeString(request.binding?.controlSessionId, 160),
8678
+ operationId,
8679
+ direction,
8680
+ hubConnectionId: safeString(request.hubConnectionId, 128),
8681
+ controlCommandId: safeString(request.binding?.controlCommandId, 128),
8682
+ captureGeneration: Number(request.binding?.captureGeneration || 0),
8683
+ monitorIndex: Number(request.binding?.monitorIndex),
8684
+ contentKind: requestedManifest?.contentKind || 'text',
8685
+ manifest: requestedManifest,
8686
+ offsets: new Map(),
8687
+ status: action === 'clipboard.paste.begin' ? 'preparing' : 'copying',
8688
+ inFlight: false,
8689
+ touchedAt: Date.now()
8690
+ };
8691
+ clipboardOperations.set(operationKey, operation);
8692
+ } else {
8693
+ if (!operation) {
8694
+ return { ok: false, error: 'clipboard-operation-not-found' };
8695
+ }
8696
+ const requestedDirection = direction || operation.direction;
8697
+ if (operation.direction !== requestedDirection
8698
+ || operation.hubConnectionId !== safeString(request.hubConnectionId, 128)
8699
+ || operation.controlCommandId !== safeString(request.binding?.controlCommandId, 128)
8700
+ || operation.captureGeneration !== Number(request.binding?.captureGeneration || 0)
8701
+ || operation.monitorIndex !== Number(request.binding?.monitorIndex)) {
8702
+ return { ok: false, error: 'clipboard-operation-owner-stale' };
8703
+ }
8704
+ }
8705
+
8706
+ if (operation.inFlight) {
8707
+ return { ok: false, error: 'clipboard-operation-busy' };
8708
+ }
8709
+
8710
+ const contentKind = operation.contentKind || requestedManifest?.contentKind || 'text';
8711
+ const bindingResult = validateCurrentClipboardBinding(
8712
+ deviceId,
8713
+ { ...request, clipboardDirection: operation.direction },
8714
+ contentKind,
8715
+ { requireFileSocket: true });
8716
+ if (!bindingResult.ok) {
8717
+ if (beginsOperation || bindingResult.error?.startsWith('STALE_') || bindingResult.error === 'input-owner-not-current') {
8718
+ clipboardOperations.delete(operationKey);
8719
+ }
8720
+ return bindingResult;
8721
+ }
8722
+
8723
+ const { device } = bindingResult;
8724
+ const fileSocket = device.fileSocket;
8725
+ const itemIndex = request.payload?.itemIndex;
8726
+ const expectedOffset = Number.isInteger(itemIndex)
8727
+ ? Number(operation.offsets.get(itemIndex) || 0)
8728
+ : 0;
8729
+ let outgoingChunkBytes = 0;
8730
+ if (action === 'clipboard.copy.chunk' || action === 'clipboard.paste.chunk') {
8731
+ const item = operation.manifest?.items?.[itemIndex];
8732
+ if (!item) {
8733
+ return { ok: false, error: 'clipboard-item-not-found' };
8734
+ }
8735
+ if (request.payload.offset !== expectedOffset) {
8736
+ return { ok: false, error: 'clipboard-chunk-offset-stale', expectedOffset };
8737
+ }
8738
+ if (action === 'clipboard.copy.chunk'
8739
+ && request.payload.maxBytes > Number(device.capabilities?.clipboardMaxChunkBytes || 0)) {
8740
+ return { ok: false, error: 'device-clipboard-chunk-limit-exceeded' };
8741
+ }
8742
+ if (action === 'clipboard.paste.chunk') {
8743
+ outgoingChunkBytes = Buffer.from(request.payload.dataBase64, 'base64').length;
8744
+ if (outgoingChunkBytes > Number(device.capabilities?.clipboardMaxChunkBytes || 0)) {
8745
+ return { ok: false, error: 'device-clipboard-chunk-limit-exceeded' };
8746
+ }
8747
+ const nextOffset = expectedOffset + outgoingChunkBytes;
8748
+ if (outgoingChunkBytes === 0
8749
+ && !(item.size === 0 && expectedOffset === 0 && request.payload.final === true)) {
8750
+ return { ok: false, error: 'clipboard-empty-chunk-invalid' };
8751
+ }
8752
+ if (nextOffset > item.size
8753
+ || request.payload.final !== (nextOffset === item.size)) {
8754
+ return { ok: false, error: 'clipboard-chunk-boundary-invalid' };
8755
+ }
8756
+ }
8757
+ }
8758
+
8759
+ operation.inFlight = true;
8760
+ operation.touchedAt = Date.now();
8761
+ let commandCompleted = false;
8762
+ const commandId = crypto.randomUUID();
8763
+ const resultPromise = waitForCommandResult(
8764
+ device,
8765
+ commandId,
8766
+ REMOTE_CLIPBOARD_LIMITS.commandTimeoutMs,
8767
+ { channel: 'file', operationId });
8768
+ const commandPayload = {
8769
+ type: 'command',
8770
+ commandId,
8771
+ command: action,
8772
+ payload: {
8773
+ operationId,
8774
+ controlSessionId: operation.sessionId,
8775
+ controlCommandId: operation.controlCommandId,
8776
+ captureGeneration: operation.captureGeneration,
8777
+ monitorIndex: operation.monitorIndex,
8778
+ hubConnectionId: operation.hubConnectionId,
8779
+ ...(request.payload || {})
8780
+ },
8781
+ issuedAt: new Date().toISOString()
8782
+ };
8783
+
8784
+ try {
8785
+ try {
8786
+ // Clipboard payloads are never allowed onto the concurrent
8787
+ // main command socket. The dedicated file side channel owns
8788
+ // the one bounded command and its exact-session waiter.
8789
+ writeJsonLine(fileSocket, commandPayload);
8790
+ } catch {
8791
+ failPendingCommandResultWaiter(device, commandId, 'clipboard-file-channel-write-failed');
8792
+ if (device.fileSocket === fileSocket) {
8793
+ closeFileSocket(device, 'clipboard-file-channel-write-failed');
8794
+ }
8795
+ return { ok: false, error: 'clipboard-file-channel-unavailable' };
8796
+ }
8797
+ device.counters.commandsSent += 1;
8798
+ emitRemoteEvent('RemoteCommandQueued', device, {
8799
+ commandId,
8800
+ command: action,
8801
+ channel: 'file',
8802
+ operationId
8803
+ });
8804
+
8805
+ const awaited = await resultPromise;
8806
+ if (!awaited.ok) {
8807
+ return {
8808
+ ok: false,
8809
+ error: awaited.error || 'clipboard-command-failed',
8810
+ commandId
8811
+ };
8812
+ }
8813
+ if (device.fileSocket !== fileSocket) {
8814
+ return { ok: false, error: 'clipboard-file-channel-owner-changed' };
8815
+ }
8816
+ const currentBinding = validateCurrentClipboardBinding(
8817
+ deviceId,
8818
+ { ...request, clipboardDirection: operation.direction },
8819
+ operation.contentKind || 'text',
8820
+ { requireFileSocket: true });
8821
+ if (!currentBinding.ok) {
8822
+ return currentBinding;
8823
+ }
8824
+
8825
+ let response;
8826
+ try {
8827
+ response = normalizeRemoteClipboardCommandResult(action, operationId, awaited.result);
8828
+ } catch (error) {
8829
+ return {
8830
+ ok: false,
8831
+ error: safeString(error?.code || error?.message, 160) || 'clipboard-response-invalid'
8832
+ };
8833
+ }
8834
+ if (response.manifest) {
8835
+ const responseManifestLimitError = clipboardManifestCapabilityError(device, response.manifest);
8836
+ if (responseManifestLimitError) {
8837
+ clipboardOperations.delete(operationKey);
8838
+ return { ok: false, error: responseManifestLimitError };
8839
+ }
8840
+ const responsePolicy = validateCurrentClipboardBinding(
8841
+ deviceId,
8842
+ { ...request, clipboardDirection: operation.direction },
8843
+ response.manifest.contentKind,
8844
+ { requireFileSocket: true });
8845
+ if (!responsePolicy.ok) {
8846
+ clipboardOperations.delete(operationKey);
8847
+ return responsePolicy;
8848
+ }
8849
+ operation.manifest = response.manifest;
8850
+ operation.contentKind = response.manifest.contentKind;
8851
+ }
8852
+ if (action === 'clipboard.copy.chunk') {
8853
+ const item = operation.manifest?.items?.[response.itemIndex];
8854
+ const nextOffset = response.offset + response.bytes;
8855
+ if (!item
8856
+ || response.itemIndex !== itemIndex
8857
+ || response.offset !== expectedOffset
8858
+ || (response.bytes === 0
8859
+ && !(item.size === 0 && response.offset === 0 && response.final === true))
8860
+ || nextOffset > item.size
8861
+ || response.final !== (nextOffset === item.size)) {
8862
+ return { ok: false, error: 'clipboard-chunk-response-boundary-invalid' };
8863
+ }
8864
+ if (response.bytes > Number(device.capabilities?.clipboardMaxChunkBytes || 0)) {
8865
+ return { ok: false, error: 'device-clipboard-chunk-limit-exceeded' };
8866
+ }
8867
+ operation.offsets.set(response.itemIndex, nextOffset);
8868
+ } else if (action === 'clipboard.paste.chunk') {
8869
+ if (response.itemIndex !== itemIndex
8870
+ || response.offset !== expectedOffset
8871
+ || response.bytes !== outgoingChunkBytes
8872
+ || response.totalBytes !== operation.manifest.items[itemIndex].size
8873
+ || response.final !== request.payload.final) {
8874
+ return { ok: false, error: 'clipboard-chunk-response-boundary-invalid' };
8875
+ }
8876
+ operation.offsets.set(itemIndex, expectedOffset + outgoingChunkBytes);
8877
+ }
8878
+ operation.status = response.status || operation.status;
8879
+ operation.touchedAt = Date.now();
8880
+ if (action === 'clipboard.operation.status' && operation.status === 'not-found') {
8881
+ clipboardOperations.delete(operationKey);
8882
+ return { ok: false, error: 'clipboard-operation-not-found' };
8883
+ }
8884
+ if (action === 'clipboard.operation.status'
8885
+ && ['completed', 'complete', 'applied', 'pasted', 'cancelled', 'cleaned', 'failed', 'expired', 'already-ended']
8886
+ .includes(operation.status)) {
8887
+ clipboardOperations.delete(operationKey);
8888
+ }
8889
+ commandCompleted = true;
8890
+ return response;
8891
+ } finally {
8892
+ const currentOperation = clipboardOperations.get(operationKey);
8893
+ if (currentOperation === operation) {
8894
+ operation.inFlight = false;
8895
+ if (endsOperation || (beginsOperation && !commandCompleted)) {
8896
+ clipboardOperations.delete(operationKey);
8897
+ }
8898
+ }
8899
+ }
8900
+ }
8901
+
8902
+ function refreshDevicePolicies(deviceIds = undefined) {
8201
8903
  const requestedIds = Array.isArray(deviceIds) && deviceIds.length > 0
8202
8904
  ? new Set(deviceIds.map(deviceId => safeString(deviceId, 160)).filter(Boolean))
8203
8905
  : null;
8204
8906
  let updated = 0;
8205
8907
  let total = 0;
8206
- for (const device of devices.values()) {
8207
- if (requestedIds && !requestedIds.has(device.deviceId)) continue;
8208
- if (!device?.socket || device.socket.destroyed || !device.connected) continue;
8209
- total += 1;
8908
+ for (const device of devices.values()) {
8909
+ if (requestedIds && !requestedIds.has(device.deviceId)) continue;
8910
+ if (!device?.socket || device.socket.destroyed || !device.connected) continue;
8911
+ if (device.synthetic !== true && !isDeviceConnectionFresh(device)) {
8912
+ retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
8913
+ continue;
8914
+ }
8915
+ total += 1;
8210
8916
  const effectivePolicy = getDevicePolicy(device);
8211
8917
  if (writeJsonLine(device.socket, {
8212
8918
  type: 'policy.update',
@@ -8225,8 +8931,14 @@ export function createRemoteHub(options = {}) {
8225
8931
  const device = devices.get(String(deviceId || ''));
8226
8932
  const denied = policyError(device);
8227
8933
  if (denied) return { ok: false, error: denied };
8228
- if (!device) return { ok: false, error: 'device-not-found' };
8229
- if (!device.socket || device.socket.destroyed || !device.connected) {
8934
+ if (!device) return { ok: false, error: 'device-not-found' };
8935
+ if (device.synthetic !== true
8936
+ && device.connected
8937
+ && !isDeviceConnectionFresh(device)) {
8938
+ retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
8939
+ return { ok: false, error: 'device-not-connected' };
8940
+ }
8941
+ if (!device.socket || device.socket.destroyed || !device.connected) {
8230
8942
  return { ok: false, error: 'device-not-connected' };
8231
8943
  }
8232
8944
 
@@ -8322,15 +9034,21 @@ export function createRemoteHub(options = {}) {
8322
9034
  return device.inputWriteOwner;
8323
9035
  }
8324
9036
 
8325
- function sendInputControl(deviceId, input = {}) {
8326
- const device = devices.get(String(deviceId || ''));
8327
- if (!device) {
8328
- return { ok: false, error: 'device-not-found' };
8329
- }
8330
-
8331
- if (!device.connected) {
8332
- return { ok: false, error: 'device-not-connected' };
8333
- }
9037
+ function sendInputControl(deviceId, input = {}) {
9038
+ const device = devices.get(String(deviceId || ''));
9039
+ if (!device) {
9040
+ return { ok: false, error: 'device-not-found' };
9041
+ }
9042
+
9043
+ if (device.synthetic !== true
9044
+ && device.connected
9045
+ && !isDeviceConnectionFresh(device)) {
9046
+ retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
9047
+ return { ok: false, error: 'device-not-connected' };
9048
+ }
9049
+ if (!device.connected) {
9050
+ return { ok: false, error: 'device-not-connected' };
9051
+ }
8334
9052
 
8335
9053
  const normalized = normalizeRemoteInputEvent(input);
8336
9054
  if (!normalized.type) {
@@ -8396,9 +9114,51 @@ export function createRemoteHub(options = {}) {
8396
9114
  || device.capabilities?.accessibilityPermission === false) {
8397
9115
  return { ok: false, error: 'INPUT_PERMISSION_DENIED' };
8398
9116
  }
8399
- if (!readCapabilityFlag(device.capabilities, 'control')) {
8400
- return { ok: false, error: 'device-input-control-unavailable' };
8401
- }
9117
+ if (!readCapabilityFlag(device.capabilities, 'control')) {
9118
+ return { ok: false, error: 'device-input-control-unavailable' };
9119
+ }
9120
+
9121
+ const normalizedInputType = normalized.type.toLowerCase();
9122
+ const isClipboardInput = normalizedInputType === 'clipboard.prepare'
9123
+ || normalizedInputType === 'clipboard.copy'
9124
+ || normalizedInputType === 'clipboard.paste';
9125
+ let clipboardManifest = null;
9126
+ if (isClipboardInput) {
9127
+ if (!normalized.clipboardOperationId) {
9128
+ return { ok: false, error: 'clipboard-operation-id-invalid' };
9129
+ }
9130
+ if (!['copy', 'paste'].includes(normalized.clipboardDirection)) {
9131
+ return { ok: false, error: 'clipboard-direction-required' };
9132
+ }
9133
+ if ((normalizedInputType === 'clipboard.copy' && normalized.clipboardDirection !== 'copy')
9134
+ || (normalizedInputType === 'clipboard.paste' && normalized.clipboardDirection !== 'paste')) {
9135
+ return { ok: false, error: 'clipboard-direction-mismatch' };
9136
+ }
9137
+ try {
9138
+ clipboardManifest = normalized.manifest
9139
+ ? normalizeRemoteClipboardManifest(normalized.manifest, normalized.clipboardOperationId)
9140
+ : null;
9141
+ } catch (error) {
9142
+ return { ok: false, error: safeString(error?.code || error?.message, 160) || 'clipboard-manifest-invalid' };
9143
+ }
9144
+ const clipboardPolicy = getDevicePolicy(device);
9145
+ if (clipboardPolicy.allowClipboardText !== true) {
9146
+ return { ok: false, error: 'allowClipboardText-blocked-by-settings' };
9147
+ }
9148
+ const contentKind = clipboardManifest?.contentKind || normalized.contentKind || 'text';
9149
+ if (remoteClipboardContentNeedsFileTransfer(contentKind)
9150
+ && clipboardPolicy.allowFileTransfer !== true) {
9151
+ return { ok: false, error: 'allowFileTransfer-blocked-by-settings' };
9152
+ }
9153
+ const clipboardCapabilityDenied = clipboardCapabilityError(
9154
+ device,
9155
+ normalized.clipboardDirection,
9156
+ contentKind);
9157
+ if (clipboardCapabilityDenied) {
9158
+ return { ok: false, error: clipboardCapabilityDenied };
9159
+ }
9160
+ normalized.manifest = clipboardManifest;
9161
+ }
8402
9162
 
8403
9163
  const controlStream = getActiveControlStream(device);
8404
9164
  if (!controlStream
@@ -8434,14 +9194,32 @@ export function createRemoteHub(options = {}) {
8434
9194
  };
8435
9195
  }
8436
9196
  const activeMonitorIndex = normalizeMonitorIndex(controlStream.monitorIndex);
8437
- if (!Number.isInteger(normalized.monitorIndex)
8438
- || normalized.monitorIndex !== activeMonitorIndex) {
9197
+ if (!Number.isInteger(normalized.monitorIndex)
9198
+ || normalized.monitorIndex !== activeMonitorIndex) {
8439
9199
  return {
8440
9200
  ok: false,
8441
9201
  error: 'STALE_CONTROL_MONITOR',
8442
- activeMonitorIndex
8443
- };
8444
- }
9202
+ activeMonitorIndex
9203
+ };
9204
+ }
9205
+
9206
+ let clipboardOperationKey = '';
9207
+ if (normalizedInputType === 'clipboard.paste') {
9208
+ clipboardOperationKey = buildClipboardOperationKey(
9209
+ device.deviceId,
9210
+ device.sessionId,
9211
+ normalized.clipboardOperationId);
9212
+ const operation = clipboardOperations.get(clipboardOperationKey);
9213
+ if (!operation
9214
+ || operation.direction !== 'paste'
9215
+ || operation.hubConnectionId !== normalized.hubConnectionId
9216
+ || operation.controlCommandId !== normalized.controlCommandId
9217
+ || operation.captureGeneration !== normalized.captureGeneration
9218
+ || operation.monitorIndex !== normalized.monitorIndex
9219
+ || operation.status !== 'ready') {
9220
+ return { ok: false, error: 'clipboard-paste-not-ready' };
9221
+ }
9222
+ }
8445
9223
 
8446
9224
  const inputSocket = device.inputSocket;
8447
9225
  const inputWriteOwner = getCurrentInputWriteOwner(device);
@@ -8458,9 +9236,17 @@ export function createRemoteHub(options = {}) {
8458
9236
  && normalized.hubConnectionId !== previousOwnerConnectionId;
8459
9237
  const bindingChanged = previousInputBindingKey
8460
9238
  && previousInputBindingKey !== activeInputBindingKey;
8461
- const writeBindingChanged = inputWriteOwner.getBindingKey() !== activeInputBindingKey;
8462
- if (ownerChanged || bindingChanged || writeBindingChanged) {
8463
- const resetSent = inputWriteOwner.replaceBinding(
9239
+ const writeBindingChanged = inputWriteOwner.getBindingKey() !== activeInputBindingKey;
9240
+ if (ownerChanged || bindingChanged || writeBindingChanged) {
9241
+ if (ownerChanged) {
9242
+ clearClipboardOperationsForDevice(
9243
+ device,
9244
+ 'browser-input-owner-changed',
9245
+ previousOwnerConnectionId);
9246
+ } else if (bindingChanged || writeBindingChanged) {
9247
+ clearClipboardOperationsForDevice(device, 'control-input-binding-changed');
9248
+ }
9249
+ const resetSent = inputWriteOwner.replaceBinding(
8464
9250
  activeInputBindingKey,
8465
9251
  buildRemoteInputResetMessage(
8466
9252
  device,
@@ -8490,9 +9276,16 @@ export function createRemoteHub(options = {}) {
8490
9276
  device.inputOwnerConnectionId = normalized.hubConnectionId;
8491
9277
  }
8492
9278
  device.inputOwnerBindingKey = activeInputBindingKey;
8493
- device.counters.commandsSent += 1;
8494
- device.inputLastSeenAt = new Date().toISOString();
8495
- return {
9279
+ device.counters.commandsSent += 1;
9280
+ device.inputLastSeenAt = new Date().toISOString();
9281
+ if (clipboardOperationKey) {
9282
+ const operation = clipboardOperations.get(clipboardOperationKey);
9283
+ if (operation) {
9284
+ operation.status = 'pasting';
9285
+ operation.touchedAt = Date.now();
9286
+ }
9287
+ }
9288
+ return {
8496
9289
  ok: true,
8497
9290
  inputSocket: true,
8498
9291
  sessionId: device.sessionId,
@@ -8527,7 +9320,7 @@ export function createRemoteHub(options = {}) {
8527
9320
  issuedAt: normalized.issuedAt || new Date().toISOString()
8528
9321
  }
8529
9322
  });
8530
- if (fallback.ok && normalized.requestAck && normalized.inputSeq > 0) {
9323
+ if (fallback.ok && normalized.requestAck && normalized.inputSeq > 0) {
8531
9324
  schedulePendingInputFallback(device, {
8532
9325
  commandId: fallback.commandId,
8533
9326
  inputSeq: normalized.inputSeq,
@@ -8539,9 +9332,16 @@ export function createRemoteHub(options = {}) {
8539
9332
  hubReceivedAtEpochMs: normalized.hubReceivedAtEpochMs,
8540
9333
  hubForwardedAtEpochMs,
8541
9334
  fallback: true,
8542
- timeoutTimer: null
8543
- });
8544
- }
9335
+ timeoutTimer: null
9336
+ });
9337
+ }
9338
+ if (fallback.ok && clipboardOperationKey) {
9339
+ const operation = clipboardOperations.get(clipboardOperationKey);
9340
+ if (operation) {
9341
+ operation.status = 'pasting';
9342
+ operation.touchedAt = Date.now();
9343
+ }
9344
+ }
8545
9345
  return fallback.ok
8546
9346
  ? {
8547
9347
  ...fallback,
@@ -8561,14 +9361,15 @@ export function createRemoteHub(options = {}) {
8561
9361
  };
8562
9362
  }
8563
9363
 
8564
- function releaseInputOwner(deviceId, ownerConnectionId, reason = 'browser-input-owner-closed') {
9364
+ function releaseInputOwner(deviceId, ownerConnectionId, reason = 'browser-input-owner-closed') {
8565
9365
  const device = devices.get(String(deviceId || ''));
8566
9366
  const owner = safeString(ownerConnectionId, 128);
8567
- if (!device || !owner || safeString(device.inputOwnerConnectionId, 128) !== owner) {
8568
- return { ok: false, error: 'input-owner-not-current' };
8569
- }
8570
-
8571
- device.inputOwnerConnectionId = '';
9367
+ if (!device || !owner || safeString(device.inputOwnerConnectionId, 128) !== owner) {
9368
+ return { ok: false, error: 'input-owner-not-current' };
9369
+ }
9370
+
9371
+ clearClipboardOperationsForDevice(device, reason, owner);
9372
+ device.inputOwnerConnectionId = '';
8572
9373
  device.inputOwnerBindingKey = '';
8573
9374
  const inputSocket = device.inputSocket;
8574
9375
  const inputWriteOwner = getCurrentInputWriteOwner(device);
@@ -8612,8 +9413,12 @@ export function createRemoteHub(options = {}) {
8612
9413
  };
8613
9414
  const results = targets.map(deviceId => {
8614
9415
  const device = devices.get(deviceId);
8615
- if (device?.synthetic === true && device.connected) return { deviceId, ok: true, synthetic: true };
8616
- if (!device?.connected || !device.socket || device.socket.destroyed) {
9416
+ if (device?.synthetic === true && device.connected) return { deviceId, ok: true, synthetic: true };
9417
+ if (device?.connected && !isDeviceConnectionFresh(device)) {
9418
+ retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
9419
+ return { deviceId, ok: false, error: 'device-not-connected' };
9420
+ }
9421
+ if (!device?.connected || !device.socket || device.socket.destroyed) {
8617
9422
  return { deviceId, ok: false, error: 'device-not-connected' };
8618
9423
  }
8619
9424
  return writeJsonLine(device.socket, payload)
@@ -8645,7 +9450,7 @@ export function createRemoteHub(options = {}) {
8645
9450
  const denied = policyError(device, 'allowAgent');
8646
9451
  if (denied) return { ok: false, error: denied };
8647
9452
 
8648
- if (device?.synthetic === true && device.connected) {
9453
+ if (device?.synthetic === true && device.connected) {
8649
9454
  const instruction = safeText(options.instruction, MAX_AGENT_TASK_CHARS);
8650
9455
  if (!instruction) {
8651
9456
  return { ok: false, error: 'missing-instruction' };
@@ -8705,10 +9510,14 @@ export function createRemoteHub(options = {}) {
8705
9510
  error: '',
8706
9511
  synthetic: true
8707
9512
  });
8708
- return { ok: true, commandId, taskId, approvalLevel, synthetic: true };
8709
- }
8710
-
8711
- if (!device?.socket || device.socket.destroyed || !device.connected) {
9513
+ return { ok: true, commandId, taskId, approvalLevel, synthetic: true };
9514
+ }
9515
+
9516
+ if (device.connected && !isDeviceConnectionFresh(device)) {
9517
+ retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
9518
+ return { ok: false, error: 'device-not-connected' };
9519
+ }
9520
+ if (!device?.socket || device.socket.destroyed || !device.connected) {
8712
9521
  return { ok: false, error: 'device-not-connected' };
8713
9522
  }
8714
9523
 
@@ -9528,11 +10337,58 @@ export function createRemoteHub(options = {}) {
9528
10337
  === normalized.streamPurpose;
9529
10338
  }
9530
10339
 
9531
- function startLiveStream(deviceId, options = {}) {
9532
- const device = devices.get(String(deviceId || ''));
9533
- const denied = policyError(device);
9534
- if (denied) return { ok: false, error: denied };
9535
- if (device?.liveCapturePause?.token) {
10340
+ function liveStreamMatchesOwnerIdentity(activeLiveStream, normalized) {
10341
+ if (!activeLiveStream) {
10342
+ return false;
10343
+ }
10344
+ return activeLiveStream.frameMode === normalized.transfer.frameMode
10345
+ && Number(activeLiveStream.monitorIndex || 0) === normalized.monitorIndex
10346
+ && (safeString(activeLiveStream.streamPurpose, 24).toLowerCase() || 'wall')
10347
+ === normalized.streamPurpose;
10348
+ }
10349
+
10350
+ function sharedLiveProfileResult(device, activeLiveStream, normalized, extra = {}) {
10351
+ return {
10352
+ ok: true,
10353
+ commandId: activeLiveStream.commandId,
10354
+ sessionId: device.sessionId,
10355
+ streamId: activeLiveStream.streamId,
10356
+ streamPurpose: safeString(activeLiveStream.streamPurpose, 24) || normalized.streamPurpose,
10357
+ fps: Number(activeLiveStream.fps || normalized.fps),
10358
+ mode: activeLiveStream.mode || normalized.transfer.mode,
10359
+ frameMode: activeLiveStream.frameMode || normalized.transfer.frameMode,
10360
+ monitorIndex: Number(activeLiveStream.monitorIndex ?? normalized.monitorIndex),
10361
+ captureGeneration: Number(activeLiveStream.captureGeneration || 0),
10362
+ ready: liveStreamHasCurrentFrame(activeLiveStream),
10363
+ reused: true,
10364
+ sharedProfileReused: true,
10365
+ requestedProfile: {
10366
+ fps: normalized.fps,
10367
+ maxWidth: normalized.maxWidth,
10368
+ maxHeight: normalized.maxHeight,
10369
+ quality: normalized.quality
10370
+ },
10371
+ effectiveProfile: {
10372
+ fps: Number(activeLiveStream.fps || normalized.fps),
10373
+ maxWidth: Number(activeLiveStream.maxWidth || normalized.maxWidth),
10374
+ maxHeight: Number(activeLiveStream.maxHeight || normalized.maxHeight),
10375
+ quality: Number(activeLiveStream.quality || normalized.quality)
10376
+ },
10377
+ ...extra
10378
+ };
10379
+ }
10380
+
10381
+ function startLiveStream(deviceId, options = {}) {
10382
+ const device = devices.get(String(deviceId || ''));
10383
+ const denied = policyError(device);
10384
+ if (denied) return { ok: false, error: denied };
10385
+ if (device?.synthetic !== true
10386
+ && device?.connected
10387
+ && !isDeviceConnectionFresh(device)) {
10388
+ retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
10389
+ return { ok: false, error: 'device-not-connected' };
10390
+ }
10391
+ if (device?.liveCapturePause?.token) {
9536
10392
  return {
9537
10393
  ok: false,
9538
10394
  error: 'LIVE_CAPTURE_PAUSED',
@@ -9664,6 +10520,13 @@ export function createRemoteHub(options = {}) {
9664
10520
  const modePolicyError = liveStreamModePolicyError(normalized);
9665
10521
  if (modePolicyError) return { ok: false, error: modePolicyError };
9666
10522
  const { fps, maxWidth, maxHeight, quality, monitorIndex, transfer, streamPurpose } = normalized;
10523
+ if (deviceExplicitlyRejectsFrameMode(device, transfer.frameMode)) {
10524
+ return {
10525
+ ok: false,
10526
+ error: 'device-frame-mode-unavailable',
10527
+ frameMode: transfer.frameMode
10528
+ };
10529
+ }
9667
10530
  const streamId = safeString(options.streamId, 128) || makeStableLiveStreamId(deviceId, streamPurpose);
9668
10531
  if (!reserveDeviceLiveStreamDescriptor(device, streamId)) {
9669
10532
  return {
@@ -9678,13 +10541,24 @@ export function createRemoteHub(options = {}) {
9678
10541
  const pendingDescriptor = getPendingLiveStreamDescriptor(activeLiveStream);
9679
10542
  if (pendingDescriptor?.commandId) {
9680
10543
  const pendingMatchesRequest = liveStreamMatchesOptions(pendingDescriptor, normalized);
9681
- if (pendingMatchesRequest && liveStreamReplacementStillPending(activeLiveStream)) {
10544
+ const pendingMatchesSharedOwner = options.forceRestart !== true
10545
+ && options.reuseExisting === true
10546
+ && options.reuseSharedExisting === true
10547
+ && liveStreamMatchesOwnerIdentity(pendingDescriptor, normalized);
10548
+ if ((pendingMatchesRequest || pendingMatchesSharedOwner)
10549
+ && liveStreamReplacementStillPending(activeLiveStream)) {
9682
10550
  emitRemoteEvent('RemoteLiveStreamRestartPending', device, {
9683
10551
  streamId,
9684
10552
  commandId: pendingDescriptor.commandId,
9685
10553
  activeCommandId: activeLiveStream.commandId,
9686
10554
  reason: safeString(options.restartReason || 'duplicate-restart', 128)
9687
10555
  });
10556
+ if (pendingMatchesSharedOwner && !pendingMatchesRequest) {
10557
+ return sharedLiveProfileResult(device, pendingDescriptor, normalized, {
10558
+ ready: false,
10559
+ pending: true
10560
+ });
10561
+ }
9688
10562
  return {
9689
10563
  ok: true,
9690
10564
  commandId: pendingDescriptor.commandId,
@@ -9758,6 +10632,25 @@ export function createRemoteHub(options = {}) {
9758
10632
  reused: true
9759
10633
  };
9760
10634
  }
10635
+ if (activeLiveStream
10636
+ && options.forceRestart !== true
10637
+ && options.reuseExisting === true
10638
+ && options.reuseSharedExisting === true
10639
+ && liveStreamMatchesOwnerIdentity(activeLiveStream, normalized)
10640
+ && liveStreamIsReusable(activeLiveStream)) {
10641
+ emitRemoteEvent('RemoteLiveStreamSharedProfileReused', device, {
10642
+ streamId,
10643
+ commandId: activeLiveStream.commandId,
10644
+ captureGeneration: Number(activeLiveStream.captureGeneration || 0),
10645
+ requestedFps: normalized.fps,
10646
+ effectiveFps: Number(activeLiveStream.fps || normalized.fps),
10647
+ requestedMaxWidth: normalized.maxWidth,
10648
+ requestedMaxHeight: normalized.maxHeight,
10649
+ effectiveMaxWidth: Number(activeLiveStream.maxWidth || normalized.maxWidth),
10650
+ effectiveMaxHeight: Number(activeLiveStream.maxHeight || normalized.maxHeight)
10651
+ });
10652
+ return sharedLiveProfileResult(device, activeLiveStream, normalized);
10653
+ }
9761
10654
  if (activeLiveStream
9762
10655
  && options.forceRestart !== true
9763
10656
  && liveStreamMatchesOptions(activeLiveStream, normalized)
@@ -9926,9 +10819,15 @@ export function createRemoteHub(options = {}) {
9926
10819
  };
9927
10820
  }
9928
10821
 
9929
- function stopLiveStream(deviceId, options = {}) {
9930
- const device = devices.get(String(deviceId || ''));
9931
- const stopAll = options.stopAll === true;
10822
+ function stopLiveStream(deviceId, options = {}) {
10823
+ const device = devices.get(String(deviceId || ''));
10824
+ if (device?.synthetic !== true
10825
+ && device?.connected
10826
+ && !isDeviceConnectionFresh(device)) {
10827
+ retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
10828
+ return { ok: false, error: 'device-not-connected' };
10829
+ }
10830
+ const stopAll = options.stopAll === true;
9932
10831
  const streamPurpose = safeString(options.streamPurpose || options.purpose, 24).toLowerCase();
9933
10832
  const requestedStreamId = safeString(options.streamId, 128);
9934
10833
  const purposeStreams = !stopAll && !requestedStreamId && streamPurpose && device
@@ -10529,12 +11428,18 @@ export function createRemoteHub(options = {}) {
10529
11428
  };
10530
11429
  }
10531
11430
 
10532
- function startAudioStream(deviceId, options = {}) {
10533
- const device = devices.get(String(deviceId || ''));
10534
- if (!device) {
10535
- return { ok: false, error: 'device-not-found' };
10536
- }
10537
- if (!device.connected) {
11431
+ function startAudioStream(deviceId, options = {}) {
11432
+ const device = devices.get(String(deviceId || ''));
11433
+ if (!device) {
11434
+ return { ok: false, error: 'device-not-found' };
11435
+ }
11436
+ if (device.synthetic !== true
11437
+ && device.connected
11438
+ && !isDeviceConnectionFresh(device)) {
11439
+ retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
11440
+ return { ok: false, error: 'device-not-connected' };
11441
+ }
11442
+ if (!device.connected) {
10538
11443
  return { ok: false, error: 'device-not-connected' };
10539
11444
  }
10540
11445
  const denied = policyError(device, 'allowRemoteAudio');
@@ -10611,12 +11516,18 @@ export function createRemoteHub(options = {}) {
10611
11516
  return { ok: true, commandId, streamId };
10612
11517
  }
10613
11518
 
10614
- function stopAudioStream(deviceId, options = {}) {
10615
- const device = devices.get(String(deviceId || ''));
10616
- if (!device) {
10617
- return { ok: false, error: 'device-not-found' };
10618
- }
10619
- if (!device.connected) {
11519
+ function stopAudioStream(deviceId, options = {}) {
11520
+ const device = devices.get(String(deviceId || ''));
11521
+ if (!device) {
11522
+ return { ok: false, error: 'device-not-found' };
11523
+ }
11524
+ if (device.synthetic !== true
11525
+ && device.connected
11526
+ && !isDeviceConnectionFresh(device)) {
11527
+ retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
11528
+ return { ok: false, error: 'device-not-connected' };
11529
+ }
11530
+ if (!device.connected) {
10620
11531
  return { ok: false, error: 'device-not-connected' };
10621
11532
  }
10622
11533
  if (device.synthetic === true || !device.socket || device.socket.destroyed) {
@@ -10829,10 +11740,14 @@ export function createRemoteHub(options = {}) {
10829
11740
  replacementTimerCount: device.liveStreamReplacementTimers instanceof Map
10830
11741
  ? device.liveStreamReplacementTimers.size
10831
11742
  : 0,
10832
- commandResultWaiterCount: [...pendingCommandResultWaiters.values()]
10833
- .filter(waiter => waiter.deviceId === device.deviceId
10834
- && waiter.sessionId === device.sessionId)
10835
- .length,
11743
+ commandResultWaiterCount: [...pendingCommandResultWaiters.values()]
11744
+ .filter(waiter => waiter.deviceId === device.deviceId
11745
+ && waiter.sessionId === device.sessionId)
11746
+ .length,
11747
+ clipboardOperationCount: [...clipboardOperations.values()]
11748
+ .filter(operation => operation.deviceId === device.deviceId
11749
+ && operation.sessionId === device.sessionId)
11750
+ .length,
10836
11751
  retainedPayloadCount: retainedPayloads.size,
10837
11752
  retainedPayloadBytes: [...retainedPayloads].reduce(
10838
11753
  (total, payload) => total + payload.length,
@@ -10842,8 +11757,9 @@ export function createRemoteHub(options = {}) {
10842
11757
  && snapshot.stopPendingCount === 0
10843
11758
  && snapshot.pendingStartCount === 0
10844
11759
  && snapshot.pendingStopPromiseCount === 0
10845
- && snapshot.replacementTimerCount === 0
10846
- && snapshot.commandResultWaiterCount === 0
11760
+ && snapshot.replacementTimerCount === 0
11761
+ && snapshot.commandResultWaiterCount === 0
11762
+ && snapshot.clipboardOperationCount === 0
10847
11763
  && snapshot.retainedPayloadCount === 0
10848
11764
  && snapshot.retainedPayloadBytes === 0;
10849
11765
  return snapshot;
@@ -10855,8 +11771,9 @@ export function createRemoteHub(options = {}) {
10855
11771
  'stopPendingCount',
10856
11772
  'pendingStartCount',
10857
11773
  'pendingStopPromiseCount',
10858
- 'replacementTimerCount',
10859
- 'commandResultWaiterCount',
11774
+ 'replacementTimerCount',
11775
+ 'commandResultWaiterCount',
11776
+ 'clipboardOperationCount',
10860
11777
  'retainedPayloadCount',
10861
11778
  'retainedPayloadBytes'
10862
11779
  ]) {
@@ -10869,9 +11786,10 @@ export function createRemoteHub(options = {}) {
10869
11786
  stopPendingCount: 0,
10870
11787
  pendingStartCount: 0,
10871
11788
  pendingStopPromiseCount: 0,
10872
- replacementTimerCount: 0,
10873
- commandResultWaiterCount: 0,
10874
- retainedPayloadCount: 0,
11789
+ replacementTimerCount: 0,
11790
+ commandResultWaiterCount: 0,
11791
+ clipboardOperationCount: 0,
11792
+ retainedPayloadCount: 0,
10875
11793
  retainedPayloadBytes: 0
10876
11794
  });
10877
11795
  return {
@@ -10911,8 +11829,9 @@ export function createRemoteHub(options = {}) {
10911
11829
  listDeviceFrames,
10912
11830
  disconnectDevice,
10913
11831
  assignDeviceSlot,
10914
- sendCommand,
10915
- refreshDevicePolicies,
11832
+ sendCommand,
11833
+ sendClipboardCommand,
11834
+ refreshDevicePolicies,
10916
11835
  sendLegacyClientUpdate,
10917
11836
  sendInputControl,
10918
11837
  releaseInputOwner,