@livedesk/client 0.1.120 → 0.1.122

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.
@@ -24,4 +24,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
24
  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
25
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
26
  SOFTWARE.
27
-
@@ -5,6 +5,7 @@ import os from 'os';
5
5
  import path from 'path';
6
6
  import crypto from 'crypto';
7
7
  import { promises as fs, statfsSync } from 'fs';
8
+ import { spawn } from 'child_process';
8
9
 
9
10
  const AGENT_VERSION = '0.1.24-livedesk.1';
10
11
  const DEFAULT_MANAGER = '127.0.0.1:5197';
@@ -18,6 +19,7 @@ const MAX_FRAME_BASE64_CHARS = 3 * 1024 * 1024;
18
19
  const MAX_AI_OUTPUT_CHARS = 6000;
19
20
  const MAX_FILE_TRANSFER_FILES = 24;
20
21
  const MAX_FILE_TRANSFER_BYTES = 24 * 1024 * 1024;
22
+ const MAX_AGENT_OUTPUT_CHARS = 32000;
21
23
 
22
24
  function printHelp() {
23
25
  console.log(`
@@ -868,6 +870,233 @@ function startLiveStream(socket, options, message, nextFrameSeq, activeStreams)
868
870
  return { streamId, fps, intervalMs };
869
871
  }
870
872
 
873
+ function isSensitiveAgentPath(value) {
874
+ const normalized = String(value || '').replaceAll('\\', '/').toLowerCase();
875
+ 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');
876
+ }
877
+
878
+ function resolveAgentPath(options, value, permissionMode, rejectSensitive = true) {
879
+ const base = normalizeDirectoryPath(options.filesDir || undefined);
880
+ const text = String(value || '').replace(/\0/g, '').trim();
881
+ if (!text || text.length > 600) throw new Error('path is invalid');
882
+ const resolved = path.resolve(path.isAbsolute(text) ? text : path.join(base, text));
883
+ const relative = path.relative(base, resolved);
884
+ 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');
885
+ if (rejectSensitive && isSensitiveAgentPath(resolved)) throw new Error('credential and secret paths are not available');
886
+ return resolved;
887
+ }
888
+
889
+ function runNodeAgentProcess(executable, args, timeoutMs = 15000, cwd = undefined) {
890
+ return new Promise((resolve, reject) => {
891
+ const child = spawn(executable, args, { cwd, windowsHide: true, shell: false, stdio: ['ignore', 'pipe', 'pipe'] });
892
+ let output = '';
893
+ let timedOut = false;
894
+ const append = chunk => { output = `${output}${String(chunk || '')}`.replace(/[\0\r]/g, ' ').slice(0, MAX_AGENT_OUTPUT_CHARS); };
895
+ child.stdout.on('data', append);
896
+ child.stderr.on('data', append);
897
+ const timer = setTimeout(() => {
898
+ timedOut = true;
899
+ child.kill('SIGTERM');
900
+ setTimeout(() => child.kill('SIGKILL'), 1000).unref?.();
901
+ }, Math.max(1000, Math.min(30000, Number(timeoutMs) || 15000)));
902
+ child.once('error', error => { clearTimeout(timer); reject(error); });
903
+ child.once('close', code => { clearTimeout(timer); resolve({ output: redactAgentOutput(`${output}${timedOut ? '\n[timeout]' : ''}`.trim()), exitCode: timedOut ? -1 : code ?? -1, timedOut }); });
904
+ });
905
+ }
906
+
907
+ function redactAgentOutput(value) {
908
+ let output = String(value || '').replace(/[\0\r]/g, ' ');
909
+ output = output.replace(/\b(token|secret|password|api[-_]?key|authorization|private[-_]?key|connection[-_]?string|access[-_]?key|client[-_]?secret)\b\s*[:=]\s*("[^"]*"|'[^']*'|[^\s,;]+)/gi, '$1=[redacted]');
910
+ output = output.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [redacted]');
911
+ output = output.replace(/^(\s*(?:set\s+)?(?:[A-Z_][A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|KEY|AUTH|CREDENTIAL|CONNECTION)[A-Z0-9_]*)\s*=\s*).+$/gim, '$1[redacted]');
912
+ return output.slice(0, MAX_AGENT_OUTPUT_CHARS);
913
+ }
914
+
915
+ async function findNodeProcessExecutable(processName) {
916
+ const name = String(processName || '').trim();
917
+ if (!name) return '';
918
+ if (process.platform === 'win32') {
919
+ const result = await runNodeAgentProcess('powershell.exe', [
920
+ '-NoProfile', '-NonInteractive', '-Command',
921
+ '$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)}',
922
+ name
923
+ ], 10000);
924
+ return result.exitCode === 0 ? String(result.output || '').trim().split(/\r?\n/)[0] : '';
925
+ }
926
+ if (process.platform === 'linux') {
927
+ 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);
928
+ return result.exitCode === 0 ? String(result.output || '').trim().split(/\r?\n/)[0] : '';
929
+ }
930
+ return '';
931
+ }
932
+
933
+ async function persistNodeEnvironmentVariable(name, value) {
934
+ const variableName = String(name || '').trim();
935
+ const variableValue = String(value ?? '');
936
+ if (process.platform === 'win32') {
937
+ const result = await runNodeAgentProcess('setx.exe', [variableName, variableValue], 15000);
938
+ if (result.exitCode !== 0 || result.timedOut) throw new Error(`Persistent environment update failed (exit ${result.exitCode}).`);
939
+ process.env[variableName] = variableValue;
940
+ return { persisted: true, scope: 'user', output: result.output };
941
+ }
942
+ const profilePath = path.join(os.homedir(), '.profile');
943
+ const marker = `# LiveDesk managed environment: ${variableName}`;
944
+ const quoted = `'${variableValue.replaceAll("'", "'\\\"'\\\"'")}'`;
945
+ let profile = '';
946
+ try { profile = await fs.readFile(profilePath, 'utf8'); } catch { /* create on demand */ }
947
+ const lines = profile.split(/\r?\n/).filter(line => !line.includes(marker));
948
+ while (lines.length && lines[lines.length - 1] === '') lines.pop();
949
+ lines.push(marker, `export ${variableName}=${quoted}`);
950
+ await fs.writeFile(profilePath, `${lines.join('\n')}\n`, { encoding: 'utf8', mode: 0o600 });
951
+ process.env[variableName] = variableValue;
952
+ return { persisted: true, scope: 'user-profile', path: profilePath };
953
+ }
954
+
955
+ async function executeNodeAgentOperation(options, operation, payload = {}) {
956
+ const permissionMode = String(payload.permissionMode || 'ask');
957
+ const args = payload.toolArguments && typeof payload.toolArguments === 'object' ? payload.toolArguments : payload;
958
+ if (operation === 'file.read') {
959
+ const filePath = resolveAgentPath(options, args.path, permissionMode);
960
+ const info = await fs.stat(filePath);
961
+ const maxBytes = Math.max(1, Math.min(65536, Number(args.maxBytes) || 65536));
962
+ const content = (await fs.readFile(filePath)).subarray(0, maxBytes).toString('utf8');
963
+ 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 } };
964
+ }
965
+ if (operation === 'file.write') {
966
+ const filePath = resolveAgentPath(options, args.path, permissionMode);
967
+ const content = String(args.content || '');
968
+ if (Buffer.byteLength(content) > 1048576) throw new Error('file content exceeds the 1 MiB limit');
969
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
970
+ if (args.append === true) await fs.appendFile(filePath, content, 'utf8');
971
+ else await fs.writeFile(filePath, content, 'utf8');
972
+ return { summary: `Wrote ${Buffer.byteLength(content)} bytes to ${path.basename(filePath)}.`, data: { path: filePath, sizeBytes: (await fs.stat(filePath)).size, append: args.append === true } };
973
+ }
974
+ if (operation === 'file.delete') {
975
+ const filePath = resolveAgentPath(options, args.path, permissionMode);
976
+ if (args.recursive === true) await fs.rm(filePath, { recursive: true, force: false });
977
+ else await fs.unlink(filePath);
978
+ return { summary: `Deleted ${path.basename(filePath)}.`, data: { path: filePath, recursive: args.recursive === true } };
979
+ }
980
+ if (operation === 'file.list') {
981
+ const directory = resolveAgentPath(options, args.path, permissionMode);
982
+ const entries = await fs.readdir(directory, { withFileTypes: true });
983
+ const maxEntries = Math.max(1, Math.min(500, Number(args.maxEntries) || 200));
984
+ const data = [];
985
+ for (const entry of entries.slice(0, maxEntries)) {
986
+ const entryPath = path.join(directory, entry.name);
987
+ if (isSensitiveAgentPath(entryPath)) continue;
988
+ data.push({ name: entry.name, path: entryPath, type: entry.isDirectory() ? 'directory' : 'file', sizeBytes: entry.isDirectory() ? 0 : (await fs.stat(entryPath)).size });
989
+ }
990
+ return { summary: `Listed ${data.length} entries.`, data: { path: directory, entries: data, truncated: entries.length > maxEntries } };
991
+ }
992
+ if (operation === 'application.launch') {
993
+ const executable = String(args.executable || '').trim();
994
+ if (!executable || executable.length > 400 || /[\0\r\n]/.test(executable)) throw new Error('executable is invalid');
995
+ 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' });
996
+ child.unref();
997
+ return { summary: `Started ${path.basename(executable)}.`, data: { executable, pid: child.pid } };
998
+ }
999
+ if (operation === 'process.control') {
1000
+ const action = String(args.action || 'stop');
1001
+ const processName = String(args.processName || '').trim();
1002
+ if (action === 'restart') {
1003
+ const executable = await findNodeProcessExecutable(processName);
1004
+ if (!executable) throw new Error(`Restart is not supported because the executable path for ${processName} could not be resolved.`);
1005
+ const stop = await runNodeAgentProcess(process.platform === 'win32' ? 'taskkill' : 'pkill', process.platform === 'win32' ? ['/IM', processName, '/T', '/F'] : ['-TERM', processName]);
1006
+ if (stop.exitCode !== 0 && !stop.timedOut) throw new Error(`Process stop failed before restart (exit ${stop.exitCode}).`);
1007
+ const child = spawn(executable, [], { detached: true, windowsHide: true, stdio: 'ignore' });
1008
+ child.unref();
1009
+ return { summary: `Restarted ${processName}.`, data: { action, processName, stop, restarted: true, pid: child.pid, executable } };
1010
+ }
1011
+ const result = await runNodeAgentProcess(process.platform === 'win32' ? 'taskkill' : 'pkill', process.platform === 'win32' ? ['/IM', processName, '/T'] : ['-TERM', processName]);
1012
+ return { summary: `${action} requested for ${processName}.`, data: { action, processName, ...result } };
1013
+ }
1014
+ if (operation === 'application.close') return executeNodeAgentOperation(options, 'process.control', { ...payload, toolArguments: { ...args, action: 'stop' } });
1015
+ if (operation === 'service.control') {
1016
+ const service = String(args.serviceName || '');
1017
+ const action = String(args.action || 'status');
1018
+ const executable = process.platform === 'win32' ? 'sc.exe' : 'systemctl';
1019
+ const actions = action === 'restart' ? ['stop', 'start'] : [action];
1020
+ const results = [];
1021
+ for (const step of actions) {
1022
+ const result = await runNodeAgentProcess(executable, [step, service]);
1023
+ results.push({ action: step, ...result });
1024
+ if (result.exitCode !== 0 || result.timedOut) break;
1025
+ }
1026
+ const ok = results.length === actions.length && results.every(result => result.exitCode === 0 && !result.timedOut);
1027
+ if (!ok) throw new Error(`Service ${action} failed for ${service}.`);
1028
+ return { summary: `Service ${action} completed for ${service}.`, data: { service, action, results } };
1029
+ }
1030
+ if (operation === 'command.run') {
1031
+ const command = String(args.command || '');
1032
+ const executable = process.platform === 'win32' ? 'cmd.exe' : '/bin/sh';
1033
+ const commandArgs = process.platform === 'win32' ? ['/d', '/s', '/c', command] : ['-lc', command];
1034
+ const result = await runNodeAgentProcess(executable, commandArgs, args.timeoutMs, args.workingDirectory ? resolveAgentPath(options, args.workingDirectory, permissionMode, false) : undefined);
1035
+ return { summary: result.timedOut ? 'Command timed out.' : `Command exited with code ${result.exitCode}.`, data: { ...result, output: redactAgentOutput(result.output) } };
1036
+ }
1037
+ if (operation === 'script.run') {
1038
+ const scriptPath = resolveAgentPath(options, args.path, permissionMode);
1039
+ const executable = process.platform === 'win32' ? 'powershell.exe' : '/bin/sh';
1040
+ 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) : [])];
1041
+ const result = await runNodeAgentProcess(executable, scriptArgs, args.timeoutMs);
1042
+ return { summary: result.timedOut ? 'Script timed out.' : `Script exited with code ${result.exitCode}.`, data: { ...result, output: redactAgentOutput(result.output) } };
1043
+ }
1044
+ if (operation === 'software.install') {
1045
+ const manager = String(args.manager || '').toLowerCase();
1046
+ const packageName = String(args.packageName || '');
1047
+ if (!/^[A-Za-z0-9._@:+/-]{1,200}$/.test(packageName)) throw new Error('packageName contains unsupported characters');
1048
+ const commands = { winget: ['winget.exe', ['install', '--id', packageName, '--silent', '--accept-source-agreements', '--accept-package-agreements']], brew: ['brew', ['install', packageName]], apt: ['apt-get', ['install', '-y', packageName]], npm: ['npm', ['install', '--global', args.version ? `${packageName}@${args.version}` : packageName]] };
1049
+ if (!commands[manager]) throw new Error('unsupported package manager');
1050
+ const [executable, commandArgs] = commands[manager];
1051
+ const result = await runNodeAgentProcess(executable, commandArgs, 30000);
1052
+ return { summary: `Package install exited with code ${result.exitCode}.`, data: { manager, packageName, ...result } };
1053
+ }
1054
+ if (operation === 'network.status') return { summary: 'Network status collected.', data: Object.fromEntries(Object.entries(os.networkInterfaces()).map(([name, values]) => [name, (values || []).map(value => ({ address: value.address, family: value.family, internal: value.internal }))])) };
1055
+ if (operation === 'system.power') {
1056
+ const action = String(args.action || '');
1057
+ const delaySec = String(Math.max(0, Math.min(3600, Number(args.delaySec) || 0)));
1058
+ let executable;
1059
+ let powerArgs;
1060
+ if (process.platform === 'win32') {
1061
+ ({ executable, args: powerArgs } = {
1062
+ lock: { executable: 'rundll32.exe', args: ['user32.dll,LockWorkStation'] },
1063
+ sleep: { executable: 'rundll32.exe', args: ['powrprof.dll,SetSuspendState', '0,1,0'] },
1064
+ logoff: { executable: 'shutdown.exe', args: ['/l'] },
1065
+ restart: { executable: 'shutdown.exe', args: ['/r', '/t', delaySec] },
1066
+ shutdown: { executable: 'shutdown.exe', args: ['/s', '/t', delaySec] }
1067
+ }[action] || {});
1068
+ } else {
1069
+ ({ executable, args: powerArgs } = {
1070
+ lock: { executable: 'loginctl', args: ['lock-session'] },
1071
+ sleep: { executable: 'systemctl', args: ['suspend'] },
1072
+ logoff: { executable: 'loginctl', args: ['terminate-user', os.userInfo().username] },
1073
+ restart: { executable: 'systemctl', args: ['reboot'] },
1074
+ shutdown: { executable: 'systemctl', args: ['poweroff'] }
1075
+ }[action] || {});
1076
+ }
1077
+ if (!executable) throw new Error(`Unsupported power action: ${action}.`);
1078
+ const result = await runNodeAgentProcess(executable, powerArgs, 15000);
1079
+ return { summary: `Power action ${action} requested.`, data: result };
1080
+ }
1081
+ if (operation === 'system.configure') {
1082
+ if (args.action === 'set-environment-variable') {
1083
+ if (!/^[A-Za-z_][A-Za-z0-9_]{0,119}$/.test(String(args.name || '')) || /^(PATH|PATHEXT|SYSTEMROOT|WINDIR|COMSPEC)$/i.test(String(args.name))) throw new Error('environment variable name is not allowed');
1084
+ const persistence = await persistNodeEnvironmentVariable(String(args.name), String(args.value ?? ''));
1085
+ return { summary: `Environment variable ${args.name} was persisted.`, data: { action: args.action, name: args.name, changed: true, ...persistence } };
1086
+ }
1087
+ const result = await runNodeAgentProcess(process.platform === 'win32' ? 'tzutil.exe' : 'timedatectl', process.platform === 'win32' ? ['/s', String(args.value || '')] : ['set-timezone', String(args.value || '')]);
1088
+ return { summary: `Timezone update exited with code ${result.exitCode}.`, data: result };
1089
+ }
1090
+ if (operation === 'logs.collect') {
1091
+ const maxLines = Math.max(1, Math.min(500, Number(args.maxLines) || 100));
1092
+ const result = await runNodeAgentProcess(process.platform === 'win32' ? 'powershell.exe' : 'journalctl', process.platform === 'win32' ? ['-NoProfile', '-NonInteractive', '-Command', `Get-WinEvent -LogName System -MaxEvents ${maxLines} | Format-List`] : ['-n', String(maxLines), '--no-pager', '-o', 'short']);
1093
+ return { summary: 'Recent logs collected.', data: { source: args.source || 'system', ...result, output: result.output.replace(/(token|password|secret|api[-_]?key|authorization)\s*[:=]\s*[^\s]+/gi, '$1=[redacted]') } };
1094
+ }
1095
+ throw new Error(`Unsupported command: ${operation}`);
1096
+ }
1097
+
1098
+ 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']);
1099
+
871
1100
  async function handleRemoteCommand(socket, options, message, nextFrameSeq, activeStreams) {
872
1101
  const command = String(message.command || '');
873
1102
  if (command === 'ping') {
@@ -882,6 +1111,33 @@ async function handleRemoteCommand(socket, options, message, nextFrameSeq, activ
882
1111
  return;
883
1112
  }
884
1113
 
1114
+ if (NODE_AGENT_OPERATIONS.has(command)) {
1115
+ if (!options.taskEnabled) {
1116
+ writeJsonLine(socket, { type: 'command.result', commandId: message.commandId, error: 'remote task capability is disabled' });
1117
+ return;
1118
+ }
1119
+ try {
1120
+ const result = await executeNodeAgentOperation(options, command, message.payload || {});
1121
+ writeJsonLine(socket, {
1122
+ type: 'command.result',
1123
+ commandId: message.commandId,
1124
+ result: {
1125
+ kind: command,
1126
+ mode: String(message.payload?.permissionMode || 'ask'),
1127
+ taskId: String(message.payload?.taskId || '').slice(0, 128),
1128
+ status: 'completed',
1129
+ summary: result.summary,
1130
+ data: result.data,
1131
+ sideEffects: ['file.read', 'file.list', 'network.status', 'logs.collect'].includes(command) ? 'none' : 'audited',
1132
+ completedAt: new Date().toISOString()
1133
+ }
1134
+ });
1135
+ } catch (error) {
1136
+ writeJsonLine(socket, { type: 'command.result', commandId: message.commandId, error: error?.message || String(error) });
1137
+ }
1138
+ return;
1139
+ }
1140
+
885
1141
  if (command === 'agent.task') {
886
1142
  if (!options.taskEnabled) {
887
1143
  writeJsonLine(socket, {
@@ -1178,10 +1434,14 @@ function connectOnce(options, deviceId) {
1178
1434
  fileTransferMaxBytes: MAX_FILE_TRANSFER_BYTES,
1179
1435
  computerAgent: options.taskEnabled,
1180
1436
  taskDispatch: options.taskEnabled,
1437
+ agentApproval: options.taskEnabled,
1438
+ agentAudit: options.taskEnabled,
1439
+ agentTools: [...NODE_AGENT_OPERATIONS],
1440
+ elevation: typeof process.getuid === 'function' ? process.getuid() === 0 : false,
1181
1441
  aiAssist: options.taskEnabled && options.aiEnabled && (options.fakeAi || !!options.openAiApiKey),
1182
1442
  aiModel: options.aiModel,
1183
1443
  aiProvider: options.fakeAi ? 'fake' : (options.openAiApiKey ? 'openai' : ''),
1184
- externalEffects: false
1444
+ externalEffects: options.taskEnabled
1185
1445
  }
1186
1446
  });
1187
1447
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livedesk/client",
3
- "version": "0.1.120",
3
+ "version": "0.1.122",
4
4
  "description": "LiveDesk local remote client",
5
5
  "type": "module",
6
6
  "bin": {