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;
14
15/// Result of an isolation run
16#[derive(Debug, Default)]
17pub struct IsolationResult {
18    /// Whether the run succeeded
19    pub success: bool,
20    /// Session or container name
21    pub session_name: Option<String>,
22    /// Container ID (for docker)
23    pub container_id: Option<String>,
24    /// Message describing the result
25    pub message: String,
26    /// Exit code
27    pub exit_code: Option<i32>,
28    /// Captured output
29    pub output: Option<String>,
30}
31
32/// Options for isolation
33#[derive(Debug, Clone)]
34pub struct IsolationOptions {
35    /// Session name
36    pub session: Option<String>,
37    /// Docker image
38    pub image: Option<String>,
39    /// Docker bind mounts/volumes (-v/--volume)
40    pub volumes: Vec<String>,
41    /// Docker --mount specs
42    pub mounts: Vec<String>,
43    /// Docker environment variables (-e/--env, KEY=VALUE)
44    pub env: Vec<String>,
45    /// Run docker container in privileged mode
46    pub privileged: bool,
47    /// SSH endpoint
48    pub endpoint: Option<String>,
49    /// Run in detached mode
50    pub detached: bool,
51    /// User to run command as
52    pub user: Option<String>,
53    /// Keep environment alive after command exits
54    pub keep_alive: bool,
55    /// Auto-remove docker container after exit
56    pub auto_remove_docker_container: bool,
57    /// Shell to use in isolation environments: auto, bash, zsh, sh
58    pub shell: String,
59    /// Log path where isolation backends should append live output
60    pub log_path: Option<PathBuf>,
61}
62
63impl Default for IsolationOptions {
64    fn default() -> Self {
65        IsolationOptions {
66            session: None,
67            image: None,
68            volumes: Vec::new(),
69            mounts: Vec::new(),
70            env: Vec::new(),
71            privileged: false,
72            endpoint: None,
73            detached: false,
74            user: None,
75            keep_alive: false,
76            auto_remove_docker_container: false,
77            shell: "auto".to_string(),
78            log_path: None,
79        }
80    }
81}
82
83/// Check if a command is available on the system
84pub fn is_command_available(command: &str) -> bool {
85    let check_cmd = if cfg!(windows) { "where" } else { "which" };
86    Command::new(check_cmd)
87        .arg(command)
88        .stdout(Stdio::null())
89        .stderr(Stdio::null())
90        .status()
91        .map(|s| s.success())
92        .unwrap_or(false)
93}
94
95/// Get the shell to use for command execution
96pub fn get_shell() -> (String, String) {
97    if cfg!(windows) {
98        ("cmd.exe".to_string(), "/c".to_string())
99    } else {
100        let shell = env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
101        (shell, "-c".to_string())
102    }
103}
104
105/// Check if the current process has a TTY attached
106pub fn has_tty() -> bool {
107    atty::is(atty::Stream::Stdin) && atty::is(atty::Stream::Stdout)
108}
109
110/// Wrap command with sudo -u if user option is specified
111pub fn wrap_command_with_user(command: &str, user: Option<&str>) -> String {
112    match user {
113        Some(u) => {
114            // Escape single quotes in command
115            let escaped = command.replace('\'', "'\\''");
116            format!("sudo -n -u {} sh -c '{}'", u, escaped)
117        }
118        None => command.to_string(),
119    }
120}
121
122/// Shell names recognized as bare interactive shells (without -c flag).
123/// Mirrors JS SHELL_NAMES constant in isolation.js.
124const SHELL_NAMES: [&str; 8] = ["bash", "zsh", "sh", "fish", "ksh", "csh", "tcsh", "dash"];
125
126/// Returns true if command is a bare interactive shell invocation (no -c flag).
127/// Used to avoid double-wrapping shells in isolation environments (issue #84).
128///
129/// Examples: "bash", "zsh", "bash --norc", "/usr/local/bin/bash"
130/// Counter-examples: "bash -c echo hi", "npm test"
131pub fn is_interactive_shell_command(command: &str) -> bool {
132    let parts: Vec<&str> = command.split_whitespace().collect();
133    if parts.is_empty() {
134        return false;
135    }
136    let basename = parts[0].rsplit('/').next().unwrap_or(parts[0]);
137    SHELL_NAMES.contains(&basename) && !parts.contains(&"-c")
138}
139
140/// Returns true if command is a shell invocation that includes -c (e.g. `bash -i -c "cmd"`).
141/// Used to pass such commands directly without double-wrapping (issue #91).
142pub fn is_shell_invocation_with_args(command: &str) -> bool {
143    let parts: Vec<&str> = command.split_whitespace().collect();
144    if parts.is_empty() {
145        return false;
146    }
147    let basename = parts[0].rsplit('/').next().unwrap_or(parts[0]);
148    SHELL_NAMES.contains(&basename) && parts.contains(&"-c")
149}
150
151/// Build argv for a shell-with-c command; everything after -c is joined as one argument.
152/// Reverses the join(' ') that collapsed the original quoted argument.
153/// Used to pass `bash -i -c "nvm --version"` directly as argv (issue #91 fix).
154pub fn build_shell_with_args_cmd_args(command: &str) -> Vec<String> {
155    let parts: Vec<&str> = command.split_whitespace().collect();
156    let c_idx = parts.iter().position(|&p| p == "-c");
157    match c_idx {
158        None => parts.iter().map(|s| s.to_string()).collect(),
159        Some(idx) => {
160            let script_arg = parts[idx + 1..].join(" ");
161            let mut result: Vec<String> = parts[..idx + 1].iter().map(|s| s.to_string()).collect();
162            if !script_arg.is_empty() {
163                result.push(script_arg);
164            }
165            result
166        }
167    }
168}
169
170/// Returns "-i" for bash/zsh (interactive mode, sources startup files), None for other shells.
171fn get_shell_interactive_flag(shell_path: &str) -> Option<&'static str> {
172    let shell_name = shell_path.rsplit('/').next().unwrap_or(shell_path);
173    match shell_name {
174        "bash" => Some("-i"),
175        "zsh" => Some("-i"),
176        _ => None,
177    }
178}
179
180/// Detect the best available shell in an isolation environment (docker/ssh)
181/// Tries shells in order: bash, zsh, sh
182/// Returns the shell path to use
183pub fn detect_shell_in_environment(environment: &str, options: &IsolationOptions) -> String {
184    let shell_preference = &options.shell;
185
186    // If a specific shell is requested (not auto), use it directly
187    if !shell_preference.is_empty() && shell_preference != "auto" {
188        if is_debug() {
189            eprintln!("[DEBUG] Using forced shell: {}", shell_preference);
190        }
191        return shell_preference.clone();
192    }
193
194    // In auto mode, try shells in order of preference
195    let shells_to_try = ["bash", "zsh", "sh"];
196
197    if environment == "docker" {
198        let image = match &options.image {
199            Some(i) => i.clone(),
200            None => return "sh".to_string(),
201        };
202
203        for shell in &shells_to_try {
204            let result = Command::new("docker")
205                .args([
206                    "run",
207                    "--rm",
208                    &image,
209                    "sh",
210                    "-c",
211                    &format!("command -v {}", shell),
212                ])
213                .stdout(Stdio::piped())
214                .stderr(Stdio::null())
215                .output();
216
217            if let Ok(output) = result {
218                if output.status.success() {
219                    let detected = String::from_utf8_lossy(&output.stdout).trim().to_string();
220                    if !detected.is_empty() {
221                        if is_debug() {
222                            eprintln!(
223                                "[DEBUG] Detected shell in docker image {}: {}",
224                                image, detected
225                            );
226                        }
227                        return detected;
228                    }
229                }
230            }
231        }
232
233        if is_debug() {
234            eprintln!(
235                "[DEBUG] Could not detect shell in docker image {}, falling back to sh",
236                image
237            );
238        }
239        return "sh".to_string();
240    }
241
242    if environment == "ssh" {
243        let endpoint = match &options.endpoint {
244            Some(e) => e.clone(),
245            None => return "sh".to_string(),
246        };
247
248        // Run a single SSH command to check for available shells in order
249        let check_cmd: Vec<String> = shells_to_try
250            .iter()
251            .map(|s| format!("command -v {}", s))
252            .collect();
253        let check_cmd_str = check_cmd.join(" || ");
254
255        let result = Command::new("ssh")
256            .args([&endpoint, &check_cmd_str])
257            .stdout(Stdio::piped())
258            .stderr(Stdio::null())
259            .output();
260
261        if let Ok(output) = result {
262            if output.status.success() {
263                let detected = String::from_utf8_lossy(&output.stdout).trim().to_string();
264                if !detected.is_empty() {
265                    if is_debug() {
266                        eprintln!(
267                            "[DEBUG] Detected shell on SSH host {}: {}",
268                            endpoint, detected
269                        );
270                    }
271                    return detected;
272                }
273            }
274        }
275
276        if is_debug() {
277            eprintln!(
278                "[DEBUG] Could not detect shell on SSH host {}, falling back to sh",
279                endpoint
280            );
281        }
282        return "sh".to_string();
283    }
284
285    "sh".to_string()
286}
287
288#[path = "isolation_screen.rs"]
289pub mod isolation_screen;
290pub use self::isolation_screen::{get_screen_version, supports_logfile_option};
291
292/// Run command in GNU Screen
293pub fn run_in_screen(command: &str, options: &IsolationOptions) -> IsolationResult {
294    if !is_command_available("screen") {
295        return IsolationResult {
296            success: false,
297            message: "screen is not installed. Install it with: sudo apt-get install screen (Debian/Ubuntu) or brew install screen (macOS)".to_string(),
298            ..Default::default()
299        };
300    }
301
302    let session_name = options
303        .session
304        .clone()
305        .unwrap_or_else(|| generate_session_name(Some("screen")));
306
307    if options.detached {
308        isolation_screen::start_detached_screen_with_log_capture(
309            command,
310            &session_name,
311            options.user.as_deref(),
312            options.keep_alive,
313            options.log_path.as_deref(),
314        )
315    } else {
316        // Attached mode with log capture
317        isolation_screen::run_screen_with_log_capture(
318            command,
319            &session_name,
320            options.user.as_deref(),
321            options.log_path.as_deref(),
322        )
323    }
324}
325
326/// Run command in tmux
327pub fn run_in_tmux(command: &str, options: &IsolationOptions) -> IsolationResult {
328    if !is_command_available("tmux") {
329        return IsolationResult {
330            success: false,
331            message: "tmux is not installed. Install it with: sudo apt-get install tmux (Debian/Ubuntu) or brew install tmux (macOS)".to_string(),
332            ..Default::default()
333        };
334    }
335
336    let session_name = options
337        .session
338        .clone()
339        .unwrap_or_else(|| generate_session_name(Some("tmux")));
340
341    let (shell, _) = get_shell();
342    let effective_command = wrap_command_with_user(command, options.user.as_deref());
343
344    if options.detached {
345        let final_command = if options.log_path.is_some() {
346            crate::isolation::isolation_log::wrap_command_with_log_footer(
347                &effective_command,
348                &shell,
349                options.keep_alive,
350            )
351        } else if options.keep_alive {
352            format!("{}; exec {}", effective_command, shell)
353        } else {
354            effective_command.clone()
355        };
356
357        let status = if let Some(log_path) = options.log_path.as_ref() {
358            let start_status = Command::new("tmux")
359                .args(["new-session", "-d", "-s", &session_name, &shell])
360                .status();
361            if start_status.as_ref().is_ok_and(|s| s.success()) {
362                let pipe_command = format!(
363                    "cat >> {}",
364                    crate::isolation::isolation_log::shell_quote(&log_path.to_string_lossy())
365                );
366                let _ = Command::new("tmux")
367                    .args(["pipe-pane", "-t", &session_name, "-o", &pipe_command])
368                    .status();
369                Command::new("tmux")
370                    .args(["send-keys", "-t", &session_name, &final_command, "C-m"])
371                    .status()
372            } else {
373                start_status
374            }
375        } else {
376            Command::new("tmux")
377                .args(["new-session", "-d", "-s", &session_name, &final_command])
378                .status()
379        };
380
381        match status {
382            Ok(s) if s.success() => {
383                let mut message =
384                    format!("Command started in detached tmux session: {}", session_name);
385                if options.keep_alive {
386                    message.push_str("\nSession will stay alive after command completes.");
387                } else {
388                    message.push_str("\nSession will exit automatically after command completes.");
389                }
390                message.push_str(&format!("\nReattach with: tmux attach -t {}", session_name));
391                if let Some(log_path) = options.log_path.as_ref() {
392                    message.push_str(&format!("\nLive log: {}", log_path.display()));
393                }
394
395                IsolationResult {
396                    success: true,
397                    session_name: Some(session_name),
398                    message,
399                    ..Default::default()
400                }
401            }
402            _ => IsolationResult {
403                success: false,
404                session_name: Some(session_name),
405                message: "Failed to start tmux session".to_string(),
406                ..Default::default()
407            },
408        }
409    } else {
410        // Attached mode
411        let output = Command::new("tmux")
412            .args(["new-session", "-s", &session_name, &effective_command])
413            .status();
414
415        match output {
416            Ok(status) => IsolationResult {
417                success: status.success(),
418                session_name: Some(session_name.clone()),
419                message: format!(
420                    "Tmux session \"{}\" exited with code {}",
421                    session_name,
422                    status.code().unwrap_or(-1)
423                ),
424                exit_code: status.code(),
425                ..Default::default()
426            },
427            Err(e) => IsolationResult {
428                success: false,
429                session_name: Some(session_name),
430                message: format!("Failed to start tmux: {}", e),
431                ..Default::default()
432            },
433        }
434    }
435}
436
437/// Run command over SSH
438pub fn run_in_ssh(command: &str, options: &IsolationOptions) -> IsolationResult {
439    if !is_command_available("ssh") {
440        return IsolationResult {
441            success: false,
442            message: "ssh is not installed".to_string(),
443            ..Default::default()
444        };
445    }
446
447    let endpoint = match &options.endpoint {
448        Some(e) => e.clone(),
449        None => {
450            return IsolationResult {
451                success: false,
452                message: "SSH isolation requires --endpoint option".to_string(),
453                ..Default::default()
454            };
455        }
456    };
457
458    let session_name = options
459        .session
460        .clone()
461        .unwrap_or_else(|| generate_session_name(Some("ssh")));
462
463    // Detect the shell to use on the remote host
464    let shell_to_use = detect_shell_in_environment("ssh", options);
465    // Use interactive mode (-i) for shells that support it (bash, zsh) so that startup
466    // files like .bashrc are sourced, making tools like nvm available in commands.
467    let shell_interactive_flag = get_shell_interactive_flag(&shell_to_use);
468
469    if options.detached {
470        // Detached mode: run in background on remote server using nohup
471        // Build the shell invocation with interactive flag if supported
472        let shell_invocation = if let Some(flag) = shell_interactive_flag {
473            format!("{} {}", shell_to_use, flag)
474        } else {
475            shell_to_use.clone()
476        };
477        let remote_command = format!(
478            "mkdir -p /tmp/start-command/logs/isolation/ssh && nohup {} -c {} > /tmp/start-command/logs/isolation/ssh/{}.log 2>&1 &",
479            shell_invocation,
480            shell_escape(command),
481            session_name
482        );
483        let ssh_args = vec![endpoint.as_str(), remote_command.as_str()];
484
485        if is_debug() {
486            eprintln!("[DEBUG] Running: ssh {:?}", ssh_args);
487            eprintln!("[DEBUG] shell: {}", shell_invocation);
488        }
489
490        let status = Command::new("ssh").args(&ssh_args).status();
491
492        match status {
493            Ok(s) if s.success() => IsolationResult {
494                success: true,
495                session_name: Some(session_name.clone()),
496                message: format!(
497                    "Command started in detached SSH session on {}\nSession: {}\nView logs: ssh {} \"tail -f /tmp/start-command/logs/isolation/ssh/{}.log\"",
498                    endpoint, session_name, endpoint, session_name
499                ),
500                ..Default::default()
501            },
502            _ => IsolationResult {
503                success: false,
504                session_name: Some(session_name),
505                message: "Failed to start SSH session".to_string(),
506                ..Default::default()
507            },
508        }
509    } else {
510        // Attached mode: Run command using the detected shell with interactive mode
511        // so that startup files (.bashrc etc.) are sourced and tools like nvm are available.
512        let mut ssh_cmd_args = vec![endpoint.clone(), shell_to_use.clone()];
513        if let Some(flag) = shell_interactive_flag {
514            ssh_cmd_args.push(flag.to_string());
515        }
516        ssh_cmd_args.push("-c".to_string());
517        ssh_cmd_args.push(command.to_string());
518
519        if is_debug() {
520            eprintln!("[DEBUG] Running: ssh {:?}", ssh_cmd_args);
521            eprintln!("[DEBUG] shell: {}", shell_to_use);
522        }
523
524        let status = Command::new("ssh").args(&ssh_cmd_args).status();
525
526        match status {
527            Ok(s) => IsolationResult {
528                success: s.success(),
529                session_name: Some(session_name.clone()),
530                message: format!(
531                    "SSH session \"{}\" on {} exited with code {}",
532                    session_name,
533                    endpoint,
534                    s.code().unwrap_or(-1)
535                ),
536                exit_code: s.code(),
537                ..Default::default()
538            },
539            Err(e) => IsolationResult {
540                success: false,
541                session_name: Some(session_name),
542                message: format!("Failed to start SSH: {}", e),
543                ..Default::default()
544            },
545        }
546    }
547}
548
549/// Check if a Docker image exists locally
550pub fn docker_image_exists(image: &str) -> bool {
551    Command::new("docker")
552        .args(["image", "inspect", image])
553        .stdout(Stdio::null())
554        .stderr(Stdio::null())
555        .status()
556        .map(|s| s.success())
557        .unwrap_or(false)
558}
559
560/// Pull a Docker image with output streaming
561/// Returns (success, output) tuple
562pub fn docker_pull_image(image: &str) -> (bool, String) {
563    use std::io::{BufRead, BufReader};
564
565    // Print the virtual command line followed by empty line for visual separation
566    println!(
567        "{}",
568        crate::output_blocks::create_virtual_command_block(&format!("docker pull {}", image))
569    );
570    println!();
571
572    let mut child = match Command::new("docker")
573        .args(["pull", image])
574        .stdout(Stdio::piped())
575        .stderr(Stdio::piped())
576        .spawn()
577    {
578        Ok(c) => c,
579        Err(e) => {
580            let error_msg = format!("Failed to run docker pull: {}", e);
581            eprintln!("{}", error_msg);
582            println!();
583            println!(
584                "{}",
585                crate::output_blocks::create_virtual_command_result(false)
586            );
587            return (false, error_msg);
588        }
589    };
590
591    let mut output = String::new();
592
593    // Read and display stdout
594    if let Some(stdout) = child.stdout.take() {
595        let reader = BufReader::new(stdout);
596        for line in reader.lines().map_while(Result::ok) {
597            println!("{}", line);
598            output.push_str(&line);
599            output.push('\n');
600        }
601    }
602
603    // Read and display stderr
604    if let Some(stderr) = child.stderr.take() {
605        let reader = BufReader::new(stderr);
606        for line in reader.lines().map_while(Result::ok) {
607            eprintln!("{}", line);
608            output.push_str(&line);
609            output.push('\n');
610        }
611    }
612
613    let success = child.wait().map(|s| s.success()).unwrap_or(false);
614
615    // Print empty line before result marker for visual separation (issue #73)
616    // This ensures output is visually separated from the result marker
617    println!();
618    println!(
619        "{}",
620        crate::output_blocks::create_virtual_command_result(success)
621    );
622    println!("{}", crate::output_blocks::create_timeline_separator());
623
624    (success, output)
625}
626
627/// Build the extra `docker run` arguments contributed by runtime options
628/// (--privileged, --env/-e, --volume/-v, --mount). Returned references borrow
629/// from `options`, which outlives the `docker run` invocation.
630fn build_docker_runtime_args(options: &IsolationOptions) -> Vec<&str> {
631    let mut args: Vec<&str> = Vec::new();
632    if options.privileged {
633        args.push("--privileged");
634    }
635    for env_var in &options.env {
636        args.push("-e");
637        args.push(env_var);
638    }
639    for volume in &options.volumes {
640        args.push("-v");
641        args.push(volume);
642    }
643    for mount in &options.mounts {
644        args.push("--mount");
645        args.push(mount);
646    }
647    args
648}
649
650/// Run command in Docker container
651pub fn run_in_docker(command: &str, options: &IsolationOptions) -> IsolationResult {
652    if !is_command_available("docker") {
653        return IsolationResult {
654            success: false,
655            message:
656                "docker is not installed. Install Docker from https://docs.docker.com/get-docker/"
657                    .to_string(),
658            ..Default::default()
659        };
660    }
661
662    let image = match &options.image {
663        Some(i) => i.clone(),
664        None => {
665            return IsolationResult {
666                success: false,
667                message: "Docker isolation requires --image option".to_string(),
668                ..Default::default()
669            };
670        }
671    };
672
673    // Check if image exists locally; if not, pull it as a virtual command
674    if !docker_image_exists(&image) {
675        let (pull_success, _pull_output) = docker_pull_image(&image);
676        if !pull_success {
677            return IsolationResult {
678                success: false,
679                message: format!("Failed to pull Docker image: {}", image),
680                exit_code: Some(1),
681                ..Default::default()
682            };
683        }
684    }
685
686    let container_name = options
687        .session
688        .clone()
689        .unwrap_or_else(|| generate_session_name(Some("docker")));
690
691    // Detect the shell to use in the container
692    let shell_to_use = detect_shell_in_environment("docker", options);
693    // Use interactive mode (-i) for shells that support it (bash, zsh) so that startup
694    // files like .bashrc are sourced, making tools like nvm available in commands.
695    let shell_interactive_flag = get_shell_interactive_flag(&shell_to_use);
696
697    // Print the user command (this appears after any virtual commands like docker pull)
698    println!("{}", crate::output_blocks::create_command_line(command));
699    println!();
700
701    if options.detached {
702        let effective_command = if options.keep_alive {
703            format!("{}; exec {}", command, shell_to_use)
704        } else {
705            command.to_string()
706        };
707
708        let mut args = vec!["run", "-d", "--name", &container_name];
709
710        if options.auto_remove_docker_container {
711            args.push("--rm");
712        }
713
714        if let Some(ref user) = options.user {
715            args.push("--user");
716            args.push(user);
717        }
718
719        args.extend(build_docker_runtime_args(options));
720
721        args.push(&image);
722        args.push(&shell_to_use);
723        if let Some(flag) = shell_interactive_flag {
724            args.push(flag);
725        }
726        args.extend(&["-c", &effective_command]);
727
728        if is_debug() {
729            eprintln!("[DEBUG] Running: docker {:?}", args);
730            eprintln!("[DEBUG] shell: {}", shell_to_use);
731        }
732
733        match Command::new("docker").args(&args).output() {
734            Ok(output) if output.status.success() => {
735                let container_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
736
737                if let Some(log_path) = options.log_path.as_ref() {
738                    let logger_script = format!(
739                        "docker logs -f {} >> {} 2>&1; __start_command_exit=$(docker inspect -f '{{{{.State.ExitCode}}}}' {} 2>/dev/null || printf '%s' '-1'); {} >> {}",
740                        crate::isolation::isolation_log::shell_quote(&container_name),
741                        crate::isolation::isolation_log::shell_quote(&log_path.to_string_lossy()),
742                        crate::isolation::isolation_log::shell_quote(&container_name),
743                        crate::isolation::isolation_log::create_shell_log_footer_snippet(),
744                        crate::isolation::isolation_log::shell_quote(&log_path.to_string_lossy())
745                    );
746                    let _ = Command::new("sh")
747                        .args(["-c", &logger_script])
748                        .stdout(Stdio::null())
749                        .stderr(Stdio::null())
750                        .spawn();
751                }
752
753                let mut message = format!(
754                    "Command started in detached docker container: {}",
755                    container_name
756                );
757                message.push_str(&format!(
758                    "\nContainer ID: {}",
759                    &container_id[..12.min(container_id.len())]
760                ));
761                if options.keep_alive {
762                    message.push_str("\nContainer will stay alive after command completes.");
763                } else {
764                    message
765                        .push_str("\nContainer will exit automatically after command completes.");
766                }
767                if options.auto_remove_docker_container {
768                    message.push_str("\nContainer will be automatically removed after exit.");
769                } else {
770                    message.push_str("\nContainer filesystem will be preserved after exit.");
771                }
772                message.push_str(&format!("\nAttach with: docker attach {}", container_name));
773                message.push_str(&format!("\nView logs: docker logs {}", container_name));
774                if let Some(log_path) = options.log_path.as_ref() {
775                    message.push_str(&format!("\nLive log: {}", log_path.display()));
776                }
777
778                IsolationResult {
779                    success: true,
780                    session_name: Some(container_name),
781                    container_id: Some(container_id),
782                    message,
783                    ..Default::default()
784                }
785            }
786            Ok(output) => {
787                let stderr = String::from_utf8_lossy(&output.stderr);
788                IsolationResult {
789                    success: false,
790                    session_name: Some(container_name),
791                    message: format!("Failed to start docker container: {}", stderr),
792                    ..Default::default()
793                }
794            }
795            Err(e) => IsolationResult {
796                success: false,
797                session_name: Some(container_name),
798                message: format!("Failed to run docker: {}", e),
799                ..Default::default()
800            },
801        }
802    } else {
803        // Attached mode
804        let mut args = vec!["run", "-it", "--rm", "--name", &container_name];
805
806        if let Some(ref user) = options.user {
807            args.push("--user");
808            args.push(user);
809        }
810
811        args.extend(build_docker_runtime_args(options));
812
813        if is_debug() {
814            eprintln!("[DEBUG] shell: {}", shell_to_use);
815        }
816
817        args.push(&image);
818        args.push(&shell_to_use);
819        if let Some(flag) = shell_interactive_flag {
820            args.push(flag);
821        }
822        args.extend(&["-c", command]);
823
824        let status = Command::new("docker").args(&args).status();
825
826        match status {
827            Ok(s) => IsolationResult {
828                success: s.success(),
829                session_name: Some(container_name.clone()),
830                message: format!(
831                    "Docker container \"{}\" exited with code {}",
832                    container_name,
833                    s.code().unwrap_or(-1)
834                ),
835                exit_code: s.code(),
836                ..Default::default()
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;
891pub use self::isolation_log::{
892    append_log_file, create_log_footer, create_log_header, create_log_path,
893    create_log_path_for_execution, generate_log_filename, get_default_docker_image, get_log_dir,
894    get_temp_dir, get_temp_root, get_timestamp, write_log_file, LogHeaderParams,
895};
896
897fn is_debug() -> bool {
898    env::var("START_DEBUG").is_ok_and(|v| v == "1" || v == "true")
899}
900
901fn shell_escape(command: &str) -> String {
902    format!("'{}'", command.replace('\'', "'\\''"))
903}
904
905#[path = "atty.rs"]
906mod atty;
907
908#[cfg(test)]
909#[path = "isolation_cases.rs"]
910mod tests;