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