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 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#[derive(Debug, Clone, Default)]
33pub struct CommandOutput {
34 pub stdout: String,
36
37 pub stderr: String,
39
40 pub exit_code: Option<u32>,
42
43 pub stdout_truncated: bool,
45
46 pub stderr_truncated: bool,
48
49 pub stdout_total_tokens: usize,
51
52 pub stderr_total_tokens: usize,
54}
55
56#[derive(Debug, Clone, Default)]
60pub struct TransferRawOutput {
61 pub stdout_bytes: u64,
63
64 pub stdin_bytes: u64,
66
67 pub stderr: String,
69
70 pub exit_code: Option<u32>,
72}
73
74#[derive(Debug, Clone)]
76pub struct ProcessStatus {
77 pub pid: u32,
79 pub state: String,
81 pub running: bool,
83 pub exit_code: Option<u32>,
85 pub state_reason: Option<String>,
87 pub elapsed_time: String,
89 pub command: String,
91 pub log_path: String,
93 pub log_exists: bool,
95 pub log_tail: String,
97}
98
99impl CommandOutput {
100 pub fn new() -> Self {
102 Self::default()
103 }
104
105 pub fn success(&self) -> bool {
107 self.exit_code.is_some_and(|code| code == 0)
108 }
109
110 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
122pub 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 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
198enum PreExecError {
201 ChannelOpen(String),
202 ExecSend(String),
203}
204
205impl PreExecError {
206 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
215enum SuSendError {
218 SendFailed(String),
219}
220
221impl SshConnectionManager {
222 pub async fn exec_command(
240 &self,
241 command: &str,
242 timeout_duration: Duration,
243 ) -> Result<CommandOutput> {
244 let _permit = self.acquire_command_slot().await?;
246
247 self.ensure_connected().await?;
249
250 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 debug!("Using normal exec channel for command execution");
258 self.exec_via_channel(command, timeout_duration).await
259 }
260
261 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 let use_wrapper = self.determine_timeout_wrapper_usage().await;
275
276 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 let mut channel = match self.try_take_su_channel().await {
292 Some(ch) => ch,
293 None => {
294 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 match self
311 .try_send_to_su_channel(&mut channel, &wrapped_cmd)
312 .await
313 {
314 Ok(()) => {
315 let result = self
317 .collect_su_output(&mut channel, timeout_duration, use_wrapper)
318 .await;
319
320 {
322 let mut guard = self.su_channel.lock().await;
323 *guard = Some(channel);
324 }
325
326 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 drop(channel);
340
341 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 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 if let Err(SuSendError::SendFailed(e2)) = self
361 .try_send_to_su_channel(&mut channel, &wrapped_cmd)
362 .await
363 {
364 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 let result = self
375 .collect_su_output(&mut channel, timeout_duration, use_wrapper)
376 .await;
377
378 {
380 let mut guard = self.su_channel.lock().await;
381 *guard = Some(channel);
382 }
383
384 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 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 async fn reset_su_state(&self) {
405 let channel = {
407 let mut guard = self.su_channel.lock().await;
408 guard.take()
409 };
410
411 if let Some(ch) = channel {
413 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 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 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 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 if buffer.contains('#') {
492 let lines: Vec<&str> = buffer.lines().collect();
494 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), ..Default::default()
510 });
511 }
512 }
513 ChannelMsg::Close => {
514 return Err(SshMcpError::connection(
515 "Channel closed during command execution",
516 ));
517 }
518 _ => {
519 }
521 }
522 }
523 Ok(None) => {
524 return Err(SshMcpError::connection(
525 "Channel ended during command execution",
526 ));
527 }
528 Err(_) => {
529 continue;
531 }
532 }
533 }
534 }
535
536 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 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 command.to_string()
560 };
561
562 let (channel, _exec_sent) = self
564 .open_and_exec_with_reconnect_retry(&wrapped_cmd)
565 .await?;
566
567 let output_result = if use_wrapper {
571 self.collect_channel_output(channel).await
573 } else {
574 let result = timeout(timeout_duration, self.collect_channel_output(channel)).await;
576
577 match result {
578 Ok(inner_result) => inner_result,
579 Err(_) => {
580 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 if !matches!(e, SshMcpError::Timeout(_)) {
599 self.invalidate_session("channel failed after exec").await;
600 }
601 return Err(e);
602 }
603 };
604
605 if use_wrapper {
607 let stderr_lower = output.stderr.to_lowercase();
608 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 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 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 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 async fn collect_channel_output(
712 &self,
713 mut channel: russh::Channel<russh::client::Msg>,
714 ) -> Result<CommandOutput> {
715 const BYTES_PER_TOKEN: usize = 4;
717 const TAIL_BYTES: usize = 512;
720
721 let mut output = CommandOutput::new();
722
723 let max_bytes = self
725 .config
726 .max_output_tokens
727 .map(|tokens| tokens.saturating_mul(BYTES_PER_TOKEN));
728
729 let mut total_stdout_tokens: usize = 0;
731 let mut total_stderr_tokens: usize = 0;
732
733 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 if current_len.saturating_add(data_str.len()) > limit {
770 if !stdout_truncation_added {
771 let remaining = limit.saturating_sub(current_len);
773 let mut take: usize = 0;
774 if remaining > 0 {
775 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 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 } else {
812 output.stdout.push_str(&data_str);
813 }
814 } else {
815 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 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 if current_len.saturating_add(data_str.len()) > limit {
832 if !stderr_truncation_added {
833 let remaining = limit.saturating_sub(current_len);
835 let mut take: usize = 0;
836 if remaining > 0 {
837 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 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 } else {
874 output.stderr.push_str(&data_str);
875 }
876 } else {
877 output.stderr.push_str(&data_str);
879 }
880 } else {
881 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 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 }
912 _ => {
913 }
915 }
916 }
917
918 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 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 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 async fn abort_command(&self, command: &str) {
967 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 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 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 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 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; } 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 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 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 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 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 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 sent_closed = true;
1627 let _ = send_evt(RawStreamEvent::Closed).await;
1628 }
1629 ChannelMsg::Close | ChannelMsg::Eof => {}
1630 _ => {}
1631 }
1632 }
1633 None => {
1634 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 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")); assert!(cmd.contains("sleep 10"));
1725 }
1726
1727 #[test]
1728 fn test_wrap_command_with_timeout_zero_duration() {
1729 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 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 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}