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