start-command 0.7.6 → 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/ARCHITECTURE.md +297 -0
- package/CHANGELOG.md +46 -0
- package/README.md +68 -7
- package/REQUIREMENTS.md +72 -1
- package/experiments/user-isolation-research.md +83 -0
- package/package.json +1 -1
- package/src/bin/cli.js +131 -36
- package/src/lib/args-parser.js +95 -5
- package/src/lib/isolation.js +184 -43
- package/src/lib/user-manager.js +429 -0
- package/test/args-parser.test.js +309 -0
- package/test/docker-autoremove.test.js +169 -0
- package/test/isolation-cleanup.test.js +377 -0
- package/test/isolation.test.js +233 -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)
|
|
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,12 +335,31 @@ 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
|
|
344
|
+
// With keepAlive=true, we start a shell that runs the command but stays alive
|
|
345
|
+
|
|
346
|
+
if (options.keepAlive) {
|
|
347
|
+
// With keep-alive: run command, then keep shell open
|
|
348
|
+
effectiveCommand = `${effectiveCommand}; exec ${shell}`;
|
|
349
|
+
}
|
|
350
|
+
// Without keep-alive: command runs and session exits naturally when done
|
|
351
|
+
|
|
352
|
+
const screenArgs = [
|
|
353
|
+
'-dmS',
|
|
354
|
+
sessionName,
|
|
355
|
+
shell,
|
|
356
|
+
shellArg,
|
|
357
|
+
effectiveCommand,
|
|
358
|
+
];
|
|
323
359
|
|
|
324
360
|
if (DEBUG) {
|
|
325
361
|
console.log(`[DEBUG] Running: screen ${screenArgs.join(' ')}`);
|
|
362
|
+
console.log(`[DEBUG] keepAlive: ${options.keepAlive || false}`);
|
|
326
363
|
}
|
|
327
364
|
|
|
328
365
|
// Use spawnSync with array arguments to avoid shell quoting issues
|
|
@@ -336,10 +373,18 @@ function runInScreen(command, options = {}) {
|
|
|
336
373
|
throw result.error;
|
|
337
374
|
}
|
|
338
375
|
|
|
376
|
+
let message = `Command started in detached screen session: ${sessionName}`;
|
|
377
|
+
if (options.keepAlive) {
|
|
378
|
+
message += `\nSession will stay alive after command completes.`;
|
|
379
|
+
} else {
|
|
380
|
+
message += `\nSession will exit automatically after command completes.`;
|
|
381
|
+
}
|
|
382
|
+
message += `\nReattach with: screen -r ${sessionName}`;
|
|
383
|
+
|
|
339
384
|
return Promise.resolve({
|
|
340
385
|
success: true,
|
|
341
386
|
sessionName,
|
|
342
|
-
message
|
|
387
|
+
message,
|
|
343
388
|
});
|
|
344
389
|
} else {
|
|
345
390
|
// Attached mode: always use detached mode with log capture
|
|
@@ -357,7 +402,12 @@ function runInScreen(command, options = {}) {
|
|
|
357
402
|
);
|
|
358
403
|
}
|
|
359
404
|
|
|
360
|
-
return runScreenWithLogCapture(
|
|
405
|
+
return runScreenWithLogCapture(
|
|
406
|
+
command,
|
|
407
|
+
sessionName,
|
|
408
|
+
shellInfo,
|
|
409
|
+
options.user
|
|
410
|
+
);
|
|
361
411
|
}
|
|
362
412
|
} catch (err) {
|
|
363
413
|
return Promise.resolve({
|
|
@@ -371,7 +421,7 @@ function runInScreen(command, options = {}) {
|
|
|
371
421
|
/**
|
|
372
422
|
* Run command in tmux
|
|
373
423
|
* @param {string} command - Command to execute
|
|
374
|
-
* @param {object} options - Options (session, detached)
|
|
424
|
+
* @param {object} options - Options (session, detached, user, keepAlive)
|
|
375
425
|
* @returns {Promise<{success: boolean, sessionName: string, message: string}>}
|
|
376
426
|
*/
|
|
377
427
|
function runInTmux(command, options = {}) {
|
|
@@ -385,37 +435,63 @@ function runInTmux(command, options = {}) {
|
|
|
385
435
|
}
|
|
386
436
|
|
|
387
437
|
const sessionName = options.session || generateSessionName('tmux');
|
|
438
|
+
const shellInfo = getShell();
|
|
439
|
+
const { shell } = shellInfo;
|
|
440
|
+
|
|
441
|
+
// Wrap command with user switch if specified
|
|
442
|
+
let effectiveCommand = wrapCommandWithUser(command, options.user);
|
|
388
443
|
|
|
389
444
|
try {
|
|
390
445
|
if (options.detached) {
|
|
391
446
|
// Detached mode: tmux new-session -d -s <session> '<command>'
|
|
447
|
+
// By default (keepAlive=false), the session will exit after command completes
|
|
448
|
+
// With keepAlive=true, we keep the shell alive after the command
|
|
449
|
+
|
|
450
|
+
if (options.keepAlive) {
|
|
451
|
+
// With keep-alive: run command, then keep shell open
|
|
452
|
+
effectiveCommand = `${effectiveCommand}; exec ${shell}`;
|
|
453
|
+
}
|
|
454
|
+
// Without keep-alive: command runs and session exits naturally when done
|
|
455
|
+
|
|
392
456
|
if (DEBUG) {
|
|
393
457
|
console.log(
|
|
394
|
-
`[DEBUG] Running: tmux new-session -d -s "${sessionName}" "${
|
|
458
|
+
`[DEBUG] Running: tmux new-session -d -s "${sessionName}" "${effectiveCommand}"`
|
|
395
459
|
);
|
|
460
|
+
console.log(`[DEBUG] keepAlive: ${options.keepAlive || false}`);
|
|
396
461
|
}
|
|
397
462
|
|
|
398
|
-
execSync(
|
|
399
|
-
|
|
400
|
-
|
|
463
|
+
execSync(
|
|
464
|
+
`tmux new-session -d -s "${sessionName}" "${effectiveCommand}"`,
|
|
465
|
+
{
|
|
466
|
+
stdio: 'inherit',
|
|
467
|
+
}
|
|
468
|
+
);
|
|
469
|
+
|
|
470
|
+
let message = `Command started in detached tmux session: ${sessionName}`;
|
|
471
|
+
if (options.keepAlive) {
|
|
472
|
+
message += `\nSession will stay alive after command completes.`;
|
|
473
|
+
} else {
|
|
474
|
+
message += `\nSession will exit automatically after command completes.`;
|
|
475
|
+
}
|
|
476
|
+
message += `\nReattach with: tmux attach -t ${sessionName}`;
|
|
401
477
|
|
|
402
478
|
return Promise.resolve({
|
|
403
479
|
success: true,
|
|
404
480
|
sessionName,
|
|
405
|
-
message
|
|
481
|
+
message,
|
|
406
482
|
});
|
|
407
483
|
} else {
|
|
408
484
|
// Attached mode: tmux new-session -s <session> '<command>'
|
|
409
485
|
if (DEBUG) {
|
|
410
486
|
console.log(
|
|
411
|
-
`[DEBUG] Running: tmux new-session -s "${sessionName}" "${
|
|
487
|
+
`[DEBUG] Running: tmux new-session -s "${sessionName}" "${effectiveCommand}"`
|
|
412
488
|
);
|
|
413
489
|
}
|
|
414
490
|
|
|
415
491
|
return new Promise((resolve) => {
|
|
416
492
|
const child = spawn(
|
|
417
493
|
'tmux',
|
|
418
|
-
['new-session', '-s', sessionName,
|
|
494
|
+
['new-session', '-s', sessionName, effectiveCommand],
|
|
419
495
|
{
|
|
420
496
|
stdio: 'inherit',
|
|
421
497
|
}
|
|
@@ -451,7 +527,7 @@ function runInTmux(command, options = {}) {
|
|
|
451
527
|
/**
|
|
452
528
|
* Run command in Docker container
|
|
453
529
|
* @param {string} command - Command to execute
|
|
454
|
-
* @param {object} options - Options (image, session/name, detached)
|
|
530
|
+
* @param {object} options - Options (image, session/name, detached, user, keepAlive, autoRemoveDockerContainer)
|
|
455
531
|
* @returns {Promise<{success: boolean, containerName: string, message: string}>}
|
|
456
532
|
*/
|
|
457
533
|
function runInDocker(command, options = {}) {
|
|
@@ -476,45 +552,75 @@ function runInDocker(command, options = {}) {
|
|
|
476
552
|
|
|
477
553
|
try {
|
|
478
554
|
if (options.detached) {
|
|
479
|
-
// Detached mode: docker run -d --name <name> <image> <shell> -c '<command>'
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
555
|
+
// Detached mode: docker run -d --name <name> [--user <user>] <image> <shell> -c '<command>'
|
|
556
|
+
// By default (keepAlive=false), the container exits after command completes
|
|
557
|
+
// With keepAlive=true, we keep the container running with a shell
|
|
558
|
+
let effectiveCommand = command;
|
|
559
|
+
|
|
560
|
+
if (options.keepAlive) {
|
|
561
|
+
// With keep-alive: run command, then keep shell alive
|
|
562
|
+
effectiveCommand = `${command}; exec /bin/sh`;
|
|
563
|
+
}
|
|
564
|
+
// Without keep-alive: container exits naturally when command completes
|
|
565
|
+
|
|
566
|
+
const dockerArgs = ['run', '-d', '--name', containerName];
|
|
567
|
+
|
|
568
|
+
// Add --rm flag if autoRemoveDockerContainer is true
|
|
569
|
+
// Note: --rm must come before the image name
|
|
570
|
+
if (options.autoRemoveDockerContainer) {
|
|
571
|
+
dockerArgs.splice(2, 0, '--rm');
|
|
572
|
+
}
|
|
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);
|
|
490
580
|
|
|
491
581
|
if (DEBUG) {
|
|
492
582
|
console.log(`[DEBUG] Running: docker ${dockerArgs.join(' ')}`);
|
|
583
|
+
console.log(`[DEBUG] keepAlive: ${options.keepAlive || false}`);
|
|
584
|
+
console.log(
|
|
585
|
+
`[DEBUG] autoRemoveDockerContainer: ${options.autoRemoveDockerContainer || false}`
|
|
586
|
+
);
|
|
493
587
|
}
|
|
494
588
|
|
|
495
589
|
const containerId = execSync(`docker ${dockerArgs.join(' ')}`, {
|
|
496
590
|
encoding: 'utf8',
|
|
497
591
|
}).trim();
|
|
498
592
|
|
|
593
|
+
let message = `Command started in detached docker container: ${containerName}`;
|
|
594
|
+
message += `\nContainer ID: ${containerId.substring(0, 12)}`;
|
|
595
|
+
if (options.keepAlive) {
|
|
596
|
+
message += `\nContainer will stay alive after command completes.`;
|
|
597
|
+
} else {
|
|
598
|
+
message += `\nContainer will exit automatically after command completes.`;
|
|
599
|
+
}
|
|
600
|
+
if (options.autoRemoveDockerContainer) {
|
|
601
|
+
message += `\nContainer will be automatically removed after exit.`;
|
|
602
|
+
} else {
|
|
603
|
+
message += `\nContainer filesystem will be preserved after exit.`;
|
|
604
|
+
}
|
|
605
|
+
message += `\nAttach with: docker attach ${containerName}`;
|
|
606
|
+
message += `\nView logs: docker logs ${containerName}`;
|
|
607
|
+
|
|
499
608
|
return Promise.resolve({
|
|
500
609
|
success: true,
|
|
501
610
|
containerName,
|
|
502
611
|
containerId,
|
|
503
|
-
message
|
|
612
|
+
message,
|
|
504
613
|
});
|
|
505
614
|
} else {
|
|
506
|
-
// Attached mode: docker run -it --name <name> <image> <shell> -c '<command>'
|
|
507
|
-
const dockerArgs = [
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
'--
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
'-c',
|
|
516
|
-
command,
|
|
517
|
-
];
|
|
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);
|
|
518
624
|
|
|
519
625
|
if (DEBUG) {
|
|
520
626
|
console.log(`[DEBUG] Running: docker ${dockerArgs.join(' ')}`);
|
|
@@ -602,6 +708,7 @@ function generateLogFilename(environment) {
|
|
|
602
708
|
* @param {string} params.mode - attached or detached
|
|
603
709
|
* @param {string} params.sessionName - Session/container name
|
|
604
710
|
* @param {string} [params.image] - Docker image (for docker environment)
|
|
711
|
+
* @param {string} [params.user] - User to run command as (optional)
|
|
605
712
|
* @param {string} params.startTime - Start timestamp
|
|
606
713
|
* @returns {string} Log header content
|
|
607
714
|
*/
|
|
@@ -615,6 +722,9 @@ function createLogHeader(params) {
|
|
|
615
722
|
if (params.image) {
|
|
616
723
|
content += `Image: ${params.image}\n`;
|
|
617
724
|
}
|
|
725
|
+
if (params.user) {
|
|
726
|
+
content += `User: ${params.user}\n`;
|
|
727
|
+
}
|
|
618
728
|
content += `Platform: ${process.platform}\n`;
|
|
619
729
|
content += `Node Version: ${process.version}\n`;
|
|
620
730
|
content += `Working Directory: ${process.cwd()}\n`;
|
|
@@ -678,6 +788,37 @@ function resetScreenVersionCache() {
|
|
|
678
788
|
screenVersionChecked = false;
|
|
679
789
|
}
|
|
680
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
|
+
|
|
681
822
|
module.exports = {
|
|
682
823
|
isCommandAvailable,
|
|
683
824
|
hasTTY,
|
|
@@ -685,7 +826,8 @@ module.exports = {
|
|
|
685
826
|
runInTmux,
|
|
686
827
|
runInDocker,
|
|
687
828
|
runIsolated,
|
|
688
|
-
|
|
829
|
+
runAsIsolatedUser,
|
|
830
|
+
wrapCommandWithUser,
|
|
689
831
|
getTimestamp,
|
|
690
832
|
generateLogFilename,
|
|
691
833
|
createLogHeader,
|
|
@@ -693,7 +835,6 @@ module.exports = {
|
|
|
693
835
|
writeLogFile,
|
|
694
836
|
getLogDir,
|
|
695
837
|
createLogPath,
|
|
696
|
-
// Export screen version utilities for testing and debugging
|
|
697
838
|
getScreenVersion,
|
|
698
839
|
supportsLogfileOption,
|
|
699
840
|
resetScreenVersionCache,
|