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