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