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