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 self.restore_su_channel(channel).await;
322
323 if let Err(ref e) = result {
325 warn!(error = ?e, "su channel failed after command sent");
326 self.reset_su_state().await;
327 self.invalidate_session("su channel failed after send")
328 .await;
329 }
330
331 result
332 }
333 Err(SuSendError::SendFailed(e)) => {
334 drop(channel);
337
338 warn!(
340 error = ?e,
341 "su channel send failed (pre-send), resetting and re-elevating"
342 );
343 self.reset_su_state().await;
344 self.ensure_elevated().await?;
345
346 let mut channel = match self.try_take_su_channel().await {
348 Some(ch) => ch,
349 None => {
350 return Err(SshMcpError::connection(
351 "No su channel available after re-elevation",
352 ));
353 }
354 };
355
356 if let Err(SuSendError::SendFailed(e2)) = self
358 .try_send_to_su_channel(&mut channel, &wrapped_cmd)
359 .await
360 {
361 drop(channel);
363 self.reset_su_state().await;
364 return Err(SshMcpError::connection(format!(
365 "Failed to send command to su channel after retry: {}",
366 e2
367 )));
368 }
369
370 let result = self
372 .collect_su_output(&mut channel, timeout_duration, use_wrapper)
373 .await;
374
375 self.restore_su_channel(channel).await;
377
378 if let Err(ref e) = result {
380 warn!(error = ?e, "su channel failed after command sent (retry)");
381 self.reset_su_state().await;
382 self.invalidate_session("su channel failed after send (retry)")
383 .await;
384 }
385
386 result
387 }
388 }
389 }
390
391 async fn try_take_su_channel(&self) -> Option<russh::Channel<russh::client::Msg>> {
393 let mut guard = self.su_channel.lock().await;
394 guard.take()
395 }
396
397 async fn restore_su_channel(&self, channel: russh::Channel<russh::client::Msg>) {
398 let mut guard = self.su_channel.lock().await;
399 if self.is_shutting_down() {
400 drop(guard);
401 let _ = channel.eof().await;
402 } else {
403 *guard = Some(channel);
404 }
405 }
406
407 async fn reset_su_state(&self) {
409 let channel = {
411 let mut guard = self.su_channel.lock().await;
412 guard.take()
413 };
414
415 if let Some(ch) = channel {
417 let _ = ch.eof().await;
419 }
420
421 use std::sync::atomic::Ordering;
422 self.is_elevated.store(false, Ordering::SeqCst);
423 debug!("su state reset: channel cleared, is_elevated=false");
424 }
425
426 async fn try_send_to_su_channel(
429 &self,
430 channel: &mut russh::Channel<russh::client::Msg>,
431 command: &str,
432 ) -> std::result::Result<(), SuSendError> {
433 let wrapped_command = wrap_command_for_channel_exec(command);
434 channel
435 .data(format!("{}\n", wrapped_command).as_bytes())
436 .await
437 .map_err(|e| SuSendError::SendFailed(e.to_string()))
438 }
439
440 async fn collect_su_output(
446 &self,
447 channel: &mut russh::Channel<russh::client::Msg>,
448 timeout_duration: Duration,
449 use_wrapper: bool,
450 ) -> Result<CommandOutput> {
451 let mut buffer = String::new();
452 let deadline = if use_wrapper {
454 None
455 } else {
456 Some(tokio::time::Instant::now() + timeout_duration)
457 };
458
459 loop {
460 if let Some(deadline_ref) = deadline
461 && tokio::time::Instant::now() > deadline_ref
462 {
463 return Err(SshMcpError::Timeout(timeout_duration.as_millis() as u64));
464 }
465
466 let wait_result =
467 tokio::time::timeout(Duration::from_millis(500), channel.wait()).await;
468
469 match wait_result {
470 Ok(Some(msg)) => {
471 match msg {
472 ChannelMsg::Data { data } => {
473 let text = String::from_utf8_lossy(&data);
474 buffer.push_str(&text);
475
476 if buffer.contains('#') {
481 let lines: Vec<&str> = buffer.lines().collect();
483 let output = if lines.len() > 2 {
485 lines[1..lines.len() - 1].join("\n")
486 } else {
487 String::new()
488 };
489
490 return Ok(CommandOutput {
491 stdout: if output.is_empty() {
492 output
493 } else {
494 format!("{}\n", output)
495 },
496 stderr: String::new(),
497 exit_code: Some(0), ..Default::default()
499 });
500 }
501 }
502 ChannelMsg::Close => {
503 return Err(SshMcpError::connection(
504 "Channel closed during command execution",
505 ));
506 }
507 _ => {
508 }
510 }
511 }
512 Ok(None) => {
513 return Err(SshMcpError::connection(
514 "Channel ended during command execution",
515 ));
516 }
517 Err(_) => {
518 continue;
520 }
521 }
522 }
523 }
524
525 async fn exec_via_channel(
534 &self,
535 command: &str,
536 timeout_duration: Duration,
537 ) -> Result<CommandOutput> {
538 let duration_secs = validate_timeout_duration(timeout_duration)?;
539
540 let use_wrapper = self.determine_timeout_wrapper_usage().await;
543
544 let wrapped_cmd = if use_wrapper {
545 wrap_command_with_timeout(command, duration_secs)
546 } else {
547 command.to_string()
549 };
550
551 let (channel, _exec_sent) = self
553 .open_and_exec_with_reconnect_retry(&wrapped_cmd)
554 .await?;
555
556 let output_result = if use_wrapper {
560 self.collect_channel_output(channel).await
562 } else {
563 let result = timeout(timeout_duration, self.collect_channel_output(channel)).await;
565
566 match result {
567 Ok(inner_result) => inner_result,
568 Err(_) => {
569 warn!(
571 "Command timed out after {}ms, attempting abort",
572 timeout_duration.as_millis()
573 );
574 self.abort_command(command).await;
575 self.invalidate_session("command timed out after exec")
576 .await;
577 return Err(SshMcpError::Timeout(timeout_duration.as_millis() as u64));
578 }
579 }
580 };
581
582 let output = match output_result {
583 Ok(out) => out,
584 Err(e) => {
585 if !matches!(e, SshMcpError::Timeout(_)) {
588 self.invalidate_session("channel failed after exec").await;
589 }
590 return Err(e);
591 }
592 };
593
594 if use_wrapper {
596 let stderr_lower = output.stderr.to_lowercase();
597 let timeout_not_found = stderr_lower.contains("timeout: command not found")
599 || stderr_lower.contains("timeout: не найдена команда")
600 || stderr_lower.contains("timeout: introuvable")
601 || stderr_lower.contains("timeout: команда не найдена");
602
603 if timeout_not_found {
604 error!("timeout command not available on remote host, enabling fallback");
605 self.disable_timeout_wrapper();
606
607 let (channel, _) = self
610 .open_and_exec_with_reconnect_retry(command)
611 .await
612 .map_err(|e| {
613 SshMcpError::connection(format!(
614 "Failed to start fallback execution after reconnect retry: {e}"
615 ))
616 })?;
617
618 let result = timeout(timeout_duration, self.collect_channel_output(channel)).await;
619
620 return match result {
621 Ok(inner_output) => inner_output,
622 Err(_) => {
623 warn!(
624 "Command timed out after {}ms (fallback), attempting abort",
625 timeout_duration.as_millis()
626 );
627 self.abort_command(command).await;
628 self.invalidate_session("fallback command timed out after exec")
629 .await;
630 Err(SshMcpError::Timeout(timeout_duration.as_millis() as u64))
631 }
632 };
633 }
634
635 if output.exit_code == Some(124) {
638 warn!("Command timed out (timeout wrapper returned 124)");
639 return Err(SshMcpError::Timeout(timeout_duration.as_millis() as u64));
640 }
641 }
642
643 Ok(output)
644 }
645
646 async fn try_open_and_exec(
651 &self,
652 command: &str,
653 ) -> std::result::Result<(russh::Channel<russh::client::Msg>, bool), PreExecError> {
654 let channel = self
655 .open_channel()
656 .await
657 .map_err(|e| PreExecError::ChannelOpen(e.to_string()))?;
658
659 debug!("Executing command: cmd_len={}", command.len());
660 let wrapped_command = wrap_command_for_channel_exec(command);
661 channel
662 .exec(true, wrapped_command.as_str())
663 .await
664 .map_err(|e| PreExecError::ExecSend(format!("Failed to exec command: {}", e)))?;
665
666 Ok((channel, true))
667 }
668
669 async fn open_and_exec_with_reconnect_retry(
670 &self,
671 command: &str,
672 ) -> Result<(russh::Channel<russh::client::Msg>, bool)> {
673 match self.try_open_and_exec(command).await {
674 Ok(result) => Ok(result),
675 Err(pre_exec_err) => {
676 match &pre_exec_err {
677 PreExecError::ChannelOpen(e) => {
678 warn!(
679 error = ?e,
680 "Channel open failed, attempting reconnect and retry"
681 );
682 }
683 PreExecError::ExecSend(e) => {
684 warn!(error = ?e, "Exec send failed, attempting reconnect and retry");
685 }
686 }
687
688 self.reconnect().await?;
689 self.try_open_and_exec(command)
690 .await
691 .map_err(|retry_err| retry_err.into_ssh_error())
692 }
693 }
694 }
695
696 async fn collect_channel_output(
701 &self,
702 mut channel: russh::Channel<russh::client::Msg>,
703 ) -> Result<CommandOutput> {
704 const BYTES_PER_TOKEN: usize = 4;
706 const TAIL_BYTES: usize = 512;
709
710 let mut output = CommandOutput::new();
711
712 let max_bytes = self
714 .config
715 .max_output_tokens
716 .map(|tokens| tokens.saturating_mul(BYTES_PER_TOKEN));
717
718 let mut total_stdout_tokens: usize = 0;
720 let mut total_stderr_tokens: usize = 0;
721
722 let mut stdout_truncation_added = false;
724 let mut stderr_truncation_added = false;
725
726 let mut stdout_tail: String = String::new();
727 let mut stderr_tail: String = String::new();
728
729 let push_tail = |buf: &mut String, chunk: &str| {
730 if chunk.is_empty() {
731 return;
732 }
733 buf.push_str(chunk);
734 if buf.len() > TAIL_BYTES {
735 let start = buf.len().saturating_sub(TAIL_BYTES);
736 let mut safe_start = start;
737 while safe_start > 0 && !buf.is_char_boundary(safe_start) {
738 safe_start = safe_start.saturating_sub(1);
739 }
740 if safe_start > 0 {
741 buf.drain(..safe_start);
742 }
743 }
744 };
745
746 while let Some(msg) = channel.wait().await {
747 match msg {
748 ChannelMsg::Data { data } => {
749 let data_len = data.len();
750 total_stdout_tokens =
751 total_stdout_tokens.saturating_add(data_len / BYTES_PER_TOKEN);
752 let data_str = String::from_utf8_lossy(&data);
753
754 if let Some(limit) = max_bytes {
755 let current_len = output.stdout.len();
756
757 if current_len.saturating_add(data_str.len()) > limit {
759 if !stdout_truncation_added {
760 let remaining = limit.saturating_sub(current_len);
762 let mut take: usize = 0;
763 if remaining > 0 {
764 let safe_end = data_str
766 .char_indices()
767 .map(|(i, _)| i)
768 .find(|&i| i > remaining)
769 .unwrap_or(data_str.len());
770 take = std::cmp::min(safe_end, remaining);
771 output.stdout.push_str(&data_str[..take]);
772 }
773 output.stdout_truncated = true;
774 output.stdout_total_tokens = total_stdout_tokens;
775
776 output.stdout.push_str(&format!(
778 "\n[Output truncated: {} tokens total]",
779 total_stdout_tokens
780 ));
781 output.stdout.push_str(
782 "\n[Tip: Use 'head -n 100' for first lines, 'tail -n 100' for last lines]",
783 );
784 output.stdout.push_str(
785 "\n[Tip: For large output use SFTP/SCP tools to download files]",
786 );
787
788 stdout_truncation_added = true;
789 warn!(
790 "stdout truncated: total_tokens={}, limit_tokens={}",
791 total_stdout_tokens,
792 max_bytes.map(|b| b / BYTES_PER_TOKEN).unwrap_or(0)
793 );
794
795 push_tail(&mut stdout_tail, &data_str[take..]);
796 } else {
797 push_tail(&mut stdout_tail, &data_str);
798 }
799 } else {
801 output.stdout.push_str(&data_str);
802 }
803 } else {
804 output.stdout.push_str(&data_str);
806 }
807 }
808 ChannelMsg::ExtendedData { data, ext } => {
809 let data_len = data.len();
810 total_stderr_tokens =
811 total_stderr_tokens.saturating_add(data_len / BYTES_PER_TOKEN);
812
813 if ext == 1 {
815 let data_str = String::from_utf8_lossy(&data);
816 if let Some(limit) = max_bytes {
817 let current_len = output.stderr.len();
818
819 if current_len.saturating_add(data_str.len()) > limit {
821 if !stderr_truncation_added {
822 let remaining = limit.saturating_sub(current_len);
824 let mut take: usize = 0;
825 if remaining > 0 {
826 let safe_end = data_str
828 .char_indices()
829 .map(|(i, _)| i)
830 .find(|&i| i > remaining)
831 .unwrap_or(data_str.len());
832 take = std::cmp::min(safe_end, remaining);
833 output.stderr.push_str(&data_str[..take]);
834 }
835 output.stderr_truncated = true;
836 output.stderr_total_tokens = total_stderr_tokens;
837
838 output.stderr.push_str(&format!(
840 "\n[Output truncated: {} tokens total]",
841 total_stderr_tokens
842 ));
843 output.stderr.push_str(
844 "\n[Tip: Use 'head -n 100' for first lines, 'tail -n 100' for last lines]",
845 );
846 output.stderr.push_str(
847 "\n[Tip: For large output use SFTP/SCP tools to download files]",
848 );
849
850 stderr_truncation_added = true;
851 warn!(
852 "stderr truncated: total_tokens={}, limit_tokens={}",
853 total_stderr_tokens,
854 max_bytes.map(|b| b / BYTES_PER_TOKEN).unwrap_or(0)
855 );
856
857 push_tail(&mut stderr_tail, &data_str[take..]);
858 } else {
859 push_tail(&mut stderr_tail, &data_str);
860 }
861 } else {
863 output.stderr.push_str(&data_str);
864 }
865 } else {
866 output.stderr.push_str(&data_str);
868 }
869 } else {
870 output.stdout.push_str(&String::from_utf8_lossy(&data));
872 }
873 }
874 ChannelMsg::ExitStatus { exit_status } => {
875 output.exit_code = Some(exit_status);
876 }
877 ChannelMsg::ExitSignal { signal_name, .. } => {
878 let code = match signal_name {
881 russh::Sig::HUP => 129,
882 russh::Sig::INT => 130,
883 russh::Sig::QUIT => 131,
884 russh::Sig::ILL => 132,
885 russh::Sig::ABRT => 134,
886 russh::Sig::FPE => 136,
887 russh::Sig::KILL => 137,
888 russh::Sig::USR1 => 138,
889 russh::Sig::SEGV => 139,
890 russh::Sig::PIPE => 141,
891 russh::Sig::ALRM => 142,
892 russh::Sig::TERM => 143,
893 russh::Sig::Custom(_) => 128,
894 };
895 output.exit_code = Some(code);
896 }
897 ChannelMsg::Close | ChannelMsg::Eof => {
898 }
901 _ => {
902 }
904 }
905 }
906
907 if output.stdout_total_tokens == 0 {
909 output.stdout_total_tokens = total_stdout_tokens;
910 }
911 if output.stderr_total_tokens == 0 {
912 output.stderr_total_tokens = total_stderr_tokens;
913 }
914
915 if output.stdout_truncated && !stdout_tail.is_empty() {
916 output.stdout.push('\n');
917 output.stdout.push_str(&stdout_tail);
918 }
919
920 if output.stderr_truncated && !stderr_tail.is_empty() {
921 output.stderr.push('\n');
922 output.stderr.push_str(&stderr_tail);
923 }
924
925 debug!(
928 "Command completed: exit_code={:?}, stdout_len={}, stderr_len={}, stdout_truncated={}, stderr_truncated={}",
929 output.exit_code,
930 output.stdout.len(),
931 output.stderr.len(),
932 output.stdout_truncated,
933 output.stderr_truncated
934 );
935
936 if output.exit_code.is_none() {
943 return Err(SshMcpError::connection(
944 "SSH channel closed without exit status (session may have been torn down)",
945 ));
946 }
947
948 Ok(output)
949 }
950
951 async fn abort_command(&self, command: &str) {
956 let channel = match self.open_channel().await {
958 Ok(ch) => ch,
959 Err(e) => {
960 error!(error = ?e, "Failed to open channel for abort");
961 return;
962 }
963 };
964
965 let escaped_command = escape_command_for_shell(command);
966 let abort_cmd = format!(
967 "timeout 3s pkill -f '{}' 2>/dev/null || true",
968 escaped_command
969 );
970
971 debug!(
972 "Sending abort command: pattern_len={}, abort_len={}",
973 command.len(),
974 abort_cmd.len()
975 );
976
977 if let Err(e) = channel.exec(true, abort_cmd.as_str()).await {
978 error!(error = ?e, "Failed to exec abort command");
979 return;
980 }
981
982 let abort_timeout = Duration::from_secs(5);
984 let _ = timeout(abort_timeout, async {
985 let mut channel = channel;
986 while let Some(msg) = channel.wait().await {
987 match msg {
988 ChannelMsg::Close | ChannelMsg::Eof => break,
989 _ => continue,
990 }
991 }
992 })
993 .await;
994
995 debug!("Abort command completed");
996 }
997
998 pub async fn exec_raw_streaming<R, W>(
1007 &self,
1008 command: &str,
1009 mut stdin: Option<&mut R>,
1010 mut stdout: Option<&mut W>,
1011 timeout_duration: Duration,
1012 ) -> Result<TransferRawOutput>
1013 where
1014 R: AsyncRead + Unpin,
1015 W: AsyncWrite + Unpin,
1016 {
1017 let _permit = self.acquire_command_slot().await?;
1018
1019 self.ensure_connected().await?;
1020
1021 let fut = async {
1023 let channel = self.open_channel().await?;
1024 channel
1025 .exec(true, command)
1026 .await
1027 .map_err(|e| SshMcpError::connection(format!("Failed to exec command: {e}")))?;
1028
1029 let (stdin_tx, mut stdin_rx) = tokio::sync::mpsc::channel::<Vec<u8>>(4);
1033 let (out_tx, mut out_rx) = tokio::sync::mpsc::channel::<RawStreamEvent>(8);
1034
1035 let task_guard = JoinAbortGuard::new(tokio::spawn(async move {
1036 raw_channel_task(channel, &mut stdin_rx, out_tx).await
1037 }));
1038
1039 let mut output = TransferRawOutput::default();
1040 let stderr_limit_bytes = resolve_raw_stream_stderr_limit(self.config.max_output_tokens);
1041 let mut total_stderr_bytes = 0usize;
1042 let mut stderr_truncated = false;
1043 let mut stdin_done = stdin.is_none();
1044 let mut stdin_tx: Option<tokio::sync::mpsc::Sender<Vec<u8>>> =
1045 if stdin_done { None } else { Some(stdin_tx) };
1046 let mut channel_closed = false;
1047 let mut out_rx_closed = false;
1048
1049 let mut buf = vec![0u8; 32 * 1024];
1050
1051 loop {
1052 if stdin_done && channel_closed && out_rx_closed {
1053 break;
1054 }
1055
1056 tokio::select! {
1057 read_res = async {
1058 match stdin.as_mut() {
1059 Some(r) => r.read(&mut buf).await,
1060 None => Ok(0),
1061 }
1062 }, if !stdin_done => {
1063 let n = read_res?;
1064 if n == 0 {
1065 stdin_done = true;
1066 stdin_tx = None; } else {
1068 let chunk = buf[..n].to_vec();
1069 match stdin_tx.as_mut() {
1070 Some(tx) => {
1071 tx.send(chunk).await.map_err(|_| {
1072 SshMcpError::connection("raw channel task ended while sending stdin".to_string())
1073 })?;
1074 output.stdin_bytes += n as u64;
1075 }
1076 None => {
1077 return Err(SshMcpError::connection(
1078 "raw stdin channel closed unexpectedly".to_string(),
1079 ));
1080 }
1081 }
1082 }
1083 }
1084 maybe_evt = out_rx.recv() => {
1085 match maybe_evt {
1086 Some(RawStreamEvent::Stdout(data)) => {
1087 output.stdout_bytes += data.len() as u64;
1088 if let Some(writer) = stdout.as_mut() {
1089 writer.write_all(&data).await?;
1090 }
1091 }
1092 Some(RawStreamEvent::Stderr(data)) => {
1093 total_stderr_bytes = total_stderr_bytes.saturating_add(data.len());
1094 if !stderr_truncated {
1095 stderr_truncated = append_bounded_lossy_stderr(
1096 &mut output.stderr,
1097 &data,
1098 stderr_limit_bytes,
1099 );
1100 if stderr_truncated {
1101 warn!(
1102 total_stderr_bytes,
1103 stderr_limit_bytes,
1104 "raw streaming stderr truncated"
1105 );
1106 }
1107 }
1108 }
1109 Some(RawStreamEvent::ExitStatus(code)) => {
1110 output.exit_code = Some(code);
1111 }
1112 Some(RawStreamEvent::Closed) => {
1113 channel_closed = true;
1114 }
1115 None => {
1116 out_rx_closed = true;
1117 }
1118 }
1119 }
1120 }
1121 }
1122
1123 if stderr_truncated {
1124 output.stderr.push_str(&format!(
1125 "\n[stderr truncated: {} bytes total, limit {} bytes]",
1126 total_stderr_bytes, stderr_limit_bytes
1127 ));
1128 }
1129
1130 if let Some(writer) = stdout.as_mut() {
1131 writer.flush().await?;
1132 }
1133
1134 let join_handle = match task_guard.into_handle() {
1135 Some(h) => h,
1136 None => {
1137 return Err(SshMcpError::connection(
1138 "raw channel task handle missing".to_string(),
1139 ));
1140 }
1141 };
1142
1143 match join_handle.await {
1144 Ok(Ok(())) => Ok(output),
1145 Ok(Err(e)) => Err(e),
1146 Err(e) => Err(SshMcpError::connection(format!(
1147 "raw channel task join failed: {e}"
1148 ))),
1149 }
1150 };
1151
1152 match timeout(timeout_duration, fut).await {
1153 Ok(res) => res,
1154 Err(_) => {
1155 self.invalidate_session("raw command timed out").await;
1156 Err(SshMcpError::Timeout(timeout_duration.as_millis() as u64))
1157 }
1158 }
1159 }
1160
1161 pub async fn check_process(
1175 &self,
1176 job_id: &str,
1177 tail_lines: usize,
1178 registry: &JobRegistry,
1179 spooler: &LocalLogSpooler,
1180 ) -> Result<ProcessStatus> {
1181 debug!(job_id = ?job_id, "Checking process status");
1182
1183 let job = match registry.get(job_id).await {
1184 Some(job) => job,
1185 None => match spooler.load_job_state(job_id).await {
1186 Ok(Some(recovered)) => {
1187 let shared = Arc::new(Mutex::new(recovered));
1188 registry
1189 .insert(job_id.to_string(), Arc::clone(&shared))
1190 .await;
1191 shared
1192 }
1193 Ok(None) => {
1194 return Err(SshMcpError::invalid_params(format!(
1195 "job not found: {job_id}"
1196 )));
1197 }
1198 Err(e) => {
1199 return Err(SshMcpError::invalid_params(format!(
1200 "failed to recover job state for {job_id}: {e}"
1201 )));
1202 }
1203 },
1204 };
1205
1206 let job_guard = job.lock().await;
1207 let pid = job_guard.pid;
1208 let command = job_guard.command.clone();
1209 let log_path = job_guard.log_path.clone();
1210 let status = job_guard.status;
1211 let exit_code_i32 = job_guard.exit_code;
1212 let stored_state_reason = job_guard.state_reason.clone();
1213 let elapsed_time = job_guard.elapsed_time();
1214 drop(job_guard);
1215
1216 let (running, effective_status, effective_exit_code_i32, effective_reason) = match status {
1217 JobStatus::Running => {
1218 self.ensure_connected().await?;
1219 if self.is_pid_running(pid).await? {
1220 (true, JobStatus::Running, None, None)
1221 } else if let Some(code) = exit_code_i32 {
1222 (false, job_status_from_exit_code(code), Some(code), None)
1223 } else {
1224 let (settled_status, settled_exit_code, settled_reason) =
1225 await_running_job_settle(&job).await;
1226 match settled_status {
1227 JobStatus::Running => match settled_exit_code {
1228 Some(code) => {
1229 (false, job_status_from_exit_code(code), Some(code), None)
1230 }
1231 None => (
1232 false,
1233 JobStatus::StateLost,
1234 None,
1235 Some(settled_reason.unwrap_or_else(|| {
1236 "pid_not_running_and_no_exit_status".to_string()
1237 })),
1238 ),
1239 },
1240 JobStatus::Completed | JobStatus::Failed => match settled_exit_code {
1241 Some(code) => {
1242 (false, job_status_from_exit_code(code), Some(code), None)
1243 }
1244 None => (
1245 false,
1246 JobStatus::StateLost,
1247 None,
1248 Some(settled_reason.unwrap_or_else(|| {
1249 "missing_exit_code_for_terminal_state".to_string()
1250 })),
1251 ),
1252 },
1253 JobStatus::StateLost => (
1254 false,
1255 JobStatus::StateLost,
1256 None,
1257 Some(settled_reason.unwrap_or_else(|| "state_lost".to_string())),
1258 ),
1259 }
1260 }
1261 }
1262 JobStatus::Completed | JobStatus::Failed => match exit_code_i32 {
1263 Some(code) => (false, job_status_from_exit_code(code), Some(code), None),
1264 None => (
1265 false,
1266 JobStatus::StateLost,
1267 None,
1268 Some(
1269 stored_state_reason
1270 .clone()
1271 .unwrap_or_else(|| "missing_exit_code_for_terminal_state".to_string()),
1272 ),
1273 ),
1274 },
1275 JobStatus::StateLost => (
1276 false,
1277 JobStatus::StateLost,
1278 None,
1279 Some(
1280 stored_state_reason
1281 .clone()
1282 .unwrap_or_else(|| "state_lost".to_string()),
1283 ),
1284 ),
1285 };
1286
1287 if status != effective_status
1288 || exit_code_i32 != effective_exit_code_i32
1289 || stored_state_reason != effective_reason
1290 {
1291 let mut guard = job.lock().await;
1292 match effective_status {
1293 JobStatus::Running => {
1294 guard.status = JobStatus::Running;
1295 guard.exit_code = None;
1296 guard.state_reason = None;
1297 }
1298 JobStatus::Completed | JobStatus::Failed => {
1299 if let Some(code) = effective_exit_code_i32 {
1300 guard.mark_exit(code);
1301 }
1302 }
1303 JobStatus::StateLost => {
1304 guard.mark_state_lost(
1305 effective_reason
1306 .clone()
1307 .unwrap_or_else(|| "state_lost".to_string()),
1308 );
1309 }
1310 }
1311
1312 let persisted = guard.clone();
1313 drop(guard);
1314
1315 if let Err(e) = spooler.persist_job_state(&persisted).await {
1316 warn!(job_id = ?job_id, error = ?e, "failed to persist reconciled job state");
1317 }
1318 }
1319
1320 let exit_code = if running || effective_status == JobStatus::StateLost {
1321 None
1322 } else {
1323 effective_exit_code_i32.and_then(|code| u32::try_from(code).ok())
1324 };
1325
1326 let log_exists = log_file_exists(&log_path).await?;
1327
1328 let log_tail = read_local_log_tail(&log_path, tail_lines).await?;
1329
1330 Ok(ProcessStatus {
1331 pid,
1332 state: effective_status.as_str().to_string(),
1333 running,
1334 exit_code,
1335 state_reason: effective_reason,
1336 elapsed_time,
1337 command,
1338 log_path: log_path.to_string_lossy().to_string(),
1339 log_exists,
1340 log_tail,
1341 })
1342 }
1343
1344 async fn is_pid_running(&self, pid: u32) -> Result<bool> {
1345 let cmd = format!("sh -c 'kill -0 {pid} 2>/dev/null'");
1347 let output = self.exec_command(&cmd, Duration::from_secs(5)).await?;
1348 Ok(output.exit_code == Some(0))
1349 }
1350}
1351
1352fn job_status_from_exit_code(exit_code: i32) -> JobStatus {
1353 if exit_code == 0 {
1354 JobStatus::Completed
1355 } else {
1356 JobStatus::Failed
1357 }
1358}
1359
1360async fn await_running_job_settle(
1361 job: &SharedJobState,
1362) -> (JobStatus, Option<i32>, Option<String>) {
1363 tokio::time::sleep(Duration::from_millis(150)).await;
1364 let guard = job.lock().await;
1365 (guard.status, guard.exit_code, guard.state_reason.clone())
1366}
1367
1368pub(crate) async fn read_local_log_tail(path: &Path, lines: usize) -> Result<String> {
1369 if lines == 0 {
1370 return Ok(String::new());
1371 }
1372
1373 let mut file = match open_log_read_no_symlink(path).await? {
1374 Some(f) => f,
1375 None => return Ok(String::new()),
1376 };
1377
1378 let meta = file.metadata().await?;
1379 let mut pos = meta.len();
1380
1381 const CHUNK_SIZE: u64 = 8192;
1382 const MAX_READ_BYTES: usize = 1024 * 1024;
1383
1384 let mut buf: Vec<u8> = Vec::new();
1385 let mut newlines = 0usize;
1386
1387 while pos > 0 && newlines <= lines && buf.len() < MAX_READ_BYTES {
1388 let read_len = std::cmp::min(CHUNK_SIZE, pos) as usize;
1389 pos = pos.saturating_sub(read_len as u64);
1390
1391 file.seek(std::io::SeekFrom::Start(pos)).await?;
1392
1393 let mut chunk = vec![0u8; read_len];
1394 let mut got = 0usize;
1395 while got < read_len {
1396 let n = file.read(&mut chunk[got..]).await?;
1397 if n == 0 {
1398 break;
1399 }
1400 got = got.saturating_add(n);
1401 }
1402 if got == 0 {
1403 break;
1404 }
1405 chunk.truncate(got);
1406
1407 newlines = newlines.saturating_add(chunk.iter().filter(|&&b| b == b'\n').count());
1408
1409 if chunk.len().saturating_add(buf.len()) > MAX_READ_BYTES {
1411 let allowed = MAX_READ_BYTES.saturating_sub(buf.len());
1412 chunk.truncate(allowed);
1413 }
1414 chunk.extend_from_slice(&buf);
1415 buf = chunk;
1416 }
1417
1418 let text = String::from_utf8_lossy(&buf);
1419 let all_lines: Vec<&str> = text.lines().collect();
1420 if all_lines.is_empty() {
1421 return Ok(String::new());
1422 }
1423
1424 let start = all_lines.len().saturating_sub(lines);
1425 Ok(all_lines[start..].join("\n"))
1426}
1427
1428async fn log_file_exists(path: &Path) -> Result<bool> {
1429 match tokio::fs::symlink_metadata(path).await {
1430 Ok(meta) => {
1431 if meta.file_type().is_symlink() {
1432 return Err(std::io::Error::new(
1433 std::io::ErrorKind::InvalidInput,
1434 "log path is a symlink (refusing to follow it)",
1435 )
1436 .into());
1437 }
1438 Ok(meta.is_file())
1439 }
1440 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
1441 Err(e) => Err(e.into()),
1442 }
1443}
1444
1445async fn open_log_read_no_symlink(path: &Path) -> Result<Option<tokio::fs::File>> {
1446 match tokio::fs::symlink_metadata(path).await {
1447 Ok(meta) => {
1448 if meta.file_type().is_symlink() {
1449 return Err(std::io::Error::new(
1450 std::io::ErrorKind::InvalidInput,
1451 "log path is a symlink (refusing to follow it)",
1452 )
1453 .into());
1454 }
1455 if !meta.is_file() {
1456 return Err(std::io::Error::new(
1457 std::io::ErrorKind::InvalidInput,
1458 "log path is not a regular file",
1459 )
1460 .into());
1461 }
1462 }
1463 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1464 return Ok(None);
1465 }
1466 Err(e) => return Err(e.into()),
1467 }
1468
1469 let mut opts = tokio::fs::OpenOptions::new();
1470 opts.read(true);
1471
1472 #[cfg(unix)]
1473 {
1474 opts.custom_flags(O_NOFOLLOW_FLAG);
1475 }
1476
1477 match opts.open(path).await {
1478 Ok(f) => {
1479 let meta = f.metadata().await?;
1481 if !meta.is_file() {
1482 return Err(std::io::Error::new(
1483 std::io::ErrorKind::InvalidInput,
1484 "log path is not a regular file",
1485 )
1486 .into());
1487 }
1488 Ok(Some(f))
1489 }
1490 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
1491 Err(e) => {
1492 if let Ok(meta) = tokio::fs::symlink_metadata(path).await
1493 && meta.file_type().is_symlink()
1494 {
1495 return Err(std::io::Error::new(
1496 std::io::ErrorKind::InvalidInput,
1497 "log path is a symlink (refusing to follow it)",
1498 )
1499 .into());
1500 }
1501 Err(e.into())
1502 }
1503 }
1504}
1505
1506#[derive(Debug)]
1507enum RawStreamEvent {
1508 Stdout(Vec<u8>),
1509 Stderr(Vec<u8>),
1510 ExitStatus(u32),
1511 Closed,
1512}
1513
1514struct JoinAbortGuard<T> {
1515 handle: Option<tokio::task::JoinHandle<T>>,
1516}
1517
1518impl<T> JoinAbortGuard<T> {
1519 fn new(handle: tokio::task::JoinHandle<T>) -> Self {
1520 Self {
1521 handle: Some(handle),
1522 }
1523 }
1524
1525 fn into_handle(mut self) -> Option<tokio::task::JoinHandle<T>> {
1526 self.handle.take()
1527 }
1528}
1529
1530impl<T> Drop for JoinAbortGuard<T> {
1531 fn drop(&mut self) {
1532 if let Some(handle) = &self.handle {
1533 handle.abort();
1534 }
1535 }
1536}
1537
1538async fn raw_channel_task(
1539 mut channel: russh::Channel<russh::client::Msg>,
1540 stdin_rx: &mut tokio::sync::mpsc::Receiver<Vec<u8>>,
1541 out_tx: tokio::sync::mpsc::Sender<RawStreamEvent>,
1542) -> Result<()> {
1543 let mut stdin_closed = false;
1544 let mut sent_closed = false;
1545 loop {
1546 tokio::select! {
1547 maybe_chunk = stdin_rx.recv(), if !stdin_closed => {
1548 match maybe_chunk {
1549 Some(chunk) => {
1550 channel.data(chunk.as_slice()).await.map_err(|e| {
1551 SshMcpError::connection(format!("Failed to send stdin: {e}"))
1552 })?;
1553 }
1554 None => {
1555 stdin_closed = true;
1556 let _ = channel.eof().await;
1557 }
1558 }
1559 }
1560 maybe_msg = channel.wait() => {
1561 match maybe_msg {
1562 Some(msg) => {
1563 let send_evt = |evt: RawStreamEvent| async {
1564 out_tx.send(evt).await.map_err(|_| ())
1565 };
1566
1567 match msg {
1568 ChannelMsg::Data { data } => {
1569 let bytes = data.as_ref().to_vec();
1570 if send_evt(RawStreamEvent::Stdout(bytes)).await.is_err() {
1571 return Ok(());
1572 }
1573 }
1574 ChannelMsg::ExtendedData { data, ext } => {
1575 let bytes = data.as_ref().to_vec();
1576 let evt = if ext == 1 {
1577 RawStreamEvent::Stderr(bytes)
1578 } else {
1579 RawStreamEvent::Stdout(bytes)
1580 };
1581 if send_evt(evt).await.is_err() {
1582 return Ok(());
1583 }
1584 }
1585 ChannelMsg::ExitStatus { exit_status }
1586 if send_evt(RawStreamEvent::ExitStatus(exit_status)).await.is_err() =>
1587 {
1588 return Ok(());
1589 }
1590 ChannelMsg::ExitStatus { .. } => {}
1591 ChannelMsg::ExitSignal { signal_name, .. } => {
1592 let code = match signal_name {
1595 russh::Sig::HUP => 129,
1596 russh::Sig::INT => 130,
1597 russh::Sig::QUIT => 131,
1598 russh::Sig::ILL => 132,
1599 russh::Sig::ABRT => 134,
1600 russh::Sig::FPE => 136,
1601 russh::Sig::KILL => 137,
1602 russh::Sig::USR1 => 138,
1603 russh::Sig::SEGV => 139,
1604 russh::Sig::PIPE => 141,
1605 russh::Sig::ALRM => 142,
1606 russh::Sig::TERM => 143,
1607 russh::Sig::Custom(_) => 128,
1608 };
1609 if send_evt(RawStreamEvent::ExitStatus(code)).await.is_err() {
1610 return Ok(());
1611 }
1612 }
1613 ChannelMsg::Close | ChannelMsg::Eof if !sent_closed => {
1614 sent_closed = true;
1616 let _ = send_evt(RawStreamEvent::Closed).await;
1617 }
1618 ChannelMsg::Close | ChannelMsg::Eof => {}
1619 _ => {}
1620 }
1621 }
1622 None => {
1623 if !sent_closed {
1625 let _ = out_tx.send(RawStreamEvent::Closed).await;
1626 }
1627 break;
1628 }
1629 }
1630 }
1631 }
1632 }
1633
1634 Ok(())
1635}
1636
1637#[cfg(test)]
1638mod tests {
1639 use super::*;
1640
1641 #[test]
1642 fn test_command_output_success() {
1643 let output = CommandOutput {
1644 stdout: "hello".to_string(),
1645 stderr: String::new(),
1646 exit_code: Some(0),
1647 ..Default::default()
1648 };
1649 assert!(output.success());
1650 }
1651
1652 #[test]
1653 fn test_command_output_failure() {
1654 let output = CommandOutput {
1655 stdout: String::new(),
1656 stderr: "error".to_string(),
1657 exit_code: Some(1),
1658 ..Default::default()
1659 };
1660 assert!(!output.success());
1661 }
1662
1663 #[test]
1664 fn test_command_output_no_exit_code() {
1665 let output = CommandOutput {
1666 stdout: "hello".to_string(),
1667 stderr: String::new(),
1668 exit_code: None,
1669 ..Default::default()
1670 };
1671 assert!(!output.success());
1673 }
1674
1675 #[test]
1676 fn test_command_output_combined() {
1677 let output = CommandOutput {
1678 stdout: "stdout".to_string(),
1679 stderr: "stderr".to_string(),
1680 exit_code: Some(0),
1681 ..Default::default()
1682 };
1683 assert_eq!(output.combined_output(), "stdout\nstderr");
1684 }
1685
1686 #[test]
1687 fn test_command_output_combined_only_stdout() {
1688 let output = CommandOutput {
1689 stdout: "stdout".to_string(),
1690 stderr: String::new(),
1691 exit_code: Some(0),
1692 ..Default::default()
1693 };
1694 assert_eq!(output.combined_output(), "stdout");
1695 }
1696
1697 #[test]
1698 fn test_command_output_combined_only_stderr() {
1699 let output = CommandOutput {
1700 stdout: String::new(),
1701 stderr: "stderr".to_string(),
1702 exit_code: Some(1),
1703 ..Default::default()
1704 };
1705 assert_eq!(output.combined_output(), "stderr");
1706 }
1707
1708 #[test]
1709 fn test_wrap_command_with_timeout() {
1710 let cmd = wrap_command_with_timeout("sleep 10", 2.0);
1711 assert!(cmd.contains("timeout -k 2s 2s"));
1712 assert!(cmd.contains("sh -lc")); assert!(cmd.contains("sleep 10"));
1714 }
1715
1716 #[test]
1717 fn test_wrap_command_with_timeout_zero_duration() {
1718 let cmd = wrap_command_with_timeout("echo test", 0.0);
1720 assert!(cmd.contains("timeout -k 2s 0s"));
1721 assert!(cmd.contains("sh -lc"));
1722 assert!(cmd.contains("echo test"));
1723 }
1724
1725 #[test]
1726 fn test_wrap_command_with_timeout_fractional() {
1727 let cmd = wrap_command_with_timeout("sleep 1", 0.5);
1729 assert!(cmd.contains("timeout -k 2s 0.5s"));
1730 assert!(cmd.contains("sh -lc"));
1731 assert!(cmd.contains("sleep 1"));
1732 }
1733
1734 #[test]
1735 fn test_wrap_command_with_timeout_complex_command() {
1736 let cmd = wrap_command_with_timeout("echo 'hello world'", 10.0);
1737 assert!(cmd.contains("timeout -k 2s 10s"));
1738 assert!(cmd.contains("sh -lc"));
1739 assert!(cmd.contains("echo"));
1740 }
1741
1742 #[test]
1743 fn test_wrap_command_with_timeout_with_single_quotes() {
1744 let cmd = wrap_command_with_timeout("echo 'hello'", 10.0);
1745 assert!(cmd.contains("timeout -k 2s 10s"));
1746 assert!(cmd.contains("sh -lc"));
1747 assert!(cmd.contains("'\"'\"'"));
1749 }
1750
1751 #[test]
1752 fn test_wrap_command_for_channel_exec_non_login_shell() {
1753 let cmd = wrap_command_for_channel_exec("echo hello");
1754 assert_eq!(cmd, "sh -c 'echo hello'");
1755 }
1756
1757 #[test]
1758 fn test_wrap_command_for_channel_exec_timeout_wrapper_payload() {
1759 let timeout_wrapped = wrap_command_with_timeout("echo hello", 1.0);
1760 assert_eq!(timeout_wrapped, "timeout -k 2s 1s sh -lc 'echo hello'");
1761
1762 let cmd = wrap_command_for_channel_exec(&timeout_wrapped);
1763 assert_eq!(
1764 cmd,
1765 "sh -c 'timeout -k 2s 1s sh -lc '\"'\"'echo hello'\"'\"''"
1766 );
1767 }
1768
1769 #[test]
1770 fn test_wrap_command_for_channel_exec_timeout_wrapper_payload_with_single_quotes() {
1771 let timeout_wrapped = wrap_command_with_timeout("echo 'hello'", 1.0);
1772 assert_eq!(
1773 timeout_wrapped,
1774 "timeout -k 2s 1s sh -lc 'echo '\"'\"'hello'\"'\"''"
1775 );
1776
1777 let cmd = wrap_command_for_channel_exec(&timeout_wrapped);
1778 assert!(cmd.starts_with("sh -c '"));
1779 assert!(cmd.ends_with('\''));
1780
1781 let inner = &cmd[7..cmd.len() - 1];
1782 let unescaped_once = inner.replace("'\"'\"'", "'");
1783 assert_eq!(unescaped_once, timeout_wrapped);
1784 assert!(cmd.contains("hello"));
1785 }
1786
1787 #[test]
1788 fn test_resolve_raw_stream_stderr_limit_uses_token_limit() {
1789 assert_eq!(
1790 resolve_raw_stream_stderr_limit(Some(12_000)),
1791 12_000 * RAW_STREAM_BYTES_PER_TOKEN
1792 );
1793 }
1794
1795 #[test]
1796 fn test_resolve_raw_stream_stderr_limit_none_uses_hard_cap() {
1797 assert_eq!(
1798 resolve_raw_stream_stderr_limit(None),
1799 RAW_STREAM_STDERR_HARD_MAX_BYTES
1800 );
1801 }
1802
1803 #[test]
1804 fn test_resolve_raw_stream_stderr_limit_applies_hard_cap() {
1805 assert_eq!(
1806 resolve_raw_stream_stderr_limit(Some(RAW_STREAM_STDERR_HARD_MAX_BYTES)),
1807 RAW_STREAM_STDERR_HARD_MAX_BYTES
1808 );
1809 }
1810
1811 #[test]
1812 fn test_append_bounded_lossy_stderr_no_truncation() {
1813 let mut stderr = String::new();
1814 let truncated = append_bounded_lossy_stderr(&mut stderr, b"hello", 16);
1815 assert!(!truncated);
1816 assert_eq!(stderr, "hello");
1817 }
1818
1819 #[test]
1820 fn test_append_bounded_lossy_stderr_truncates_at_utf8_boundary() {
1821 let mut stderr = String::new();
1822 let truncated = append_bounded_lossy_stderr(&mut stderr, "абв".as_bytes(), 3);
1823 assert!(truncated);
1824 assert_eq!(stderr, "а");
1825 }
1826}