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