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