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