1use crate::mcp::McpSession;
25use serde_json::json;
26use std::path::PathBuf;
27use std::sync::Arc;
28use std::time::Duration;
29use tokio::sync::Mutex;
30
31#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct CommandOutput {
34 pub stdout: String,
35 pub stderr: String,
36 pub exit_code: i32,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum PathState {
44 Missing,
45 Exists,
46 Unknown(String),
47}
48
49#[async_trait::async_trait]
57pub trait Substrate: Send + Sync {
58 fn name(&self) -> &str;
60
61 async fn run_command(&self, cmd: &str, timeout_s: Option<f64>)
63 -> Result<CommandOutput, String>;
64
65 async fn read_text(&self, path: &str) -> Result<String, String>;
67
68 async fn write_text(&self, path: &str, content: &str) -> Result<(), String>;
70
71 async fn path_state(&self, _path: &str) -> PathState {
76 PathState::Unknown("substrate does not expose a path-presence check".into())
77 }
78
79 async fn read_bytes(
81 &self,
82 path: &str,
83 offset: Option<u64>,
84 len: Option<u64>,
85 ) -> Result<Vec<u8>, String>;
86
87 async fn write_bytes(&self, path: &str, bytes: &[u8]) -> Result<(), String>;
89
90 fn is_local(&self) -> bool {
95 false
96 }
97
98 fn display_path(&self, path: &str) -> String {
104 path.to_string()
105 }
106
107 async fn pty_start(&self, _cmd: &str) -> Result<String, String> {
110 Err("pty not supported by this substrate".into())
111 }
112 async fn pty_input(&self, _id: &str, _data: &str) -> Result<(), String> {
113 Err("pty not supported by this substrate".into())
114 }
115 async fn pty_read(&self, _id: &str) -> Result<String, String> {
116 Err("pty not supported by this substrate".into())
117 }
118 async fn pty_resize(&self, _id: &str, _rows: u16, _cols: u16) -> Result<(), String> {
119 Err("pty not supported by this substrate".into())
120 }
121 async fn pty_kill(&self, _id: &str) -> Result<(), String> {
122 Err("pty not supported by this substrate".into())
123 }
124}
125
126#[derive(Debug, Default, Clone)]
136pub struct LocalSubstrate;
137
138impl LocalSubstrate {
139 pub fn new() -> Self {
140 Self
141 }
142
143 pub fn resolve_path(path: &str) -> Result<PathBuf, String> {
146 let candidate = PathBuf::from(path);
147 if candidate.is_absolute() {
148 Ok(candidate)
149 } else {
150 std::env::current_dir()
151 .map(|cwd| cwd.join(candidate))
152 .map_err(|e| format!("failed to resolve working directory: {e}"))
153 }
154 }
155}
156
157#[async_trait::async_trait]
158impl Substrate for LocalSubstrate {
159 fn name(&self) -> &str {
160 "local"
161 }
162
163 fn is_local(&self) -> bool {
164 true
165 }
166
167 async fn run_command(
168 &self,
169 cmd: &str,
170 timeout_s: Option<f64>,
171 ) -> Result<CommandOutput, String> {
172 use tokio::process::Command;
173 let mut command = if cfg!(target_os = "windows") {
174 let mut c = Command::new("cmd");
175 crate::win_env::push_shell_command(c.as_std_mut(), cmd);
180 if let Some(path) = crate::win_env::cmd_path_override() {
183 c.env("PATH", path);
184 }
185 c
186 } else {
187 let mut c = Command::new("sh");
188 c.arg("-c").arg(cmd);
189 c
190 };
191
192 let fut = command.output();
193 let output = match timeout_s {
194 Some(secs) if secs > 0.0 => {
195 let dur = std::time::Duration::from_secs_f64(secs);
196 match tokio::time::timeout(dur, fut).await {
197 Ok(res) => res.map_err(|e| format!("failed to run command: {e}"))?,
198 Err(_) => return Err(format!("command timed out after {secs}s")),
199 }
200 }
201 _ => fut
202 .await
203 .map_err(|e| format!("failed to run command: {e}"))?,
204 };
205
206 Ok(CommandOutput {
207 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
208 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
209 exit_code: output.status.code().unwrap_or(-1),
210 })
211 }
212
213 async fn read_text(&self, path: &str) -> Result<String, String> {
214 let full = Self::resolve_path(path)?;
215 std::fs::read_to_string(&full)
216 .map_err(|e| format!("failed to read file '{}': {e}", full.display()))
217 }
218
219 async fn write_text(&self, path: &str, content: &str) -> Result<(), String> {
220 let full = Self::resolve_path(path)?;
221 if let Some(parent) = full.parent() {
222 std::fs::create_dir_all(parent)
223 .map_err(|e| format!("failed to create parent dir '{}': {e}", parent.display()))?;
224 }
225 std::fs::write(&full, content)
226 .map_err(|e| format!("failed to write file '{}': {e}", full.display()))
227 }
228
229 async fn path_state(&self, path: &str) -> PathState {
230 let full = match Self::resolve_path(path) {
231 Ok(path) => path,
232 Err(error) => return PathState::Unknown(error),
233 };
234 match std::fs::symlink_metadata(&full) {
235 Ok(_) => PathState::Exists,
236 Err(error) if error.kind() == std::io::ErrorKind::NotFound => PathState::Missing,
237 Err(error) => {
238 PathState::Unknown(format!("failed to inspect '{}': {error}", full.display()))
239 }
240 }
241 }
242
243 async fn read_bytes(
244 &self,
245 path: &str,
246 offset: Option<u64>,
247 len: Option<u64>,
248 ) -> Result<Vec<u8>, String> {
249 let full = Self::resolve_path(path)?;
250 let bytes = std::fs::read(&full)
251 .map_err(|e| format!("failed to read file '{}': {e}", full.display()))?;
252 let start = offset.unwrap_or(0) as usize;
253 if start >= bytes.len() {
254 return Ok(Vec::new());
255 }
256 let end = match len {
257 Some(l) => (start + l as usize).min(bytes.len()),
258 None => bytes.len(),
259 };
260 Ok(bytes[start..end].to_vec())
261 }
262
263 async fn write_bytes(&self, path: &str, bytes: &[u8]) -> Result<(), String> {
264 let full = Self::resolve_path(path)?;
265 if let Some(parent) = full.parent() {
266 std::fs::create_dir_all(parent)
267 .map_err(|e| format!("failed to create parent dir '{}': {e}", parent.display()))?;
268 }
269 std::fs::write(&full, bytes)
270 .map_err(|e| format!("failed to write file '{}': {e}", full.display()))
271 }
272
273 fn display_path(&self, path: &str) -> String {
274 match Self::resolve_path(path) {
275 Ok(p) => p.display().to_string(),
276 Err(_) => path.to_string(),
277 }
278 }
279}
280
281pub const SUBSTRATE_TRANSPORT_ERR_PREFIX: &str = "[substrate-transport] ";
309
310fn looks_like_transport_error(msg: &str) -> bool {
314 const SIGS: &[&str] = &[
315 "fetch failed",
316 "connection",
317 "econnrefused",
318 "econnreset",
319 "transport",
320 "timed out",
321 "timeout",
322 "socket hang",
323 "broken pipe",
324 "stream closed",
325 "channel closed",
326 "unexpected eof",
327 ];
328 let m = msg.to_ascii_lowercase();
329 SIGS.iter().any(|s| m.contains(s))
330}
331
332fn shell_quote(s: &str) -> String {
334 format!("'{}'", s.replace('\'', "'\\''"))
335}
336
337fn is_retryable_transport_error(msg: &str) -> bool {
353 const RETRYABLE: &[&str] = &[
354 "fetch failed",
355 "econnrefused",
356 "econnreset",
357 "connection refused",
358 "connection reset",
359 "broken pipe",
360 "stream closed",
361 "channel closed",
362 "unexpected eof",
363 "closed the connection",
364 ];
365 let m = msg.to_ascii_lowercase();
366 RETRYABLE.iter().any(|s| m.contains(s))
367}
368
369const SUBSTRATE_RETRY_ATTEMPTS: u32 = 3;
371
372pub struct McpSubstrate {
373 session: Arc<Mutex<dyn McpSession>>,
374 name: String,
375}
376
377impl McpSubstrate {
378 pub fn new(session: Arc<Mutex<dyn McpSession>>, name: impl Into<String>) -> Self {
381 Self {
382 session,
383 name: name.into(),
384 }
385 }
386
387 async fn call(&self, tool: &str, args: serde_json::Value) -> Result<serde_json::Value, String> {
388 self.call_timed(tool, args, None).await
389 }
390
391 async fn call_timed(
395 &self,
396 tool: &str,
397 args: serde_json::Value,
398 timeout: Option<Duration>,
399 ) -> Result<serde_json::Value, String> {
400 let mut last_err = String::new();
401 for attempt in 1..=SUBSTRATE_RETRY_ATTEMPTS {
402 let result = {
404 let mut guard = self.session.lock().await;
405 guard
406 .call_tool_with_timeout(tool, args.clone(), timeout)
407 .await
408 };
409 match result {
410 Ok(v) => return Ok(v),
411 Err(e)
415 if is_retryable_transport_error(&e) && attempt < SUBSTRATE_RETRY_ATTEMPTS =>
416 {
417 last_err = e;
418 tokio::time::sleep(Duration::from_millis(300 * attempt as u64)).await;
419 continue;
420 }
421 Err(e) => {
425 return Err(if looks_like_transport_error(&e) {
426 format!("{SUBSTRATE_TRANSPORT_ERR_PREFIX}{e}")
427 } else {
428 e
429 });
430 }
431 }
432 }
433 Err(format!("{SUBSTRATE_TRANSPORT_ERR_PREFIX}{last_err}"))
435 }
436}
437
438#[async_trait::async_trait]
439impl Substrate for McpSubstrate {
440 fn name(&self) -> &str {
441 &self.name
442 }
443
444 async fn run_command(
445 &self,
446 cmd: &str,
447 timeout_s: Option<f64>,
448 ) -> Result<CommandOutput, String> {
449 let mut args = json!({ "command": cmd });
450 if let Some(secs) = timeout_s {
451 args["timeout"] = json!(secs);
452 }
453 let await_timeout = timeout_s.map(|s| Duration::from_secs_f64(s + 30.0));
457 let result = self.call_timed("run_command", args, await_timeout).await?;
458
459 match &result {
462 serde_json::Value::String(s) => Ok(CommandOutput {
463 stdout: s.clone(),
464 stderr: String::new(),
465 exit_code: 0,
466 }),
467 serde_json::Value::Object(_) => Ok(CommandOutput {
468 stdout: result
469 .get("stdout")
470 .and_then(|v| v.as_str())
471 .unwrap_or("")
472 .to_string(),
473 stderr: result
474 .get("stderr")
475 .and_then(|v| v.as_str())
476 .unwrap_or("")
477 .to_string(),
478 exit_code: result
479 .get("exit_code")
480 .and_then(|v| v.as_i64())
481 .unwrap_or(0) as i32,
482 }),
483 other => Ok(CommandOutput {
484 stdout: other.to_string(),
485 stderr: String::new(),
486 exit_code: 0,
487 }),
488 }
489 }
490
491 async fn read_text(&self, path: &str) -> Result<String, String> {
492 let result = self.call("read_text", json!({ "path": path })).await?;
493 match result {
494 serde_json::Value::String(s) => Ok(s),
495 other => Ok(other
496 .get("content")
497 .and_then(|v| v.as_str())
498 .map(|s| s.to_string())
499 .unwrap_or_else(|| other.to_string())),
500 }
501 }
502
503 async fn write_text(&self, path: &str, content: &str) -> Result<(), String> {
504 self.call("write_text", json!({ "path": path, "content": content }))
505 .await
506 .map(|_| ())
507 }
508
509 async fn path_state(&self, path: &str) -> PathState {
510 let quoted = shell_quote(path);
511 match self
512 .run_command(
513 &format!("if [ -e {quoted} ] || [ -L {quoted} ]; then exit 0; else exit 1; fi"),
514 Some(5.0),
515 )
516 .await
517 {
518 Ok(output) if output.exit_code == 0 => PathState::Exists,
519 Ok(output) if output.exit_code == 1 => PathState::Missing,
520 Ok(output) => PathState::Unknown(format!(
521 "presence check exited {}: {}",
522 output.exit_code,
523 output.stderr.trim()
524 )),
525 Err(error) => PathState::Unknown(error),
526 }
527 }
528
529 async fn read_bytes(
530 &self,
531 path: &str,
532 offset: Option<u64>,
533 len: Option<u64>,
534 ) -> Result<Vec<u8>, String> {
535 let mut args = json!({ "path": path });
536 if let Some(o) = offset {
537 args["offset"] = json!(o);
538 }
539 if let Some(l) = len {
540 args["length"] = json!(l);
541 }
542 let result = self.call("read_bytes", args).await?;
543 let b64 = match &result {
544 serde_json::Value::String(s) => s.clone(),
545 other => other
546 .get("data")
547 .or_else(|| other.get("base64"))
548 .and_then(|v| v.as_str())
549 .map(|s| s.to_string())
550 .ok_or_else(|| "read_bytes: no base64 data in response".to_string())?,
551 };
552 base64_decode(b64.trim()).map_err(|e| format!("read_bytes: invalid base64: {e}"))
553 }
554
555 async fn write_bytes(&self, path: &str, bytes: &[u8]) -> Result<(), String> {
556 let b64 = base64_encode(bytes);
557 self.call("write_bytes", json!({ "path": path, "content_b64": b64 }))
558 .await
559 .map(|_| ())
560 }
561
562 async fn pty_start(&self, cmd: &str) -> Result<String, String> {
563 let result = self.call("pty_start", json!({ "command": cmd })).await?;
564 if let Some(pid) = result.get("pid").and_then(|v| v.as_i64()) {
569 return Ok(pid.to_string());
570 }
571 let text = match &result {
572 serde_json::Value::String(s) => s.clone(),
573 other => other.to_string(),
574 };
575 text.lines()
576 .find_map(|l| {
577 l.trim_start()
578 .strip_prefix("pid:")
579 .map(|r| r.trim().to_string())
580 })
581 .filter(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
582 .ok_or_else(|| format!("pty_start: no pid in response: {text}"))
583 }
584
585 async fn pty_input(&self, id: &str, data: &str) -> Result<(), String> {
586 self.call("pty_input", json!({ "pid": pty_pid(id)?, "data": data }))
587 .await
588 .map(|_| ())
589 }
590
591 async fn pty_read(&self, id: &str) -> Result<String, String> {
592 let result = self
593 .call("pty_read", json!({ "pid": pty_pid(id)? }))
594 .await?;
595 match result {
596 serde_json::Value::String(s) => Ok(s),
597 other => Ok(other
598 .get("data")
599 .and_then(|v| v.as_str())
600 .map(|s| s.to_string())
601 .unwrap_or_else(|| other.to_string())),
602 }
603 }
604
605 async fn pty_resize(&self, id: &str, rows: u16, cols: u16) -> Result<(), String> {
606 self.call(
607 "pty_resize",
608 json!({ "pid": pty_pid(id)?, "rows": rows, "cols": cols }),
609 )
610 .await
611 .map(|_| ())
612 }
613
614 async fn pty_kill(&self, id: &str) -> Result<(), String> {
615 self.call("pty_kill", json!({ "pid": pty_pid(id)? }))
616 .await
617 .map(|_| ())
618 }
619}
620
621fn pty_pid(id: &str) -> Result<i64, String> {
624 id.trim()
625 .parse::<i64>()
626 .map_err(|_| format!("invalid pty id (expected numeric pid): {id:?}"))
627}
628
629const B64_ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
636
637fn base64_encode(bytes: &[u8]) -> String {
638 let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
639 for chunk in bytes.chunks(3) {
640 let b0 = chunk[0] as u32;
641 let b1 = *chunk.get(1).unwrap_or(&0) as u32;
642 let b2 = *chunk.get(2).unwrap_or(&0) as u32;
643 let n = (b0 << 16) | (b1 << 8) | b2;
644 out.push(B64_ALPHABET[((n >> 18) & 0x3f) as usize] as char);
645 out.push(B64_ALPHABET[((n >> 12) & 0x3f) as usize] as char);
646 if chunk.len() > 1 {
647 out.push(B64_ALPHABET[((n >> 6) & 0x3f) as usize] as char);
648 } else {
649 out.push('=');
650 }
651 if chunk.len() > 2 {
652 out.push(B64_ALPHABET[(n & 0x3f) as usize] as char);
653 } else {
654 out.push('=');
655 }
656 }
657 out
658}
659
660fn base64_decode(s: &str) -> Result<Vec<u8>, String> {
661 fn val(c: u8) -> Result<u32, String> {
662 match c {
663 b'A'..=b'Z' => Ok((c - b'A') as u32),
664 b'a'..=b'z' => Ok((c - b'a' + 26) as u32),
665 b'0'..=b'9' => Ok((c - b'0' + 52) as u32),
666 b'+' => Ok(62),
667 b'/' => Ok(63),
668 _ => Err(format!("invalid base64 char {:?}", c as char)),
669 }
670 }
671 let clean: Vec<u8> = s
672 .bytes()
673 .filter(|b| !b.is_ascii_whitespace() && *b != b'=')
674 .collect();
675 let mut out = Vec::with_capacity(clean.len() / 4 * 3);
676 for chunk in clean.chunks(4) {
677 let mut n = 0u32;
678 let mut bits = 0;
679 for &c in chunk {
680 n = (n << 6) | val(c)?;
681 bits += 6;
682 }
683 n <<= 24 - bits;
685 let nbytes = bits / 8;
686 for i in 0..nbytes {
687 out.push(((n >> (16 - i * 8)) & 0xff) as u8);
688 }
689 }
690 Ok(out)
691}
692
693#[cfg(test)]
694mod tests {
695 use super::*;
696
697 #[test]
698 fn base64_roundtrip() {
699 for case in [
700 &b""[..],
701 b"f",
702 b"fo",
703 b"foo",
704 b"foob",
705 b"fooba",
706 b"foobar",
707 &[0u8, 1, 2, 255],
708 ] {
709 let enc = base64_encode(case);
710 let dec = base64_decode(&enc).unwrap();
711 assert_eq!(dec, case, "roundtrip failed for {case:?} (enc={enc})");
712 }
713 assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
714 assert_eq!(base64_decode("Zm9vYmFy").unwrap(), b"foobar");
715 }
716
717 #[tokio::test]
718 async fn local_read_write_roundtrip() {
719 let dir = std::env::temp_dir().join(format!("car-substrate-{}", uuid_like()));
720 std::fs::create_dir_all(&dir).unwrap();
721 let path = dir.join("hello.txt");
722 let path_str = path.to_string_lossy().to_string();
723
724 let sub = LocalSubstrate::new();
725 assert_eq!(sub.name(), "local");
726
727 sub.write_text(&path_str, "hello world").await.unwrap();
728 let read = sub.read_text(&path_str).await.unwrap();
729 assert_eq!(read, "hello world");
730
731 let bytes = sub.read_bytes(&path_str, None, None).await.unwrap();
732 assert_eq!(bytes, b"hello world");
733
734 let window = sub.read_bytes(&path_str, Some(6), Some(5)).await.unwrap();
736 assert_eq!(window, b"world");
737
738 sub.write_bytes(&path_str, b"\x00\x01\x02").await.unwrap();
739 let raw = sub.read_bytes(&path_str, None, None).await.unwrap();
740 assert_eq!(raw, vec![0u8, 1, 2]);
741
742 std::fs::remove_dir_all(&dir).ok();
743 }
744
745 #[cfg(unix)]
746 #[tokio::test]
747 async fn local_path_state_treats_a_dangling_symlink_as_existing() {
748 let dir = std::env::temp_dir().join(format!("car-substrate-state-{}", uuid_like()));
749 std::fs::create_dir_all(&dir).unwrap();
750 let missing = dir.join("missing.txt");
751 let dangling = dir.join("dangling.txt");
752 std::os::unix::fs::symlink(&missing, &dangling).unwrap();
753
754 let sub = LocalSubstrate::new();
755 assert_eq!(
756 sub.path_state(&missing.to_string_lossy()).await,
757 PathState::Missing
758 );
759 assert_eq!(
760 sub.path_state(&dangling.to_string_lossy()).await,
761 PathState::Exists
762 );
763
764 std::fs::remove_dir_all(&dir).ok();
765 }
766
767 #[tokio::test]
768 async fn local_run_command_captures_output() {
769 let sub = LocalSubstrate::new();
770 let out = sub.run_command("echo hi", None).await.unwrap();
771 assert_eq!(out.stdout.trim(), "hi");
772 assert_eq!(out.exit_code, 0);
773 }
774
775 #[tokio::test]
776 async fn local_pty_defaults_to_unsupported() {
777 let sub = LocalSubstrate::new();
778 assert!(sub.pty_start("sh").await.is_err());
779 }
780
781 use crate::mcp::McpToolInfo;
784 use serde_json::Value;
785 use std::sync::Mutex as StdMutex;
786
787 struct MockSession {
788 name: String,
789 calls: Arc<StdMutex<Vec<(String, Value)>>>,
791 files: Arc<StdMutex<std::collections::HashMap<String, String>>>,
792 }
793
794 #[async_trait::async_trait]
795 impl McpSession for MockSession {
796 async fn list_tools(&mut self) -> Result<Vec<McpToolInfo>, String> {
797 Ok(vec![])
798 }
799 async fn call_tool(&mut self, name: &str, arguments: Value) -> Result<Value, String> {
800 self.calls
801 .lock()
802 .unwrap()
803 .push((name.to_string(), arguments.clone()));
804 match name {
805 "write_text" => {
806 let p = arguments["path"].as_str().unwrap().to_string();
807 let c = arguments["content"].as_str().unwrap().to_string();
808 self.files.lock().unwrap().insert(p, c);
809 Ok(Value::String("ok".into()))
810 }
811 "read_text" => {
812 let p = arguments["path"].as_str().unwrap();
813 let c = self
814 .files
815 .lock()
816 .unwrap()
817 .get(p)
818 .cloned()
819 .ok_or_else(|| "not found".to_string())?;
820 Ok(Value::String(c))
822 }
823 "run_command" => Ok(json!({
824 "stdout": "from-vm",
825 "stderr": "",
826 "exit_code": 0
827 })),
828 "read_bytes" => Ok(Value::String("aGk=".into())),
831 "write_bytes" => Ok(Value::String("ok".into())),
832 "pty_start" => Ok(Value::String("pid: 4242\ncols: 80\nrows: 24".into())),
834 "pty_read" => Ok(Value::String("pty-out".into())),
835 "pty_input" | "pty_resize" | "pty_kill" => Ok(Value::String("ok".into())),
836 _ => Err(format!("unknown tool {name}")),
837 }
838 }
839 fn name(&self) -> &str {
840 &self.name
841 }
842 }
843
844 #[tokio::test]
845 async fn mcp_substrate_routes_to_session() {
846 let calls = Arc::new(StdMutex::new(Vec::new()));
847 let files = Arc::new(StdMutex::new(std::collections::HashMap::new()));
848 let mock = MockSession {
849 name: "vm".into(),
850 calls: calls.clone(),
851 files: files.clone(),
852 };
853 let session: Arc<Mutex<dyn McpSession>> = Arc::new(Mutex::new(mock));
854 let sub = McpSubstrate::new(session, "vm");
855
856 assert_eq!(sub.name(), "vm");
857
858 sub.write_text("/tmp/a.txt", "vm-content").await.unwrap();
859 let read = sub.read_text("/tmp/a.txt").await.unwrap();
860 assert_eq!(read, "vm-content");
861
862 let out = sub.run_command("ls", None).await.unwrap();
863 assert_eq!(out.stdout, "from-vm");
864 assert_eq!(out.exit_code, 0);
865
866 let recorded = calls.lock().unwrap();
868 let names: Vec<&str> = recorded.iter().map(|(n, _)| n.as_str()).collect();
869 assert_eq!(names, vec!["write_text", "read_text", "run_command"]);
870 }
871
872 #[tokio::test]
880 async fn mcp_substrate_wire_args_match_canonical_bridge() {
881 let calls = Arc::new(StdMutex::new(Vec::new()));
882 let files = Arc::new(StdMutex::new(std::collections::HashMap::new()));
883 let mock = MockSession {
884 name: "vm".into(),
885 calls: calls.clone(),
886 files: files.clone(),
887 };
888 let session: Arc<Mutex<dyn McpSession>> = Arc::new(Mutex::new(mock));
889 let sub = McpSubstrate::new(session, "vm");
890
891 sub.run_command("ls", Some(5.0)).await.unwrap();
892 let bytes = sub.read_bytes("/f", Some(2), Some(4)).await.unwrap();
893 assert_eq!(bytes, b"hi"); sub.write_bytes("/f", b"hi").await.unwrap();
895 let pid = sub.pty_start("bash").await.unwrap();
897 assert_eq!(pid, "4242");
898 sub.pty_input(&pid, "echo\n").await.unwrap();
899 let out = sub.pty_read(&pid).await.unwrap();
900 assert_eq!(out, "pty-out");
901 sub.pty_resize(&pid, 40, 100).await.unwrap();
902 sub.pty_kill(&pid).await.unwrap();
903
904 let recorded = calls.lock().unwrap();
905 let by_tool =
906 |t: &str| -> Value { recorded.iter().find(|(n, _)| n == t).unwrap().1.clone() };
907
908 assert_eq!(
909 by_tool("run_command"),
910 json!({"command": "ls", "timeout": 5.0})
911 );
912 assert_eq!(
913 by_tool("read_bytes"),
914 json!({"path": "/f", "offset": 2, "length": 4})
915 );
916 let wb = by_tool("write_bytes");
918 assert!(
919 wb.get("content_b64").is_some(),
920 "write_bytes must use content_b64: {wb}"
921 );
922 assert!(
923 wb.get("data").is_none(),
924 "write_bytes must not use legacy `data`: {wb}"
925 );
926 assert_eq!(wb["path"], json!("/f"));
927 assert_eq!(by_tool("pty_start"), json!({"command": "bash"}));
928 assert_eq!(by_tool("pty_input"), json!({"pid": 4242, "data": "echo\n"}));
930 assert_eq!(by_tool("pty_read"), json!({"pid": 4242}));
931 assert_eq!(
932 by_tool("pty_resize"),
933 json!({"pid": 4242, "rows": 40, "cols": 100})
934 );
935 assert_eq!(by_tool("pty_kill"), json!({"pid": 4242}));
936 }
937
938 fn uuid_like() -> String {
939 use std::time::{SystemTime, UNIX_EPOCH};
940 let nanos = SystemTime::now()
941 .duration_since(UNIX_EPOCH)
942 .unwrap()
943 .as_nanos();
944 format!("{nanos}")
945 }
946
947 struct TimeoutSpySession {
951 last: Arc<StdMutex<Option<Option<Duration>>>>,
952 }
953
954 #[async_trait::async_trait]
955 impl McpSession for TimeoutSpySession {
956 async fn list_tools(&mut self) -> Result<Vec<McpToolInfo>, String> {
957 Ok(vec![])
958 }
959 async fn call_tool(&mut self, _n: &str, _a: Value) -> Result<Value, String> {
960 *self.last.lock().unwrap() = Some(None);
963 Ok(json!({"stdout": "x", "stderr": "", "exit_code": 0}))
964 }
965 async fn call_tool_with_timeout(
966 &mut self,
967 _n: &str,
968 _a: Value,
969 t: Option<Duration>,
970 ) -> Result<Value, String> {
971 *self.last.lock().unwrap() = Some(t);
972 Ok(json!({"stdout": "x", "stderr": "", "exit_code": 0}))
973 }
974 fn name(&self) -> &str {
975 "vm"
976 }
977 }
978
979 #[tokio::test]
980 async fn run_command_threads_its_timeout_else_backstop() {
981 let last = Arc::new(StdMutex::new(None));
982 let sub = McpSubstrate::new(
983 Arc::new(Mutex::new(TimeoutSpySession { last: last.clone() })),
984 "vm",
985 );
986
987 sub.run_command("echo hi", Some(120.0)).await.unwrap();
989 assert_eq!(
990 *last.lock().unwrap(),
991 Some(Some(Duration::from_secs_f64(150.0))),
992 "run_command must thread its own timeout (+margin) to the session",
993 );
994
995 sub.run_command("echo hi", None).await.unwrap();
997 assert_eq!(*last.lock().unwrap(), Some(None));
998
999 let _ = sub.read_text("/p").await;
1001 assert_eq!(*last.lock().unwrap(), Some(None));
1002 }
1003
1004 struct FlakySession {
1006 err: String,
1007 succeed_at: u32,
1008 attempts: Arc<StdMutex<u32>>,
1009 }
1010
1011 #[async_trait::async_trait]
1012 impl McpSession for FlakySession {
1013 async fn list_tools(&mut self) -> Result<Vec<McpToolInfo>, String> {
1014 Ok(vec![])
1015 }
1016 async fn call_tool(&mut self, n: &str, a: Value) -> Result<Value, String> {
1017 self.call_tool_with_timeout(n, a, None).await
1018 }
1019 async fn call_tool_with_timeout(
1020 &mut self,
1021 _n: &str,
1022 _a: Value,
1023 _t: Option<Duration>,
1024 ) -> Result<Value, String> {
1025 let n = {
1026 let mut a = self.attempts.lock().unwrap();
1027 *a += 1;
1028 *a
1029 };
1030 if n >= self.succeed_at {
1031 Ok(json!({"stdout": "ok", "stderr": "", "exit_code": 0}))
1032 } else {
1033 Err(self.err.clone())
1034 }
1035 }
1036 fn name(&self) -> &str {
1037 "vm"
1038 }
1039 }
1040
1041 fn flaky(err: &str, succeed_at: u32) -> (McpSubstrate, Arc<StdMutex<u32>>) {
1042 let attempts = Arc::new(StdMutex::new(0));
1043 let sub = McpSubstrate::new(
1044 Arc::new(Mutex::new(FlakySession {
1045 err: err.to_string(),
1046 succeed_at,
1047 attempts: attempts.clone(),
1048 })),
1049 "vm",
1050 );
1051 (sub, attempts)
1052 }
1053
1054 #[tokio::test]
1055 async fn transient_connection_drop_is_retried_and_recovers() {
1056 let (sub, attempts) = flaky("fetch failed", 2);
1058 let out = sub.run_command("echo hi", None).await.unwrap();
1059 assert_eq!(out.stdout, "ok");
1060 assert_eq!(*attempts.lock().unwrap(), 2, "retried once then succeeded");
1061 }
1062
1063 #[tokio::test]
1064 async fn persistent_connection_drop_exhausts_and_is_tagged() {
1065 let (sub, attempts) = flaky("fetch failed", u32::MAX);
1066 let err = sub.run_command("echo hi", None).await.unwrap_err();
1067 assert!(
1068 err.starts_with(SUBSTRATE_TRANSPORT_ERR_PREFIX),
1069 "tagged: {err}"
1070 );
1071 assert_eq!(*attempts.lock().unwrap(), SUBSTRATE_RETRY_ATTEMPTS);
1072 }
1073
1074 #[tokio::test]
1075 async fn timeout_is_not_retried_but_is_tagged() {
1076 let (sub, attempts) = flaky("MCP request 'tools/call' timed out", u32::MAX);
1078 let err = sub.run_command("echo hi", None).await.unwrap_err();
1079 assert!(
1080 err.starts_with(SUBSTRATE_TRANSPORT_ERR_PREFIX),
1081 "tagged: {err}"
1082 );
1083 assert_eq!(*attempts.lock().unwrap(), 1, "timeout must not be retried");
1084 }
1085
1086 #[tokio::test]
1087 async fn task_error_is_not_retried_or_tagged() {
1088 let (sub, attempts) = flaky("no such file", u32::MAX);
1090 let err = sub.run_command("cat /nope", None).await.unwrap_err();
1091 assert!(
1092 !err.starts_with(SUBSTRATE_TRANSPORT_ERR_PREFIX),
1093 "not tagged: {err}"
1094 );
1095 assert_eq!(
1096 *attempts.lock().unwrap(),
1097 1,
1098 "task errors must not be retried"
1099 );
1100 }
1101}