@livedesk/client 0.1.123 → 0.1.124

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.
@@ -18,8 +18,9 @@ const MAX_LIVE_FPS = 30;
18
18
  const MAX_FRAME_BASE64_CHARS = 3 * 1024 * 1024;
19
19
  const MAX_AI_OUTPUT_CHARS = 6000;
20
20
  const MAX_FILE_TRANSFER_FILES = 24;
21
- const MAX_FILE_TRANSFER_BYTES = 24 * 1024 * 1024;
22
- const MAX_AGENT_OUTPUT_CHARS = 32000;
21
+ const MAX_FILE_TRANSFER_BYTES = 24 * 1024 * 1024;
22
+ const MAX_AGENT_OUTPUT_CHARS = 32000;
23
+ const launchedProcessRegistry = new Map();
23
24
 
24
25
  function printHelp() {
25
26
  console.log(`
@@ -888,21 +889,51 @@ function startLiveStream(socket, options, message, nextFrameSeq, activeStreams)
888
889
  return { streamId, fps, intervalMs };
889
890
  }
890
891
 
891
- function isSensitiveAgentPath(value) {
892
+ function isSensitiveAgentPath(value) {
892
893
  const normalized = String(value || '').replaceAll('\\', '/').toLowerCase();
893
- return normalized.includes('/.codex/') || normalized.endsWith('/auth.json') || normalized.includes('/.ssh/') || normalized.includes('/id_rsa') || normalized.endsWith('.pem') || normalized.endsWith('.key') || normalized.endsWith('/.env') || normalized.includes('/credential') || normalized.includes('/secret') || normalized.includes('/password');
894
- }
895
-
896
- function resolveAgentPath(options, value, permissionMode, rejectSensitive = true) {
897
- const base = normalizeDirectoryPath(options.filesDir || undefined);
898
- const text = String(value || '').replace(/\0/g, '').trim();
899
- if (!text || text.length > 600) throw new Error('path is invalid');
900
- const resolved = path.resolve(path.isAbsolute(text) ? text : path.join(base, text));
901
- const relative = path.relative(base, resolved);
902
- if (permissionMode !== 'full-access' && (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))) throw new Error('path is outside the LiveDesk files directory for this permission mode');
903
- if (rejectSensitive && isSensitiveAgentPath(resolved)) throw new Error('credential and secret paths are not available');
904
- return resolved;
905
- }
894
+ return normalized.includes('/.codex/') || normalized.endsWith('/auth.json') || normalized.includes('/.ssh/') || normalized.includes('/id_rsa') || normalized.endsWith('.pem') || normalized.endsWith('.key') || normalized.endsWith('/.env') || normalized.includes('/credential') || normalized.includes('/secret') || normalized.includes('/password');
895
+ }
896
+
897
+ function isPathWithinAgentRoot(root, candidate) {
898
+ const relative = path.relative(root, candidate);
899
+ return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
900
+ }
901
+
902
+ async function assertAgentPathDoesNotTraverseLink(base, resolved) {
903
+ await fs.mkdir(base, { recursive: true });
904
+ const canonicalRoot = await fs.realpath(base);
905
+ let current = resolved;
906
+ while (true) {
907
+ try {
908
+ const stat = await fs.lstat(current);
909
+ if (stat.isSymbolicLink()) {
910
+ throw new Error('Safe Agent paths cannot traverse symbolic links or junctions.');
911
+ }
912
+ const canonicalCurrent = await fs.realpath(current);
913
+ if (!isPathWithinAgentRoot(canonicalRoot, canonicalCurrent)) {
914
+ throw new Error('Safe Agent path resolves outside the LiveDesk files directory.');
915
+ }
916
+ return;
917
+ } catch (error) {
918
+ if (error?.code !== 'ENOENT') throw error;
919
+ const parent = path.dirname(current);
920
+ if (parent === current) throw new Error('Safe Agent path could not be verified.');
921
+ current = parent;
922
+ }
923
+ }
924
+ }
925
+
926
+ async function resolveAgentPath(options, value, permissionMode, rejectSensitive = true) {
927
+ const base = normalizeDirectoryPath(options.filesDir || undefined);
928
+ const text = String(value || '').replace(/\0/g, '').trim();
929
+ if (!text || text.length > 600) throw new Error('path is invalid');
930
+ const resolved = path.resolve(path.isAbsolute(text) ? text : path.join(base, text));
931
+ const relative = path.relative(base, resolved);
932
+ if (permissionMode !== 'full-access' && (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))) throw new Error('path is outside the LiveDesk files directory for this permission mode');
933
+ if (permissionMode !== 'full-access') await assertAgentPathDoesNotTraverseLink(base, resolved);
934
+ if (rejectSensitive && isSensitiveAgentPath(resolved)) throw new Error('credential and secret paths are not available');
935
+ return resolved;
936
+ }
906
937
 
907
938
  function runNodeAgentProcess(executable, args, timeoutMs = 15000, cwd = undefined) {
908
939
  return new Promise((resolve, reject) => {
@@ -930,23 +961,111 @@ function redactAgentOutput(value) {
930
961
  return output.slice(0, MAX_AGENT_OUTPUT_CHARS);
931
962
  }
932
963
 
933
- async function findNodeProcessExecutable(processName) {
934
- const name = String(processName || '').trim();
935
- if (!name) return '';
936
- if (process.platform === 'win32') {
937
- const result = await runNodeAgentProcess('powershell.exe', [
938
- '-NoProfile', '-NonInteractive', '-Command',
939
- '$name=$args[0]; $p=Get-CimInstance Win32_Process | Where-Object { $_.Name -ieq $name } | Select-Object -First 1 -ExpandProperty ExecutablePath; if($p){[Console]::Out.Write($p)}',
940
- name
941
- ], 10000);
942
- return result.exitCode === 0 ? String(result.output || '').trim().split(/\r?\n/)[0] : '';
943
- }
944
- if (process.platform === 'linux') {
945
- const result = await runNodeAgentProcess('/bin/sh', ['-lc', 'pid=$(pgrep -xo -- "$1" || true); if [ -n "$pid" ] && [ -e "/proc/$pid/exe" ]; then readlink -f "/proc/$pid/exe"; fi', 'livedesk-process-path', name], 10000);
946
- return result.exitCode === 0 ? String(result.output || '').trim().split(/\r?\n/)[0] : '';
947
- }
948
- return '';
949
- }
964
+ function normalizeNodeProcessKey(value) {
965
+ return path.basename(String(value || '').trim()).toLowerCase().replace(/\.(exe|bin)$/i, '');
966
+ }
967
+
968
+ function parseWindowsCommandLine(commandLine) {
969
+ const text = String(commandLine || '').trim();
970
+ const parsed = [];
971
+ let index = 0;
972
+ while (index < text.length) {
973
+ while (/\s/.test(text[index] || '')) index += 1;
974
+ if (index >= text.length) break;
975
+ let value = '';
976
+ let quoted = false;
977
+ while (index < text.length) {
978
+ const character = text[index];
979
+ if (character === '\\') {
980
+ let slashCount = 0;
981
+ while (text[index + slashCount] === '\\') slashCount += 1;
982
+ const next = text[index + slashCount];
983
+ if (next === '"') {
984
+ value += '\\'.repeat(Math.floor(slashCount / 2));
985
+ index += slashCount;
986
+ if (slashCount % 2 === 1) {
987
+ value += '"';
988
+ index += 1;
989
+ } else {
990
+ quoted = !quoted;
991
+ index += 1;
992
+ }
993
+ } else {
994
+ value += '\\'.repeat(slashCount);
995
+ index += slashCount;
996
+ }
997
+ continue;
998
+ }
999
+ if (character === '"') {
1000
+ quoted = !quoted;
1001
+ index += 1;
1002
+ continue;
1003
+ }
1004
+ if (/\s/.test(character) && !quoted) break;
1005
+ value += character;
1006
+ index += 1;
1007
+ }
1008
+ parsed.push(value);
1009
+ while (/\s/.test(text[index] || '')) index += 1;
1010
+ }
1011
+ return parsed;
1012
+ }
1013
+
1014
+ function decodeUtf16Base64(value) {
1015
+ try {
1016
+ return Buffer.from(String(value || ''), 'base64').toString('utf16le');
1017
+ } catch {
1018
+ return '';
1019
+ }
1020
+ }
1021
+
1022
+ function decodeNullSeparatedBase64(value) {
1023
+ try {
1024
+ return Buffer.from(String(value || ''), 'base64').toString('utf8');
1025
+ } catch {
1026
+ return '';
1027
+ }
1028
+ }
1029
+
1030
+ async function findNodeProcessDetails(processName) {
1031
+ const name = String(processName || '').trim();
1032
+ if (!name) return null;
1033
+ if (process.platform === 'win32') {
1034
+ const result = await runNodeAgentProcess('powershell.exe', [
1035
+ '-NoProfile', '-NonInteractive', '-Command',
1036
+ '$name=$args[0]; $p=Get-CimInstance Win32_Process | Where-Object { $_.Name -ieq $name } | Select-Object -First 1; if($p){ $bytes=[Text.Encoding]::Unicode.GetBytes([string]$p.CommandLine); [Console]::Out.WriteLine([string]$p.ProcessId); [Console]::Out.WriteLine([string]$p.ExecutablePath); [Console]::Out.Write([Convert]::ToBase64String($bytes)) }',
1037
+ name
1038
+ ], 10000);
1039
+ if (result.exitCode !== 0) return null;
1040
+ const lines = String(result.output || '').trim().split(/\r?\n/);
1041
+ const executable = String(lines[1] || '').trim();
1042
+ if (!lines[0] || !executable) return null;
1043
+ const commandLine = decodeUtf16Base64(lines[2]);
1044
+ const parsed = parseWindowsCommandLine(commandLine);
1045
+ return { pid: Number(lines[0]) || 0, executable, args: parsed.length > 1 ? parsed.slice(1) : null, workingDirectory: null, metadataSource: 'process-command-line' };
1046
+ }
1047
+ if (process.platform === 'linux') {
1048
+ const result = await runNodeAgentProcess('/bin/sh', ['-lc', 'pid=$(pgrep -xo -- "$1" || true); if [ -n "$pid" ] && [ -e "/proc/$pid/exe" ]; then printf "%s\\n%s\\n%s\\n" "$pid" "$(readlink -f "/proc/$pid/exe")" "$(readlink -f "/proc/$pid/cwd")"; base64 -w0 "/proc/$pid/cmdline"; fi', 'livedesk-process-details', name], 10000);
1049
+ if (result.exitCode !== 0) return null;
1050
+ const lines = String(result.output || '').trim().split(/\r?\n/);
1051
+ const executable = String(lines[1] || '').trim();
1052
+ const workingDirectory = String(lines[2] || '').trim();
1053
+ if (!lines[0] || !executable) return null;
1054
+ const parsed = decodeNullSeparatedBase64(lines[3]).split('\0').filter(Boolean);
1055
+ return { pid: Number(lines[0]) || 0, executable, args: parsed.length > 1 ? parsed.slice(1) : null, workingDirectory: workingDirectory || null, metadataSource: 'proc' };
1056
+ }
1057
+ if (process.platform === 'darwin') {
1058
+ const result = await runNodeAgentProcess('/bin/sh', ['-lc', 'pid=$(pgrep -xo -- "$1" || true); if [ -n "$pid" ]; then exe=$(ps -p "$pid" -o comm= | sed "s/^ *//"); cwd=$(lsof -a -p "$pid" -d cwd -Fn 2>/dev/null | sed -n "s/^n//p" | head -n 1); command=$(ps -p "$pid" -o command=); printf "%s\\n%s\\n%s\\n" "$pid" "$exe" "$cwd"; printf "%s" "$command" | base64 | tr -d "\\n"; fi', 'livedesk-process-details', name], 10000);
1059
+ if (result.exitCode !== 0) return null;
1060
+ const lines = String(result.output || '').trim().split(/\r?\n/);
1061
+ const executable = String(lines[1] || '').trim();
1062
+ const workingDirectory = String(lines[2] || '').trim();
1063
+ if (!lines[0] || !executable) return null;
1064
+ const parsed = parseWindowsCommandLine(Buffer.from(String(lines[3] || ''), 'base64').toString('utf8'));
1065
+ return { pid: Number(lines[0]) || 0, executable, args: parsed.length > 1 ? parsed.slice(1) : null, workingDirectory: workingDirectory || null, metadataSource: 'ps-lsof' };
1066
+ }
1067
+ return null;
1068
+ }
950
1069
 
951
1070
  async function persistNodeEnvironmentVariable(name, value) {
952
1071
  const variableName = String(name || '').trim();
@@ -972,31 +1091,34 @@ async function persistNodeEnvironmentVariable(name, value) {
972
1091
 
973
1092
  async function executeNodeAgentOperation(options, operation, payload = {}) {
974
1093
  const permissionMode = String(payload.permissionMode || 'ask');
975
- const args = payload.toolArguments && typeof payload.toolArguments === 'object' ? payload.toolArguments : payload;
976
- if (operation === 'file.read') {
977
- const filePath = resolveAgentPath(options, args.path, permissionMode);
1094
+ const args = payload.toolArguments && typeof payload.toolArguments === 'object' ? payload.toolArguments : payload;
1095
+ if (operation === 'file.read') {
1096
+ const filePath = await resolveAgentPath(options, args.path, permissionMode);
978
1097
  const info = await fs.stat(filePath);
979
1098
  const maxBytes = Math.max(1, Math.min(65536, Number(args.maxBytes) || 65536));
980
1099
  const content = (await fs.readFile(filePath)).subarray(0, maxBytes).toString('utf8');
981
1100
  return { summary: `Read ${Buffer.byteLength(content)} bytes from ${path.basename(filePath)}.`, data: { path: filePath, sizeBytes: info.size, returnedBytes: Buffer.byteLength(content), truncated: info.size > Buffer.byteLength(content), content } };
982
- }
983
- if (operation === 'file.write') {
984
- const filePath = resolveAgentPath(options, args.path, permissionMode);
985
- const content = String(args.content || '');
986
- if (Buffer.byteLength(content) > 1048576) throw new Error('file content exceeds the 1 MiB limit');
987
- await fs.mkdir(path.dirname(filePath), { recursive: true });
988
- if (args.append === true) await fs.appendFile(filePath, content, 'utf8');
1101
+ }
1102
+ if (operation === 'file.write') {
1103
+ let filePath = await resolveAgentPath(options, args.path, permissionMode);
1104
+ const content = String(args.content || '');
1105
+ if (Buffer.byteLength(content) > 1048576) throw new Error('file content exceeds the 1 MiB limit');
1106
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
1107
+ // Re-check after creating missing parents so a newly introduced link
1108
+ // cannot turn the write into an outside-root operation.
1109
+ filePath = await resolveAgentPath(options, args.path, permissionMode);
1110
+ if (args.append === true) await fs.appendFile(filePath, content, 'utf8');
989
1111
  else await fs.writeFile(filePath, content, 'utf8');
990
1112
  return { summary: `Wrote ${Buffer.byteLength(content)} bytes to ${path.basename(filePath)}.`, data: { path: filePath, sizeBytes: (await fs.stat(filePath)).size, append: args.append === true } };
991
1113
  }
992
- if (operation === 'file.delete') {
993
- const filePath = resolveAgentPath(options, args.path, permissionMode);
1114
+ if (operation === 'file.delete') {
1115
+ const filePath = await resolveAgentPath(options, args.path, permissionMode);
994
1116
  if (args.recursive === true) await fs.rm(filePath, { recursive: true, force: false });
995
1117
  else await fs.unlink(filePath);
996
1118
  return { summary: `Deleted ${path.basename(filePath)}.`, data: { path: filePath, recursive: args.recursive === true } };
997
1119
  }
998
- if (operation === 'file.list') {
999
- const directory = resolveAgentPath(options, args.path, permissionMode);
1120
+ if (operation === 'file.list') {
1121
+ const directory = await resolveAgentPath(options, args.path, permissionMode);
1000
1122
  const entries = await fs.readdir(directory, { withFileTypes: true });
1001
1123
  const maxEntries = Math.max(1, Math.min(500, Number(args.maxEntries) || 200));
1002
1124
  const data = [];
@@ -1006,26 +1128,36 @@ async function executeNodeAgentOperation(options, operation, payload = {}) {
1006
1128
  data.push({ name: entry.name, path: entryPath, type: entry.isDirectory() ? 'directory' : 'file', sizeBytes: entry.isDirectory() ? 0 : (await fs.stat(entryPath)).size });
1007
1129
  }
1008
1130
  return { summary: `Listed ${data.length} entries.`, data: { path: directory, entries: data, truncated: entries.length > maxEntries } };
1009
- }
1010
- if (operation === 'application.launch') {
1011
- const executable = String(args.executable || '').trim();
1012
- if (!executable || executable.length > 400 || /[\0\r\n]/.test(executable)) throw new Error('executable is invalid');
1013
- const child = spawn(executable, Array.isArray(args.args) ? args.args.slice(0, 32).map(String) : [], { cwd: args.workingDirectory ? resolveAgentPath(options, args.workingDirectory, permissionMode, false) : undefined, detached: true, windowsHide: true, stdio: 'ignore' });
1014
- child.unref();
1015
- return { summary: `Started ${path.basename(executable)}.`, data: { executable, pid: child.pid } };
1016
- }
1017
- if (operation === 'process.control') {
1018
- const action = String(args.action || 'stop');
1019
- const processName = String(args.processName || '').trim();
1020
- if (action === 'restart') {
1021
- const executable = await findNodeProcessExecutable(processName);
1022
- if (!executable) throw new Error(`Restart is not supported because the executable path for ${processName} could not be resolved.`);
1023
- const stop = await runNodeAgentProcess(process.platform === 'win32' ? 'taskkill' : 'pkill', process.platform === 'win32' ? ['/IM', processName, '/T', '/F'] : ['-TERM', processName]);
1024
- if (stop.exitCode !== 0 && !stop.timedOut) throw new Error(`Process stop failed before restart (exit ${stop.exitCode}).`);
1025
- const child = spawn(executable, [], { detached: true, windowsHide: true, stdio: 'ignore' });
1026
- child.unref();
1027
- return { summary: `Restarted ${processName}.`, data: { action, processName, stop, restarted: true, pid: child.pid, executable } };
1028
- }
1131
+ }
1132
+ if (operation === 'application.launch') {
1133
+ const executable = String(args.executable || '').trim();
1134
+ if (!executable || executable.length > 400 || /[\0\r\n]/.test(executable)) throw new Error('executable is invalid');
1135
+ const launchArgs = Array.isArray(args.args) ? args.args.slice(0, 32).map(String) : [];
1136
+ const workingDirectory = args.workingDirectory ? await resolveAgentPath(options, args.workingDirectory, permissionMode, false) : process.cwd();
1137
+ const child = spawn(executable, launchArgs, { cwd: workingDirectory, detached: true, windowsHide: true, stdio: 'ignore' });
1138
+ child.unref();
1139
+ launchedProcessRegistry.set(normalizeNodeProcessKey(executable), { executable, args: launchArgs, workingDirectory, metadataSource: 'launch_application' });
1140
+ return { summary: `Started ${path.basename(executable)}.`, data: { executable, args: launchArgs, workingDirectory, pid: child.pid } };
1141
+ }
1142
+ if (operation === 'process.control') {
1143
+ const action = String(args.action || 'stop');
1144
+ const processName = String(args.processName || '').trim();
1145
+ const processDetails = await findNodeProcessDetails(processName);
1146
+ if (!processDetails) {
1147
+ if (action === 'restart') throw new Error(`process-not-found: ${processName}`);
1148
+ return { summary: `${processName} is already stopped.`, data: { action, processName, status: 'already-stopped', ok: true } };
1149
+ }
1150
+ if (action === 'restart') {
1151
+ const restartMetadata = launchedProcessRegistry.get(normalizeNodeProcessKey(processName)) || processDetails;
1152
+ if (!restartMetadata.executable || !Array.isArray(restartMetadata.args) || !restartMetadata.workingDirectory) {
1153
+ throw new Error(`process-restart-metadata-unavailable: ${processName}`);
1154
+ }
1155
+ const stop = await runNodeAgentProcess(process.platform === 'win32' ? 'taskkill' : 'pkill', process.platform === 'win32' ? ['/IM', processName, '/T', '/F'] : ['-TERM', processName]);
1156
+ if (stop.exitCode !== 0 || stop.timedOut) throw new Error(`Process stop failed before restart (exit ${stop.exitCode}).`);
1157
+ const child = spawn(restartMetadata.executable, restartMetadata.args, { cwd: restartMetadata.workingDirectory, detached: true, windowsHide: true, stdio: 'ignore' });
1158
+ child.unref();
1159
+ return { summary: `Restarted ${processName}.`, data: { action, processName, stop, restarted: true, pid: child.pid, executable: restartMetadata.executable, args: restartMetadata.args, workingDirectory: restartMetadata.workingDirectory, metadataSource: restartMetadata.metadataSource } };
1160
+ }
1029
1161
  const result = await runNodeAgentProcess(process.platform === 'win32' ? 'taskkill' : 'pkill', process.platform === 'win32' ? ['/IM', processName, '/T'] : ['-TERM', processName]);
1030
1162
  return { summary: `${action} requested for ${processName}.`, data: { action, processName, ...result } };
1031
1163
  }
@@ -1054,11 +1186,11 @@ async function executeNodeAgentOperation(options, operation, payload = {}) {
1054
1186
  const command = String(args.command || '');
1055
1187
  const executable = process.platform === 'win32' ? 'cmd.exe' : '/bin/sh';
1056
1188
  const commandArgs = process.platform === 'win32' ? ['/d', '/s', '/c', command] : ['-lc', command];
1057
- const result = await runNodeAgentProcess(executable, commandArgs, args.timeoutMs, args.workingDirectory ? resolveAgentPath(options, args.workingDirectory, permissionMode, false) : undefined);
1189
+ const result = await runNodeAgentProcess(executable, commandArgs, args.timeoutMs, args.workingDirectory ? await resolveAgentPath(options, args.workingDirectory, permissionMode, false) : undefined);
1058
1190
  return { summary: result.timedOut ? 'Command timed out.' : `Command exited with code ${result.exitCode}.`, data: { ...result, output: redactAgentOutput(result.output) } };
1059
1191
  }
1060
1192
  if (operation === 'script.run') {
1061
- const scriptPath = resolveAgentPath(options, args.path, permissionMode);
1193
+ const scriptPath = await resolveAgentPath(options, args.path, permissionMode);
1062
1194
  const executable = process.platform === 'win32' ? 'powershell.exe' : '/bin/sh';
1063
1195
  const scriptArgs = process.platform === 'win32' ? ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...(Array.isArray(args.args) ? args.args.slice(0, 32).map(String) : [])] : [scriptPath, ...(Array.isArray(args.args) ? args.args.slice(0, 32).map(String) : [])];
1064
1196
  const result = await runNodeAgentProcess(executable, scriptArgs, args.timeoutMs);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livedesk/client",
3
- "version": "0.1.123",
3
+ "version": "0.1.124",
4
4
  "description": "LiveDesk local remote client",
5
5
  "type": "module",
6
6
  "bin": {