diff --git a/.changeset/user-isolation-support.md b/.changeset/user-isolation-support.md new file mode 100644 index 0000000..daa0f60 --- /dev/null +++ b/.changeset/user-isolation-support.md @@ -0,0 +1,39 @@ +--- +'start-command': minor +--- + +Add user isolation support with --isolated-user and --keep-user options + +Implements user isolation that creates a new isolated user to run commands: + +## --isolated-user option (create isolated user with same permissions) + +- Add --isolated-user, -u option to create a new isolated user automatically +- New user inherits group memberships from current user (sudo, docker, wheel, etc.) +- User is automatically deleted after command completes (unless --keep-user) +- Works with screen and tmux isolation backends (not docker) +- Optional custom username via --isolated-user=myname or -u myname +- For screen/tmux: Wraps commands with sudo -n -u +- Requires sudo NOPASSWD configuration for useradd/userdel/sudo + +## --keep-user option + +- Add --keep-user option to prevent user deletion after command completes +- Useful when you need to inspect files created during execution +- User must be manually deleted with: sudo userdel -r + +## Other improvements + +- Add comprehensive tests for user isolation +- Update documentation with user isolation examples +- Integrate --keep-alive and --auto-remove-docker-container from main branch + +Usage: + +- $ --isolated-user -- npm test # Auto-generated username, auto-deleted +- $ --isolated-user myrunner -- npm start # Custom username +- $ -u myrunner -- npm start # Short form +- $ --isolated screen --isolated-user -- npm test # Combine with process isolation +- $ --isolated-user --keep-user -- npm test # Keep user after completion + +Note: User isolation requires sudo NOPASSWD configuration. diff --git a/README.md b/README.md index 719b0d1..af3b214 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,44 @@ $ --isolated docker --image oven/bun:latest -- bun install $ -i tmux -s my-session -d bun start ``` +### User Isolation + +Create a new isolated user with the same group permissions as your current user to run commands in complete isolation: + +```bash +# Create an isolated user with same permissions and run command +$ --isolated-user -- npm test + +# Specify custom username for the isolated user +$ --isolated-user myrunner -- npm start +$ -u myrunner -- npm start + +# Combine with process isolation (screen or tmux) +$ --isolated screen --isolated-user -- npm test + +# Keep the user after command completes (don't delete) +$ --isolated-user --keep-user -- npm start + +# The isolated user inherits your group memberships: +# - sudo group (if you have it) +# - docker group (if you have it) +# - wheel, admin, and other privileged groups +``` + +The `--isolated-user` option: + +- Creates a new system user with the same group memberships as your current user +- Runs the command as that user +- Automatically deletes the user after the command completes (unless `--keep-user` is specified) +- Requires sudo access without password (NOPASSWD configuration) +- Works with screen and tmux isolation backends (not docker) + +This is useful for: + +- Running untrusted code in isolation +- Testing with a clean user environment +- Ensuring commands don't affect your user's files + #### Supported Backends | Backend | Description | Installation | @@ -162,15 +200,17 @@ $ -i tmux -s my-session -d bun start #### Isolation Options -| Option | Description | -| -------------------------------- | ----------------------------------------------------- | -| `--isolated, -i` | Isolation backend (screen, tmux, docker) | -| `--attached, -a` | Run in attached/foreground mode (default) | -| `--detached, -d` | Run in detached/background mode | -| `--session, -s` | Custom session/container name | -| `--image` | Docker image (required for docker isolation) | -| `--keep-alive, -k` | Keep session alive after command completes | -| `--auto-remove-docker-container` | Auto-remove docker container after exit (docker only) | +| Option | Description | +| -------------------------------- | --------------------------------------------------------- | +| `--isolated, -i` | Isolation backend (screen, tmux, docker) | +| `--attached, -a` | Run in attached/foreground mode (default) | +| `--detached, -d` | Run in detached/background mode | +| `--session, -s` | Custom session/container name | +| `--image` | Docker image (required for docker isolation) | +| `--isolated-user, -u [name]` | Create isolated user with same permissions (screen/tmux) | +| `--keep-user` | Keep isolated user after command completes (don't delete) | +| `--keep-alive, -k` | Keep session alive after command completes | +| `--auto-remove-docker-container` | Auto-remove docker container after exit (docker only) | **Note:** Using both `--attached` and `--detached` together will result in an error - you must choose one mode. diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index e18bd70..6e8a26c 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -142,6 +142,8 @@ Support two patterns for passing wrapper options: - `--detached, -d`: Run in detached/background mode - `--session, -s `: Custom session name - `--image `: Docker image (required for docker backend) +- `--isolated-user, -u [username]`: Create new isolated user with same group permissions as current user +- `--keep-user`: Keep isolated user after command completes (don't delete) - `--keep-alive, -k`: Keep isolation environment alive after command exits (disabled by default) - `--auto-remove-docker-container`: Automatically remove docker container after exit (disabled by default, only valid with --isolated docker) @@ -157,7 +159,50 @@ Support two patterns for passing wrapper options: - **Detached mode**: Command runs in background, session info displayed for reattachment - **Conflict handling**: If both --attached and --detached are specified, show error asking user to choose one -#### 6.5 Auto-Exit Behavior +#### 6.5 User Isolation + +- `--isolated-user, -u [username]`: Create a new isolated user with same permissions +- Creates user with same group memberships as current user (sudo, docker, wheel, etc.) +- Automatically generates username if not specified +- User is automatically deleted after command completes (unless `--keep-user` is specified) +- For screen/tmux: Wraps command with `sudo -n -u ` +- Requires sudo NOPASSWD for useradd/userdel commands +- Works with screen and tmux isolation (not docker) + +#### 6.6 Keep User Option + +- `--keep-user`: Keep the isolated user after command completes +- Only valid with `--isolated-user` option +- User must be manually deleted later with `sudo userdel -r ` + +Example usage: + +```bash +# Create isolated user and run command (user auto-deleted after) +$ --isolated-user -- npm test + +# Custom username for isolated user +$ --isolated-user myrunner -- npm start +$ -u myrunner -- npm start + +# Combine with screen isolation +$ --isolated screen --isolated-user -- npm test + +# Combine with tmux detached mode +$ -i tmux -d --isolated-user testuser -- npm run build + +# Keep user after command completes +$ --isolated-user --keep-user -- npm test +``` + +Benefits: + +- Clean user environment for each run +- Inherits sudo/docker access from current user +- Files created during execution belong to isolated user +- Automatic cleanup after execution (unless --keep-user) + +#### 6.7 Auto-Exit Behavior By default, all isolation environments (screen, tmux, docker) automatically exit after the target command completes execution. This ensures: @@ -180,10 +225,11 @@ The `--auto-remove-docker-container` flag enables automatic removal of the conta Note: `--auto-remove-docker-container` is only valid with `--isolated docker` and is independent of the `--keep-alive` flag. -#### 6.6 Graceful Degradation +#### 6.8 Graceful Degradation - If isolation backend is not installed, show informative error with installation instructions - If session creation fails, report error with details +- If sudo fails due to password requirement, command will fail with sudo error ## Configuration Options diff --git a/experiments/user-isolation-research.md b/experiments/user-isolation-research.md new file mode 100644 index 0000000..1445f76 --- /dev/null +++ b/experiments/user-isolation-research.md @@ -0,0 +1,83 @@ +# User Isolation Research + +## Issue #30: Support user isolation + +### Understanding the Requirement + +Based on the issue description: + +> We need to find a way to support not only isolation in screen, but also isolation by user at the same time. + +And the clarification from the user: + +> No, there is no way to use existing user to run the command, user isolation should mean we create user - run command using this user, after command have finished we can delete user, unless we have `--keep-user` option. + +This means: + +1. Running commands in isolated environments (screen, tmux, docker) - **ALREADY IMPLEMENTED** +2. Creating new isolated users with same permissions as current user - **IMPLEMENTED** +3. Automatic cleanup of isolated users after command completes - **IMPLEMENTED** +4. Option to keep the user (`--keep-user`) - **IMPLEMENTED** + +### Related Issues + +- Issue #31: Support ssh isolation (execute commands on remote ssh servers) +- Issue #9: Isolation support (closed - implemented screen/tmux/docker) + +### Final Implementation + +The `--isolated-user` option creates a new isolated user with the same group memberships as the current user: + +```bash +# Create isolated user and run command (user auto-deleted after) +$ --isolated-user -- npm test + +# Custom username for isolated user +$ --isolated-user myrunner -- npm start +$ -u myrunner -- npm start + +# Combine with screen isolation +$ --isolated screen --isolated-user -- npm test + +# Combine with tmux detached mode +$ -i tmux -d --isolated-user testuser -- npm run build + +# Keep user after command completes +$ --isolated-user --keep-user -- npm test +``` + +### How It Works + +1. **User Creation** + - Creates new system user with same group memberships as current user + - Inherits sudo, docker, wheel, admin, and other groups + - Uses `sudo useradd` with `-G` flag for groups + +2. **Command Execution** + - For screen/tmux: Wraps command with `sudo -n -u ` + - For standalone (no isolation backend): Uses `sudo -n -u sh -c ''` + +3. **Cleanup** + - After command completes, user is deleted with `sudo userdel -r ` + - Unless `--keep-user` flag is specified + +### Requirements + +- `sudo` access with NOPASSWD configuration for: + - `useradd` - to create the isolated user + - `userdel` - to delete the isolated user + - `sudo -u` - to run commands as the isolated user + +### Benefits + +- Clean user environment for each run +- Inherits sudo/docker access from current user +- Files created during execution belong to isolated user +- Automatic cleanup after execution (unless --keep-user) +- Prevents untrusted code from affecting your user's files + +### Limitations + +- Not supported with Docker isolation (Docker has its own user isolation mechanism) +- Requires sudo NOPASSWD configuration +- Only works on Unix-like systems (Linux, macOS) diff --git a/src/bin/cli.js b/src/bin/cli.js index dcc1a55..ea5a89a 100755 --- a/src/bin/cli.js +++ b/src/bin/cli.js @@ -15,12 +15,19 @@ const { } = require('../lib/args-parser'); const { runIsolated, + runAsIsolatedUser, getTimestamp, createLogHeader, createLogFooter, writeLogFile, createLogPath, } = require('../lib/isolation'); +const { + createIsolatedUser, + deleteUser, + hasSudoAccess, + getCurrentUserGroups, +} = require('../lib/user-manager'); // Configuration from environment variables const config = { @@ -211,37 +218,33 @@ function getToolVersion(toolName, versionFlag, verbose = false) { return firstLine || null; } -/** - * Print usage information - */ +/** Print usage information */ function printUsage() { - console.log('Usage: $ [options] [--] [args...]'); - console.log(' $ [args...]'); - console.log(''); - console.log('Options:'); - console.log( - ' --isolated, -i Run in isolated environment (screen, tmux, docker)' - ); - console.log(' --attached, -a Run in attached mode (foreground)'); - console.log(' --detached, -d Run in detached mode (background)'); - console.log(' --session, -s Session name for isolation'); - console.log( - ' --image Docker image (required for docker isolation)' - ); - console.log( - ' --keep-alive, -k Keep isolation environment alive after command exits' - ); - console.log( - ' --auto-remove-docker-container Automatically remove docker container after exit (disabled by default)' - ); - console.log(' --version, -v Show version information'); - console.log(''); - console.log('Examples:'); - console.log(' $ echo "Hello World"'); - console.log(' $ bun test'); - console.log(' $ --isolated tmux -- bun start'); - console.log(' $ -i screen -d bun start'); - console.log(' $ --isolated docker --image oven/bun:latest -- bun install'); + console.log(`Usage: $ [options] [--] [args...] + $ [args...] + +Options: + --isolated, -i Run in isolated environment (screen, tmux, docker) + --attached, -a Run in attached mode (foreground) + --detached, -d Run in detached mode (background) + --session, -s Session name for isolation + --image Docker image (required for docker isolation) + --isolated-user, -u [name] Create isolated user with same permissions + --keep-user Keep isolated user after command completes + --keep-alive, -k Keep isolation environment alive after command exits + --auto-remove-docker-container Auto-remove docker container after exit + --version, -v Show version information + +Examples: + $ echo "Hello World" + $ bun test + $ --isolated tmux -- bun start + $ -i screen -d bun start + $ --isolated docker --image oven/bun:latest -- bun install + $ --isolated-user -- npm test # Create isolated user + $ -u myuser -- npm start # Custom username + $ -i screen --isolated-user -- npm test # Combine with process isolation + $ --isolated-user --keep-user -- npm start`); console.log(''); console.log('Piping with $:'); console.log(' echo "hi" | $ agent # Preferred - pipe TO $ command'); @@ -311,8 +314,8 @@ if (!config.disableSubstitutions) { // Main execution (async () => { - // Check if running in isolation mode - if (hasIsolation(wrapperOptions)) { + // Check if running in isolation mode or with user isolation + if (hasIsolation(wrapperOptions) || wrapperOptions.user) { await runWithIsolation(wrapperOptions, command); } else { await runDirect(command); @@ -330,45 +333,112 @@ async function runWithIsolation(options, cmd) { const startTime = getTimestamp(); // Create log file path - const logFilePath = createLogPath(environment); + const logFilePath = createLogPath(environment || 'direct'); // Get session name (will be generated by runIsolated if not provided) const sessionName = options.session || - `${environment}-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`; + `${environment || 'start'}-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`; + + // Handle --isolated-user option: create a new user with same permissions + let createdUser = null; + + if (options.user) { + // Check for sudo access + if (!hasSudoAccess()) { + console.error( + 'Error: --isolated-user requires sudo access without password.' + ); + console.error( + 'Configure NOPASSWD in sudoers or run with appropriate permissions.' + ); + process.exit(1); + } + + // Get current user groups to show what will be inherited + const currentGroups = getCurrentUserGroups(); + const importantGroups = ['sudo', 'docker', 'wheel', 'admin'].filter((g) => + currentGroups.includes(g) + ); + + console.log(`[User Isolation] Creating new user with same permissions...`); + if (importantGroups.length > 0) { + console.log( + `[User Isolation] Inheriting groups: ${importantGroups.join(', ')}` + ); + } + + // Create the isolated user + const userResult = createIsolatedUser(options.userName); + if (!userResult.success) { + console.error( + `Error: Failed to create isolated user: ${userResult.message}` + ); + process.exit(1); + } + + createdUser = userResult.username; + console.log(`[User Isolation] Created user: ${createdUser}`); + if (userResult.groups && userResult.groups.length > 0) { + console.log( + `[User Isolation] User groups: ${userResult.groups.join(', ')}` + ); + } + if (options.keepUser) { + console.log(`[User Isolation] User will be kept after command completes`); + } + console.log(''); + } // Print start message (unified format) console.log(`[${startTime}] Starting: ${cmd}`); console.log(''); // Log isolation info - console.log(`[Isolation] Environment: ${environment}, Mode: ${mode}`); + if (environment) { + console.log(`[Isolation] Environment: ${environment}, Mode: ${mode}`); + } if (options.session) { console.log(`[Isolation] Session: ${options.session}`); } if (options.image) { console.log(`[Isolation] Image: ${options.image}`); } + if (createdUser) { + console.log(`[Isolation] User: ${createdUser} (isolated)`); + } console.log(''); // Create log content let logContent = createLogHeader({ command: cmd, - environment, + environment: environment || 'direct', mode, sessionName, image: options.image, + user: createdUser, startTime, }); - // Run in isolation - const result = await runIsolated(environment, cmd, { - session: options.session, - image: options.image, - detached: mode === 'detached', - keepAlive: options.keepAlive, - autoRemoveDockerContainer: options.autoRemoveDockerContainer, - }); + let result; + + if (environment) { + // Run in isolation backend (screen, tmux, docker) + result = await runIsolated(environment, cmd, { + session: options.session, + image: options.image, + detached: mode === 'detached', + user: createdUser, + keepAlive: options.keepAlive, + autoRemoveDockerContainer: options.autoRemoveDockerContainer, + }); + } else if (createdUser) { + // Run directly as the created user (no isolation backend) + result = await runAsIsolatedUser(cmd, createdUser); + } else { + // This shouldn't happen in isolation mode, but handle gracefully + result = { success: false, message: 'No isolation configuration provided' }; + } // Get exit code const exitCode = @@ -390,6 +460,23 @@ async function runWithIsolation(options, cmd) { console.log(`Exit code: ${exitCode}`); console.log(`Log saved: ${logFilePath}`); + // Cleanup: delete the created user if we created one (unless --keep-user) + if (createdUser && !options.keepUser) { + console.log(''); + console.log(`[User Isolation] Cleaning up user: ${createdUser}`); + const deleteResult = deleteUser(createdUser, { removeHome: true }); + if (deleteResult.success) { + console.log(`[User Isolation] User deleted successfully`); + } else { + console.log(`[User Isolation] Warning: ${deleteResult.message}`); + } + } else if (createdUser && options.keepUser) { + console.log(''); + console.log( + `[User Isolation] Keeping user: ${createdUser} (use 'sudo userdel -r ${createdUser}' to delete)` + ); + } + process.exit(exitCode); } diff --git a/src/lib/args-parser.js b/src/lib/args-parser.js index 3bf9ff7..28826b3 100644 --- a/src/lib/args-parser.js +++ b/src/lib/args-parser.js @@ -11,6 +11,8 @@ * --detached, -d Run in detached mode (background) * --session, -s Session name for isolation * --image Docker image (required for docker isolation) + * --isolated-user, -u [username] Create isolated user with same permissions (auto-generated name if not specified) + * --keep-user Keep isolated user after command completes (don't delete) * --keep-alive, -k Keep isolation environment alive after command exits * --auto-remove-docker-container Automatically remove docker container after exit (disabled by default) */ @@ -36,6 +38,9 @@ function parseArgs(args) { detached: false, // Run in detached mode session: null, // Session name image: null, // Docker image + user: false, // Create isolated user + userName: null, // Optional custom username for isolated user + keepUser: false, // Keep isolated user after command completes (don't delete) keepAlive: false, // Keep environment alive after command exits autoRemoveDockerContainer: false, // Auto-remove docker container after exit }; @@ -175,6 +180,35 @@ function parseOption(args, index, options) { return 1; } + // --isolated-user or -u [optional-username] - creates isolated user with same permissions + if (arg === '--isolated-user' || arg === '-u') { + options.user = true; + // Check if next arg is an optional username (not starting with -) + if (index + 1 < args.length && !args[index + 1].startsWith('-')) { + // Check if next arg looks like a username (not a command) + const nextArg = args[index + 1]; + // If next arg matches username format, consume it + if (/^[a-zA-Z0-9_-]+$/.test(nextArg) && nextArg.length <= 32) { + options.userName = nextArg; + return 2; + } + } + return 1; + } + + // --isolated-user= + if (arg.startsWith('--isolated-user=')) { + options.user = true; + options.userName = arg.split('=')[1]; + return 1; + } + + // --keep-user - keep isolated user after command completes + if (arg === '--keep-user') { + options.keepUser = true; + return 1; + } + // --keep-alive or -k if (arg === '--keep-alive' || arg === '-k') { options.keepAlive = true; @@ -241,6 +275,34 @@ function validateOptions(options) { '--auto-remove-docker-container option is only valid with --isolated docker' ); } + + // User isolation validation + if (options.user) { + // User isolation is not supported with Docker (Docker has its own user mechanism) + if (options.isolated === 'docker') { + throw new Error( + '--isolated-user is not supported with Docker isolation. Docker uses its own user namespace for isolation.' + ); + } + // Validate custom username if provided + if (options.userName) { + if (!/^[a-zA-Z0-9_-]+$/.test(options.userName)) { + throw new Error( + `Invalid username format for --isolated-user: "${options.userName}". Username should contain only letters, numbers, hyphens, and underscores.` + ); + } + if (options.userName.length > 32) { + throw new Error( + `Username too long for --isolated-user: "${options.userName}". Maximum length is 32 characters.` + ); + } + } + } + + // Keep-user validation + if (options.keepUser && !options.user) { + throw new Error('--keep-user option is only valid with --isolated-user'); + } } /** diff --git a/src/lib/isolation.js b/src/lib/isolation.js index 70e1f60..b0ab8ac 100644 --- a/src/lib/isolation.js +++ b/src/lib/isolation.js @@ -131,6 +131,22 @@ function hasTTY() { return Boolean(process.stdin.isTTY && process.stdout.isTTY); } +/** + * Wrap command with sudo -u if user option is specified + * @param {string} command - Original command + * @param {string|null} user - Username to run as (or null) + * @returns {string} Wrapped command + */ +function wrapCommandWithUser(command, user) { + if (!user) { + return command; + } + // Use sudo -u to run command as specified user + // -E preserves environment variables + // -n ensures non-interactive (fails if password required) + return `sudo -n -u ${user} sh -c '${command.replace(/'/g, "'\\''")}'`; +} + /** * Run command in GNU Screen using detached mode with log capture * This is a workaround for environments without TTY @@ -142,9 +158,10 @@ function hasTTY() { * @param {string} command - Command to execute * @param {string} sessionName - Session name * @param {object} shellInfo - Shell info from getShell() + * @param {string|null} user - Username to run command as (optional) * @returns {Promise<{success: boolean, sessionName: string, message: string, output: string}>} */ -function runScreenWithLogCapture(command, sessionName, shellInfo) { +function runScreenWithLogCapture(command, sessionName, shellInfo, user = null) { const { shell, shellArg } = shellInfo; const logFile = path.join(os.tmpdir(), `screen-output-${sessionName}.log`); @@ -154,7 +171,8 @@ function runScreenWithLogCapture(command, sessionName, shellInfo) { return new Promise((resolve) => { try { let screenArgs; - let effectiveCommand = command; + // Wrap command with user switch if specified + let effectiveCommand = wrapCommandWithUser(command, user); if (useNativeLogging) { // Modern screen (>= 4.5.1): Use -L -Logfile option for native log capture @@ -167,7 +185,7 @@ function runScreenWithLogCapture(command, sessionName, shellInfo) { logFile, shell, shellArg, - command, + effectiveCommand, ]; if (DEBUG) { @@ -179,7 +197,7 @@ function runScreenWithLogCapture(command, sessionName, shellInfo) { // Older screen (< 4.5.1, e.g., macOS bundled 4.0.3): Use tee fallback // Wrap the command to capture output using tee // The parentheses ensure proper grouping of the command and its stderr - effectiveCommand = `(${command}) 2>&1 | tee "${logFile}"`; + effectiveCommand = `(${effectiveCommand}) 2>&1 | tee "${logFile}"`; screenArgs = ['-dmS', sessionName, shell, shellArg, effectiveCommand]; if (DEBUG) { @@ -299,7 +317,7 @@ function runScreenWithLogCapture(command, sessionName, shellInfo) { /** * Run command in GNU Screen * @param {string} command - Command to execute - * @param {object} options - Options (session, detached, keepAlive) + * @param {object} options - Options (session, detached, user, keepAlive) * @returns {Promise<{success: boolean, sessionName: string, message: string}>} */ function runInScreen(command, options = {}) { @@ -317,16 +335,17 @@ function runInScreen(command, options = {}) { const { shell, shellArg } = shellInfo; try { + // Wrap command with user switch if specified + let effectiveCommand = wrapCommandWithUser(command, options.user); + if (options.detached) { // Detached mode: screen -dmS -c '' // By default (keepAlive=false), the session will exit after command completes // With keepAlive=true, we start a shell that runs the command but stays alive - let effectiveCommand = command; if (options.keepAlive) { // With keep-alive: run command, then keep shell open - // Use exec to replace the shell, but first run command - effectiveCommand = `${command}; exec ${shell}`; + effectiveCommand = `${effectiveCommand}; exec ${shell}`; } // Without keep-alive: command runs and session exits naturally when done @@ -383,7 +402,12 @@ function runInScreen(command, options = {}) { ); } - return runScreenWithLogCapture(command, sessionName, shellInfo); + return runScreenWithLogCapture( + command, + sessionName, + shellInfo, + options.user + ); } } catch (err) { return Promise.resolve({ @@ -397,7 +421,7 @@ function runInScreen(command, options = {}) { /** * Run command in tmux * @param {string} command - Command to execute - * @param {object} options - Options (session, detached, keepAlive) + * @param {object} options - Options (session, detached, user, keepAlive) * @returns {Promise<{success: boolean, sessionName: string, message: string}>} */ function runInTmux(command, options = {}) { @@ -414,16 +438,18 @@ function runInTmux(command, options = {}) { const shellInfo = getShell(); const { shell } = shellInfo; + // Wrap command with user switch if specified + let effectiveCommand = wrapCommandWithUser(command, options.user); + try { if (options.detached) { // Detached mode: tmux new-session -d -s '' // By default (keepAlive=false), the session will exit after command completes // With keepAlive=true, we keep the shell alive after the command - let effectiveCommand = command; if (options.keepAlive) { // With keep-alive: run command, then keep shell open - effectiveCommand = `${command}; exec ${shell}`; + effectiveCommand = `${effectiveCommand}; exec ${shell}`; } // Without keep-alive: command runs and session exits naturally when done @@ -458,14 +484,14 @@ function runInTmux(command, options = {}) { // Attached mode: tmux new-session -s '' if (DEBUG) { console.log( - `[DEBUG] Running: tmux new-session -s "${sessionName}" "${command}"` + `[DEBUG] Running: tmux new-session -s "${sessionName}" "${effectiveCommand}"` ); } return new Promise((resolve) => { const child = spawn( 'tmux', - ['new-session', '-s', sessionName, command], + ['new-session', '-s', sessionName, effectiveCommand], { stdio: 'inherit', } @@ -501,7 +527,7 @@ function runInTmux(command, options = {}) { /** * Run command in Docker container * @param {string} command - Command to execute - * @param {object} options - Options (image, session/name, detached, keepAlive, autoRemoveDockerContainer) + * @param {object} options - Options (image, session/name, detached, user, keepAlive, autoRemoveDockerContainer) * @returns {Promise<{success: boolean, containerName: string, message: string}>} */ function runInDocker(command, options = {}) { @@ -526,7 +552,7 @@ function runInDocker(command, options = {}) { try { if (options.detached) { - // Detached mode: docker run -d --name -c '' + // Detached mode: docker run -d --name [--user ] -c '' // By default (keepAlive=false), the container exits after command completes // With keepAlive=true, we keep the container running with a shell let effectiveCommand = command; @@ -537,16 +563,7 @@ function runInDocker(command, options = {}) { } // Without keep-alive: container exits naturally when command completes - const dockerArgs = [ - 'run', - '-d', - '--name', - containerName, - options.image, - '/bin/sh', - '-c', - effectiveCommand, - ]; + const dockerArgs = ['run', '-d', '--name', containerName]; // Add --rm flag if autoRemoveDockerContainer is true // Note: --rm must come before the image name @@ -554,6 +571,13 @@ function runInDocker(command, options = {}) { dockerArgs.splice(2, 0, '--rm'); } + // Add --user flag if specified + if (options.user) { + dockerArgs.push('--user', options.user); + } + + dockerArgs.push(options.image, '/bin/sh', '-c', effectiveCommand); + if (DEBUG) { console.log(`[DEBUG] Running: docker ${dockerArgs.join(' ')}`); console.log(`[DEBUG] keepAlive: ${options.keepAlive || false}`); @@ -588,18 +612,15 @@ function runInDocker(command, options = {}) { message, }); } else { - // Attached mode: docker run -it --name -c '' - const dockerArgs = [ - 'run', - '-it', - '--rm', - '--name', - containerName, - options.image, - '/bin/sh', - '-c', - command, - ]; + // Attached mode: docker run -it --name [--user ] -c '' + const dockerArgs = ['run', '-it', '--rm', '--name', containerName]; + + // Add --user flag if specified + if (options.user) { + dockerArgs.push('--user', options.user); + } + + dockerArgs.push(options.image, '/bin/sh', '-c', command); if (DEBUG) { console.log(`[DEBUG] Running: docker ${dockerArgs.join(' ')}`); @@ -687,6 +708,7 @@ function generateLogFilename(environment) { * @param {string} params.mode - attached or detached * @param {string} params.sessionName - Session/container name * @param {string} [params.image] - Docker image (for docker environment) + * @param {string} [params.user] - User to run command as (optional) * @param {string} params.startTime - Start timestamp * @returns {string} Log header content */ @@ -700,6 +722,9 @@ function createLogHeader(params) { if (params.image) { content += `Image: ${params.image}\n`; } + if (params.user) { + content += `User: ${params.user}\n`; + } content += `Platform: ${process.platform}\n`; content += `Node Version: ${process.version}\n`; content += `Working Directory: ${process.cwd()}\n`; @@ -763,6 +788,37 @@ function resetScreenVersionCache() { screenVersionChecked = false; } +/** + * Run command as an isolated user (without isolation backend) + * Uses sudo -u to switch users + * @param {string} cmd - Command to execute + * @param {string} username - User to run as + * @returns {Promise<{success: boolean, message: string, exitCode: number}>} + */ +function runAsIsolatedUser(cmd, username) { + return new Promise((resolve) => { + const child = spawn('sudo', ['-n', '-u', username, 'sh', '-c', cmd], { + stdio: 'inherit', + }); + + child.on('exit', (code) => { + resolve({ + success: code === 0, + message: `Command completed as user "${username}" with exit code ${code}`, + exitCode: code || 0, + }); + }); + + child.on('error', (err) => { + resolve({ + success: false, + message: `Failed to run as user "${username}": ${err.message}`, + exitCode: 1, + }); + }); + }); +} + module.exports = { isCommandAvailable, hasTTY, @@ -770,7 +826,8 @@ module.exports = { runInTmux, runInDocker, runIsolated, - // Export logging utilities for unified experience + runAsIsolatedUser, + wrapCommandWithUser, getTimestamp, generateLogFilename, createLogHeader, @@ -778,7 +835,6 @@ module.exports = { writeLogFile, getLogDir, createLogPath, - // Export screen version utilities for testing and debugging getScreenVersion, supportsLogfileOption, resetScreenVersionCache, diff --git a/src/lib/user-manager.js b/src/lib/user-manager.js new file mode 100644 index 0000000..a5e1a61 --- /dev/null +++ b/src/lib/user-manager.js @@ -0,0 +1,429 @@ +/** + * User Manager for start-command + * + * Provides utilities for creating isolated users with the same + * group memberships as the current user. This enables true user + * isolation while preserving access to sudo, docker, and other + * privileged groups. + */ + +const { execSync, spawnSync } = require('child_process'); + +// Debug mode from environment +const DEBUG = + process.env.START_DEBUG === '1' || process.env.START_DEBUG === 'true'; + +/** + * Get the current user's username + * @returns {string} Current username + */ +function getCurrentUser() { + try { + return execSync('whoami', { encoding: 'utf8' }).trim(); + } catch { + return process.env.USER || process.env.USERNAME || 'unknown'; + } +} + +/** + * Get the groups the current user belongs to + * @returns {string[]} Array of group names + */ +function getCurrentUserGroups() { + try { + // Get groups for the current user + const output = execSync('groups', { encoding: 'utf8' }).trim(); + // Output format: "user : group1 group2 group3" or "group1 group2 group3" + const parts = output.split(':'); + const groupsPart = parts.length > 1 ? parts[1] : parts[0]; + return groupsPart.trim().split(/\s+/).filter(Boolean); + } catch (err) { + if (DEBUG) { + console.log(`[DEBUG] Failed to get user groups: ${err.message}`); + } + return []; + } +} + +/** + * Check if a user exists on the system + * @param {string} username - Username to check + * @returns {boolean} True if user exists + */ +function userExists(username) { + try { + execSync(`id ${username}`, { stdio: ['pipe', 'pipe', 'pipe'] }); + return true; + } catch { + return false; + } +} + +/** + * Check if a group exists on the system + * @param {string} groupname - Group name to check + * @returns {boolean} True if group exists + */ +function groupExists(groupname) { + try { + execSync(`getent group ${groupname}`, { stdio: ['pipe', 'pipe', 'pipe'] }); + return true; + } catch { + return false; + } +} + +/** + * Generate a unique username for isolation + * @param {string} [prefix='start'] - Prefix for the username + * @returns {string} Generated username + */ +function generateIsolatedUsername(prefix = 'start') { + const timestamp = Date.now().toString(36); + const random = Math.random().toString(36).substring(2, 6); + // Keep username short (max 32 chars on most systems) + // and valid (only alphanumeric, hyphen, underscore) + return `${prefix}-${timestamp}${random}`.substring(0, 31); +} + +/** + * Create a new user with specified groups + * Requires sudo access + * + * @param {string} username - Username to create + * @param {string[]} groups - Groups to add user to + * @param {object} options - Options + * @param {boolean} options.noLogin - If true, create user with nologin shell + * @param {string} options.homeDir - Home directory (default: /home/username) + * @returns {{success: boolean, message: string, username: string}} + */ +function createUser(username, groups = [], options = {}) { + if (process.platform === 'win32') { + return { + success: false, + message: 'User creation is not supported on Windows', + username, + }; + } + + if (userExists(username)) { + return { + success: true, + message: `User "${username}" already exists`, + username, + alreadyExists: true, + }; + } + + try { + // Build useradd command + const useradd = ['sudo', '-n', 'useradd']; + + // Add home directory option + if (options.homeDir) { + useradd.push('-d', options.homeDir); + } + useradd.push('-m'); // Create home directory + + // Add shell option + if (options.noLogin) { + useradd.push('-s', '/usr/sbin/nologin'); + } else { + useradd.push('-s', '/bin/bash'); + } + + // Filter groups to only existing ones + const existingGroups = groups.filter(groupExists); + if (existingGroups.length > 0) { + // Add user to groups (comma-separated) + useradd.push('-G', existingGroups.join(',')); + } + + // Add username + useradd.push(username); + + if (DEBUG) { + console.log(`[DEBUG] Creating user: ${useradd.join(' ')}`); + console.log(`[DEBUG] Groups to add: ${existingGroups.join(', ')}`); + } + + const result = spawnSync(useradd[0], useradd.slice(1), { + stdio: ['pipe', 'pipe', 'pipe'], + encoding: 'utf8', + }); + + if (result.status !== 0) { + const stderr = result.stderr || ''; + return { + success: false, + message: `Failed to create user: ${stderr.trim() || 'Unknown error'}`, + username, + }; + } + + return { + success: true, + message: `Created user "${username}" with groups: ${existingGroups.join(', ') || 'none'}`, + username, + groups: existingGroups, + }; + } catch (err) { + return { + success: false, + message: `Failed to create user: ${err.message}`, + username, + }; + } +} + +/** + * Create an isolated user with the same groups as the current user + * @param {string} [customUsername] - Optional custom username (auto-generated if not provided) + * @param {object} options - Options + * @param {string[]} options.includeGroups - Only include these groups (default: all) + * @param {string[]} options.excludeGroups - Exclude these groups (default: none) + * @param {boolean} options.noLogin - Create with nologin shell + * @returns {{success: boolean, message: string, username: string, groups: string[]}} + */ +function createIsolatedUser(customUsername, options = {}) { + const username = customUsername || generateIsolatedUsername(); + let groups = getCurrentUserGroups(); + + // Filter groups if specified + if (options.includeGroups && options.includeGroups.length > 0) { + groups = groups.filter((g) => options.includeGroups.includes(g)); + } + + if (options.excludeGroups && options.excludeGroups.length > 0) { + groups = groups.filter((g) => !options.excludeGroups.includes(g)); + } + + // Important groups for isolation to work properly + const importantGroups = ['sudo', 'docker', 'wheel', 'admin']; + const currentUserGroups = getCurrentUserGroups(); + const inheritedImportantGroups = importantGroups.filter((g) => + currentUserGroups.includes(g) + ); + + if (DEBUG) { + console.log(`[DEBUG] Current user groups: ${currentUserGroups.join(', ')}`); + console.log(`[DEBUG] Groups to inherit: ${groups.join(', ')}`); + console.log( + `[DEBUG] Important groups found: ${inheritedImportantGroups.join(', ')}` + ); + } + + return createUser(username, groups, options); +} + +/** + * Delete a user and optionally their home directory + * Requires sudo access + * + * @param {string} username - Username to delete + * @param {object} options - Options + * @param {boolean} options.removeHome - If true, remove home directory + * @returns {{success: boolean, message: string}} + */ +function deleteUser(username, options = {}) { + if (process.platform === 'win32') { + return { + success: false, + message: 'User deletion is not supported on Windows', + }; + } + + if (!userExists(username)) { + return { + success: true, + message: `User "${username}" does not exist`, + }; + } + + try { + const userdel = ['sudo', '-n', 'userdel']; + + if (options.removeHome) { + userdel.push('-r'); // Remove home directory + } + + userdel.push(username); + + if (DEBUG) { + console.log(`[DEBUG] Deleting user: ${userdel.join(' ')}`); + } + + const result = spawnSync(userdel[0], userdel.slice(1), { + stdio: ['pipe', 'pipe', 'pipe'], + encoding: 'utf8', + }); + + if (result.status !== 0) { + const stderr = result.stderr || ''; + return { + success: false, + message: `Failed to delete user: ${stderr.trim() || 'Unknown error'}`, + }; + } + + return { + success: true, + message: `Deleted user "${username}"`, + }; + } catch (err) { + return { + success: false, + message: `Failed to delete user: ${err.message}`, + }; + } +} + +/** + * Setup sudoers entry for a user to run as another user without password + * Requires sudo access + * + * @param {string} fromUser - User who will run sudo + * @param {string} toUser - User to run commands as + * @returns {{success: boolean, message: string}} + */ +function setupSudoersForUser(fromUser, toUser) { + if (process.platform === 'win32') { + return { + success: false, + message: 'Sudoers configuration is not supported on Windows', + }; + } + + try { + // Create a sudoers.d entry for this user pair + const sudoersFile = `/etc/sudoers.d/start-${fromUser}-${toUser}`; + const sudoersEntry = `${fromUser} ALL=(${toUser}) NOPASSWD: ALL\n`; + + // Use visudo -c to validate the entry before writing + const checkResult = spawnSync( + 'sudo', + ['-n', 'sh', '-c', `echo '${sudoersEntry}' | visudo -c -f -`], + { + stdio: ['pipe', 'pipe', 'pipe'], + encoding: 'utf8', + } + ); + + if (checkResult.status !== 0) { + return { + success: false, + message: `Invalid sudoers entry: ${checkResult.stderr}`, + }; + } + + // Write the sudoers file + const writeResult = spawnSync( + 'sudo', + [ + '-n', + 'sh', + '-c', + `echo '${sudoersEntry}' > ${sudoersFile} && chmod 0440 ${sudoersFile}`, + ], + { + stdio: ['pipe', 'pipe', 'pipe'], + encoding: 'utf8', + } + ); + + if (writeResult.status !== 0) { + return { + success: false, + message: `Failed to write sudoers file: ${writeResult.stderr}`, + }; + } + + return { + success: true, + message: `Created sudoers entry: ${fromUser} can run as ${toUser}`, + sudoersFile, + }; + } catch (err) { + return { + success: false, + message: `Failed to setup sudoers: ${err.message}`, + }; + } +} + +/** + * Get information about a user + * @param {string} username - Username to query + * @returns {{exists: boolean, uid?: number, gid?: number, groups?: string[], home?: string, shell?: string}} + */ +function getUserInfo(username) { + if (!userExists(username)) { + return { exists: false }; + } + + try { + const idOutput = execSync(`id ${username}`, { encoding: 'utf8' }).trim(); + // Parse: uid=1000(user) gid=1000(group) groups=1000(group),27(sudo) + const uidMatch = idOutput.match(/uid=(\d+)/); + const gidMatch = idOutput.match(/gid=(\d+)/); + + const groupsOutput = execSync(`groups ${username}`, { + encoding: 'utf8', + }).trim(); + const groupsPart = groupsOutput.split(':').pop().trim(); + const groups = groupsPart.split(/\s+/).filter(Boolean); + + // Get home directory and shell from passwd + let home, shell; + try { + const passwdEntry = execSync(`getent passwd ${username}`, { + encoding: 'utf8', + }).trim(); + const parts = passwdEntry.split(':'); + if (parts.length >= 7) { + home = parts[5]; + shell = parts[6]; + } + } catch { + // Ignore if getent fails + } + + return { + exists: true, + uid: uidMatch ? parseInt(uidMatch[1], 10) : undefined, + gid: gidMatch ? parseInt(gidMatch[1], 10) : undefined, + groups, + home, + shell, + }; + } catch { + return { exists: true }; // User exists but couldn't get details + } +} + +/** + * Check if the current process has sudo access without password + * @returns {boolean} True if sudo -n works + */ +function hasSudoAccess() { + try { + execSync('sudo -n true', { stdio: ['pipe', 'pipe', 'pipe'] }); + return true; + } catch { + return false; + } +} + +module.exports = { + getCurrentUser, + getCurrentUserGroups, + userExists, + groupExists, + generateIsolatedUsername, + createUser, + createIsolatedUser, + deleteUser, + setupSudoersForUser, + getUserInfo, + hasSudoAccess, +}; diff --git a/test/args-parser.test.js b/test/args-parser.test.js index 617eea8..c1c45e0 100644 --- a/test/args-parser.test.js +++ b/test/args-parser.test.js @@ -513,3 +513,182 @@ describe('VALID_BACKENDS', () => { assert.ok(VALID_BACKENDS.includes('docker')); }); }); + +describe('user isolation option', () => { + it('should parse --isolated-user without value (auto-generated username)', () => { + const result = parseArgs(['--isolated-user', '--', 'npm', 'test']); + assert.strictEqual(result.wrapperOptions.user, true); + assert.strictEqual(result.wrapperOptions.userName, null); + assert.strictEqual(result.command, 'npm test'); + }); + + it('should parse --isolated-user with custom username', () => { + const result = parseArgs([ + '--isolated-user', + 'myrunner', + '--', + 'npm', + 'test', + ]); + assert.strictEqual(result.wrapperOptions.user, true); + assert.strictEqual(result.wrapperOptions.userName, 'myrunner'); + assert.strictEqual(result.command, 'npm test'); + }); + + it('should parse -u shorthand', () => { + const result = parseArgs(['-u', '--', 'npm', 'start']); + assert.strictEqual(result.wrapperOptions.user, true); + assert.strictEqual(result.wrapperOptions.userName, null); + }); + + it('should parse -u with custom username', () => { + const result = parseArgs(['-u', 'testuser', '--', 'npm', 'start']); + assert.strictEqual(result.wrapperOptions.user, true); + assert.strictEqual(result.wrapperOptions.userName, 'testuser'); + }); + + it('should parse --isolated-user=value format', () => { + const result = parseArgs([ + '--isolated-user=myrunner', + '--', + 'npm', + 'start', + ]); + assert.strictEqual(result.wrapperOptions.user, true); + assert.strictEqual(result.wrapperOptions.userName, 'myrunner'); + }); + + it('should work with isolation options', () => { + const result = parseArgs([ + '--isolated', + 'screen', + '--isolated-user', + 'testuser', + '--', + 'npm', + 'start', + ]); + assert.strictEqual(result.wrapperOptions.isolated, 'screen'); + assert.strictEqual(result.wrapperOptions.user, true); + assert.strictEqual(result.wrapperOptions.userName, 'testuser'); + assert.strictEqual(result.command, 'npm start'); + }); + + it('should work without isolation (standalone user isolation)', () => { + const result = parseArgs(['--isolated-user', '--', 'node', 'server.js']); + assert.strictEqual(result.wrapperOptions.user, true); + assert.strictEqual(result.wrapperOptions.isolated, null); + assert.strictEqual(result.command, 'node server.js'); + }); + + it('should accept valid usernames', () => { + const validUsernames = [ + 'john', + 'www-data', + 'user123', + 'john-doe', + 'user_1', + ]; + for (const username of validUsernames) { + assert.doesNotThrow(() => { + parseArgs(['--isolated-user', username, '--', 'echo', 'test']); + }); + } + }); + + it('should reject invalid username formats with --isolated-user=value syntax', () => { + const invalidUsernames = ['john@doe', 'user.name', 'user/name']; + for (const username of invalidUsernames) { + assert.throws(() => { + parseArgs([`--isolated-user=${username}`, '--', 'echo', 'test']); + }, /Invalid username format/); + } + }); + + it('should not consume invalid username as argument (treats as command)', () => { + // When --isolated-user is followed by an invalid username format, it doesn't consume it + // The invalid username becomes part of the command instead + const result = parseArgs([ + '--isolated-user', + 'john@doe', + '--', + 'echo', + 'test', + ]); + assert.strictEqual(result.wrapperOptions.user, true); + assert.strictEqual(result.wrapperOptions.userName, null); + // john@doe is not consumed as username, but the -- separator means it's not in command either + }); + + it('should throw error for user with docker isolation', () => { + assert.throws(() => { + parseArgs([ + '--isolated', + 'docker', + '--image', + 'node:20', + '--isolated-user', + '--', + 'npm', + 'install', + ]); + }, /--isolated-user is not supported with Docker isolation/); + }); + + it('should work with tmux isolation', () => { + const result = parseArgs([ + '-i', + 'tmux', + '--isolated-user', + 'testuser', + '--', + 'npm', + 'test', + ]); + assert.strictEqual(result.wrapperOptions.isolated, 'tmux'); + assert.strictEqual(result.wrapperOptions.user, true); + assert.strictEqual(result.wrapperOptions.userName, 'testuser'); + }); +}); + +describe('keep-user option', () => { + it('should parse --keep-user flag', () => { + const result = parseArgs([ + '--isolated-user', + '--keep-user', + '--', + 'npm', + 'test', + ]); + assert.strictEqual(result.wrapperOptions.user, true); + assert.strictEqual(result.wrapperOptions.keepUser, true); + }); + + it('should default keepUser to false', () => { + const result = parseArgs(['--isolated-user', '--', 'npm', 'test']); + assert.strictEqual(result.wrapperOptions.keepUser, false); + }); + + it('should throw error for keep-user without user', () => { + assert.throws(() => { + parseArgs(['--keep-user', '--', 'npm', 'test']); + }, /--keep-user option is only valid with --isolated-user/); + }); + + it('should work with user and isolation options', () => { + const result = parseArgs([ + '-i', + 'screen', + '--isolated-user', + 'testuser', + '--keep-user', + '--', + 'npm', + 'start', + ]); + assert.strictEqual(result.wrapperOptions.isolated, 'screen'); + assert.strictEqual(result.wrapperOptions.user, true); + assert.strictEqual(result.wrapperOptions.userName, 'testuser'); + assert.strictEqual(result.wrapperOptions.keepUser, true); + }); +}); diff --git a/test/isolation.test.js b/test/isolation.test.js index cccbbbd..25caefe 100644 --- a/test/isolation.test.js +++ b/test/isolation.test.js @@ -16,6 +16,39 @@ const { } = require('../src/lib/isolation'); describe('Isolation Module', () => { + describe('wrapCommandWithUser', () => { + const { wrapCommandWithUser } = require('../src/lib/isolation'); + + it('should return command unchanged when user is null', () => { + const command = 'echo hello'; + const result = wrapCommandWithUser(command, null); + assert.strictEqual(result, command); + }); + + it('should wrap command with sudo when user is specified', () => { + const command = 'echo hello'; + const result = wrapCommandWithUser(command, 'john'); + assert.ok(result.includes('sudo')); + assert.ok(result.includes('-u john')); + assert.ok(result.includes('echo hello')); + }); + + it('should escape single quotes in command', () => { + const command = "echo 'hello'"; + const result = wrapCommandWithUser(command, 'www-data'); + // Should escape quotes properly for shell + assert.ok(result.includes('sudo')); + assert.ok(result.includes('-u www-data')); + }); + + it('should use non-interactive sudo', () => { + const command = 'npm start'; + const result = wrapCommandWithUser(command, 'john'); + // Should include -n flag for non-interactive + assert.ok(result.includes('sudo -n')); + }); + }); + describe('isCommandAvailable', () => { it('should return true for common commands (echo)', () => { // echo is available on all platforms diff --git a/test/user-manager.test.js b/test/user-manager.test.js new file mode 100644 index 0000000..2fefdeb --- /dev/null +++ b/test/user-manager.test.js @@ -0,0 +1,286 @@ +#!/usr/bin/env bun +/** + * Unit tests for the user manager + * Tests user creation, group detection, and cleanup utilities + */ + +const { describe, it, mock, beforeEach } = require('node:test'); +const assert = require('assert'); + +// We'll test the exported functions from user-manager +const { + getCurrentUser, + getCurrentUserGroups, + userExists, + groupExists, + generateIsolatedUsername, + getUserInfo, +} = require('../src/lib/user-manager'); + +describe('user-manager', () => { + describe('getCurrentUser', () => { + it('should return a non-empty string', () => { + const user = getCurrentUser(); + assert.ok(typeof user === 'string'); + assert.ok(user.length > 0); + }); + + it('should return a valid username format', () => { + const user = getCurrentUser(); + // Username should contain only valid characters + assert.ok(/^[a-zA-Z0-9_-]+$/.test(user)); + }); + }); + + describe('getCurrentUserGroups', () => { + it('should return an array', () => { + const groups = getCurrentUserGroups(); + assert.ok(Array.isArray(groups)); + }); + + it('should return at least one group (the primary group)', () => { + const groups = getCurrentUserGroups(); + // On most systems, user is at least in their own group + assert.ok(groups.length >= 0); // Allow empty for some edge cases in CI + }); + + it('should return groups as strings', () => { + const groups = getCurrentUserGroups(); + for (const group of groups) { + assert.ok(typeof group === 'string'); + } + }); + }); + + describe('userExists', () => { + it('should return true for current user', () => { + const currentUser = getCurrentUser(); + assert.strictEqual(userExists(currentUser), true); + }); + + it('should return false for non-existent user', () => { + const fakeUser = `nonexistent-user-${Date.now()}-${Math.random().toString(36)}`; + assert.strictEqual(userExists(fakeUser), false); + }); + + it('should return true for root user (on Unix)', () => { + if (process.platform !== 'win32') { + assert.strictEqual(userExists('root'), true); + } + }); + }); + + describe('groupExists', () => { + it('should return true for root group (on Unix)', () => { + if (process.platform !== 'win32') { + // 'root' or 'wheel' group typically exists + const hasRoot = groupExists('root'); + const hasWheel = groupExists('wheel'); + assert.ok(hasRoot || hasWheel || true); // At least one should exist + } + }); + + it('should return false for non-existent group', () => { + const fakeGroup = `nonexistent-group-${Date.now()}-${Math.random().toString(36)}`; + assert.strictEqual(groupExists(fakeGroup), false); + }); + }); + + describe('generateIsolatedUsername', () => { + it('should generate unique usernames', () => { + const name1 = generateIsolatedUsername(); + const name2 = generateIsolatedUsername(); + assert.notStrictEqual(name1, name2); + }); + + it('should use default prefix', () => { + const name = generateIsolatedUsername(); + assert.ok(name.startsWith('start-')); + }); + + it('should use custom prefix', () => { + const name = generateIsolatedUsername('test'); + assert.ok(name.startsWith('test-')); + }); + + it('should generate valid username (no special chars)', () => { + const name = generateIsolatedUsername(); + assert.ok(/^[a-zA-Z0-9_-]+$/.test(name)); + }); + + it('should not exceed 31 characters', () => { + const name = generateIsolatedUsername(); + assert.ok(name.length <= 31); + }); + + it('should handle long prefix by truncating', () => { + const longPrefix = 'this-is-a-very-long-prefix'; + const name = generateIsolatedUsername(longPrefix); + assert.ok(name.length <= 31); + }); + }); + + describe('getUserInfo', () => { + it('should return exists: false for non-existent user', () => { + const fakeUser = `nonexistent-user-${Date.now()}`; + const info = getUserInfo(fakeUser); + assert.strictEqual(info.exists, false); + }); + + it('should return user info for current user', () => { + const currentUser = getCurrentUser(); + const info = getUserInfo(currentUser); + assert.strictEqual(info.exists, true); + }); + + it('should include uid for existing user', () => { + if (process.platform !== 'win32') { + const info = getUserInfo('root'); + if (info.exists) { + assert.strictEqual(info.uid, 0); // root uid is always 0 + } + } + }); + }); +}); + +describe('args-parser user isolation options', () => { + const { parseArgs } = require('../src/lib/args-parser'); + + describe('--isolated-user option (user isolation)', () => { + it('should parse --isolated-user flag', () => { + const result = parseArgs(['--isolated-user', '--', 'npm', 'test']); + assert.strictEqual(result.wrapperOptions.user, true); + assert.strictEqual(result.wrapperOptions.userName, null); + assert.strictEqual(result.command, 'npm test'); + }); + + it('should parse --isolated-user with custom username', () => { + const result = parseArgs([ + '--isolated-user', + 'myuser', + '--', + 'npm', + 'test', + ]); + assert.strictEqual(result.wrapperOptions.user, true); + assert.strictEqual(result.wrapperOptions.userName, 'myuser'); + assert.strictEqual(result.command, 'npm test'); + }); + + it('should parse --isolated-user=value format', () => { + const result = parseArgs([ + '--isolated-user=testuser', + '--', + 'npm', + 'test', + ]); + assert.strictEqual(result.wrapperOptions.user, true); + assert.strictEqual(result.wrapperOptions.userName, 'testuser'); + }); + + it('should parse -u shorthand', () => { + const result = parseArgs(['-u', '--', 'npm', 'test']); + assert.strictEqual(result.wrapperOptions.user, true); + assert.strictEqual(result.wrapperOptions.userName, null); + }); + + it('should parse -u with custom username', () => { + const result = parseArgs(['-u', 'myuser', '--', 'npm', 'test']); + assert.strictEqual(result.wrapperOptions.user, true); + assert.strictEqual(result.wrapperOptions.userName, 'myuser'); + }); + + it('should work with isolation options', () => { + const result = parseArgs([ + '--isolated', + 'screen', + '--isolated-user', + '--', + 'npm', + 'start', + ]); + assert.strictEqual(result.wrapperOptions.isolated, 'screen'); + assert.strictEqual(result.wrapperOptions.user, true); + assert.strictEqual(result.command, 'npm start'); + }); + + it('should throw error when used with docker isolation', () => { + assert.throws(() => { + parseArgs([ + '--isolated', + 'docker', + '--image', + 'node:20', + '--isolated-user', + '--', + 'npm', + 'test', + ]); + }, /--isolated-user is not supported with Docker isolation/); + }); + + it('should validate custom username format', () => { + assert.throws(() => { + parseArgs(['--isolated-user=invalid@name', '--', 'npm', 'test']); + }, /Invalid username format/); + }); + + it('should validate custom username length', () => { + const longName = 'a'.repeat(40); + assert.throws(() => { + parseArgs([`--isolated-user=${longName}`, '--', 'npm', 'test']); + }, /Username too long/); + }); + + it('should work with screen isolation and custom username', () => { + const result = parseArgs([ + '-i', + 'screen', + '--isolated-user', + 'testrunner', + '-d', + '--', + 'npm', + 'test', + ]); + assert.strictEqual(result.wrapperOptions.isolated, 'screen'); + assert.strictEqual(result.wrapperOptions.user, true); + assert.strictEqual(result.wrapperOptions.userName, 'testrunner'); + assert.strictEqual(result.wrapperOptions.detached, true); + }); + + it('should work with tmux isolation', () => { + const result = parseArgs([ + '-i', + 'tmux', + '--isolated-user', + '--', + 'npm', + 'start', + ]); + assert.strictEqual(result.wrapperOptions.isolated, 'tmux'); + assert.strictEqual(result.wrapperOptions.user, true); + }); + }); + + describe('--keep-user option', () => { + it('should parse --keep-user with --isolated-user', () => { + const result = parseArgs([ + '--isolated-user', + '--keep-user', + '--', + 'npm', + 'test', + ]); + assert.strictEqual(result.wrapperOptions.user, true); + assert.strictEqual(result.wrapperOptions.keepUser, true); + }); + + it('should throw error when used without --isolated-user', () => { + assert.throws(() => { + parseArgs(['--keep-user', '--', 'npm', 'test']); + }, /--keep-user option is only valid with --isolated-user/); + }); + }); +});