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///
562/// When `log_path` is provided, the image-preparation phase (the `docker pull`)
563/// is also recorded in the session log so the single log file is a gap-free
564/// record of everything that ran (issue #138): a `Preparing image …` marker with
565/// a timestamp is written before the pull, each line of pull output is teed into
566/// the log as it streams, and an `Image ready (<duration>)` marker is written
567/// afterwards. Without a `log_path` the behavior is unchanged.
568///
569/// Returns (success, output) tuple
570pub fn docker_pull_image(image: &str, log_path: Option<&PathBuf>) -> (bool, String) {
571    use crate::isolation::isolation_log::{append_log_file, get_timestamp};
572    use std::io::{BufRead, BufReader};
573    use std::time::Instant;
574
575    // Print the virtual command line followed by empty line for visual separation
576    println!(
577        "{}",
578        crate::output_blocks::create_virtual_command_block(&format!("docker pull {}", image))
579    );
580    println!();
581
582    // Record the start of the image-preparation phase in the session log so
583    // operators tailing the log see progress instead of a header-only file.
584    let prep_start = Instant::now();
585    if let Some(path) = log_path {
586        append_log_file(
587            path,
588            &format!(
589                "$ docker pull {}\nPreparing image {}… ({})\n",
590                image,
591                image,
592                get_timestamp()
593            ),
594        );
595    }
596
597    let mut child = match Command::new("docker")
598        .args(["pull", image])
599        .stdout(Stdio::piped())
600        .stderr(Stdio::piped())
601        .spawn()
602    {
603        Ok(c) => c,
604        Err(e) => {
605            let error_msg = format!("Failed to run docker pull: {}", e);
606            eprintln!("{}", error_msg);
607            if let Some(path) = log_path {
608                append_log_file(
609                    path,
610                    &format!(
611                        "{}\nImage preparation failed ({:.1}s)\n",
612                        error_msg,
613                        prep_start.elapsed().as_secs_f64()
614                    ),
615                );
616            }
617            println!();
618            println!(
619                "{}",
620                crate::output_blocks::create_virtual_command_result(false)
621            );
622            return (false, error_msg);
623        }
624    };
625
626    let mut output = String::new();
627
628    // Read and display stdout, teeing each line into the session log.
629    if let Some(stdout) = child.stdout.take() {
630        let reader = BufReader::new(stdout);
631        for line in reader.lines().map_while(Result::ok) {
632            println!("{}", line);
633            if let Some(path) = log_path {
634                append_log_file(path, &format!("{}\n", line));
635            }
636            output.push_str(&line);
637            output.push('\n');
638        }
639    }
640
641    // Read and display stderr, teeing each line into the session log.
642    if let Some(stderr) = child.stderr.take() {
643        let reader = BufReader::new(stderr);
644        for line in reader.lines().map_while(Result::ok) {
645            eprintln!("{}", line);
646            if let Some(path) = log_path {
647                append_log_file(path, &format!("{}\n", line));
648            }
649            output.push_str(&line);
650            output.push('\n');
651        }
652    }
653
654    let success = child.wait().map(|s| s.success()).unwrap_or(false);
655
656    // Record the end of the image-preparation phase with elapsed duration so the
657    // prep time is visible even when full progress is unavailable (issue #138).
658    if let Some(path) = log_path {
659        let duration = prep_start.elapsed().as_secs_f64();
660        append_log_file(
661            path,
662            &if success {
663                format!("Image ready ({:.1}s)\n", duration)
664            } else {
665                format!("Image preparation failed ({:.1}s)\n", duration)
666            },
667        );
668    }
669
670    // Print empty line before result marker for visual separation (issue #73)
671    // This ensures output is visually separated from the result marker
672    println!();
673    println!(
674        "{}",
675        crate::output_blocks::create_virtual_command_result(success)
676    );
677    println!("{}", crate::output_blocks::create_timeline_separator());
678
679    (success, output)
680}
681
682/// Build the extra `docker run` arguments contributed by runtime options
683/// (--privileged, --env/-e, --volume/-v, --mount). Returned references borrow
684/// from `options`, which outlives the `docker run` invocation.
685fn build_docker_runtime_args(options: &IsolationOptions) -> Vec<&str> {
686    let mut args: Vec<&str> = Vec::new();
687    if options.privileged {
688        args.push("--privileged");
689    }
690    for env_var in &options.env {
691        args.push("-e");
692        args.push(env_var);
693    }
694    for volume in &options.volumes {
695        args.push("-v");
696        args.push(volume);
697    }
698    for mount in &options.mounts {
699        args.push("--mount");
700        args.push(mount);
701    }
702    args
703}
704
705/// Run command in Docker container
706pub fn run_in_docker(command: &str, options: &IsolationOptions) -> IsolationResult {
707    if !is_command_available("docker") {
708        return IsolationResult {
709            success: false,
710            message:
711                "docker is not installed. Install Docker from https://docs.docker.com/get-docker/"
712                    .to_string(),
713            ..Default::default()
714        };
715    }
716
717    let image = match &options.image {
718        Some(i) => i.clone(),
719        None => {
720            return IsolationResult {
721                success: false,
722                message: "Docker isolation requires --image option".to_string(),
723                ..Default::default()
724            };
725        }
726    };
727
728    // Check if image exists locally; if not, pull it as a virtual command.
729    // Pass log_path so the image-preparation phase (docker pull) is recorded in
730    // the session log, keeping it a gap-free record of the run (issue #138).
731    if !docker_image_exists(&image) {
732        let (pull_success, _pull_output) = docker_pull_image(&image, options.log_path.as_ref());
733        if !pull_success {
734            return IsolationResult {
735                success: false,
736                message: format!("Failed to pull Docker image: {}", image),
737                exit_code: Some(1),
738                ..Default::default()
739            };
740        }
741    }
742
743    let container_name = options
744        .session
745        .clone()
746        .unwrap_or_else(|| generate_session_name(Some("docker")));
747
748    // Detect the shell to use in the container
749    let shell_to_use = detect_shell_in_environment("docker", options);
750    // Use interactive mode (-i) for shells that support it (bash, zsh) so that startup
751    // files like .bashrc are sourced, making tools like nvm available in commands.
752    let shell_interactive_flag = get_shell_interactive_flag(&shell_to_use);
753
754    // Print the user command (this appears after any virtual commands like docker pull)
755    println!("{}", crate::output_blocks::create_command_line(command));
756    println!();
757
758    if options.detached {
759        let effective_command = if options.keep_alive {
760            format!("{}; exec {}", command, shell_to_use)
761        } else {
762            command.to_string()
763        };
764
765        let mut args = vec!["run", "-d", "--name", &container_name];
766
767        if options.auto_remove_docker_container {
768            args.push("--rm");
769        }
770
771        if let Some(ref user) = options.user {
772            args.push("--user");
773            args.push(user);
774        }
775
776        args.extend(build_docker_runtime_args(options));
777
778        args.push(&image);
779        args.push(&shell_to_use);
780        if let Some(flag) = shell_interactive_flag {
781            args.push(flag);
782        }
783        args.extend(&["-c", &effective_command]);
784
785        if is_debug() {
786            eprintln!("[DEBUG] Running: docker {:?}", args);
787            eprintln!("[DEBUG] shell: {}", shell_to_use);
788        }
789
790        match Command::new("docker").args(&args).output() {
791            Ok(output) if output.status.success() => {
792                let container_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
793
794                if let Some(log_path) = options.log_path.as_ref() {
795                    let logger_script = format!(
796                        "docker logs -f {} >> {} 2>&1; __start_command_exit=$(docker inspect -f '{{{{.State.ExitCode}}}}' {} 2>/dev/null || printf '%s' '-1'); {} >> {}",
797                        crate::isolation::isolation_log::shell_quote(&container_name),
798                        crate::isolation::isolation_log::shell_quote(&log_path.to_string_lossy()),
799                        crate::isolation::isolation_log::shell_quote(&container_name),
800                        crate::isolation::isolation_log::create_shell_log_footer_snippet(),
801                        crate::isolation::isolation_log::shell_quote(&log_path.to_string_lossy())
802                    );
803                    let _ = Command::new("sh")
804                        .args(["-c", &logger_script])
805                        .stdout(Stdio::null())
806                        .stderr(Stdio::null())
807                        .spawn();
808                }
809
810                let mut message = format!(
811                    "Command started in detached docker container: {}",
812                    container_name
813                );
814                message.push_str(&format!(
815                    "\nContainer ID: {}",
816                    &container_id[..12.min(container_id.len())]
817                ));
818                if options.keep_alive {
819                    message.push_str("\nContainer will stay alive after command completes.");
820                } else {
821                    message
822                        .push_str("\nContainer will exit automatically after command completes.");
823                }
824                if options.auto_remove_docker_container {
825                    message.push_str("\nContainer will be automatically removed after exit.");
826                } else {
827                    message.push_str("\nContainer filesystem will be preserved after exit.");
828                }
829                message.push_str(&format!("\nAttach with: docker attach {}", container_name));
830                message.push_str(&format!("\nView logs: docker logs {}", container_name));
831                if let Some(log_path) = options.log_path.as_ref() {
832                    message.push_str(&format!("\nLive log: {}", log_path.display()));
833                }
834
835                IsolationResult {
836                    success: true,
837                    session_name: Some(container_name),
838                    container_id: Some(container_id),
839                    message,
840                    ..Default::default()
841                }
842            }
843            Ok(output) => {
844                let stderr = String::from_utf8_lossy(&output.stderr);
845                IsolationResult {
846                    success: false,
847                    session_name: Some(container_name),
848                    message: format!("Failed to start docker container: {}", stderr),
849                    ..Default::default()
850                }
851            }
852            Err(e) => IsolationResult {
853                success: false,
854                session_name: Some(container_name),
855                message: format!("Failed to run docker: {}", e),
856                ..Default::default()
857            },
858        }
859    } else {
860        // Attached mode
861        let mut args = vec!["run", "-it", "--rm", "--name", &container_name];
862
863        if let Some(ref user) = options.user {
864            args.push("--user");
865            args.push(user);
866        }
867
868        args.extend(build_docker_runtime_args(options));
869
870        if is_debug() {
871            eprintln!("[DEBUG] shell: {}", shell_to_use);
872        }
873
874        args.push(&image);
875        args.push(&shell_to_use);
876        if let Some(flag) = shell_interactive_flag {
877            args.push(flag);
878        }
879        args.extend(&["-c", command]);
880
881        let status = Command::new("docker").args(&args).status();
882
883        match status {
884            Ok(s) => IsolationResult {
885                success: s.success(),
886                session_name: Some(container_name.clone()),
887                message: format!(
888                    "Docker container \"{}\" exited with code {}",
889                    container_name,
890                    s.code().unwrap_or(-1)
891                ),
892                exit_code: s.code(),
893                ..Default::default()
894            },
895            Err(e) => IsolationResult {
896                success: false,
897                session_name: Some(container_name),
898                message: format!("Failed to start docker: {}", e),
899                ..Default::default()
900            },
901        }
902    }
903}
904
905/// Run command in the specified isolation backend
906pub fn run_isolated(backend: &str, command: &str, options: &IsolationOptions) -> IsolationResult {
907    match backend {
908        "screen" => run_in_screen(command, options),
909        "tmux" => run_in_tmux(command, options),
910        "docker" => run_in_docker(command, options),
911        "ssh" => run_in_ssh(command, options),
912        _ => IsolationResult {
913            success: false,
914            message: format!("Unknown isolation backend: {}", backend),
915            ..Default::default()
916        },
917    }
918}
919
920/// Run command as an isolated user (without isolation backend)
921pub fn run_as_isolated_user(command: &str, username: &str) -> IsolationResult {
922    let status = Command::new("sudo")
923        .args(["-n", "-u", username, "sh", "-c", command])
924        .status();
925
926    match status {
927        Ok(s) => IsolationResult {
928            success: s.success(),
929            message: format!(
930                "Command completed as user \"{}\" with exit code {}",
931                username,
932                s.code().unwrap_or(-1)
933            ),
934            exit_code: s.code(),
935            ..Default::default()
936        },
937        Err(e) => IsolationResult {
938            success: false,
939            message: format!("Failed to run as user \"{}\": {}", username, e),
940            exit_code: Some(1),
941            ..Default::default()
942        },
943    }
944}
945
946#[path = "isolation_log.rs"]
947pub mod isolation_log;
948pub use self::isolation_log::{
949    append_log_file, create_log_footer, create_log_header, create_log_path,
950    create_log_path_for_execution, generate_log_filename, get_default_docker_image, get_log_dir,
951    get_temp_dir, get_temp_root, get_timestamp, write_log_file, LogHeaderParams,
952};
953
954fn is_debug() -> bool {
955    env::var("START_DEBUG").is_ok_and(|v| v == "1" || v == "true")
956}
957
958fn shell_escape(command: &str) -> String {
959    format!("'{}'", command.replace('\'', "'\\''"))
960}
961
962#[path = "atty.rs"]
963mod atty;
964
965#[cfg(test)]
966#[path = "isolation_cases.rs"]
967mod tests;