@livedesk/client 0.1.235 → 0.1.236

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.
@@ -1,15 +1,13 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
 
3
3
  import net from 'net';
4
4
  import os from 'os';
5
5
  import path from 'path';
6
6
  import crypto from 'crypto';
7
- import { constants as fsConstants, existsSync, promises as fs, statfsSync } from 'fs';
7
+ import { 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';
11
- import { createClientDeviceCredentialStore } from '../src/security/device-credential-store.js';
12
- import { connectSecureDirect } from '../src/security/secure-direct-client.js';
9
+ import { createRequire } from 'node:module';
10
+ import { fileURLToPath } from 'node:url';
13
11
 
14
12
  const require = createRequire(import.meta.url);
15
13
  const CLIENT_UPDATE_BOOTSTRAP_PATH = fileURLToPath(
@@ -659,7 +657,7 @@ function sanitizePathSegment(value, fallback = 'file') {
659
657
  return normalized.slice(0, 160);
660
658
  }
661
659
 
662
- function sanitizeRelativeFilePath(value, fallbackName = 'file') {
660
+ function sanitizeRelativeFilePath(value, fallbackName = 'file') {
663
661
  const parts = String(value || '')
664
662
  .replace(/\0/g, '')
665
663
  .split(/[\\/]+/)
@@ -670,52 +668,7 @@ function sanitizeRelativeFilePath(value, fallbackName = 'file') {
670
668
  return sanitizePathSegment(fallbackName, 'file');
671
669
  }
672
670
  return path.join(...parts.slice(-8));
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
- }
671
+ }
719
672
 
720
673
  async function handleFileTransferCommand(options, payload = {}) {
721
674
  const files = Array.isArray(payload.files) ? payload.files.slice(0, MAX_FILE_TRANSFER_FILES) : [];
@@ -723,46 +676,32 @@ async function handleFileTransferCommand(options, payload = {}) {
723
676
  throw new Error('No files were included in the transfer.');
724
677
  }
725
678
 
726
- const { root, directory: baseDir } = await resolveReceiveDirectory(options, payload.remoteDirectory);
679
+ const baseDir = normalizeDirectoryPath(payload.remoteDirectory || options.filesDir);
680
+ await fs.mkdir(baseDir, { recursive: true });
727
681
 
728
682
  let totalBytes = 0;
729
683
  const saved = [];
730
684
  for (const file of files) {
731
685
  const name = sanitizePathSegment(file?.name, 'file');
732
686
  const relativePath = sanitizeRelativeFilePath(file?.relativePath || name, name);
733
- const targetPath = path.resolve(baseDir, relativePath);
734
- if (!isPathInsideRoot(baseDir, targetPath)) {
735
- throw new Error(`Unsafe file path: ${relativePath}`);
736
- }
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
+ }
737
692
 
738
693
  const dataBase64 = String(file?.dataBase64 || '').replace(/^data:[^,]*,/i, '').trim();
739
694
  if (!dataBase64) {
740
695
  throw new Error(`Missing file data: ${name}`);
741
696
  }
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}`);
697
+ const buffer = Buffer.from(dataBase64, 'base64');
745
698
  totalBytes += buffer.length;
746
699
  if (totalBytes > MAX_FILE_TRANSFER_BYTES) {
747
700
  throw new Error('File transfer exceeded the local size limit.');
748
701
  }
749
702
 
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
- }
703
+ await fs.mkdir(path.dirname(targetPath), { recursive: true });
704
+ await fs.writeFile(targetPath, buffer);
766
705
  saved.push({
767
706
  name,
768
707
  relativePath,
@@ -783,11 +722,12 @@ async function handleFileTransferCommand(options, payload = {}) {
783
722
  }
784
723
 
785
724
  async function handleFileTransferChunkCommand(options, payload = {}) {
786
- const { root, directory: baseDir } = await resolveReceiveDirectory(options, payload.remoteDirectory);
725
+ const baseDir = normalizeDirectoryPath(payload.remoteDirectory || options.filesDir);
787
726
  const name = sanitizePathSegment(payload.name, 'file');
788
727
  const relativePath = sanitizeRelativeFilePath(payload.relativePath || name, name);
789
728
  const targetPath = path.resolve(baseDir, relativePath);
790
- if (!isPathInsideRoot(baseDir, targetPath)) {
729
+ const relativeFromBase = path.relative(baseDir, targetPath);
730
+ if (relativeFromBase.startsWith('..') || path.isAbsolute(relativeFromBase)) {
791
731
  throw new Error(`Unsafe file path: ${relativePath}`);
792
732
  }
793
733
 
@@ -796,31 +736,19 @@ async function handleFileTransferChunkCommand(options, payload = {}) {
796
736
  const totalBytes = Math.max(0, Math.floor(Number(payload.totalBytes) || 0));
797
737
  const final = payload.final === true;
798
738
  const buffer = payload.dataBase64 ? Buffer.from(String(payload.dataBase64), 'base64') : Buffer.alloc(0);
799
- if (!transferId || totalBytes > MAX_FILE_TRANSFER_BYTES || offset > totalBytes || offset + buffer.length > totalBytes || buffer.length > 512 * 1024) {
739
+ if (!transferId || offset > totalBytes || offset + buffer.length > totalBytes || buffer.length > 512 * 1024) {
800
740
  throw new Error('Invalid file transfer chunk.');
801
741
  }
802
742
  if (buffer.length === 0 && !(final && totalBytes === 0 && offset === 0)) {
803
743
  throw new Error('Empty file chunks are not allowed.');
804
744
  }
805
745
 
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);
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+');
820
749
  let completedSize = 0;
821
750
  try {
822
- const before = await handle.stat();
823
- if (!before.isFile() || before.nlink !== 1) throw new Error('Unsafe file transfer staging path.');
751
+ const before = await handle.stat();
824
752
  if (offset > before.size) {
825
753
  throw new Error(`File chunk gap detected at ${offset}; current length is ${before.size}.`);
826
754
  }
@@ -839,19 +767,9 @@ async function handleFileTransferChunkCommand(options, payload = {}) {
839
767
  await handle.close();
840
768
  }
841
769
 
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
- }
770
+ if (final) {
771
+ await fs.rm(targetPath, { force: true });
772
+ await fs.rename(tempPath, targetPath);
855
773
  const lastModified = Number(payload.lastModified || 0);
856
774
  if (lastModified > 0) {
857
775
  const modifiedAt = new Date(lastModified);
@@ -1512,20 +1430,7 @@ function normalizeNodeAgentTaskResult(result) {
1512
1430
  return { ...result, ok, status: ok ? 'completed' : 'failed', error };
1513
1431
  }
1514
1432
 
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
- ]);
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']);
1529
1434
 
1530
1435
  function remotePolicyAllows(options, command) {
1531
1436
  const policy = options.effectivePolicy;
@@ -1682,8 +1587,8 @@ async function scheduleClientUpdate(options, payload = {}) {
1682
1587
  }
1683
1588
  }
1684
1589
 
1685
- async function handleRemoteCommand(socket, options, message, nextFrameSeq, activeStreams) {
1686
- const command = String(message.command || '');
1590
+ async function handleRemoteCommand(socket, options, message, nextFrameSeq, activeStreams) {
1591
+ const command = String(message.command || '');
1687
1592
  if (command === 'ping') {
1688
1593
  writeJsonLine(socket, {
1689
1594
  type: 'command.result',
@@ -1693,20 +1598,8 @@ async function handleRemoteCommand(socket, options, message, nextFrameSeq, activ
1693
1598
  at: new Date().toISOString()
1694
1599
  }
1695
1600
  });
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
- }
1601
+ return;
1602
+ }
1710
1603
 
1711
1604
  const policyDecision = remotePolicyAllows(options, command);
1712
1605
  if (!policyDecision.ok) {
@@ -2007,20 +1900,18 @@ async function handleRemoteCommand(socket, options, message, nextFrameSeq, activ
2007
1900
  });
2008
1901
  }
2009
1902
 
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 = '';
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 = '';
2024
1915
  let heartbeatTimer = null;
2025
1916
  let resolved = false;
2026
1917
  let frameSeq = 0;
@@ -2054,43 +1945,44 @@ async function connectOnce(options, deviceId) {
2054
1945
  socket.setNoDelay(true);
2055
1946
  socket.setKeepAlive(true, options.heartbeatMs);
2056
1947
 
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
- });
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
+ });
2094
1986
 
2095
1987
  socket.on('data', chunk => {
2096
1988
  buffer += chunk;