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/bin/cli.js
CHANGED
|
@@ -15,12 +15,19 @@ const {
|
|
|
15
15
|
} = require('../lib/args-parser');
|
|
16
16
|
const {
|
|
17
17
|
runIsolated,
|
|
18
|
+
runAsIsolatedUser,
|
|
18
19
|
getTimestamp,
|
|
19
20
|
createLogHeader,
|
|
20
21
|
createLogFooter,
|
|
21
22
|
writeLogFile,
|
|
22
23
|
createLogPath,
|
|
23
24
|
} = require('../lib/isolation');
|
|
25
|
+
const {
|
|
26
|
+
createIsolatedUser,
|
|
27
|
+
deleteUser,
|
|
28
|
+
hasSudoAccess,
|
|
29
|
+
getCurrentUserGroups,
|
|
30
|
+
} = require('../lib/user-manager');
|
|
24
31
|
|
|
25
32
|
// Configuration from environment variables
|
|
26
33
|
const config = {
|
|
@@ -211,31 +218,33 @@ function getToolVersion(toolName, versionFlag, verbose = false) {
|
|
|
211
218
|
return firstLine || null;
|
|
212
219
|
}
|
|
213
220
|
|
|
214
|
-
/**
|
|
215
|
-
* Print usage information
|
|
216
|
-
*/
|
|
221
|
+
/** Print usage information */
|
|
217
222
|
function printUsage() {
|
|
218
|
-
console.log(
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
)
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
223
|
+
console.log(`Usage: $ [options] [--] <command> [args...]
|
|
224
|
+
$ <command> [args...]
|
|
225
|
+
|
|
226
|
+
Options:
|
|
227
|
+
--isolated, -i <env> Run in isolated environment (screen, tmux, docker)
|
|
228
|
+
--attached, -a Run in attached mode (foreground)
|
|
229
|
+
--detached, -d Run in detached mode (background)
|
|
230
|
+
--session, -s <name> Session name for isolation
|
|
231
|
+
--image <image> Docker image (required for docker isolation)
|
|
232
|
+
--isolated-user, -u [name] Create isolated user with same permissions
|
|
233
|
+
--keep-user Keep isolated user after command completes
|
|
234
|
+
--keep-alive, -k Keep isolation environment alive after command exits
|
|
235
|
+
--auto-remove-docker-container Auto-remove docker container after exit
|
|
236
|
+
--version, -v Show version information
|
|
237
|
+
|
|
238
|
+
Examples:
|
|
239
|
+
$ echo "Hello World"
|
|
240
|
+
$ bun test
|
|
241
|
+
$ --isolated tmux -- bun start
|
|
242
|
+
$ -i screen -d bun start
|
|
243
|
+
$ --isolated docker --image oven/bun:latest -- bun install
|
|
244
|
+
$ --isolated-user -- npm test # Create isolated user
|
|
245
|
+
$ -u myuser -- npm start # Custom username
|
|
246
|
+
$ -i screen --isolated-user -- npm test # Combine with process isolation
|
|
247
|
+
$ --isolated-user --keep-user -- npm start`);
|
|
239
248
|
console.log('');
|
|
240
249
|
console.log('Piping with $:');
|
|
241
250
|
console.log(' echo "hi" | $ agent # Preferred - pipe TO $ command');
|
|
@@ -305,8 +314,8 @@ if (!config.disableSubstitutions) {
|
|
|
305
314
|
|
|
306
315
|
// Main execution
|
|
307
316
|
(async () => {
|
|
308
|
-
// Check if running in isolation mode
|
|
309
|
-
if (hasIsolation(wrapperOptions)) {
|
|
317
|
+
// Check if running in isolation mode or with user isolation
|
|
318
|
+
if (hasIsolation(wrapperOptions) || wrapperOptions.user) {
|
|
310
319
|
await runWithIsolation(wrapperOptions, command);
|
|
311
320
|
} else {
|
|
312
321
|
await runDirect(command);
|
|
@@ -324,43 +333,112 @@ async function runWithIsolation(options, cmd) {
|
|
|
324
333
|
const startTime = getTimestamp();
|
|
325
334
|
|
|
326
335
|
// Create log file path
|
|
327
|
-
const logFilePath = createLogPath(environment);
|
|
336
|
+
const logFilePath = createLogPath(environment || 'direct');
|
|
328
337
|
|
|
329
338
|
// Get session name (will be generated by runIsolated if not provided)
|
|
330
339
|
const sessionName =
|
|
331
340
|
options.session ||
|
|
332
|
-
`${environment}-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
|
|
341
|
+
`${environment || 'start'}-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
|
|
342
|
+
|
|
343
|
+
// Handle --isolated-user option: create a new user with same permissions
|
|
344
|
+
let createdUser = null;
|
|
345
|
+
|
|
346
|
+
if (options.user) {
|
|
347
|
+
// Check for sudo access
|
|
348
|
+
if (!hasSudoAccess()) {
|
|
349
|
+
console.error(
|
|
350
|
+
'Error: --isolated-user requires sudo access without password.'
|
|
351
|
+
);
|
|
352
|
+
console.error(
|
|
353
|
+
'Configure NOPASSWD in sudoers or run with appropriate permissions.'
|
|
354
|
+
);
|
|
355
|
+
process.exit(1);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Get current user groups to show what will be inherited
|
|
359
|
+
const currentGroups = getCurrentUserGroups();
|
|
360
|
+
const importantGroups = ['sudo', 'docker', 'wheel', 'admin'].filter((g) =>
|
|
361
|
+
currentGroups.includes(g)
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
console.log(`[User Isolation] Creating new user with same permissions...`);
|
|
365
|
+
if (importantGroups.length > 0) {
|
|
366
|
+
console.log(
|
|
367
|
+
`[User Isolation] Inheriting groups: ${importantGroups.join(', ')}`
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Create the isolated user
|
|
372
|
+
const userResult = createIsolatedUser(options.userName);
|
|
373
|
+
if (!userResult.success) {
|
|
374
|
+
console.error(
|
|
375
|
+
`Error: Failed to create isolated user: ${userResult.message}`
|
|
376
|
+
);
|
|
377
|
+
process.exit(1);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
createdUser = userResult.username;
|
|
381
|
+
console.log(`[User Isolation] Created user: ${createdUser}`);
|
|
382
|
+
if (userResult.groups && userResult.groups.length > 0) {
|
|
383
|
+
console.log(
|
|
384
|
+
`[User Isolation] User groups: ${userResult.groups.join(', ')}`
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
if (options.keepUser) {
|
|
388
|
+
console.log(`[User Isolation] User will be kept after command completes`);
|
|
389
|
+
}
|
|
390
|
+
console.log('');
|
|
391
|
+
}
|
|
333
392
|
|
|
334
393
|
// Print start message (unified format)
|
|
335
394
|
console.log(`[${startTime}] Starting: ${cmd}`);
|
|
336
395
|
console.log('');
|
|
337
396
|
|
|
338
397
|
// Log isolation info
|
|
339
|
-
|
|
398
|
+
if (environment) {
|
|
399
|
+
console.log(`[Isolation] Environment: ${environment}, Mode: ${mode}`);
|
|
400
|
+
}
|
|
340
401
|
if (options.session) {
|
|
341
402
|
console.log(`[Isolation] Session: ${options.session}`);
|
|
342
403
|
}
|
|
343
404
|
if (options.image) {
|
|
344
405
|
console.log(`[Isolation] Image: ${options.image}`);
|
|
345
406
|
}
|
|
407
|
+
if (createdUser) {
|
|
408
|
+
console.log(`[Isolation] User: ${createdUser} (isolated)`);
|
|
409
|
+
}
|
|
346
410
|
console.log('');
|
|
347
411
|
|
|
348
412
|
// Create log content
|
|
349
413
|
let logContent = createLogHeader({
|
|
350
414
|
command: cmd,
|
|
351
|
-
environment,
|
|
415
|
+
environment: environment || 'direct',
|
|
352
416
|
mode,
|
|
353
417
|
sessionName,
|
|
354
418
|
image: options.image,
|
|
419
|
+
user: createdUser,
|
|
355
420
|
startTime,
|
|
356
421
|
});
|
|
357
422
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
423
|
+
let result;
|
|
424
|
+
|
|
425
|
+
if (environment) {
|
|
426
|
+
// Run in isolation backend (screen, tmux, docker)
|
|
427
|
+
result = await runIsolated(environment, cmd, {
|
|
428
|
+
session: options.session,
|
|
429
|
+
image: options.image,
|
|
430
|
+
detached: mode === 'detached',
|
|
431
|
+
user: createdUser,
|
|
432
|
+
keepAlive: options.keepAlive,
|
|
433
|
+
autoRemoveDockerContainer: options.autoRemoveDockerContainer,
|
|
434
|
+
});
|
|
435
|
+
} else if (createdUser) {
|
|
436
|
+
// Run directly as the created user (no isolation backend)
|
|
437
|
+
result = await runAsIsolatedUser(cmd, createdUser);
|
|
438
|
+
} else {
|
|
439
|
+
// This shouldn't happen in isolation mode, but handle gracefully
|
|
440
|
+
result = { success: false, message: 'No isolation configuration provided' };
|
|
441
|
+
}
|
|
364
442
|
|
|
365
443
|
// Get exit code
|
|
366
444
|
const exitCode =
|
|
@@ -382,6 +460,23 @@ async function runWithIsolation(options, cmd) {
|
|
|
382
460
|
console.log(`Exit code: ${exitCode}`);
|
|
383
461
|
console.log(`Log saved: ${logFilePath}`);
|
|
384
462
|
|
|
463
|
+
// Cleanup: delete the created user if we created one (unless --keep-user)
|
|
464
|
+
if (createdUser && !options.keepUser) {
|
|
465
|
+
console.log('');
|
|
466
|
+
console.log(`[User Isolation] Cleaning up user: ${createdUser}`);
|
|
467
|
+
const deleteResult = deleteUser(createdUser, { removeHome: true });
|
|
468
|
+
if (deleteResult.success) {
|
|
469
|
+
console.log(`[User Isolation] User deleted successfully`);
|
|
470
|
+
} else {
|
|
471
|
+
console.log(`[User Isolation] Warning: ${deleteResult.message}`);
|
|
472
|
+
}
|
|
473
|
+
} else if (createdUser && options.keepUser) {
|
|
474
|
+
console.log('');
|
|
475
|
+
console.log(
|
|
476
|
+
`[User Isolation] Keeping user: ${createdUser} (use 'sudo userdel -r ${createdUser}' to delete)`
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
|
|
385
480
|
process.exit(exitCode);
|
|
386
481
|
}
|
|
387
482
|
|
package/src/lib/args-parser.js
CHANGED
|
@@ -6,11 +6,15 @@
|
|
|
6
6
|
* 2. $ [wrapper-options] command [command-options]
|
|
7
7
|
*
|
|
8
8
|
* Wrapper Options:
|
|
9
|
-
* --isolated, -i <backend>
|
|
10
|
-
* --attached, -a
|
|
11
|
-
* --detached, -d
|
|
12
|
-
* --session, -s <name>
|
|
13
|
-
* --image <image>
|
|
9
|
+
* --isolated, -i <backend> Run in isolated environment (screen, tmux, docker)
|
|
10
|
+
* --attached, -a Run in attached mode (foreground)
|
|
11
|
+
* --detached, -d Run in detached mode (background)
|
|
12
|
+
* --session, -s <name> Session name for isolation
|
|
13
|
+
* --image <image> Docker image (required for docker isolation)
|
|
14
|
+
* --isolated-user, -u [username] Create isolated user with same permissions (auto-generated name if not specified)
|
|
15
|
+
* --keep-user Keep isolated user after command completes (don't delete)
|
|
16
|
+
* --keep-alive, -k Keep isolation environment alive after command exits
|
|
17
|
+
* --auto-remove-docker-container Automatically remove docker container after exit (disabled by default)
|
|
14
18
|
*/
|
|
15
19
|
|
|
16
20
|
// Debug mode from environment
|
|
@@ -34,6 +38,11 @@ function parseArgs(args) {
|
|
|
34
38
|
detached: false, // Run in detached mode
|
|
35
39
|
session: null, // Session name
|
|
36
40
|
image: null, // Docker image
|
|
41
|
+
user: false, // Create isolated user
|
|
42
|
+
userName: null, // Optional custom username for isolated user
|
|
43
|
+
keepUser: false, // Keep isolated user after command completes (don't delete)
|
|
44
|
+
keepAlive: false, // Keep environment alive after command exits
|
|
45
|
+
autoRemoveDockerContainer: false, // Auto-remove docker container after exit
|
|
37
46
|
};
|
|
38
47
|
|
|
39
48
|
let commandArgs = [];
|
|
@@ -171,6 +180,47 @@ function parseOption(args, index, options) {
|
|
|
171
180
|
return 1;
|
|
172
181
|
}
|
|
173
182
|
|
|
183
|
+
// --isolated-user or -u [optional-username] - creates isolated user with same permissions
|
|
184
|
+
if (arg === '--isolated-user' || arg === '-u') {
|
|
185
|
+
options.user = true;
|
|
186
|
+
// Check if next arg is an optional username (not starting with -)
|
|
187
|
+
if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
|
|
188
|
+
// Check if next arg looks like a username (not a command)
|
|
189
|
+
const nextArg = args[index + 1];
|
|
190
|
+
// If next arg matches username format, consume it
|
|
191
|
+
if (/^[a-zA-Z0-9_-]+$/.test(nextArg) && nextArg.length <= 32) {
|
|
192
|
+
options.userName = nextArg;
|
|
193
|
+
return 2;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return 1;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// --isolated-user=<value>
|
|
200
|
+
if (arg.startsWith('--isolated-user=')) {
|
|
201
|
+
options.user = true;
|
|
202
|
+
options.userName = arg.split('=')[1];
|
|
203
|
+
return 1;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// --keep-user - keep isolated user after command completes
|
|
207
|
+
if (arg === '--keep-user') {
|
|
208
|
+
options.keepUser = true;
|
|
209
|
+
return 1;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// --keep-alive or -k
|
|
213
|
+
if (arg === '--keep-alive' || arg === '-k') {
|
|
214
|
+
options.keepAlive = true;
|
|
215
|
+
return 1;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// --auto-remove-docker-container
|
|
219
|
+
if (arg === '--auto-remove-docker-container') {
|
|
220
|
+
options.autoRemoveDockerContainer = true;
|
|
221
|
+
return 1;
|
|
222
|
+
}
|
|
223
|
+
|
|
174
224
|
// Not a recognized wrapper option
|
|
175
225
|
return 0;
|
|
176
226
|
}
|
|
@@ -213,6 +263,46 @@ function validateOptions(options) {
|
|
|
213
263
|
if (options.image && options.isolated !== 'docker') {
|
|
214
264
|
throw new Error('--image option is only valid with --isolated docker');
|
|
215
265
|
}
|
|
266
|
+
|
|
267
|
+
// Keep-alive is only valid with isolation
|
|
268
|
+
if (options.keepAlive && !options.isolated) {
|
|
269
|
+
throw new Error('--keep-alive option is only valid with --isolated');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Auto-remove-docker-container is only valid with docker isolation
|
|
273
|
+
if (options.autoRemoveDockerContainer && options.isolated !== 'docker') {
|
|
274
|
+
throw new Error(
|
|
275
|
+
'--auto-remove-docker-container option is only valid with --isolated docker'
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// User isolation validation
|
|
280
|
+
if (options.user) {
|
|
281
|
+
// User isolation is not supported with Docker (Docker has its own user mechanism)
|
|
282
|
+
if (options.isolated === 'docker') {
|
|
283
|
+
throw new Error(
|
|
284
|
+
'--isolated-user is not supported with Docker isolation. Docker uses its own user namespace for isolation.'
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
// Validate custom username if provided
|
|
288
|
+
if (options.userName) {
|
|
289
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(options.userName)) {
|
|
290
|
+
throw new Error(
|
|
291
|
+
`Invalid username format for --isolated-user: "${options.userName}". Username should contain only letters, numbers, hyphens, and underscores.`
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
if (options.userName.length > 32) {
|
|
295
|
+
throw new Error(
|
|
296
|
+
`Username too long for --isolated-user: "${options.userName}". Maximum length is 32 characters.`
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Keep-user validation
|
|
303
|
+
if (options.keepUser && !options.user) {
|
|
304
|
+
throw new Error('--keep-user option is only valid with --isolated-user');
|
|
305
|
+
}
|
|
216
306
|
}
|
|
217
307
|
|
|
218
308
|
/**
|