@livedesk/hub 0.1.48 → 0.1.50

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,18 +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';
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';
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';
18
18
 
19
19
  const DEFAULT_REMOTE_HUB_PORT = 5197;
20
20
  const DEFAULT_REMOTE_HUB_HOST = '0.0.0.0';
@@ -236,13 +236,13 @@ const REMOTE_FRAME_MODE_ALIASES = new Map([
236
236
  ]);
237
237
  const MAX_SYNTHETIC_DEVICES = 1000;
238
238
  const DEFAULT_HOST_TARGET_LEASE_MS = 30000;
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;
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;
246
246
  const AGENT_BINARY_INGRESS_QUEUE_MAX_BYTES = 12 * 1024 * 1024;
247
247
  const AGENT_BINARY_INGRESS_QUEUE_MAX_AGE_MS = 120;
248
248
  const MODE5_AGENT_BINARY_INGRESS_QUEUE_PACKETS = 2;
@@ -1089,7 +1089,7 @@ async function fetchTextWithTimeout(url, timeoutMs) {
1089
1089
  signal: controller.signal,
1090
1090
  headers: {
1091
1091
  accept: 'application/json,text/plain;q=0.9,*/*;q=0.1',
1092
- 'user-agent': 'LiveDesk-Hub/0.1'
1092
+ 'user-agent': 'VuvoDesk-Hub/0.1'
1093
1093
  }
1094
1094
  });
1095
1095
  if (!response.ok) {
@@ -1246,6 +1246,8 @@ const SUPPORTED_AGENT_OPERATIONS = new Set([
1246
1246
  'file.write',
1247
1247
  'file.delete',
1248
1248
  'file.list',
1249
+ 'file.search',
1250
+ 'directory.create',
1249
1251
  'command.run',
1250
1252
  'script.run',
1251
1253
  'software.install',
@@ -1278,7 +1280,7 @@ function readRawRemoteInputMonitorIndex(input) {
1278
1280
  return undefined;
1279
1281
  }
1280
1282
 
1281
- function normalizeRemoteInputEvent(value = {}) {
1283
+ function normalizeRemoteInputEvent(value = {}) {
1282
1284
  const input = value && typeof value === 'object' ? value : {};
1283
1285
  const type = safeString(input.type || input.Type, 48);
1284
1286
  const normalizedX = Number(input.normalizedX ?? input.NormalizedX);
@@ -1299,14 +1301,14 @@ function normalizeRemoteInputEvent(value = {}) {
1299
1301
  .filter(Boolean))]
1300
1302
  .slice(0, 64)
1301
1303
  : null;
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;
1304
+ const key = normalizeRemoteKeyboardKey(input.key ?? input.Key);
1305
+ const clipboardOperationId = safeString(
1306
+ input.clipboardOperationId || input.operationId || input.ClipboardOperationId || input.OperationId,
1307
+ 128);
1308
+ const clipboardDirection = safeString(
1309
+ input.clipboardDirection || input.ClipboardDirection,
1310
+ 16).toLowerCase();
1311
+ const clipboardManifest = input.clipboardManifest || input.manifest || input.ClipboardManifest || input.Manifest;
1310
1312
  let code = safeString(input.code ?? input.Code, 80);
1311
1313
  if ((!code || code === 'Unidentified') && (key === ' ' || key === 'Spacebar' || keyCode === 32)) {
1312
1314
  code = 'Space';
@@ -1331,13 +1333,13 @@ function normalizeRemoteInputEvent(value = {}) {
1331
1333
  shiftKey: input.shiftKey === true || input.ShiftKey === true,
1332
1334
  ctrlKey: input.ctrlKey === true || input.ControlKey === true || input.CtrlKey === true,
1333
1335
  altKey: input.altKey === true || input.AltKey === 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,
1336
+ metaKey: input.metaKey === true || input.MetaKey === true || input.commandKey === true || input.CommandKey === true,
1337
+ clipboardOperationId,
1338
+ operationId: clipboardOperationId,
1339
+ clipboardDirection,
1340
+ manifest: clipboardManifest && typeof clipboardManifest === 'object' && !Array.isArray(clipboardManifest)
1341
+ ? clipboardManifest
1342
+ : null,
1341
1343
  controlLeaseId: safeString(input.controlLeaseId || input.ControlLeaseId, 128),
1342
1344
  controlSessionId: safeString(input.controlSessionId || input.sessionId || input.ControlSessionId || input.SessionId, 160),
1343
1345
  controlCommandId: safeString(input.controlCommandId || input.commandId || input.ControlCommandId || input.CommandId, 128),
@@ -2329,29 +2331,29 @@ export function createRemoteHub(options = {}) {
2329
2331
 
2330
2332
  const enabled = isEnabledValue(env.LIVEDESK_REMOTE_HUB ?? env.MINDEXEC_REMOTE_HUB ?? env.REMOTE_HUB_ENABLED, true)
2331
2333
  && !isDisabledValue(env.REMOTE_HUB_DISABLED);
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,
2334
+ const host = safeString(env.REMOTE_HUB_HOST || DEFAULT_REMOTE_HUB_HOST, 128);
2335
+ const requestedPort = normalizePort(env.REMOTE_HUB_PORT || DEFAULT_REMOTE_HUB_PORT);
2336
+ const heartbeatMs = clampNumber(env.REMOTE_HUB_HEARTBEAT_MS, 1000, 60000, DEFAULT_HEARTBEAT_MS);
2337
+ const defaultDeviceConnectionStaleMs = Math.max(
2338
+ DEFAULT_DEVICE_CONNECTION_STALE_MS,
2339
+ heartbeatMs * 4);
2340
+ const deviceConnectionStaleMs = Number.isFinite(Number(options.deviceConnectionStaleMs))
2341
+ ? clampNumber(options.deviceConnectionStaleMs, 100, 10 * 60 * 1000, defaultDeviceConnectionStaleMs)
2342
+ : clampNumber(
2343
+ env.REMOTE_HUB_DEVICE_CONNECTION_STALE_MS,
2344
+ defaultDeviceConnectionStaleMs,
2345
+ 10 * 60 * 1000,
2346
+ defaultDeviceConnectionStaleMs);
2347
+ const deviceConnectionSweepMs = Number.isFinite(Number(options.deviceConnectionSweepMs))
2348
+ ? clampNumber(options.deviceConnectionSweepMs, 25, 60000, DEFAULT_DEVICE_CONNECTION_SWEEP_MS)
2349
+ : clampNumber(
2350
+ env.REMOTE_HUB_DEVICE_CONNECTION_SWEEP_MS,
2351
+ 1000,
2352
+ 60000,
2353
+ Math.min(DEFAULT_DEVICE_CONNECTION_SWEEP_MS, heartbeatMs));
2354
+ const taskTimeoutMs = clampNumber(
2355
+ options.taskTimeoutMs ?? env.REMOTE_HUB_TASK_TIMEOUT_MS,
2356
+ 50,
2355
2357
  30 * 60 * 1000,
2356
2358
  DEFAULT_AGENT_TASK_TIMEOUT_MS);
2357
2359
  const inputAckTimeoutMs = clampNumber(
@@ -2414,18 +2416,18 @@ export function createRemoteHub(options = {}) {
2414
2416
  const audioSockets = new Map();
2415
2417
  const fileSockets = new Map();
2416
2418
  const allSockets = new Set();
2417
- const agentBinaryIngressStates = new Set();
2418
- const pendingCommandResultWaiters = new Map();
2419
- const clipboardOperations = new Map();
2419
+ const agentBinaryIngressStates = new Set();
2420
+ const pendingCommandResultWaiters = new Map();
2421
+ const clipboardOperations = new Map();
2420
2422
  const transportDiagnostics = new Map();
2421
2423
  const transportDiagnosticStartingDevices = new Set();
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;
2424
+ const duplicateDeviceLogAt = new Map();
2425
+ let server = null;
2426
+ let started = false;
2427
+ let deviceConnectionSweepTimer = null;
2428
+ let boundPort = requestedPort;
2429
+ let lastError = '';
2430
+ let hostTarget = null;
2429
2431
  let publicIPv4Cache = {
2430
2432
  address: safeString(env.LIVEDESK_REMOTE_PUBLIC_IPV4 || env.LIVEDESK_PUBLIC_IPV4 || env.REMOTE_HUB_PUBLIC_IPV4, 64),
2431
2433
  updatedAt: 0,
@@ -2746,14 +2748,14 @@ export function createRemoteHub(options = {}) {
2746
2748
  protocol: 'tcp-jsonl',
2747
2749
  protocolVersion: REMOTE_PROTOCOL_VERSION,
2748
2750
  agentProtocol: REMOTE_AGENT_PROTOCOL,
2749
- frameProtocol: buildRemoteFrameProtocolDescriptor(),
2750
- frameModes: getSupportedRemoteFrameModeProfiles(),
2751
- heartbeatMs,
2752
- deviceConnectionStaleMs,
2753
- deviceConnectionSweepMs,
2754
- taskTimeoutMs,
2755
- managerPackage,
2756
- managerVersion,
2751
+ frameProtocol: buildRemoteFrameProtocolDescriptor(),
2752
+ frameModes: getSupportedRemoteFrameModeProfiles(),
2753
+ heartbeatMs,
2754
+ deviceConnectionStaleMs,
2755
+ deviceConnectionSweepMs,
2756
+ taskTimeoutMs,
2757
+ managerPackage,
2758
+ managerVersion,
2757
2759
  hostInstanceId,
2758
2760
  agentPackage: '@livedesk/client',
2759
2761
  agentEndpoint: routeInfo.endpoint,
@@ -3016,7 +3018,7 @@ export function createRemoteHub(options = {}) {
3016
3018
  return true;
3017
3019
  }
3018
3020
 
3019
- function failPendingCommandResultWaiters(device, error = 'device-disconnected') {
3021
+ function failPendingCommandResultWaiters(device, error = 'device-disconnected') {
3020
3022
  if (!device?.deviceId) {
3021
3023
  return;
3022
3024
  }
@@ -3033,31 +3035,31 @@ export function createRemoteHub(options = {}) {
3033
3035
  result: null,
3034
3036
  error: safeString(error, 500) || 'command-result-unavailable'
3035
3037
  });
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') {
3038
+ }
3039
+ }
3040
+
3041
+ function failPendingFileCommandResultWaiters(device, error = 'file-channel-disconnected') {
3042
+ if (!device?.deviceId) {
3043
+ return;
3044
+ }
3045
+ for (const [waiterKey, waiter] of pendingCommandResultWaiters) {
3046
+ if (waiter.deviceId !== device.deviceId
3047
+ || waiter.sessionId !== device.sessionId
3048
+ || waiter.channel !== 'file') {
3049
+ continue;
3050
+ }
3051
+ pendingCommandResultWaiters.delete(waiterKey);
3052
+ clearPendingCommandResultWaiterDeadline(waiter);
3053
+ waiter.resolve({
3054
+ ok: false,
3055
+ commandId: waiter.commandId,
3056
+ result: null,
3057
+ error: safeString(error, 500) || 'file-channel-disconnected'
3058
+ });
3059
+ }
3060
+ }
3061
+
3062
+ function failPendingCommandResultWaiter(device, commandId, error = 'command-not-sent') {
3061
3063
  const normalizedCommandId = safeString(commandId, 128);
3062
3064
  const waiterKey = buildPendingCommandResultWaiterKey(
3063
3065
  device?.deviceId,
@@ -3078,7 +3080,7 @@ export function createRemoteHub(options = {}) {
3078
3080
  return true;
3079
3081
  }
3080
3082
 
3081
- function waitForCommandResult(device, commandId, timeoutMs = liveCaptureStopAckTimeoutMs, metadata = {}) {
3083
+ function waitForCommandResult(device, commandId, timeoutMs = liveCaptureStopAckTimeoutMs, metadata = {}) {
3082
3084
  const normalizedCommandId = safeString(commandId, 128);
3083
3085
  const waiterKey = buildPendingCommandResultWaiterKey(
3084
3086
  device?.deviceId,
@@ -3104,13 +3106,13 @@ export function createRemoteHub(options = {}) {
3104
3106
  error: 'command-result-wait-replaced'
3105
3107
  });
3106
3108
  }
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,
3109
+ const waiterRecord = {
3110
+ deviceId: device.deviceId,
3111
+ sessionId: device.sessionId,
3112
+ commandId: normalizedCommandId,
3113
+ channel: safeString(metadata.channel, 24),
3114
+ operationId: safeString(metadata.operationId, 128),
3115
+ timer: null,
3114
3116
  deadlineImmediate: null,
3115
3117
  resolve
3116
3118
  };
@@ -3143,246 +3145,246 @@ export function createRemoteHub(options = {}) {
3143
3145
  });
3144
3146
  }, timeoutMs);
3145
3147
  waiterRecord.timer.unref?.();
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
- }
3148
+ pendingCommandResultWaiters.set(waiterKey, waiterRecord);
3149
+ });
3150
+ }
3151
+
3152
+ function buildClipboardOperationKey(deviceId, sessionId, operationId) {
3153
+ const normalizedDeviceId = safeString(deviceId, 160);
3154
+ const normalizedSessionId = safeString(sessionId, 160);
3155
+ const normalizedOperationId = safeString(operationId, 128);
3156
+ return normalizedDeviceId && normalizedSessionId && normalizedOperationId
3157
+ ? `${normalizedDeviceId.length}:${normalizedDeviceId}`
3158
+ + `${normalizedSessionId.length}:${normalizedSessionId}`
3159
+ + `${normalizedOperationId.length}:${normalizedOperationId}`
3160
+ : '';
3161
+ }
3162
+
3163
+ function queueClipboardAgentCleanup(device, operation, reason) {
3164
+ if (operation?.inFlight || !device?.fileSocket || device.fileSocket.destroyed) {
3165
+ return false;
3166
+ }
3167
+ const cleanupCommand = operation.direction === 'paste'
3168
+ ? 'clipboard.paste.cancel'
3169
+ : 'clipboard.copy.end';
3170
+ const commandId = crypto.randomUUID();
3171
+ try {
3172
+ writeJsonLine(device.fileSocket, {
3173
+ type: 'command',
3174
+ commandId,
3175
+ command: cleanupCommand,
3176
+ payload: {
3177
+ operationId: operation.operationId,
3178
+ reason: safeString(reason, 120) || 'clipboard-owner-closed'
3179
+ },
3180
+ issuedAt: new Date().toISOString()
3181
+ });
3182
+ device.counters.commandsSent += 1;
3183
+ emitRemoteEvent('RemoteCommandQueued', device, {
3184
+ commandId,
3185
+ command: cleanupCommand,
3186
+ channel: 'file',
3187
+ operationId: operation.operationId,
3188
+ cleanup: true
3189
+ });
3190
+ return true;
3191
+ } catch {
3192
+ // The exact side channel is already closing. Native session
3193
+ // teardown remains the authoritative cleanup.
3194
+ return false;
3195
+ }
3196
+ }
3197
+
3198
+ function clearClipboardOperationsForDevice(device, reason = 'clipboard-owner-closed', ownerConnectionId = '') {
3199
+ const requestedOwner = safeString(ownerConnectionId, 128);
3200
+ let cleared = 0;
3201
+ for (const [key, operation] of clipboardOperations) {
3202
+ if (operation.deviceId !== device?.deviceId
3203
+ || operation.sessionId !== device?.sessionId
3204
+ || (requestedOwner && operation.hubConnectionId !== requestedOwner)) {
3205
+ continue;
3206
+ }
3207
+ queueClipboardAgentCleanup(device, operation, reason);
3208
+ clipboardOperations.delete(key);
3209
+ cleared += 1;
3210
+ }
3211
+ if (cleared > 0) {
3212
+ emitRemoteEvent('RemoteClipboardOperationsCleared', device, {
3213
+ reason,
3214
+ ownerConnectionId: requestedOwner,
3215
+ count: cleared
3216
+ });
3217
+ }
3218
+ return cleared;
3219
+ }
3220
+
3221
+ function pruneClipboardOperations(nowMs = Date.now()) {
3222
+ for (const [key, operation] of clipboardOperations) {
3223
+ const currentDevice = devices.get(operation.deviceId);
3224
+ const expired = nowMs - Number(operation.touchedAt || 0)
3225
+ > REMOTE_CLIPBOARD_LIMITS.operationIdleTimeoutMs;
3226
+ const staleOwner = !currentDevice
3227
+ || currentDevice.sessionId !== operation.sessionId
3228
+ || safeString(currentDevice.inputOwnerConnectionId, 128) !== operation.hubConnectionId;
3229
+ if (expired || staleOwner) {
3230
+ if (currentDevice?.sessionId === operation.sessionId) {
3231
+ queueClipboardAgentCleanup(
3232
+ currentDevice,
3233
+ operation,
3234
+ expired ? 'clipboard-operation-expired' : 'clipboard-owner-stale');
3235
+ }
3236
+ clipboardOperations.delete(key);
3237
+ }
3238
+ }
3239
+ }
3240
+
3241
+ function clipboardCapabilityError(device, direction, contentKind = 'text') {
3242
+ const capabilities = device?.capabilities || {};
3243
+ if (capabilities.clipboardProtocol !== REMOTE_CLIPBOARD_PROTOCOL
3244
+ || capabilities.clipboardText !== true) {
3245
+ return 'device-clipboard-text-unavailable';
3246
+ }
3247
+ if (capabilities.sessionBoundSideChannels !== true) {
3248
+ return 'device-clipboard-session-bound-channel-unavailable';
3249
+ }
3250
+ if (direction === 'copy' && capabilities.clipboardCopy !== true) {
3251
+ return 'device-clipboard-copy-unavailable';
3252
+ }
3253
+ if (direction === 'paste' && capabilities.clipboardPaste !== true) {
3254
+ return 'device-clipboard-paste-unavailable';
3255
+ }
3256
+ if (contentKind === 'image' && capabilities.clipboardImage !== true) {
3257
+ return 'device-clipboard-image-unavailable';
3258
+ }
3259
+ if (contentKind === 'files' && capabilities.clipboardFiles !== true) {
3260
+ return 'device-clipboard-files-unavailable';
3261
+ }
3262
+ for (const key of ['clipboardMaxTextBytes', 'clipboardMaxTotalBytes', 'clipboardMaxChunkBytes']) {
3263
+ if (!Number.isSafeInteger(Number(capabilities[key])) || Number(capabilities[key]) <= 0) {
3264
+ return 'device-clipboard-limits-unavailable';
3265
+ }
3266
+ }
3267
+ if (Number(capabilities.clipboardMaxChunkBytes) < REMOTE_CLIPBOARD_LIMITS.maxChunkBytes) {
3268
+ return 'device-clipboard-chunk-limit-unavailable';
3269
+ }
3270
+ if (contentKind === 'image'
3271
+ && (!Number.isSafeInteger(Number(
3272
+ capabilities.clipboardMaxImageBytes ?? capabilities.clipboardMaxPngBytes))
3273
+ || Number(capabilities.clipboardMaxImageBytes ?? capabilities.clipboardMaxPngBytes) <= 0)) {
3274
+ return 'device-clipboard-limits-unavailable';
3275
+ }
3276
+ if (contentKind === 'files'
3277
+ && (!Number.isSafeInteger(Number(capabilities.clipboardMaxFiles))
3278
+ || Number(capabilities.clipboardMaxFiles) <= 0)) {
3279
+ return 'device-clipboard-limits-unavailable';
3280
+ }
3281
+ return '';
3282
+ }
3283
+
3284
+ function clipboardManifestCapabilityError(device, manifest) {
3285
+ if (!manifest) {
3286
+ return '';
3287
+ }
3288
+ const capabilities = device?.capabilities || {};
3289
+ if (manifest.totalBytes > Number(capabilities.clipboardMaxTotalBytes || 0)) {
3290
+ return 'device-clipboard-total-limit-exceeded';
3291
+ }
3292
+ if (manifest.contentKind === 'text'
3293
+ && manifest.totalBytes > Number(capabilities.clipboardMaxTextBytes || 0)) {
3294
+ return 'device-clipboard-text-limit-exceeded';
3295
+ }
3296
+ if (manifest.contentKind === 'image'
3297
+ && manifest.totalBytes > Number(
3298
+ capabilities.clipboardMaxImageBytes ?? capabilities.clipboardMaxPngBytes ?? 0)) {
3299
+ return 'device-clipboard-image-limit-exceeded';
3300
+ }
3301
+ if (manifest.contentKind === 'files'
3302
+ && manifest.items.length > Number(capabilities.clipboardMaxFiles || 0)) {
3303
+ return 'device-clipboard-file-count-limit-exceeded';
3304
+ }
3305
+ return '';
3306
+ }
3307
+
3308
+ function validateCurrentClipboardBinding(deviceId, request = {}, contentKind = 'text', options = {}) {
3309
+ const device = devices.get(String(deviceId || ''));
3310
+ if (!device) {
3311
+ return { ok: false, error: 'device-not-found' };
3312
+ }
3313
+ if (device.synthetic === true) {
3314
+ return { ok: false, error: 'device-clipboard-unavailable' };
3315
+ }
3316
+ if (device.connected && !isDeviceConnectionFresh(device)) {
3317
+ retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
3318
+ return { ok: false, error: 'device-not-connected' };
3319
+ }
3320
+ if (!device.connected || !device.socket || device.socket.destroyed) {
3321
+ return { ok: false, error: 'device-not-connected' };
3322
+ }
3323
+
3324
+ const policy = getDevicePolicy(device);
3325
+ if (policy.accessMode === 'block-remote-access') {
3326
+ return { ok: false, error: 'remote-access-blocked-by-settings' };
3327
+ }
3328
+ if (policy.allowControl !== true) {
3329
+ return { ok: false, error: 'allowControl-blocked-by-settings' };
3330
+ }
3331
+ if (policy.allowClipboardText !== true) {
3332
+ return { ok: false, error: 'allowClipboardText-blocked-by-settings' };
3333
+ }
3334
+ if (remoteClipboardContentNeedsFileTransfer(contentKind)
3335
+ && policy.allowFileTransfer !== true) {
3336
+ return { ok: false, error: 'allowFileTransfer-blocked-by-settings' };
3337
+ }
3338
+
3339
+ const direction = request.clipboardDirection
3340
+ || (String(request.action || '').includes('.paste') ? 'paste' : 'copy');
3341
+ const capabilityError = clipboardCapabilityError(device, direction, contentKind);
3342
+ if (capabilityError) {
3343
+ return { ok: false, error: capabilityError };
3344
+ }
3345
+ const manifestCapabilityError = clipboardManifestCapabilityError(
3346
+ device,
3347
+ request.payload?.manifest || request.manifest);
3348
+ if (manifestCapabilityError) {
3349
+ return { ok: false, error: manifestCapabilityError };
3350
+ }
3351
+
3352
+ const controlStream = getActiveControlStream(device);
3353
+ if (!controlStream
3354
+ || !liveStreamHasCurrentFrame(controlStream)
3355
+ || !safeString(controlStream.commandId, 128)
3356
+ || Number(controlStream.captureGeneration || 0) <= 0) {
3357
+ return { ok: false, error: 'CONTROL_NOT_READY' };
3358
+ }
3359
+ if (getPendingLiveStreamDescriptor(controlStream)) {
3360
+ return { ok: false, error: 'CAPTURE_TRANSITION_IN_PROGRESS' };
3361
+ }
3362
+ const binding = request.binding || request;
3363
+ if (safeString(binding.controlSessionId || binding.sessionId, 160)
3364
+ !== safeString(device.sessionId, 160)
3365
+ || safeString(binding.controlCommandId || binding.commandId, 128)
3366
+ !== safeString(controlStream.commandId, 128)) {
3367
+ return { ok: false, error: 'STALE_CONTROL_SESSION' };
3368
+ }
3369
+ if (Number(binding.captureGeneration || 0)
3370
+ !== Number(controlStream.captureGeneration || 0)) {
3371
+ return { ok: false, error: 'STALE_CAPTURE_GENERATION' };
3372
+ }
3373
+ const activeMonitorIndex = normalizeMonitorIndex(controlStream.monitorIndex);
3374
+ if (Number(binding.monitorIndex) !== activeMonitorIndex) {
3375
+ return { ok: false, error: 'STALE_CONTROL_MONITOR' };
3376
+ }
3377
+ const hubConnectionId = safeString(request.hubConnectionId, 128);
3378
+ if (!hubConnectionId
3379
+ || hubConnectionId !== safeString(device.inputOwnerConnectionId, 128)) {
3380
+ return { ok: false, error: 'input-owner-not-current' };
3381
+ }
3382
+ if (options.requireFileSocket === true
3383
+ && (!device.fileSocket || device.fileSocket.destroyed)) {
3384
+ return { ok: false, error: 'clipboard-file-channel-unavailable' };
3385
+ }
3386
+ return { ok: true, device, controlStream, activeMonitorIndex, policy };
3387
+ }
3386
3388
 
3387
3389
  function inspectAnnexBH264(payload) {
3388
3390
  if (!Buffer.isBuffer(payload) || payload.length < 5) {
@@ -4264,15 +4266,15 @@ export function createRemoteHub(options = {}) {
4264
4266
  }
4265
4267
  }
4266
4268
 
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;
4269
+ function closeFileSocket(device, reason = 'file-socket-closed') {
4270
+ const socket = device?.fileSocket;
4271
+ if (!socket) {
4272
+ return;
4273
+ }
4274
+
4275
+ clearClipboardOperationsForDevice(device, reason);
4276
+ failPendingFileCommandResultWaiters(device, 'clipboard-file-channel-disconnected');
4277
+ device.fileSocket = null;
4276
4278
  fileSockets.delete(socket);
4277
4279
  try {
4278
4280
  writeJsonLine(socket, { type: 'disconnect', channel: 'file', reason });
@@ -4286,14 +4288,14 @@ export function createRemoteHub(options = {}) {
4286
4288
  }
4287
4289
  }
4288
4290
 
4289
- function closeInputSocket(device, reason = 'input-socket-closed') {
4291
+ function closeInputSocket(device, reason = 'input-socket-closed') {
4290
4292
  const socket = device?.inputSocket;
4291
4293
  if (!socket) {
4292
4294
  return;
4293
4295
  }
4294
4296
 
4295
- const writeOwner = device.inputWriteOwner;
4296
- clearClipboardOperationsForDevice(device, reason);
4297
+ const writeOwner = device.inputWriteOwner;
4298
+ clearClipboardOperationsForDevice(device, reason);
4297
4299
  device.inputSocket = null;
4298
4300
  device.inputWriteOwner = null;
4299
4301
  device.inputSocketConnectionId = '';
@@ -4313,7 +4315,7 @@ export function createRemoteHub(options = {}) {
4313
4315
  }
4314
4316
  }
4315
4317
 
4316
- function detachInputSocket(socket, reason = 'input-socket-closed') {
4318
+ function detachInputSocket(socket, reason = 'input-socket-closed') {
4317
4319
  const deviceId = inputSockets.get(socket);
4318
4320
  inputSockets.delete(socket);
4319
4321
  if (!deviceId) {
@@ -4325,8 +4327,8 @@ export function createRemoteHub(options = {}) {
4325
4327
  return;
4326
4328
  }
4327
4329
 
4328
- const writeOwner = device.inputWriteOwner;
4329
- clearClipboardOperationsForDevice(device, reason);
4330
+ const writeOwner = device.inputWriteOwner;
4331
+ clearClipboardOperationsForDevice(device, reason);
4330
4332
  device.inputSocket = null;
4331
4333
  device.inputWriteOwner = null;
4332
4334
  device.inputSocketConnectionId = '';
@@ -4371,7 +4373,7 @@ export function createRemoteHub(options = {}) {
4371
4373
  emitRemoteEvent('RemoteAudioSocketDisconnected', device, { reason });
4372
4374
  }
4373
4375
 
4374
- function detachFileSocket(socket, reason = 'file-socket-closed') {
4376
+ function detachFileSocket(socket, reason = 'file-socket-closed') {
4375
4377
  const deviceId = fileSockets.get(socket);
4376
4378
  fileSockets.delete(socket);
4377
4379
  if (!deviceId) {
@@ -4379,107 +4381,107 @@ export function createRemoteHub(options = {}) {
4379
4381
  }
4380
4382
 
4381
4383
  const device = devices.get(deviceId);
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;
4384
+ if (!device || device.fileSocket !== socket) {
4385
+ return;
4386
+ }
4387
+
4388
+ clearClipboardOperationsForDevice(device, reason);
4389
+ failPendingFileCommandResultWaiters(device, 'clipboard-file-channel-disconnected');
4390
+ device.fileSocket = null;
4389
4391
  device.fileLastSeenAt = new Date().toISOString();
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()) {
4392
+ emitRemoteEvent('RemoteFileSocketDisconnected', device, { reason });
4393
+ }
4394
+
4395
+ function getDeviceConnectionHeartbeatMs(device) {
4396
+ return Date.parse(device?.lastStatusAt || '')
4397
+ || Date.parse(device?.connectedAt || '')
4398
+ || 0;
4399
+ }
4400
+
4401
+ function isDeviceConnectionFresh(device, nowMs = Date.now()) {
4400
4402
  if (!device?.connected || !device.socket || device.socket.destroyed) {
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) {
4403
+ return false;
4404
+ }
4405
+
4406
+ const lastHeartbeatMs = getDeviceConnectionHeartbeatMs(device);
4407
+ if (!lastHeartbeatMs) {
4408
+ return true;
4409
+ }
4410
+
4411
+ return nowMs - lastHeartbeatMs <= deviceConnectionStaleMs;
4412
+ }
4413
+
4414
+ function retireStaleDeviceConnection(device, reason = 'status-heartbeat-timeout') {
4415
+ const deviceId = safeString(device?.deviceId, 160);
4416
+ const sessionId = safeString(device?.sessionId, 160);
4417
+ const socket = device?.socket;
4418
+ if (!deviceId
4419
+ || !sessionId
4420
+ || !socket
4421
+ || socket.destroyed
4422
+ || devices.get(deviceId) !== device
4423
+ || device.socket !== socket
4424
+ || device.sessionId !== sessionId) {
4425
+ return false;
4426
+ }
4427
+
4428
+ closeInputSocket(device, reason);
4429
+ closeFrameSocket(device, reason);
4430
+ closeAudioSocket(device, reason);
4431
+ closeFileSocket(device, reason);
4432
+ try {
4433
+ writeJsonLine(socket, { type: 'disconnect', reason, sessionId });
4434
+ } catch {
4435
+ // The stale connection is retired below even if the final notice fails.
4436
+ }
4437
+ detachSocket(socket, reason);
4438
+ try {
4439
+ socket.destroy?.();
4440
+ } catch {
4441
+ // detachSocket already made the exact session terminal.
4442
+ }
4443
+ return true;
4444
+ }
4445
+
4446
+ function sweepStaleDeviceConnections(nowMs = Date.now()) {
4447
+ pruneClipboardOperations(nowMs);
4448
+ let retired = 0;
4449
+ for (const device of devices.values()) {
4450
+ if (device?.synthetic === true || isDeviceConnectionFresh(device, nowMs)) {
4451
+ continue;
4452
+ }
4453
+ if (!device?.connected || !device.socket || device.socket.destroyed) {
4454
+ continue;
4455
+ }
4456
+ const ageMs = Math.max(0, nowMs - getDeviceConnectionHeartbeatMs(device));
4457
+ if (retireStaleDeviceConnection(device, 'status-heartbeat-timeout')) {
4458
+ retired += 1;
4459
+ logWarn(
4460
+ 'remote',
4461
+ `retired stale device session ${device.deviceName} (${device.deviceId}) `
4462
+ + `session=${device.sessionId} heartbeatAgeMs=${ageMs} `
4463
+ + `staleAfterMs=${deviceConnectionStaleMs}`);
4464
+ }
4465
+ }
4466
+ return retired;
4467
+ }
4468
+
4469
+ function scheduleDeviceConnectionSweep() {
4470
+ if (!started || deviceConnectionSweepTimer) {
4471
+ return;
4472
+ }
4473
+ deviceConnectionSweepTimer = setTimeout(() => {
4474
+ deviceConnectionSweepTimer = null;
4475
+ if (!started) {
4476
+ return;
4477
+ }
4478
+ sweepStaleDeviceConnections();
4479
+ scheduleDeviceConnectionSweep();
4480
+ }, deviceConnectionSweepMs);
4481
+ deviceConnectionSweepTimer.unref?.();
4482
+ }
4483
+
4484
+ function rejectDuplicateActiveDevice(socket, existing, attemptedSessionId) {
4483
4485
  const retryAfterMs = Math.max(heartbeatMs, duplicateDeviceRetryAfterMs);
4484
4486
  writeJsonLine(socket, {
4485
4487
  type: 'disconnect',
@@ -4931,20 +4933,20 @@ export function createRemoteHub(options = {}) {
4931
4933
  return device;
4932
4934
  }
4933
4935
 
4934
- function attachInputSocket(socket, hello) {
4935
- const deviceId = normalizeDeviceId(hello.deviceId);
4936
- const device = devices.get(deviceId);
4936
+ function attachInputSocket(socket, hello) {
4937
+ const deviceId = normalizeDeviceId(hello.deviceId);
4938
+ const device = devices.get(deviceId);
4937
4939
  if (!device || !device.connected || !device.socket || device.socket.destroyed) {
4938
4940
  writeJsonLine(socket, { type: 'error', error: 'device-not-connected' });
4939
4941
  socket.destroy();
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');
4942
+ return null;
4943
+ }
4944
+ if (safeString(hello.parentSessionId, 160) !== safeString(device.sessionId, 160)) {
4945
+ writeJsonLineAndClose(socket, { type: 'error', error: 'side-channel-parent-session-stale' });
4946
+ return null;
4947
+ }
4948
+
4949
+ closeInputSocket(device, 'replaced-by-new-input-socket');
4948
4950
 
4949
4951
  const now = new Date().toISOString();
4950
4952
  const inputSocketConnectionId = crypto.randomUUID();
@@ -5058,20 +5060,20 @@ export function createRemoteHub(options = {}) {
5058
5060
  return device;
5059
5061
  }
5060
5062
 
5061
- function attachFileSocket(socket, hello) {
5062
- const deviceId = normalizeDeviceId(hello.deviceId);
5063
- const device = devices.get(deviceId);
5063
+ function attachFileSocket(socket, hello) {
5064
+ const deviceId = normalizeDeviceId(hello.deviceId);
5065
+ const device = devices.get(deviceId);
5064
5066
  if (!device || !device.connected || !device.socket || device.socket.destroyed) {
5065
5067
  writeJsonLine(socket, { type: 'error', error: 'device-not-connected' });
5066
5068
  socket.destroy();
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');
5069
+ return null;
5070
+ }
5071
+ if (safeString(hello.parentSessionId, 160) !== safeString(device.sessionId, 160)) {
5072
+ writeJsonLineAndClose(socket, { type: 'error', error: 'side-channel-parent-session-stale' });
5073
+ return null;
5074
+ }
5075
+
5076
+ closeFileSocket(device, 'replaced-by-new-file-socket');
5075
5077
  const now = new Date().toISOString();
5076
5078
  device.fileSocket = socket;
5077
5079
  device.fileConnectedAt = now;
@@ -8374,9 +8376,9 @@ export function createRemoteHub(options = {}) {
8374
8376
  started = true;
8375
8377
  boundPort = candidateServer.address()?.port || port;
8376
8378
  lastError = '';
8377
- logEvent('remote', `LiveDesk Hub client endpoint listening on tcp://${host}:${boundPort}`, 'success');
8379
+ logEvent('remote', `VuvoDesk Hub client endpoint listening on tcp://${host}:${boundPort}`, 'success');
8378
8380
  if (host === '0.0.0.0' || host === '::') {
8379
- logWarn('remote', 'LiveDesk Hub client endpoint is externally reachable. Use a strong pairing token and trusted network.');
8381
+ logWarn('remote', 'VuvoDesk Hub client endpoint is externally reachable. Use a strong pairing token and trusted network.');
8380
8382
  }
8381
8383
  emitRemoteEvent('RemoteHubStarted', null);
8382
8384
  resolve();
@@ -8394,14 +8396,14 @@ export function createRemoteHub(options = {}) {
8394
8396
  await listenOnPort(requestedPort);
8395
8397
  try {
8396
8398
  await relayControl?.start?.();
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(
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.`);
8399
+ } catch {
8400
+ logWarn('relay', 'Encrypted relay control could not start; direct TCP remains available.');
8401
+ }
8402
+ scheduleDeviceConnectionSweep();
8403
+ } catch (error) {
8404
+ if (error?.code === 'EADDRINUSE') {
8405
+ const conflict = new Error(
8406
+ `VuvoDesk 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.`);
8405
8407
  conflict.code = 'EADDRINUSE';
8406
8408
  conflict.port = requestedPort;
8407
8409
  conflict.cause = error;
@@ -8412,16 +8414,16 @@ export function createRemoteHub(options = {}) {
8412
8414
  }
8413
8415
 
8414
8416
  return getStatus({ includeSecrets: false });
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
- }
8417
+ }
8418
+
8419
+ async function close() {
8420
+ if (deviceConnectionSweepTimer) {
8421
+ clearTimeout(deviceConnectionSweepTimer);
8422
+ deviceConnectionSweepTimer = null;
8423
+ }
8424
+ for (const state of [...agentBinaryIngressStates]) {
8425
+ closeAgentBinaryIngress(state, 'hub-shutdown');
8426
+ }
8425
8427
 
8426
8428
  for (const device of devices.values()) {
8427
8429
  abortTransportDiagnosticsForDevice(device, 'hub-shutdown');
@@ -8556,12 +8558,12 @@ export function createRemoteHub(options = {}) {
8556
8558
  };
8557
8559
  }
8558
8560
 
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
- }
8561
+ function sendCommand(deviceId, command) {
8562
+ const device = devices.get(String(deviceId || ''));
8563
+ const commandName = safeString(command?.command || 'ping', 80);
8564
+ if (REMOTE_CLIPBOARD_FILE_ACTIONS.has(commandName)) {
8565
+ return { ok: false, error: 'clipboard-command-requires-awaited-file-channel' };
8566
+ }
8565
8567
  const requiredPermission = commandName === 'input.control'
8566
8568
  ? 'allowControl'
8567
8569
  : commandName === 'system.power'
@@ -8597,16 +8599,16 @@ export function createRemoteHub(options = {}) {
8597
8599
  result: { ok: true, synthetic: true },
8598
8600
  error: ''
8599
8601
  });
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
- }
8602
+ return { ok: true, commandId, synthetic: true };
8603
+ }
8604
+
8605
+ if (device?.connected && !isDeviceConnectionFresh(device)) {
8606
+ retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
8607
+ return { ok: false, error: 'device-not-connected' };
8608
+ }
8609
+ if (!device?.socket || device.socket.destroyed || !device.connected) {
8610
+ return { ok: false, error: 'device-not-connected' };
8611
+ }
8610
8612
 
8611
8613
  const commandId = safeString(command?.commandId, 128) || crypto.randomUUID();
8612
8614
  const payload = {
@@ -8619,300 +8621,300 @@ export function createRemoteHub(options = {}) {
8619
8621
 
8620
8622
  const dedicatedFileSocket = commandName.startsWith('file.transfer')
8621
8623
  && device.fileSocket
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,
8624
+ && !device.fileSocket.destroyed
8625
+ ? device.fileSocket
8626
+ : null;
8627
+ try {
8628
+ writeJsonLine(dedicatedFileSocket || device.socket, payload);
8629
+ } catch {
8630
+ retireStaleDeviceConnection(device, 'command-channel-write-failed');
8631
+ return { ok: false, error: 'device-not-connected' };
8632
+ }
8633
+ device.counters.commandsSent += 1;
8634
+ emitRemoteEvent('RemoteCommandQueued', device, {
8635
+ commandId,
8634
8636
  command: payload.command,
8635
8637
  channel: dedicatedFileSocket ? 'file' : 'control'
8636
8638
  });
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) {
8639
+ return { ok: true, commandId };
8640
+ }
8641
+
8642
+ async function sendClipboardCommand(deviceId, request = {}) {
8643
+ pruneClipboardOperations();
8644
+ const action = safeString(request.action, 80).toLowerCase();
8645
+ if (!REMOTE_CLIPBOARD_FILE_ACTIONS.has(action)) {
8646
+ return { ok: false, error: 'clipboard-file-action-invalid' };
8647
+ }
8648
+ const operationId = safeString(request.operationId, 128);
8649
+ if (!operationId) {
8650
+ return { ok: false, error: 'clipboard-operation-id-invalid' };
8651
+ }
8652
+
8653
+ const direction = action.startsWith('clipboard.paste.')
8654
+ ? 'paste'
8655
+ : action.startsWith('clipboard.copy.')
8656
+ ? 'copy'
8657
+ : '';
8658
+ const operationKey = buildClipboardOperationKey(deviceId, request.binding?.controlSessionId, operationId);
8659
+ let operation = clipboardOperations.get(operationKey);
8660
+ const beginsOperation = action === 'clipboard.copy.begin' || action === 'clipboard.paste.begin';
8661
+ const endsOperation = action === 'clipboard.copy.end' || action === 'clipboard.paste.cancel';
8662
+ const requestedManifest = request.payload?.manifest || null;
8663
+
8664
+ if (beginsOperation) {
8665
+ if (operation) {
8666
+ return { ok: false, error: 'clipboard-operation-already-exists' };
8667
+ }
8668
+ const activeForDevice = [...clipboardOperations.values()].filter(candidate =>
8669
+ candidate.deviceId === String(deviceId || '')
8670
+ && candidate.sessionId === request.binding?.controlSessionId);
8671
+ if (activeForDevice.some(candidate => candidate.direction === direction)) {
8672
+ return { ok: false, error: 'clipboard-operation-direction-busy' };
8673
+ }
8674
+ if (activeForDevice.length >= REMOTE_CLIPBOARD_LIMITS.maxOperationsPerDevice) {
8675
+ return { ok: false, error: 'clipboard-operation-capacity-reached' };
8676
+ }
8677
+ operation = {
8678
+ deviceId: String(deviceId || ''),
8679
+ sessionId: safeString(request.binding?.controlSessionId, 160),
8680
+ operationId,
8681
+ direction,
8682
+ hubConnectionId: safeString(request.hubConnectionId, 128),
8683
+ controlCommandId: safeString(request.binding?.controlCommandId, 128),
8684
+ captureGeneration: Number(request.binding?.captureGeneration || 0),
8685
+ monitorIndex: Number(request.binding?.monitorIndex),
8686
+ contentKind: requestedManifest?.contentKind || 'text',
8687
+ manifest: requestedManifest,
8688
+ offsets: new Map(),
8689
+ status: action === 'clipboard.paste.begin' ? 'preparing' : 'copying',
8690
+ inFlight: false,
8691
+ touchedAt: Date.now()
8692
+ };
8693
+ clipboardOperations.set(operationKey, operation);
8694
+ } else {
8695
+ if (!operation) {
8696
+ return { ok: false, error: 'clipboard-operation-not-found' };
8697
+ }
8698
+ const requestedDirection = direction || operation.direction;
8699
+ if (operation.direction !== requestedDirection
8700
+ || operation.hubConnectionId !== safeString(request.hubConnectionId, 128)
8701
+ || operation.controlCommandId !== safeString(request.binding?.controlCommandId, 128)
8702
+ || operation.captureGeneration !== Number(request.binding?.captureGeneration || 0)
8703
+ || operation.monitorIndex !== Number(request.binding?.monitorIndex)) {
8704
+ return { ok: false, error: 'clipboard-operation-owner-stale' };
8705
+ }
8706
+ }
8707
+
8708
+ if (operation.inFlight) {
8709
+ return { ok: false, error: 'clipboard-operation-busy' };
8710
+ }
8711
+
8712
+ const contentKind = operation.contentKind || requestedManifest?.contentKind || 'text';
8713
+ const bindingResult = validateCurrentClipboardBinding(
8714
+ deviceId,
8715
+ { ...request, clipboardDirection: operation.direction },
8716
+ contentKind,
8717
+ { requireFileSocket: true });
8718
+ if (!bindingResult.ok) {
8719
+ if (beginsOperation || bindingResult.error?.startsWith('STALE_') || bindingResult.error === 'input-owner-not-current') {
8720
+ clipboardOperations.delete(operationKey);
8721
+ }
8722
+ return bindingResult;
8723
+ }
8724
+
8725
+ const { device } = bindingResult;
8726
+ const fileSocket = device.fileSocket;
8727
+ const itemIndex = request.payload?.itemIndex;
8728
+ const expectedOffset = Number.isInteger(itemIndex)
8729
+ ? Number(operation.offsets.get(itemIndex) || 0)
8730
+ : 0;
8731
+ let outgoingChunkBytes = 0;
8732
+ if (action === 'clipboard.copy.chunk' || action === 'clipboard.paste.chunk') {
8733
+ const item = operation.manifest?.items?.[itemIndex];
8734
+ if (!item) {
8735
+ return { ok: false, error: 'clipboard-item-not-found' };
8736
+ }
8737
+ if (request.payload.offset !== expectedOffset) {
8738
+ return { ok: false, error: 'clipboard-chunk-offset-stale', expectedOffset };
8739
+ }
8740
+ if (action === 'clipboard.copy.chunk'
8741
+ && request.payload.maxBytes > Number(device.capabilities?.clipboardMaxChunkBytes || 0)) {
8742
+ return { ok: false, error: 'device-clipboard-chunk-limit-exceeded' };
8743
+ }
8744
+ if (action === 'clipboard.paste.chunk') {
8745
+ outgoingChunkBytes = Buffer.from(request.payload.dataBase64, 'base64').length;
8746
+ if (outgoingChunkBytes > Number(device.capabilities?.clipboardMaxChunkBytes || 0)) {
8747
+ return { ok: false, error: 'device-clipboard-chunk-limit-exceeded' };
8748
+ }
8749
+ const nextOffset = expectedOffset + outgoingChunkBytes;
8750
+ if (outgoingChunkBytes === 0
8751
+ && !(item.size === 0 && expectedOffset === 0 && request.payload.final === true)) {
8752
+ return { ok: false, error: 'clipboard-empty-chunk-invalid' };
8753
+ }
8754
+ if (nextOffset > item.size
8755
+ || request.payload.final !== (nextOffset === item.size)) {
8756
+ return { ok: false, error: 'clipboard-chunk-boundary-invalid' };
8757
+ }
8758
+ }
8759
+ }
8760
+
8761
+ operation.inFlight = true;
8762
+ operation.touchedAt = Date.now();
8763
+ let commandCompleted = false;
8764
+ const commandId = crypto.randomUUID();
8765
+ const resultPromise = waitForCommandResult(
8766
+ device,
8767
+ commandId,
8768
+ REMOTE_CLIPBOARD_LIMITS.commandTimeoutMs,
8769
+ { channel: 'file', operationId });
8770
+ const commandPayload = {
8771
+ type: 'command',
8772
+ commandId,
8773
+ command: action,
8774
+ payload: {
8775
+ operationId,
8776
+ controlSessionId: operation.sessionId,
8777
+ controlCommandId: operation.controlCommandId,
8778
+ captureGeneration: operation.captureGeneration,
8779
+ monitorIndex: operation.monitorIndex,
8780
+ hubConnectionId: operation.hubConnectionId,
8781
+ ...(request.payload || {})
8782
+ },
8783
+ issuedAt: new Date().toISOString()
8784
+ };
8785
+
8786
+ try {
8787
+ try {
8788
+ // Clipboard payloads are never allowed onto the concurrent
8789
+ // main command socket. The dedicated file side channel owns
8790
+ // the one bounded command and its exact-session waiter.
8791
+ writeJsonLine(fileSocket, commandPayload);
8792
+ } catch {
8793
+ failPendingCommandResultWaiter(device, commandId, 'clipboard-file-channel-write-failed');
8794
+ if (device.fileSocket === fileSocket) {
8795
+ closeFileSocket(device, 'clipboard-file-channel-write-failed');
8796
+ }
8797
+ return { ok: false, error: 'clipboard-file-channel-unavailable' };
8798
+ }
8799
+ device.counters.commandsSent += 1;
8800
+ emitRemoteEvent('RemoteCommandQueued', device, {
8801
+ commandId,
8802
+ command: action,
8803
+ channel: 'file',
8804
+ operationId
8805
+ });
8806
+
8807
+ const awaited = await resultPromise;
8808
+ if (!awaited.ok) {
8809
+ return {
8810
+ ok: false,
8811
+ error: awaited.error || 'clipboard-command-failed',
8812
+ commandId
8813
+ };
8814
+ }
8815
+ if (device.fileSocket !== fileSocket) {
8816
+ return { ok: false, error: 'clipboard-file-channel-owner-changed' };
8817
+ }
8818
+ const currentBinding = validateCurrentClipboardBinding(
8819
+ deviceId,
8820
+ { ...request, clipboardDirection: operation.direction },
8821
+ operation.contentKind || 'text',
8822
+ { requireFileSocket: true });
8823
+ if (!currentBinding.ok) {
8824
+ return currentBinding;
8825
+ }
8826
+
8827
+ let response;
8828
+ try {
8829
+ response = normalizeRemoteClipboardCommandResult(action, operationId, awaited.result);
8830
+ } catch (error) {
8831
+ return {
8832
+ ok: false,
8833
+ error: safeString(error?.code || error?.message, 160) || 'clipboard-response-invalid'
8834
+ };
8835
+ }
8836
+ if (response.manifest) {
8837
+ const responseManifestLimitError = clipboardManifestCapabilityError(device, response.manifest);
8838
+ if (responseManifestLimitError) {
8839
+ clipboardOperations.delete(operationKey);
8840
+ return { ok: false, error: responseManifestLimitError };
8841
+ }
8842
+ const responsePolicy = validateCurrentClipboardBinding(
8843
+ deviceId,
8844
+ { ...request, clipboardDirection: operation.direction },
8845
+ response.manifest.contentKind,
8846
+ { requireFileSocket: true });
8847
+ if (!responsePolicy.ok) {
8848
+ clipboardOperations.delete(operationKey);
8849
+ return responsePolicy;
8850
+ }
8851
+ operation.manifest = response.manifest;
8852
+ operation.contentKind = response.manifest.contentKind;
8853
+ }
8854
+ if (action === 'clipboard.copy.chunk') {
8855
+ const item = operation.manifest?.items?.[response.itemIndex];
8856
+ const nextOffset = response.offset + response.bytes;
8857
+ if (!item
8858
+ || response.itemIndex !== itemIndex
8859
+ || response.offset !== expectedOffset
8860
+ || (response.bytes === 0
8861
+ && !(item.size === 0 && response.offset === 0 && response.final === true))
8862
+ || nextOffset > item.size
8863
+ || response.final !== (nextOffset === item.size)) {
8864
+ return { ok: false, error: 'clipboard-chunk-response-boundary-invalid' };
8865
+ }
8866
+ if (response.bytes > Number(device.capabilities?.clipboardMaxChunkBytes || 0)) {
8867
+ return { ok: false, error: 'device-clipboard-chunk-limit-exceeded' };
8868
+ }
8869
+ operation.offsets.set(response.itemIndex, nextOffset);
8870
+ } else if (action === 'clipboard.paste.chunk') {
8871
+ if (response.itemIndex !== itemIndex
8872
+ || response.offset !== expectedOffset
8873
+ || response.bytes !== outgoingChunkBytes
8874
+ || response.totalBytes !== operation.manifest.items[itemIndex].size
8875
+ || response.final !== request.payload.final) {
8876
+ return { ok: false, error: 'clipboard-chunk-response-boundary-invalid' };
8877
+ }
8878
+ operation.offsets.set(itemIndex, expectedOffset + outgoingChunkBytes);
8879
+ }
8880
+ operation.status = response.status || operation.status;
8881
+ operation.touchedAt = Date.now();
8882
+ if (action === 'clipboard.operation.status' && operation.status === 'not-found') {
8883
+ clipboardOperations.delete(operationKey);
8884
+ return { ok: false, error: 'clipboard-operation-not-found' };
8885
+ }
8886
+ if (action === 'clipboard.operation.status'
8887
+ && ['completed', 'complete', 'applied', 'pasted', 'cancelled', 'cleaned', 'failed', 'expired', 'already-ended']
8888
+ .includes(operation.status)) {
8889
+ clipboardOperations.delete(operationKey);
8890
+ }
8891
+ commandCompleted = true;
8892
+ return response;
8893
+ } finally {
8894
+ const currentOperation = clipboardOperations.get(operationKey);
8895
+ if (currentOperation === operation) {
8896
+ operation.inFlight = false;
8897
+ if (endsOperation || (beginsOperation && !commandCompleted)) {
8898
+ clipboardOperations.delete(operationKey);
8899
+ }
8900
+ }
8901
+ }
8902
+ }
8903
+
8904
+ function refreshDevicePolicies(deviceIds = undefined) {
8903
8905
  const requestedIds = Array.isArray(deviceIds) && deviceIds.length > 0
8904
8906
  ? new Set(deviceIds.map(deviceId => safeString(deviceId, 160)).filter(Boolean))
8905
8907
  : null;
8906
8908
  let updated = 0;
8907
8909
  let total = 0;
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;
8910
+ for (const device of devices.values()) {
8911
+ if (requestedIds && !requestedIds.has(device.deviceId)) continue;
8912
+ if (!device?.socket || device.socket.destroyed || !device.connected) continue;
8913
+ if (device.synthetic !== true && !isDeviceConnectionFresh(device)) {
8914
+ retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
8915
+ continue;
8916
+ }
8917
+ total += 1;
8916
8918
  const effectivePolicy = getDevicePolicy(device);
8917
8919
  if (writeJsonLine(device.socket, {
8918
8920
  type: 'policy.update',
@@ -8931,14 +8933,14 @@ export function createRemoteHub(options = {}) {
8931
8933
  const device = devices.get(String(deviceId || ''));
8932
8934
  const denied = policyError(device);
8933
8935
  if (denied) return { ok: false, error: denied };
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) {
8936
+ if (!device) return { ok: false, error: 'device-not-found' };
8937
+ if (device.synthetic !== true
8938
+ && device.connected
8939
+ && !isDeviceConnectionFresh(device)) {
8940
+ retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
8941
+ return { ok: false, error: 'device-not-connected' };
8942
+ }
8943
+ if (!device.socket || device.socket.destroyed || !device.connected) {
8942
8944
  return { ok: false, error: 'device-not-connected' };
8943
8945
  }
8944
8946
 
@@ -9034,21 +9036,21 @@ export function createRemoteHub(options = {}) {
9034
9036
  return device.inputWriteOwner;
9035
9037
  }
9036
9038
 
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
- }
9039
+ function sendInputControl(deviceId, input = {}) {
9040
+ const device = devices.get(String(deviceId || ''));
9041
+ if (!device) {
9042
+ return { ok: false, error: 'device-not-found' };
9043
+ }
9044
+
9045
+ if (device.synthetic !== true
9046
+ && device.connected
9047
+ && !isDeviceConnectionFresh(device)) {
9048
+ retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
9049
+ return { ok: false, error: 'device-not-connected' };
9050
+ }
9051
+ if (!device.connected) {
9052
+ return { ok: false, error: 'device-not-connected' };
9053
+ }
9052
9054
 
9053
9055
  const normalized = normalizeRemoteInputEvent(input);
9054
9056
  if (!normalized.type) {
@@ -9114,51 +9116,51 @@ export function createRemoteHub(options = {}) {
9114
9116
  || device.capabilities?.accessibilityPermission === false) {
9115
9117
  return { ok: false, error: 'INPUT_PERMISSION_DENIED' };
9116
9118
  }
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
- }
9119
+ if (!readCapabilityFlag(device.capabilities, 'control')) {
9120
+ return { ok: false, error: 'device-input-control-unavailable' };
9121
+ }
9122
+
9123
+ const normalizedInputType = normalized.type.toLowerCase();
9124
+ const isClipboardInput = normalizedInputType === 'clipboard.prepare'
9125
+ || normalizedInputType === 'clipboard.copy'
9126
+ || normalizedInputType === 'clipboard.paste';
9127
+ let clipboardManifest = null;
9128
+ if (isClipboardInput) {
9129
+ if (!normalized.clipboardOperationId) {
9130
+ return { ok: false, error: 'clipboard-operation-id-invalid' };
9131
+ }
9132
+ if (!['copy', 'paste'].includes(normalized.clipboardDirection)) {
9133
+ return { ok: false, error: 'clipboard-direction-required' };
9134
+ }
9135
+ if ((normalizedInputType === 'clipboard.copy' && normalized.clipboardDirection !== 'copy')
9136
+ || (normalizedInputType === 'clipboard.paste' && normalized.clipboardDirection !== 'paste')) {
9137
+ return { ok: false, error: 'clipboard-direction-mismatch' };
9138
+ }
9139
+ try {
9140
+ clipboardManifest = normalized.manifest
9141
+ ? normalizeRemoteClipboardManifest(normalized.manifest, normalized.clipboardOperationId)
9142
+ : null;
9143
+ } catch (error) {
9144
+ return { ok: false, error: safeString(error?.code || error?.message, 160) || 'clipboard-manifest-invalid' };
9145
+ }
9146
+ const clipboardPolicy = getDevicePolicy(device);
9147
+ if (clipboardPolicy.allowClipboardText !== true) {
9148
+ return { ok: false, error: 'allowClipboardText-blocked-by-settings' };
9149
+ }
9150
+ const contentKind = clipboardManifest?.contentKind || normalized.contentKind || 'text';
9151
+ if (remoteClipboardContentNeedsFileTransfer(contentKind)
9152
+ && clipboardPolicy.allowFileTransfer !== true) {
9153
+ return { ok: false, error: 'allowFileTransfer-blocked-by-settings' };
9154
+ }
9155
+ const clipboardCapabilityDenied = clipboardCapabilityError(
9156
+ device,
9157
+ normalized.clipboardDirection,
9158
+ contentKind);
9159
+ if (clipboardCapabilityDenied) {
9160
+ return { ok: false, error: clipboardCapabilityDenied };
9161
+ }
9162
+ normalized.manifest = clipboardManifest;
9163
+ }
9162
9164
 
9163
9165
  const controlStream = getActiveControlStream(device);
9164
9166
  if (!controlStream
@@ -9194,32 +9196,32 @@ export function createRemoteHub(options = {}) {
9194
9196
  };
9195
9197
  }
9196
9198
  const activeMonitorIndex = normalizeMonitorIndex(controlStream.monitorIndex);
9197
- if (!Number.isInteger(normalized.monitorIndex)
9198
- || normalized.monitorIndex !== activeMonitorIndex) {
9199
+ if (!Number.isInteger(normalized.monitorIndex)
9200
+ || normalized.monitorIndex !== activeMonitorIndex) {
9199
9201
  return {
9200
9202
  ok: false,
9201
9203
  error: 'STALE_CONTROL_MONITOR',
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
- }
9204
+ activeMonitorIndex
9205
+ };
9206
+ }
9207
+
9208
+ let clipboardOperationKey = '';
9209
+ if (normalizedInputType === 'clipboard.paste') {
9210
+ clipboardOperationKey = buildClipboardOperationKey(
9211
+ device.deviceId,
9212
+ device.sessionId,
9213
+ normalized.clipboardOperationId);
9214
+ const operation = clipboardOperations.get(clipboardOperationKey);
9215
+ if (!operation
9216
+ || operation.direction !== 'paste'
9217
+ || operation.hubConnectionId !== normalized.hubConnectionId
9218
+ || operation.controlCommandId !== normalized.controlCommandId
9219
+ || operation.captureGeneration !== normalized.captureGeneration
9220
+ || operation.monitorIndex !== normalized.monitorIndex
9221
+ || operation.status !== 'ready') {
9222
+ return { ok: false, error: 'clipboard-paste-not-ready' };
9223
+ }
9224
+ }
9223
9225
 
9224
9226
  const inputSocket = device.inputSocket;
9225
9227
  const inputWriteOwner = getCurrentInputWriteOwner(device);
@@ -9236,17 +9238,17 @@ export function createRemoteHub(options = {}) {
9236
9238
  && normalized.hubConnectionId !== previousOwnerConnectionId;
9237
9239
  const bindingChanged = previousInputBindingKey
9238
9240
  && previousInputBindingKey !== activeInputBindingKey;
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(
9241
+ const writeBindingChanged = inputWriteOwner.getBindingKey() !== activeInputBindingKey;
9242
+ if (ownerChanged || bindingChanged || writeBindingChanged) {
9243
+ if (ownerChanged) {
9244
+ clearClipboardOperationsForDevice(
9245
+ device,
9246
+ 'browser-input-owner-changed',
9247
+ previousOwnerConnectionId);
9248
+ } else if (bindingChanged || writeBindingChanged) {
9249
+ clearClipboardOperationsForDevice(device, 'control-input-binding-changed');
9250
+ }
9251
+ const resetSent = inputWriteOwner.replaceBinding(
9250
9252
  activeInputBindingKey,
9251
9253
  buildRemoteInputResetMessage(
9252
9254
  device,
@@ -9276,16 +9278,16 @@ export function createRemoteHub(options = {}) {
9276
9278
  device.inputOwnerConnectionId = normalized.hubConnectionId;
9277
9279
  }
9278
9280
  device.inputOwnerBindingKey = activeInputBindingKey;
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 {
9281
+ device.counters.commandsSent += 1;
9282
+ device.inputLastSeenAt = new Date().toISOString();
9283
+ if (clipboardOperationKey) {
9284
+ const operation = clipboardOperations.get(clipboardOperationKey);
9285
+ if (operation) {
9286
+ operation.status = 'pasting';
9287
+ operation.touchedAt = Date.now();
9288
+ }
9289
+ }
9290
+ return {
9289
9291
  ok: true,
9290
9292
  inputSocket: true,
9291
9293
  sessionId: device.sessionId,
@@ -9320,7 +9322,7 @@ export function createRemoteHub(options = {}) {
9320
9322
  issuedAt: normalized.issuedAt || new Date().toISOString()
9321
9323
  }
9322
9324
  });
9323
- if (fallback.ok && normalized.requestAck && normalized.inputSeq > 0) {
9325
+ if (fallback.ok && normalized.requestAck && normalized.inputSeq > 0) {
9324
9326
  schedulePendingInputFallback(device, {
9325
9327
  commandId: fallback.commandId,
9326
9328
  inputSeq: normalized.inputSeq,
@@ -9332,16 +9334,16 @@ export function createRemoteHub(options = {}) {
9332
9334
  hubReceivedAtEpochMs: normalized.hubReceivedAtEpochMs,
9333
9335
  hubForwardedAtEpochMs,
9334
9336
  fallback: true,
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
- }
9337
+ timeoutTimer: null
9338
+ });
9339
+ }
9340
+ if (fallback.ok && clipboardOperationKey) {
9341
+ const operation = clipboardOperations.get(clipboardOperationKey);
9342
+ if (operation) {
9343
+ operation.status = 'pasting';
9344
+ operation.touchedAt = Date.now();
9345
+ }
9346
+ }
9345
9347
  return fallback.ok
9346
9348
  ? {
9347
9349
  ...fallback,
@@ -9361,15 +9363,15 @@ export function createRemoteHub(options = {}) {
9361
9363
  };
9362
9364
  }
9363
9365
 
9364
- function releaseInputOwner(deviceId, ownerConnectionId, reason = 'browser-input-owner-closed') {
9366
+ function releaseInputOwner(deviceId, ownerConnectionId, reason = 'browser-input-owner-closed') {
9365
9367
  const device = devices.get(String(deviceId || ''));
9366
9368
  const owner = safeString(ownerConnectionId, 128);
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 = '';
9369
+ if (!device || !owner || safeString(device.inputOwnerConnectionId, 128) !== owner) {
9370
+ return { ok: false, error: 'input-owner-not-current' };
9371
+ }
9372
+
9373
+ clearClipboardOperationsForDevice(device, reason, owner);
9374
+ device.inputOwnerConnectionId = '';
9373
9375
  device.inputOwnerBindingKey = '';
9374
9376
  const inputSocket = device.inputSocket;
9375
9377
  const inputWriteOwner = getCurrentInputWriteOwner(device);
@@ -9413,12 +9415,12 @@ export function createRemoteHub(options = {}) {
9413
9415
  };
9414
9416
  const results = targets.map(deviceId => {
9415
9417
  const device = devices.get(deviceId);
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) {
9418
+ if (device?.synthetic === true && device.connected) return { deviceId, ok: true, synthetic: true };
9419
+ if (device?.connected && !isDeviceConnectionFresh(device)) {
9420
+ retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
9421
+ return { deviceId, ok: false, error: 'device-not-connected' };
9422
+ }
9423
+ if (!device?.connected || !device.socket || device.socket.destroyed) {
9422
9424
  return { deviceId, ok: false, error: 'device-not-connected' };
9423
9425
  }
9424
9426
  return writeJsonLine(device.socket, payload)
@@ -9450,7 +9452,7 @@ export function createRemoteHub(options = {}) {
9450
9452
  const denied = policyError(device, 'allowAgent');
9451
9453
  if (denied) return { ok: false, error: denied };
9452
9454
 
9453
- if (device?.synthetic === true && device.connected) {
9455
+ if (device?.synthetic === true && device.connected) {
9454
9456
  const instruction = safeText(options.instruction, MAX_AGENT_TASK_CHARS);
9455
9457
  if (!instruction) {
9456
9458
  return { ok: false, error: 'missing-instruction' };
@@ -9510,14 +9512,14 @@ export function createRemoteHub(options = {}) {
9510
9512
  error: '',
9511
9513
  synthetic: true
9512
9514
  });
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) {
9515
+ return { ok: true, commandId, taskId, approvalLevel, synthetic: true };
9516
+ }
9517
+
9518
+ if (device.connected && !isDeviceConnectionFresh(device)) {
9519
+ retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
9520
+ return { ok: false, error: 'device-not-connected' };
9521
+ }
9522
+ if (!device?.socket || device.socket.destroyed || !device.connected) {
9521
9523
  return { ok: false, error: 'device-not-connected' };
9522
9524
  }
9523
9525
 
@@ -10378,17 +10380,17 @@ export function createRemoteHub(options = {}) {
10378
10380
  };
10379
10381
  }
10380
10382
 
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) {
10383
+ function startLiveStream(deviceId, options = {}) {
10384
+ const device = devices.get(String(deviceId || ''));
10385
+ const denied = policyError(device);
10386
+ if (denied) return { ok: false, error: denied };
10387
+ if (device?.synthetic !== true
10388
+ && device?.connected
10389
+ && !isDeviceConnectionFresh(device)) {
10390
+ retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
10391
+ return { ok: false, error: 'device-not-connected' };
10392
+ }
10393
+ if (device?.liveCapturePause?.token) {
10392
10394
  return {
10393
10395
  ok: false,
10394
10396
  error: 'LIVE_CAPTURE_PAUSED',
@@ -10819,15 +10821,15 @@ export function createRemoteHub(options = {}) {
10819
10821
  };
10820
10822
  }
10821
10823
 
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;
10824
+ function stopLiveStream(deviceId, options = {}) {
10825
+ const device = devices.get(String(deviceId || ''));
10826
+ if (device?.synthetic !== true
10827
+ && device?.connected
10828
+ && !isDeviceConnectionFresh(device)) {
10829
+ retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
10830
+ return { ok: false, error: 'device-not-connected' };
10831
+ }
10832
+ const stopAll = options.stopAll === true;
10831
10833
  const streamPurpose = safeString(options.streamPurpose || options.purpose, 24).toLowerCase();
10832
10834
  const requestedStreamId = safeString(options.streamId, 128);
10833
10835
  const purposeStreams = !stopAll && !requestedStreamId && streamPurpose && device
@@ -11428,18 +11430,18 @@ export function createRemoteHub(options = {}) {
11428
11430
  };
11429
11431
  }
11430
11432
 
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) {
11433
+ function startAudioStream(deviceId, options = {}) {
11434
+ const device = devices.get(String(deviceId || ''));
11435
+ if (!device) {
11436
+ return { ok: false, error: 'device-not-found' };
11437
+ }
11438
+ if (device.synthetic !== true
11439
+ && device.connected
11440
+ && !isDeviceConnectionFresh(device)) {
11441
+ retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
11442
+ return { ok: false, error: 'device-not-connected' };
11443
+ }
11444
+ if (!device.connected) {
11443
11445
  return { ok: false, error: 'device-not-connected' };
11444
11446
  }
11445
11447
  const denied = policyError(device, 'allowRemoteAudio');
@@ -11516,18 +11518,18 @@ export function createRemoteHub(options = {}) {
11516
11518
  return { ok: true, commandId, streamId };
11517
11519
  }
11518
11520
 
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) {
11521
+ function stopAudioStream(deviceId, options = {}) {
11522
+ const device = devices.get(String(deviceId || ''));
11523
+ if (!device) {
11524
+ return { ok: false, error: 'device-not-found' };
11525
+ }
11526
+ if (device.synthetic !== true
11527
+ && device.connected
11528
+ && !isDeviceConnectionFresh(device)) {
11529
+ retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
11530
+ return { ok: false, error: 'device-not-connected' };
11531
+ }
11532
+ if (!device.connected) {
11531
11533
  return { ok: false, error: 'device-not-connected' };
11532
11534
  }
11533
11535
  if (device.synthetic === true || !device.socket || device.socket.destroyed) {
@@ -11740,14 +11742,14 @@ export function createRemoteHub(options = {}) {
11740
11742
  replacementTimerCount: device.liveStreamReplacementTimers instanceof Map
11741
11743
  ? device.liveStreamReplacementTimers.size
11742
11744
  : 0,
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,
11745
+ commandResultWaiterCount: [...pendingCommandResultWaiters.values()]
11746
+ .filter(waiter => waiter.deviceId === device.deviceId
11747
+ && waiter.sessionId === device.sessionId)
11748
+ .length,
11749
+ clipboardOperationCount: [...clipboardOperations.values()]
11750
+ .filter(operation => operation.deviceId === device.deviceId
11751
+ && operation.sessionId === device.sessionId)
11752
+ .length,
11751
11753
  retainedPayloadCount: retainedPayloads.size,
11752
11754
  retainedPayloadBytes: [...retainedPayloads].reduce(
11753
11755
  (total, payload) => total + payload.length,
@@ -11757,9 +11759,9 @@ export function createRemoteHub(options = {}) {
11757
11759
  && snapshot.stopPendingCount === 0
11758
11760
  && snapshot.pendingStartCount === 0
11759
11761
  && snapshot.pendingStopPromiseCount === 0
11760
- && snapshot.replacementTimerCount === 0
11761
- && snapshot.commandResultWaiterCount === 0
11762
- && snapshot.clipboardOperationCount === 0
11762
+ && snapshot.replacementTimerCount === 0
11763
+ && snapshot.commandResultWaiterCount === 0
11764
+ && snapshot.clipboardOperationCount === 0
11763
11765
  && snapshot.retainedPayloadCount === 0
11764
11766
  && snapshot.retainedPayloadBytes === 0;
11765
11767
  return snapshot;
@@ -11771,9 +11773,9 @@ export function createRemoteHub(options = {}) {
11771
11773
  'stopPendingCount',
11772
11774
  'pendingStartCount',
11773
11775
  'pendingStopPromiseCount',
11774
- 'replacementTimerCount',
11775
- 'commandResultWaiterCount',
11776
- 'clipboardOperationCount',
11776
+ 'replacementTimerCount',
11777
+ 'commandResultWaiterCount',
11778
+ 'clipboardOperationCount',
11777
11779
  'retainedPayloadCount',
11778
11780
  'retainedPayloadBytes'
11779
11781
  ]) {
@@ -11786,10 +11788,10 @@ export function createRemoteHub(options = {}) {
11786
11788
  stopPendingCount: 0,
11787
11789
  pendingStartCount: 0,
11788
11790
  pendingStopPromiseCount: 0,
11789
- replacementTimerCount: 0,
11790
- commandResultWaiterCount: 0,
11791
- clipboardOperationCount: 0,
11792
- retainedPayloadCount: 0,
11791
+ replacementTimerCount: 0,
11792
+ commandResultWaiterCount: 0,
11793
+ clipboardOperationCount: 0,
11794
+ retainedPayloadCount: 0,
11793
11795
  retainedPayloadBytes: 0
11794
11796
  });
11795
11797
  return {
@@ -11829,9 +11831,9 @@ export function createRemoteHub(options = {}) {
11829
11831
  listDeviceFrames,
11830
11832
  disconnectDevice,
11831
11833
  assignDeviceSlot,
11832
- sendCommand,
11833
- sendClipboardCommand,
11834
- refreshDevicePolicies,
11834
+ sendCommand,
11835
+ sendClipboardCommand,
11836
+ refreshDevicePolicies,
11835
11837
  sendLegacyClientUpdate,
11836
11838
  sendInputControl,
11837
11839
  releaseInputOwner,