@livedesk/hub 0.1.49 → 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) {
@@ -1244,11 +1244,11 @@ const SUPPORTED_AGENT_OPERATIONS = new Set([
1244
1244
  'application.close',
1245
1245
  'file.read',
1246
1246
  'file.write',
1247
- 'file.delete',
1248
- 'file.list',
1249
- 'file.search',
1250
- 'directory.create',
1251
- 'command.run',
1247
+ 'file.delete',
1248
+ 'file.list',
1249
+ 'file.search',
1250
+ 'directory.create',
1251
+ 'command.run',
1252
1252
  'script.run',
1253
1253
  'software.install',
1254
1254
  'network.status',
@@ -1280,7 +1280,7 @@ function readRawRemoteInputMonitorIndex(input) {
1280
1280
  return undefined;
1281
1281
  }
1282
1282
 
1283
- function normalizeRemoteInputEvent(value = {}) {
1283
+ function normalizeRemoteInputEvent(value = {}) {
1284
1284
  const input = value && typeof value === 'object' ? value : {};
1285
1285
  const type = safeString(input.type || input.Type, 48);
1286
1286
  const normalizedX = Number(input.normalizedX ?? input.NormalizedX);
@@ -1301,14 +1301,14 @@ function normalizeRemoteInputEvent(value = {}) {
1301
1301
  .filter(Boolean))]
1302
1302
  .slice(0, 64)
1303
1303
  : null;
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;
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;
1312
1312
  let code = safeString(input.code ?? input.Code, 80);
1313
1313
  if ((!code || code === 'Unidentified') && (key === ' ' || key === 'Spacebar' || keyCode === 32)) {
1314
1314
  code = 'Space';
@@ -1333,13 +1333,13 @@ function normalizeRemoteInputEvent(value = {}) {
1333
1333
  shiftKey: input.shiftKey === true || input.ShiftKey === true,
1334
1334
  ctrlKey: input.ctrlKey === true || input.ControlKey === true || input.CtrlKey === true,
1335
1335
  altKey: input.altKey === true || input.AltKey === true,
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,
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,
1343
1343
  controlLeaseId: safeString(input.controlLeaseId || input.ControlLeaseId, 128),
1344
1344
  controlSessionId: safeString(input.controlSessionId || input.sessionId || input.ControlSessionId || input.SessionId, 160),
1345
1345
  controlCommandId: safeString(input.controlCommandId || input.commandId || input.ControlCommandId || input.CommandId, 128),
@@ -2331,29 +2331,29 @@ export function createRemoteHub(options = {}) {
2331
2331
 
2332
2332
  const enabled = isEnabledValue(env.LIVEDESK_REMOTE_HUB ?? env.MINDEXEC_REMOTE_HUB ?? env.REMOTE_HUB_ENABLED, true)
2333
2333
  && !isDisabledValue(env.REMOTE_HUB_DISABLED);
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,
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,
2357
2357
  30 * 60 * 1000,
2358
2358
  DEFAULT_AGENT_TASK_TIMEOUT_MS);
2359
2359
  const inputAckTimeoutMs = clampNumber(
@@ -2416,18 +2416,18 @@ export function createRemoteHub(options = {}) {
2416
2416
  const audioSockets = new Map();
2417
2417
  const fileSockets = new Map();
2418
2418
  const allSockets = new Set();
2419
- const agentBinaryIngressStates = new Set();
2420
- const pendingCommandResultWaiters = new Map();
2421
- const clipboardOperations = new Map();
2419
+ const agentBinaryIngressStates = new Set();
2420
+ const pendingCommandResultWaiters = new Map();
2421
+ const clipboardOperations = new Map();
2422
2422
  const transportDiagnostics = new Map();
2423
2423
  const transportDiagnosticStartingDevices = new Set();
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;
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;
2431
2431
  let publicIPv4Cache = {
2432
2432
  address: safeString(env.LIVEDESK_REMOTE_PUBLIC_IPV4 || env.LIVEDESK_PUBLIC_IPV4 || env.REMOTE_HUB_PUBLIC_IPV4, 64),
2433
2433
  updatedAt: 0,
@@ -2748,14 +2748,14 @@ export function createRemoteHub(options = {}) {
2748
2748
  protocol: 'tcp-jsonl',
2749
2749
  protocolVersion: REMOTE_PROTOCOL_VERSION,
2750
2750
  agentProtocol: REMOTE_AGENT_PROTOCOL,
2751
- frameProtocol: buildRemoteFrameProtocolDescriptor(),
2752
- frameModes: getSupportedRemoteFrameModeProfiles(),
2753
- heartbeatMs,
2754
- deviceConnectionStaleMs,
2755
- deviceConnectionSweepMs,
2756
- taskTimeoutMs,
2757
- managerPackage,
2758
- managerVersion,
2751
+ frameProtocol: buildRemoteFrameProtocolDescriptor(),
2752
+ frameModes: getSupportedRemoteFrameModeProfiles(),
2753
+ heartbeatMs,
2754
+ deviceConnectionStaleMs,
2755
+ deviceConnectionSweepMs,
2756
+ taskTimeoutMs,
2757
+ managerPackage,
2758
+ managerVersion,
2759
2759
  hostInstanceId,
2760
2760
  agentPackage: '@livedesk/client',
2761
2761
  agentEndpoint: routeInfo.endpoint,
@@ -3018,7 +3018,7 @@ export function createRemoteHub(options = {}) {
3018
3018
  return true;
3019
3019
  }
3020
3020
 
3021
- function failPendingCommandResultWaiters(device, error = 'device-disconnected') {
3021
+ function failPendingCommandResultWaiters(device, error = 'device-disconnected') {
3022
3022
  if (!device?.deviceId) {
3023
3023
  return;
3024
3024
  }
@@ -3035,31 +3035,31 @@ export function createRemoteHub(options = {}) {
3035
3035
  result: null,
3036
3036
  error: safeString(error, 500) || 'command-result-unavailable'
3037
3037
  });
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') {
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') {
3063
3063
  const normalizedCommandId = safeString(commandId, 128);
3064
3064
  const waiterKey = buildPendingCommandResultWaiterKey(
3065
3065
  device?.deviceId,
@@ -3080,7 +3080,7 @@ export function createRemoteHub(options = {}) {
3080
3080
  return true;
3081
3081
  }
3082
3082
 
3083
- function waitForCommandResult(device, commandId, timeoutMs = liveCaptureStopAckTimeoutMs, metadata = {}) {
3083
+ function waitForCommandResult(device, commandId, timeoutMs = liveCaptureStopAckTimeoutMs, metadata = {}) {
3084
3084
  const normalizedCommandId = safeString(commandId, 128);
3085
3085
  const waiterKey = buildPendingCommandResultWaiterKey(
3086
3086
  device?.deviceId,
@@ -3106,13 +3106,13 @@ export function createRemoteHub(options = {}) {
3106
3106
  error: 'command-result-wait-replaced'
3107
3107
  });
3108
3108
  }
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,
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,
3116
3116
  deadlineImmediate: null,
3117
3117
  resolve
3118
3118
  };
@@ -3145,246 +3145,246 @@ export function createRemoteHub(options = {}) {
3145
3145
  });
3146
3146
  }, timeoutMs);
3147
3147
  waiterRecord.timer.unref?.();
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
- }
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
+ }
3388
3388
 
3389
3389
  function inspectAnnexBH264(payload) {
3390
3390
  if (!Buffer.isBuffer(payload) || payload.length < 5) {
@@ -4266,15 +4266,15 @@ export function createRemoteHub(options = {}) {
4266
4266
  }
4267
4267
  }
4268
4268
 
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;
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;
4278
4278
  fileSockets.delete(socket);
4279
4279
  try {
4280
4280
  writeJsonLine(socket, { type: 'disconnect', channel: 'file', reason });
@@ -4288,14 +4288,14 @@ export function createRemoteHub(options = {}) {
4288
4288
  }
4289
4289
  }
4290
4290
 
4291
- function closeInputSocket(device, reason = 'input-socket-closed') {
4291
+ function closeInputSocket(device, reason = 'input-socket-closed') {
4292
4292
  const socket = device?.inputSocket;
4293
4293
  if (!socket) {
4294
4294
  return;
4295
4295
  }
4296
4296
 
4297
- const writeOwner = device.inputWriteOwner;
4298
- clearClipboardOperationsForDevice(device, reason);
4297
+ const writeOwner = device.inputWriteOwner;
4298
+ clearClipboardOperationsForDevice(device, reason);
4299
4299
  device.inputSocket = null;
4300
4300
  device.inputWriteOwner = null;
4301
4301
  device.inputSocketConnectionId = '';
@@ -4315,7 +4315,7 @@ export function createRemoteHub(options = {}) {
4315
4315
  }
4316
4316
  }
4317
4317
 
4318
- function detachInputSocket(socket, reason = 'input-socket-closed') {
4318
+ function detachInputSocket(socket, reason = 'input-socket-closed') {
4319
4319
  const deviceId = inputSockets.get(socket);
4320
4320
  inputSockets.delete(socket);
4321
4321
  if (!deviceId) {
@@ -4327,8 +4327,8 @@ export function createRemoteHub(options = {}) {
4327
4327
  return;
4328
4328
  }
4329
4329
 
4330
- const writeOwner = device.inputWriteOwner;
4331
- clearClipboardOperationsForDevice(device, reason);
4330
+ const writeOwner = device.inputWriteOwner;
4331
+ clearClipboardOperationsForDevice(device, reason);
4332
4332
  device.inputSocket = null;
4333
4333
  device.inputWriteOwner = null;
4334
4334
  device.inputSocketConnectionId = '';
@@ -4373,7 +4373,7 @@ export function createRemoteHub(options = {}) {
4373
4373
  emitRemoteEvent('RemoteAudioSocketDisconnected', device, { reason });
4374
4374
  }
4375
4375
 
4376
- function detachFileSocket(socket, reason = 'file-socket-closed') {
4376
+ function detachFileSocket(socket, reason = 'file-socket-closed') {
4377
4377
  const deviceId = fileSockets.get(socket);
4378
4378
  fileSockets.delete(socket);
4379
4379
  if (!deviceId) {
@@ -4381,107 +4381,107 @@ export function createRemoteHub(options = {}) {
4381
4381
  }
4382
4382
 
4383
4383
  const device = devices.get(deviceId);
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;
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;
4391
4391
  device.fileLastSeenAt = new Date().toISOString();
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()) {
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()) {
4402
4402
  if (!device?.connected || !device.socket || device.socket.destroyed) {
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) {
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) {
4485
4485
  const retryAfterMs = Math.max(heartbeatMs, duplicateDeviceRetryAfterMs);
4486
4486
  writeJsonLine(socket, {
4487
4487
  type: 'disconnect',
@@ -4933,20 +4933,20 @@ export function createRemoteHub(options = {}) {
4933
4933
  return device;
4934
4934
  }
4935
4935
 
4936
- function attachInputSocket(socket, hello) {
4937
- const deviceId = normalizeDeviceId(hello.deviceId);
4938
- const device = devices.get(deviceId);
4936
+ function attachInputSocket(socket, hello) {
4937
+ const deviceId = normalizeDeviceId(hello.deviceId);
4938
+ const device = devices.get(deviceId);
4939
4939
  if (!device || !device.connected || !device.socket || device.socket.destroyed) {
4940
4940
  writeJsonLine(socket, { type: 'error', error: 'device-not-connected' });
4941
4941
  socket.destroy();
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');
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');
4950
4950
 
4951
4951
  const now = new Date().toISOString();
4952
4952
  const inputSocketConnectionId = crypto.randomUUID();
@@ -5060,20 +5060,20 @@ export function createRemoteHub(options = {}) {
5060
5060
  return device;
5061
5061
  }
5062
5062
 
5063
- function attachFileSocket(socket, hello) {
5064
- const deviceId = normalizeDeviceId(hello.deviceId);
5065
- const device = devices.get(deviceId);
5063
+ function attachFileSocket(socket, hello) {
5064
+ const deviceId = normalizeDeviceId(hello.deviceId);
5065
+ const device = devices.get(deviceId);
5066
5066
  if (!device || !device.connected || !device.socket || device.socket.destroyed) {
5067
5067
  writeJsonLine(socket, { type: 'error', error: 'device-not-connected' });
5068
5068
  socket.destroy();
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');
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');
5077
5077
  const now = new Date().toISOString();
5078
5078
  device.fileSocket = socket;
5079
5079
  device.fileConnectedAt = now;
@@ -8376,9 +8376,9 @@ export function createRemoteHub(options = {}) {
8376
8376
  started = true;
8377
8377
  boundPort = candidateServer.address()?.port || port;
8378
8378
  lastError = '';
8379
- 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');
8380
8380
  if (host === '0.0.0.0' || host === '::') {
8381
- 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.');
8382
8382
  }
8383
8383
  emitRemoteEvent('RemoteHubStarted', null);
8384
8384
  resolve();
@@ -8396,14 +8396,14 @@ export function createRemoteHub(options = {}) {
8396
8396
  await listenOnPort(requestedPort);
8397
8397
  try {
8398
8398
  await relayControl?.start?.();
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
- `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.`);
8407
8407
  conflict.code = 'EADDRINUSE';
8408
8408
  conflict.port = requestedPort;
8409
8409
  conflict.cause = error;
@@ -8414,16 +8414,16 @@ export function createRemoteHub(options = {}) {
8414
8414
  }
8415
8415
 
8416
8416
  return getStatus({ includeSecrets: false });
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
- }
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
+ }
8427
8427
 
8428
8428
  for (const device of devices.values()) {
8429
8429
  abortTransportDiagnosticsForDevice(device, 'hub-shutdown');
@@ -8558,12 +8558,12 @@ export function createRemoteHub(options = {}) {
8558
8558
  };
8559
8559
  }
8560
8560
 
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
- }
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
+ }
8567
8567
  const requiredPermission = commandName === 'input.control'
8568
8568
  ? 'allowControl'
8569
8569
  : commandName === 'system.power'
@@ -8599,16 +8599,16 @@ export function createRemoteHub(options = {}) {
8599
8599
  result: { ok: true, synthetic: true },
8600
8600
  error: ''
8601
8601
  });
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
- }
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
+ }
8612
8612
 
8613
8613
  const commandId = safeString(command?.commandId, 128) || crypto.randomUUID();
8614
8614
  const payload = {
@@ -8621,300 +8621,300 @@ export function createRemoteHub(options = {}) {
8621
8621
 
8622
8622
  const dedicatedFileSocket = commandName.startsWith('file.transfer')
8623
8623
  && device.fileSocket
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,
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,
8636
8636
  command: payload.command,
8637
8637
  channel: dedicatedFileSocket ? 'file' : 'control'
8638
8638
  });
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) {
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) {
8905
8905
  const requestedIds = Array.isArray(deviceIds) && deviceIds.length > 0
8906
8906
  ? new Set(deviceIds.map(deviceId => safeString(deviceId, 160)).filter(Boolean))
8907
8907
  : null;
8908
8908
  let updated = 0;
8909
8909
  let total = 0;
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;
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;
8918
8918
  const effectivePolicy = getDevicePolicy(device);
8919
8919
  if (writeJsonLine(device.socket, {
8920
8920
  type: 'policy.update',
@@ -8933,14 +8933,14 @@ export function createRemoteHub(options = {}) {
8933
8933
  const device = devices.get(String(deviceId || ''));
8934
8934
  const denied = policyError(device);
8935
8935
  if (denied) return { ok: false, error: denied };
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) {
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) {
8944
8944
  return { ok: false, error: 'device-not-connected' };
8945
8945
  }
8946
8946
 
@@ -9036,21 +9036,21 @@ export function createRemoteHub(options = {}) {
9036
9036
  return device.inputWriteOwner;
9037
9037
  }
9038
9038
 
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
- }
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
+ }
9054
9054
 
9055
9055
  const normalized = normalizeRemoteInputEvent(input);
9056
9056
  if (!normalized.type) {
@@ -9116,51 +9116,51 @@ export function createRemoteHub(options = {}) {
9116
9116
  || device.capabilities?.accessibilityPermission === false) {
9117
9117
  return { ok: false, error: 'INPUT_PERMISSION_DENIED' };
9118
9118
  }
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
- }
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
+ }
9164
9164
 
9165
9165
  const controlStream = getActiveControlStream(device);
9166
9166
  if (!controlStream
@@ -9196,32 +9196,32 @@ export function createRemoteHub(options = {}) {
9196
9196
  };
9197
9197
  }
9198
9198
  const activeMonitorIndex = normalizeMonitorIndex(controlStream.monitorIndex);
9199
- if (!Number.isInteger(normalized.monitorIndex)
9200
- || normalized.monitorIndex !== activeMonitorIndex) {
9199
+ if (!Number.isInteger(normalized.monitorIndex)
9200
+ || normalized.monitorIndex !== activeMonitorIndex) {
9201
9201
  return {
9202
9202
  ok: false,
9203
9203
  error: 'STALE_CONTROL_MONITOR',
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
- }
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
+ }
9225
9225
 
9226
9226
  const inputSocket = device.inputSocket;
9227
9227
  const inputWriteOwner = getCurrentInputWriteOwner(device);
@@ -9238,17 +9238,17 @@ export function createRemoteHub(options = {}) {
9238
9238
  && normalized.hubConnectionId !== previousOwnerConnectionId;
9239
9239
  const bindingChanged = previousInputBindingKey
9240
9240
  && previousInputBindingKey !== activeInputBindingKey;
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(
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(
9252
9252
  activeInputBindingKey,
9253
9253
  buildRemoteInputResetMessage(
9254
9254
  device,
@@ -9278,16 +9278,16 @@ export function createRemoteHub(options = {}) {
9278
9278
  device.inputOwnerConnectionId = normalized.hubConnectionId;
9279
9279
  }
9280
9280
  device.inputOwnerBindingKey = activeInputBindingKey;
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 {
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 {
9291
9291
  ok: true,
9292
9292
  inputSocket: true,
9293
9293
  sessionId: device.sessionId,
@@ -9322,7 +9322,7 @@ export function createRemoteHub(options = {}) {
9322
9322
  issuedAt: normalized.issuedAt || new Date().toISOString()
9323
9323
  }
9324
9324
  });
9325
- if (fallback.ok && normalized.requestAck && normalized.inputSeq > 0) {
9325
+ if (fallback.ok && normalized.requestAck && normalized.inputSeq > 0) {
9326
9326
  schedulePendingInputFallback(device, {
9327
9327
  commandId: fallback.commandId,
9328
9328
  inputSeq: normalized.inputSeq,
@@ -9334,16 +9334,16 @@ export function createRemoteHub(options = {}) {
9334
9334
  hubReceivedAtEpochMs: normalized.hubReceivedAtEpochMs,
9335
9335
  hubForwardedAtEpochMs,
9336
9336
  fallback: true,
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
- }
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
+ }
9347
9347
  return fallback.ok
9348
9348
  ? {
9349
9349
  ...fallback,
@@ -9363,15 +9363,15 @@ export function createRemoteHub(options = {}) {
9363
9363
  };
9364
9364
  }
9365
9365
 
9366
- function releaseInputOwner(deviceId, ownerConnectionId, reason = 'browser-input-owner-closed') {
9366
+ function releaseInputOwner(deviceId, ownerConnectionId, reason = 'browser-input-owner-closed') {
9367
9367
  const device = devices.get(String(deviceId || ''));
9368
9368
  const owner = safeString(ownerConnectionId, 128);
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 = '';
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 = '';
9375
9375
  device.inputOwnerBindingKey = '';
9376
9376
  const inputSocket = device.inputSocket;
9377
9377
  const inputWriteOwner = getCurrentInputWriteOwner(device);
@@ -9415,12 +9415,12 @@ export function createRemoteHub(options = {}) {
9415
9415
  };
9416
9416
  const results = targets.map(deviceId => {
9417
9417
  const device = devices.get(deviceId);
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) {
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) {
9424
9424
  return { deviceId, ok: false, error: 'device-not-connected' };
9425
9425
  }
9426
9426
  return writeJsonLine(device.socket, payload)
@@ -9452,7 +9452,7 @@ export function createRemoteHub(options = {}) {
9452
9452
  const denied = policyError(device, 'allowAgent');
9453
9453
  if (denied) return { ok: false, error: denied };
9454
9454
 
9455
- if (device?.synthetic === true && device.connected) {
9455
+ if (device?.synthetic === true && device.connected) {
9456
9456
  const instruction = safeText(options.instruction, MAX_AGENT_TASK_CHARS);
9457
9457
  if (!instruction) {
9458
9458
  return { ok: false, error: 'missing-instruction' };
@@ -9512,14 +9512,14 @@ export function createRemoteHub(options = {}) {
9512
9512
  error: '',
9513
9513
  synthetic: true
9514
9514
  });
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) {
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) {
9523
9523
  return { ok: false, error: 'device-not-connected' };
9524
9524
  }
9525
9525
 
@@ -10380,17 +10380,17 @@ export function createRemoteHub(options = {}) {
10380
10380
  };
10381
10381
  }
10382
10382
 
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) {
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) {
10394
10394
  return {
10395
10395
  ok: false,
10396
10396
  error: 'LIVE_CAPTURE_PAUSED',
@@ -10821,15 +10821,15 @@ export function createRemoteHub(options = {}) {
10821
10821
  };
10822
10822
  }
10823
10823
 
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;
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;
10833
10833
  const streamPurpose = safeString(options.streamPurpose || options.purpose, 24).toLowerCase();
10834
10834
  const requestedStreamId = safeString(options.streamId, 128);
10835
10835
  const purposeStreams = !stopAll && !requestedStreamId && streamPurpose && device
@@ -11430,18 +11430,18 @@ export function createRemoteHub(options = {}) {
11430
11430
  };
11431
11431
  }
11432
11432
 
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) {
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) {
11445
11445
  return { ok: false, error: 'device-not-connected' };
11446
11446
  }
11447
11447
  const denied = policyError(device, 'allowRemoteAudio');
@@ -11518,18 +11518,18 @@ export function createRemoteHub(options = {}) {
11518
11518
  return { ok: true, commandId, streamId };
11519
11519
  }
11520
11520
 
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) {
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) {
11533
11533
  return { ok: false, error: 'device-not-connected' };
11534
11534
  }
11535
11535
  if (device.synthetic === true || !device.socket || device.socket.destroyed) {
@@ -11742,14 +11742,14 @@ export function createRemoteHub(options = {}) {
11742
11742
  replacementTimerCount: device.liveStreamReplacementTimers instanceof Map
11743
11743
  ? device.liveStreamReplacementTimers.size
11744
11744
  : 0,
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,
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,
11753
11753
  retainedPayloadCount: retainedPayloads.size,
11754
11754
  retainedPayloadBytes: [...retainedPayloads].reduce(
11755
11755
  (total, payload) => total + payload.length,
@@ -11759,9 +11759,9 @@ export function createRemoteHub(options = {}) {
11759
11759
  && snapshot.stopPendingCount === 0
11760
11760
  && snapshot.pendingStartCount === 0
11761
11761
  && snapshot.pendingStopPromiseCount === 0
11762
- && snapshot.replacementTimerCount === 0
11763
- && snapshot.commandResultWaiterCount === 0
11764
- && snapshot.clipboardOperationCount === 0
11762
+ && snapshot.replacementTimerCount === 0
11763
+ && snapshot.commandResultWaiterCount === 0
11764
+ && snapshot.clipboardOperationCount === 0
11765
11765
  && snapshot.retainedPayloadCount === 0
11766
11766
  && snapshot.retainedPayloadBytes === 0;
11767
11767
  return snapshot;
@@ -11773,9 +11773,9 @@ export function createRemoteHub(options = {}) {
11773
11773
  'stopPendingCount',
11774
11774
  'pendingStartCount',
11775
11775
  'pendingStopPromiseCount',
11776
- 'replacementTimerCount',
11777
- 'commandResultWaiterCount',
11778
- 'clipboardOperationCount',
11776
+ 'replacementTimerCount',
11777
+ 'commandResultWaiterCount',
11778
+ 'clipboardOperationCount',
11779
11779
  'retainedPayloadCount',
11780
11780
  'retainedPayloadBytes'
11781
11781
  ]) {
@@ -11788,10 +11788,10 @@ export function createRemoteHub(options = {}) {
11788
11788
  stopPendingCount: 0,
11789
11789
  pendingStartCount: 0,
11790
11790
  pendingStopPromiseCount: 0,
11791
- replacementTimerCount: 0,
11792
- commandResultWaiterCount: 0,
11793
- clipboardOperationCount: 0,
11794
- retainedPayloadCount: 0,
11791
+ replacementTimerCount: 0,
11792
+ commandResultWaiterCount: 0,
11793
+ clipboardOperationCount: 0,
11794
+ retainedPayloadCount: 0,
11795
11795
  retainedPayloadBytes: 0
11796
11796
  });
11797
11797
  return {
@@ -11831,9 +11831,9 @@ export function createRemoteHub(options = {}) {
11831
11831
  listDeviceFrames,
11832
11832
  disconnectDevice,
11833
11833
  assignDeviceSlot,
11834
- sendCommand,
11835
- sendClipboardCommand,
11836
- refreshDevicePolicies,
11834
+ sendCommand,
11835
+ sendClipboardCommand,
11836
+ refreshDevicePolicies,
11837
11837
  sendLegacyClientUpdate,
11838
11838
  sendInputControl,
11839
11839
  releaseInputOwner,