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