livedesk 0.1.477 → 0.1.480

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.
Files changed (42) hide show
  1. package/README.md +34 -3
  2. package/bin/livedesk.js +5 -1
  3. package/bootstrap/hub-auth-handoff.js +60 -7
  4. package/client/bin/livedesk-client-node.js +189 -81
  5. package/client/bin/livedesk-client.js +65 -24
  6. package/client/package.json +6 -6
  7. package/client/src/runtime/client-runtime-server.js +83 -11
  8. package/client/src/security/device-credential-store.js +190 -0
  9. package/client/src/security/secure-direct-client.js +224 -0
  10. package/electron/electron-builder.mac-arm64.yml +1 -1
  11. package/electron/electron-builder.mac-x64.yml +1 -1
  12. package/electron/main.mjs +65 -16
  13. package/electron/update-manager.mjs +56 -11
  14. package/hub/package.json +2 -2
  15. package/hub/src/agents/agent-audit-store.js +16 -6
  16. package/hub/src/agents/agent-permissions.js +9 -3
  17. package/hub/src/agents/agent-tool-registry.js +19 -1
  18. package/hub/src/captures/capture-store.js +50 -3
  19. package/hub/src/filesystem/shared-folders.js +8 -0
  20. package/hub/src/filesystem/transfer-jobs.js +36 -5
  21. package/hub/src/live-desk-update.js +87 -19
  22. package/hub/src/remote-hub.js +722 -173
  23. package/hub/src/security/device-credential-authority.js +406 -0
  24. package/hub/src/security/security-audit-store.js +260 -0
  25. package/hub/src/server.js +1012 -277
  26. package/hub/src/settings/settings-schema.js +19 -39
  27. package/hub/src/transport/relay-hub-control.js +330 -3
  28. package/hub/src/transport/secure-direct-acceptor.js +433 -0
  29. package/hub/src/transport/udp-hub-transport.js +28 -5
  30. package/hub/src/transport/udp-rendezvous.js +179 -13
  31. package/package.json +6 -6
  32. package/runtime-core/package.json +6 -1
  33. package/runtime-core/src/index.js +2 -0
  34. package/runtime-core/src/os-secret-store.js +142 -0
  35. package/runtime-core/src/secure-session-protocol.js +347 -0
  36. package/runtime-core/src/signed-update-manifest.js +220 -0
  37. package/web/dist/assets/{icons-CpeZfjFK.js → icons-BtzQBJR4.js} +1 -1
  38. package/web/dist/assets/index-CNcg85GR.js +186 -0
  39. package/web/dist/assets/{react-CSxCfTKV.js → react-BDGiddLC.js} +1 -1
  40. package/web/dist/index.html +3 -3
  41. package/web/dist/livedesk-build-evidence.json +13 -13
  42. package/web/dist/assets/index-Cn1YPcj3.js +0 -186
package/README.md CHANGED
@@ -148,7 +148,7 @@ device, Hub URL, timeout, and report-path options.
148
148
 
149
149
  Monthly and yearly subscriptions are planned after the LTD launch.
150
150
 
151
- ## Free ad slot
151
+ ## Free ad slot
152
152
 
153
153
  Free accounts show a standard bottom ad slot. Set these Vite build variables
154
154
  for a real AdSense placement:
@@ -158,5 +158,36 @@ VITE_LIVEDESK_ADSENSE_CLIENT=ca-pub-...
158
158
  VITE_LIVEDESK_ADSENSE_SLOT=...
159
159
  ```
160
160
 
161
- Without those values, LiveDesk renders a neutral sponsored-placement placeholder
162
- so the free-plan layout can still be tested.
161
+ Without those values, LiveDesk renders a neutral sponsored-placement placeholder
162
+ so the free-plan layout can still be tested.
163
+
164
+ ## Signed update configuration
165
+
166
+ Production update checks fail closed until all applicable values are set:
167
+
168
+ ```text
169
+ LIVEDESK_UPDATE_MANIFEST_URL=https://updates.example.com/stable.json
170
+ LIVEDESK_UPDATE_PUBLIC_KEY=<Ed25519 SPKI PEM or base64 DER public key>
171
+ LIVEDESK_UPDATE_URL=https://updates.example.com/desktop
172
+ ```
173
+
174
+ The public key may instead be bundled at
175
+ `electron/update-public-key.pem` for desktop builds. Keep the Ed25519 private
176
+ key offline and outside this repository. Release automation supplies it only to
177
+ the signing command as `LIVEDESK_UPDATE_PRIVATE_KEY` or `--private-key`:
178
+
179
+ ```powershell
180
+ npm run sign:update-manifest -- --input release-payload.json --output stable.json --private-key E:\secure\livedesk-update-ed25519.pem
181
+ ```
182
+
183
+ The payload must include exact npm `dist.integrity` values for `livedesk` and
184
+ `@livedesk/client`, desktop version/platform/architecture SHA-512 values, a
185
+ maximum 31-day validity window, and emergency blocked-version lists.
186
+
187
+ Desktop publisher signing is independent of the LiveDesk manifest signature.
188
+ Configure electron-builder secrets only in the release environment: `CSC_LINK`
189
+ and `CSC_KEY_PASSWORD` for the Windows/macOS certificate; for Apple
190
+ notarization also configure `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, and
191
+ `APPLE_TEAM_ID`. Do not put any private key, certificate password, OAuth secret,
192
+ Supabase service key, Dodo webhook secret, or API token in source, Vite public
193
+ variables, logs, or the signed manifest.
package/bin/livedesk.js CHANGED
@@ -2506,7 +2506,8 @@ async function runManager(args, resolvedRole = null, runtimeLock = null) {
2506
2506
  : (existsSync(internalHubEntry)
2507
2507
  ? internalHubEntry
2508
2508
  : resolvePackageEntry('@livedesk/hub', '@livedesk/hub/src/server.js'));
2509
- const packagedWebDist = resolve(packageRoot, 'web', 'dist');
2509
+ const packagedWebDist = resolve(packageRoot, 'web', 'dist');
2510
+ const launcherShutdownToken = randomBytes(32).toString('base64url');
2510
2511
  const env = {
2511
2512
  ...process.env,
2512
2513
  LIVEDESK_HUB_HTTP_HOST: configuredHttpHost,
@@ -2527,6 +2528,7 @@ async function runManager(args, resolvedRole = null, runtimeLock = null) {
2527
2528
  LIVEDESK_DEVICE_NAME: String(resolvedRole?.deviceName || process.env.LIVEDESK_DEVICE_NAME || os.hostname()),
2528
2529
  LIVEDESK_ROLE_CACHE_PATH: join(MANAGER_STATE_DIR, 'device-role.json'),
2529
2530
  LIVEDESK_AUTH_STATE_PATH: join(MANAGER_STATE_DIR, 'auth.json'),
2531
+ LIVEDESK_LAUNCHER_SHUTDOWN_TOKEN: launcherShutdownToken,
2530
2532
  LIVEDESK_ROLE_SOURCE: String(resolvedRole?.source || process.env.LIVEDESK_ROLE_SOURCE || '')
2531
2533
  };
2532
2534
  const debugConsole = process.env.LIVEDESK_DEBUG === '1';
@@ -2601,6 +2603,7 @@ async function runManager(args, resolvedRole = null, runtimeLock = null) {
2601
2603
  try {
2602
2604
  const response = await fetch(`${hubProbeBaseUrl}/api/runtime/shutdown`, {
2603
2605
  method: 'POST',
2606
+ headers: { 'X-LiveDesk-Launcher-Shutdown': launcherShutdownToken },
2604
2607
  signal: AbortSignal.timeout(750)
2605
2608
  });
2606
2609
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
@@ -2855,6 +2858,7 @@ async function runManager(args, resolvedRole = null, runtimeLock = null) {
2855
2858
  updateStopTimer.unref?.();
2856
2859
  void fetch(`${hubProbeBaseUrl}/api/runtime/shutdown`, {
2857
2860
  method: 'POST',
2861
+ headers: { 'X-LiveDesk-Launcher-Shutdown': launcherShutdownToken },
2858
2862
  signal: AbortSignal.timeout(2_500)
2859
2863
  }).then(response => {
2860
2864
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
@@ -14,6 +14,7 @@ import {
14
14
  } from '../runtime-core/src/auth-config.js';
15
15
  import { fetchAuthResponse } from '../runtime-core/src/auth-http.js';
16
16
  import { normalizeRuntimeAuthSession } from '../runtime-core/src/auth-session.js';
17
+ import { createOsSecretStore, OS_SECRET_REFERENCE } from '../runtime-core/src/os-secret-store.js';
17
18
 
18
19
  const CLIENT_AUTH_STORAGE_KEY = 'livedesk.client.supabase.auth';
19
20
  const MINIMUM_SESSION_LIFETIME_SECONDS = 30;
@@ -61,6 +62,28 @@ function sessionIsFresh(session, nowSeconds) {
61
62
  && expiresAt - Number(nowSeconds || 0) > MINIMUM_SESSION_LIFETIME_SECONDS;
62
63
  }
63
64
 
65
+ function clientRefreshSecretStore(stateDir) {
66
+ return createOsSecretStore({
67
+ service: 'LiveDesk',
68
+ account: 'client-refresh-token',
69
+ dataDir: stateDir
70
+ });
71
+ }
72
+
73
+ function persistProtectedClientSession(stateDir, session) {
74
+ const normalized = normalizeRuntimeAuthSession(session, { requireRefreshToken: true });
75
+ if (!normalized.ok) return false;
76
+ const secretStore = clientRefreshSecretStore(stateDir);
77
+ if (!secretStore.write(normalized.session.refresh_token)) return false;
78
+ const persisted = { ...normalized.session, refresh_token_ref: OS_SECRET_REFERENCE };
79
+ delete persisted.refresh_token;
80
+ const authPath = join(stateDir, 'auth.json');
81
+ const authState = readAuthState(authPath);
82
+ authState[CLIENT_AUTH_STORAGE_KEY] = JSON.stringify(persisted);
83
+ writePrivateJsonAtomic(authPath, authState);
84
+ return true;
85
+ }
86
+
64
87
  function resolveSupabaseAuthConfig(env = process.env) {
65
88
  const supabaseUrl = String(
66
89
  env.LIVEDESK_RUNTIME_SUPABASE_URL
@@ -82,9 +105,22 @@ function resolveSupabaseAuthConfig(env = process.env) {
82
105
  * - Never logs or serializes token values outside the loopback handoff.
83
106
  */
84
107
  export async function readSavedClientSessionForHub(stateDir) {
85
- const authState = readAuthState(join(stateDir, 'auth.json'));
108
+ const authPath = join(stateDir, 'auth.json');
109
+ const authState = readAuthState(authPath);
86
110
  const candidate = parseStoredSession(authState?.[CLIENT_AUTH_STORAGE_KEY]);
87
- const normalized = normalizeRuntimeAuthSession(candidate, { requireRefreshToken: true });
111
+ if (!candidate) return null;
112
+ const plaintextRefreshToken = String(candidate.refresh_token || '').trim();
113
+ const refreshToken = plaintextRefreshToken || (candidate.refresh_token_ref === OS_SECRET_REFERENCE
114
+ ? clientRefreshSecretStore(stateDir).read()
115
+ : '');
116
+ const normalized = normalizeRuntimeAuthSession(
117
+ { ...candidate, refresh_token: refreshToken },
118
+ { requireRefreshToken: true }
119
+ );
120
+ if (normalized.ok && plaintextRefreshToken
121
+ && !persistProtectedClientSession(stateDir, normalized.session)) {
122
+ return null;
123
+ }
88
124
  return normalized.ok ? normalized.session : null;
89
125
  }
90
126
 
@@ -122,10 +158,9 @@ async function refreshSavedClientSessionForHub(
122
158
  throw new Error('saved-client-session-refresh-invalid');
123
159
  }
124
160
 
125
- const authPath = join(stateDir, 'auth.json');
126
- const authState = readAuthState(authPath);
127
- authState[CLIENT_AUTH_STORAGE_KEY] = JSON.stringify(normalized.session);
128
- writePrivateJsonAtomic(authPath, authState);
161
+ if (!persistProtectedClientSession(stateDir, normalized.session)) {
162
+ throw new Error('saved-client-session-secure-store-unavailable');
163
+ }
129
164
  return normalized.session;
130
165
  }
131
166
 
@@ -152,11 +187,29 @@ export async function transferSavedClientSessionToHub(
152
187
  if (!resolved) {
153
188
  return { ok: false, skipped: true, reason: 'no-refreshable-saved-client-session' };
154
189
  }
190
+ const adminSessionResponse = await fetchAuthResponse(fetchImpl, new URL('/api/security/session', baseUrl), {
191
+ method: 'GET',
192
+ headers: { Accept: 'application/json' }
193
+ }, 5_000);
194
+ const adminSession = adminSessionResponse.status === 404
195
+ ? null
196
+ : await adminSessionResponse.json().catch(() => null);
197
+ if (adminSessionResponse.status !== 404 && (
198
+ !adminSessionResponse.ok
199
+ || !adminSession?.adminToken
200
+ || !adminSession?.csrfToken
201
+ )) {
202
+ throw new Error(`hub-admin-session-http-${adminSessionResponse.status}`);
203
+ }
155
204
  const response = await fetchAuthResponse(fetchImpl, new URL('/api/auth/session', baseUrl), {
156
205
  method: 'POST',
157
206
  headers: {
158
207
  'Content-Type': 'application/json',
159
- Accept: 'application/json'
208
+ Accept: 'application/json',
209
+ ...(adminSession ? {
210
+ 'X-LiveDesk-Admin-Session': String(adminSession.adminToken),
211
+ 'X-LiveDesk-Admin-CSRF': String(adminSession.csrfToken)
212
+ } : {})
160
213
  },
161
214
  body: JSON.stringify({
162
215
  accessToken: resolved.session.access_token,
@@ -4,10 +4,12 @@ import net from 'net';
4
4
  import os from 'os';
5
5
  import path from 'path';
6
6
  import crypto from 'crypto';
7
- import { existsSync, promises as fs, statfsSync } from 'fs';
7
+ import { constants as fsConstants, existsSync, promises as fs, statfsSync } from 'fs';
8
8
  import { spawn } from 'child_process';
9
- import { createRequire } from 'node:module';
10
- import { fileURLToPath } from 'node:url';
9
+ import { createRequire } from 'node:module';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { createClientDeviceCredentialStore } from '../src/security/device-credential-store.js';
12
+ import { connectSecureDirect } from '../src/security/secure-direct-client.js';
11
13
 
12
14
  const require = createRequire(import.meta.url);
13
15
  const CLIENT_UPDATE_BOOTSTRAP_PATH = fileURLToPath(
@@ -657,7 +659,7 @@ function sanitizePathSegment(value, fallback = 'file') {
657
659
  return normalized.slice(0, 160);
658
660
  }
659
661
 
660
- function sanitizeRelativeFilePath(value, fallbackName = 'file') {
662
+ function sanitizeRelativeFilePath(value, fallbackName = 'file') {
661
663
  const parts = String(value || '')
662
664
  .replace(/\0/g, '')
663
665
  .split(/[\\/]+/)
@@ -668,7 +670,52 @@ function sanitizeRelativeFilePath(value, fallbackName = 'file') {
668
670
  return sanitizePathSegment(fallbackName, 'file');
669
671
  }
670
672
  return path.join(...parts.slice(-8));
671
- }
673
+ }
674
+
675
+ function isPathInsideRoot(root, candidate) {
676
+ const relative = path.relative(root, candidate);
677
+ return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
678
+ }
679
+
680
+ async function resolveReceiveDirectory(options, remoteDirectory) {
681
+ const configuredRoot = path.resolve(options.filesDir || getDefaultFilesDir());
682
+ await fs.mkdir(configuredRoot, { recursive: true });
683
+ const rootStat = await fs.lstat(configuredRoot);
684
+ if (rootStat.isSymbolicLink()) throw new Error('The LiveDesk receive root cannot be a symbolic link or junction.');
685
+ const canonicalRoot = await fs.realpath(configuredRoot);
686
+ const relativeDirectory = String(remoteDirectory || '').replace(/\0/g, '').trim();
687
+ if (relativeDirectory.length > 600 || path.isAbsolute(relativeDirectory)) {
688
+ throw new Error('The remote receive directory must be relative to the LiveDesk files root.');
689
+ }
690
+ const candidate = path.resolve(canonicalRoot, relativeDirectory || '.');
691
+ if (!isPathInsideRoot(canonicalRoot, candidate)) throw new Error('The remote receive directory escapes the LiveDesk files root.');
692
+ await assertAgentPathDoesNotTraverseLink(canonicalRoot, candidate);
693
+ await fs.mkdir(candidate, { recursive: true });
694
+ await assertAgentPathDoesNotTraverseLink(canonicalRoot, candidate);
695
+ const canonicalCandidate = await fs.realpath(candidate);
696
+ if (!isPathInsideRoot(canonicalRoot, canonicalCandidate)) throw new Error('The remote receive directory resolves outside the LiveDesk files root.');
697
+ return { root: canonicalRoot, directory: canonicalCandidate };
698
+ }
699
+
700
+ function requireFileSha256(value) {
701
+ const hash = String(value || '').trim().toLowerCase();
702
+ if (!/^[a-f0-9]{64}$/.test(hash)) throw new Error('A valid SHA-256 file hash is required.');
703
+ return hash;
704
+ }
705
+
706
+ function hashBuffer(buffer) {
707
+ return crypto.createHash('sha256').update(buffer).digest('hex');
708
+ }
709
+
710
+ async function commitNewFile(tempPath, targetPath) {
711
+ try {
712
+ await fs.link(tempPath, targetPath);
713
+ } catch (error) {
714
+ if (error?.code === 'EEXIST') throw new Error(`Refusing to overwrite an existing file: ${path.basename(targetPath)}`);
715
+ throw error;
716
+ }
717
+ await fs.rm(tempPath, { force: true });
718
+ }
672
719
 
673
720
  async function handleFileTransferCommand(options, payload = {}) {
674
721
  const files = Array.isArray(payload.files) ? payload.files.slice(0, MAX_FILE_TRANSFER_FILES) : [];
@@ -676,32 +723,46 @@ async function handleFileTransferCommand(options, payload = {}) {
676
723
  throw new Error('No files were included in the transfer.');
677
724
  }
678
725
 
679
- const baseDir = normalizeDirectoryPath(payload.remoteDirectory || options.filesDir);
680
- await fs.mkdir(baseDir, { recursive: true });
726
+ const { root, directory: baseDir } = await resolveReceiveDirectory(options, payload.remoteDirectory);
681
727
 
682
728
  let totalBytes = 0;
683
729
  const saved = [];
684
730
  for (const file of files) {
685
731
  const name = sanitizePathSegment(file?.name, 'file');
686
732
  const relativePath = sanitizeRelativeFilePath(file?.relativePath || name, name);
687
- const targetPath = path.resolve(baseDir, relativePath);
688
- const relativeFromBase = path.relative(baseDir, targetPath);
689
- if (relativeFromBase.startsWith('..') || path.isAbsolute(relativeFromBase)) {
690
- throw new Error(`Unsafe file path: ${relativePath}`);
691
- }
733
+ const targetPath = path.resolve(baseDir, relativePath);
734
+ if (!isPathInsideRoot(baseDir, targetPath)) {
735
+ throw new Error(`Unsafe file path: ${relativePath}`);
736
+ }
692
737
 
693
738
  const dataBase64 = String(file?.dataBase64 || '').replace(/^data:[^,]*,/i, '').trim();
694
739
  if (!dataBase64) {
695
740
  throw new Error(`Missing file data: ${name}`);
696
741
  }
697
- const buffer = Buffer.from(dataBase64, 'base64');
742
+ const buffer = Buffer.from(dataBase64, 'base64');
743
+ const expectedSha256 = requireFileSha256(file?.sha256);
744
+ if (hashBuffer(buffer) !== expectedSha256) throw new Error(`File SHA-256 mismatch: ${name}`);
698
745
  totalBytes += buffer.length;
699
746
  if (totalBytes > MAX_FILE_TRANSFER_BYTES) {
700
747
  throw new Error('File transfer exceeded the local size limit.');
701
748
  }
702
749
 
703
- await fs.mkdir(path.dirname(targetPath), { recursive: true });
704
- await fs.writeFile(targetPath, buffer);
750
+ await fs.mkdir(path.dirname(targetPath), { recursive: true });
751
+ await assertAgentPathDoesNotTraverseLink(root, path.dirname(targetPath));
752
+ await assertAgentPathDoesNotTraverseLink(root, targetPath);
753
+ const tempPath = `${targetPath}.livedesk-${crypto.randomBytes(12).toString('hex')}.part`;
754
+ try {
755
+ const handle = await fs.open(tempPath, 'wx', 0o600);
756
+ try {
757
+ await handle.writeFile(buffer);
758
+ await handle.sync();
759
+ } finally {
760
+ await handle.close();
761
+ }
762
+ await commitNewFile(tempPath, targetPath);
763
+ } finally {
764
+ await fs.rm(tempPath, { force: true }).catch(() => undefined);
765
+ }
705
766
  saved.push({
706
767
  name,
707
768
  relativePath,
@@ -722,12 +783,11 @@ async function handleFileTransferCommand(options, payload = {}) {
722
783
  }
723
784
 
724
785
  async function handleFileTransferChunkCommand(options, payload = {}) {
725
- const baseDir = normalizeDirectoryPath(payload.remoteDirectory || options.filesDir);
786
+ const { root, directory: baseDir } = await resolveReceiveDirectory(options, payload.remoteDirectory);
726
787
  const name = sanitizePathSegment(payload.name, 'file');
727
788
  const relativePath = sanitizeRelativeFilePath(payload.relativePath || name, name);
728
789
  const targetPath = path.resolve(baseDir, relativePath);
729
- const relativeFromBase = path.relative(baseDir, targetPath);
730
- if (relativeFromBase.startsWith('..') || path.isAbsolute(relativeFromBase)) {
790
+ if (!isPathInsideRoot(baseDir, targetPath)) {
731
791
  throw new Error(`Unsafe file path: ${relativePath}`);
732
792
  }
733
793
 
@@ -736,19 +796,31 @@ async function handleFileTransferChunkCommand(options, payload = {}) {
736
796
  const totalBytes = Math.max(0, Math.floor(Number(payload.totalBytes) || 0));
737
797
  const final = payload.final === true;
738
798
  const buffer = payload.dataBase64 ? Buffer.from(String(payload.dataBase64), 'base64') : Buffer.alloc(0);
739
- if (!transferId || offset > totalBytes || offset + buffer.length > totalBytes || buffer.length > 512 * 1024) {
799
+ if (!transferId || totalBytes > MAX_FILE_TRANSFER_BYTES || offset > totalBytes || offset + buffer.length > totalBytes || buffer.length > 512 * 1024) {
740
800
  throw new Error('Invalid file transfer chunk.');
741
801
  }
742
802
  if (buffer.length === 0 && !(final && totalBytes === 0 && offset === 0)) {
743
803
  throw new Error('Empty file chunks are not allowed.');
744
804
  }
745
805
 
746
- await fs.mkdir(path.dirname(targetPath), { recursive: true });
747
- const tempPath = `${targetPath}.livedesk-${transferId}.part`;
748
- const handle = await fs.open(tempPath, offset === 0 ? 'w+' : 'r+');
806
+ await fs.mkdir(path.dirname(targetPath), { recursive: true });
807
+ await assertAgentPathDoesNotTraverseLink(root, path.dirname(targetPath));
808
+ await assertAgentPathDoesNotTraverseLink(root, targetPath);
809
+ const tempPath = `${targetPath}.livedesk-${transferId}.part`;
810
+ if (offset > 0) {
811
+ const staging = await fs.lstat(tempPath);
812
+ if (!staging.isFile() || staging.isSymbolicLink() || staging.nlink !== 1) {
813
+ throw new Error('Unsafe file transfer staging path.');
814
+ }
815
+ }
816
+ const openFlags = offset === 0
817
+ ? fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_RDWR
818
+ : fsConstants.O_RDWR | (fsConstants.O_NOFOLLOW || 0);
819
+ const handle = await fs.open(tempPath, openFlags, 0o600);
749
820
  let completedSize = 0;
750
821
  try {
751
- const before = await handle.stat();
822
+ const before = await handle.stat();
823
+ if (!before.isFile() || before.nlink !== 1) throw new Error('Unsafe file transfer staging path.');
752
824
  if (offset > before.size) {
753
825
  throw new Error(`File chunk gap detected at ${offset}; current length is ${before.size}.`);
754
826
  }
@@ -767,9 +839,19 @@ async function handleFileTransferChunkCommand(options, payload = {}) {
767
839
  await handle.close();
768
840
  }
769
841
 
770
- if (final) {
771
- await fs.rm(targetPath, { force: true });
772
- await fs.rename(tempPath, targetPath);
842
+ if (final) {
843
+ const expectedSha256 = requireFileSha256(payload.sha256);
844
+ const received = await fs.readFile(tempPath);
845
+ if (hashBuffer(received) !== expectedSha256) {
846
+ await fs.rm(tempPath, { force: true });
847
+ throw new Error('File SHA-256 mismatch.');
848
+ }
849
+ try {
850
+ await commitNewFile(tempPath, targetPath);
851
+ } catch (error) {
852
+ await fs.rm(tempPath, { force: true });
853
+ throw error;
854
+ }
773
855
  const lastModified = Number(payload.lastModified || 0);
774
856
  if (lastModified > 0) {
775
857
  const modifiedAt = new Date(lastModified);
@@ -1430,7 +1512,20 @@ function normalizeNodeAgentTaskResult(result) {
1430
1512
  return { ...result, ok, status: ok ? 'completed' : 'failed', error };
1431
1513
  }
1432
1514
 
1433
- const NODE_AGENT_OPERATIONS = new Set(['process.control', 'service.control', 'application.launch', 'application.close', 'file.read', 'file.write', 'file.delete', 'file.list', 'command.run', 'script.run', 'software.install', 'network.status', 'system.power', 'system.configure', 'logs.collect']);
1515
+ const NODE_AGENT_OPERATIONS = new Set(['file.read', 'file.list', 'network.status', 'logs.collect']);
1516
+ const RETIRED_NODE_AGENT_MUTATING_OPERATIONS = new Set([
1517
+ 'process.control',
1518
+ 'service.control',
1519
+ 'application.launch',
1520
+ 'application.close',
1521
+ 'file.write',
1522
+ 'file.delete',
1523
+ 'command.run',
1524
+ 'script.run',
1525
+ 'software.install',
1526
+ 'system.power',
1527
+ 'system.configure'
1528
+ ]);
1434
1529
 
1435
1530
  function remotePolicyAllows(options, command) {
1436
1531
  const policy = options.effectivePolicy;
@@ -1587,8 +1682,8 @@ async function scheduleClientUpdate(options, payload = {}) {
1587
1682
  }
1588
1683
  }
1589
1684
 
1590
- async function handleRemoteCommand(socket, options, message, nextFrameSeq, activeStreams) {
1591
- const command = String(message.command || '');
1685
+ async function handleRemoteCommand(socket, options, message, nextFrameSeq, activeStreams) {
1686
+ const command = String(message.command || '');
1592
1687
  if (command === 'ping') {
1593
1688
  writeJsonLine(socket, {
1594
1689
  type: 'command.result',
@@ -1598,8 +1693,20 @@ async function handleRemoteCommand(socket, options, message, nextFrameSeq, activ
1598
1693
  at: new Date().toISOString()
1599
1694
  }
1600
1695
  });
1601
- return;
1602
- }
1696
+ return;
1697
+ }
1698
+
1699
+ // LD-SEC-AI-001 is enforced before settings or permission modes. A stale
1700
+ // Hub can therefore never revive a retired mutation with full-access.
1701
+ if (RETIRED_NODE_AGENT_MUTATING_OPERATIONS.has(command)) {
1702
+ writeJsonLine(socket, {
1703
+ type: 'command.result',
1704
+ commandId: message.commandId,
1705
+ error: 'agent-mutating-tool-disabled',
1706
+ result: { ok: false, status: 'rejected', error: 'agent-mutating-tool-disabled', sideEffects: 'none' }
1707
+ });
1708
+ return;
1709
+ }
1603
1710
 
1604
1711
  const policyDecision = remotePolicyAllows(options, command);
1605
1712
  if (!policyDecision.ok) {
@@ -1900,18 +2007,20 @@ async function handleRemoteCommand(socket, options, message, nextFrameSeq, activ
1900
2007
  });
1901
2008
  }
1902
2009
 
1903
- function connectOnce(options, deviceId) {
1904
- const manager = parseManagerAddress(options.manager);
1905
- if (!isPrivateLanHost(manager.host) && !isTruthy(process.env.LIVEDESK_ALLOW_UNENCRYPTED_LAN)) {
1906
- return Promise.reject(new Error('Plain TCP LiveDesk connections are limited to loopback/private LAN. Use an encrypted endpoint for external connections.'));
1907
- }
1908
- return new Promise((resolve, reject) => {
1909
- const socket = net.createConnection({
1910
- host: manager.host,
1911
- port: manager.port
1912
- });
1913
-
1914
- let buffer = '';
2010
+ async function connectOnce(options, deviceId) {
2011
+ const manager = parseManagerAddress(options.manager);
2012
+ const credentialStore = createClientDeviceCredentialStore({ deviceId });
2013
+ const socket = await connectSecureDirect({
2014
+ host: manager.host,
2015
+ port: manager.port,
2016
+ channel: 'control',
2017
+ deviceId,
2018
+ enrollmentToken: options.pair,
2019
+ credentialStore,
2020
+ timeoutMs: 5000
2021
+ });
2022
+ return new Promise((resolve, reject) => {
2023
+ let buffer = '';
1915
2024
  let heartbeatTimer = null;
1916
2025
  let resolved = false;
1917
2026
  let frameSeq = 0;
@@ -1945,44 +2054,43 @@ function connectOnce(options, deviceId) {
1945
2054
  socket.setNoDelay(true);
1946
2055
  socket.setKeepAlive(true, options.heartbeatMs);
1947
2056
 
1948
- socket.once('connect', () => {
1949
- writeJsonLine(socket, {
1950
- type: 'hello',
1951
- pairToken: options.pair,
1952
- deviceId,
1953
- deviceName: options.name,
1954
- slotNumber: options.slotNumber || undefined,
1955
- hostname: os.hostname(),
1956
- platform: os.platform(),
1957
- arch: os.arch(),
1958
- pid: process.pid,
1959
- agentVersion: AGENT_VERSION,
1960
- productVersion: PRODUCT_VERSION,
1961
- capabilities: {
1962
- status: true,
1963
- thumbnail: options.thumbnailEnabled,
1964
- liveStream: options.liveEnabled,
1965
- monitorSelection: true,
1966
- screenCount: 1,
1967
- monitorCount: 1,
1968
- control: false,
1969
- audio: false,
1970
- remoteAudio: false,
1971
- fileTransfer: true,
1972
- remoteFiles: true,
1973
- fileTransferMaxBytes: MAX_FILE_TRANSFER_BYTES,
1974
- computerAgent: options.taskEnabled,
1975
- taskDispatch: options.taskEnabled,
1976
- clientUpdate: true,
1977
- productVersion: PRODUCT_VERSION,
1978
- agentApproval: options.taskEnabled,
1979
- agentAudit: options.taskEnabled,
1980
- agentTools: [...NODE_AGENT_OPERATIONS],
1981
- elevation: typeof process.getuid === 'function' ? process.getuid() === 0 : false,
1982
- externalEffects: options.taskEnabled
1983
- }
1984
- });
1985
- });
2057
+ writeJsonLine(socket, {
2058
+ type: 'hello',
2059
+ deviceId,
2060
+ deviceName: options.name,
2061
+ slotNumber: options.slotNumber || undefined,
2062
+ hostname: os.hostname(),
2063
+ platform: os.platform(),
2064
+ arch: os.arch(),
2065
+ pid: process.pid,
2066
+ agentVersion: AGENT_VERSION,
2067
+ productVersion: PRODUCT_VERSION,
2068
+ capabilities: {
2069
+ status: true,
2070
+ thumbnail: options.thumbnailEnabled,
2071
+ liveStream: options.liveEnabled,
2072
+ monitorSelection: true,
2073
+ screenCount: 1,
2074
+ monitorCount: 1,
2075
+ control: false,
2076
+ audio: false,
2077
+ remoteAudio: false,
2078
+ fileTransfer: true,
2079
+ remoteFiles: true,
2080
+ fileTransferMaxBytes: MAX_FILE_TRANSFER_BYTES,
2081
+ computerAgent: options.taskEnabled,
2082
+ taskDispatch: options.taskEnabled,
2083
+ clientUpdate: true,
2084
+ productVersion: PRODUCT_VERSION,
2085
+ agentApproval: options.taskEnabled,
2086
+ agentAudit: options.taskEnabled,
2087
+ agentTools: [...NODE_AGENT_OPERATIONS],
2088
+ elevation: typeof process.getuid === 'function' ? process.getuid() === 0 : false,
2089
+ externalEffects: false,
2090
+ authenticatedDeviceCredential: true,
2091
+ encryptedDirect: true
2092
+ }
2093
+ });
1986
2094
 
1987
2095
  socket.on('data', chunk => {
1988
2096
  buffer += chunk;