1use 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#[derive(Debug, Clone, Default)]
34pub struct CommandOutput {
35 pub stdout: String,
37
38 pub stderr: String,
40
41 pub exit_code: Option<u32>,
43
44 pub stdout_truncated: bool,
46
47 pub stderr_truncated: bool,
49
50 pub stdout_total_tokens: usize,
52
53 pub stderr_total_tokens: usize,
55}
56
57#[derive(Debug, Clone, Default)]
61pub struct TransferRawOutput {
62 pub stdout_bytes: u64,
64
65 pub stdin_bytes: u64,
67
68 pub stderr: String,
70
71 pub exit_code: Option<u32>,
73}
74
75#[derive(Debug, Clone)]
77pub struct ProcessStatus {
78 pub pid: u32,
80 pub state: String,
82 pub running: bool,
84 pub exit_code: Option<u32>,
86 pub state_reason: Option<String>,
88 pub elapsed_time: String,
90 pub command: String,
92 pub log_path: String,
94 pub log_exists: bool,
96 pub log_tail: String,
98}
99
100impl CommandOutput {
101 pub fn new() -> Self {
103 Self::default()
104 }
105
106 pub fn success(&self) -> bool {
108 self.exit_code.is_some_and(|code| code == 0)
109 }
110
111 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
123pub 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 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
199enum PreExecError {
202 ChannelOpen(String),
203 ExecSend(String),
204}
205
206impl PreExecError {
207 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
216enum SuSendError {
219 SendFailed(String),
220}
221
222impl SshConnectionManager {
223 pub async fn exec_command(
241 &self,
242 command: &str,
243 timeout_duration: Duration,
244 ) -> Result<CommandOutput> {
245 let _permit = self.acquire_command_slot().await?;
247
248 self.ensure_connected().await?;
250
251 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 debug!("Using normal exec channel for command execution");
259 self.exec_via_channel(command, timeout_duration).await
260 }
261
262 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 let use_wrapper = self.determine_timeout_wrapper_usage().await;
276
277 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 let mut channel = match self.try_take_su_channel().await {
293 Some(ch) => ch,
294 None => {
295 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 match self
312 .try_send_to_su_channel(&mut channel, &wrapped_cmd)
313 .await
314 {
315 Ok(()) => {
316 let result = self
318 .collect_su_output(&mut channel, timeout_duration, use_wrapper)
319 .await;
320
321 self.restore_su_channel(channel).await;
323
324 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 drop(channel);
338
339 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 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 if let Err(SuSendError::SendFailed(e2)) = self
359 .try_send_to_su_channel(&mut channel, &wrapped_cmd)
360 .await
361 {
362 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 let result = self
373 .collect_su_output(&mut channel, timeout_duration, use_wrapper)
374 .await;
375
376 self.restore_su_channel(channel).await;
378
379 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 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 async fn reset_su_state(&self) {
410 let channel = {
412 let mut guard = self.su_channel.lock().await;
413 guard.take()
414 };
415
416 if let Some(ch) = channel {
418 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 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 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 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 if buffer.contains('#') {
482 let lines: Vec<&str> = buffer.lines().collect();
484 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), ..Default::default()
500 });
501 }
502 }
503 ChannelMsg::Close => {
504 return Err(SshMcpError::connection(
505 "Channel closed during command execution",
506 ));
507 }
508 _ => {
509 }
511 }
512 }
513 Ok(None) => {
514 return Err(SshMcpError::connection(
515 "Channel ended during command execution",
516 ));
517 }
518 Err(_) => {
519 continue;
521 }
522 }
523 }
524 }
525
526 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 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 command.to_string()
550 };
551
552 let (channel, _exec_sent) = self
554 .open_and_exec_with_reconnect_retry(&wrapped_cmd)
555 .await?;
556
557 let output_result = if use_wrapper {
561 self.collect_channel_output(channel).await
563 } else {
564 let result = timeout(timeout_duration, self.collect_channel_output(channel)).await;
566
567 match result {
568 Ok(inner_result) => inner_result,
569 Err(_) => {
570 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 if !matches!(e, SshMcpError::Timeout(_)) {
589 self.invalidate_session("channel failed after exec").await;
590 }
591 return Err(e);
592 }
593 };
594
595 if use_wrapper {
597 let stderr_lower = output.stderr.to_lowercase();
598 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 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 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 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 async fn collect_channel_output(
702 &self,
703 mut channel: russh::Channel<russh::client::Msg>,
704 ) -> Result<CommandOutput> {
705 const BYTES_PER_TOKEN: usize = 4;
707 const TAIL_BYTES: usize = 512;
710
711 let mut output = CommandOutput::new();
712
713 let max_bytes = self
715 .config
716 .max_output_tokens
717 .map(|tokens| tokens.saturating_mul(BYTES_PER_TOKEN));
718
719 let mut total_stdout_tokens: usize = 0;
721 let mut total_stderr_tokens: usize = 0;
722
723 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 if current_len.saturating_add(data_str.len()) > limit {
760 if !stdout_truncation_added {
761 let remaining = limit.saturating_sub(current_len);
763 let mut take: usize = 0;
764 if remaining > 0 {
765 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 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 } else {
802 output.stdout.push_str(&data_str);
803 }
804 } else {
805 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 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 if current_len.saturating_add(data_str.len()) > limit {
822 if !stderr_truncation_added {
823 let remaining = limit.saturating_sub(current_len);
825 let mut take: usize = 0;
826 if remaining > 0 {
827 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 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 } else {
864 output.stderr.push_str(&data_str);
865 }
866 } else {
867 output.stderr.push_str(&data_str);
869 }
870 } else {
871 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 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 }
902 _ => {
903 }
905 }
906 }
907
908 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 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 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 async fn abort_command(&self, command: &str) {
957 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 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 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 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 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; } 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 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 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 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 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 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 sent_closed = true;
1678 let _ = send_evt(RawStreamEvent::Closed).await;
1679 }
1680 ChannelMsg::Close | ChannelMsg::Eof => {}
1681 _ => {}
1682 }
1683 }
1684 None => {
1685 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 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")); assert!(cmd.contains("sleep 10"));
1776 }
1777
1778 #[test]
1779 fn test_wrap_command_with_timeout_zero_duration() {
1780 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 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 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}