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