start-command 0.9.0 → 0.10.0
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/CHANGELOG.md +36 -0
- package/README.md +49 -9
- package/REQUIREMENTS.md +48 -2
- package/experiments/user-isolation-research.md +83 -0
- package/package.json +1 -1
- package/src/bin/cli.js +131 -44
- package/src/lib/args-parser.js +62 -0
- package/src/lib/isolation.js +96 -40
- package/src/lib/user-manager.js +429 -0
- package/test/args-parser.test.js +179 -0
- package/test/isolation.test.js +33 -0
- package/test/user-manager.test.js +286 -0
package/src/lib/isolation.js
CHANGED
|
@@ -131,6 +131,22 @@ function hasTTY() {
|
|
|
131
131
|
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Wrap command with sudo -u if user option is specified
|
|
136
|
+
* @param {string} command - Original command
|
|
137
|
+
* @param {string|null} user - Username to run as (or null)
|
|
138
|
+
* @returns {string} Wrapped command
|
|
139
|
+
*/
|
|
140
|
+
function wrapCommandWithUser(command, user) {
|
|
141
|
+
if (!user) {
|
|
142
|
+
return command;
|
|
143
|
+
}
|
|
144
|
+
// Use sudo -u to run command as specified user
|
|
145
|
+
// -E preserves environment variables
|
|
146
|
+
// -n ensures non-interactive (fails if password required)
|
|
147
|
+
return `sudo -n -u ${user} sh -c '${command.replace(/'/g, "'\\''")}'`;
|
|
148
|
+
}
|
|
149
|
+
|
|
134
150
|
/**
|
|
135
151
|
* Run command in GNU Screen using detached mode with log capture
|
|
136
152
|
* This is a workaround for environments without TTY
|
|
@@ -142,9 +158,10 @@ function hasTTY() {
|
|
|
142
158
|
* @param {string} command - Command to execute
|
|
143
159
|
* @param {string} sessionName - Session name
|
|
144
160
|
* @param {object} shellInfo - Shell info from getShell()
|
|
161
|
+
* @param {string|null} user - Username to run command as (optional)
|
|
145
162
|
* @returns {Promise<{success: boolean, sessionName: string, message: string, output: string}>}
|
|
146
163
|
*/
|
|
147
|
-
function runScreenWithLogCapture(command, sessionName, shellInfo) {
|
|
164
|
+
function runScreenWithLogCapture(command, sessionName, shellInfo, user = null) {
|
|
148
165
|
const { shell, shellArg } = shellInfo;
|
|
149
166
|
const logFile = path.join(os.tmpdir(), `screen-output-${sessionName}.log`);
|
|
150
167
|
|
|
@@ -154,7 +171,8 @@ function runScreenWithLogCapture(command, sessionName, shellInfo) {
|
|
|
154
171
|
return new Promise((resolve) => {
|
|
155
172
|
try {
|
|
156
173
|
let screenArgs;
|
|
157
|
-
|
|
174
|
+
// Wrap command with user switch if specified
|
|
175
|
+
let effectiveCommand = wrapCommandWithUser(command, user);
|
|
158
176
|
|
|
159
177
|
if (useNativeLogging) {
|
|
160
178
|
// Modern screen (>= 4.5.1): Use -L -Logfile option for native log capture
|
|
@@ -167,7 +185,7 @@ function runScreenWithLogCapture(command, sessionName, shellInfo) {
|
|
|
167
185
|
logFile,
|
|
168
186
|
shell,
|
|
169
187
|
shellArg,
|
|
170
|
-
|
|
188
|
+
effectiveCommand,
|
|
171
189
|
];
|
|
172
190
|
|
|
173
191
|
if (DEBUG) {
|
|
@@ -179,7 +197,7 @@ function runScreenWithLogCapture(command, sessionName, shellInfo) {
|
|
|
179
197
|
// Older screen (< 4.5.1, e.g., macOS bundled 4.0.3): Use tee fallback
|
|
180
198
|
// Wrap the command to capture output using tee
|
|
181
199
|
// The parentheses ensure proper grouping of the command and its stderr
|
|
182
|
-
effectiveCommand = `(${
|
|
200
|
+
effectiveCommand = `(${effectiveCommand}) 2>&1 | tee "${logFile}"`;
|
|
183
201
|
screenArgs = ['-dmS', sessionName, shell, shellArg, effectiveCommand];
|
|
184
202
|
|
|
185
203
|
if (DEBUG) {
|
|
@@ -299,7 +317,7 @@ function runScreenWithLogCapture(command, sessionName, shellInfo) {
|
|
|
299
317
|
/**
|
|
300
318
|
* Run command in GNU Screen
|
|
301
319
|
* @param {string} command - Command to execute
|
|
302
|
-
* @param {object} options - Options (session, detached, keepAlive)
|
|
320
|
+
* @param {object} options - Options (session, detached, user, keepAlive)
|
|
303
321
|
* @returns {Promise<{success: boolean, sessionName: string, message: string}>}
|
|
304
322
|
*/
|
|
305
323
|
function runInScreen(command, options = {}) {
|
|
@@ -317,16 +335,17 @@ function runInScreen(command, options = {}) {
|
|
|
317
335
|
const { shell, shellArg } = shellInfo;
|
|
318
336
|
|
|
319
337
|
try {
|
|
338
|
+
// Wrap command with user switch if specified
|
|
339
|
+
let effectiveCommand = wrapCommandWithUser(command, options.user);
|
|
340
|
+
|
|
320
341
|
if (options.detached) {
|
|
321
342
|
// Detached mode: screen -dmS <session> <shell> -c '<command>'
|
|
322
343
|
// By default (keepAlive=false), the session will exit after command completes
|
|
323
344
|
// With keepAlive=true, we start a shell that runs the command but stays alive
|
|
324
|
-
let effectiveCommand = command;
|
|
325
345
|
|
|
326
346
|
if (options.keepAlive) {
|
|
327
347
|
// With keep-alive: run command, then keep shell open
|
|
328
|
-
|
|
329
|
-
effectiveCommand = `${command}; exec ${shell}`;
|
|
348
|
+
effectiveCommand = `${effectiveCommand}; exec ${shell}`;
|
|
330
349
|
}
|
|
331
350
|
// Without keep-alive: command runs and session exits naturally when done
|
|
332
351
|
|
|
@@ -383,7 +402,12 @@ function runInScreen(command, options = {}) {
|
|
|
383
402
|
);
|
|
384
403
|
}
|
|
385
404
|
|
|
386
|
-
return runScreenWithLogCapture(
|
|
405
|
+
return runScreenWithLogCapture(
|
|
406
|
+
command,
|
|
407
|
+
sessionName,
|
|
408
|
+
shellInfo,
|
|
409
|
+
options.user
|
|
410
|
+
);
|
|
387
411
|
}
|
|
388
412
|
} catch (err) {
|
|
389
413
|
return Promise.resolve({
|
|
@@ -397,7 +421,7 @@ function runInScreen(command, options = {}) {
|
|
|
397
421
|
/**
|
|
398
422
|
* Run command in tmux
|
|
399
423
|
* @param {string} command - Command to execute
|
|
400
|
-
* @param {object} options - Options (session, detached, keepAlive)
|
|
424
|
+
* @param {object} options - Options (session, detached, user, keepAlive)
|
|
401
425
|
* @returns {Promise<{success: boolean, sessionName: string, message: string}>}
|
|
402
426
|
*/
|
|
403
427
|
function runInTmux(command, options = {}) {
|
|
@@ -414,16 +438,18 @@ function runInTmux(command, options = {}) {
|
|
|
414
438
|
const shellInfo = getShell();
|
|
415
439
|
const { shell } = shellInfo;
|
|
416
440
|
|
|
441
|
+
// Wrap command with user switch if specified
|
|
442
|
+
let effectiveCommand = wrapCommandWithUser(command, options.user);
|
|
443
|
+
|
|
417
444
|
try {
|
|
418
445
|
if (options.detached) {
|
|
419
446
|
// Detached mode: tmux new-session -d -s <session> '<command>'
|
|
420
447
|
// By default (keepAlive=false), the session will exit after command completes
|
|
421
448
|
// With keepAlive=true, we keep the shell alive after the command
|
|
422
|
-
let effectiveCommand = command;
|
|
423
449
|
|
|
424
450
|
if (options.keepAlive) {
|
|
425
451
|
// With keep-alive: run command, then keep shell open
|
|
426
|
-
effectiveCommand = `${
|
|
452
|
+
effectiveCommand = `${effectiveCommand}; exec ${shell}`;
|
|
427
453
|
}
|
|
428
454
|
// Without keep-alive: command runs and session exits naturally when done
|
|
429
455
|
|
|
@@ -458,14 +484,14 @@ function runInTmux(command, options = {}) {
|
|
|
458
484
|
// Attached mode: tmux new-session -s <session> '<command>'
|
|
459
485
|
if (DEBUG) {
|
|
460
486
|
console.log(
|
|
461
|
-
`[DEBUG] Running: tmux new-session -s "${sessionName}" "${
|
|
487
|
+
`[DEBUG] Running: tmux new-session -s "${sessionName}" "${effectiveCommand}"`
|
|
462
488
|
);
|
|
463
489
|
}
|
|
464
490
|
|
|
465
491
|
return new Promise((resolve) => {
|
|
466
492
|
const child = spawn(
|
|
467
493
|
'tmux',
|
|
468
|
-
['new-session', '-s', sessionName,
|
|
494
|
+
['new-session', '-s', sessionName, effectiveCommand],
|
|
469
495
|
{
|
|
470
496
|
stdio: 'inherit',
|
|
471
497
|
}
|
|
@@ -501,7 +527,7 @@ function runInTmux(command, options = {}) {
|
|
|
501
527
|
/**
|
|
502
528
|
* Run command in Docker container
|
|
503
529
|
* @param {string} command - Command to execute
|
|
504
|
-
* @param {object} options - Options (image, session/name, detached, keepAlive, autoRemoveDockerContainer)
|
|
530
|
+
* @param {object} options - Options (image, session/name, detached, user, keepAlive, autoRemoveDockerContainer)
|
|
505
531
|
* @returns {Promise<{success: boolean, containerName: string, message: string}>}
|
|
506
532
|
*/
|
|
507
533
|
function runInDocker(command, options = {}) {
|
|
@@ -526,7 +552,7 @@ function runInDocker(command, options = {}) {
|
|
|
526
552
|
|
|
527
553
|
try {
|
|
528
554
|
if (options.detached) {
|
|
529
|
-
// Detached mode: docker run -d --name <name> <image> <shell> -c '<command>'
|
|
555
|
+
// Detached mode: docker run -d --name <name> [--user <user>] <image> <shell> -c '<command>'
|
|
530
556
|
// By default (keepAlive=false), the container exits after command completes
|
|
531
557
|
// With keepAlive=true, we keep the container running with a shell
|
|
532
558
|
let effectiveCommand = command;
|
|
@@ -537,16 +563,7 @@ function runInDocker(command, options = {}) {
|
|
|
537
563
|
}
|
|
538
564
|
// Without keep-alive: container exits naturally when command completes
|
|
539
565
|
|
|
540
|
-
const dockerArgs = [
|
|
541
|
-
'run',
|
|
542
|
-
'-d',
|
|
543
|
-
'--name',
|
|
544
|
-
containerName,
|
|
545
|
-
options.image,
|
|
546
|
-
'/bin/sh',
|
|
547
|
-
'-c',
|
|
548
|
-
effectiveCommand,
|
|
549
|
-
];
|
|
566
|
+
const dockerArgs = ['run', '-d', '--name', containerName];
|
|
550
567
|
|
|
551
568
|
// Add --rm flag if autoRemoveDockerContainer is true
|
|
552
569
|
// Note: --rm must come before the image name
|
|
@@ -554,6 +571,13 @@ function runInDocker(command, options = {}) {
|
|
|
554
571
|
dockerArgs.splice(2, 0, '--rm');
|
|
555
572
|
}
|
|
556
573
|
|
|
574
|
+
// Add --user flag if specified
|
|
575
|
+
if (options.user) {
|
|
576
|
+
dockerArgs.push('--user', options.user);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
dockerArgs.push(options.image, '/bin/sh', '-c', effectiveCommand);
|
|
580
|
+
|
|
557
581
|
if (DEBUG) {
|
|
558
582
|
console.log(`[DEBUG] Running: docker ${dockerArgs.join(' ')}`);
|
|
559
583
|
console.log(`[DEBUG] keepAlive: ${options.keepAlive || false}`);
|
|
@@ -588,18 +612,15 @@ function runInDocker(command, options = {}) {
|
|
|
588
612
|
message,
|
|
589
613
|
});
|
|
590
614
|
} else {
|
|
591
|
-
// Attached mode: docker run -it --name <name> <image> <shell> -c '<command>'
|
|
592
|
-
const dockerArgs = [
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
'--
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
'-c',
|
|
601
|
-
command,
|
|
602
|
-
];
|
|
615
|
+
// Attached mode: docker run -it --name <name> [--user <user>] <image> <shell> -c '<command>'
|
|
616
|
+
const dockerArgs = ['run', '-it', '--rm', '--name', containerName];
|
|
617
|
+
|
|
618
|
+
// Add --user flag if specified
|
|
619
|
+
if (options.user) {
|
|
620
|
+
dockerArgs.push('--user', options.user);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
dockerArgs.push(options.image, '/bin/sh', '-c', command);
|
|
603
624
|
|
|
604
625
|
if (DEBUG) {
|
|
605
626
|
console.log(`[DEBUG] Running: docker ${dockerArgs.join(' ')}`);
|
|
@@ -687,6 +708,7 @@ function generateLogFilename(environment) {
|
|
|
687
708
|
* @param {string} params.mode - attached or detached
|
|
688
709
|
* @param {string} params.sessionName - Session/container name
|
|
689
710
|
* @param {string} [params.image] - Docker image (for docker environment)
|
|
711
|
+
* @param {string} [params.user] - User to run command as (optional)
|
|
690
712
|
* @param {string} params.startTime - Start timestamp
|
|
691
713
|
* @returns {string} Log header content
|
|
692
714
|
*/
|
|
@@ -700,6 +722,9 @@ function createLogHeader(params) {
|
|
|
700
722
|
if (params.image) {
|
|
701
723
|
content += `Image: ${params.image}\n`;
|
|
702
724
|
}
|
|
725
|
+
if (params.user) {
|
|
726
|
+
content += `User: ${params.user}\n`;
|
|
727
|
+
}
|
|
703
728
|
content += `Platform: ${process.platform}\n`;
|
|
704
729
|
content += `Node Version: ${process.version}\n`;
|
|
705
730
|
content += `Working Directory: ${process.cwd()}\n`;
|
|
@@ -763,6 +788,37 @@ function resetScreenVersionCache() {
|
|
|
763
788
|
screenVersionChecked = false;
|
|
764
789
|
}
|
|
765
790
|
|
|
791
|
+
/**
|
|
792
|
+
* Run command as an isolated user (without isolation backend)
|
|
793
|
+
* Uses sudo -u to switch users
|
|
794
|
+
* @param {string} cmd - Command to execute
|
|
795
|
+
* @param {string} username - User to run as
|
|
796
|
+
* @returns {Promise<{success: boolean, message: string, exitCode: number}>}
|
|
797
|
+
*/
|
|
798
|
+
function runAsIsolatedUser(cmd, username) {
|
|
799
|
+
return new Promise((resolve) => {
|
|
800
|
+
const child = spawn('sudo', ['-n', '-u', username, 'sh', '-c', cmd], {
|
|
801
|
+
stdio: 'inherit',
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
child.on('exit', (code) => {
|
|
805
|
+
resolve({
|
|
806
|
+
success: code === 0,
|
|
807
|
+
message: `Command completed as user "${username}" with exit code ${code}`,
|
|
808
|
+
exitCode: code || 0,
|
|
809
|
+
});
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
child.on('error', (err) => {
|
|
813
|
+
resolve({
|
|
814
|
+
success: false,
|
|
815
|
+
message: `Failed to run as user "${username}": ${err.message}`,
|
|
816
|
+
exitCode: 1,
|
|
817
|
+
});
|
|
818
|
+
});
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
|
|
766
822
|
module.exports = {
|
|
767
823
|
isCommandAvailable,
|
|
768
824
|
hasTTY,
|
|
@@ -770,7 +826,8 @@ module.exports = {
|
|
|
770
826
|
runInTmux,
|
|
771
827
|
runInDocker,
|
|
772
828
|
runIsolated,
|
|
773
|
-
|
|
829
|
+
runAsIsolatedUser,
|
|
830
|
+
wrapCommandWithUser,
|
|
774
831
|
getTimestamp,
|
|
775
832
|
generateLogFilename,
|
|
776
833
|
createLogHeader,
|
|
@@ -778,7 +835,6 @@ module.exports = {
|
|
|
778
835
|
writeLogFile,
|
|
779
836
|
getLogDir,
|
|
780
837
|
createLogPath,
|
|
781
|
-
// Export screen version utilities for testing and debugging
|
|
782
838
|
getScreenVersion,
|
|
783
839
|
supportsLogfileOption,
|
|
784
840
|
resetScreenVersionCache,
|