start-command 0.9.0 → 0.11.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/.github/workflows/release.yml +34 -0
- package/CHANGELOG.md +46 -0
- package/README.md +60 -15
- package/REQUIREMENTS.md +48 -2
- package/experiments/user-isolation-research.md +83 -0
- package/package.json +1 -1
- package/src/bin/cli.js +137 -44
- package/src/lib/args-parser.js +95 -3
- package/src/lib/isolation.js +193 -41
- package/src/lib/user-manager.js +429 -0
- package/test/args-parser.test.js +237 -1
- package/test/isolation.test.js +73 -0
- package/test/ssh-integration.test.js +328 -0
- package/test/user-manager.test.js +286 -0
package/src/lib/args-parser.js
CHANGED
|
@@ -6,11 +6,14 @@
|
|
|
6
6
|
* 2. $ [wrapper-options] command [command-options]
|
|
7
7
|
*
|
|
8
8
|
* Wrapper Options:
|
|
9
|
-
* --isolated, -i <backend> Run in isolated environment (screen, tmux, docker)
|
|
9
|
+
* --isolated, -i <backend> Run in isolated environment (screen, tmux, docker, ssh)
|
|
10
10
|
* --attached, -a Run in attached mode (foreground)
|
|
11
11
|
* --detached, -d Run in detached mode (background)
|
|
12
12
|
* --session, -s <name> Session name for isolation
|
|
13
13
|
* --image <image> Docker image (required for docker isolation)
|
|
14
|
+
* --endpoint <endpoint> SSH endpoint (required for ssh isolation, e.g., user@host)
|
|
15
|
+
* --isolated-user, -u [username] Create isolated user with same permissions (auto-generated name if not specified)
|
|
16
|
+
* --keep-user Keep isolated user after command completes (don't delete)
|
|
14
17
|
* --keep-alive, -k Keep isolation environment alive after command exits
|
|
15
18
|
* --auto-remove-docker-container Automatically remove docker container after exit (disabled by default)
|
|
16
19
|
*/
|
|
@@ -22,7 +25,7 @@ const DEBUG =
|
|
|
22
25
|
/**
|
|
23
26
|
* Valid isolation backends
|
|
24
27
|
*/
|
|
25
|
-
const VALID_BACKENDS = ['screen', 'tmux', 'docker'];
|
|
28
|
+
const VALID_BACKENDS = ['screen', 'tmux', 'docker', 'ssh'];
|
|
26
29
|
|
|
27
30
|
/**
|
|
28
31
|
* Parse command line arguments into wrapper options and command
|
|
@@ -31,11 +34,15 @@ const VALID_BACKENDS = ['screen', 'tmux', 'docker'];
|
|
|
31
34
|
*/
|
|
32
35
|
function parseArgs(args) {
|
|
33
36
|
const wrapperOptions = {
|
|
34
|
-
isolated: null, // Isolation backend: screen, tmux, docker
|
|
37
|
+
isolated: null, // Isolation backend: screen, tmux, docker, ssh
|
|
35
38
|
attached: false, // Run in attached mode
|
|
36
39
|
detached: false, // Run in detached mode
|
|
37
40
|
session: null, // Session name
|
|
38
41
|
image: null, // Docker image
|
|
42
|
+
endpoint: null, // SSH endpoint (e.g., user@host)
|
|
43
|
+
user: false, // Create isolated user
|
|
44
|
+
userName: null, // Optional custom username for isolated user
|
|
45
|
+
keepUser: false, // Keep isolated user after command completes (don't delete)
|
|
39
46
|
keepAlive: false, // Keep environment alive after command exits
|
|
40
47
|
autoRemoveDockerContainer: false, // Auto-remove docker container after exit
|
|
41
48
|
};
|
|
@@ -175,6 +182,51 @@ function parseOption(args, index, options) {
|
|
|
175
182
|
return 1;
|
|
176
183
|
}
|
|
177
184
|
|
|
185
|
+
// --endpoint (for ssh)
|
|
186
|
+
if (arg === '--endpoint') {
|
|
187
|
+
if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
|
|
188
|
+
options.endpoint = args[index + 1];
|
|
189
|
+
return 2;
|
|
190
|
+
} else {
|
|
191
|
+
throw new Error(`Option ${arg} requires an endpoint argument`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// --endpoint=<value>
|
|
196
|
+
if (arg.startsWith('--endpoint=')) {
|
|
197
|
+
options.endpoint = arg.split('=')[1];
|
|
198
|
+
return 1;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// --isolated-user or -u [optional-username] - creates isolated user with same permissions
|
|
202
|
+
if (arg === '--isolated-user' || arg === '-u') {
|
|
203
|
+
options.user = true;
|
|
204
|
+
// Check if next arg is an optional username (not starting with -)
|
|
205
|
+
if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
|
|
206
|
+
// Check if next arg looks like a username (not a command)
|
|
207
|
+
const nextArg = args[index + 1];
|
|
208
|
+
// If next arg matches username format, consume it
|
|
209
|
+
if (/^[a-zA-Z0-9_-]+$/.test(nextArg) && nextArg.length <= 32) {
|
|
210
|
+
options.userName = nextArg;
|
|
211
|
+
return 2;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return 1;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// --isolated-user=<value>
|
|
218
|
+
if (arg.startsWith('--isolated-user=')) {
|
|
219
|
+
options.user = true;
|
|
220
|
+
options.userName = arg.split('=')[1];
|
|
221
|
+
return 1;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// --keep-user - keep isolated user after command completes
|
|
225
|
+
if (arg === '--keep-user') {
|
|
226
|
+
options.keepUser = true;
|
|
227
|
+
return 1;
|
|
228
|
+
}
|
|
229
|
+
|
|
178
230
|
// --keep-alive or -k
|
|
179
231
|
if (arg === '--keep-alive' || arg === '-k') {
|
|
180
232
|
options.keepAlive = true;
|
|
@@ -218,6 +270,13 @@ function validateOptions(options) {
|
|
|
218
270
|
'Docker isolation requires --image option to specify the container image'
|
|
219
271
|
);
|
|
220
272
|
}
|
|
273
|
+
|
|
274
|
+
// SSH requires --endpoint
|
|
275
|
+
if (options.isolated === 'ssh' && !options.endpoint) {
|
|
276
|
+
throw new Error(
|
|
277
|
+
'SSH isolation requires --endpoint option to specify the remote server (e.g., user@host)'
|
|
278
|
+
);
|
|
279
|
+
}
|
|
221
280
|
}
|
|
222
281
|
|
|
223
282
|
// Session name is only valid with isolation
|
|
@@ -230,6 +289,11 @@ function validateOptions(options) {
|
|
|
230
289
|
throw new Error('--image option is only valid with --isolated docker');
|
|
231
290
|
}
|
|
232
291
|
|
|
292
|
+
// Endpoint is only valid with ssh
|
|
293
|
+
if (options.endpoint && options.isolated !== 'ssh') {
|
|
294
|
+
throw new Error('--endpoint option is only valid with --isolated ssh');
|
|
295
|
+
}
|
|
296
|
+
|
|
233
297
|
// Keep-alive is only valid with isolation
|
|
234
298
|
if (options.keepAlive && !options.isolated) {
|
|
235
299
|
throw new Error('--keep-alive option is only valid with --isolated');
|
|
@@ -241,6 +305,34 @@ function validateOptions(options) {
|
|
|
241
305
|
'--auto-remove-docker-container option is only valid with --isolated docker'
|
|
242
306
|
);
|
|
243
307
|
}
|
|
308
|
+
|
|
309
|
+
// User isolation validation
|
|
310
|
+
if (options.user) {
|
|
311
|
+
// User isolation is not supported with Docker (Docker has its own user mechanism)
|
|
312
|
+
if (options.isolated === 'docker') {
|
|
313
|
+
throw new Error(
|
|
314
|
+
'--isolated-user is not supported with Docker isolation. Docker uses its own user namespace for isolation.'
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
// Validate custom username if provided
|
|
318
|
+
if (options.userName) {
|
|
319
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(options.userName)) {
|
|
320
|
+
throw new Error(
|
|
321
|
+
`Invalid username format for --isolated-user: "${options.userName}". Username should contain only letters, numbers, hyphens, and underscores.`
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
if (options.userName.length > 32) {
|
|
325
|
+
throw new Error(
|
|
326
|
+
`Username too long for --isolated-user: "${options.userName}". Maximum length is 32 characters.`
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Keep-user validation
|
|
333
|
+
if (options.keepUser && !options.user) {
|
|
334
|
+
throw new Error('--keep-user option is only valid with --isolated-user');
|
|
335
|
+
}
|
|
244
336
|
}
|
|
245
337
|
|
|
246
338
|
/**
|
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
|
}
|
|
@@ -498,10 +524,103 @@ function runInTmux(command, options = {}) {
|
|
|
498
524
|
}
|
|
499
525
|
}
|
|
500
526
|
|
|
527
|
+
/**
|
|
528
|
+
* Run command over SSH on a remote server
|
|
529
|
+
* @param {string} command - Command to execute
|
|
530
|
+
* @param {object} options - Options (endpoint, session, detached)
|
|
531
|
+
* @returns {Promise<{success: boolean, sessionName: string, message: string}>}
|
|
532
|
+
*/
|
|
533
|
+
function runInSsh(command, options = {}) {
|
|
534
|
+
if (!isCommandAvailable('ssh')) {
|
|
535
|
+
return Promise.resolve({
|
|
536
|
+
success: false,
|
|
537
|
+
sessionName: null,
|
|
538
|
+
message:
|
|
539
|
+
'ssh is not installed. Install it with: sudo apt-get install openssh-client (Debian/Ubuntu) or brew install openssh (macOS)',
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (!options.endpoint) {
|
|
544
|
+
return Promise.resolve({
|
|
545
|
+
success: false,
|
|
546
|
+
sessionName: null,
|
|
547
|
+
message:
|
|
548
|
+
'SSH isolation requires --endpoint option to specify the remote server (e.g., user@host)',
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const sessionName = options.session || generateSessionName('ssh');
|
|
553
|
+
const sshTarget = options.endpoint;
|
|
554
|
+
|
|
555
|
+
try {
|
|
556
|
+
if (options.detached) {
|
|
557
|
+
// Detached mode: Run command in background on remote server using nohup
|
|
558
|
+
// The command will continue running even after SSH connection closes
|
|
559
|
+
const remoteCommand = `nohup ${command} > /tmp/${sessionName}.log 2>&1 &`;
|
|
560
|
+
const sshArgs = [sshTarget, remoteCommand];
|
|
561
|
+
|
|
562
|
+
if (DEBUG) {
|
|
563
|
+
console.log(`[DEBUG] Running: ssh ${sshArgs.join(' ')}`);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const result = spawnSync('ssh', sshArgs, {
|
|
567
|
+
stdio: 'inherit',
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
if (result.error) {
|
|
571
|
+
throw result.error;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
return Promise.resolve({
|
|
575
|
+
success: true,
|
|
576
|
+
sessionName,
|
|
577
|
+
message: `Command started in detached SSH session on ${sshTarget}\nSession: ${sessionName}\nView logs: ssh ${sshTarget} "tail -f /tmp/${sessionName}.log"`,
|
|
578
|
+
});
|
|
579
|
+
} else {
|
|
580
|
+
// Attached mode: Run command interactively over SSH
|
|
581
|
+
// This creates a direct SSH connection and runs the command
|
|
582
|
+
const sshArgs = [sshTarget, command];
|
|
583
|
+
|
|
584
|
+
if (DEBUG) {
|
|
585
|
+
console.log(`[DEBUG] Running: ssh ${sshArgs.join(' ')}`);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
return new Promise((resolve) => {
|
|
589
|
+
const child = spawn('ssh', sshArgs, {
|
|
590
|
+
stdio: 'inherit',
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
child.on('exit', (code) => {
|
|
594
|
+
resolve({
|
|
595
|
+
success: code === 0,
|
|
596
|
+
sessionName,
|
|
597
|
+
message: `SSH session "${sessionName}" on ${sshTarget} exited with code ${code}`,
|
|
598
|
+
exitCode: code,
|
|
599
|
+
});
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
child.on('error', (err) => {
|
|
603
|
+
resolve({
|
|
604
|
+
success: false,
|
|
605
|
+
sessionName,
|
|
606
|
+
message: `Failed to start SSH: ${err.message}`,
|
|
607
|
+
});
|
|
608
|
+
});
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
} catch (err) {
|
|
612
|
+
return Promise.resolve({
|
|
613
|
+
success: false,
|
|
614
|
+
sessionName,
|
|
615
|
+
message: `Failed to run over SSH: ${err.message}`,
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
501
620
|
/**
|
|
502
621
|
* Run command in Docker container
|
|
503
622
|
* @param {string} command - Command to execute
|
|
504
|
-
* @param {object} options - Options (image, session/name, detached, keepAlive, autoRemoveDockerContainer)
|
|
623
|
+
* @param {object} options - Options (image, session/name, detached, user, keepAlive, autoRemoveDockerContainer)
|
|
505
624
|
* @returns {Promise<{success: boolean, containerName: string, message: string}>}
|
|
506
625
|
*/
|
|
507
626
|
function runInDocker(command, options = {}) {
|
|
@@ -526,7 +645,7 @@ function runInDocker(command, options = {}) {
|
|
|
526
645
|
|
|
527
646
|
try {
|
|
528
647
|
if (options.detached) {
|
|
529
|
-
// Detached mode: docker run -d --name <name> <image> <shell> -c '<command>'
|
|
648
|
+
// Detached mode: docker run -d --name <name> [--user <user>] <image> <shell> -c '<command>'
|
|
530
649
|
// By default (keepAlive=false), the container exits after command completes
|
|
531
650
|
// With keepAlive=true, we keep the container running with a shell
|
|
532
651
|
let effectiveCommand = command;
|
|
@@ -537,16 +656,7 @@ function runInDocker(command, options = {}) {
|
|
|
537
656
|
}
|
|
538
657
|
// Without keep-alive: container exits naturally when command completes
|
|
539
658
|
|
|
540
|
-
const dockerArgs = [
|
|
541
|
-
'run',
|
|
542
|
-
'-d',
|
|
543
|
-
'--name',
|
|
544
|
-
containerName,
|
|
545
|
-
options.image,
|
|
546
|
-
'/bin/sh',
|
|
547
|
-
'-c',
|
|
548
|
-
effectiveCommand,
|
|
549
|
-
];
|
|
659
|
+
const dockerArgs = ['run', '-d', '--name', containerName];
|
|
550
660
|
|
|
551
661
|
// Add --rm flag if autoRemoveDockerContainer is true
|
|
552
662
|
// Note: --rm must come before the image name
|
|
@@ -554,6 +664,13 @@ function runInDocker(command, options = {}) {
|
|
|
554
664
|
dockerArgs.splice(2, 0, '--rm');
|
|
555
665
|
}
|
|
556
666
|
|
|
667
|
+
// Add --user flag if specified
|
|
668
|
+
if (options.user) {
|
|
669
|
+
dockerArgs.push('--user', options.user);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
dockerArgs.push(options.image, '/bin/sh', '-c', effectiveCommand);
|
|
673
|
+
|
|
557
674
|
if (DEBUG) {
|
|
558
675
|
console.log(`[DEBUG] Running: docker ${dockerArgs.join(' ')}`);
|
|
559
676
|
console.log(`[DEBUG] keepAlive: ${options.keepAlive || false}`);
|
|
@@ -588,18 +705,15 @@ function runInDocker(command, options = {}) {
|
|
|
588
705
|
message,
|
|
589
706
|
});
|
|
590
707
|
} 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
|
-
];
|
|
708
|
+
// Attached mode: docker run -it --name <name> [--user <user>] <image> <shell> -c '<command>'
|
|
709
|
+
const dockerArgs = ['run', '-it', '--rm', '--name', containerName];
|
|
710
|
+
|
|
711
|
+
// Add --user flag if specified
|
|
712
|
+
if (options.user) {
|
|
713
|
+
dockerArgs.push('--user', options.user);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
dockerArgs.push(options.image, '/bin/sh', '-c', command);
|
|
603
717
|
|
|
604
718
|
if (DEBUG) {
|
|
605
719
|
console.log(`[DEBUG] Running: docker ${dockerArgs.join(' ')}`);
|
|
@@ -639,7 +753,7 @@ function runInDocker(command, options = {}) {
|
|
|
639
753
|
|
|
640
754
|
/**
|
|
641
755
|
* Run command in the specified isolation backend
|
|
642
|
-
* @param {string} backend - Isolation backend (screen, tmux, docker)
|
|
756
|
+
* @param {string} backend - Isolation backend (screen, tmux, docker, ssh)
|
|
643
757
|
* @param {string} command - Command to execute
|
|
644
758
|
* @param {object} options - Options
|
|
645
759
|
* @returns {Promise<{success: boolean, message: string}>}
|
|
@@ -652,6 +766,8 @@ function runIsolated(backend, command, options = {}) {
|
|
|
652
766
|
return runInTmux(command, options);
|
|
653
767
|
case 'docker':
|
|
654
768
|
return runInDocker(command, options);
|
|
769
|
+
case 'ssh':
|
|
770
|
+
return runInSsh(command, options);
|
|
655
771
|
default:
|
|
656
772
|
return Promise.resolve({
|
|
657
773
|
success: false,
|
|
@@ -687,6 +803,7 @@ function generateLogFilename(environment) {
|
|
|
687
803
|
* @param {string} params.mode - attached or detached
|
|
688
804
|
* @param {string} params.sessionName - Session/container name
|
|
689
805
|
* @param {string} [params.image] - Docker image (for docker environment)
|
|
806
|
+
* @param {string} [params.user] - User to run command as (optional)
|
|
690
807
|
* @param {string} params.startTime - Start timestamp
|
|
691
808
|
* @returns {string} Log header content
|
|
692
809
|
*/
|
|
@@ -700,6 +817,9 @@ function createLogHeader(params) {
|
|
|
700
817
|
if (params.image) {
|
|
701
818
|
content += `Image: ${params.image}\n`;
|
|
702
819
|
}
|
|
820
|
+
if (params.user) {
|
|
821
|
+
content += `User: ${params.user}\n`;
|
|
822
|
+
}
|
|
703
823
|
content += `Platform: ${process.platform}\n`;
|
|
704
824
|
content += `Node Version: ${process.version}\n`;
|
|
705
825
|
content += `Working Directory: ${process.cwd()}\n`;
|
|
@@ -763,14 +883,47 @@ function resetScreenVersionCache() {
|
|
|
763
883
|
screenVersionChecked = false;
|
|
764
884
|
}
|
|
765
885
|
|
|
886
|
+
/**
|
|
887
|
+
* Run command as an isolated user (without isolation backend)
|
|
888
|
+
* Uses sudo -u to switch users
|
|
889
|
+
* @param {string} cmd - Command to execute
|
|
890
|
+
* @param {string} username - User to run as
|
|
891
|
+
* @returns {Promise<{success: boolean, message: string, exitCode: number}>}
|
|
892
|
+
*/
|
|
893
|
+
function runAsIsolatedUser(cmd, username) {
|
|
894
|
+
return new Promise((resolve) => {
|
|
895
|
+
const child = spawn('sudo', ['-n', '-u', username, 'sh', '-c', cmd], {
|
|
896
|
+
stdio: 'inherit',
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
child.on('exit', (code) => {
|
|
900
|
+
resolve({
|
|
901
|
+
success: code === 0,
|
|
902
|
+
message: `Command completed as user "${username}" with exit code ${code}`,
|
|
903
|
+
exitCode: code || 0,
|
|
904
|
+
});
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
child.on('error', (err) => {
|
|
908
|
+
resolve({
|
|
909
|
+
success: false,
|
|
910
|
+
message: `Failed to run as user "${username}": ${err.message}`,
|
|
911
|
+
exitCode: 1,
|
|
912
|
+
});
|
|
913
|
+
});
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
|
|
766
917
|
module.exports = {
|
|
767
918
|
isCommandAvailable,
|
|
768
919
|
hasTTY,
|
|
769
920
|
runInScreen,
|
|
770
921
|
runInTmux,
|
|
771
922
|
runInDocker,
|
|
923
|
+
runInSsh,
|
|
772
924
|
runIsolated,
|
|
773
|
-
|
|
925
|
+
runAsIsolatedUser,
|
|
926
|
+
wrapCommandWithUser,
|
|
774
927
|
getTimestamp,
|
|
775
928
|
generateLogFilename,
|
|
776
929
|
createLogHeader,
|
|
@@ -778,7 +931,6 @@ module.exports = {
|
|
|
778
931
|
writeLogFile,
|
|
779
932
|
getLogDir,
|
|
780
933
|
createLogPath,
|
|
781
|
-
// Export screen version utilities for testing and debugging
|
|
782
934
|
getScreenVersion,
|
|
783
935
|
supportsLogfileOption,
|
|
784
936
|
resetScreenVersionCache,
|