Skip to main content

start_command/
isolation.rs

1//! Isolation Runners for start-command
2//!
3//! Provides execution of commands in various isolated environments:
4//! - screen: GNU Screen terminal multiplexer
5//! - tmux: tmux terminal multiplexer
6//! - docker: Docker containers
7//! - ssh: Remote SSH execution
8
9use std::env;
10use std::path::PathBuf;
11use std::process::{Command, Stdio};
12
13use crate::args_parser::generate_session_name;
14
15/// Result of an isolation run
16#[derive(Debug, Default)]
17pub struct IsolationResult {
18    /// Whether the run succeeded
19    pub success: bool,
20    /// Session or container name
21    pub session_name: Option<String>,
22    /// Container ID (for docker)
23    pub container_id: Option<String>,
24    /// Message describing the result
25    pub message: String,
26    /// Exit code
27    pub exit_code: Option<i32>,
28    /// Captured output
29    pub output: Option<String>,
30}
31
32/// Options for isolation
33#[derive(Debug, Clone)]
34pub struct IsolationOptions {
35    /// Session name
36    pub session: Option<String>,
37    /// Docker image
38    pub image: Option<String>,
39    /// SSH endpoint
40    pub endpoint: Option<String>,
41    /// Run in detached mode
42    pub detached: bool,
43    /// User to run command as
44    pub user: Option<String>,
45    /// Keep environment alive after command exits
46    pub keep_alive: bool,
47    /// Auto-remove docker container after exit
48    pub auto_remove_docker_container: bool,
49    /// Shell to use in isolation environments: auto, bash, zsh, sh
50    pub shell: String,
51    /// Log path where isolation backends should append live output
52    pub log_path: Option<PathBuf>,
53}
54
55impl Default for IsolationOptions {
56    fn default() -> Self {
57        IsolationOptions {
58            session: None,
59            image: None,
60            endpoint: None,
61            detached: false,
62            user: None,
63            keep_alive: false,
64            auto_remove_docker_container: false,
65            shell: "auto".to_string(),
66            log_path: None,
67        }
68    }
69}
70
71/// Check if a command is available on the system
72pub fn is_command_available(command: &str) -> bool {
73    let check_cmd = if cfg!(windows) { "where" } else { "which" };
74    Command::new(check_cmd)
75        .arg(command)
76        .stdout(Stdio::null())
77        .stderr(Stdio::null())
78        .status()
79        .map(|s| s.success())
80        .unwrap_or(false)
81}
82
83/// Get the shell to use for command execution
84pub fn get_shell() -> (String, String) {
85    if cfg!(windows) {
86        ("cmd.exe".to_string(), "/c".to_string())
87    } else {
88        let shell = env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
89        (shell, "-c".to_string())
90    }
91}
92
93/// Check if the current process has a TTY attached
94pub fn has_tty() -> bool {
95    atty::is(atty::Stream::Stdin) && atty::is(atty::Stream::Stdout)
96}
97
98/// Wrap command with sudo -u if user option is specified
99pub fn wrap_command_with_user(command: &str, user: Option<&str>) -> String {
100    match user {
101        Some(u) => {
102            // Escape single quotes in command
103            let escaped = command.replace('\'', "'\\''");
104            format!("sudo -n -u {} sh -c '{}'", u, escaped)
105        }
106        None => command.to_string(),
107    }
108}
109
110/// Shell names recognized as bare interactive shells (without -c flag).
111/// Mirrors JS SHELL_NAMES constant in isolation.js.
112const SHELL_NAMES: [&str; 8] = ["bash", "zsh", "sh", "fish", "ksh", "csh", "tcsh", "dash"];
113
114/// Returns true if command is a bare interactive shell invocation (no -c flag).
115/// Used to avoid double-wrapping shells in isolation environments (issue #84).
116///
117/// Examples: "bash", "zsh", "bash --norc", "/usr/local/bin/bash"
118/// Counter-examples: "bash -c echo hi", "npm test"
119pub fn is_interactive_shell_command(command: &str) -> bool {
120    let parts: Vec<&str> = command.split_whitespace().collect();
121    if parts.is_empty() {
122        return false;
123    }
124    let basename = parts[0].rsplit('/').next().unwrap_or(parts[0]);
125    SHELL_NAMES.contains(&basename) && !parts.contains(&"-c")
126}
127
128/// Returns true if command is a shell invocation that includes -c (e.g. `bash -i -c "cmd"`).
129/// Used to pass such commands directly without double-wrapping (issue #91).
130pub fn is_shell_invocation_with_args(command: &str) -> bool {
131    let parts: Vec<&str> = command.split_whitespace().collect();
132    if parts.is_empty() {
133        return false;
134    }
135    let basename = parts[0].rsplit('/').next().unwrap_or(parts[0]);
136    SHELL_NAMES.contains(&basename) && parts.contains(&"-c")
137}
138
139/// Build argv for a shell-with-c command; everything after -c is joined as one argument.
140/// Reverses the join(' ') that collapsed the original quoted argument.
141/// Used to pass `bash -i -c "nvm --version"` directly as argv (issue #91 fix).
142pub fn build_shell_with_args_cmd_args(command: &str) -> Vec<String> {
143    let parts: Vec<&str> = command.split_whitespace().collect();
144    let c_idx = parts.iter().position(|&p| p == "-c");
145    match c_idx {
146        None => parts.iter().map(|s| s.to_string()).collect(),
147        Some(idx) => {
148            let script_arg = parts[idx + 1..].join(" ");
149            let mut result: Vec<String> = parts[..idx + 1].iter().map(|s| s.to_string()).collect();
150            if !script_arg.is_empty() {
151                result.push(script_arg);
152            }
153            result
154        }
155    }
156}
157
158/// Returns "-i" for bash/zsh (interactive mode, sources startup files), None for other shells.
159fn get_shell_interactive_flag(shell_path: &str) -> Option<&'static str> {
160    let shell_name = shell_path.rsplit('/').next().unwrap_or(shell_path);
161    match shell_name {
162        "bash" => Some("-i"),
163        "zsh" => Some("-i"),
164        _ => None,
165    }
166}
167
168/// Detect the best available shell in an isolation environment (docker/ssh)
169/// Tries shells in order: bash, zsh, sh
170/// Returns the shell path to use
171pub fn detect_shell_in_environment(environment: &str, options: &IsolationOptions) -> String {
172    let shell_preference = &options.shell;
173
174    // If a specific shell is requested (not auto), use it directly
175    if !shell_preference.is_empty() && shell_preference != "auto" {
176        if is_debug() {
177            eprintln!("[DEBUG] Using forced shell: {}", shell_preference);
178        }
179        return shell_preference.clone();
180    }
181
182    // In auto mode, try shells in order of preference
183    let shells_to_try = ["bash", "zsh", "sh"];
184
185    if environment == "docker" {
186        let image = match &options.image {
187            Some(i) => i.clone(),
188            None => return "sh".to_string(),
189        };
190
191        for shell in &shells_to_try {
192            let result = Command::new("docker")
193                .args([
194                    "run",
195                    "--rm",
196                    &image,
197                    "sh",
198                    "-c",
199                    &format!("command -v {}", shell),
200                ])
201                .stdout(Stdio::piped())
202                .stderr(Stdio::null())
203                .output();
204
205            if let Ok(output) = result {
206                if output.status.success() {
207                    let detected = String::from_utf8_lossy(&output.stdout).trim().to_string();
208                    if !detected.is_empty() {
209                        if is_debug() {
210                            eprintln!(
211                                "[DEBUG] Detected shell in docker image {}: {}",
212                                image, detected
213                            );
214                        }
215                        return detected;
216                    }
217                }
218            }
219        }
220
221        if is_debug() {
222            eprintln!(
223                "[DEBUG] Could not detect shell in docker image {}, falling back to sh",
224                image
225            );
226        }
227        return "sh".to_string();
228    }
229
230    if environment == "ssh" {
231        let endpoint = match &options.endpoint {
232            Some(e) => e.clone(),
233            None => return "sh".to_string(),
234        };
235
236        // Run a single SSH command to check for available shells in order
237        let check_cmd: Vec<String> = shells_to_try
238            .iter()
239            .map(|s| format!("command -v {}", s))
240            .collect();
241        let check_cmd_str = check_cmd.join(" || ");
242
243        let result = Command::new("ssh")
244            .args([&endpoint, &check_cmd_str])
245            .stdout(Stdio::piped())
246            .stderr(Stdio::null())
247            .output();
248
249        if let Ok(output) = result {
250            if output.status.success() {
251                let detected = String::from_utf8_lossy(&output.stdout).trim().to_string();
252                if !detected.is_empty() {
253                    if is_debug() {
254                        eprintln!(
255                            "[DEBUG] Detected shell on SSH host {}: {}",
256                            endpoint, detected
257                        );
258                    }
259                    return detected;
260                }
261            }
262        }
263
264        if is_debug() {
265            eprintln!(
266                "[DEBUG] Could not detect shell on SSH host {}, falling back to sh",
267                endpoint
268            );
269        }
270        return "sh".to_string();
271    }
272
273    "sh".to_string()
274}
275
276#[path = "isolation_screen.rs"]
277pub mod isolation_screen;
278pub use self::isolation_screen::{get_screen_version, supports_logfile_option};
279
280/// Run command in GNU Screen
281pub fn run_in_screen(command: &str, options: &IsolationOptions) -> IsolationResult {
282    if !is_command_available("screen") {
283        return IsolationResult {
284            success: false,
285            message: "screen is not installed. Install it with: sudo apt-get install screen (Debian/Ubuntu) or brew install screen (macOS)".to_string(),
286            ..Default::default()
287        };
288    }
289
290    let session_name = options
291        .session
292        .clone()
293        .unwrap_or_else(|| generate_session_name(Some("screen")));
294
295    if options.detached {
296        isolation_screen::start_detached_screen_with_log_capture(
297            command,
298            &session_name,
299            options.user.as_deref(),
300            options.keep_alive,
301            options.log_path.as_deref(),
302        )
303    } else {
304        // Attached mode with log capture
305        isolation_screen::run_screen_with_log_capture(
306            command,
307            &session_name,
308            options.user.as_deref(),
309            options.log_path.as_deref(),
310        )
311    }
312}
313
314/// Run command in tmux
315pub fn run_in_tmux(command: &str, options: &IsolationOptions) -> IsolationResult {
316    if !is_command_available("tmux") {
317        return IsolationResult {
318            success: false,
319            message: "tmux is not installed. Install it with: sudo apt-get install tmux (Debian/Ubuntu) or brew install tmux (macOS)".to_string(),
320            ..Default::default()
321        };
322    }
323
324    let session_name = options
325        .session
326        .clone()
327        .unwrap_or_else(|| generate_session_name(Some("tmux")));
328
329    let (shell, _) = get_shell();
330    let effective_command = wrap_command_with_user(command, options.user.as_deref());
331
332    if options.detached {
333        let final_command = if options.log_path.is_some() {
334            crate::isolation::isolation_log::wrap_command_with_log_footer(
335                &effective_command,
336                &shell,
337                options.keep_alive,
338            )
339        } else if options.keep_alive {
340            format!("{}; exec {}", effective_command, shell)
341        } else {
342            effective_command.clone()
343        };
344
345        let status = if let Some(log_path) = options.log_path.as_ref() {
346            let start_status = Command::new("tmux")
347                .args(["new-session", "-d", "-s", &session_name, &shell])
348                .status();
349            if start_status.as_ref().is_ok_and(|s| s.success()) {
350                let pipe_command = format!(
351                    "cat >> {}",
352                    crate::isolation::isolation_log::shell_quote(&log_path.to_string_lossy())
353                );
354                let _ = Command::new("tmux")
355                    .args(["pipe-pane", "-t", &session_name, "-o", &pipe_command])
356                    .status();
357                Command::new("tmux")
358                    .args(["send-keys", "-t", &session_name, &final_command, "C-m"])
359                    .status()
360            } else {
361                start_status
362            }
363        } else {
364            Command::new("tmux")
365                .args(["new-session", "-d", "-s", &session_name, &final_command])
366                .status()
367        };
368
369        match status {
370            Ok(s) if s.success() => {
371                let mut message =
372                    format!("Command started in detached tmux session: {}", session_name);
373                if options.keep_alive {
374                    message.push_str("\nSession will stay alive after command completes.");
375                } else {
376                    message.push_str("\nSession will exit automatically after command completes.");
377                }
378                message.push_str(&format!("\nReattach with: tmux attach -t {}", session_name));
379                if let Some(log_path) = options.log_path.as_ref() {
380                    message.push_str(&format!("\nLive log: {}", log_path.display()));
381                }
382
383                IsolationResult {
384                    success: true,
385                    session_name: Some(session_name),
386                    message,
387                    ..Default::default()
388                }
389            }
390            _ => IsolationResult {
391                success: false,
392                session_name: Some(session_name),
393                message: "Failed to start tmux session".to_string(),
394                ..Default::default()
395            },
396        }
397    } else {
398        // Attached mode
399        let output = Command::new("tmux")
400            .args(["new-session", "-s", &session_name, &effective_command])
401            .status();
402
403        match output {
404            Ok(status) => IsolationResult {
405                success: status.success(),
406                session_name: Some(session_name.clone()),
407                message: format!(
408                    "Tmux session \"{}\" exited with code {}",
409                    session_name,
410                    status.code().unwrap_or(-1)
411                ),
412                exit_code: status.code(),
413                ..Default::default()
414            },
415            Err(e) => IsolationResult {
416                success: false,
417                session_name: Some(session_name),
418                message: format!("Failed to start tmux: {}", e),
419                ..Default::default()
420            },
421        }
422    }
423}
424
425/// Run command over SSH
426pub fn run_in_ssh(command: &str, options: &IsolationOptions) -> IsolationResult {
427    if !is_command_available("ssh") {
428        return IsolationResult {
429            success: false,
430            message: "ssh is not installed".to_string(),
431            ..Default::default()
432        };
433    }
434
435    let endpoint = match &options.endpoint {
436        Some(e) => e.clone(),
437        None => {
438            return IsolationResult {
439                success: false,
440                message: "SSH isolation requires --endpoint option".to_string(),
441                ..Default::default()
442            };
443        }
444    };
445
446    let session_name = options
447        .session
448        .clone()
449        .unwrap_or_else(|| generate_session_name(Some("ssh")));
450
451    // Detect the shell to use on the remote host
452    let shell_to_use = detect_shell_in_environment("ssh", options);
453    // Use interactive mode (-i) for shells that support it (bash, zsh) so that startup
454    // files like .bashrc are sourced, making tools like nvm available in commands.
455    let shell_interactive_flag = get_shell_interactive_flag(&shell_to_use);
456
457    if options.detached {
458        // Detached mode: run in background on remote server using nohup
459        // Build the shell invocation with interactive flag if supported
460        let shell_invocation = if let Some(flag) = shell_interactive_flag {
461            format!("{} {}", shell_to_use, flag)
462        } else {
463            shell_to_use.clone()
464        };
465        let remote_command = format!(
466            "mkdir -p /tmp/start-command/logs/isolation/ssh && nohup {} -c {} > /tmp/start-command/logs/isolation/ssh/{}.log 2>&1 &",
467            shell_invocation,
468            shell_escape(command),
469            session_name
470        );
471        let ssh_args = vec![endpoint.as_str(), remote_command.as_str()];
472
473        if is_debug() {
474            eprintln!("[DEBUG] Running: ssh {:?}", ssh_args);
475            eprintln!("[DEBUG] shell: {}", shell_invocation);
476        }
477
478        let status = Command::new("ssh").args(&ssh_args).status();
479
480        match status {
481            Ok(s) if s.success() => IsolationResult {
482                success: true,
483                session_name: Some(session_name.clone()),
484                message: format!(
485                    "Command started in detached SSH session on {}\nSession: {}\nView logs: ssh {} \"tail -f /tmp/start-command/logs/isolation/ssh/{}.log\"",
486                    endpoint, session_name, endpoint, session_name
487                ),
488                ..Default::default()
489            },
490            _ => IsolationResult {
491                success: false,
492                session_name: Some(session_name),
493                message: "Failed to start SSH session".to_string(),
494                ..Default::default()
495            },
496        }
497    } else {
498        // Attached mode: Run command using the detected shell with interactive mode
499        // so that startup files (.bashrc etc.) are sourced and tools like nvm are available.
500        let mut ssh_cmd_args = vec![endpoint.clone(), shell_to_use.clone()];
501        if let Some(flag) = shell_interactive_flag {
502            ssh_cmd_args.push(flag.to_string());
503        }
504        ssh_cmd_args.push("-c".to_string());
505        ssh_cmd_args.push(command.to_string());
506
507        if is_debug() {
508            eprintln!("[DEBUG] Running: ssh {:?}", ssh_cmd_args);
509            eprintln!("[DEBUG] shell: {}", shell_to_use);
510        }
511
512        let status = Command::new("ssh").args(&ssh_cmd_args).status();
513
514        match status {
515            Ok(s) => IsolationResult {
516                success: s.success(),
517                session_name: Some(session_name.clone()),
518                message: format!(
519                    "SSH session \"{}\" on {} exited with code {}",
520                    session_name,
521                    endpoint,
522                    s.code().unwrap_or(-1)
523                ),
524                exit_code: s.code(),
525                ..Default::default()
526            },
527            Err(e) => IsolationResult {
528                success: false,
529                session_name: Some(session_name),
530                message: format!("Failed to start SSH: {}", e),
531                ..Default::default()
532            },
533        }
534    }
535}
536
537/// Check if a Docker image exists locally
538pub fn docker_image_exists(image: &str) -> bool {
539    Command::new("docker")
540        .args(["image", "inspect", image])
541        .stdout(Stdio::null())
542        .stderr(Stdio::null())
543        .status()
544        .map(|s| s.success())
545        .unwrap_or(false)
546}
547
548/// Pull a Docker image with output streaming
549/// Returns (success, output) tuple
550pub fn docker_pull_image(image: &str) -> (bool, String) {
551    use std::io::{BufRead, BufReader};
552
553    // Print the virtual command line followed by empty line for visual separation
554    println!(
555        "{}",
556        crate::output_blocks::create_virtual_command_block(&format!("docker pull {}", image))
557    );
558    println!();
559
560    let mut child = match Command::new("docker")
561        .args(["pull", image])
562        .stdout(Stdio::piped())
563        .stderr(Stdio::piped())
564        .spawn()
565    {
566        Ok(c) => c,
567        Err(e) => {
568            let error_msg = format!("Failed to run docker pull: {}", e);
569            eprintln!("{}", error_msg);
570            println!();
571            println!(
572                "{}",
573                crate::output_blocks::create_virtual_command_result(false)
574            );
575            return (false, error_msg);
576        }
577    };
578
579    let mut output = String::new();
580
581    // Read and display stdout
582    if let Some(stdout) = child.stdout.take() {
583        let reader = BufReader::new(stdout);
584        for line in reader.lines().map_while(Result::ok) {
585            println!("{}", line);
586            output.push_str(&line);
587            output.push('\n');
588        }
589    }
590
591    // Read and display stderr
592    if let Some(stderr) = child.stderr.take() {
593        let reader = BufReader::new(stderr);
594        for line in reader.lines().map_while(Result::ok) {
595            eprintln!("{}", line);
596            output.push_str(&line);
597            output.push('\n');
598        }
599    }
600
601    let success = child.wait().map(|s| s.success()).unwrap_or(false);
602
603    // Print empty line before result marker for visual separation (issue #73)
604    // This ensures output is visually separated from the result marker
605    println!();
606    println!(
607        "{}",
608        crate::output_blocks::create_virtual_command_result(success)
609    );
610    println!("{}", crate::output_blocks::create_timeline_separator());
611
612    (success, output)
613}
614
615/// Run command in Docker container
616pub fn run_in_docker(command: &str, options: &IsolationOptions) -> IsolationResult {
617    if !is_command_available("docker") {
618        return IsolationResult {
619            success: false,
620            message:
621                "docker is not installed. Install Docker from https://docs.docker.com/get-docker/"
622                    .to_string(),
623            ..Default::default()
624        };
625    }
626
627    let image = match &options.image {
628        Some(i) => i.clone(),
629        None => {
630            return IsolationResult {
631                success: false,
632                message: "Docker isolation requires --image option".to_string(),
633                ..Default::default()
634            };
635        }
636    };
637
638    // Check if image exists locally; if not, pull it as a virtual command
639    if !docker_image_exists(&image) {
640        let (pull_success, _pull_output) = docker_pull_image(&image);
641        if !pull_success {
642            return IsolationResult {
643                success: false,
644                message: format!("Failed to pull Docker image: {}", image),
645                exit_code: Some(1),
646                ..Default::default()
647            };
648        }
649    }
650
651    let container_name = options
652        .session
653        .clone()
654        .unwrap_or_else(|| generate_session_name(Some("docker")));
655
656    // Detect the shell to use in the container
657    let shell_to_use = detect_shell_in_environment("docker", options);
658    // Use interactive mode (-i) for shells that support it (bash, zsh) so that startup
659    // files like .bashrc are sourced, making tools like nvm available in commands.
660    let shell_interactive_flag = get_shell_interactive_flag(&shell_to_use);
661
662    // Print the user command (this appears after any virtual commands like docker pull)
663    println!("{}", crate::output_blocks::create_command_line(command));
664    println!();
665
666    if options.detached {
667        let effective_command = if options.keep_alive {
668            format!("{}; exec {}", command, shell_to_use)
669        } else {
670            command.to_string()
671        };
672
673        let mut args = vec!["run", "-d", "--name", &container_name];
674
675        if options.auto_remove_docker_container {
676            args.push("--rm");
677        }
678
679        if let Some(ref user) = options.user {
680            args.push("--user");
681            args.push(user);
682        }
683
684        args.push(&image);
685        args.push(&shell_to_use);
686        if let Some(flag) = shell_interactive_flag {
687            args.push(flag);
688        }
689        args.extend(&["-c", &effective_command]);
690
691        if is_debug() {
692            eprintln!("[DEBUG] Running: docker {:?}", args);
693            eprintln!("[DEBUG] shell: {}", shell_to_use);
694        }
695
696        match Command::new("docker").args(&args).output() {
697            Ok(output) if output.status.success() => {
698                let container_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
699
700                if let Some(log_path) = options.log_path.as_ref() {
701                    let logger_script = format!(
702                        "docker logs -f {} >> {} 2>&1; __start_command_exit=$(docker inspect -f '{{{{.State.ExitCode}}}}' {} 2>/dev/null || printf '%s' '-1'); {} >> {}",
703                        crate::isolation::isolation_log::shell_quote(&container_name),
704                        crate::isolation::isolation_log::shell_quote(&log_path.to_string_lossy()),
705                        crate::isolation::isolation_log::shell_quote(&container_name),
706                        crate::isolation::isolation_log::create_shell_log_footer_snippet(),
707                        crate::isolation::isolation_log::shell_quote(&log_path.to_string_lossy())
708                    );
709                    let _ = Command::new("sh")
710                        .args(["-c", &logger_script])
711                        .stdout(Stdio::null())
712                        .stderr(Stdio::null())
713                        .spawn();
714                }
715
716                let mut message = format!(
717                    "Command started in detached docker container: {}",
718                    container_name
719                );
720                message.push_str(&format!(
721                    "\nContainer ID: {}",
722                    &container_id[..12.min(container_id.len())]
723                ));
724                if options.keep_alive {
725                    message.push_str("\nContainer will stay alive after command completes.");
726                } else {
727                    message
728                        .push_str("\nContainer will exit automatically after command completes.");
729                }
730                if options.auto_remove_docker_container {
731                    message.push_str("\nContainer will be automatically removed after exit.");
732                } else {
733                    message.push_str("\nContainer filesystem will be preserved after exit.");
734                }
735                message.push_str(&format!("\nAttach with: docker attach {}", container_name));
736                message.push_str(&format!("\nView logs: docker logs {}", container_name));
737                if let Some(log_path) = options.log_path.as_ref() {
738                    message.push_str(&format!("\nLive log: {}", log_path.display()));
739                }
740
741                IsolationResult {
742                    success: true,
743                    session_name: Some(container_name),
744                    container_id: Some(container_id),
745                    message,
746                    ..Default::default()
747                }
748            }
749            Ok(output) => {
750                let stderr = String::from_utf8_lossy(&output.stderr);
751                IsolationResult {
752                    success: false,
753                    session_name: Some(container_name),
754                    message: format!("Failed to start docker container: {}", stderr),
755                    ..Default::default()
756                }
757            }
758            Err(e) => IsolationResult {
759                success: false,
760                session_name: Some(container_name),
761                message: format!("Failed to run docker: {}", e),
762                ..Default::default()
763            },
764        }
765    } else {
766        // Attached mode
767        let mut args = vec!["run", "-it", "--rm", "--name", &container_name];
768
769        if let Some(ref user) = options.user {
770            args.push("--user");
771            args.push(user);
772        }
773
774        if is_debug() {
775            eprintln!("[DEBUG] shell: {}", shell_to_use);
776        }
777
778        args.push(&image);
779        args.push(&shell_to_use);
780        if let Some(flag) = shell_interactive_flag {
781            args.push(flag);
782        }
783        args.extend(&["-c", command]);
784
785        let status = Command::new("docker").args(&args).status();
786
787        match status {
788            Ok(s) => IsolationResult {
789                success: s.success(),
790                session_name: Some(container_name.clone()),
791                message: format!(
792                    "Docker container \"{}\" exited with code {}",
793                    container_name,
794                    s.code().unwrap_or(-1)
795                ),
796                exit_code: s.code(),
797                ..Default::default()
798            },
799            Err(e) => IsolationResult {
800                success: false,
801                session_name: Some(container_name),
802                message: format!("Failed to start docker: {}", e),
803                ..Default::default()
804            },
805        }
806    }
807}
808
809/// Run command in the specified isolation backend
810pub fn run_isolated(backend: &str, command: &str, options: &IsolationOptions) -> IsolationResult {
811    match backend {
812        "screen" => run_in_screen(command, options),
813        "tmux" => run_in_tmux(command, options),
814        "docker" => run_in_docker(command, options),
815        "ssh" => run_in_ssh(command, options),
816        _ => IsolationResult {
817            success: false,
818            message: format!("Unknown isolation backend: {}", backend),
819            ..Default::default()
820        },
821    }
822}
823
824/// Run command as an isolated user (without isolation backend)
825pub fn run_as_isolated_user(command: &str, username: &str) -> IsolationResult {
826    let status = Command::new("sudo")
827        .args(["-n", "-u", username, "sh", "-c", command])
828        .status();
829
830    match status {
831        Ok(s) => IsolationResult {
832            success: s.success(),
833            message: format!(
834                "Command completed as user \"{}\" with exit code {}",
835                username,
836                s.code().unwrap_or(-1)
837            ),
838            exit_code: s.code(),
839            ..Default::default()
840        },
841        Err(e) => IsolationResult {
842            success: false,
843            message: format!("Failed to run as user \"{}\": {}", username, e),
844            exit_code: Some(1),
845            ..Default::default()
846        },
847    }
848}
849
850#[path = "isolation_log.rs"]
851pub mod isolation_log;
852pub use self::isolation_log::{
853    append_log_file, create_log_footer, create_log_header, create_log_path,
854    create_log_path_for_execution, generate_log_filename, get_default_docker_image, get_log_dir,
855    get_temp_dir, get_temp_root, get_timestamp, write_log_file, LogHeaderParams,
856};
857
858fn is_debug() -> bool {
859    env::var("START_DEBUG").is_ok_and(|v| v == "1" || v == "true")
860}
861
862fn shell_escape(command: &str) -> String {
863    format!("'{}'", command.replace('\'', "'\\''"))
864}
865
866#[path = "atty.rs"]
867mod atty;
868
869#[cfg(test)]
870#[path = "isolation_cases.rs"]
871mod tests;