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