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, Clone)]
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!(
712        "{}",
713        crate::output_blocks::create_command_line(&build_display_command(command))
714    );
715    println!();
716
717    let needs_network_setup = docker_networks(options).len() > 1;
718
719    if options.detached {
720        let effective_command = if options.keep_alive {
721            format!("{}; exec {}", command, shell_to_use)
722        } else {
723            command.to_string()
724        };
725
726        let mut args = vec![if needs_network_setup { "create" } else { "run" }];
727        if !needs_network_setup {
728            args.push("-d");
729        }
730        args.extend(["--name", &container_name]);
731
732        if let Some(ref user) = options.user {
733            args.push("--user");
734            args.push(user);
735        }
736
737        args.extend(build_docker_runtime_args(options));
738
739        args.push(&image);
740        args.push(&shell_to_use);
741        if let Some(flag) = shell_interactive_flag {
742            args.push(flag);
743        }
744        args.extend(&["-c", &effective_command]);
745
746        if is_debug() {
747            eprintln!("[DEBUG] Running: docker {:?}", args);
748            eprintln!("[DEBUG] shell: {}", shell_to_use);
749        }
750
751        match Command::new("docker").args(&args).output() {
752            Ok(output) if output.status.success() => {
753                let container_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
754
755                if needs_network_setup {
756                    let setup_result = connect_and_start(&container_name, options);
757                    if let Err(error) = setup_result {
758                        if !container_existed_before_launch {
759                            remove_docker_container(&container_name, options.log_path.as_ref());
760                        }
761                        return IsolationResult {
762                            success: false,
763                            session_name: Some(container_name),
764                            message: format!("Failed to start docker container: {}", error),
765                            ..Default::default()
766                        };
767                    }
768                }
769
770                if let Some(log_path) = options.log_path.as_ref() {
771                    start_detached_docker_completion_watcher(
772                        &container_name,
773                        cleanup_policy,
774                        Some(log_path),
775                    );
776                } else {
777                    start_detached_docker_completion_watcher(&container_name, cleanup_policy, None);
778                }
779
780                let mut message = format!(
781                    "Command started in detached docker container: {}",
782                    container_name
783                );
784                message.push_str(&format!(
785                    "\nContainer ID: {}",
786                    &container_id[..12.min(container_id.len())]
787                ));
788                if options.keep_alive {
789                    message.push_str("\nContainer will stay alive after command completes.");
790                } else {
791                    message
792                        .push_str("\nContainer will exit automatically after command completes.");
793                }
794                append_docker_container_cleanup_policy_message(
795                    &mut message,
796                    &container_name,
797                    cleanup_policy,
798                );
799                message.push_str(&format!(
800                    "\nAttach with: $ --attach {} (or docker attach {})",
801                    container_name, container_name
802                ));
803                message.push_str(&format!("\nView logs: docker logs {}", container_name));
804                if let Some(log_path) = options.log_path.as_ref() {
805                    message.push_str(&format!("\nLive log: {}", log_path.display()));
806                }
807
808                IsolationResult {
809                    success: true,
810                    session_name: Some(container_name),
811                    container_id: Some(container_id),
812                    message,
813                    ..Default::default()
814                }
815            }
816            Ok(output) => {
817                let stderr = String::from_utf8_lossy(&output.stderr);
818                if !container_existed_before_launch
819                    && crate::docker_cleanup::read_docker_container_status(&container_name)
820                        .as_deref()
821                        == Some("created")
822                {
823                    remove_docker_container(&container_name, options.log_path.as_ref());
824                }
825                IsolationResult {
826                    success: false,
827                    session_name: Some(container_name),
828                    message: format!("Failed to start docker container: {}", stderr),
829                    ..Default::default()
830                }
831            }
832            Err(e) => IsolationResult {
833                success: false,
834                session_name: Some(container_name),
835                message: format!("Failed to run docker: {}", e),
836                ..Default::default()
837            },
838        }
839    } else {
840        // Attached mode
841        let mut args = vec![if needs_network_setup { "create" } else { "run" }];
842        args.push(if has_tty() { "-it" } else { "-i" });
843        args.extend(["--name", &container_name]);
844
845        if let Some(ref user) = options.user {
846            args.push("--user");
847            args.push(user);
848        }
849
850        args.extend(build_docker_runtime_args(options));
851
852        if is_debug() {
853            eprintln!("[DEBUG] shell: {}", shell_to_use);
854        }
855
856        args.push(&image);
857        args.push(&shell_to_use);
858        if let Some(flag) = shell_interactive_flag {
859            args.push(flag);
860        }
861        args.extend(&["-c", command]);
862
863        if needs_network_setup {
864            let setup_result = create_and_connect(&args, &container_name, options);
865            if let Err(error) = setup_result {
866                if !container_existed_before_launch {
867                    remove_docker_container(&container_name, options.log_path.as_ref());
868                }
869                return IsolationResult {
870                    success: false,
871                    session_name: Some(container_name),
872                    message: format!("Failed to start docker container: {}", error),
873                    ..Default::default()
874                };
875            }
876        }
877
878        let start_args = ["start", "-a", "-i", &container_name];
879        let child = if needs_network_setup {
880            spawn_attached_docker(&start_args, options.log_path.as_ref())
881        } else {
882            spawn_attached_docker(&args, options.log_path.as_ref())
883        };
884
885        match child {
886            Ok(child) => match child.wait() {
887                Ok(s) => {
888                    let exit_code = s.code().unwrap_or(1);
889                    let mut message = format!(
890                        "Docker container \"{}\" exited with code {}",
891                        container_name, exit_code
892                    );
893                    append_attached_docker_cleanup_message(
894                        &mut message,
895                        &container_name,
896                        cleanup_policy,
897                        exit_code,
898                        options.log_path.as_ref(),
899                        container_existed_before_launch,
900                    );
901
902                    IsolationResult {
903                        success: s.success(),
904                        session_name: Some(container_name.clone()),
905                        message,
906                        exit_code: Some(exit_code),
907                        ..Default::default()
908                    }
909                }
910                Err(e) => IsolationResult {
911                    success: false,
912                    session_name: Some(container_name),
913                    message: format!("Failed to wait for docker: {}", e),
914                    ..Default::default()
915                },
916            },
917            Err(e) => IsolationResult {
918                success: false,
919                session_name: Some(container_name),
920                message: format!("Failed to start docker: {}", e),
921                ..Default::default()
922            },
923        }
924    }
925}
926
927/// Run command in the specified isolation backend
928pub fn run_isolated(backend: &str, command: &str, options: &IsolationOptions) -> IsolationResult {
929    match backend {
930        "screen" => run_in_screen(command, options),
931        "tmux" => run_in_tmux(command, options),
932        "docker" => run_in_docker(command, options),
933        "ssh" => run_in_ssh(command, options),
934        _ => IsolationResult {
935            success: false,
936            message: format!("Unknown isolation backend: {}", backend),
937            ..Default::default()
938        },
939    }
940}
941
942/// Run command as an isolated user (without isolation backend)
943pub fn run_as_isolated_user(command: &str, username: &str) -> IsolationResult {
944    let status = Command::new("sudo")
945        .args(["-n", "-u", username, "sh", "-c", command])
946        .status();
947
948    match status {
949        Ok(s) => IsolationResult {
950            success: s.success(),
951            message: format!(
952                "Command completed as user \"{}\" with exit code {}",
953                username,
954                s.code().unwrap_or(-1)
955            ),
956            exit_code: s.code(),
957            ..Default::default()
958        },
959        Err(e) => IsolationResult {
960            success: false,
961            message: format!("Failed to run as user \"{}\": {}", username, e),
962            exit_code: Some(1),
963            ..Default::default()
964        },
965    }
966}
967
968#[path = "isolation_log.rs"]
969pub mod isolation_log;
970#[path = "isolation_shell.rs"]
971mod isolation_shell;
972pub use self::isolation_log::{
973    append_log_file, create_log_footer, create_log_header, create_log_path,
974    create_log_path_for_execution, generate_log_filename, get_default_docker_image, get_log_dir,
975    get_temp_dir, get_temp_root, get_timestamp, write_log_file, LogHeaderParams,
976};
977pub use isolation_shell::{
978    build_command_string, build_command_string_with, build_display_command,
979    build_shell_with_args_cmd_args, command_name, is_interactive_shell_command,
980    is_shell_invocation_with_args, quote_shell_arg, quote_shell_arg_with, split_shell_words,
981    split_shell_words_with, to_shell_words, ShellQuotingStyle,
982};
983
984fn is_debug() -> bool {
985    env::var("START_DEBUG").is_ok_and(|v| v == "1" || v == "true")
986}
987
988fn shell_escape(command: &str) -> String {
989    format!("'{}'", command.replace('\'', "'\\''"))
990}
991
992#[path = "atty.rs"]
993mod atty;
994
995#[cfg(test)]
996#[path = "isolation_cases.rs"]
997mod tests;