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    /// NOTE: This method is only reachable when ALL of the following hold:
439    /// 1. `--su-password` is configured
440    /// 2. `ensure_elevated()` succeeded (su PTY channel is open)
441    /// 3. `detach_mode == DirectOnly` (neither nohup nor setsid available on remote)
442    ///
443    /// In practice, condition 3 is near-impossible: nohup (coreutils) and
444    /// setsid (util-linux) are present on virtually every Linux system. When
445    /// detach mode is Full or Portable, the shell tool uses
446    /// `execute_detachable_foreground_impl` which runs commands through a
447    /// background wrapper (`sh -lc`), never touching the su PTY channel.
448    ///
449    /// Empirical testing with `--su-password` on a real deployment confirmed
450    /// the su PTY path is not activated — commands run as the SSH user, not
451    /// root, because detach mode resolves to Full. The `#`-sentinel and
452    /// hardcoded `exit_code: Some(0)` below are therefore not exercised in
453    /// normal operation.
454    async fn collect_su_output(
455        &self,
456        channel: &mut russh::Channel<russh::client::Msg>,
457        timeout_duration: Duration,
458        use_wrapper: bool,
459    ) -> Result<CommandOutput> {
460        let mut buffer = String::new();
461        // When using wrapper, timeout is handled remotely - no local deadline needed
462        let deadline = if use_wrapper {
463            None
464        } else {
465            Some(tokio::time::Instant::now() + timeout_duration)
466        };
467
468        loop {
469            if let Some(deadline_ref) = deadline
470                && tokio::time::Instant::now() > deadline_ref
471            {
472                return Err(SshMcpError::Timeout(timeout_duration.as_millis() as u64));
473            }
474
475            let wait_result =
476                tokio::time::timeout(Duration::from_millis(500), channel.wait()).await;
477
478            match wait_result {
479                Ok(Some(msg)) => {
480                    match msg {
481                        ChannelMsg::Data { data } => {
482                            let text = String::from_utf8_lossy(&data);
483                            buffer.push_str(&text);
484
485                            // Check for root prompt - indicates command complete.
486                            // The `#` sentinel is inherently fragile (any `#` in
487                            // command output would match), but this path is only
488                            // reachable when detach_mode == DirectOnly — see the
489                            // method-level NOTE above. In normal deployments with
490                            // nohup/setsid available, this code is never executed.
491                            if buffer.contains('#') {
492                                // Extract output: remove the command echo and final prompt
493                                let lines: Vec<&str> = buffer.lines().collect();
494                                // First line is often the echoed command; last line is the prompt
495                                let output = if lines.len() > 2 {
496                                    lines[1..lines.len() - 1].join("\n")
497                                } else {
498                                    String::new()
499                                };
500
501                                return Ok(CommandOutput {
502                                    stdout: if output.is_empty() {
503                                        output
504                                    } else {
505                                        format!("{}\n", output)
506                                    },
507                                    stderr: String::new(),
508                                    exit_code: Some(0), // Assume success in PTY mode
509                                    ..Default::default()
510                                });
511                            }
512                        }
513                        ChannelMsg::Close => {
514                            return Err(SshMcpError::connection(
515                                "Channel closed during command execution",
516                            ));
517                        }
518                        _ => {
519                            // Ignore other messages
520                        }
521                    }
522                }
523                Ok(None) => {
524                    return Err(SshMcpError::connection(
525                        "Channel ended during command execution",
526                    ));
527                }
528                Err(_) => {
529                    // Timeout on wait, continue loop
530                    continue;
531                }
532            }
533        }
534    }
535
536    /// Execute command via a new exec channel
537    ///
538    /// Implements deterministic one-shot retry for pre-exec failures:
539    /// - Channel open failure: reconnect and retry once
540    /// - channel.exec() send failure: reconnect and retry once
541    /// - Failures after exec starts (output collection, Close/Eof): no retry,
542    ///   just invalidate session so next command reconnects
543    /// - Timeout errors: no retry (command may have partially run)
544    async fn exec_via_channel(
545        &self,
546        command: &str,
547        timeout_duration: Duration,
548    ) -> Result<CommandOutput> {
549        let duration_secs = validate_timeout_duration(timeout_duration)?;
550
551        // Wrap command with timeout if available
552        // Check timeout availability lazily on first use
553        let use_wrapper = self.determine_timeout_wrapper_usage().await;
554
555        let wrapped_cmd = if use_wrapper {
556            wrap_command_with_timeout(command, duration_secs)
557        } else {
558            // Fall back to old method: use tokio timeout + pkill
559            command.to_string()
560        };
561
562        // Attempt #1: open channel and exec
563        let (channel, _exec_sent) = self
564            .open_and_exec_with_reconnect_retry(&wrapped_cmd)
565            .await?;
566
567        // At this point, exec has been sent successfully.
568        // Collect output with appropriate timeout strategy.
569        // Failures here do NOT trigger retry - we just invalidate the session.
570        let output_result = if use_wrapper {
571            // When using wrapper, timeout is handled remotely - no tokio timeout needed
572            self.collect_channel_output(channel).await
573        } else {
574            // Fall back: use tokio timeout + pkill for abort
575            let result = timeout(timeout_duration, self.collect_channel_output(channel)).await;
576
577            match result {
578                Ok(inner_result) => inner_result,
579                Err(_) => {
580                    // Timeout occurred - attempt graceful abort
581                    warn!(
582                        "Command timed out after {}ms, attempting abort",
583                        timeout_duration.as_millis()
584                    );
585                    self.abort_command(command).await;
586                    self.invalidate_session("command timed out after exec")
587                        .await;
588                    return Err(SshMcpError::Timeout(timeout_duration.as_millis() as u64));
589                }
590            }
591        };
592
593        let output = match output_result {
594            Ok(out) => out,
595            Err(e) => {
596                // Failure after exec started - invalidate session, no retry
597                // Do not retry: command may have partially executed
598                if !matches!(e, SshMcpError::Timeout(_)) {
599                    self.invalidate_session("channel failed after exec").await;
600                }
601                return Err(e);
602            }
603        };
604
605        // Check if timeout command failed (e.g., not found) when using wrapper
606        if use_wrapper {
607            let stderr_lower = output.stderr.to_lowercase();
608            // Check for timeout command not found errors (multiple languages)
609            let timeout_not_found = stderr_lower.contains("timeout: command not found")
610                || stderr_lower.contains("timeout: не найдена команда")
611                || stderr_lower.contains("timeout: introuvable")
612                || stderr_lower.contains("timeout: команда не найдена");
613
614            if timeout_not_found {
615                error!("timeout command not available on remote host, enabling fallback");
616                self.disable_timeout_wrapper();
617
618                // Execute the command again using fallback method (tokio timeout + pkill)
619                // Note: This is a feature fallback, not a connection retry
620                let (channel, _) = self
621                    .open_and_exec_with_reconnect_retry(command)
622                    .await
623                    .map_err(|e| {
624                        SshMcpError::connection(format!(
625                            "Failed to start fallback execution after reconnect retry: {e}"
626                        ))
627                    })?;
628
629                let result = timeout(timeout_duration, self.collect_channel_output(channel)).await;
630
631                return match result {
632                    Ok(inner_output) => inner_output,
633                    Err(_) => {
634                        warn!(
635                            "Command timed out after {}ms (fallback), attempting abort",
636                            timeout_duration.as_millis()
637                        );
638                        self.abort_command(command).await;
639                        self.invalidate_session("fallback command timed out after exec")
640                            .await;
641                        Err(SshMcpError::Timeout(timeout_duration.as_millis() as u64))
642                    }
643                };
644            }
645
646            // Check if the command was killed by timeout
647            // timeout returns 124 when it kills the command
648            if output.exit_code == Some(124) {
649                warn!("Command timed out (timeout wrapper returned 124)");
650                return Err(SshMcpError::Timeout(timeout_duration.as_millis() as u64));
651            }
652        }
653
654        Ok(output)
655    }
656
657    /// Try to open a channel and send exec command
658    ///
659    /// Returns the channel and a boolean indicating exec was sent successfully.
660    /// Separates pre-exec failures (which can be retried) from post-exec state.
661    async fn try_open_and_exec(
662        &self,
663        command: &str,
664    ) -> std::result::Result<(russh::Channel<russh::client::Msg>, bool), PreExecError> {
665        let channel = self
666            .open_channel()
667            .await
668            .map_err(|e| PreExecError::ChannelOpen(e.to_string()))?;
669
670        debug!("Executing command: cmd_len={}", command.len());
671        let wrapped_command = wrap_command_for_channel_exec(command);
672        channel
673            .exec(true, wrapped_command.as_str())
674            .await
675            .map_err(|e| PreExecError::ExecSend(format!("Failed to exec command: {}", e)))?;
676
677        Ok((channel, true))
678    }
679
680    async fn open_and_exec_with_reconnect_retry(
681        &self,
682        command: &str,
683    ) -> Result<(russh::Channel<russh::client::Msg>, bool)> {
684        match self.try_open_and_exec(command).await {
685            Ok(result) => Ok(result),
686            Err(pre_exec_err) => {
687                match &pre_exec_err {
688                    PreExecError::ChannelOpen(e) => {
689                        warn!(
690                            error = ?e,
691                            "Channel open failed, attempting reconnect and retry"
692                        );
693                    }
694                    PreExecError::ExecSend(e) => {
695                        warn!(error = ?e, "Exec send failed, attempting reconnect and retry");
696                    }
697                }
698
699                self.reconnect().await?;
700                self.try_open_and_exec(command)
701                    .await
702                    .map_err(|retry_err| retry_err.into_ssh_error())
703            }
704        }
705    }
706
707    /// Collect output from a channel until it closes
708    ///
709    /// Implements output limiting to prevent OOM and context overflow.
710    /// Approximate token count: 1 token ≈ 4 bytes for UTF-8 text.
711    async fn collect_channel_output(
712        &self,
713        mut channel: russh::Channel<russh::client::Msg>,
714    ) -> Result<CommandOutput> {
715        // Approximate: 1 token ≈ 4 bytes for estimation
716        const BYTES_PER_TOKEN: usize = 4;
717        // Keep a small tail of truncated output so callers can still see
718        // end-of-command markers (e.g. "done").
719        const TAIL_BYTES: usize = 512;
720
721        let mut output = CommandOutput::new();
722
723        // Calculate byte limit from config (if set)
724        let max_bytes = self
725            .config
726            .max_output_tokens
727            .map(|tokens| tokens.saturating_mul(BYTES_PER_TOKEN));
728
729        // Track total tokens received (including what was truncated)
730        let mut total_stdout_tokens: usize = 0;
731        let mut total_stderr_tokens: usize = 0;
732
733        // Flags to track if we've already added truncation messages
734        let mut stdout_truncation_added = false;
735        let mut stderr_truncation_added = false;
736
737        let mut stdout_tail: String = String::new();
738        let mut stderr_tail: String = String::new();
739
740        let push_tail = |buf: &mut String, chunk: &str| {
741            if chunk.is_empty() {
742                return;
743            }
744            buf.push_str(chunk);
745            if buf.len() > TAIL_BYTES {
746                let start = buf.len().saturating_sub(TAIL_BYTES);
747                let mut safe_start = start;
748                while safe_start > 0 && !buf.is_char_boundary(safe_start) {
749                    safe_start = safe_start.saturating_sub(1);
750                }
751                if safe_start > 0 {
752                    buf.drain(..safe_start);
753                }
754            }
755        };
756
757        while let Some(msg) = channel.wait().await {
758            match msg {
759                ChannelMsg::Data { data } => {
760                    let data_len = data.len();
761                    total_stdout_tokens =
762                        total_stdout_tokens.saturating_add(data_len / BYTES_PER_TOKEN);
763                    let data_str = String::from_utf8_lossy(&data);
764
765                    if let Some(limit) = max_bytes {
766                        let current_len = output.stdout.len();
767
768                        // Check if we need to truncate
769                        if current_len.saturating_add(data_str.len()) > limit {
770                            if !stdout_truncation_added {
771                                // Calculate how much we can take
772                                let remaining = limit.saturating_sub(current_len);
773                                let mut take: usize = 0;
774                                if remaining > 0 {
775                                    // Safe slicing: we know remaining is within bounds since data_str.len() > remaining
776                                    let safe_end = data_str
777                                        .char_indices()
778                                        .map(|(i, _)| i)
779                                        .find(|&i| i > remaining)
780                                        .unwrap_or(data_str.len());
781                                    take = std::cmp::min(safe_end, remaining);
782                                    output.stdout.push_str(&data_str[..take]);
783                                }
784                                output.stdout_truncated = true;
785                                output.stdout_total_tokens = total_stdout_tokens;
786
787                                // Add truncation notice with tips
788                                output.stdout.push_str(&format!(
789                                    "\n[Output truncated: {} tokens total]",
790                                    total_stdout_tokens
791                                ));
792                                output.stdout.push_str(
793                                    "\n[Tip: Use 'head -n 100' for first lines, 'tail -n 100' for last lines]",
794                                );
795                                output.stdout.push_str(
796                                    "\n[Tip: For large output use SFTP/SCP tools to download files]",
797                                 );
798
799                                stdout_truncation_added = true;
800                                warn!(
801                                    "stdout truncated: total_tokens={}, limit_tokens={}",
802                                    total_stdout_tokens,
803                                    max_bytes.map(|b| b / BYTES_PER_TOKEN).unwrap_or(0)
804                                );
805
806                                push_tail(&mut stdout_tail, &data_str[take..]);
807                            } else {
808                                push_tail(&mut stdout_tail, &data_str);
809                            }
810                            // Skip remaining stdout data
811                        } else {
812                            output.stdout.push_str(&data_str);
813                        }
814                    } else {
815                        // No limit - add all data
816                        output.stdout.push_str(&data_str);
817                    }
818                }
819                ChannelMsg::ExtendedData { data, ext } => {
820                    let data_len = data.len();
821                    total_stderr_tokens =
822                        total_stderr_tokens.saturating_add(data_len / BYTES_PER_TOKEN);
823
824                    // ext == 1 is typically stderr
825                    if ext == 1 {
826                        let data_str = String::from_utf8_lossy(&data);
827                        if let Some(limit) = max_bytes {
828                            let current_len = output.stderr.len();
829
830                            // Check if we need to truncate
831                            if current_len.saturating_add(data_str.len()) > limit {
832                                if !stderr_truncation_added {
833                                    // Calculate how much we can take
834                                    let remaining = limit.saturating_sub(current_len);
835                                    let mut take: usize = 0;
836                                    if remaining > 0 {
837                                        // Safe slicing: find UTF-8 safe boundary
838                                        let safe_end = data_str
839                                            .char_indices()
840                                            .map(|(i, _)| i)
841                                            .find(|&i| i > remaining)
842                                            .unwrap_or(data_str.len());
843                                        take = std::cmp::min(safe_end, remaining);
844                                        output.stderr.push_str(&data_str[..take]);
845                                    }
846                                    output.stderr_truncated = true;
847                                    output.stderr_total_tokens = total_stderr_tokens;
848
849                                    // Add truncation notice
850                                    output.stderr.push_str(&format!(
851                                        "\n[Output truncated: {} tokens total]",
852                                        total_stderr_tokens
853                                    ));
854                                    output.stderr.push_str(
855                                        "\n[Tip: Use 'head -n 100' for first lines, 'tail -n 100' for last lines]",
856                                    );
857                                    output.stderr.push_str(
858                                        "\n[Tip: For large output use SFTP/SCP tools to download files]",
859                                    );
860
861                                    stderr_truncation_added = true;
862                                    warn!(
863                                        "stderr truncated: total_tokens={}, limit_tokens={}",
864                                        total_stderr_tokens,
865                                        max_bytes.map(|b| b / BYTES_PER_TOKEN).unwrap_or(0)
866                                    );
867
868                                    push_tail(&mut stderr_tail, &data_str[take..]);
869                                } else {
870                                    push_tail(&mut stderr_tail, &data_str);
871                                }
872                                // Skip remaining stderr data
873                            } else {
874                                output.stderr.push_str(&data_str);
875                            }
876                        } else {
877                            // No limit - add all data
878                            output.stderr.push_str(&data_str);
879                        }
880                    } else {
881                        // Non-stderr extended data goes to stdout
882                        output.stdout.push_str(&String::from_utf8_lossy(&data));
883                    }
884                }
885                ChannelMsg::ExitStatus { exit_status } => {
886                    output.exit_code = Some(exit_status);
887                }
888                ChannelMsg::ExitSignal { signal_name, .. } => {
889                    // Map signal to conventional shell exit code (128 + signal),
890                    // matching stream_channel_inner in background/stream.rs.
891                    let code = match signal_name {
892                        russh::Sig::HUP => 129,
893                        russh::Sig::INT => 130,
894                        russh::Sig::QUIT => 131,
895                        russh::Sig::ILL => 132,
896                        russh::Sig::ABRT => 134,
897                        russh::Sig::FPE => 136,
898                        russh::Sig::KILL => 137,
899                        russh::Sig::USR1 => 138,
900                        russh::Sig::SEGV => 139,
901                        russh::Sig::PIPE => 141,
902                        russh::Sig::ALRM => 142,
903                        russh::Sig::TERM => 143,
904                        russh::Sig::Custom(_) => 128,
905                    };
906                    output.exit_code = Some(code);
907                }
908                ChannelMsg::Close | ChannelMsg::Eof => {
909                    // Don't break - ExitStatus may arrive after Close/Eof
910                    // Loop will exit naturally when channel.wait() returns None
911                }
912                _ => {
913                    // Ignore other messages
914                }
915            }
916        }
917
918        // Store final token counts (if not already set from truncation)
919        if output.stdout_total_tokens == 0 {
920            output.stdout_total_tokens = total_stdout_tokens;
921        }
922        if output.stderr_total_tokens == 0 {
923            output.stderr_total_tokens = total_stderr_tokens;
924        }
925
926        if output.stdout_truncated && !stdout_tail.is_empty() {
927            output.stdout.push('\n');
928            output.stdout.push_str(&stdout_tail);
929        }
930
931        if output.stderr_truncated && !stderr_tail.is_empty() {
932            output.stderr.push('\n');
933            output.stderr.push_str(&stderr_tail);
934        }
935
936        // If there's stderr and a non-zero exit code, we might want to handle it
937        // For now, just return the output as-is
938        debug!(
939            "Command completed: exit_code={:?}, stdout_len={}, stderr_len={}, stdout_truncated={}, stderr_truncated={}",
940            output.exit_code,
941            output.stdout.len(),
942            output.stderr.len(),
943            output.stdout_truncated,
944            output.stderr_truncated
945        );
946
947        // A channel that closed without an exit status or exit signal indicates
948        // the SSH session was torn down (e.g. concurrent invalidate_session,
949        // network drop, server kill).  Returning Ok with exit_code=None would
950        // be treated as success by calltool_from_command_output — a silent
951        // failure.  Return an explicit error instead so the caller can surface
952        // it and trigger reconnection.
953        if output.exit_code.is_none() {
954            return Err(SshMcpError::connection(
955                "SSH channel closed without exit status (session may have been torn down)",
956            ));
957        }
958
959        Ok(output)
960    }
961
962    /// Attempt to abort a running command by killing matching processes
963    ///
964    /// Sends `timeout 3s pkill -f 'command' 2>/dev/null || true` to kill
965    /// any processes matching the command pattern.
966    async fn abort_command(&self, command: &str) {
967        // Try to open a new channel for the abort command
968        let channel = match self.open_channel().await {
969            Ok(ch) => ch,
970            Err(e) => {
971                error!(error = ?e, "Failed to open channel for abort");
972                return;
973            }
974        };
975
976        let escaped_command = escape_command_for_shell(command);
977        let abort_cmd = format!(
978            "timeout 3s pkill -f '{}' 2>/dev/null || true",
979            escaped_command
980        );
981
982        debug!(
983            "Sending abort command: pattern_len={}, abort_len={}",
984            command.len(),
985            abort_cmd.len()
986        );
987
988        if let Err(e) = channel.exec(true, abort_cmd.as_str()).await {
989            error!(error = ?e, "Failed to exec abort command");
990            return;
991        }
992
993        // Wait briefly for abort to complete (max 5 seconds)
994        let abort_timeout = Duration::from_secs(5);
995        let _ = timeout(abort_timeout, async {
996            let mut channel = channel;
997            while let Some(msg) = channel.wait().await {
998                match msg {
999                    ChannelMsg::Close | ChannelMsg::Eof => break,
1000                    _ => continue,
1001                }
1002            }
1003        })
1004        .await;
1005
1006        debug!("Abort command completed");
1007    }
1008
1009    /// Execute a command over SSH with binary-safe streaming.
1010    ///
1011    /// This method is designed for use-cases like file transfer where stdout must
1012    /// be treated as bytes and forwarded to a sink without UTF-8 decoding.
1013    ///
1014    /// Notes:
1015    /// - This does not use the interactive su shell.
1016    /// - Timeouts are enforced locally via tokio timeout.
1017    pub async fn exec_raw_streaming<R, W>(
1018        &self,
1019        command: &str,
1020        mut stdin: Option<&mut R>,
1021        mut stdout: Option<&mut W>,
1022        timeout_duration: Duration,
1023    ) -> Result<TransferRawOutput>
1024    where
1025        R: AsyncRead + Unpin,
1026        W: AsyncWrite + Unpin,
1027    {
1028        let _permit = self.acquire_command_slot().await?;
1029
1030        self.ensure_connected().await?;
1031
1032        // Raw transfers must not reuse the PTY/su channel.
1033        let fut = async {
1034            let channel = self.open_channel().await?;
1035            channel
1036                .exec(true, command)
1037                .await
1038                .map_err(|e| SshMcpError::connection(format!("Failed to exec command: {e}")))?;
1039
1040            // Prevent deadlocks by pumping stdin and stdout/stderr concurrently.
1041            // stdin/stdout are borrowed, so we keep IO in this task and run the SSH channel
1042            // event loop in a spawned task (owned channel).
1043            let (stdin_tx, mut stdin_rx) = tokio::sync::mpsc::channel::<Vec<u8>>(4);
1044            let (out_tx, mut out_rx) = tokio::sync::mpsc::channel::<RawStreamEvent>(8);
1045
1046            let task_guard = JoinAbortGuard::new(tokio::spawn(async move {
1047                raw_channel_task(channel, &mut stdin_rx, out_tx).await
1048            }));
1049
1050            let mut output = TransferRawOutput::default();
1051            let stderr_limit_bytes = resolve_raw_stream_stderr_limit(self.config.max_output_tokens);
1052            let mut total_stderr_bytes = 0usize;
1053            let mut stderr_truncated = false;
1054            let mut stdin_done = stdin.is_none();
1055            let mut stdin_tx: Option<tokio::sync::mpsc::Sender<Vec<u8>>> =
1056                if stdin_done { None } else { Some(stdin_tx) };
1057            let mut channel_closed = false;
1058            let mut out_rx_closed = false;
1059
1060            let mut buf = vec![0u8; 32 * 1024];
1061
1062            loop {
1063                if stdin_done && channel_closed && out_rx_closed {
1064                    break;
1065                }
1066
1067                tokio::select! {
1068                    read_res = async {
1069                        match stdin.as_mut() {
1070                            Some(r) => r.read(&mut buf).await,
1071                            None => Ok(0),
1072                        }
1073                    }, if !stdin_done => {
1074                        let n = read_res?;
1075                        if n == 0 {
1076                            stdin_done = true;
1077                            stdin_tx = None; // drop -> EOF
1078                        } else {
1079                            let chunk = buf[..n].to_vec();
1080                            match stdin_tx.as_mut() {
1081                                Some(tx) => {
1082                                    tx.send(chunk).await.map_err(|_| {
1083                                        SshMcpError::connection("raw channel task ended while sending stdin".to_string())
1084                                    })?;
1085                                    output.stdin_bytes += n as u64;
1086                                }
1087                                None => {
1088                                    return Err(SshMcpError::connection(
1089                                        "raw stdin channel closed unexpectedly".to_string(),
1090                                    ));
1091                                }
1092                            }
1093                        }
1094                    }
1095                    maybe_evt = out_rx.recv() => {
1096                        match maybe_evt {
1097                            Some(RawStreamEvent::Stdout(data)) => {
1098                                output.stdout_bytes += data.len() as u64;
1099                                if let Some(writer) = stdout.as_mut() {
1100                                    writer.write_all(&data).await?;
1101                                }
1102                            }
1103                            Some(RawStreamEvent::Stderr(data)) => {
1104                                total_stderr_bytes = total_stderr_bytes.saturating_add(data.len());
1105                                if !stderr_truncated {
1106                                    stderr_truncated = append_bounded_lossy_stderr(
1107                                        &mut output.stderr,
1108                                        &data,
1109                                        stderr_limit_bytes,
1110                                    );
1111                                    if stderr_truncated {
1112                                        warn!(
1113                                            total_stderr_bytes,
1114                                            stderr_limit_bytes,
1115                                            "raw streaming stderr truncated"
1116                                        );
1117                                    }
1118                                }
1119                            }
1120                            Some(RawStreamEvent::ExitStatus(code)) => {
1121                                output.exit_code = Some(code);
1122                            }
1123                            Some(RawStreamEvent::Closed) => {
1124                                channel_closed = true;
1125                            }
1126                            None => {
1127                                out_rx_closed = true;
1128                            }
1129                        }
1130                    }
1131                }
1132            }
1133
1134            if stderr_truncated {
1135                output.stderr.push_str(&format!(
1136                    "\n[stderr truncated: {} bytes total, limit {} bytes]",
1137                    total_stderr_bytes, stderr_limit_bytes
1138                ));
1139            }
1140
1141            if let Some(writer) = stdout.as_mut() {
1142                writer.flush().await?;
1143            }
1144
1145            let join_handle = match task_guard.into_handle() {
1146                Some(h) => h,
1147                None => {
1148                    return Err(SshMcpError::connection(
1149                        "raw channel task handle missing".to_string(),
1150                    ));
1151                }
1152            };
1153
1154            match join_handle.await {
1155                Ok(Ok(())) => Ok(output),
1156                Ok(Err(e)) => Err(e),
1157                Err(e) => Err(SshMcpError::connection(format!(
1158                    "raw channel task join failed: {e}"
1159                ))),
1160            }
1161        };
1162
1163        match timeout(timeout_duration, fut).await {
1164            Ok(res) => res,
1165            Err(_) => {
1166                self.invalidate_session("raw command timed out").await;
1167                Err(SshMcpError::Timeout(timeout_duration.as_millis() as u64))
1168            }
1169        }
1170    }
1171
1172    /// Check the status of a background job by job_id.
1173    ///
1174    /// Uses `kill -0` for process detection (existence/permission check without sending a signal).
1175    /// This avoids parsing `ps` output (GNU vs BusyBox differences) and works on common Linux
1176    /// distributions.
1177    ///
1178    /// # Arguments
1179    /// * `job_id` - Job id returned by background exec
1180    /// * `tail_lines` - Number of lines to read from log tail
1181    /// * `registry` - Job registry holding current job state
1182    ///
1183    /// # Returns
1184    /// ProcessStatus with running state, exit code, elapsed time, command, and log tail
1185    pub async fn check_process(
1186        &self,
1187        job_id: &str,
1188        tail_lines: usize,
1189        registry: &JobRegistry,
1190        spooler: &LocalLogSpooler,
1191    ) -> Result<ProcessStatus> {
1192        debug!(job_id = ?job_id, "Checking process status");
1193
1194        let job = match registry.get(job_id).await {
1195            Some(job) => job,
1196            None => match spooler.load_job_state(job_id).await {
1197                Ok(Some(recovered)) => {
1198                    let shared = Arc::new(Mutex::new(recovered));
1199                    registry
1200                        .insert(job_id.to_string(), Arc::clone(&shared))
1201                        .await;
1202                    shared
1203                }
1204                Ok(None) => {
1205                    return Err(SshMcpError::invalid_params(format!(
1206                        "job not found: {job_id}"
1207                    )));
1208                }
1209                Err(e) => {
1210                    return Err(SshMcpError::invalid_params(format!(
1211                        "failed to recover job state for {job_id}: {e}"
1212                    )));
1213                }
1214            },
1215        };
1216
1217        let job_guard = job.lock().await;
1218        let pid = job_guard.pid;
1219        let command = job_guard.command.clone();
1220        let log_path = job_guard.log_path.clone();
1221        let status = job_guard.status;
1222        let exit_code_i32 = job_guard.exit_code;
1223        let stored_state_reason = job_guard.state_reason.clone();
1224        let elapsed_time = job_guard.elapsed_time();
1225        drop(job_guard);
1226
1227        let (running, effective_status, effective_exit_code_i32, effective_reason) = match status {
1228            JobStatus::Running => {
1229                self.ensure_connected().await?;
1230                if self.is_pid_running(pid).await? {
1231                    (true, JobStatus::Running, None, None)
1232                } else if let Some(code) = exit_code_i32 {
1233                    (false, job_status_from_exit_code(code), Some(code), None)
1234                } else {
1235                    let (settled_status, settled_exit_code, settled_reason) =
1236                        await_running_job_settle(&job).await;
1237                    match settled_status {
1238                        JobStatus::Running => match settled_exit_code {
1239                            Some(code) => {
1240                                (false, job_status_from_exit_code(code), Some(code), None)
1241                            }
1242                            None => (
1243                                false,
1244                                JobStatus::StateLost,
1245                                None,
1246                                Some(settled_reason.unwrap_or_else(|| {
1247                                    "pid_not_running_and_no_exit_status".to_string()
1248                                })),
1249                            ),
1250                        },
1251                        JobStatus::Completed | JobStatus::Failed => match settled_exit_code {
1252                            Some(code) => {
1253                                (false, job_status_from_exit_code(code), Some(code), None)
1254                            }
1255                            None => (
1256                                false,
1257                                JobStatus::StateLost,
1258                                None,
1259                                Some(settled_reason.unwrap_or_else(|| {
1260                                    "missing_exit_code_for_terminal_state".to_string()
1261                                })),
1262                            ),
1263                        },
1264                        JobStatus::StateLost => (
1265                            false,
1266                            JobStatus::StateLost,
1267                            None,
1268                            Some(settled_reason.unwrap_or_else(|| "state_lost".to_string())),
1269                        ),
1270                    }
1271                }
1272            }
1273            JobStatus::Completed | JobStatus::Failed => match exit_code_i32 {
1274                Some(code) => (false, job_status_from_exit_code(code), Some(code), None),
1275                None => (
1276                    false,
1277                    JobStatus::StateLost,
1278                    None,
1279                    Some(
1280                        stored_state_reason
1281                            .clone()
1282                            .unwrap_or_else(|| "missing_exit_code_for_terminal_state".to_string()),
1283                    ),
1284                ),
1285            },
1286            JobStatus::StateLost => (
1287                false,
1288                JobStatus::StateLost,
1289                None,
1290                Some(
1291                    stored_state_reason
1292                        .clone()
1293                        .unwrap_or_else(|| "state_lost".to_string()),
1294                ),
1295            ),
1296        };
1297
1298        if status != effective_status
1299            || exit_code_i32 != effective_exit_code_i32
1300            || stored_state_reason != effective_reason
1301        {
1302            let mut guard = job.lock().await;
1303            match effective_status {
1304                JobStatus::Running => {
1305                    guard.status = JobStatus::Running;
1306                    guard.exit_code = None;
1307                    guard.state_reason = None;
1308                }
1309                JobStatus::Completed | JobStatus::Failed => {
1310                    if let Some(code) = effective_exit_code_i32 {
1311                        guard.mark_exit(code);
1312                    }
1313                }
1314                JobStatus::StateLost => {
1315                    guard.mark_state_lost(
1316                        effective_reason
1317                            .clone()
1318                            .unwrap_or_else(|| "state_lost".to_string()),
1319                    );
1320                }
1321            }
1322
1323            let persisted = guard.clone();
1324            drop(guard);
1325
1326            if let Err(e) = spooler.persist_job_state(&persisted).await {
1327                warn!(job_id = ?job_id, error = ?e, "failed to persist reconciled job state");
1328            }
1329        }
1330
1331        let exit_code = if running || effective_status == JobStatus::StateLost {
1332            None
1333        } else {
1334            effective_exit_code_i32.and_then(|code| u32::try_from(code).ok())
1335        };
1336
1337        let log_exists = log_file_exists(&log_path).await?;
1338
1339        let log_tail = read_local_log_tail(&log_path, tail_lines).await?;
1340
1341        Ok(ProcessStatus {
1342            pid,
1343            state: effective_status.as_str().to_string(),
1344            running,
1345            exit_code,
1346            state_reason: effective_reason,
1347            elapsed_time,
1348            command,
1349            log_path: log_path.to_string_lossy().to_string(),
1350            log_exists,
1351            log_tail,
1352        })
1353    }
1354
1355    async fn is_pid_running(&self, pid: u32) -> Result<bool> {
1356        // `kill -0` checks for existence/permission without sending a signal.
1357        let cmd = format!("sh -c 'kill -0 {pid} 2>/dev/null'");
1358        let output = self.exec_command(&cmd, Duration::from_secs(5)).await?;
1359        Ok(output.exit_code == Some(0))
1360    }
1361}
1362
1363fn job_status_from_exit_code(exit_code: i32) -> JobStatus {
1364    if exit_code == 0 {
1365        JobStatus::Completed
1366    } else {
1367        JobStatus::Failed
1368    }
1369}
1370
1371async fn await_running_job_settle(
1372    job: &SharedJobState,
1373) -> (JobStatus, Option<i32>, Option<String>) {
1374    tokio::time::sleep(Duration::from_millis(150)).await;
1375    let guard = job.lock().await;
1376    (guard.status, guard.exit_code, guard.state_reason.clone())
1377}
1378
1379pub(crate) async fn read_local_log_tail(path: &Path, lines: usize) -> Result<String> {
1380    if lines == 0 {
1381        return Ok(String::new());
1382    }
1383
1384    let mut file = match open_log_read_no_symlink(path).await? {
1385        Some(f) => f,
1386        None => return Ok(String::new()),
1387    };
1388
1389    let meta = file.metadata().await?;
1390    let mut pos = meta.len();
1391
1392    const CHUNK_SIZE: u64 = 8192;
1393    const MAX_READ_BYTES: usize = 1024 * 1024;
1394
1395    let mut buf: Vec<u8> = Vec::new();
1396    let mut newlines = 0usize;
1397
1398    while pos > 0 && newlines <= lines && buf.len() < MAX_READ_BYTES {
1399        let read_len = std::cmp::min(CHUNK_SIZE, pos) as usize;
1400        pos = pos.saturating_sub(read_len as u64);
1401
1402        file.seek(std::io::SeekFrom::Start(pos)).await?;
1403
1404        let mut chunk = vec![0u8; read_len];
1405        let mut got = 0usize;
1406        while got < read_len {
1407            let n = file.read(&mut chunk[got..]).await?;
1408            if n == 0 {
1409                break;
1410            }
1411            got = got.saturating_add(n);
1412        }
1413        if got == 0 {
1414            break;
1415        }
1416        chunk.truncate(got);
1417
1418        newlines = newlines.saturating_add(chunk.iter().filter(|&&b| b == b'\n').count());
1419
1420        // Prepend chunk to existing buffer (bounded by MAX_READ_BYTES).
1421        if chunk.len().saturating_add(buf.len()) > MAX_READ_BYTES {
1422            let allowed = MAX_READ_BYTES.saturating_sub(buf.len());
1423            chunk.truncate(allowed);
1424        }
1425        chunk.extend_from_slice(&buf);
1426        buf = chunk;
1427    }
1428
1429    let text = String::from_utf8_lossy(&buf);
1430    let all_lines: Vec<&str> = text.lines().collect();
1431    if all_lines.is_empty() {
1432        return Ok(String::new());
1433    }
1434
1435    let start = all_lines.len().saturating_sub(lines);
1436    Ok(all_lines[start..].join("\n"))
1437}
1438
1439async fn log_file_exists(path: &Path) -> Result<bool> {
1440    match tokio::fs::symlink_metadata(path).await {
1441        Ok(meta) => {
1442            if meta.file_type().is_symlink() {
1443                return Err(std::io::Error::new(
1444                    std::io::ErrorKind::InvalidInput,
1445                    "log path is a symlink (refusing to follow it)",
1446                )
1447                .into());
1448            }
1449            Ok(meta.is_file())
1450        }
1451        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
1452        Err(e) => Err(e.into()),
1453    }
1454}
1455
1456async fn open_log_read_no_symlink(path: &Path) -> Result<Option<tokio::fs::File>> {
1457    match tokio::fs::symlink_metadata(path).await {
1458        Ok(meta) => {
1459            if meta.file_type().is_symlink() {
1460                return Err(std::io::Error::new(
1461                    std::io::ErrorKind::InvalidInput,
1462                    "log path is a symlink (refusing to follow it)",
1463                )
1464                .into());
1465            }
1466            if !meta.is_file() {
1467                return Err(std::io::Error::new(
1468                    std::io::ErrorKind::InvalidInput,
1469                    "log path is not a regular file",
1470                )
1471                .into());
1472            }
1473        }
1474        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1475            return Ok(None);
1476        }
1477        Err(e) => return Err(e.into()),
1478    }
1479
1480    let mut opts = tokio::fs::OpenOptions::new();
1481    opts.read(true);
1482
1483    #[cfg(unix)]
1484    {
1485        opts.custom_flags(O_NOFOLLOW_FLAG);
1486    }
1487
1488    match opts.open(path).await {
1489        Ok(f) => {
1490            // Re-check based on the opened file handle to avoid TOCTOU.
1491            let meta = f.metadata().await?;
1492            if !meta.is_file() {
1493                return Err(std::io::Error::new(
1494                    std::io::ErrorKind::InvalidInput,
1495                    "log path is not a regular file",
1496                )
1497                .into());
1498            }
1499            Ok(Some(f))
1500        }
1501        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
1502        Err(e) => {
1503            if let Ok(meta) = tokio::fs::symlink_metadata(path).await
1504                && meta.file_type().is_symlink()
1505            {
1506                return Err(std::io::Error::new(
1507                    std::io::ErrorKind::InvalidInput,
1508                    "log path is a symlink (refusing to follow it)",
1509                )
1510                .into());
1511            }
1512            Err(e.into())
1513        }
1514    }
1515}
1516
1517#[derive(Debug)]
1518enum RawStreamEvent {
1519    Stdout(Vec<u8>),
1520    Stderr(Vec<u8>),
1521    ExitStatus(u32),
1522    Closed,
1523}
1524
1525struct JoinAbortGuard<T> {
1526    handle: Option<tokio::task::JoinHandle<T>>,
1527}
1528
1529impl<T> JoinAbortGuard<T> {
1530    fn new(handle: tokio::task::JoinHandle<T>) -> Self {
1531        Self {
1532            handle: Some(handle),
1533        }
1534    }
1535
1536    fn into_handle(mut self) -> Option<tokio::task::JoinHandle<T>> {
1537        self.handle.take()
1538    }
1539}
1540
1541impl<T> Drop for JoinAbortGuard<T> {
1542    fn drop(&mut self) {
1543        if let Some(handle) = &self.handle {
1544            handle.abort();
1545        }
1546    }
1547}
1548
1549async fn raw_channel_task(
1550    mut channel: russh::Channel<russh::client::Msg>,
1551    stdin_rx: &mut tokio::sync::mpsc::Receiver<Vec<u8>>,
1552    out_tx: tokio::sync::mpsc::Sender<RawStreamEvent>,
1553) -> Result<()> {
1554    let mut stdin_closed = false;
1555    let mut sent_closed = false;
1556    loop {
1557        tokio::select! {
1558            maybe_chunk = stdin_rx.recv(), if !stdin_closed => {
1559                match maybe_chunk {
1560                    Some(chunk) => {
1561                        channel.data(chunk.as_slice()).await.map_err(|e| {
1562                            SshMcpError::connection(format!("Failed to send stdin: {e}"))
1563                        })?;
1564                    }
1565                    None => {
1566                        stdin_closed = true;
1567                        let _ = channel.eof().await;
1568                    }
1569                }
1570            }
1571            maybe_msg = channel.wait() => {
1572                match maybe_msg {
1573                    Some(msg) => {
1574                        let send_evt = |evt: RawStreamEvent| async {
1575                            out_tx.send(evt).await.map_err(|_| ())
1576                        };
1577
1578                        match msg {
1579                            ChannelMsg::Data { data } => {
1580                                let bytes = data.as_ref().to_vec();
1581                                if send_evt(RawStreamEvent::Stdout(bytes)).await.is_err() {
1582                                    return Ok(());
1583                                }
1584                            }
1585                            ChannelMsg::ExtendedData { data, ext } => {
1586                                let bytes = data.as_ref().to_vec();
1587                                let evt = if ext == 1 {
1588                                    RawStreamEvent::Stderr(bytes)
1589                                } else {
1590                                    RawStreamEvent::Stdout(bytes)
1591                                };
1592                                if send_evt(evt).await.is_err() {
1593                                    return Ok(());
1594                                }
1595                            }
1596                            ChannelMsg::ExitStatus { exit_status }
1597                                if send_evt(RawStreamEvent::ExitStatus(exit_status)).await.is_err() =>
1598                            {
1599                                return Ok(());
1600                            }
1601                            ChannelMsg::ExitStatus { .. } => {}
1602                            ChannelMsg::ExitSignal { signal_name, .. } => {
1603                                // Map signal to exit code (128 + signal number)
1604                                // Common signals: HUP=1, INT=2, QUIT=3, ILL=4, TRAP=5, ABRT=6, BUS=7, FPE=8, KILL=9
1605                                let code = match signal_name {
1606                                    russh::Sig::HUP => 129,
1607                                    russh::Sig::INT => 130,
1608                                    russh::Sig::QUIT => 131,
1609                                    russh::Sig::ILL => 132,
1610                                    russh::Sig::ABRT => 134,
1611                                    russh::Sig::FPE => 136,
1612                                    russh::Sig::KILL => 137,
1613                                    russh::Sig::USR1 => 138,
1614                                    russh::Sig::SEGV => 139,
1615                                    russh::Sig::PIPE => 141,
1616                                    russh::Sig::ALRM => 142,
1617                                    russh::Sig::TERM => 143,
1618                                    russh::Sig::Custom(_) => 128,
1619                                };
1620                                if send_evt(RawStreamEvent::ExitStatus(code)).await.is_err() {
1621                                    return Ok(());
1622                                }
1623                            }
1624                            ChannelMsg::Close | ChannelMsg::Eof if !sent_closed => {
1625                                // Send Closed once but keep looping to capture trailing ExitStatus
1626                                sent_closed = true;
1627                                let _ = send_evt(RawStreamEvent::Closed).await;
1628                            }
1629                            ChannelMsg::Close | ChannelMsg::Eof => {}
1630                            _ => {}
1631                        }
1632                    }
1633                    None => {
1634                        // Channel fully closed - ensure we send Closed before exiting
1635                        if !sent_closed {
1636                            let _ = out_tx.send(RawStreamEvent::Closed).await;
1637                        }
1638                        break;
1639                    }
1640                }
1641            }
1642        }
1643    }
1644
1645    Ok(())
1646}
1647
1648#[cfg(test)]
1649mod tests {
1650    use super::*;
1651
1652    #[test]
1653    fn test_command_output_success() {
1654        let output = CommandOutput {
1655            stdout: "hello".to_string(),
1656            stderr: String::new(),
1657            exit_code: Some(0),
1658            ..Default::default()
1659        };
1660        assert!(output.success());
1661    }
1662
1663    #[test]
1664    fn test_command_output_failure() {
1665        let output = CommandOutput {
1666            stdout: String::new(),
1667            stderr: "error".to_string(),
1668            exit_code: Some(1),
1669            ..Default::default()
1670        };
1671        assert!(!output.success());
1672    }
1673
1674    #[test]
1675    fn test_command_output_no_exit_code() {
1676        let output = CommandOutput {
1677            stdout: "hello".to_string(),
1678            stderr: String::new(),
1679            exit_code: None,
1680            ..Default::default()
1681        };
1682        // No exit code means the channel was torn down — not success
1683        assert!(!output.success());
1684    }
1685
1686    #[test]
1687    fn test_command_output_combined() {
1688        let output = CommandOutput {
1689            stdout: "stdout".to_string(),
1690            stderr: "stderr".to_string(),
1691            exit_code: Some(0),
1692            ..Default::default()
1693        };
1694        assert_eq!(output.combined_output(), "stdout\nstderr");
1695    }
1696
1697    #[test]
1698    fn test_command_output_combined_only_stdout() {
1699        let output = CommandOutput {
1700            stdout: "stdout".to_string(),
1701            stderr: String::new(),
1702            exit_code: Some(0),
1703            ..Default::default()
1704        };
1705        assert_eq!(output.combined_output(), "stdout");
1706    }
1707
1708    #[test]
1709    fn test_command_output_combined_only_stderr() {
1710        let output = CommandOutput {
1711            stdout: String::new(),
1712            stderr: "stderr".to_string(),
1713            exit_code: Some(1),
1714            ..Default::default()
1715        };
1716        assert_eq!(output.combined_output(), "stderr");
1717    }
1718
1719    #[test]
1720    fn test_wrap_command_with_timeout() {
1721        let cmd = wrap_command_with_timeout("sleep 10", 2.0);
1722        assert!(cmd.contains("timeout -k 2s 2s"));
1723        assert!(cmd.contains("sh -lc")); // Uses login shell
1724        assert!(cmd.contains("sleep 10"));
1725    }
1726
1727    #[test]
1728    fn test_wrap_command_with_timeout_zero_duration() {
1729        // Edge case: wrapper accepts zero (validation is elsewhere)
1730        let cmd = wrap_command_with_timeout("echo test", 0.0);
1731        assert!(cmd.contains("timeout -k 2s 0s"));
1732        assert!(cmd.contains("sh -lc"));
1733        assert!(cmd.contains("echo test"));
1734    }
1735
1736    #[test]
1737    fn test_wrap_command_with_timeout_fractional() {
1738        // Test fractional seconds for sub-second precision
1739        let cmd = wrap_command_with_timeout("sleep 1", 0.5);
1740        assert!(cmd.contains("timeout -k 2s 0.5s"));
1741        assert!(cmd.contains("sh -lc"));
1742        assert!(cmd.contains("sleep 1"));
1743    }
1744
1745    #[test]
1746    fn test_wrap_command_with_timeout_complex_command() {
1747        let cmd = wrap_command_with_timeout("echo 'hello world'", 10.0);
1748        assert!(cmd.contains("timeout -k 2s 10s"));
1749        assert!(cmd.contains("sh -lc"));
1750        assert!(cmd.contains("echo"));
1751    }
1752
1753    #[test]
1754    fn test_wrap_command_with_timeout_with_single_quotes() {
1755        let cmd = wrap_command_with_timeout("echo 'hello'", 10.0);
1756        assert!(cmd.contains("timeout -k 2s 10s"));
1757        assert!(cmd.contains("sh -lc"));
1758        // Single quotes are escaped as '"'"'
1759        assert!(cmd.contains("'\"'\"'"));
1760    }
1761
1762    #[test]
1763    fn test_wrap_command_for_channel_exec_non_login_shell() {
1764        let cmd = wrap_command_for_channel_exec("echo hello");
1765        assert_eq!(cmd, "sh -c 'echo hello'");
1766    }
1767
1768    #[test]
1769    fn test_wrap_command_for_channel_exec_timeout_wrapper_payload() {
1770        let timeout_wrapped = wrap_command_with_timeout("echo hello", 1.0);
1771        assert_eq!(timeout_wrapped, "timeout -k 2s 1s sh -lc 'echo hello'");
1772
1773        let cmd = wrap_command_for_channel_exec(&timeout_wrapped);
1774        assert_eq!(
1775            cmd,
1776            "sh -c 'timeout -k 2s 1s sh -lc '\"'\"'echo hello'\"'\"''"
1777        );
1778    }
1779
1780    #[test]
1781    fn test_wrap_command_for_channel_exec_timeout_wrapper_payload_with_single_quotes() {
1782        let timeout_wrapped = wrap_command_with_timeout("echo 'hello'", 1.0);
1783        assert_eq!(
1784            timeout_wrapped,
1785            "timeout -k 2s 1s sh -lc 'echo '\"'\"'hello'\"'\"''"
1786        );
1787
1788        let cmd = wrap_command_for_channel_exec(&timeout_wrapped);
1789        assert!(cmd.starts_with("sh -c '"));
1790        assert!(cmd.ends_with('\''));
1791
1792        let inner = &cmd[7..cmd.len() - 1];
1793        let unescaped_once = inner.replace("'\"'\"'", "'");
1794        assert_eq!(unescaped_once, timeout_wrapped);
1795        assert!(cmd.contains("hello"));
1796    }
1797
1798    #[test]
1799    fn test_resolve_raw_stream_stderr_limit_uses_token_limit() {
1800        assert_eq!(
1801            resolve_raw_stream_stderr_limit(Some(12_000)),
1802            12_000 * RAW_STREAM_BYTES_PER_TOKEN
1803        );
1804    }
1805
1806    #[test]
1807    fn test_resolve_raw_stream_stderr_limit_none_uses_hard_cap() {
1808        assert_eq!(
1809            resolve_raw_stream_stderr_limit(None),
1810            RAW_STREAM_STDERR_HARD_MAX_BYTES
1811        );
1812    }
1813
1814    #[test]
1815    fn test_resolve_raw_stream_stderr_limit_applies_hard_cap() {
1816        assert_eq!(
1817            resolve_raw_stream_stderr_limit(Some(RAW_STREAM_STDERR_HARD_MAX_BYTES)),
1818            RAW_STREAM_STDERR_HARD_MAX_BYTES
1819        );
1820    }
1821
1822    #[test]
1823    fn test_append_bounded_lossy_stderr_no_truncation() {
1824        let mut stderr = String::new();
1825        let truncated = append_bounded_lossy_stderr(&mut stderr, b"hello", 16);
1826        assert!(!truncated);
1827        assert_eq!(stderr, "hello");
1828    }
1829
1830    #[test]
1831    fn test_append_bounded_lossy_stderr_truncates_at_utf8_boundary() {
1832        let mut stderr = String::new();
1833        let truncated = append_bounded_lossy_stderr(&mut stderr, "абв".as_bytes(), 3);
1834        assert!(truncated);
1835        assert_eq!(stderr, "а");
1836    }
1837}