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