Skip to main content

ssh_mcp/ssh/
command.rs

1//! Command execution over SSH
2//!
3//! Provides the `CommandOutput` struct and `exec_command` functionality
4//! for executing commands over an SSH connection with timeout support.
5//!
6//! This module is designed to be compatible with both GNU and BusyBox-based
7//! systems (e.g., Debian/Ubuntu and Alpine Linux). All command detection and
8//! process monitoring uses portable mechanisms that work across distributions.
9
10use std::path::Path;
11use std::sync::Arc;
12use std::time::Duration;
13
14use russh::ChannelMsg;
15use tokio::io::{AsyncRead, AsyncReadExt, AsyncSeekExt, AsyncWrite, AsyncWriteExt};
16use tokio::sync::Mutex;
17use tokio::time::timeout;
18use tracing::{debug, error, warn};
19
20use super::config::TIMEOUT_KILL_AFTER_SECS;
21use super::connection::SshConnectionManager;
22use super::sanitize::{escape_command_for_shell, escape_for_timeout_wrapper, wrap_in_posix_shell};
23use crate::background::{JobRegistry, JobStatus, LocalLogSpooler, SharedJobState};
24use crate::error::{Result, SshMcpError};
25#[cfg(unix)]
26use crate::platform::O_NOFOLLOW_FLAG;
27
28const RAW_STREAM_BYTES_PER_TOKEN: usize = 4;
29const RAW_STREAM_STDERR_HARD_MAX_BYTES: usize = 1024 * 1024;
30
31/// Output from a command execution
32#[derive(Debug, Clone, Default)]
33pub struct CommandOutput {
34    /// Standard output from the command
35    pub stdout: String,
36
37    /// Standard error from the command
38    pub stderr: String,
39
40    /// Exit code of the command (if available)
41    pub exit_code: Option<u32>,
42
43    /// Whether stdout was truncated due to output limits
44    pub stdout_truncated: bool,
45
46    /// Whether stderr was truncated due to output limits
47    pub stderr_truncated: bool,
48
49    /// Approximate total token count for stdout (including truncated content)
50    pub stdout_total_tokens: usize,
51
52    /// Approximate total token count for stderr (including truncated content)
53    pub stderr_total_tokens: usize,
54}
55
56/// Output from a raw streaming command execution.
57///
58/// This is intended for binary-safe stdin/stdout streaming (e.g. file transfer).
59#[derive(Debug, Clone, Default)]
60pub struct TransferRawOutput {
61    /// Total bytes written to remote stdout (as received).
62    pub stdout_bytes: u64,
63
64    /// Total bytes written to remote stdin.
65    pub stdin_bytes: u64,
66
67    /// Collected stderr (lossy UTF-8).
68    pub stderr: String,
69
70    /// Exit code of the remote command (if provided).
71    pub exit_code: Option<u32>,
72}
73
74/// Process status check result
75#[derive(Debug, Clone)]
76pub struct ProcessStatus {
77    /// PID of the background process on the remote host.
78    pub pid: u32,
79    /// Strict job state label: running, completed, failed, or state_lost.
80    pub state: String,
81    /// Whether the process is currently running
82    pub running: bool,
83    /// Exit code if process has completed
84    pub exit_code: Option<u32>,
85    /// Why the job entered state_lost, if known.
86    pub state_reason: Option<String>,
87    /// Elapsed time in ps format (e.g., "12:34" or "2-12:34:56")
88    pub elapsed_time: String,
89    /// Original command string tracked for this job.
90    pub command: String,
91    /// Absolute local log path on the MCP server.
92    pub log_path: String,
93    /// Whether the local log file currently exists.
94    pub log_exists: bool,
95    /// Tail of the log file (if log_path provided)
96    pub log_tail: String,
97}
98
99impl CommandOutput {
100    /// Create a new empty CommandOutput
101    pub fn new() -> Self {
102        Self::default()
103    }
104
105    /// Check if the command succeeded (exit code 0 or no exit code available)
106    pub fn success(&self) -> bool {
107        self.exit_code.is_some_and(|code| code == 0)
108    }
109
110    /// Get combined output (stdout + stderr)
111    pub fn combined_output(&self) -> String {
112        if self.stderr.is_empty() {
113            self.stdout.clone()
114        } else if self.stdout.is_empty() {
115            self.stderr.clone()
116        } else {
117            format!("{}\n{}", self.stdout, self.stderr)
118        }
119    }
120}
121
122/// Wrap a command with the timeout utility
123///
124/// Creates a wrapper command: `timeout -k {kill_after}s {duration}s sh -lc '{command}'`
125///
126/// The use of `sh -lc` ensures a login shell is used, which properly loads
127/// environment variables like PATH from ~/.profile or /etc/profile.
128///
129/// # Arguments
130/// * `command` - The command to wrap (should be pre-escaped)
131/// * `duration_secs` - Timeout duration in seconds (supports fractional seconds like 0.5)
132///
133/// # Returns
134/// A wrapped command string that includes timeout logic
135pub fn wrap_command_with_timeout(command: &str, duration_secs: f64) -> String {
136    let escaped_command = escape_for_timeout_wrapper(command);
137    format!(
138        "timeout -k {}s {}s sh -lc '{}'",
139        TIMEOUT_KILL_AFTER_SECS, duration_secs, escaped_command
140    )
141}
142
143fn wrap_command_for_channel_exec(command: &str) -> String {
144    wrap_in_posix_shell(command, false)
145}
146
147fn validate_timeout_duration(timeout_duration: Duration) -> Result<f64> {
148    // Convert duration to fractional seconds for millisecond precision.
149    // as_secs_f64() preserves sub-second precision (e.g., 500ms -> 0.5, 1500ms -> 1.5).
150    let duration_secs = timeout_duration.as_secs_f64();
151    if !duration_secs.is_finite() || duration_secs <= 0.0 {
152        return Err(SshMcpError::InvalidParams(
153            "duration must be finite and > 0".to_string(),
154        ));
155    }
156    Ok(duration_secs)
157}
158
159fn resolve_raw_stream_stderr_limit(max_output_tokens: Option<usize>) -> usize {
160    max_output_tokens
161        .and_then(|tokens| tokens.checked_mul(RAW_STREAM_BYTES_PER_TOKEN))
162        .filter(|bytes| *bytes > 0)
163        .unwrap_or(RAW_STREAM_STDERR_HARD_MAX_BYTES)
164        .min(RAW_STREAM_STDERR_HARD_MAX_BYTES)
165}
166
167fn utf8_prefix_len(input: &str, max_bytes: usize) -> usize {
168    if input.len() <= max_bytes {
169        return input.len();
170    }
171
172    let mut end = max_bytes;
173    while end > 0 && !input.is_char_boundary(end) {
174        end = end.saturating_sub(1);
175    }
176    end
177}
178
179fn append_bounded_lossy_stderr(stderr: &mut String, chunk: &[u8], max_len: usize) -> bool {
180    if stderr.len() >= max_len {
181        return true;
182    }
183
184    let chunk_str = String::from_utf8_lossy(chunk);
185    let remaining = max_len.saturating_sub(stderr.len());
186    if chunk_str.len() <= remaining {
187        stderr.push_str(&chunk_str);
188        return false;
189    }
190
191    let take = utf8_prefix_len(&chunk_str, remaining);
192    if take > 0 {
193        stderr.push_str(&chunk_str[..take]);
194    }
195    true
196}
197
198/// Errors that can occur before exec is successfully sent.
199/// These errors are retryable since the command has not started executing yet.
200enum PreExecError {
201    ChannelOpen(String),
202    ExecSend(String),
203}
204
205impl PreExecError {
206    /// Convert the pre-exec error into an SSH connection error.
207    fn into_ssh_error(self) -> SshMcpError {
208        match self {
209            PreExecError::ChannelOpen(msg) => SshMcpError::connection(msg),
210            PreExecError::ExecSend(msg) => SshMcpError::connection(msg),
211        }
212    }
213}
214
215/// Errors that can occur when sending command to su shell channel.
216/// These errors are retryable since the command has not started executing yet.
217enum SuSendError {
218    SendFailed(String),
219}
220
221impl SshConnectionManager {
222    /// Execute a command over SSH
223    ///
224    /// This method:
225    /// 1. Ensures the connection is active
226    /// 2. If elevated (su shell), uses the PTY shell channel
227    /// 3. Otherwise, opens a new exec channel
228    /// 4. Collects stdout/stderr with timeout
229    /// 5. On timeout, attempts graceful abort via pkill
230    ///
231    /// # Arguments
232    /// * `command` - The command to execute (should be pre-sanitized)
233    /// * `timeout_duration` - Maximum time to wait for command completion
234    ///
235    /// # Returns
236    /// * `Ok(CommandOutput)` - Command output with stdout, stderr, and exit code
237    /// * `Err(SshMcpError::Timeout)` - If command times out
238    /// * `Err(SshMcpError::Connection)` - If connection issues occur
239    pub async fn exec_command(
240        &self,
241        command: &str,
242        timeout_duration: Duration,
243    ) -> Result<CommandOutput> {
244        // Acquire semaphore permit to limit concurrent command execution
245        let _permit = self.acquire_command_slot().await?;
246
247        // Ensure we're connected
248        self.ensure_connected().await?;
249
250        // Check if we have an elevated su shell
251        if self.is_elevated() && self.has_su_channel().await {
252            debug!("Using elevated su shell for command execution");
253            return self.exec_via_su_shell(command, timeout_duration).await;
254        }
255
256        // Normal exec via new channel
257        debug!("Using normal exec channel for command execution");
258        self.exec_via_channel(command, timeout_duration).await
259    }
260
261    /// Execute command via the elevated su shell (PTY)
262    ///
263    /// Implements deterministic one-shot retry for pre-send failures:
264    /// - If sending command to su channel fails: reset su state, re-elevate, retry once
265    /// - If failure occurs after command is sent: no retry, reset su state and invalidate session
266    async fn exec_via_su_shell(
267        &self,
268        command: &str,
269        timeout_duration: Duration,
270    ) -> Result<CommandOutput> {
271        let duration_secs = validate_timeout_duration(timeout_duration)?;
272
273        // Check timeout availability lazily on first use (same as exec_via_channel)
274        let use_wrapper = self.determine_timeout_wrapper_usage().await;
275
276        // Wrap command with timeout if available
277        let wrapped_cmd = if use_wrapper {
278            wrap_command_with_timeout(command, duration_secs)
279        } else {
280            command.to_string()
281        };
282
283        debug!(
284            "Executing elevated command: cmd_len={}, wrapped_len={}, timeout_wrapped={}",
285            command.len(),
286            wrapped_cmd.len(),
287            use_wrapper
288        );
289
290        // Attempt #1: try to send command via existing su channel
291        let mut channel = match self.try_take_su_channel().await {
292            Some(ch) => ch,
293            None => {
294                // No channel available - try to elevate and retry once
295                warn!("No su channel available, attempting elevation");
296                self.reset_su_state().await;
297                self.ensure_elevated().await?;
298                match self.try_take_su_channel().await {
299                    Some(ch) => ch,
300                    None => {
301                        return Err(SshMcpError::connection(
302                            "No su channel available after elevation",
303                        ));
304                    }
305                }
306            }
307        };
308
309        // Try to send the command
310        match self
311            .try_send_to_su_channel(&mut channel, &wrapped_cmd)
312            .await
313        {
314            Ok(()) => {
315                // Command sent successfully - collect output
316                let result = self
317                    .collect_su_output(&mut channel, timeout_duration, use_wrapper)
318                    .await;
319
320                // Put the channel back (even if collection failed)
321                {
322                    let mut guard = self.su_channel.lock().await;
323                    *guard = Some(channel);
324                }
325
326                // Handle post-send failure: reset su state and invalidate session, no retry
327                if let Err(ref e) = result {
328                    warn!(error = ?e, "su channel failed after command sent");
329                    self.reset_su_state().await;
330                    self.invalidate_session("su channel failed after send")
331                        .await;
332                }
333
334                result
335            }
336            Err(SuSendError::SendFailed(e)) => {
337                // Pre-send failure: command was NOT sent
338                // Drop the bad channel (don't put it back)
339                drop(channel);
340
341                // Reset su state and re-elevate once
342                warn!(
343                    error = ?e,
344                    "su channel send failed (pre-send), resetting and re-elevating"
345                );
346                self.reset_su_state().await;
347                self.ensure_elevated().await?;
348
349                // Attempt #2: take new channel and send
350                let mut channel = match self.try_take_su_channel().await {
351                    Some(ch) => ch,
352                    None => {
353                        return Err(SshMcpError::connection(
354                            "No su channel available after re-elevation",
355                        ));
356                    }
357                };
358
359                // Try to send again - if this fails, no more retries
360                if let Err(SuSendError::SendFailed(e2)) = self
361                    .try_send_to_su_channel(&mut channel, &wrapped_cmd)
362                    .await
363                {
364                    // Second failure - drop channel, reset state, return error
365                    drop(channel);
366                    self.reset_su_state().await;
367                    return Err(SshMcpError::connection(format!(
368                        "Failed to send command to su channel after retry: {}",
369                        e2
370                    )));
371                }
372
373                // Second attempt succeeded - collect output
374                let result = self
375                    .collect_su_output(&mut channel, timeout_duration, use_wrapper)
376                    .await;
377
378                // Put the channel back
379                {
380                    let mut guard = self.su_channel.lock().await;
381                    *guard = Some(channel);
382                }
383
384                // Handle post-send failure: reset su state and invalidate session, no retry
385                if let Err(ref e) = result {
386                    warn!(error = ?e, "su channel failed after command sent (retry)");
387                    self.reset_su_state().await;
388                    self.invalidate_session("su channel failed after send (retry)")
389                        .await;
390                }
391
392                result
393            }
394        }
395    }
396
397    /// Try to take the su channel from the mutex
398    async fn try_take_su_channel(&self) -> Option<russh::Channel<russh::client::Msg>> {
399        let mut guard = self.su_channel.lock().await;
400        guard.take()
401    }
402
403    /// Reset su state (clear channel and elevation flag)
404    async fn reset_su_state(&self) {
405        // Take channel out of mutex before awaiting to avoid deadlock
406        let channel = {
407            let mut guard = self.su_channel.lock().await;
408            guard.take()
409        };
410
411        // Drop lock before awaiting EOF
412        if let Some(ch) = channel {
413            // Try to close gracefully, but don't wait
414            let _ = ch.eof().await;
415        }
416
417        use std::sync::atomic::Ordering;
418        self.is_elevated.store(false, Ordering::SeqCst);
419        debug!("su state reset: channel cleared, is_elevated=false");
420    }
421
422    /// Try to send command to su channel
423    /// Returns Ok(()) if sent successfully, Err(SuSendError) if send failed
424    async fn try_send_to_su_channel(
425        &self,
426        channel: &mut russh::Channel<russh::client::Msg>,
427        command: &str,
428    ) -> std::result::Result<(), SuSendError> {
429        let wrapped_command = wrap_command_for_channel_exec(command);
430        channel
431            .data(format!("{}\n", wrapped_command).as_bytes())
432            .await
433            .map_err(|e| SuSendError::SendFailed(e.to_string()))
434    }
435
436    /// Collect output from su channel until root prompt or error
437    ///
438    /// This path is used by direct `exec_command` callers while a persistent
439    /// `su` PTY channel is active. Shell tools use their dedicated streaming
440    /// wrapper instead.
441    async fn collect_su_output(
442        &self,
443        channel: &mut russh::Channel<russh::client::Msg>,
444        timeout_duration: Duration,
445        use_wrapper: bool,
446    ) -> Result<CommandOutput> {
447        let mut buffer = String::new();
448        // When using wrapper, timeout is handled remotely - no local deadline needed
449        let deadline = if use_wrapper {
450            None
451        } else {
452            Some(tokio::time::Instant::now() + timeout_duration)
453        };
454
455        loop {
456            if let Some(deadline_ref) = deadline
457                && tokio::time::Instant::now() > deadline_ref
458            {
459                return Err(SshMcpError::Timeout(timeout_duration.as_millis() as u64));
460            }
461
462            let wait_result =
463                tokio::time::timeout(Duration::from_millis(500), channel.wait()).await;
464
465            match wait_result {
466                Ok(Some(msg)) => {
467                    match msg {
468                        ChannelMsg::Data { data } => {
469                            let text = String::from_utf8_lossy(&data);
470                            buffer.push_str(&text);
471
472                            // Check for root prompt - indicates command complete.
473                            // The `#` sentinel is inherently fragile (any `#` in
474                            // command output would match). This is limited to the
475                            // direct persistent `su` PTY path described above.
476                            if buffer.contains('#') {
477                                // Extract output: remove the command echo and final prompt
478                                let lines: Vec<&str> = buffer.lines().collect();
479                                // First line is often the echoed command; last line is the prompt
480                                let output = if lines.len() > 2 {
481                                    lines[1..lines.len() - 1].join("\n")
482                                } else {
483                                    String::new()
484                                };
485
486                                return Ok(CommandOutput {
487                                    stdout: if output.is_empty() {
488                                        output
489                                    } else {
490                                        format!("{}\n", output)
491                                    },
492                                    stderr: String::new(),
493                                    exit_code: Some(0), // Assume success in PTY mode
494                                    ..Default::default()
495                                });
496                            }
497                        }
498                        ChannelMsg::Close => {
499                            return Err(SshMcpError::connection(
500                                "Channel closed during command execution",
501                            ));
502                        }
503                        _ => {
504                            // Ignore other messages
505                        }
506                    }
507                }
508                Ok(None) => {
509                    return Err(SshMcpError::connection(
510                        "Channel ended during command execution",
511                    ));
512                }
513                Err(_) => {
514                    // Timeout on wait, continue loop
515                    continue;
516                }
517            }
518        }
519    }
520
521    /// Execute command via a new exec channel
522    ///
523    /// Implements deterministic one-shot retry for pre-exec failures:
524    /// - Channel open failure: reconnect and retry once
525    /// - channel.exec() send failure: reconnect and retry once
526    /// - Failures after exec starts (output collection, Close/Eof): no retry,
527    ///   just invalidate session so next command reconnects
528    /// - Timeout errors: no retry (command may have partially run)
529    async fn exec_via_channel(
530        &self,
531        command: &str,
532        timeout_duration: Duration,
533    ) -> Result<CommandOutput> {
534        let duration_secs = validate_timeout_duration(timeout_duration)?;
535
536        // Wrap command with timeout if available
537        // Check timeout availability lazily on first use
538        let use_wrapper = self.determine_timeout_wrapper_usage().await;
539
540        let wrapped_cmd = if use_wrapper {
541            wrap_command_with_timeout(command, duration_secs)
542        } else {
543            // Fall back to old method: use tokio timeout + pkill
544            command.to_string()
545        };
546
547        // Attempt #1: open channel and exec
548        let (channel, _exec_sent) = self
549            .open_and_exec_with_reconnect_retry(&wrapped_cmd)
550            .await?;
551
552        // At this point, exec has been sent successfully.
553        // Collect output with appropriate timeout strategy.
554        // Failures here do NOT trigger retry - we just invalidate the session.
555        let output_result = if use_wrapper {
556            // When using wrapper, timeout is handled remotely - no tokio timeout needed
557            self.collect_channel_output(channel).await
558        } else {
559            // Fall back: use tokio timeout + pkill for abort
560            let result = timeout(timeout_duration, self.collect_channel_output(channel)).await;
561
562            match result {
563                Ok(inner_result) => inner_result,
564                Err(_) => {
565                    // Timeout occurred - attempt graceful abort
566                    warn!(
567                        "Command timed out after {}ms, attempting abort",
568                        timeout_duration.as_millis()
569                    );
570                    self.abort_command(command).await;
571                    self.invalidate_session("command timed out after exec")
572                        .await;
573                    return Err(SshMcpError::Timeout(timeout_duration.as_millis() as u64));
574                }
575            }
576        };
577
578        let output = match output_result {
579            Ok(out) => out,
580            Err(e) => {
581                // Failure after exec started - invalidate session, no retry
582                // Do not retry: command may have partially executed
583                if !matches!(e, SshMcpError::Timeout(_)) {
584                    self.invalidate_session("channel failed after exec").await;
585                }
586                return Err(e);
587            }
588        };
589
590        // Check if timeout command failed (e.g., not found) when using wrapper
591        if use_wrapper {
592            let stderr_lower = output.stderr.to_lowercase();
593            // Check for timeout command not found errors (multiple languages)
594            let timeout_not_found = stderr_lower.contains("timeout: command not found")
595                || stderr_lower.contains("timeout: не найдена команда")
596                || stderr_lower.contains("timeout: introuvable")
597                || stderr_lower.contains("timeout: команда не найдена");
598
599            if timeout_not_found {
600                error!("timeout command not available on remote host, enabling fallback");
601                self.disable_timeout_wrapper();
602
603                // Execute the command again using fallback method (tokio timeout + pkill)
604                // Note: This is a feature fallback, not a connection retry
605                let (channel, _) = self
606                    .open_and_exec_with_reconnect_retry(command)
607                    .await
608                    .map_err(|e| {
609                        SshMcpError::connection(format!(
610                            "Failed to start fallback execution after reconnect retry: {e}"
611                        ))
612                    })?;
613
614                let result = timeout(timeout_duration, self.collect_channel_output(channel)).await;
615
616                return match result {
617                    Ok(inner_output) => inner_output,
618                    Err(_) => {
619                        warn!(
620                            "Command timed out after {}ms (fallback), attempting abort",
621                            timeout_duration.as_millis()
622                        );
623                        self.abort_command(command).await;
624                        self.invalidate_session("fallback command timed out after exec")
625                            .await;
626                        Err(SshMcpError::Timeout(timeout_duration.as_millis() as u64))
627                    }
628                };
629            }
630
631            // Check if the command was killed by timeout
632            // timeout returns 124 when it kills the command
633            if output.exit_code == Some(124) {
634                warn!("Command timed out (timeout wrapper returned 124)");
635                return Err(SshMcpError::Timeout(timeout_duration.as_millis() as u64));
636            }
637        }
638
639        Ok(output)
640    }
641
642    /// Try to open a channel and send exec command
643    ///
644    /// Returns the channel and a boolean indicating exec was sent successfully.
645    /// Separates pre-exec failures (which can be retried) from post-exec state.
646    async fn try_open_and_exec(
647        &self,
648        command: &str,
649    ) -> std::result::Result<(russh::Channel<russh::client::Msg>, bool), PreExecError> {
650        let channel = self
651            .open_channel()
652            .await
653            .map_err(|e| PreExecError::ChannelOpen(e.to_string()))?;
654
655        debug!("Executing command: cmd_len={}", command.len());
656        let wrapped_command = wrap_command_for_channel_exec(command);
657        channel
658            .exec(true, wrapped_command.as_str())
659            .await
660            .map_err(|e| PreExecError::ExecSend(format!("Failed to exec command: {}", e)))?;
661
662        Ok((channel, true))
663    }
664
665    async fn open_and_exec_with_reconnect_retry(
666        &self,
667        command: &str,
668    ) -> Result<(russh::Channel<russh::client::Msg>, bool)> {
669        match self.try_open_and_exec(command).await {
670            Ok(result) => Ok(result),
671            Err(pre_exec_err) => {
672                match &pre_exec_err {
673                    PreExecError::ChannelOpen(e) => {
674                        warn!(
675                            error = ?e,
676                            "Channel open failed, attempting reconnect and retry"
677                        );
678                    }
679                    PreExecError::ExecSend(e) => {
680                        warn!(error = ?e, "Exec send failed, attempting reconnect and retry");
681                    }
682                }
683
684                self.reconnect().await?;
685                self.try_open_and_exec(command)
686                    .await
687                    .map_err(|retry_err| retry_err.into_ssh_error())
688            }
689        }
690    }
691
692    /// Collect output from a channel until it closes
693    ///
694    /// Implements output limiting to prevent OOM and context overflow.
695    /// Approximate token count: 1 token ≈ 4 bytes for UTF-8 text.
696    async fn collect_channel_output(
697        &self,
698        mut channel: russh::Channel<russh::client::Msg>,
699    ) -> Result<CommandOutput> {
700        // Approximate: 1 token ≈ 4 bytes for estimation
701        const BYTES_PER_TOKEN: usize = 4;
702        // Keep a small tail of truncated output so callers can still see
703        // end-of-command markers (e.g. "done").
704        const TAIL_BYTES: usize = 512;
705
706        let mut output = CommandOutput::new();
707
708        // Calculate byte limit from config (if set)
709        let max_bytes = self
710            .config
711            .max_output_tokens
712            .map(|tokens| tokens.saturating_mul(BYTES_PER_TOKEN));
713
714        // Track total tokens received (including what was truncated)
715        let mut total_stdout_tokens: usize = 0;
716        let mut total_stderr_tokens: usize = 0;
717
718        // Flags to track if we've already added truncation messages
719        let mut stdout_truncation_added = false;
720        let mut stderr_truncation_added = false;
721
722        let mut stdout_tail: String = String::new();
723        let mut stderr_tail: String = String::new();
724
725        let push_tail = |buf: &mut String, chunk: &str| {
726            if chunk.is_empty() {
727                return;
728            }
729            buf.push_str(chunk);
730            if buf.len() > TAIL_BYTES {
731                let start = buf.len().saturating_sub(TAIL_BYTES);
732                let mut safe_start = start;
733                while safe_start > 0 && !buf.is_char_boundary(safe_start) {
734                    safe_start = safe_start.saturating_sub(1);
735                }
736                if safe_start > 0 {
737                    buf.drain(..safe_start);
738                }
739            }
740        };
741
742        while let Some(msg) = channel.wait().await {
743            match msg {
744                ChannelMsg::Data { data } => {
745                    let data_len = data.len();
746                    total_stdout_tokens =
747                        total_stdout_tokens.saturating_add(data_len / BYTES_PER_TOKEN);
748                    let data_str = String::from_utf8_lossy(&data);
749
750                    if let Some(limit) = max_bytes {
751                        let current_len = output.stdout.len();
752
753                        // Check if we need to truncate
754                        if current_len.saturating_add(data_str.len()) > limit {
755                            if !stdout_truncation_added {
756                                // Calculate how much we can take
757                                let remaining = limit.saturating_sub(current_len);
758                                let mut take: usize = 0;
759                                if remaining > 0 {
760                                    // Safe slicing: we know remaining is within bounds since data_str.len() > remaining
761                                    let safe_end = data_str
762                                        .char_indices()
763                                        .map(|(i, _)| i)
764                                        .find(|&i| i > remaining)
765                                        .unwrap_or(data_str.len());
766                                    take = std::cmp::min(safe_end, remaining);
767                                    output.stdout.push_str(&data_str[..take]);
768                                }
769                                output.stdout_truncated = true;
770                                output.stdout_total_tokens = total_stdout_tokens;
771
772                                // Add truncation notice with tips
773                                output.stdout.push_str(&format!(
774                                    "\n[Output truncated: {} tokens total]",
775                                    total_stdout_tokens
776                                ));
777                                output.stdout.push_str(
778                                    "\n[Tip: Use 'head -n 100' for first lines, 'tail -n 100' for last lines]",
779                                );
780                                output.stdout.push_str(
781                                    "\n[Tip: For large output use SFTP/SCP tools to download files]",
782                                 );
783
784                                stdout_truncation_added = true;
785                                warn!(
786                                    "stdout truncated: total_tokens={}, limit_tokens={}",
787                                    total_stdout_tokens,
788                                    max_bytes.map(|b| b / BYTES_PER_TOKEN).unwrap_or(0)
789                                );
790
791                                push_tail(&mut stdout_tail, &data_str[take..]);
792                            } else {
793                                push_tail(&mut stdout_tail, &data_str);
794                            }
795                            // Skip remaining stdout data
796                        } else {
797                            output.stdout.push_str(&data_str);
798                        }
799                    } else {
800                        // No limit - add all data
801                        output.stdout.push_str(&data_str);
802                    }
803                }
804                ChannelMsg::ExtendedData { data, ext } => {
805                    let data_len = data.len();
806                    total_stderr_tokens =
807                        total_stderr_tokens.saturating_add(data_len / BYTES_PER_TOKEN);
808
809                    // ext == 1 is typically stderr
810                    if ext == 1 {
811                        let data_str = String::from_utf8_lossy(&data);
812                        if let Some(limit) = max_bytes {
813                            let current_len = output.stderr.len();
814
815                            // Check if we need to truncate
816                            if current_len.saturating_add(data_str.len()) > limit {
817                                if !stderr_truncation_added {
818                                    // Calculate how much we can take
819                                    let remaining = limit.saturating_sub(current_len);
820                                    let mut take: usize = 0;
821                                    if remaining > 0 {
822                                        // Safe slicing: find UTF-8 safe boundary
823                                        let safe_end = data_str
824                                            .char_indices()
825                                            .map(|(i, _)| i)
826                                            .find(|&i| i > remaining)
827                                            .unwrap_or(data_str.len());
828                                        take = std::cmp::min(safe_end, remaining);
829                                        output.stderr.push_str(&data_str[..take]);
830                                    }
831                                    output.stderr_truncated = true;
832                                    output.stderr_total_tokens = total_stderr_tokens;
833
834                                    // Add truncation notice
835                                    output.stderr.push_str(&format!(
836                                        "\n[Output truncated: {} tokens total]",
837                                        total_stderr_tokens
838                                    ));
839                                    output.stderr.push_str(
840                                        "\n[Tip: Use 'head -n 100' for first lines, 'tail -n 100' for last lines]",
841                                    );
842                                    output.stderr.push_str(
843                                        "\n[Tip: For large output use SFTP/SCP tools to download files]",
844                                    );
845
846                                    stderr_truncation_added = true;
847                                    warn!(
848                                        "stderr truncated: total_tokens={}, limit_tokens={}",
849                                        total_stderr_tokens,
850                                        max_bytes.map(|b| b / BYTES_PER_TOKEN).unwrap_or(0)
851                                    );
852
853                                    push_tail(&mut stderr_tail, &data_str[take..]);
854                                } else {
855                                    push_tail(&mut stderr_tail, &data_str);
856                                }
857                                // Skip remaining stderr data
858                            } else {
859                                output.stderr.push_str(&data_str);
860                            }
861                        } else {
862                            // No limit - add all data
863                            output.stderr.push_str(&data_str);
864                        }
865                    } else {
866                        // Non-stderr extended data goes to stdout
867                        output.stdout.push_str(&String::from_utf8_lossy(&data));
868                    }
869                }
870                ChannelMsg::ExitStatus { exit_status } => {
871                    output.exit_code = Some(exit_status);
872                }
873                ChannelMsg::ExitSignal { signal_name, .. } => {
874                    // Map signal to conventional shell exit code (128 + signal),
875                    // matching stream_channel_inner in background/stream.rs.
876                    let code = match signal_name {
877                        russh::Sig::HUP => 129,
878                        russh::Sig::INT => 130,
879                        russh::Sig::QUIT => 131,
880                        russh::Sig::ILL => 132,
881                        russh::Sig::ABRT => 134,
882                        russh::Sig::FPE => 136,
883                        russh::Sig::KILL => 137,
884                        russh::Sig::USR1 => 138,
885                        russh::Sig::SEGV => 139,
886                        russh::Sig::PIPE => 141,
887                        russh::Sig::ALRM => 142,
888                        russh::Sig::TERM => 143,
889                        russh::Sig::Custom(_) => 128,
890                    };
891                    output.exit_code = Some(code);
892                }
893                ChannelMsg::Close | ChannelMsg::Eof => {
894                    // Don't break - ExitStatus may arrive after Close/Eof
895                    // Loop will exit naturally when channel.wait() returns None
896                }
897                _ => {
898                    // Ignore other messages
899                }
900            }
901        }
902
903        // Store final token counts (if not already set from truncation)
904        if output.stdout_total_tokens == 0 {
905            output.stdout_total_tokens = total_stdout_tokens;
906        }
907        if output.stderr_total_tokens == 0 {
908            output.stderr_total_tokens = total_stderr_tokens;
909        }
910
911        if output.stdout_truncated && !stdout_tail.is_empty() {
912            output.stdout.push('\n');
913            output.stdout.push_str(&stdout_tail);
914        }
915
916        if output.stderr_truncated && !stderr_tail.is_empty() {
917            output.stderr.push('\n');
918            output.stderr.push_str(&stderr_tail);
919        }
920
921        // If there's stderr and a non-zero exit code, we might want to handle it
922        // For now, just return the output as-is
923        debug!(
924            "Command completed: exit_code={:?}, stdout_len={}, stderr_len={}, stdout_truncated={}, stderr_truncated={}",
925            output.exit_code,
926            output.stdout.len(),
927            output.stderr.len(),
928            output.stdout_truncated,
929            output.stderr_truncated
930        );
931
932        // A channel that closed without an exit status or exit signal indicates
933        // the SSH session was torn down (e.g. concurrent invalidate_session,
934        // network drop, server kill).  Returning Ok with exit_code=None would
935        // be treated as success by calltool_from_command_output — a silent
936        // failure.  Return an explicit error instead so the caller can surface
937        // it and trigger reconnection.
938        if output.exit_code.is_none() {
939            return Err(SshMcpError::connection(
940                "SSH channel closed without exit status (session may have been torn down)",
941            ));
942        }
943
944        Ok(output)
945    }
946
947    /// Attempt to abort a running command by killing matching processes
948    ///
949    /// Sends `timeout 3s pkill -f 'command' 2>/dev/null || true` to kill
950    /// any processes matching the command pattern.
951    async fn abort_command(&self, command: &str) {
952        // Try to open a new channel for the abort command
953        let channel = match self.open_channel().await {
954            Ok(ch) => ch,
955            Err(e) => {
956                error!(error = ?e, "Failed to open channel for abort");
957                return;
958            }
959        };
960
961        let escaped_command = escape_command_for_shell(command);
962        let abort_cmd = format!(
963            "timeout 3s pkill -f '{}' 2>/dev/null || true",
964            escaped_command
965        );
966
967        debug!(
968            "Sending abort command: pattern_len={}, abort_len={}",
969            command.len(),
970            abort_cmd.len()
971        );
972
973        if let Err(e) = channel.exec(true, abort_cmd.as_str()).await {
974            error!(error = ?e, "Failed to exec abort command");
975            return;
976        }
977
978        // Wait briefly for abort to complete (max 5 seconds)
979        let abort_timeout = Duration::from_secs(5);
980        let _ = timeout(abort_timeout, async {
981            let mut channel = channel;
982            while let Some(msg) = channel.wait().await {
983                match msg {
984                    ChannelMsg::Close | ChannelMsg::Eof => break,
985                    _ => continue,
986                }
987            }
988        })
989        .await;
990
991        debug!("Abort command completed");
992    }
993
994    /// Execute a command over SSH with binary-safe streaming.
995    ///
996    /// This method is designed for use-cases like file transfer where stdout must
997    /// be treated as bytes and forwarded to a sink without UTF-8 decoding.
998    ///
999    /// Notes:
1000    /// - This does not use the interactive su shell.
1001    /// - Timeouts are enforced locally via tokio timeout.
1002    pub async fn exec_raw_streaming<R, W>(
1003        &self,
1004        command: &str,
1005        mut stdin: Option<&mut R>,
1006        mut stdout: Option<&mut W>,
1007        timeout_duration: Duration,
1008    ) -> Result<TransferRawOutput>
1009    where
1010        R: AsyncRead + Unpin,
1011        W: AsyncWrite + Unpin,
1012    {
1013        let _permit = self.acquire_command_slot().await?;
1014
1015        self.ensure_connected().await?;
1016
1017        // Raw transfers must not reuse the PTY/su channel.
1018        let fut = async {
1019            let channel = self.open_channel().await?;
1020            channel
1021                .exec(true, command)
1022                .await
1023                .map_err(|e| SshMcpError::connection(format!("Failed to exec command: {e}")))?;
1024
1025            // Prevent deadlocks by pumping stdin and stdout/stderr concurrently.
1026            // stdin/stdout are borrowed, so we keep IO in this task and run the SSH channel
1027            // event loop in a spawned task (owned channel).
1028            let (stdin_tx, mut stdin_rx) = tokio::sync::mpsc::channel::<Vec<u8>>(4);
1029            let (out_tx, mut out_rx) = tokio::sync::mpsc::channel::<RawStreamEvent>(8);
1030
1031            let task_guard = JoinAbortGuard::new(tokio::spawn(async move {
1032                raw_channel_task(channel, &mut stdin_rx, out_tx).await
1033            }));
1034
1035            let mut output = TransferRawOutput::default();
1036            let stderr_limit_bytes = resolve_raw_stream_stderr_limit(self.config.max_output_tokens);
1037            let mut total_stderr_bytes = 0usize;
1038            let mut stderr_truncated = false;
1039            let mut stdin_done = stdin.is_none();
1040            let mut stdin_tx: Option<tokio::sync::mpsc::Sender<Vec<u8>>> =
1041                if stdin_done { None } else { Some(stdin_tx) };
1042            let mut channel_closed = false;
1043            let mut out_rx_closed = false;
1044
1045            let mut buf = vec![0u8; 32 * 1024];
1046
1047            loop {
1048                if stdin_done && channel_closed && out_rx_closed {
1049                    break;
1050                }
1051
1052                tokio::select! {
1053                    read_res = async {
1054                        match stdin.as_mut() {
1055                            Some(r) => r.read(&mut buf).await,
1056                            None => Ok(0),
1057                        }
1058                    }, if !stdin_done => {
1059                        let n = read_res?;
1060                        if n == 0 {
1061                            stdin_done = true;
1062                            stdin_tx = None; // drop -> EOF
1063                        } else {
1064                            let chunk = buf[..n].to_vec();
1065                            match stdin_tx.as_mut() {
1066                                Some(tx) => {
1067                                    tx.send(chunk).await.map_err(|_| {
1068                                        SshMcpError::connection("raw channel task ended while sending stdin".to_string())
1069                                    })?;
1070                                    output.stdin_bytes += n as u64;
1071                                }
1072                                None => {
1073                                    return Err(SshMcpError::connection(
1074                                        "raw stdin channel closed unexpectedly".to_string(),
1075                                    ));
1076                                }
1077                            }
1078                        }
1079                    }
1080                    maybe_evt = out_rx.recv() => {
1081                        match maybe_evt {
1082                            Some(RawStreamEvent::Stdout(data)) => {
1083                                output.stdout_bytes += data.len() as u64;
1084                                if let Some(writer) = stdout.as_mut() {
1085                                    writer.write_all(&data).await?;
1086                                }
1087                            }
1088                            Some(RawStreamEvent::Stderr(data)) => {
1089                                total_stderr_bytes = total_stderr_bytes.saturating_add(data.len());
1090                                if !stderr_truncated {
1091                                    stderr_truncated = append_bounded_lossy_stderr(
1092                                        &mut output.stderr,
1093                                        &data,
1094                                        stderr_limit_bytes,
1095                                    );
1096                                    if stderr_truncated {
1097                                        warn!(
1098                                            total_stderr_bytes,
1099                                            stderr_limit_bytes,
1100                                            "raw streaming stderr truncated"
1101                                        );
1102                                    }
1103                                }
1104                            }
1105                            Some(RawStreamEvent::ExitStatus(code)) => {
1106                                output.exit_code = Some(code);
1107                            }
1108                            Some(RawStreamEvent::Closed) => {
1109                                channel_closed = true;
1110                            }
1111                            None => {
1112                                out_rx_closed = true;
1113                            }
1114                        }
1115                    }
1116                }
1117            }
1118
1119            if stderr_truncated {
1120                output.stderr.push_str(&format!(
1121                    "\n[stderr truncated: {} bytes total, limit {} bytes]",
1122                    total_stderr_bytes, stderr_limit_bytes
1123                ));
1124            }
1125
1126            if let Some(writer) = stdout.as_mut() {
1127                writer.flush().await?;
1128            }
1129
1130            let join_handle = match task_guard.into_handle() {
1131                Some(h) => h,
1132                None => {
1133                    return Err(SshMcpError::connection(
1134                        "raw channel task handle missing".to_string(),
1135                    ));
1136                }
1137            };
1138
1139            match join_handle.await {
1140                Ok(Ok(())) => Ok(output),
1141                Ok(Err(e)) => Err(e),
1142                Err(e) => Err(SshMcpError::connection(format!(
1143                    "raw channel task join failed: {e}"
1144                ))),
1145            }
1146        };
1147
1148        match timeout(timeout_duration, fut).await {
1149            Ok(res) => res,
1150            Err(_) => {
1151                self.invalidate_session("raw command timed out").await;
1152                Err(SshMcpError::Timeout(timeout_duration.as_millis() as u64))
1153            }
1154        }
1155    }
1156
1157    /// Check the status of a background job by job_id.
1158    ///
1159    /// Uses `kill -0` for process detection (existence/permission check without sending a signal).
1160    /// This avoids parsing `ps` output (GNU vs BusyBox differences) and works on common Linux
1161    /// distributions.
1162    ///
1163    /// # Arguments
1164    /// * `job_id` - Job id returned by background exec
1165    /// * `tail_lines` - Number of lines to read from log tail
1166    /// * `registry` - Job registry holding current job state
1167    ///
1168    /// # Returns
1169    /// ProcessStatus with running state, exit code, elapsed time, command, and log tail
1170    pub async fn check_process(
1171        &self,
1172        job_id: &str,
1173        tail_lines: usize,
1174        registry: &JobRegistry,
1175        spooler: &LocalLogSpooler,
1176    ) -> Result<ProcessStatus> {
1177        debug!(job_id = ?job_id, "Checking process status");
1178
1179        let job = match registry.get(job_id).await {
1180            Some(job) => job,
1181            None => match spooler.load_job_state(job_id).await {
1182                Ok(Some(recovered)) => {
1183                    let shared = Arc::new(Mutex::new(recovered));
1184                    registry
1185                        .insert(job_id.to_string(), Arc::clone(&shared))
1186                        .await;
1187                    shared
1188                }
1189                Ok(None) => {
1190                    return Err(SshMcpError::invalid_params(format!(
1191                        "job not found: {job_id}"
1192                    )));
1193                }
1194                Err(e) => {
1195                    return Err(SshMcpError::invalid_params(format!(
1196                        "failed to recover job state for {job_id}: {e}"
1197                    )));
1198                }
1199            },
1200        };
1201
1202        let job_guard = job.lock().await;
1203        let pid = job_guard.pid;
1204        let command = job_guard.command.clone();
1205        let log_path = job_guard.log_path.clone();
1206        let status = job_guard.status;
1207        let exit_code_i32 = job_guard.exit_code;
1208        let stored_state_reason = job_guard.state_reason.clone();
1209        let elapsed_time = job_guard.elapsed_time();
1210        drop(job_guard);
1211
1212        let (running, effective_status, effective_exit_code_i32, effective_reason) = match status {
1213            JobStatus::Running => {
1214                self.ensure_connected().await?;
1215                if self.is_pid_running(pid).await? {
1216                    (true, JobStatus::Running, None, None)
1217                } else if let Some(code) = exit_code_i32 {
1218                    (false, job_status_from_exit_code(code), Some(code), None)
1219                } else {
1220                    let (settled_status, settled_exit_code, settled_reason) =
1221                        await_running_job_settle(&job).await;
1222                    match settled_status {
1223                        JobStatus::Running => match settled_exit_code {
1224                            Some(code) => {
1225                                (false, job_status_from_exit_code(code), Some(code), None)
1226                            }
1227                            None => (
1228                                false,
1229                                JobStatus::StateLost,
1230                                None,
1231                                Some(settled_reason.unwrap_or_else(|| {
1232                                    "pid_not_running_and_no_exit_status".to_string()
1233                                })),
1234                            ),
1235                        },
1236                        JobStatus::Completed | JobStatus::Failed => match settled_exit_code {
1237                            Some(code) => {
1238                                (false, job_status_from_exit_code(code), Some(code), None)
1239                            }
1240                            None => (
1241                                false,
1242                                JobStatus::StateLost,
1243                                None,
1244                                Some(settled_reason.unwrap_or_else(|| {
1245                                    "missing_exit_code_for_terminal_state".to_string()
1246                                })),
1247                            ),
1248                        },
1249                        JobStatus::StateLost => (
1250                            false,
1251                            JobStatus::StateLost,
1252                            None,
1253                            Some(settled_reason.unwrap_or_else(|| "state_lost".to_string())),
1254                        ),
1255                    }
1256                }
1257            }
1258            JobStatus::Completed | JobStatus::Failed => match exit_code_i32 {
1259                Some(code) => (false, job_status_from_exit_code(code), Some(code), None),
1260                None => (
1261                    false,
1262                    JobStatus::StateLost,
1263                    None,
1264                    Some(
1265                        stored_state_reason
1266                            .clone()
1267                            .unwrap_or_else(|| "missing_exit_code_for_terminal_state".to_string()),
1268                    ),
1269                ),
1270            },
1271            JobStatus::StateLost => (
1272                false,
1273                JobStatus::StateLost,
1274                None,
1275                Some(
1276                    stored_state_reason
1277                        .clone()
1278                        .unwrap_or_else(|| "state_lost".to_string()),
1279                ),
1280            ),
1281        };
1282
1283        if status != effective_status
1284            || exit_code_i32 != effective_exit_code_i32
1285            || stored_state_reason != effective_reason
1286        {
1287            let mut guard = job.lock().await;
1288            match effective_status {
1289                JobStatus::Running => {
1290                    guard.status = JobStatus::Running;
1291                    guard.exit_code = None;
1292                    guard.state_reason = None;
1293                }
1294                JobStatus::Completed | JobStatus::Failed => {
1295                    if let Some(code) = effective_exit_code_i32 {
1296                        guard.mark_exit(code);
1297                    }
1298                }
1299                JobStatus::StateLost => {
1300                    guard.mark_state_lost(
1301                        effective_reason
1302                            .clone()
1303                            .unwrap_or_else(|| "state_lost".to_string()),
1304                    );
1305                }
1306            }
1307
1308            let persisted = guard.clone();
1309            drop(guard);
1310
1311            if let Err(e) = spooler.persist_job_state(&persisted).await {
1312                warn!(job_id = ?job_id, error = ?e, "failed to persist reconciled job state");
1313            }
1314        }
1315
1316        let exit_code = if running || effective_status == JobStatus::StateLost {
1317            None
1318        } else {
1319            effective_exit_code_i32.and_then(|code| u32::try_from(code).ok())
1320        };
1321
1322        let log_exists = log_file_exists(&log_path).await?;
1323
1324        let log_tail = read_local_log_tail(&log_path, tail_lines).await?;
1325
1326        Ok(ProcessStatus {
1327            pid,
1328            state: effective_status.as_str().to_string(),
1329            running,
1330            exit_code,
1331            state_reason: effective_reason,
1332            elapsed_time,
1333            command,
1334            log_path: log_path.to_string_lossy().to_string(),
1335            log_exists,
1336            log_tail,
1337        })
1338    }
1339
1340    async fn is_pid_running(&self, pid: u32) -> Result<bool> {
1341        // `kill -0` checks for existence/permission without sending a signal.
1342        let cmd = format!("sh -c 'kill -0 {pid} 2>/dev/null'");
1343        let output = self.exec_command(&cmd, Duration::from_secs(5)).await?;
1344        Ok(output.exit_code == Some(0))
1345    }
1346}
1347
1348fn job_status_from_exit_code(exit_code: i32) -> JobStatus {
1349    if exit_code == 0 {
1350        JobStatus::Completed
1351    } else {
1352        JobStatus::Failed
1353    }
1354}
1355
1356async fn await_running_job_settle(
1357    job: &SharedJobState,
1358) -> (JobStatus, Option<i32>, Option<String>) {
1359    tokio::time::sleep(Duration::from_millis(150)).await;
1360    let guard = job.lock().await;
1361    (guard.status, guard.exit_code, guard.state_reason.clone())
1362}
1363
1364pub(crate) async fn read_local_log_tail(path: &Path, lines: usize) -> Result<String> {
1365    if lines == 0 {
1366        return Ok(String::new());
1367    }
1368
1369    let mut file = match open_log_read_no_symlink(path).await? {
1370        Some(f) => f,
1371        None => return Ok(String::new()),
1372    };
1373
1374    let meta = file.metadata().await?;
1375    let mut pos = meta.len();
1376
1377    const CHUNK_SIZE: u64 = 8192;
1378    const MAX_READ_BYTES: usize = 1024 * 1024;
1379
1380    let mut buf: Vec<u8> = Vec::new();
1381    let mut newlines = 0usize;
1382
1383    while pos > 0 && newlines <= lines && buf.len() < MAX_READ_BYTES {
1384        let read_len = std::cmp::min(CHUNK_SIZE, pos) as usize;
1385        pos = pos.saturating_sub(read_len as u64);
1386
1387        file.seek(std::io::SeekFrom::Start(pos)).await?;
1388
1389        let mut chunk = vec![0u8; read_len];
1390        let mut got = 0usize;
1391        while got < read_len {
1392            let n = file.read(&mut chunk[got..]).await?;
1393            if n == 0 {
1394                break;
1395            }
1396            got = got.saturating_add(n);
1397        }
1398        if got == 0 {
1399            break;
1400        }
1401        chunk.truncate(got);
1402
1403        newlines = newlines.saturating_add(chunk.iter().filter(|&&b| b == b'\n').count());
1404
1405        // Prepend chunk to existing buffer (bounded by MAX_READ_BYTES).
1406        if chunk.len().saturating_add(buf.len()) > MAX_READ_BYTES {
1407            let allowed = MAX_READ_BYTES.saturating_sub(buf.len());
1408            chunk.truncate(allowed);
1409        }
1410        chunk.extend_from_slice(&buf);
1411        buf = chunk;
1412    }
1413
1414    let text = String::from_utf8_lossy(&buf);
1415    let all_lines: Vec<&str> = text.lines().collect();
1416    if all_lines.is_empty() {
1417        return Ok(String::new());
1418    }
1419
1420    let start = all_lines.len().saturating_sub(lines);
1421    Ok(all_lines[start..].join("\n"))
1422}
1423
1424async fn log_file_exists(path: &Path) -> Result<bool> {
1425    match tokio::fs::symlink_metadata(path).await {
1426        Ok(meta) => {
1427            if meta.file_type().is_symlink() {
1428                return Err(std::io::Error::new(
1429                    std::io::ErrorKind::InvalidInput,
1430                    "log path is a symlink (refusing to follow it)",
1431                )
1432                .into());
1433            }
1434            Ok(meta.is_file())
1435        }
1436        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
1437        Err(e) => Err(e.into()),
1438    }
1439}
1440
1441async fn open_log_read_no_symlink(path: &Path) -> Result<Option<tokio::fs::File>> {
1442    match tokio::fs::symlink_metadata(path).await {
1443        Ok(meta) => {
1444            if meta.file_type().is_symlink() {
1445                return Err(std::io::Error::new(
1446                    std::io::ErrorKind::InvalidInput,
1447                    "log path is a symlink (refusing to follow it)",
1448                )
1449                .into());
1450            }
1451            if !meta.is_file() {
1452                return Err(std::io::Error::new(
1453                    std::io::ErrorKind::InvalidInput,
1454                    "log path is not a regular file",
1455                )
1456                .into());
1457            }
1458        }
1459        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1460            return Ok(None);
1461        }
1462        Err(e) => return Err(e.into()),
1463    }
1464
1465    let mut opts = tokio::fs::OpenOptions::new();
1466    opts.read(true);
1467
1468    #[cfg(unix)]
1469    {
1470        opts.custom_flags(O_NOFOLLOW_FLAG);
1471    }
1472
1473    match opts.open(path).await {
1474        Ok(f) => {
1475            // Re-check based on the opened file handle to avoid TOCTOU.
1476            let meta = f.metadata().await?;
1477            if !meta.is_file() {
1478                return Err(std::io::Error::new(
1479                    std::io::ErrorKind::InvalidInput,
1480                    "log path is not a regular file",
1481                )
1482                .into());
1483            }
1484            Ok(Some(f))
1485        }
1486        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
1487        Err(e) => {
1488            if let Ok(meta) = tokio::fs::symlink_metadata(path).await
1489                && meta.file_type().is_symlink()
1490            {
1491                return Err(std::io::Error::new(
1492                    std::io::ErrorKind::InvalidInput,
1493                    "log path is a symlink (refusing to follow it)",
1494                )
1495                .into());
1496            }
1497            Err(e.into())
1498        }
1499    }
1500}
1501
1502#[derive(Debug)]
1503enum RawStreamEvent {
1504    Stdout(Vec<u8>),
1505    Stderr(Vec<u8>),
1506    ExitStatus(u32),
1507    Closed,
1508}
1509
1510struct JoinAbortGuard<T> {
1511    handle: Option<tokio::task::JoinHandle<T>>,
1512}
1513
1514impl<T> JoinAbortGuard<T> {
1515    fn new(handle: tokio::task::JoinHandle<T>) -> Self {
1516        Self {
1517            handle: Some(handle),
1518        }
1519    }
1520
1521    fn into_handle(mut self) -> Option<tokio::task::JoinHandle<T>> {
1522        self.handle.take()
1523    }
1524}
1525
1526impl<T> Drop for JoinAbortGuard<T> {
1527    fn drop(&mut self) {
1528        if let Some(handle) = &self.handle {
1529            handle.abort();
1530        }
1531    }
1532}
1533
1534async fn raw_channel_task(
1535    mut channel: russh::Channel<russh::client::Msg>,
1536    stdin_rx: &mut tokio::sync::mpsc::Receiver<Vec<u8>>,
1537    out_tx: tokio::sync::mpsc::Sender<RawStreamEvent>,
1538) -> Result<()> {
1539    let mut stdin_closed = false;
1540    let mut sent_closed = false;
1541    loop {
1542        tokio::select! {
1543            maybe_chunk = stdin_rx.recv(), if !stdin_closed => {
1544                match maybe_chunk {
1545                    Some(chunk) => {
1546                        channel.data(chunk.as_slice()).await.map_err(|e| {
1547                            SshMcpError::connection(format!("Failed to send stdin: {e}"))
1548                        })?;
1549                    }
1550                    None => {
1551                        stdin_closed = true;
1552                        let _ = channel.eof().await;
1553                    }
1554                }
1555            }
1556            maybe_msg = channel.wait() => {
1557                match maybe_msg {
1558                    Some(msg) => {
1559                        let send_evt = |evt: RawStreamEvent| async {
1560                            out_tx.send(evt).await.map_err(|_| ())
1561                        };
1562
1563                        match msg {
1564                            ChannelMsg::Data { data } => {
1565                                let bytes = data.as_ref().to_vec();
1566                                if send_evt(RawStreamEvent::Stdout(bytes)).await.is_err() {
1567                                    return Ok(());
1568                                }
1569                            }
1570                            ChannelMsg::ExtendedData { data, ext } => {
1571                                let bytes = data.as_ref().to_vec();
1572                                let evt = if ext == 1 {
1573                                    RawStreamEvent::Stderr(bytes)
1574                                } else {
1575                                    RawStreamEvent::Stdout(bytes)
1576                                };
1577                                if send_evt(evt).await.is_err() {
1578                                    return Ok(());
1579                                }
1580                            }
1581                            ChannelMsg::ExitStatus { exit_status }
1582                                if send_evt(RawStreamEvent::ExitStatus(exit_status)).await.is_err() =>
1583                            {
1584                                return Ok(());
1585                            }
1586                            ChannelMsg::ExitStatus { .. } => {}
1587                            ChannelMsg::ExitSignal { signal_name, .. } => {
1588                                // Map signal to exit code (128 + signal number)
1589                                // Common signals: HUP=1, INT=2, QUIT=3, ILL=4, TRAP=5, ABRT=6, BUS=7, FPE=8, KILL=9
1590                                let code = match signal_name {
1591                                    russh::Sig::HUP => 129,
1592                                    russh::Sig::INT => 130,
1593                                    russh::Sig::QUIT => 131,
1594                                    russh::Sig::ILL => 132,
1595                                    russh::Sig::ABRT => 134,
1596                                    russh::Sig::FPE => 136,
1597                                    russh::Sig::KILL => 137,
1598                                    russh::Sig::USR1 => 138,
1599                                    russh::Sig::SEGV => 139,
1600                                    russh::Sig::PIPE => 141,
1601                                    russh::Sig::ALRM => 142,
1602                                    russh::Sig::TERM => 143,
1603                                    russh::Sig::Custom(_) => 128,
1604                                };
1605                                if send_evt(RawStreamEvent::ExitStatus(code)).await.is_err() {
1606                                    return Ok(());
1607                                }
1608                            }
1609                            ChannelMsg::Close | ChannelMsg::Eof if !sent_closed => {
1610                                // Send Closed once but keep looping to capture trailing ExitStatus
1611                                sent_closed = true;
1612                                let _ = send_evt(RawStreamEvent::Closed).await;
1613                            }
1614                            ChannelMsg::Close | ChannelMsg::Eof => {}
1615                            _ => {}
1616                        }
1617                    }
1618                    None => {
1619                        // Channel fully closed - ensure we send Closed before exiting
1620                        if !sent_closed {
1621                            let _ = out_tx.send(RawStreamEvent::Closed).await;
1622                        }
1623                        break;
1624                    }
1625                }
1626            }
1627        }
1628    }
1629
1630    Ok(())
1631}
1632
1633#[cfg(test)]
1634mod tests {
1635    use super::*;
1636
1637    #[test]
1638    fn test_command_output_success() {
1639        let output = CommandOutput {
1640            stdout: "hello".to_string(),
1641            stderr: String::new(),
1642            exit_code: Some(0),
1643            ..Default::default()
1644        };
1645        assert!(output.success());
1646    }
1647
1648    #[test]
1649    fn test_command_output_failure() {
1650        let output = CommandOutput {
1651            stdout: String::new(),
1652            stderr: "error".to_string(),
1653            exit_code: Some(1),
1654            ..Default::default()
1655        };
1656        assert!(!output.success());
1657    }
1658
1659    #[test]
1660    fn test_command_output_no_exit_code() {
1661        let output = CommandOutput {
1662            stdout: "hello".to_string(),
1663            stderr: String::new(),
1664            exit_code: None,
1665            ..Default::default()
1666        };
1667        // No exit code means the channel was torn down — not success
1668        assert!(!output.success());
1669    }
1670
1671    #[test]
1672    fn test_command_output_combined() {
1673        let output = CommandOutput {
1674            stdout: "stdout".to_string(),
1675            stderr: "stderr".to_string(),
1676            exit_code: Some(0),
1677            ..Default::default()
1678        };
1679        assert_eq!(output.combined_output(), "stdout\nstderr");
1680    }
1681
1682    #[test]
1683    fn test_command_output_combined_only_stdout() {
1684        let output = CommandOutput {
1685            stdout: "stdout".to_string(),
1686            stderr: String::new(),
1687            exit_code: Some(0),
1688            ..Default::default()
1689        };
1690        assert_eq!(output.combined_output(), "stdout");
1691    }
1692
1693    #[test]
1694    fn test_command_output_combined_only_stderr() {
1695        let output = CommandOutput {
1696            stdout: String::new(),
1697            stderr: "stderr".to_string(),
1698            exit_code: Some(1),
1699            ..Default::default()
1700        };
1701        assert_eq!(output.combined_output(), "stderr");
1702    }
1703
1704    #[test]
1705    fn test_wrap_command_with_timeout() {
1706        let cmd = wrap_command_with_timeout("sleep 10", 2.0);
1707        assert!(cmd.contains("timeout -k 2s 2s"));
1708        assert!(cmd.contains("sh -lc")); // Uses login shell
1709        assert!(cmd.contains("sleep 10"));
1710    }
1711
1712    #[test]
1713    fn test_wrap_command_with_timeout_zero_duration() {
1714        // Edge case: wrapper accepts zero (validation is elsewhere)
1715        let cmd = wrap_command_with_timeout("echo test", 0.0);
1716        assert!(cmd.contains("timeout -k 2s 0s"));
1717        assert!(cmd.contains("sh -lc"));
1718        assert!(cmd.contains("echo test"));
1719    }
1720
1721    #[test]
1722    fn test_wrap_command_with_timeout_fractional() {
1723        // Test fractional seconds for sub-second precision
1724        let cmd = wrap_command_with_timeout("sleep 1", 0.5);
1725        assert!(cmd.contains("timeout -k 2s 0.5s"));
1726        assert!(cmd.contains("sh -lc"));
1727        assert!(cmd.contains("sleep 1"));
1728    }
1729
1730    #[test]
1731    fn test_wrap_command_with_timeout_complex_command() {
1732        let cmd = wrap_command_with_timeout("echo 'hello world'", 10.0);
1733        assert!(cmd.contains("timeout -k 2s 10s"));
1734        assert!(cmd.contains("sh -lc"));
1735        assert!(cmd.contains("echo"));
1736    }
1737
1738    #[test]
1739    fn test_wrap_command_with_timeout_with_single_quotes() {
1740        let cmd = wrap_command_with_timeout("echo 'hello'", 10.0);
1741        assert!(cmd.contains("timeout -k 2s 10s"));
1742        assert!(cmd.contains("sh -lc"));
1743        // Single quotes are escaped as '"'"'
1744        assert!(cmd.contains("'\"'\"'"));
1745    }
1746
1747    #[test]
1748    fn test_wrap_command_for_channel_exec_non_login_shell() {
1749        let cmd = wrap_command_for_channel_exec("echo hello");
1750        assert_eq!(cmd, "sh -c 'echo hello'");
1751    }
1752
1753    #[test]
1754    fn test_wrap_command_for_channel_exec_timeout_wrapper_payload() {
1755        let timeout_wrapped = wrap_command_with_timeout("echo hello", 1.0);
1756        assert_eq!(timeout_wrapped, "timeout -k 2s 1s sh -lc 'echo hello'");
1757
1758        let cmd = wrap_command_for_channel_exec(&timeout_wrapped);
1759        assert_eq!(
1760            cmd,
1761            "sh -c 'timeout -k 2s 1s sh -lc '\"'\"'echo hello'\"'\"''"
1762        );
1763    }
1764
1765    #[test]
1766    fn test_wrap_command_for_channel_exec_timeout_wrapper_payload_with_single_quotes() {
1767        let timeout_wrapped = wrap_command_with_timeout("echo 'hello'", 1.0);
1768        assert_eq!(
1769            timeout_wrapped,
1770            "timeout -k 2s 1s sh -lc 'echo '\"'\"'hello'\"'\"''"
1771        );
1772
1773        let cmd = wrap_command_for_channel_exec(&timeout_wrapped);
1774        assert!(cmd.starts_with("sh -c '"));
1775        assert!(cmd.ends_with('\''));
1776
1777        let inner = &cmd[7..cmd.len() - 1];
1778        let unescaped_once = inner.replace("'\"'\"'", "'");
1779        assert_eq!(unescaped_once, timeout_wrapped);
1780        assert!(cmd.contains("hello"));
1781    }
1782
1783    #[test]
1784    fn test_resolve_raw_stream_stderr_limit_uses_token_limit() {
1785        assert_eq!(
1786            resolve_raw_stream_stderr_limit(Some(12_000)),
1787            12_000 * RAW_STREAM_BYTES_PER_TOKEN
1788        );
1789    }
1790
1791    #[test]
1792    fn test_resolve_raw_stream_stderr_limit_none_uses_hard_cap() {
1793        assert_eq!(
1794            resolve_raw_stream_stderr_limit(None),
1795            RAW_STREAM_STDERR_HARD_MAX_BYTES
1796        );
1797    }
1798
1799    #[test]
1800    fn test_resolve_raw_stream_stderr_limit_applies_hard_cap() {
1801        assert_eq!(
1802            resolve_raw_stream_stderr_limit(Some(RAW_STREAM_STDERR_HARD_MAX_BYTES)),
1803            RAW_STREAM_STDERR_HARD_MAX_BYTES
1804        );
1805    }
1806
1807    #[test]
1808    fn test_append_bounded_lossy_stderr_no_truncation() {
1809        let mut stderr = String::new();
1810        let truncated = append_bounded_lossy_stderr(&mut stderr, b"hello", 16);
1811        assert!(!truncated);
1812        assert_eq!(stderr, "hello");
1813    }
1814
1815    #[test]
1816    fn test_append_bounded_lossy_stderr_truncates_at_utf8_boundary() {
1817        let mut stderr = String::new();
1818        let truncated = append_bounded_lossy_stderr(&mut stderr, "абв".as_bytes(), 3);
1819        assert!(truncated);
1820        assert_eq!(stderr, "а");
1821    }
1822}