@livedesk/hub 0.1.46 → 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'
@@ -1166,7 +1194,9 @@ function normalizeRemoteKeyboardKey(value) {
1166
1194
 
1167
1195
  function normalizeRemoteKeyboardText(value) {
1168
1196
  const raw = String(value ?? '').replace(/\0/g, '');
1169
- return raw === ' ' ? ' ' : safeText(raw, 256);
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);
1170
1200
  }
1171
1201
 
1172
1202
  function safeTaskData(value) {
@@ -1248,7 +1278,7 @@ function readRawRemoteInputMonitorIndex(input) {
1248
1278
  return undefined;
1249
1279
  }
1250
1280
 
1251
- function normalizeRemoteInputEvent(value = {}) {
1281
+ function normalizeRemoteInputEvent(value = {}) {
1252
1282
  const input = value && typeof value === 'object' ? value : {};
1253
1283
  const type = safeString(input.type || input.Type, 48);
1254
1284
  const normalizedX = Number(input.normalizedX ?? input.NormalizedX);
@@ -1269,7 +1299,14 @@ function normalizeRemoteInputEvent(value = {}) {
1269
1299
  .filter(Boolean))]
1270
1300
  .slice(0, 64)
1271
1301
  : null;
1272
- 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;
1273
1310
  let code = safeString(input.code ?? input.Code, 80);
1274
1311
  if ((!code || code === 'Unidentified') && (key === ' ' || key === 'Spacebar' || keyCode === 32)) {
1275
1312
  code = 'Space';
@@ -1294,7 +1331,13 @@ function normalizeRemoteInputEvent(value = {}) {
1294
1331
  shiftKey: input.shiftKey === true || input.ShiftKey === true,
1295
1332
  ctrlKey: input.ctrlKey === true || input.ControlKey === true || input.CtrlKey === true,
1296
1333
  altKey: input.altKey === true || input.AltKey === true,
1297
- 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,
1298
1341
  controlLeaseId: safeString(input.controlLeaseId || input.ControlLeaseId, 128),
1299
1342
  controlSessionId: safeString(input.controlSessionId || input.sessionId || input.ControlSessionId || input.SessionId, 160),
1300
1343
  controlCommandId: safeString(input.controlCommandId || input.commandId || input.ControlCommandId || input.CommandId, 128),
@@ -1716,6 +1759,26 @@ function writeJsonLine(socket, payload) {
1716
1759
  return true;
1717
1760
  }
1718
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
+
1719
1782
  function isRemoteHubWebSocketUpgradeStart(buffer) {
1720
1783
  if (!Buffer.isBuffer(buffer) || buffer.length < 4) {
1721
1784
  return false;
@@ -2266,12 +2329,29 @@ export function createRemoteHub(options = {}) {
2266
2329
 
2267
2330
  const enabled = isEnabledValue(env.LIVEDESK_REMOTE_HUB ?? env.MINDEXEC_REMOTE_HUB ?? env.REMOTE_HUB_ENABLED, true)
2268
2331
  && !isDisabledValue(env.REMOTE_HUB_DISABLED);
2269
- const host = safeString(env.REMOTE_HUB_HOST || DEFAULT_REMOTE_HUB_HOST, 128);
2270
- const requestedPort = normalizePort(env.REMOTE_HUB_PORT || DEFAULT_REMOTE_HUB_PORT);
2271
- const heartbeatMs = clampNumber(env.REMOTE_HUB_HEARTBEAT_MS, 1000, 60000, DEFAULT_HEARTBEAT_MS);
2272
- const taskTimeoutMs = clampNumber(
2273
- options.taskTimeoutMs ?? env.REMOTE_HUB_TASK_TIMEOUT_MS,
2274
- 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,
2275
2355
  30 * 60 * 1000,
2276
2356
  DEFAULT_AGENT_TASK_TIMEOUT_MS);
2277
2357
  const inputAckTimeoutMs = clampNumber(
@@ -2334,16 +2414,18 @@ export function createRemoteHub(options = {}) {
2334
2414
  const audioSockets = new Map();
2335
2415
  const fileSockets = new Map();
2336
2416
  const allSockets = new Set();
2337
- const agentBinaryIngressStates = new Set();
2338
- const pendingCommandResultWaiters = new Map();
2417
+ const agentBinaryIngressStates = new Set();
2418
+ const pendingCommandResultWaiters = new Map();
2419
+ const clipboardOperations = new Map();
2339
2420
  const transportDiagnostics = new Map();
2340
2421
  const transportDiagnosticStartingDevices = new Set();
2341
- const duplicateDeviceLogAt = new Map();
2342
- let server = null;
2343
- let started = false;
2344
- let boundPort = requestedPort;
2345
- let lastError = '';
2346
- 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;
2347
2429
  let publicIPv4Cache = {
2348
2430
  address: safeString(env.LIVEDESK_REMOTE_PUBLIC_IPV4 || env.LIVEDESK_PUBLIC_IPV4 || env.REMOTE_HUB_PUBLIC_IPV4, 64),
2349
2431
  updatedAt: 0,
@@ -2664,12 +2746,14 @@ export function createRemoteHub(options = {}) {
2664
2746
  protocol: 'tcp-jsonl',
2665
2747
  protocolVersion: REMOTE_PROTOCOL_VERSION,
2666
2748
  agentProtocol: REMOTE_AGENT_PROTOCOL,
2667
- frameProtocol: buildRemoteFrameProtocolDescriptor(),
2668
- frameModes: getSupportedRemoteFrameModeProfiles(),
2669
- heartbeatMs,
2670
- taskTimeoutMs,
2671
- managerPackage,
2672
- managerVersion,
2749
+ frameProtocol: buildRemoteFrameProtocolDescriptor(),
2750
+ frameModes: getSupportedRemoteFrameModeProfiles(),
2751
+ heartbeatMs,
2752
+ deviceConnectionStaleMs,
2753
+ deviceConnectionSweepMs,
2754
+ taskTimeoutMs,
2755
+ managerPackage,
2756
+ managerVersion,
2673
2757
  hostInstanceId,
2674
2758
  agentPackage: '@livedesk/client',
2675
2759
  agentEndpoint: routeInfo.endpoint,
@@ -2932,7 +3016,7 @@ export function createRemoteHub(options = {}) {
2932
3016
  return true;
2933
3017
  }
2934
3018
 
2935
- function failPendingCommandResultWaiters(device, error = 'device-disconnected') {
3019
+ function failPendingCommandResultWaiters(device, error = 'device-disconnected') {
2936
3020
  if (!device?.deviceId) {
2937
3021
  return;
2938
3022
  }
@@ -2949,10 +3033,31 @@ export function createRemoteHub(options = {}) {
2949
3033
  result: null,
2950
3034
  error: safeString(error, 500) || 'command-result-unavailable'
2951
3035
  });
2952
- }
2953
- }
2954
-
2955
- 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') {
2956
3061
  const normalizedCommandId = safeString(commandId, 128);
2957
3062
  const waiterKey = buildPendingCommandResultWaiterKey(
2958
3063
  device?.deviceId,
@@ -2973,7 +3078,7 @@ export function createRemoteHub(options = {}) {
2973
3078
  return true;
2974
3079
  }
2975
3080
 
2976
- function waitForCommandResult(device, commandId, timeoutMs = liveCaptureStopAckTimeoutMs) {
3081
+ function waitForCommandResult(device, commandId, timeoutMs = liveCaptureStopAckTimeoutMs, metadata = {}) {
2977
3082
  const normalizedCommandId = safeString(commandId, 128);
2978
3083
  const waiterKey = buildPendingCommandResultWaiterKey(
2979
3084
  device?.deviceId,
@@ -2999,11 +3104,13 @@ export function createRemoteHub(options = {}) {
2999
3104
  error: 'command-result-wait-replaced'
3000
3105
  });
3001
3106
  }
3002
- const waiterRecord = {
3003
- deviceId: device.deviceId,
3004
- sessionId: device.sessionId,
3005
- commandId: normalizedCommandId,
3006
- 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,
3007
3114
  deadlineImmediate: null,
3008
3115
  resolve
3009
3116
  };
@@ -3036,9 +3143,246 @@ export function createRemoteHub(options = {}) {
3036
3143
  });
3037
3144
  }, timeoutMs);
3038
3145
  waiterRecord.timer.unref?.();
3039
- pendingCommandResultWaiters.set(waiterKey, waiterRecord);
3040
- });
3041
- }
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
+ }
3042
3386
 
3043
3387
  function inspectAnnexBH264(payload) {
3044
3388
  if (!Buffer.isBuffer(payload) || payload.length < 5) {
@@ -3920,13 +4264,15 @@ export function createRemoteHub(options = {}) {
3920
4264
  }
3921
4265
  }
3922
4266
 
3923
- function closeFileSocket(device, reason = 'file-socket-closed') {
3924
- const socket = device?.fileSocket;
3925
- if (!socket) {
3926
- return;
3927
- }
3928
-
3929
- 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;
3930
4276
  fileSockets.delete(socket);
3931
4277
  try {
3932
4278
  writeJsonLine(socket, { type: 'disconnect', channel: 'file', reason });
@@ -3940,13 +4286,14 @@ export function createRemoteHub(options = {}) {
3940
4286
  }
3941
4287
  }
3942
4288
 
3943
- function closeInputSocket(device, reason = 'input-socket-closed') {
4289
+ function closeInputSocket(device, reason = 'input-socket-closed') {
3944
4290
  const socket = device?.inputSocket;
3945
4291
  if (!socket) {
3946
4292
  return;
3947
4293
  }
3948
4294
 
3949
- const writeOwner = device.inputWriteOwner;
4295
+ const writeOwner = device.inputWriteOwner;
4296
+ clearClipboardOperationsForDevice(device, reason);
3950
4297
  device.inputSocket = null;
3951
4298
  device.inputWriteOwner = null;
3952
4299
  device.inputSocketConnectionId = '';
@@ -3966,7 +4313,7 @@ export function createRemoteHub(options = {}) {
3966
4313
  }
3967
4314
  }
3968
4315
 
3969
- function detachInputSocket(socket, reason = 'input-socket-closed') {
4316
+ function detachInputSocket(socket, reason = 'input-socket-closed') {
3970
4317
  const deviceId = inputSockets.get(socket);
3971
4318
  inputSockets.delete(socket);
3972
4319
  if (!deviceId) {
@@ -3978,7 +4325,8 @@ export function createRemoteHub(options = {}) {
3978
4325
  return;
3979
4326
  }
3980
4327
 
3981
- const writeOwner = device.inputWriteOwner;
4328
+ const writeOwner = device.inputWriteOwner;
4329
+ clearClipboardOperationsForDevice(device, reason);
3982
4330
  device.inputSocket = null;
3983
4331
  device.inputWriteOwner = null;
3984
4332
  device.inputSocketConnectionId = '';
@@ -4023,7 +4371,7 @@ export function createRemoteHub(options = {}) {
4023
4371
  emitRemoteEvent('RemoteAudioSocketDisconnected', device, { reason });
4024
4372
  }
4025
4373
 
4026
- function detachFileSocket(socket, reason = 'file-socket-closed') {
4374
+ function detachFileSocket(socket, reason = 'file-socket-closed') {
4027
4375
  const deviceId = fileSockets.get(socket);
4028
4376
  fileSockets.delete(socket);
4029
4377
  if (!deviceId) {
@@ -4031,38 +4379,107 @@ export function createRemoteHub(options = {}) {
4031
4379
  }
4032
4380
 
4033
4381
  const device = devices.get(deviceId);
4034
- if (!device || device.fileSocket !== socket) {
4035
- return;
4036
- }
4037
-
4038
- 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;
4039
4389
  device.fileLastSeenAt = new Date().toISOString();
4040
- emitRemoteEvent('RemoteFileSocketDisconnected', device, { reason });
4041
- }
4042
-
4043
- function getDeviceActivityMs(device) {
4044
- return Math.max(
4045
- Date.parse(device?.lastSeenAt || '') || 0,
4046
- Date.parse(device?.lastStatusAt || '') || 0,
4047
- Date.parse(device?.connectedAt || '') || 0
4048
- );
4049
- }
4050
-
4051
- 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()) {
4052
4400
  if (!device?.connected || !device.socket || device.socket.destroyed) {
4053
- return false;
4054
- }
4055
-
4056
- const lastActivityMs = getDeviceActivityMs(device);
4057
- if (!lastActivityMs) {
4058
- return true;
4059
- }
4060
-
4061
- const freshnessMs = Math.max(DUPLICATE_DEVICE_ACTIVE_REJECT_MS, heartbeatMs * 3);
4062
- return nowMs - lastActivityMs <= freshnessMs;
4063
- }
4064
-
4065
- 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) {
4066
4483
  const retryAfterMs = Math.max(heartbeatMs, duplicateDeviceRetryAfterMs);
4067
4484
  writeJsonLine(socket, {
4068
4485
  type: 'disconnect',
@@ -4514,16 +4931,20 @@ export function createRemoteHub(options = {}) {
4514
4931
  return device;
4515
4932
  }
4516
4933
 
4517
- function attachInputSocket(socket, hello) {
4518
- const deviceId = normalizeDeviceId(hello.deviceId);
4519
- const device = devices.get(deviceId);
4934
+ function attachInputSocket(socket, hello) {
4935
+ const deviceId = normalizeDeviceId(hello.deviceId);
4936
+ const device = devices.get(deviceId);
4520
4937
  if (!device || !device.connected || !device.socket || device.socket.destroyed) {
4521
4938
  writeJsonLine(socket, { type: 'error', error: 'device-not-connected' });
4522
4939
  socket.destroy();
4523
- return null;
4524
- }
4525
-
4526
- 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');
4527
4948
 
4528
4949
  const now = new Date().toISOString();
4529
4950
  const inputSocketConnectionId = crypto.randomUUID();
@@ -4637,16 +5058,20 @@ export function createRemoteHub(options = {}) {
4637
5058
  return device;
4638
5059
  }
4639
5060
 
4640
- function attachFileSocket(socket, hello) {
4641
- const deviceId = normalizeDeviceId(hello.deviceId);
4642
- const device = devices.get(deviceId);
5061
+ function attachFileSocket(socket, hello) {
5062
+ const deviceId = normalizeDeviceId(hello.deviceId);
5063
+ const device = devices.get(deviceId);
4643
5064
  if (!device || !device.connected || !device.socket || device.socket.destroyed) {
4644
5065
  writeJsonLine(socket, { type: 'error', error: 'device-not-connected' });
4645
5066
  socket.destroy();
4646
- return null;
4647
- }
4648
-
4649
- 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');
4650
5075
  const now = new Date().toISOString();
4651
5076
  device.fileSocket = socket;
4652
5077
  device.fileConnectedAt = now;
@@ -7969,12 +8394,13 @@ export function createRemoteHub(options = {}) {
7969
8394
  await listenOnPort(requestedPort);
7970
8395
  try {
7971
8396
  await relayControl?.start?.();
7972
- } catch {
7973
- logWarn('relay', 'Encrypted relay control could not start; direct TCP remains available.');
7974
- }
7975
- } catch (error) {
7976
- if (error?.code === 'EADDRINUSE') {
7977
- 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(
7978
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.`);
7979
8405
  conflict.code = 'EADDRINUSE';
7980
8406
  conflict.port = requestedPort;
@@ -7986,12 +8412,16 @@ export function createRemoteHub(options = {}) {
7986
8412
  }
7987
8413
 
7988
8414
  return getStatus({ includeSecrets: false });
7989
- }
7990
-
7991
- async function close() {
7992
- for (const state of [...agentBinaryIngressStates]) {
7993
- closeAgentBinaryIngress(state, 'hub-shutdown');
7994
- }
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
+ }
7995
8425
 
7996
8426
  for (const device of devices.values()) {
7997
8427
  abortTransportDiagnosticsForDevice(device, 'hub-shutdown');
@@ -8126,9 +8556,12 @@ export function createRemoteHub(options = {}) {
8126
8556
  };
8127
8557
  }
8128
8558
 
8129
- function sendCommand(deviceId, command) {
8130
- const device = devices.get(String(deviceId || ''));
8131
- 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
+ }
8132
8565
  const requiredPermission = commandName === 'input.control'
8133
8566
  ? 'allowControl'
8134
8567
  : commandName === 'system.power'
@@ -8164,12 +8597,16 @@ export function createRemoteHub(options = {}) {
8164
8597
  result: { ok: true, synthetic: true },
8165
8598
  error: ''
8166
8599
  });
8167
- return { ok: true, commandId, synthetic: true };
8168
- }
8169
-
8170
- if (!device?.socket || device.socket.destroyed || !device.connected) {
8171
- return { ok: false, error: 'device-not-connected' };
8172
- }
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
+ }
8173
8610
 
8174
8611
  const commandId = safeString(command?.commandId, 128) || crypto.randomUUID();
8175
8612
  const payload = {
@@ -8182,29 +8619,300 @@ export function createRemoteHub(options = {}) {
8182
8619
 
8183
8620
  const dedicatedFileSocket = commandName.startsWith('file.transfer')
8184
8621
  && device.fileSocket
8185
- && !device.fileSocket.destroyed
8186
- ? device.fileSocket
8187
- : null;
8188
- writeJsonLine(dedicatedFileSocket || device.socket, payload);
8189
- device.counters.commandsSent += 1;
8190
- emitRemoteEvent('RemoteCommandQueued', device, {
8191
- 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,
8192
8634
  command: payload.command,
8193
8635
  channel: dedicatedFileSocket ? 'file' : 'control'
8194
8636
  });
8195
- return { ok: true, commandId };
8196
- }
8197
-
8198
- 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) {
8199
8903
  const requestedIds = Array.isArray(deviceIds) && deviceIds.length > 0
8200
8904
  ? new Set(deviceIds.map(deviceId => safeString(deviceId, 160)).filter(Boolean))
8201
8905
  : null;
8202
8906
  let updated = 0;
8203
8907
  let total = 0;
8204
- for (const device of devices.values()) {
8205
- if (requestedIds && !requestedIds.has(device.deviceId)) continue;
8206
- if (!device?.socket || device.socket.destroyed || !device.connected) continue;
8207
- 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;
8208
8916
  const effectivePolicy = getDevicePolicy(device);
8209
8917
  if (writeJsonLine(device.socket, {
8210
8918
  type: 'policy.update',
@@ -8223,8 +8931,14 @@ export function createRemoteHub(options = {}) {
8223
8931
  const device = devices.get(String(deviceId || ''));
8224
8932
  const denied = policyError(device);
8225
8933
  if (denied) return { ok: false, error: denied };
8226
- if (!device) return { ok: false, error: 'device-not-found' };
8227
- 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) {
8228
8942
  return { ok: false, error: 'device-not-connected' };
8229
8943
  }
8230
8944
 
@@ -8320,15 +9034,21 @@ export function createRemoteHub(options = {}) {
8320
9034
  return device.inputWriteOwner;
8321
9035
  }
8322
9036
 
8323
- function sendInputControl(deviceId, input = {}) {
8324
- const device = devices.get(String(deviceId || ''));
8325
- if (!device) {
8326
- return { ok: false, error: 'device-not-found' };
8327
- }
8328
-
8329
- if (!device.connected) {
8330
- return { ok: false, error: 'device-not-connected' };
8331
- }
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
+ }
8332
9052
 
8333
9053
  const normalized = normalizeRemoteInputEvent(input);
8334
9054
  if (!normalized.type) {
@@ -8394,9 +9114,51 @@ export function createRemoteHub(options = {}) {
8394
9114
  || device.capabilities?.accessibilityPermission === false) {
8395
9115
  return { ok: false, error: 'INPUT_PERMISSION_DENIED' };
8396
9116
  }
8397
- if (!readCapabilityFlag(device.capabilities, 'control')) {
8398
- return { ok: false, error: 'device-input-control-unavailable' };
8399
- }
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
+ }
8400
9162
 
8401
9163
  const controlStream = getActiveControlStream(device);
8402
9164
  if (!controlStream
@@ -8432,14 +9194,32 @@ export function createRemoteHub(options = {}) {
8432
9194
  };
8433
9195
  }
8434
9196
  const activeMonitorIndex = normalizeMonitorIndex(controlStream.monitorIndex);
8435
- if (!Number.isInteger(normalized.monitorIndex)
8436
- || normalized.monitorIndex !== activeMonitorIndex) {
9197
+ if (!Number.isInteger(normalized.monitorIndex)
9198
+ || normalized.monitorIndex !== activeMonitorIndex) {
8437
9199
  return {
8438
9200
  ok: false,
8439
9201
  error: 'STALE_CONTROL_MONITOR',
8440
- activeMonitorIndex
8441
- };
8442
- }
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
+ }
8443
9223
 
8444
9224
  const inputSocket = device.inputSocket;
8445
9225
  const inputWriteOwner = getCurrentInputWriteOwner(device);
@@ -8456,9 +9236,17 @@ export function createRemoteHub(options = {}) {
8456
9236
  && normalized.hubConnectionId !== previousOwnerConnectionId;
8457
9237
  const bindingChanged = previousInputBindingKey
8458
9238
  && previousInputBindingKey !== activeInputBindingKey;
8459
- const writeBindingChanged = inputWriteOwner.getBindingKey() !== activeInputBindingKey;
8460
- if (ownerChanged || bindingChanged || writeBindingChanged) {
8461
- 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(
8462
9250
  activeInputBindingKey,
8463
9251
  buildRemoteInputResetMessage(
8464
9252
  device,
@@ -8488,9 +9276,16 @@ export function createRemoteHub(options = {}) {
8488
9276
  device.inputOwnerConnectionId = normalized.hubConnectionId;
8489
9277
  }
8490
9278
  device.inputOwnerBindingKey = activeInputBindingKey;
8491
- device.counters.commandsSent += 1;
8492
- device.inputLastSeenAt = new Date().toISOString();
8493
- 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 {
8494
9289
  ok: true,
8495
9290
  inputSocket: true,
8496
9291
  sessionId: device.sessionId,
@@ -8525,7 +9320,7 @@ export function createRemoteHub(options = {}) {
8525
9320
  issuedAt: normalized.issuedAt || new Date().toISOString()
8526
9321
  }
8527
9322
  });
8528
- if (fallback.ok && normalized.requestAck && normalized.inputSeq > 0) {
9323
+ if (fallback.ok && normalized.requestAck && normalized.inputSeq > 0) {
8529
9324
  schedulePendingInputFallback(device, {
8530
9325
  commandId: fallback.commandId,
8531
9326
  inputSeq: normalized.inputSeq,
@@ -8537,9 +9332,16 @@ export function createRemoteHub(options = {}) {
8537
9332
  hubReceivedAtEpochMs: normalized.hubReceivedAtEpochMs,
8538
9333
  hubForwardedAtEpochMs,
8539
9334
  fallback: true,
8540
- timeoutTimer: null
8541
- });
8542
- }
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
+ }
8543
9345
  return fallback.ok
8544
9346
  ? {
8545
9347
  ...fallback,
@@ -8559,14 +9361,15 @@ export function createRemoteHub(options = {}) {
8559
9361
  };
8560
9362
  }
8561
9363
 
8562
- function releaseInputOwner(deviceId, ownerConnectionId, reason = 'browser-input-owner-closed') {
9364
+ function releaseInputOwner(deviceId, ownerConnectionId, reason = 'browser-input-owner-closed') {
8563
9365
  const device = devices.get(String(deviceId || ''));
8564
9366
  const owner = safeString(ownerConnectionId, 128);
8565
- if (!device || !owner || safeString(device.inputOwnerConnectionId, 128) !== owner) {
8566
- return { ok: false, error: 'input-owner-not-current' };
8567
- }
8568
-
8569
- 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 = '';
8570
9373
  device.inputOwnerBindingKey = '';
8571
9374
  const inputSocket = device.inputSocket;
8572
9375
  const inputWriteOwner = getCurrentInputWriteOwner(device);
@@ -8610,8 +9413,12 @@ export function createRemoteHub(options = {}) {
8610
9413
  };
8611
9414
  const results = targets.map(deviceId => {
8612
9415
  const device = devices.get(deviceId);
8613
- if (device?.synthetic === true && device.connected) return { deviceId, ok: true, synthetic: true };
8614
- 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) {
8615
9422
  return { deviceId, ok: false, error: 'device-not-connected' };
8616
9423
  }
8617
9424
  return writeJsonLine(device.socket, payload)
@@ -8643,7 +9450,7 @@ export function createRemoteHub(options = {}) {
8643
9450
  const denied = policyError(device, 'allowAgent');
8644
9451
  if (denied) return { ok: false, error: denied };
8645
9452
 
8646
- if (device?.synthetic === true && device.connected) {
9453
+ if (device?.synthetic === true && device.connected) {
8647
9454
  const instruction = safeText(options.instruction, MAX_AGENT_TASK_CHARS);
8648
9455
  if (!instruction) {
8649
9456
  return { ok: false, error: 'missing-instruction' };
@@ -8703,10 +9510,14 @@ export function createRemoteHub(options = {}) {
8703
9510
  error: '',
8704
9511
  synthetic: true
8705
9512
  });
8706
- return { ok: true, commandId, taskId, approvalLevel, synthetic: true };
8707
- }
8708
-
8709
- 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) {
8710
9521
  return { ok: false, error: 'device-not-connected' };
8711
9522
  }
8712
9523
 
@@ -9526,11 +10337,58 @@ export function createRemoteHub(options = {}) {
9526
10337
  === normalized.streamPurpose;
9527
10338
  }
9528
10339
 
9529
- function startLiveStream(deviceId, options = {}) {
9530
- const device = devices.get(String(deviceId || ''));
9531
- const denied = policyError(device);
9532
- if (denied) return { ok: false, error: denied };
9533
- 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) {
9534
10392
  return {
9535
10393
  ok: false,
9536
10394
  error: 'LIVE_CAPTURE_PAUSED',
@@ -9662,6 +10520,13 @@ export function createRemoteHub(options = {}) {
9662
10520
  const modePolicyError = liveStreamModePolicyError(normalized);
9663
10521
  if (modePolicyError) return { ok: false, error: modePolicyError };
9664
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
+ }
9665
10530
  const streamId = safeString(options.streamId, 128) || makeStableLiveStreamId(deviceId, streamPurpose);
9666
10531
  if (!reserveDeviceLiveStreamDescriptor(device, streamId)) {
9667
10532
  return {
@@ -9676,13 +10541,24 @@ export function createRemoteHub(options = {}) {
9676
10541
  const pendingDescriptor = getPendingLiveStreamDescriptor(activeLiveStream);
9677
10542
  if (pendingDescriptor?.commandId) {
9678
10543
  const pendingMatchesRequest = liveStreamMatchesOptions(pendingDescriptor, normalized);
9679
- 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)) {
9680
10550
  emitRemoteEvent('RemoteLiveStreamRestartPending', device, {
9681
10551
  streamId,
9682
10552
  commandId: pendingDescriptor.commandId,
9683
10553
  activeCommandId: activeLiveStream.commandId,
9684
10554
  reason: safeString(options.restartReason || 'duplicate-restart', 128)
9685
10555
  });
10556
+ if (pendingMatchesSharedOwner && !pendingMatchesRequest) {
10557
+ return sharedLiveProfileResult(device, pendingDescriptor, normalized, {
10558
+ ready: false,
10559
+ pending: true
10560
+ });
10561
+ }
9686
10562
  return {
9687
10563
  ok: true,
9688
10564
  commandId: pendingDescriptor.commandId,
@@ -9756,6 +10632,25 @@ export function createRemoteHub(options = {}) {
9756
10632
  reused: true
9757
10633
  };
9758
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
+ }
9759
10654
  if (activeLiveStream
9760
10655
  && options.forceRestart !== true
9761
10656
  && liveStreamMatchesOptions(activeLiveStream, normalized)
@@ -9924,9 +10819,15 @@ export function createRemoteHub(options = {}) {
9924
10819
  };
9925
10820
  }
9926
10821
 
9927
- function stopLiveStream(deviceId, options = {}) {
9928
- const device = devices.get(String(deviceId || ''));
9929
- 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;
9930
10831
  const streamPurpose = safeString(options.streamPurpose || options.purpose, 24).toLowerCase();
9931
10832
  const requestedStreamId = safeString(options.streamId, 128);
9932
10833
  const purposeStreams = !stopAll && !requestedStreamId && streamPurpose && device
@@ -10527,12 +11428,18 @@ export function createRemoteHub(options = {}) {
10527
11428
  };
10528
11429
  }
10529
11430
 
10530
- function startAudioStream(deviceId, options = {}) {
10531
- const device = devices.get(String(deviceId || ''));
10532
- if (!device) {
10533
- return { ok: false, error: 'device-not-found' };
10534
- }
10535
- 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) {
10536
11443
  return { ok: false, error: 'device-not-connected' };
10537
11444
  }
10538
11445
  const denied = policyError(device, 'allowRemoteAudio');
@@ -10609,12 +11516,18 @@ export function createRemoteHub(options = {}) {
10609
11516
  return { ok: true, commandId, streamId };
10610
11517
  }
10611
11518
 
10612
- function stopAudioStream(deviceId, options = {}) {
10613
- const device = devices.get(String(deviceId || ''));
10614
- if (!device) {
10615
- return { ok: false, error: 'device-not-found' };
10616
- }
10617
- 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) {
10618
11531
  return { ok: false, error: 'device-not-connected' };
10619
11532
  }
10620
11533
  if (device.synthetic === true || !device.socket || device.socket.destroyed) {
@@ -10827,10 +11740,14 @@ export function createRemoteHub(options = {}) {
10827
11740
  replacementTimerCount: device.liveStreamReplacementTimers instanceof Map
10828
11741
  ? device.liveStreamReplacementTimers.size
10829
11742
  : 0,
10830
- commandResultWaiterCount: [...pendingCommandResultWaiters.values()]
10831
- .filter(waiter => waiter.deviceId === device.deviceId
10832
- && waiter.sessionId === device.sessionId)
10833
- .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,
10834
11751
  retainedPayloadCount: retainedPayloads.size,
10835
11752
  retainedPayloadBytes: [...retainedPayloads].reduce(
10836
11753
  (total, payload) => total + payload.length,
@@ -10840,8 +11757,9 @@ export function createRemoteHub(options = {}) {
10840
11757
  && snapshot.stopPendingCount === 0
10841
11758
  && snapshot.pendingStartCount === 0
10842
11759
  && snapshot.pendingStopPromiseCount === 0
10843
- && snapshot.replacementTimerCount === 0
10844
- && snapshot.commandResultWaiterCount === 0
11760
+ && snapshot.replacementTimerCount === 0
11761
+ && snapshot.commandResultWaiterCount === 0
11762
+ && snapshot.clipboardOperationCount === 0
10845
11763
  && snapshot.retainedPayloadCount === 0
10846
11764
  && snapshot.retainedPayloadBytes === 0;
10847
11765
  return snapshot;
@@ -10853,8 +11771,9 @@ export function createRemoteHub(options = {}) {
10853
11771
  'stopPendingCount',
10854
11772
  'pendingStartCount',
10855
11773
  'pendingStopPromiseCount',
10856
- 'replacementTimerCount',
10857
- 'commandResultWaiterCount',
11774
+ 'replacementTimerCount',
11775
+ 'commandResultWaiterCount',
11776
+ 'clipboardOperationCount',
10858
11777
  'retainedPayloadCount',
10859
11778
  'retainedPayloadBytes'
10860
11779
  ]) {
@@ -10867,9 +11786,10 @@ export function createRemoteHub(options = {}) {
10867
11786
  stopPendingCount: 0,
10868
11787
  pendingStartCount: 0,
10869
11788
  pendingStopPromiseCount: 0,
10870
- replacementTimerCount: 0,
10871
- commandResultWaiterCount: 0,
10872
- retainedPayloadCount: 0,
11789
+ replacementTimerCount: 0,
11790
+ commandResultWaiterCount: 0,
11791
+ clipboardOperationCount: 0,
11792
+ retainedPayloadCount: 0,
10873
11793
  retainedPayloadBytes: 0
10874
11794
  });
10875
11795
  return {
@@ -10909,8 +11829,9 @@ export function createRemoteHub(options = {}) {
10909
11829
  listDeviceFrames,
10910
11830
  disconnectDevice,
10911
11831
  assignDeviceSlot,
10912
- sendCommand,
10913
- refreshDevicePolicies,
11832
+ sendCommand,
11833
+ sendClipboardCommand,
11834
+ refreshDevicePolicies,
10914
11835
  sendLegacyClientUpdate,
10915
11836
  sendInputControl,
10916
11837
  releaseInputOwner,