@livedesk/hub 0.1.30 → 0.1.31

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/server.js CHANGED
@@ -39,15 +39,17 @@ import { createAgentDeviceScope, resolveAgentTargetIds } from './agents/agent-de
39
39
  import { AgentRuntimeError } from './agents/agent-runtime-error.js';
40
40
  import { AGENT_PERMISSION_MODES, createAgentPermissionPolicy, evaluateAgentToolPermission, hashAgentPermissionPolicy } from './agents/agent-permissions.js';
41
41
  import { createAgentPermissionStore } from './agents/agent-permission-store.js';
42
- import { createAgentAuditStore } from './agents/agent-audit-store.js';
43
- import { getAgentToolDefinition } from './agents/agent-tool-registry.js';
42
+ import { createAgentAuditStore } from './agents/agent-audit-store.js';
43
+ import { createSecurityAuditStore } from './security/security-audit-store.js';
44
+ import { getAgentToolDefinition, isRetiredAgentMutatingToolName } from './agents/agent-tool-registry.js';
44
45
  import { LiveDeskSettingsStore, SettingsConflictError } from './settings/settings-store.js';
45
46
  import { effectiveDevicePolicy } from './settings/settings-schema.js';
46
47
  import { buildEffectiveDevicePolicy } from './settings/effective-device-policy.js';
47
48
  import { CaptureStore } from './captures/capture-store.js';
48
49
  import { createLiveDeskUpdateManager } from './live-desk-update.js';
49
50
  import { createHubUdpTransport } from './transport/udp-hub-transport.js';
50
- import { normalizeRuntimeAuthSession, runtimeRoleError } from '../../runtime-core/src/index.js';
51
+ import { normalizeRuntimeAuthSession, runtimeRoleError } from '../../runtime-core/src/index.js';
52
+ import { createOsSecretStore, OS_SECRET_REFERENCE } from '../../runtime-core/src/os-secret-store.js';
51
53
  import { fetchAuthResponse, AUTH_REQUEST_TIMEOUT_MS } from '../../runtime-core/src/auth-http.js';
52
54
  import { PRODUCTION_SUPABASE_PUBLISHABLE_KEY, PRODUCTION_SUPABASE_URL } from '../../runtime-core/src/auth-config.js';
53
55
  import { createHubRuntime } from './runtime/hub-runtime.js';
@@ -63,8 +65,20 @@ const webDistCandidates = [
63
65
  const webDistPath = webDistCandidates.find(candidate => existsSync(resolve(candidate, 'index.html'))) || webDistCandidates[webDistCandidates.length - 1];
64
66
  const webIndexPath = resolve(webDistPath, 'index.html');
65
67
  const packageInfo = JSON.parse(readFileSync(resolve(__dirname, '..', 'package.json'), 'utf8'));
66
- const httpHost = process.env.LIVEDESK_HUB_HTTP_HOST || '127.0.0.1';
67
- const httpPort = Number(process.env.LIVEDESK_HUB_HTTP_PORT || process.env.PORT || 5179);
68
+ const httpHost = process.env.LIVEDESK_HUB_HTTP_HOST || '127.0.0.1';
69
+ const httpPort = Number(process.env.LIVEDESK_HUB_HTTP_PORT || process.env.PORT || 5179);
70
+ if (!isLoopbackBindHost(httpHost) && process.env.LIVEDESK_TEST_MODE !== '1') {
71
+ throw new Error('hub-http-lan-bind-requires-tls-authenticated-proxy');
72
+ }
73
+ const localAdminSessionToken = crypto.randomBytes(32).toString('base64url');
74
+ const localAdminCsrfToken = crypto.randomBytes(32).toString('base64url');
75
+ const localAdminSessionId = crypto.randomUUID();
76
+ const launcherShutdownToken = /^[A-Za-z0-9_-]{43}$/.test(
77
+ String(process.env.LIVEDESK_LAUNCHER_SHUTDOWN_TOKEN || '').trim()
78
+ )
79
+ ? String(process.env.LIVEDESK_LAUNCHER_SHUTDOWN_TOKEN).trim()
80
+ : '';
81
+ const enforceLocalAdminAuth = process.env.LIVEDESK_TEST_MODE !== '1';
68
82
  const runtimeRole = String(process.env.LIVEDESK_RUNTIME_ROLE || 'hub').trim().toLowerCase() === 'client' ? 'client' : 'hub';
69
83
  const runtimeDeviceId = String(process.env.LIVEDESK_DEVICE_ID || '').trim();
70
84
  const runtimeDeviceName = String(process.env.LIVEDESK_DEVICE_NAME || os.hostname()).trim() || os.hostname();
@@ -165,13 +179,21 @@ const SUPABASE_AUTH_TIMEOUT_MS = process.env.LIVEDESK_AUTH_TEST_MODE === '1'
165
179
  ? readPositiveIntegerEnv('LIVEDESK_AUTH_TEST_TIMEOUT_MS', AUTH_REQUEST_TIMEOUT_MS)
166
180
  : AUTH_REQUEST_TIMEOUT_MS;
167
181
  const CLIENT_AUTH_STORAGE_KEY = 'livedesk.client.supabase.auth';
168
- const runtimeAuthStatePath = process.env.LIVEDESK_DESKTOP_HOST === '1'
182
+ const runtimeAuthStatePath = process.env.LIVEDESK_DESKTOP_HOST === '1'
169
183
  ? ''
170
- : String(process.env.LIVEDESK_AUTH_STATE_PATH || '').trim();
171
- const testLicensePlan = process.env.LIVEDESK_TEST_MODE === '1'
184
+ : String(process.env.LIVEDESK_AUTH_STATE_PATH || '').trim();
185
+ const runtimeRefreshSecretStore = createOsSecretStore({
186
+ service: 'LiveDesk',
187
+ account: 'client-refresh-token',
188
+ dataDir: process.env.LIVEDESK_STATE_DIR || (runtimeAuthStatePath ? dirname(runtimeAuthStatePath) : undefined)
189
+ });
190
+ const testLicensePlan = process.env.LIVEDESK_TEST_MODE === '1'
172
191
  && ['ltd', 'pro'].includes(String(process.env.LIVEDESK_TEST_LICENSE_PLAN || '').toLowerCase())
173
- ? String(process.env.LIVEDESK_TEST_LICENSE_PLAN).toLowerCase()
174
- : '';
192
+ ? String(process.env.LIVEDESK_TEST_LICENSE_PLAN).toLowerCase()
193
+ : '';
194
+ const testSecurityAccountId = process.env.LIVEDESK_TEST_MODE === '1'
195
+ ? String(process.env.LIVEDESK_ACCOUNT_ID || '').trim().slice(0, 128)
196
+ : '';
175
197
  const persistentSessionGcEnabled =
176
198
  process.env.LIVEDESK_TEST_MODE === '1'
177
199
  && process.env.LIVEDESK_PERSISTENT_SESSION_TEST_GC === '1';
@@ -181,10 +203,12 @@ const traceRemoteTestEventsEnabled =
181
203
  const persistentSessionGcToken = persistentSessionGcEnabled
182
204
  ? String(process.env.LIVEDESK_PERSISTENT_SESSION_TEST_TOKEN || '').trim()
183
205
  : '';
184
- let connectedDeviceCount = 0;
185
- let runtimeAccessToken = '';
186
- let runtimeRefreshToken = '';
187
- let runtimeAccessTokenExpiresAt = 0;
206
+ let connectedDeviceCount = 0;
207
+ let runtimeAccessToken = '';
208
+ let runtimeRefreshToken = '';
209
+ let runtimeAccessTokenExpiresAt = 0;
210
+ let runtimeSessionGeneration = 0;
211
+ let runtimeRefreshPromise = null;
188
212
  let roleWatchInFlight = false;
189
213
  let verifiedLicense = {
190
214
  userId: '',
@@ -196,8 +220,10 @@ let verifiedLicense = {
196
220
  let frameClientSeq = 0;
197
221
  let inputClientSeq = 0;
198
222
  let audioClientSeq = 0;
199
- let liveDeskUpdateManager = null;
200
- let hubTransferJobs = null;
223
+ let liveDeskUpdateManager = null;
224
+ let hubTransferJobs = null;
225
+ let securityAuditStore = null;
226
+ let securityAuditHealthy = true;
201
227
  const HUB_HOST_TARGET_LEASE_MS = Math.max(5000, readPositiveIntegerEnv('LIVEDESK_HOST_TARGET_LEASE_MS', 60_000));
202
228
  const HUB_HOST_TARGET_RENEW_MS = Math.max(1000, Math.min(
203
229
  readPositiveIntegerEnv('LIVEDESK_HOST_TARGET_RENEW_MS', 20_000),
@@ -228,7 +254,7 @@ let hubWakeNotificationState = {
228
254
  };
229
255
  let hubWakeNextAttemptAtMs = 0;
230
256
 
231
- function readPositiveIntegerEnv(name, fallback) {
257
+ function readPositiveIntegerEnv(name, fallback) {
232
258
  const value = Number(process.env[name]);
233
259
  return Number.isFinite(value) && value > 0 ? Math.round(value) : fallback;
234
260
  }
@@ -241,9 +267,42 @@ function secureExactTestTokenMatches(actual, expected) {
241
267
  && crypto.timingSafeEqual(actualBytes, expectedBytes);
242
268
  }
243
269
 
244
- function handleRemoteHubEvent(type, event) {
245
- liveDeskUpdateManager?.handleRemoteEvent(type, event);
246
- hubTransferJobs?.handleRemoteEvent(type, event);
270
+ function handleRemoteHubEvent(type, event) {
271
+ liveDeskUpdateManager?.handleRemoteEvent(type, event);
272
+ hubTransferJobs?.handleRemoteEvent(type, event);
273
+ const auditedRemoteActions = {
274
+ RemoteSecurityHandshakeAudit: event?.action || 'remote.handshake',
275
+ RemoteSecurityPolicyAudit: 'remote.connection-policy',
276
+ RemoteAbuseDefense: 'remote.abuse-defense',
277
+ RemoteDeviceConnected: 'remote.device.connected',
278
+ RemoteDeviceDisconnected: 'remote.device.disconnected',
279
+ RemoteInputSocketConnected: 'remote.control.channel-started',
280
+ RemoteInputSocketDisconnected: 'remote.control.channel-ended',
281
+ RemoteControlSessionStarted: 'remote.control.session-started',
282
+ RemoteControlSessionEnded: 'remote.control.session-ended',
283
+ RemoteFileSocketConnected: 'remote.file.channel-started',
284
+ RemoteFileSocketDisconnected: 'remote.file.channel-ended',
285
+ RemoteEnrollmentTokenRotated: 'remote.enrollment-token.rotated'
286
+ };
287
+ if (auditedRemoteActions[type]) {
288
+ const device = event?.device || {};
289
+ recordSecurityAudit({
290
+ action: auditedRemoteActions[type],
291
+ phase: /Disconnected|ended|rotated/i.test(`${type}:${auditedRemoteActions[type]}`) ? 'complete' : 'start',
292
+ result: event?.result || (event?.reason && /RemoteSecurity(?:Handshake|Policy)Audit|RemoteAbuseDefense/.test(type) ? 'rejected' : 'success'),
293
+ reason: event?.reason || '',
294
+ actorAccountId: event?.accountId || '',
295
+ hubId: event?.hubId || event?.remoteHub?.hostInstanceId || '',
296
+ deviceId: event?.deviceId || device.deviceId || '',
297
+ sessionId: event?.sessionId || device.sessionId || '',
298
+ authMethod: /RemoteSecurity(?:Handshake|Policy)Audit|RemoteAbuseDefense/.test(type) ? 'device-credential-p256' : 'authenticated-device-session',
299
+ details: {
300
+ transport: event?.transport || device.transport || '',
301
+ channel: event?.channel || '',
302
+ credentialSerial: event?.credentialSerial || ''
303
+ }
304
+ });
305
+ }
247
306
  if (traceRemoteTestEventsEnabled
248
307
  && (type === 'RemoteFrameDropped' || type === 'RemoteFrameTransportProofAccepted')) {
249
308
  console.warn(`[LiveDesk Hub Test Event] ${type} ${JSON.stringify({
@@ -290,10 +349,16 @@ function handleRemoteHubEvent(type, event) {
290
349
  }
291
350
  return;
292
351
  }
293
- if (type === 'RemoteDeviceConnected' || type === 'RemoteDeviceDisconnected') {
294
- connectedDeviceCount = Number(remoteHub.getStatus({ includeSecrets: false }).connectedDeviceCount || 0);
295
- }
296
- if (type !== 'RemoteDeviceConnected') {
352
+ if (type === 'RemoteDeviceConnected' || type === 'RemoteDeviceDisconnected') {
353
+ connectedDeviceCount = Number(remoteHub.getStatus({ includeSecrets: false }).connectedDeviceCount || 0);
354
+ }
355
+ if (type === 'RemoteEnrollmentTokenRotated') {
356
+ void publishHubHostTargetWithPendingRoleTakeover('enrollment-token-rotated').catch(error => {
357
+ console.warn(`[LiveDesk Hub] Enrollment token publication failed: ${error?.message || error}`);
358
+ });
359
+ return;
360
+ }
361
+ if (type !== 'RemoteDeviceConnected') {
297
362
  return;
298
363
  }
299
364
  const deviceId = String(event?.deviceId || event?.device?.deviceId || '').trim();
@@ -425,11 +490,23 @@ function readRuntimeAuthState() {
425
490
  }
426
491
  }
427
492
 
428
- function readPersistedRuntimeSession(state = readRuntimeAuthState()) {
493
+ function readPersistedRuntimeSession(state = readRuntimeAuthState()) {
429
494
  try {
430
495
  const raw = state?.[CLIENT_AUTH_STORAGE_KEY];
431
496
  const session = typeof raw === 'string' ? JSON.parse(raw) : raw;
432
- return session && typeof session === 'object' ? session : null;
497
+ if (!session || typeof session !== 'object') return null;
498
+ const plaintextRefreshToken = String(session.refresh_token || '').trim();
499
+ const refreshToken = plaintextRefreshToken || (session.refresh_token_ref === OS_SECRET_REFERENCE
500
+ ? runtimeRefreshSecretStore.read()
501
+ : '');
502
+ if (plaintextRefreshToken) {
503
+ if (!runtimeRefreshSecretStore.write(plaintextRefreshToken)) return null;
504
+ const migrated = { ...session, refresh_token_ref: OS_SECRET_REFERENCE };
505
+ delete migrated.refresh_token;
506
+ state[CLIENT_AUTH_STORAGE_KEY] = JSON.stringify(migrated);
507
+ writePrivateRuntimeAuthState(state);
508
+ }
509
+ return { ...session, refresh_token: refreshToken };
433
510
  } catch {
434
511
  return null;
435
512
  }
@@ -472,28 +549,34 @@ function persistRuntimeSession(session) {
472
549
  ...(session?.user && typeof session.user === 'object' ? session.user : {})
473
550
  }
474
551
  }, { requireRefreshToken: true });
475
- if (!normalized.ok) return false;
476
- state[CLIENT_AUTH_STORAGE_KEY] = JSON.stringify(normalized.session);
552
+ if (!normalized.ok) return false;
553
+ if (!runtimeRefreshSecretStore.write(normalized.session.refresh_token)) return false;
554
+ const persisted = { ...normalized.session, refresh_token_ref: OS_SECRET_REFERENCE };
555
+ delete persisted.refresh_token;
556
+ state[CLIENT_AUTH_STORAGE_KEY] = JSON.stringify(persisted);
477
557
  writePrivateRuntimeAuthState(state);
478
558
  return true;
479
559
  }
480
560
 
481
- function clearPersistedRuntimeSession() {
561
+ function clearPersistedRuntimeSession() {
482
562
  if (!runtimeAuthStatePath) return false;
483
563
  const state = readRuntimeAuthState();
484
- delete state[CLIENT_AUTH_STORAGE_KEY];
564
+ delete state[CLIENT_AUTH_STORAGE_KEY];
565
+ runtimeRefreshSecretStore.clear();
485
566
  return writePrivateRuntimeAuthState(state);
486
567
  }
487
568
 
488
- function clearRuntimeSession() {
489
- runtimeAccessToken = '';
490
- runtimeRefreshToken = '';
569
+ function clearRuntimeSession() {
570
+ runtimeSessionGeneration += 1;
571
+ runtimeRefreshPromise = null;
572
+ runtimeAccessToken = '';
573
+ runtimeRefreshToken = '';
491
574
  runtimeAccessTokenExpiresAt = 0;
492
575
  runtimeManager.setAuthenticated(false);
493
576
  try { clearPersistedRuntimeSession(); } catch { /* runtime auth is already invalid */ }
494
577
  }
495
578
 
496
- async function getRuntimeAccessToken() {
579
+ async function getRuntimeAccessToken() {
497
580
  const accessToken = String(runtimeAccessToken || '').trim();
498
581
  const expiresSoon = runtimeAccessTokenExpiresAt > 0
499
582
  && runtimeAccessTokenExpiresAt <= Date.now() + HUB_ACCESS_TOKEN_REFRESH_SKEW_MS;
@@ -502,51 +585,112 @@ async function getRuntimeAccessToken() {
502
585
  }
503
586
 
504
587
  const refreshToken = String(runtimeRefreshToken || '').trim();
505
- if (!refreshToken) {
506
- return accessToken;
507
- }
508
-
509
- const response = await fetchAuthResponse(
510
- fetch,
511
- `${supabaseUrl}/auth/v1/token?grant_type=refresh_token`,
512
- {
513
- method: 'POST',
514
- headers: {
515
- apikey: supabasePublishableKey,
516
- 'Content-Type': 'application/json',
517
- Accept: 'application/json'
518
- },
519
- body: JSON.stringify({ refresh_token: refreshToken })
520
- },
521
- SUPABASE_AUTH_TIMEOUT_MS
522
- );
523
- if (!response.ok) {
524
- if (response.status === 400 || response.status === 401 || response.status === 403) {
525
- clearRuntimeSession();
526
- throw new Error(`hub-session-refresh-failed:${response.status}`);
527
- }
528
- throw new Error(`hub-session-refresh-provider-failed:${response.status}`);
529
- }
530
- const refreshed = await response.json().catch(() => null);
531
- const nextAccessToken = String(refreshed?.access_token || '').trim();
532
- if (!nextAccessToken) {
533
- throw new Error('hub-session-refresh-missing-access-token');
534
- }
535
- runtimeAccessToken = nextAccessToken;
536
- runtimeRefreshToken = String(refreshed?.refresh_token || refreshToken).trim();
537
- runtimeAccessTokenExpiresAt = normalizeSessionExpiryMs(refreshed?.expires_at)
538
- || (Number(refreshed?.expires_in) > 0 ? Date.now() + Number(refreshed.expires_in) * 1000 : 0);
539
- try {
540
- persistRuntimeSession({
541
- access_token: runtimeAccessToken,
542
- refresh_token: runtimeRefreshToken,
543
- expires_at: Math.floor(runtimeAccessTokenExpiresAt / 1000)
544
- });
545
- } catch (error) {
546
- console.warn(`[LiveDesk Hub] Refreshed session persistence failed: ${error?.message || error}`);
547
- }
548
- return runtimeAccessToken;
549
- }
588
+ if (!refreshToken) {
589
+ return accessToken;
590
+ }
591
+
592
+ if (runtimeRefreshPromise) return runtimeRefreshPromise;
593
+
594
+ const ownerGeneration = runtimeSessionGeneration;
595
+ const refreshOperation = (async () => {
596
+ const response = await fetchAuthResponse(
597
+ fetch,
598
+ `${supabaseUrl}/auth/v1/token?grant_type=refresh_token`,
599
+ {
600
+ method: 'POST',
601
+ headers: {
602
+ apikey: supabasePublishableKey,
603
+ 'Content-Type': 'application/json',
604
+ Accept: 'application/json'
605
+ },
606
+ body: JSON.stringify({ refresh_token: refreshToken })
607
+ },
608
+ SUPABASE_AUTH_TIMEOUT_MS
609
+ );
610
+ if (!response.ok) {
611
+ if (response.status === 400 || response.status === 401 || response.status === 403) {
612
+ if (runtimeSessionGeneration === ownerGeneration && runtimeRefreshToken === refreshToken) {
613
+ clearRuntimeSession();
614
+ }
615
+ throw new Error(`hub-session-refresh-failed:${response.status}`);
616
+ }
617
+ throw new Error(`hub-session-refresh-provider-failed:${response.status}`);
618
+ }
619
+ const refreshed = await response.json().catch(() => null);
620
+ const nextAccessToken = String(refreshed?.access_token || '').trim();
621
+ if (!nextAccessToken) {
622
+ throw new Error('hub-session-refresh-missing-access-token');
623
+ }
624
+ if (runtimeSessionGeneration !== ownerGeneration || runtimeRefreshToken !== refreshToken) {
625
+ throw new Error('hub-session-refresh-owner-retired');
626
+ }
627
+ runtimeAccessToken = nextAccessToken;
628
+ runtimeRefreshToken = String(refreshed?.refresh_token || refreshToken).trim();
629
+ runtimeAccessTokenExpiresAt = normalizeSessionExpiryMs(refreshed?.expires_at)
630
+ || (Number(refreshed?.expires_in) > 0 ? Date.now() + Number(refreshed.expires_in) * 1000 : 0);
631
+ try {
632
+ persistRuntimeSession({
633
+ access_token: runtimeAccessToken,
634
+ refresh_token: runtimeRefreshToken,
635
+ expires_at: Math.floor(runtimeAccessTokenExpiresAt / 1000)
636
+ });
637
+ } catch (error) {
638
+ console.warn(`[LiveDesk Hub] Refreshed session persistence failed: ${error?.message || error}`);
639
+ }
640
+ return runtimeAccessToken;
641
+ })();
642
+ runtimeRefreshPromise = refreshOperation;
643
+ try {
644
+ return await refreshOperation;
645
+ } finally {
646
+ if (runtimeRefreshPromise === refreshOperation) runtimeRefreshPromise = null;
647
+ }
648
+ }
649
+
650
+ function isLoopbackBindHost(value) {
651
+ const host = String(value || '').trim().toLowerCase().replace(/^\[|\]$/g, '');
652
+ return host === '127.0.0.1' || host === 'localhost' || host === '::1';
653
+ }
654
+
655
+ async function revokeRuntimeProviderSession() {
656
+ const hasRuntimeSession = Boolean(runtimeAccessToken || runtimeRefreshToken);
657
+ if (!hasRuntimeSession) return { ok: true, skipped: true, reason: 'no-runtime-session' };
658
+
659
+ let accessToken = String(runtimeAccessToken || '').trim();
660
+ try {
661
+ accessToken = await getRuntimeAccessToken() || accessToken;
662
+ } catch (error) {
663
+ const message = String(error?.message || error);
664
+ if (/^hub-session-refresh-failed:(?:400|401|403)$/.test(message)) {
665
+ return { ok: true, alreadyInvalid: true };
666
+ }
667
+ return { ok: false, error: message };
668
+ }
669
+ if (!accessToken) return { ok: false, error: 'provider-session-token-unavailable' };
670
+
671
+ try {
672
+ const response = await fetchAuthResponse(
673
+ fetch,
674
+ `${supabaseUrl}/auth/v1/logout?scope=local`,
675
+ {
676
+ method: 'POST',
677
+ headers: {
678
+ apikey: supabasePublishableKey,
679
+ Authorization: `Bearer ${accessToken}`,
680
+ Accept: 'application/json'
681
+ }
682
+ },
683
+ SUPABASE_AUTH_TIMEOUT_MS
684
+ );
685
+ if (response.ok) return { ok: true, revoked: true };
686
+ if ([401, 403, 404].includes(response.status)) {
687
+ return { ok: true, alreadyInvalid: true, status: response.status };
688
+ }
689
+ return { ok: false, error: `provider-session-revoke-failed:${response.status}` };
690
+ } catch (error) {
691
+ return { ok: false, error: String(error?.message || error) };
692
+ }
693
+ }
550
694
 
551
695
  async function verifySupabaseUser(accessToken) {
552
696
  const token = String(accessToken || '').trim();
@@ -659,15 +803,57 @@ function requireHubFeatureAccess(_req, res, next) {
659
803
  res.status(402).json({ ok: false, error: 'livedesk-plan-device-limit', license: licenseSnapshot() });
660
804
  }
661
805
 
662
- const agentDataDir = process.env.LIVEDESK_DATA_DIR || undefined;
663
- const liveDeskSettingsStore = new LiveDeskSettingsStore({ dataDir: agentDataDir });
664
- const captureStore = new CaptureStore({ dataDir: agentDataDir });
665
- void captureStore.initialize().catch(error => {
666
- console.warn(`[LiveDesk Hub] capture store initialization failed: ${error instanceof Error ? error.message : String(error)}`);
667
- });
668
- void liveDeskSettingsStore.getRecord().catch(error => {
669
- console.warn(`[LiveDesk Hub] settings load failed: ${error instanceof Error ? error.message : String(error)}`);
670
- });
806
+ const agentDataDir = process.env.LIVEDESK_DATA_DIR || undefined;
807
+ securityAuditStore = createSecurityAuditStore({ dataDir: agentDataDir });
808
+
809
+ function requestAuditHash(value) {
810
+ try {
811
+ return crypto.createHash('sha256').update(JSON.stringify(value ?? {}), 'utf8').digest('base64url');
812
+ } catch {
813
+ return crypto.createHash('sha256').update('[unserializable]', 'utf8').digest('base64url');
814
+ }
815
+ }
816
+
817
+ function recordSecurityAudit(event = {}) {
818
+ if (!securityAuditStore) return;
819
+ const runtime = runtimeManager.getSnapshot();
820
+ void securityAuditStore.record({
821
+ actorAccountId: event.actorAccountId || runtime.userId || '',
822
+ actorUserId: event.actorUserId || runtime.userId || '',
823
+ hubId: event.hubId || runtime.deviceId || '',
824
+ ...event
825
+ }).catch(error => {
826
+ securityAuditHealthy = false;
827
+ console.error(`[LiveDesk Hub] SECURITY AUDIT FAILURE: ${error?.message || error}`);
828
+ });
829
+ }
830
+
831
+ const liveDeskSettingsStore = new LiveDeskSettingsStore({ dataDir: agentDataDir });
832
+ const captureStore = new CaptureStore({
833
+ dataDir: agentDataDir,
834
+ getRetentionPolicy: () => liveDeskSettingsStore.getCached()?.filesAudio?.captureAutoDelete || 'never',
835
+ onRetentionDelete: result => recordSecurityAudit({
836
+ action: 'privacy.capture.retention-delete',
837
+ phase: 'complete',
838
+ result: 'success',
839
+ authMethod: 'settings-retention-policy',
840
+ details: result
841
+ })
842
+ });
843
+ void captureStore.initialize().catch(error => {
844
+ console.warn(`[LiveDesk Hub] capture store initialization failed: ${error instanceof Error ? error.message : String(error)}`);
845
+ });
846
+ void liveDeskSettingsStore.getRecord()
847
+ .then(() => captureStore.enforceRetention())
848
+ .catch(error => {
849
+ console.warn(`[LiveDesk Hub] settings or capture retention load failed: ${error instanceof Error ? error.message : String(error)}`);
850
+ });
851
+ const captureRetentionTimer = setInterval(() => {
852
+ void captureStore.enforceRetention().catch(error => {
853
+ console.warn(`[LiveDesk Hub] capture retention failed: ${error instanceof Error ? error.message : String(error)}`);
854
+ });
855
+ }, 60 * 60_000);
856
+ captureRetentionTimer.unref?.();
671
857
 
672
858
  const udpTransport = createHubUdpTransport({
673
859
  env: process.env,
@@ -675,9 +861,10 @@ const udpTransport = createHubUdpTransport({
675
861
  logWarn: (_scope, message) => console.warn(`[LiveDesk Hub] ${message}`)
676
862
  });
677
863
 
678
- const remoteHub = createRemoteHub({
679
- managerPackage: '@livedesk/hub',
680
- managerVersion: packageInfo.version,
864
+ const remoteHub = createRemoteHub({
865
+ managerPackage: '@livedesk/hub',
866
+ managerVersion: packageInfo.version,
867
+ dataDir: agentDataDir,
681
868
  env: {
682
869
  ...process.env,
683
870
  MINDEXEC_MANAGER_PACKAGE: '@livedesk/hub',
@@ -688,7 +875,11 @@ const remoteHub = createRemoteHub({
688
875
  logWarn: (_scope, message) => console.warn(`[LiveDesk Hub] ${message}`),
689
876
  emitEvent: handleRemoteHubEvent,
690
877
  emitFrame: broadcastRemoteBinaryFrame,
691
- emitAudio: broadcastRemoteBinaryAudio,
878
+ emitAudio: broadcastRemoteBinaryAudio,
879
+ getSecurityIdentity: () => ({
880
+ accountId: runtimeManager.getSnapshot().userId || testSecurityAccountId,
881
+ hubId: remoteHub?.getSecurityStatus?.().hubId || ''
882
+ }),
692
883
  getEffectiveDevicePolicy: ({ deviceId, capabilities }) => buildEffectiveDevicePolicy(liveDeskSettingsStore.getCached(), { deviceId, capabilities }),
693
884
  getWelcomeDevicePolicy: ({ deviceId, capabilities }) => {
694
885
  const policy = buildEffectiveDevicePolicy(liveDeskSettingsStore.getCached(), { deviceId, capabilities });
@@ -791,9 +982,11 @@ function getLiveDeskUpdateStatus() {
791
982
  if (process.env.LIVEDESK_DESKTOP_HOST !== '1') {
792
983
  liveDeskUpdateManager = createLiveDeskUpdateManager({
793
984
  remoteHub,
794
- currentManagerVersion: process.env.LIVEDESK_MANAGER_VERSION || packageInfo.version,
795
- currentClientVersion: process.env.LIVEDESK_CLIENT_PACKAGE_VERSION || '',
796
- restartSupported: !!String(process.env.LIVEDESK_HUB_UPDATE_REQUEST_PATH || '').trim(),
985
+ currentManagerVersion: process.env.LIVEDESK_MANAGER_VERSION || packageInfo.version,
986
+ currentClientVersion: process.env.LIVEDESK_CLIENT_PACKAGE_VERSION || '',
987
+ updateManifestUrl: process.env.LIVEDESK_UPDATE_MANIFEST_URL || '',
988
+ updatePublicKey: process.env.LIVEDESK_UPDATE_PUBLIC_KEY || '',
989
+ restartSupported: !!String(process.env.LIVEDESK_HUB_UPDATE_REQUEST_PATH || '').trim(),
797
990
  requestHubRestart
798
991
  });
799
992
  }
@@ -1111,7 +1304,7 @@ function validateAgentMcpArguments(name, args) {
1111
1304
  return '';
1112
1305
  }
1113
1306
 
1114
- async function dispatchAgentMcpTool(session, name, args = {}) {
1307
+ async function dispatchAgentMcpTool(session, name, args = {}) {
1115
1308
  if (session.cancelled || session.signal?.aborted) return { ok: false, error: 'cancelled-by-user' };
1116
1309
  // This check runs synchronously before the first await, so concurrent Node
1117
1310
  // requests cannot pass the same remaining budget and queue extra Client work.
@@ -1119,8 +1312,23 @@ async function dispatchAgentMcpTool(session, name, args = {}) {
1119
1312
  session.toolLimitReached = true;
1120
1313
  return { ok: false, error: 'codex-tool-limit-reached' };
1121
1314
  }
1122
- session.toolCallCount += 1;
1123
- const tool = getAgentToolDefinition(name);
1315
+ session.toolCallCount += 1;
1316
+ if (isRetiredAgentMutatingToolName(name)) {
1317
+ recordAgentAudit({
1318
+ event: 'tool-rejected',
1319
+ runId: session.runId,
1320
+ deviceIds: [],
1321
+ toolName: name,
1322
+ category: 'mutation',
1323
+ decision: 'deny',
1324
+ status: 'rejected',
1325
+ permissionMode: session.permissionPolicy?.mode,
1326
+ policyHash: session.permissionPolicyHash,
1327
+ details: { reason: 'agent-mutating-tool-disabled' }
1328
+ });
1329
+ return { ok: false, error: 'agent-mutating-tool-disabled' };
1330
+ }
1331
+ const tool = getAgentToolDefinition(name);
1124
1332
  if (!tool) return { ok: false, error: 'codex-tool-not-allowed' };
1125
1333
  const validationError = validateAgentMcpArguments(name, args);
1126
1334
  if (validationError) return { ok: false, error: validationError };
@@ -1312,12 +1520,15 @@ async function synchronizeAgentEnablement() {
1312
1520
  }
1313
1521
 
1314
1522
  const hubFilesystem = createHubFilesystem();
1315
- hubTransferJobs = createHubTransferJobs({
1316
- filesystem: hubFilesystem,
1317
- remoteHub,
1318
- maxConcurrent: Number(process.env.LIVEDESK_MAX_FILE_JOBS || 2),
1319
- commandResultTimeoutMs: readPositiveIntegerEnv('LIVEDESK_FILE_CHUNK_ACK_TIMEOUT_MS', 30_000)
1320
- });
1523
+ hubTransferJobs = createHubTransferJobs({
1524
+ filesystem: hubFilesystem,
1525
+ remoteHub,
1526
+ maxConcurrent: Number(process.env.LIVEDESK_MAX_FILE_JOBS || 2),
1527
+ commandResultTimeoutMs: readPositiveIntegerEnv('LIVEDESK_FILE_CHUNK_ACK_TIMEOUT_MS', 30_000),
1528
+ getMaxFileSizeBytes: () => Number(
1529
+ liveDeskSettingsStore.getCached()?.filesAudio?.maxFileSizeBytes
1530
+ || 1024 * 1024 * 1024)
1531
+ });
1321
1532
  const hubSharedFolders = createHubSharedFolders({
1322
1533
  filesystem: hubFilesystem,
1323
1534
  transferJobs: hubTransferJobs,
@@ -1437,16 +1648,74 @@ app.use((req, res, next) => {
1437
1648
  res.setHeader('Access-Control-Allow-Origin', origin);
1438
1649
  res.setHeader('Access-Control-Allow-Private-Network', 'true');
1439
1650
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
1440
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
1651
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-LiveDesk-Admin-Session, X-LiveDesk-Admin-CSRF, X-LiveDesk-CSRF');
1441
1652
  res.setHeader('Vary', 'Origin, Access-Control-Request-Headers, Access-Control-Request-Private-Network');
1442
1653
  }
1443
1654
  if (req.method === 'OPTIONS') {
1444
1655
  res.status(204).end();
1445
1656
  return;
1446
1657
  }
1447
- next();
1448
- });
1449
- app.use(express.json({ limit: '32mb' }));
1658
+ next();
1659
+ });
1660
+ app.use((req, res, next) => {
1661
+ if (!enforceLocalAdminAuth || req.path === '/api/security/session' || req.method === 'OPTIONS') {
1662
+ next();
1663
+ return;
1664
+ }
1665
+ const authorizedLauncherShutdown = req.method === 'POST'
1666
+ && req.path === '/api/runtime/shutdown'
1667
+ && isLoopbackAddress(req.socket?.remoteAddress)
1668
+ && launcherShutdownToken
1669
+ && secureExactTestTokenMatches(
1670
+ String(req.headers['x-livedesk-launcher-shutdown'] || ''),
1671
+ launcherShutdownToken
1672
+ );
1673
+ if (authorizedLauncherShutdown) {
1674
+ req.liveDeskLauncherShutdown = true;
1675
+ next();
1676
+ return;
1677
+ }
1678
+ const mutating = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method);
1679
+ const sensitiveRead = req.method === 'GET'
1680
+ && ['/api/security/audit', '/api/privacy/inventory'].includes(req.path);
1681
+ if (!mutating && !sensitiveRead) {
1682
+ next();
1683
+ return;
1684
+ }
1685
+ const adminToken = String(req.headers['x-livedesk-admin-session'] || '');
1686
+ const csrfToken = String(req.headers['x-livedesk-admin-csrf'] || '');
1687
+ if (!secureExactTestTokenMatches(adminToken, localAdminSessionToken)) {
1688
+ recordSecurityAudit({
1689
+ action: 'local-admin.request',
1690
+ phase: 'complete',
1691
+ result: 'rejected',
1692
+ reason: 'admin-session-required',
1693
+ requestHash: requestAuditHash({ method: req.method, path: req.path }),
1694
+ authMethod: 'local-admin-session'
1695
+ });
1696
+ res.status(401).json({ ok: false, error: 'local-admin-session-required' });
1697
+ return;
1698
+ }
1699
+ if (mutating && !secureExactTestTokenMatches(csrfToken, localAdminCsrfToken)) {
1700
+ recordSecurityAudit({
1701
+ action: 'local-admin.request',
1702
+ phase: 'complete',
1703
+ result: 'rejected',
1704
+ reason: 'csrf-token-required',
1705
+ requestHash: requestAuditHash({ method: req.method, path: req.path }),
1706
+ authMethod: 'local-admin-session'
1707
+ });
1708
+ res.status(403).json({ ok: false, error: 'csrf-token-required' });
1709
+ return;
1710
+ }
1711
+ if (!securityAuditHealthy) {
1712
+ res.status(503).json({ ok: false, error: 'security-audit-unavailable' });
1713
+ return;
1714
+ }
1715
+ req.liveDeskAdminSessionId = localAdminSessionId;
1716
+ next();
1717
+ });
1718
+ app.use(express.json({ limit: '32mb' }));
1450
1719
  app.use((req, res, next) => {
1451
1720
  if (runtimeRole === 'client' && req.path.startsWith('/api/remote')) {
1452
1721
  res.status(403).json(roleGuardResponse(runtimeRole, 'hub'));
@@ -1597,11 +1866,12 @@ function normalizeTransferFiles(value) {
1597
1866
  if (totalBytes > MAX_FILE_TRANSFER_BYTES) {
1598
1867
  return { ok: false, error: 'file-transfer-too-large', files: [], totalBytes };
1599
1868
  }
1600
- files.push({
1601
- name,
1602
- relativePath,
1603
- size: byteLength,
1604
- mimeType: String(entry?.type || entry?.mimeType || '').slice(0, 160),
1869
+ files.push({
1870
+ name,
1871
+ relativePath,
1872
+ size: byteLength,
1873
+ sha256: crypto.createHash('sha256').update(Buffer.from(dataBase64, 'base64')).digest('hex'),
1874
+ mimeType: String(entry?.type || entry?.mimeType || '').slice(0, 160),
1605
1875
  lastModified: Number(entry?.lastModified || 0) || 0,
1606
1876
  dataBase64
1607
1877
  });
@@ -1612,24 +1882,39 @@ function normalizeTransferFiles(value) {
1612
1882
  return { ok: true, files, totalBytes };
1613
1883
  }
1614
1884
 
1615
- function normalizeTransferChunk(body = {}) {
1885
+ function normalizeTransferChunk(body = {}) {
1616
1886
  const dataBase64 = String(body.dataBase64 || '').replace(/^data:[^,]*,/i, '').trim();
1617
1887
  const name = String(body.name || '').replace(/[\r\n\t\0]/g, ' ').trim().slice(0, 240);
1618
1888
  const relativePath = String(body.relativePath || name).replace(/[\r\n\t\0]/g, ' ').trim().slice(0, 600);
1619
1889
  const offset = Math.max(0, Math.floor(Number(body.offset) || 0));
1620
1890
  const totalBytes = Math.max(0, Math.floor(Number(body.totalBytes) || 0));
1621
- const byteLength = dataBase64 ? Buffer.byteLength(dataBase64, 'base64') : 0;
1622
- const final = body.final === true;
1623
-
1624
- if (!name || !relativePath || offset > totalBytes || offset + byteLength > totalBytes) {
1625
- return { ok: false, error: 'invalid-file-transfer-chunk' };
1626
- }
1891
+ const byteLength = dataBase64 ? Buffer.byteLength(dataBase64, 'base64') : 0;
1892
+ const final = body.final === true;
1893
+ const maxFileSizeBytes = Math.max(1, Number(
1894
+ liveDeskSettingsStore.getCached()?.filesAudio?.maxFileSizeBytes
1895
+ || 1024 * 1024 * 1024));
1896
+
1897
+ if (!name || !relativePath || offset > totalBytes || offset + byteLength > totalBytes) {
1898
+ return { ok: false, error: 'invalid-file-transfer-chunk' };
1899
+ }
1900
+ if (totalBytes > maxFileSizeBytes) {
1901
+ return { ok: false, error: 'file-transfer-file-too-large', totalBytes };
1902
+ }
1627
1903
  if (byteLength > MAX_FILE_TRANSFER_CHUNK_BYTES) {
1628
1904
  return { ok: false, error: 'file-transfer-chunk-too-large', byteLength };
1629
1905
  }
1630
- if (byteLength === 0 && !(final && totalBytes === 0 && offset === 0)) {
1631
- return { ok: false, error: 'empty-file-transfer-chunk' };
1632
- }
1906
+ if (byteLength === 0 && !(final && totalBytes === 0 && offset === 0)) {
1907
+ return { ok: false, error: 'empty-file-transfer-chunk' };
1908
+ }
1909
+ const suppliedSha256 = String(body.sha256 || '').trim().toLowerCase();
1910
+ const sha256 = /^[a-f0-9]{64}$/.test(suppliedSha256)
1911
+ ? suppliedSha256
1912
+ : final && offset === 0 && totalBytes === byteLength
1913
+ ? crypto.createHash('sha256').update(Buffer.from(dataBase64, 'base64')).digest('hex')
1914
+ : '';
1915
+ if (final && !sha256) {
1916
+ return { ok: false, error: 'file-transfer-sha256-required', byteLength };
1917
+ }
1633
1918
 
1634
1919
  return {
1635
1920
  ok: true,
@@ -1639,8 +1924,9 @@ function normalizeTransferChunk(body = {}) {
1639
1924
  offset,
1640
1925
  totalBytes,
1641
1926
  byteLength,
1642
- dataBase64,
1643
- final,
1927
+ dataBase64,
1928
+ final,
1929
+ sha256,
1644
1930
  mimeType: String(body.type || body.mimeType || '').slice(0, 160),
1645
1931
  lastModified: Number(body.lastModified || 0) || 0
1646
1932
  }
@@ -2746,6 +3032,37 @@ function updateFrameSubscription(ws, payload = {}) {
2746
3032
  }
2747
3033
  }
2748
3034
 
3035
+ function refreshFrameSubscriptionLive(ws, payload = {}) {
3036
+ if (!ws || ws.readyState !== 1) {
3037
+ return;
3038
+ }
3039
+ const requestedIds = normalizeDeviceIds(payload.deviceIds ?? payload.devices ?? payload.deviceId);
3040
+ const subscribedIds = [...(ws.liveDeskDeviceIds || [])];
3041
+ const targetIds = requestedIds.length > 0
3042
+ ? requestedIds.filter(deviceId => ws.liveDeskDeviceIds?.has?.(deviceId))
3043
+ : subscribedIds;
3044
+ if (targetIds.length === 0) {
3045
+ return;
3046
+ }
3047
+ ws.liveDeskAutoStart = /^(1|true|yes|on|live)$/i.test(String(
3048
+ payload.autoStartLive ?? payload.startLive ?? ws.liveDeskAutoStart ?? ''
3049
+ ));
3050
+ ws.liveDeskLiveOptions = normalizeLiveOptions({
3051
+ ...(ws.liveDeskLiveOptions || {}),
3052
+ ...payload,
3053
+ monitorSelections: {
3054
+ ...(ws.liveDeskLiveOptions?.monitorSelections || {}),
3055
+ ...(payload.monitorSelections || {})
3056
+ }
3057
+ });
3058
+ for (const deviceId of targetIds.slice(0, 80)) {
3059
+ startFrameSubscriptionLive(ws, 'watchdog', deviceId, {
3060
+ ...ws.liveDeskLiveOptions,
3061
+ reuseExisting: true
3062
+ });
3063
+ }
3064
+ }
3065
+
2749
3066
  function startFrameSubscriptionLive(
2750
3067
  ws,
2751
3068
  reason = 'subscribe',
@@ -3677,10 +3994,26 @@ function buildHubHealthPayload({
3677
3994
  };
3678
3995
  }
3679
3996
 
3680
- app.get('/api/health', (_req, res) => {
3681
- noStore(res);
3682
- res.json(buildHubHealthPayload());
3683
- });
3997
+ app.get('/api/health', (_req, res) => {
3998
+ noStore(res);
3999
+ res.json(buildHubHealthPayload());
4000
+ });
4001
+
4002
+ app.get('/api/security/session', (req, res) => {
4003
+ noStore(res);
4004
+ if (!isLoopbackAddress(req.socket?.remoteAddress) || !isLoopbackHostname(requestHostname(req))) {
4005
+ res.status(403).json({ ok: false, error: 'local-admin-session-loopback-only' });
4006
+ return;
4007
+ }
4008
+ res.json({
4009
+ ok: true,
4010
+ sessionId: localAdminSessionId,
4011
+ adminToken: localAdminSessionToken,
4012
+ csrfToken: localAdminCsrfToken,
4013
+ issuedAt: new Date().toISOString(),
4014
+ expiresOnProcessExit: true
4015
+ });
4016
+ });
3684
4017
 
3685
4018
  if (persistentSessionGcEnabled) {
3686
4019
  app.post('/api/test/persistent-session/gc', (req, res) => {
@@ -3733,9 +4066,18 @@ app.patch('/api/settings', async (req, res) => {
3733
4066
  const record = await liveDeskSettingsStore.update(patch, revision);
3734
4067
  if (patch.agent && Object.prototype.hasOwnProperty.call(patch.agent, 'enabled')) {
3735
4068
  await agentSettingsStore.update({ enabled: record.settings.agent?.enabled === true });
3736
- }
3737
- remoteHub.refreshDevicePolicies();
3738
- res.json({ ok: true, revision: record.revision, updatedAt: record.updatedAt, settings: record.settings });
4069
+ }
4070
+ remoteHub.refreshDevicePolicies();
4071
+ void captureStore.enforceRetention().catch(() => undefined);
4072
+ recordSecurityAudit({
4073
+ action: 'settings.security-policy.updated',
4074
+ phase: 'complete',
4075
+ result: 'success',
4076
+ requestHash: requestAuditHash(patch),
4077
+ authMethod: 'local-admin-session',
4078
+ details: { revision: record.revision, sections: Object.keys(patch) }
4079
+ });
4080
+ res.json({ ok: true, revision: record.revision, updatedAt: record.updatedAt, settings: record.settings });
3739
4081
  } catch (error) {
3740
4082
  if (error instanceof SettingsConflictError) {
3741
4083
  res.status(409).json({ ok: false, error: error.code, revision: error.settings.revision, updatedAt: error.settings.updatedAt, settings: error.settings.settings });
@@ -3890,7 +4232,7 @@ app.get('/api/settings/capabilities', async (_req, res) => {
3890
4232
  });
3891
4233
  });
3892
4234
 
3893
- app.get('/api/security/trusted-devices', (_req, res) => {
4235
+ app.get('/api/security/trusted-devices', (_req, res) => {
3894
4236
  noStore(res);
3895
4237
  const devices = remoteHub.listDevices({ includeDataUrl: false }).map(device => ({
3896
4238
  deviceId: device.deviceId,
@@ -3902,24 +4244,128 @@ app.get('/api/security/trusted-devices', (_req, res) => {
3902
4244
  lastSeenAt: device.lastSeenAt || '',
3903
4245
  ipRange: String(device.remoteAddress || '').replace(/^::ffff:/, '').split(':')[0]
3904
4246
  }));
3905
- res.json({ ok: true, devices });
3906
- });
3907
-
3908
- app.delete('/api/security/trusted-devices/:deviceId', (req, res) => {
3909
- noStore(res);
3910
- const result = remoteHub.disconnectDevice(req.params.deviceId, 'trusted-device-revoked');
3911
- res.json({ ok: result === true, revoked: result === true, deviceId: req.params.deviceId });
3912
- });
4247
+ res.json({ ok: true, devices });
4248
+ });
4249
+
4250
+ app.get('/api/security/audit', async (req, res) => {
4251
+ noStore(res);
4252
+ try {
4253
+ res.json({
4254
+ ok: true,
4255
+ healthy: securityAuditHealthy,
4256
+ verification: await securityAuditStore.verify(),
4257
+ audit: await securityAuditStore.list({ limit: req.query?.limit, action: req.query?.action })
4258
+ });
4259
+ } catch (error) {
4260
+ securityAuditHealthy = false;
4261
+ res.status(503).json({ ok: false, error: error?.code || 'security-audit-unavailable' });
4262
+ }
4263
+ });
4264
+
4265
+ app.get('/api/privacy/inventory', async (_req, res) => {
4266
+ noStore(res);
4267
+ try {
4268
+ const [captures, sharedFolders, verification] = await Promise.all([
4269
+ captureStore.list(),
4270
+ hubSharedFolders.list(),
4271
+ securityAuditStore.verify()
4272
+ ]);
4273
+ res.json({
4274
+ ok: true,
4275
+ inventory: {
4276
+ authenticated: runtimeManager.getSnapshot().authenticated === true,
4277
+ captures: captures.length,
4278
+ sharedFolderDefinitions: sharedFolders.length,
4279
+ trustedDeviceCredentials: remoteHub.getSecurityStatus().devices.length,
4280
+ securityAuditRecords: verification.records,
4281
+ agentAuditRetained: (await agentAuditStore.list({ limit: 500 })).length
4282
+ }
4283
+ });
4284
+ } catch (error) {
4285
+ res.status(503).json({ ok: false, error: String(error?.code || error?.message || 'privacy-inventory-unavailable').slice(0, 120) });
4286
+ }
4287
+ });
4288
+
4289
+ app.delete('/api/privacy/local-data', async (req, res) => {
4290
+ noStore(res);
4291
+ if (String(req.body?.confirmation || '') !== 'DELETE-LIVEDESK-LOCAL-DATA') {
4292
+ res.status(400).json({ ok: false, error: 'privacy-delete-confirmation-required' });
4293
+ return;
4294
+ }
4295
+ try {
4296
+ const providerLogout = await revokeRuntimeProviderSession();
4297
+ const hostTarget = runtimeRole === 'hub'
4298
+ ? await clearHubHostTarget('privacy-delete')
4299
+ : { ok: true, active: false };
4300
+ clearRuntimeSession();
4301
+ for (const device of remoteHub.getSecurityStatus().devices) {
4302
+ remoteHub.disconnectDevice(device.deviceId, 'privacy-local-data-delete');
4303
+ }
4304
+ const trustedDeviceCredentials = remoteHub.clearDeviceCredentials();
4305
+ const transferJobs = hubTransferJobs.clear();
4306
+ const captures = await captureStore.removeAll();
4307
+ const sharedFolders = await hubSharedFolders.clear();
4308
+ await agentAuditStore.clear();
4309
+ await securityAuditStore.reset();
4310
+ securityAuditHealthy = true;
4311
+ await securityAuditStore.record({
4312
+ action: 'privacy.local-data.deleted',
4313
+ phase: 'complete',
4314
+ result: 'success',
4315
+ authMethod: 'local-admin-session',
4316
+ details: {
4317
+ capturesRemoved: captures.removed,
4318
+ captureBytesRemoved: captures.removedBytes,
4319
+ sharedFolderDefinitionsRemoved: sharedFolders.removed,
4320
+ trustedDeviceCredentialsRemoved: trustedDeviceCredentials,
4321
+ transferJobRecordsRemoved: transferJobs.removed,
4322
+ providerSessionRevoked: providerLogout.revoked === true
4323
+ }
4324
+ });
4325
+ res.json({
4326
+ ok: true,
4327
+ localDeleted: true,
4328
+ providerLogout,
4329
+ hostTarget,
4330
+ captures,
4331
+ sharedFolders,
4332
+ trustedDeviceCredentials,
4333
+ transferJobs,
4334
+ preserved: ['settings-preferences', 'hub-device-identity', 'application-binaries']
4335
+ });
4336
+ } catch (error) {
4337
+ securityAuditHealthy = false;
4338
+ res.status(503).json({ ok: false, error: String(error?.code || error?.message || 'privacy-delete-failed').slice(0, 120) });
4339
+ }
4340
+ });
4341
+
4342
+ app.delete('/api/security/trusted-devices/:deviceId', (req, res) => {
4343
+ noStore(res);
4344
+ const disconnected = remoteHub.disconnectDevice(req.params.deviceId, 'trusted-device-revoked');
4345
+ const credentialRevoked = remoteHub.revokeDeviceCredential(req.params.deviceId, 'trusted-device-revoked');
4346
+ const revoked = disconnected === true || credentialRevoked === true;
4347
+ recordSecurityAudit({
4348
+ action: 'security.device.revoked',
4349
+ phase: 'complete',
4350
+ result: revoked ? 'success' : 'not-found',
4351
+ deviceId: req.params.deviceId,
4352
+ authMethod: 'local-admin-session'
4353
+ });
4354
+ res.json({ ok: revoked, revoked, deviceId: req.params.deviceId });
4355
+ });
3913
4356
 
3914
4357
  app.post('/api/security/revoke-all', (_req, res) => {
3915
4358
  noStore(res);
3916
- const devices = remoteHub.listDevices({ includeDataUrl: false });
3917
- let revoked = 0;
3918
- for (const device of devices) {
3919
- if (device.connected && remoteHub.disconnectDevice(device.deviceId, 'trusted-devices-revoked')) revoked += 1;
3920
- }
3921
- res.json({ ok: true, revoked });
3922
- });
4359
+ const devices = remoteHub.listDevices({ includeDataUrl: false });
4360
+ let revoked = 0;
4361
+ for (const device of devices) {
4362
+ const disconnected = device.connected && remoteHub.disconnectDevice(device.deviceId, 'trusted-devices-revoked');
4363
+ const credentialRevoked = remoteHub.revokeDeviceCredential(device.deviceId, 'trusted-devices-revoked');
4364
+ if (disconnected || credentialRevoked) revoked += 1;
4365
+ }
4366
+ recordSecurityAudit({ action: 'security.devices.revoke-all', phase: 'complete', result: 'success', authMethod: 'local-admin-session', details: { revoked } });
4367
+ res.json({ ok: true, revoked });
4368
+ });
3923
4369
 
3924
4370
  app.get('/api/settings/agent', async (_req, res) => {
3925
4371
  noStore(res);
@@ -4281,9 +4727,17 @@ app.post('/api/runtime/restart', (_req, res) => {
4281
4727
  }, 150);
4282
4728
  });
4283
4729
 
4284
- app.post('/api/runtime/shutdown', (_req, res) => {
4285
- noStore(res);
4286
- runtimeManager.emit('runtime.shutdown.requested', { role: runtimeRole });
4730
+ app.post('/api/runtime/shutdown', (req, res) => {
4731
+ noStore(res);
4732
+ recordSecurityAudit({
4733
+ action: 'runtime.shutdown',
4734
+ phase: 'requested',
4735
+ result: 'accepted',
4736
+ authMethod: req.liveDeskLauncherShutdown === true
4737
+ ? 'launcher-internal-shutdown-token'
4738
+ : 'local-admin-session'
4739
+ });
4740
+ runtimeManager.emit('runtime.shutdown.requested', { role: runtimeRole });
4287
4741
  res.json({ ok: true, shuttingDown: true, role: runtimeRole });
4288
4742
  setTimeout(() => {
4289
4743
  void shutdownHub('API_SHUTDOWN')
@@ -4320,8 +4774,10 @@ app.post('/api/auth/session', async (req, res) => {
4320
4774
  }
4321
4775
  const { access_token: accessToken, refresh_token: refreshToken, expires_at: expiresAt } = normalized.session;
4322
4776
  try {
4323
- const user = await verifySupabaseUser(accessToken);
4324
- runtimeAccessToken = accessToken;
4777
+ const user = await verifySupabaseUser(accessToken);
4778
+ runtimeSessionGeneration += 1;
4779
+ runtimeRefreshPromise = null;
4780
+ runtimeAccessToken = accessToken;
4325
4781
  runtimeRefreshToken = refreshToken;
4326
4782
  runtimeAccessTokenExpiresAt = normalizeSessionExpiryMs(expiresAt);
4327
4783
  runtimeManager.setAuthenticated(true, user.id);
@@ -4334,12 +4790,29 @@ app.post('/api/auth/session', async (req, res) => {
4334
4790
  const hostTarget = runtimeRole === 'hub'
4335
4791
  ? await publishHubHostTargetWithPendingRoleTakeover('session-received')
4336
4792
  : { ok: true, active: false };
4337
- if (runtimeRole === 'hub') {
4338
- startHubHostTargetLeaseRenewal();
4339
- }
4340
- res.json({ ok: true, authenticated: true, persisted, userId: user.id, role: runtimeRole, hostTarget });
4341
- } catch (error) {
4342
- const message = error instanceof Error ? error.message : String(error);
4793
+ if (runtimeRole === 'hub') {
4794
+ startHubHostTargetLeaseRenewal();
4795
+ }
4796
+ recordSecurityAudit({
4797
+ action: 'auth.session.established',
4798
+ phase: 'complete',
4799
+ result: 'success',
4800
+ actorAccountId: user.id,
4801
+ actorUserId: user.id,
4802
+ authMethod: 'supabase-access-refresh',
4803
+ requestHash: requestAuditHash({ userId: user.id, expiresAt })
4804
+ });
4805
+ res.json({ ok: true, authenticated: true, persisted, userId: user.id, role: runtimeRole, hostTarget });
4806
+ } catch (error) {
4807
+ const message = error instanceof Error ? error.message : String(error);
4808
+ recordSecurityAudit({
4809
+ action: 'auth.session.establish',
4810
+ phase: 'complete',
4811
+ result: 'rejected',
4812
+ reason: message,
4813
+ authMethod: 'supabase-access-refresh',
4814
+ requestHash: requestAuditHash({ hasAccessToken: Boolean(req.body?.accessToken || req.body?.access_token), hasRefreshToken: Boolean(req.body?.refreshToken || req.body?.refresh_token) })
4815
+ });
4343
4816
  const status = authVerificationHttpStatus(message);
4344
4817
  res.status(status).json({ ok: false, error: message });
4345
4818
  }
@@ -4633,14 +5106,33 @@ function startHubHostTargetLeaseRenewal() {
4633
5106
  hubHostTargetRenewTimer.unref?.();
4634
5107
  }
4635
5108
 
4636
- app.delete('/api/auth/session', async (_req, res) => {
4637
- noStore(res);
4638
- const hostTarget = runtimeRole === 'hub'
4639
- ? await clearHubHostTarget('logout')
4640
- : { ok: true, active: false };
4641
- clearRuntimeSession();
4642
- res.json({ ok: true, authenticated: false, role: runtimeRole, hostTarget });
4643
- });
5109
+ app.delete('/api/auth/session', async (_req, res) => {
5110
+ noStore(res);
5111
+ const actorUserId = runtimeManager.getSnapshot().userId || '';
5112
+ const hostTarget = runtimeRole === 'hub'
5113
+ ? await clearHubHostTarget('logout')
5114
+ : { ok: true, active: false };
5115
+ const providerLogout = await revokeRuntimeProviderSession();
5116
+ clearRuntimeSession();
5117
+ recordSecurityAudit({
5118
+ action: 'auth.session.revoked',
5119
+ phase: 'complete',
5120
+ result: providerLogout.ok ? 'success' : 'provider-error',
5121
+ actorAccountId: actorUserId,
5122
+ actorUserId,
5123
+ reason: providerLogout.error || '',
5124
+ authMethod: 'supabase-local-signout',
5125
+ details: { localCleared: true, providerRevoked: providerLogout.revoked === true, alreadyInvalid: providerLogout.alreadyInvalid === true }
5126
+ });
5127
+ res.status(providerLogout.ok ? 200 : 502).json({
5128
+ ok: providerLogout.ok,
5129
+ authenticated: false,
5130
+ localCleared: true,
5131
+ role: runtimeRole,
5132
+ hostTarget,
5133
+ providerLogout
5134
+ });
5135
+ });
4644
5136
 
4645
5137
  app.get('/api/hub/status', (_req, res) => {
4646
5138
  noStore(res);
@@ -5072,15 +5564,17 @@ app.post('/api/remote/filesystem/shared-folders/:folderId/sync', requireHubFeatu
5072
5564
  }
5073
5565
  });
5074
5566
 
5075
- app.post('/api/remote/files/transfer', requireHubFeatureAccess, (req, res) => {
5567
+ app.post('/api/remote/files/transfer', requireHubFeatureAccess, (req, res) => {
5076
5568
  noStore(res);
5077
5569
  const deviceIds = normalizeDeviceIds(req.body?.deviceIds);
5078
- if (deviceIds.length === 0) {
5079
- res.status(400).json({ ok: false, error: 'no-target-devices' });
5570
+ if (deviceIds.length === 0) {
5571
+ recordSecurityAudit({ action: 'remote.file.transfer', phase: 'complete', result: 'rejected', reason: 'no-target-devices', authMethod: 'local-admin-session' });
5572
+ res.status(400).json({ ok: false, error: 'no-target-devices' });
5080
5573
  return;
5081
5574
  }
5082
5575
  const normalized = normalizeTransferFiles(req.body?.files);
5083
- if (!normalized.ok) {
5576
+ if (!normalized.ok) {
5577
+ recordSecurityAudit({ action: 'remote.file.transfer', phase: 'complete', result: 'rejected', reason: normalized.error, deviceIds, authMethod: 'local-admin-session' });
5084
5578
  res.status(400).json({ ok: false, error: normalized.error, totalBytes: normalized.totalBytes || 0 });
5085
5579
  return;
5086
5580
  }
@@ -5100,7 +5594,18 @@ app.post('/api/remote/files/transfer', requireHubFeatureAccess, (req, res) => {
5100
5594
  }
5101
5595
  })
5102
5596
  }));
5103
- const queued = results.filter(result => result.ok).length;
5597
+ const queued = results.filter(result => result.ok).length;
5598
+ recordSecurityAudit({
5599
+ action: 'remote.file.transfer',
5600
+ phase: 'start',
5601
+ result: queued > 0 ? 'queued' : 'rejected',
5602
+ reason: queued > 0 ? '' : 'no-transfer-queued',
5603
+ deviceIds,
5604
+ sessionId: transferId,
5605
+ requestHash: requestAuditHash({ transferId, remoteDirectory, files: normalized.files.map(file => ({ name: file.name, relativePath: file.relativePath, byteLength: file.size })), totalBytes: normalized.totalBytes }),
5606
+ authMethod: 'local-admin-session',
5607
+ details: { queued, total: deviceIds.length, totalBytes: normalized.totalBytes, files: normalized.files.length }
5608
+ });
5104
5609
  res.json({
5105
5610
  ok: queued > 0,
5106
5611
  transferId,
@@ -5113,15 +5618,17 @@ app.post('/api/remote/files/transfer', requireHubFeatureAccess, (req, res) => {
5113
5618
  });
5114
5619
  });
5115
5620
 
5116
- app.post('/api/remote/files/chunk', requireHubFeatureAccess, (req, res) => {
5621
+ app.post('/api/remote/files/chunk', requireHubFeatureAccess, (req, res) => {
5117
5622
  noStore(res);
5118
5623
  const deviceIds = normalizeDeviceIds(req.body?.deviceIds);
5119
- if (deviceIds.length === 0) {
5624
+ if (deviceIds.length === 0) {
5625
+ recordSecurityAudit({ action: 'remote.file.chunk', phase: 'complete', result: 'rejected', reason: 'no-target-devices', authMethod: 'local-admin-session' });
5120
5626
  res.status(400).json({ ok: false, error: 'no-target-devices' });
5121
5627
  return;
5122
5628
  }
5123
5629
  const normalized = normalizeTransferChunk(req.body || {});
5124
- if (!normalized.ok) {
5630
+ if (!normalized.ok) {
5631
+ recordSecurityAudit({ action: 'remote.file.chunk', phase: 'complete', result: 'rejected', reason: normalized.error, deviceIds, authMethod: 'local-admin-session' });
5125
5632
  res.status(400).json({ ok: false, error: normalized.error, byteLength: normalized.byteLength || 0 });
5126
5633
  return;
5127
5634
  }
@@ -5141,7 +5648,18 @@ app.post('/api/remote/files/chunk', requireHubFeatureAccess, (req, res) => {
5141
5648
  }
5142
5649
  })
5143
5650
  }));
5144
- const queued = results.filter(result => result.ok).length;
5651
+ const queued = results.filter(result => result.ok).length;
5652
+ recordSecurityAudit({
5653
+ action: 'remote.file.chunk',
5654
+ phase: normalized.chunk.final ? 'complete' : 'progress',
5655
+ result: queued > 0 ? 'queued' : 'rejected',
5656
+ reason: queued > 0 ? '' : 'no-transfer-queued',
5657
+ deviceIds,
5658
+ sessionId: transferId,
5659
+ requestHash: requestAuditHash({ transferId, remoteDirectory, offset: normalized.chunk.offset, byteLength: normalized.chunk.byteLength, final: normalized.chunk.final }),
5660
+ authMethod: 'local-admin-session',
5661
+ details: { queued, total: deviceIds.length, byteLength: normalized.chunk.byteLength, offset: normalized.chunk.offset, final: normalized.chunk.final }
5662
+ });
5145
5663
  res.json({
5146
5664
  ok: queued > 0,
5147
5665
  transferId,
@@ -5426,7 +5944,7 @@ app.post('/api/remote/devices/:deviceId/audio/stop', async (req, res, next) => {
5426
5944
  }
5427
5945
  });
5428
5946
 
5429
- if (existsSync(webIndexPath)) {
5947
+ if (existsSync(webIndexPath)) {
5430
5948
  app.use(express.static(webDistPath, {
5431
5949
  etag: true,
5432
5950
  index: false,
@@ -5439,14 +5957,39 @@ if (existsSync(webIndexPath)) {
5439
5957
  }
5440
5958
  res.sendFile(webIndexPath);
5441
5959
  });
5442
- }
5443
-
5444
- httpServer.on('upgrade', (req, socket, head) => {
5445
- if (!isTrustedBrowserRequest(req)) {
5960
+ }
5961
+
5962
+ function isAuthorizedAdminWebSocket(req) {
5963
+ if (!enforceLocalAdminAuth) return true;
5964
+ const protocols = String(req.headers['sec-websocket-protocol'] || '')
5965
+ .split(',')
5966
+ .map(value => value.trim())
5967
+ .filter(Boolean);
5968
+ const adminToken = protocols.find(value => value.startsWith('livedesk-admin-'))?.slice('livedesk-admin-'.length) || '';
5969
+ const csrfToken = protocols.find(value => value.startsWith('livedesk-admin-csrf-'))?.slice('livedesk-admin-csrf-'.length) || '';
5970
+ return secureExactTestTokenMatches(adminToken, localAdminSessionToken)
5971
+ && secureExactTestTokenMatches(csrfToken, localAdminCsrfToken);
5972
+ }
5973
+
5974
+ httpServer.on('upgrade', (req, socket, head) => {
5975
+ if (!isTrustedBrowserRequest(req)) {
5446
5976
  socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
5447
5977
  socket.destroy();
5448
- return;
5449
- }
5978
+ return;
5979
+ }
5980
+ if (!isAuthorizedAdminWebSocket(req)) {
5981
+ recordSecurityAudit({
5982
+ action: 'local-admin.websocket',
5983
+ phase: 'complete',
5984
+ result: 'rejected',
5985
+ reason: 'admin-session-required',
5986
+ requestHash: requestAuditHash({ path: req.url || '' }),
5987
+ authMethod: 'local-admin-websocket'
5988
+ });
5989
+ socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
5990
+ socket.destroy();
5991
+ return;
5992
+ }
5450
5993
  try {
5451
5994
  const parsed = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`);
5452
5995
  if (parsed.pathname === '/api/remote/frames/ws') {
@@ -5496,6 +6039,8 @@ frameWss.on('connection', (ws, req) => {
5496
6039
  const payload = JSON.parse(Buffer.isBuffer(data) ? data.toString('utf8') : String(data || ''));
5497
6040
  if (payload?.type === 'subscribe') {
5498
6041
  updateFrameSubscription(ws, payload);
6042
+ } else if (payload?.type === 'refresh-live') {
6043
+ refreshFrameSubscriptionLive(ws, payload);
5499
6044
  } else if (payload?.type === 'restart-live') {
5500
6045
  restartFrameSubscriptionLive(ws, payload);
5501
6046
  }
@@ -5581,10 +6126,17 @@ audioWss.on('connection', (ws, req) => {
5581
6126
  });
5582
6127
  });
5583
6128
 
5584
- inputWss.on('connection', ws => {
6129
+ inputWss.on('connection', ws => {
5585
6130
  inputClients.add(ws);
5586
6131
  ws.liveDeskInputClientId = `riws-${++inputClientSeq}`;
5587
- ws.liveDeskInputDeviceIds = new Set();
6132
+ ws.liveDeskInputDeviceIds = new Set();
6133
+ recordSecurityAudit({
6134
+ action: 'remote.control.browser-session',
6135
+ phase: 'start',
6136
+ result: 'success',
6137
+ sessionId: ws.liveDeskInputClientId,
6138
+ authMethod: 'local-admin-websocket'
6139
+ });
5588
6140
  try {
5589
6141
  ws._socket?.setNoDelay?.(true);
5590
6142
  } catch {
@@ -5630,11 +6182,22 @@ inputWss.on('connection', ws => {
5630
6182
  timestamp: new Date().toISOString()
5631
6183
  });
5632
6184
  });
5633
- const releaseBrowserInputOwner = reason => {
5634
- inputClients.delete(ws);
5635
- for (const deviceId of ws.liveDeskInputDeviceIds || []) {
5636
- remoteHub.releaseInputOwner(deviceId, ws.liveDeskInputClientId, reason);
5637
- }
6185
+ const releaseBrowserInputOwner = reason => {
6186
+ if (ws.liveDeskInputReleased) return;
6187
+ ws.liveDeskInputReleased = true;
6188
+ inputClients.delete(ws);
6189
+ for (const deviceId of ws.liveDeskInputDeviceIds || []) {
6190
+ remoteHub.releaseInputOwner(deviceId, ws.liveDeskInputClientId, reason);
6191
+ }
6192
+ recordSecurityAudit({
6193
+ action: 'remote.control.browser-session',
6194
+ phase: 'complete',
6195
+ result: 'success',
6196
+ reason,
6197
+ deviceIds: [...(ws.liveDeskInputDeviceIds || [])],
6198
+ sessionId: ws.liveDeskInputClientId,
6199
+ authMethod: 'local-admin-websocket'
6200
+ });
5638
6201
  ws.liveDeskInputDeviceIds?.clear?.();
5639
6202
  };
5640
6203
  ws.on('close', () => releaseBrowserInputOwner('browser-input-websocket-closed'));
@@ -5739,7 +6302,8 @@ function shutdownHub(signal) {
5739
6302
  hubShutdownPromise = (async () => {
5740
6303
  const startedAt = Date.now();
5741
6304
  console.log(`[LiveDesk Hub] Shutdown started signal=${signal} grace=${hubShutdownGraceMs}ms timeout=${hubShutdownTimeoutMs}ms.`);
5742
- if (roleWatchTimer) clearInterval(roleWatchTimer);
6305
+ if (roleWatchTimer) clearInterval(roleWatchTimer);
6306
+ clearInterval(captureRetentionTimer);
5743
6307
  clearInterval(browserWebSocketHeartbeatTimer);
5744
6308
  runSynchronousShutdownStep('update manager close', () => liveDeskUpdateManager?.close());
5745
6309
  atlasClients.clear();