Skip to main content

start_command/
isolation.rs

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