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 crate::win_env::clear_git_repository_redirects(command.as_std_mut());
194
195 let fut = command.output();
196 let output = match timeout_s {
197 Some(secs) if secs > 0.0 => {
198 let dur = std::time::Duration::from_secs_f64(secs);
199 match tokio::time::timeout(dur, fut).await {
200 Ok(res) => res.map_err(|e| format!("failed to run command: {e}"))?,
201 Err(_) => return Err(format!("command timed out after {secs}s")),
202 }
203 }
204 _ => fut
205 .await
206 .map_err(|e| format!("failed to run command: {e}"))?,
207 };
208
209 Ok(CommandOutput {
210 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
211 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
212 exit_code: output.status.code().unwrap_or(-1),
213 })
214 }
215
216 async fn read_text(&self, path: &str) -> Result<String, String> {
217 let full = Self::resolve_path(path)?;
218 std::fs::read_to_string(&full)
219 .map_err(|e| format!("failed to read file '{}': {e}", full.display()))
220 }
221
222 async fn write_text(&self, path: &str, content: &str) -> Result<(), String> {
223 let full = Self::resolve_path(path)?;
224 if let Some(parent) = full.parent() {
225 std::fs::create_dir_all(parent)
226 .map_err(|e| format!("failed to create parent dir '{}': {e}", parent.display()))?;
227 }
228 std::fs::write(&full, content)
229 .map_err(|e| format!("failed to write file '{}': {e}", full.display()))
230 }
231
232 async fn path_state(&self, path: &str) -> PathState {
233 let full = match Self::resolve_path(path) {
234 Ok(path) => path,
235 Err(error) => return PathState::Unknown(error),
236 };
237 match std::fs::symlink_metadata(&full) {
238 Ok(_) => PathState::Exists,
239 Err(error) if error.kind() == std::io::ErrorKind::NotFound => PathState::Missing,
240 Err(error) => {
241 PathState::Unknown(format!("failed to inspect '{}': {error}", full.display()))
242 }
243 }
244 }
245
246 async fn read_bytes(
247 &self,
248 path: &str,
249 offset: Option<u64>,
250 len: Option<u64>,
251 ) -> Result<Vec<u8>, String> {
252 let full = Self::resolve_path(path)?;
253 let bytes = std::fs::read(&full)
254 .map_err(|e| format!("failed to read file '{}': {e}", full.display()))?;
255 let start = offset.unwrap_or(0) as usize;
256 if start >= bytes.len() {
257 return Ok(Vec::new());
258 }
259 let end = match len {
260 Some(l) => (start + l as usize).min(bytes.len()),
261 None => bytes.len(),
262 };
263 Ok(bytes[start..end].to_vec())
264 }
265
266 async fn write_bytes(&self, path: &str, bytes: &[u8]) -> Result<(), String> {
267 let full = Self::resolve_path(path)?;
268 if let Some(parent) = full.parent() {
269 std::fs::create_dir_all(parent)
270 .map_err(|e| format!("failed to create parent dir '{}': {e}", parent.display()))?;
271 }
272 std::fs::write(&full, bytes)
273 .map_err(|e| format!("failed to write file '{}': {e}", full.display()))
274 }
275
276 fn display_path(&self, path: &str) -> String {
277 match Self::resolve_path(path) {
278 Ok(p) => p.display().to_string(),
279 Err(_) => path.to_string(),
280 }
281 }
282}
283
284pub const SUBSTRATE_TRANSPORT_ERR_PREFIX: &str = "[substrate-transport] ";
312
313fn looks_like_transport_error(msg: &str) -> bool {
317 const SIGS: &[&str] = &[
318 "fetch failed",
319 "connection",
320 "econnrefused",
321 "econnreset",
322 "transport",
323 "timed out",
324 "timeout",
325 "socket hang",
326 "broken pipe",
327 "stream closed",
328 "channel closed",
329 "unexpected eof",
330 ];
331 let m = msg.to_ascii_lowercase();
332 SIGS.iter().any(|s| m.contains(s))
333}
334
335fn shell_quote(s: &str) -> String {
337 format!("'{}'", s.replace('\'', "'\\''"))
338}
339
340fn is_retryable_transport_error(msg: &str) -> bool {
356 const RETRYABLE: &[&str] = &[
357 "fetch failed",
358 "econnrefused",
359 "econnreset",
360 "connection refused",
361 "connection reset",
362 "broken pipe",
363 "stream closed",
364 "channel closed",
365 "unexpected eof",
366 "closed the connection",
367 ];
368 let m = msg.to_ascii_lowercase();
369 RETRYABLE.iter().any(|s| m.contains(s))
370}
371
372const SUBSTRATE_RETRY_ATTEMPTS: u32 = 3;
374
375pub struct McpSubstrate {
376 session: Arc<Mutex<dyn McpSession>>,
377 name: String,
378}
379
380impl McpSubstrate {
381 pub fn new(session: Arc<Mutex<dyn McpSession>>, name: impl Into<String>) -> Self {
384 Self {
385 session,
386 name: name.into(),
387 }
388 }
389
390 async fn call(&self, tool: &str, args: serde_json::Value) -> Result<serde_json::Value, String> {
391 self.call_timed(tool, args, None).await
392 }
393
394 async fn call_timed(
398 &self,
399 tool: &str,
400 args: serde_json::Value,
401 timeout: Option<Duration>,
402 ) -> Result<serde_json::Value, String> {
403 let mut last_err = String::new();
404 for attempt in 1..=SUBSTRATE_RETRY_ATTEMPTS {
405 let result = {
407 let mut guard = self.session.lock().await;
408 guard
409 .call_tool_with_timeout(tool, args.clone(), timeout)
410 .await
411 };
412 match result {
413 Ok(v) => return Ok(v),
414 Err(e)
418 if is_retryable_transport_error(&e) && attempt < SUBSTRATE_RETRY_ATTEMPTS =>
419 {
420 last_err = e;
421 tokio::time::sleep(Duration::from_millis(300 * attempt as u64)).await;
422 continue;
423 }
424 Err(e) => {
428 return Err(if looks_like_transport_error(&e) {
429 format!("{SUBSTRATE_TRANSPORT_ERR_PREFIX}{e}")
430 } else {
431 e
432 });
433 }
434 }
435 }
436 Err(format!("{SUBSTRATE_TRANSPORT_ERR_PREFIX}{last_err}"))
438 }
439}
440
441#[async_trait::async_trait]
442impl Substrate for McpSubstrate {
443 fn name(&self) -> &str {
444 &self.name
445 }
446
447 async fn run_command(
448 &self,
449 cmd: &str,
450 timeout_s: Option<f64>,
451 ) -> Result<CommandOutput, String> {
452 let mut args = json!({ "command": cmd });
453 if let Some(secs) = timeout_s {
454 args["timeout"] = json!(secs);
455 }
456 let await_timeout = timeout_s.map(|s| Duration::from_secs_f64(s + 30.0));
460 let result = self.call_timed("run_command", args, await_timeout).await?;
461
462 match &result {
465 serde_json::Value::String(s) => Ok(CommandOutput {
466 stdout: s.clone(),
467 stderr: String::new(),
468 exit_code: 0,
469 }),
470 serde_json::Value::Object(_) => Ok(CommandOutput {
471 stdout: result
472 .get("stdout")
473 .and_then(|v| v.as_str())
474 .unwrap_or("")
475 .to_string(),
476 stderr: result
477 .get("stderr")
478 .and_then(|v| v.as_str())
479 .unwrap_or("")
480 .to_string(),
481 exit_code: result
482 .get("exit_code")
483 .and_then(|v| v.as_i64())
484 .unwrap_or(0) as i32,
485 }),
486 other => Ok(CommandOutput {
487 stdout: other.to_string(),
488 stderr: String::new(),
489 exit_code: 0,
490 }),
491 }
492 }
493
494 async fn read_text(&self, path: &str) -> Result<String, String> {
495 let result = self.call("read_text", json!({ "path": path })).await?;
496 match result {
497 serde_json::Value::String(s) => Ok(s),
498 other => Ok(other
499 .get("content")
500 .and_then(|v| v.as_str())
501 .map(|s| s.to_string())
502 .unwrap_or_else(|| other.to_string())),
503 }
504 }
505
506 async fn write_text(&self, path: &str, content: &str) -> Result<(), String> {
507 self.call("write_text", json!({ "path": path, "content": content }))
508 .await
509 .map(|_| ())
510 }
511
512 async fn path_state(&self, path: &str) -> PathState {
513 let quoted = shell_quote(path);
514 match self
515 .run_command(
516 &format!("if [ -e {quoted} ] || [ -L {quoted} ]; then exit 0; else exit 1; fi"),
517 Some(5.0),
518 )
519 .await
520 {
521 Ok(output) if output.exit_code == 0 => PathState::Exists,
522 Ok(output) if output.exit_code == 1 => PathState::Missing,
523 Ok(output) => PathState::Unknown(format!(
524 "presence check exited {}: {}",
525 output.exit_code,
526 output.stderr.trim()
527 )),
528 Err(error) => PathState::Unknown(error),
529 }
530 }
531
532 async fn read_bytes(
533 &self,
534 path: &str,
535 offset: Option<u64>,
536 len: Option<u64>,
537 ) -> Result<Vec<u8>, String> {
538 let mut args = json!({ "path": path });
539 if let Some(o) = offset {
540 args["offset"] = json!(o);
541 }
542 if let Some(l) = len {
543 args["length"] = json!(l);
544 }
545 let result = self.call("read_bytes", args).await?;
546 let b64 = match &result {
547 serde_json::Value::String(s) => s.clone(),
548 other => other
549 .get("data")
550 .or_else(|| other.get("base64"))
551 .and_then(|v| v.as_str())
552 .map(|s| s.to_string())
553 .ok_or_else(|| "read_bytes: no base64 data in response".to_string())?,
554 };
555 base64_decode(b64.trim()).map_err(|e| format!("read_bytes: invalid base64: {e}"))
556 }
557
558 async fn write_bytes(&self, path: &str, bytes: &[u8]) -> Result<(), String> {
559 let b64 = base64_encode(bytes);
560 self.call("write_bytes", json!({ "path": path, "content_b64": b64 }))
561 .await
562 .map(|_| ())
563 }
564
565 async fn pty_start(&self, cmd: &str) -> Result<String, String> {
566 let result = self.call("pty_start", json!({ "command": cmd })).await?;
567 if let Some(pid) = result.get("pid").and_then(|v| v.as_i64()) {
572 return Ok(pid.to_string());
573 }
574 let text = match &result {
575 serde_json::Value::String(s) => s.clone(),
576 other => other.to_string(),
577 };
578 text.lines()
579 .find_map(|l| {
580 l.trim_start()
581 .strip_prefix("pid:")
582 .map(|r| r.trim().to_string())
583 })
584 .filter(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
585 .ok_or_else(|| format!("pty_start: no pid in response: {text}"))
586 }
587
588 async fn pty_input(&self, id: &str, data: &str) -> Result<(), String> {
589 self.call("pty_input", json!({ "pid": pty_pid(id)?, "data": data }))
590 .await
591 .map(|_| ())
592 }
593
594 async fn pty_read(&self, id: &str) -> Result<String, String> {
595 let result = self
596 .call("pty_read", json!({ "pid": pty_pid(id)? }))
597 .await?;
598 match result {
599 serde_json::Value::String(s) => Ok(s),
600 other => Ok(other
601 .get("data")
602 .and_then(|v| v.as_str())
603 .map(|s| s.to_string())
604 .unwrap_or_else(|| other.to_string())),
605 }
606 }
607
608 async fn pty_resize(&self, id: &str, rows: u16, cols: u16) -> Result<(), String> {
609 self.call(
610 "pty_resize",
611 json!({ "pid": pty_pid(id)?, "rows": rows, "cols": cols }),
612 )
613 .await
614 .map(|_| ())
615 }
616
617 async fn pty_kill(&self, id: &str) -> Result<(), String> {
618 self.call("pty_kill", json!({ "pid": pty_pid(id)? }))
619 .await
620 .map(|_| ())
621 }
622}
623
624fn pty_pid(id: &str) -> Result<i64, String> {
627 id.trim()
628 .parse::<i64>()
629 .map_err(|_| format!("invalid pty id (expected numeric pid): {id:?}"))
630}
631
632const B64_ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
639
640fn base64_encode(bytes: &[u8]) -> String {
641 let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
642 for chunk in bytes.chunks(3) {
643 let b0 = chunk[0] as u32;
644 let b1 = *chunk.get(1).unwrap_or(&0) as u32;
645 let b2 = *chunk.get(2).unwrap_or(&0) as u32;
646 let n = (b0 << 16) | (b1 << 8) | b2;
647 out.push(B64_ALPHABET[((n >> 18) & 0x3f) as usize] as char);
648 out.push(B64_ALPHABET[((n >> 12) & 0x3f) as usize] as char);
649 if chunk.len() > 1 {
650 out.push(B64_ALPHABET[((n >> 6) & 0x3f) as usize] as char);
651 } else {
652 out.push('=');
653 }
654 if chunk.len() > 2 {
655 out.push(B64_ALPHABET[(n & 0x3f) as usize] as char);
656 } else {
657 out.push('=');
658 }
659 }
660 out
661}
662
663fn base64_decode(s: &str) -> Result<Vec<u8>, String> {
664 fn val(c: u8) -> Result<u32, String> {
665 match c {
666 b'A'..=b'Z' => Ok((c - b'A') as u32),
667 b'a'..=b'z' => Ok((c - b'a' + 26) as u32),
668 b'0'..=b'9' => Ok((c - b'0' + 52) as u32),
669 b'+' => Ok(62),
670 b'/' => Ok(63),
671 _ => Err(format!("invalid base64 char {:?}", c as char)),
672 }
673 }
674 let clean: Vec<u8> = s
675 .bytes()
676 .filter(|b| !b.is_ascii_whitespace() && *b != b'=')
677 .collect();
678 let mut out = Vec::with_capacity(clean.len() / 4 * 3);
679 for chunk in clean.chunks(4) {
680 let mut n = 0u32;
681 let mut bits = 0;
682 for &c in chunk {
683 n = (n << 6) | val(c)?;
684 bits += 6;
685 }
686 n <<= 24 - bits;
688 let nbytes = bits / 8;
689 for i in 0..nbytes {
690 out.push(((n >> (16 - i * 8)) & 0xff) as u8);
691 }
692 }
693 Ok(out)
694}
695
696#[cfg(test)]
697mod tests {
698 use super::*;
699
700 #[test]
701 fn base64_roundtrip() {
702 for case in [
703 &b""[..],
704 b"f",
705 b"fo",
706 b"foo",
707 b"foob",
708 b"fooba",
709 b"foobar",
710 &[0u8, 1, 2, 255],
711 ] {
712 let enc = base64_encode(case);
713 let dec = base64_decode(&enc).unwrap();
714 assert_eq!(dec, case, "roundtrip failed for {case:?} (enc={enc})");
715 }
716 assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
717 assert_eq!(base64_decode("Zm9vYmFy").unwrap(), b"foobar");
718 }
719
720 #[tokio::test]
721 async fn local_read_write_roundtrip() {
722 let dir = std::env::temp_dir().join(format!("car-substrate-{}", uuid_like()));
723 std::fs::create_dir_all(&dir).unwrap();
724 let path = dir.join("hello.txt");
725 let path_str = path.to_string_lossy().to_string();
726
727 let sub = LocalSubstrate::new();
728 assert_eq!(sub.name(), "local");
729
730 sub.write_text(&path_str, "hello world").await.unwrap();
731 let read = sub.read_text(&path_str).await.unwrap();
732 assert_eq!(read, "hello world");
733
734 let bytes = sub.read_bytes(&path_str, None, None).await.unwrap();
735 assert_eq!(bytes, b"hello world");
736
737 let window = sub.read_bytes(&path_str, Some(6), Some(5)).await.unwrap();
739 assert_eq!(window, b"world");
740
741 sub.write_bytes(&path_str, b"\x00\x01\x02").await.unwrap();
742 let raw = sub.read_bytes(&path_str, None, None).await.unwrap();
743 assert_eq!(raw, vec![0u8, 1, 2]);
744
745 std::fs::remove_dir_all(&dir).ok();
746 }
747
748 #[cfg(unix)]
749 #[tokio::test]
750 async fn local_path_state_treats_a_dangling_symlink_as_existing() {
751 let dir = std::env::temp_dir().join(format!("car-substrate-state-{}", uuid_like()));
752 std::fs::create_dir_all(&dir).unwrap();
753 let missing = dir.join("missing.txt");
754 let dangling = dir.join("dangling.txt");
755 std::os::unix::fs::symlink(&missing, &dangling).unwrap();
756
757 let sub = LocalSubstrate::new();
758 assert_eq!(
759 sub.path_state(&missing.to_string_lossy()).await,
760 PathState::Missing
761 );
762 assert_eq!(
763 sub.path_state(&dangling.to_string_lossy()).await,
764 PathState::Exists
765 );
766
767 std::fs::remove_dir_all(&dir).ok();
768 }
769
770 #[tokio::test]
771 async fn local_run_command_captures_output() {
772 let sub = LocalSubstrate::new();
773 let out = sub.run_command("echo hi", None).await.unwrap();
774 assert_eq!(out.stdout.trim(), "hi");
775 assert_eq!(out.exit_code, 0);
776 }
777
778 #[tokio::test]
779 async fn local_pty_defaults_to_unsupported() {
780 let sub = LocalSubstrate::new();
781 assert!(sub.pty_start("sh").await.is_err());
782 }
783
784 use crate::mcp::McpToolInfo;
787 use serde_json::Value;
788 use std::sync::Mutex as StdMutex;
789
790 struct MockSession {
791 name: String,
792 calls: Arc<StdMutex<Vec<(String, Value)>>>,
794 files: Arc<StdMutex<std::collections::HashMap<String, String>>>,
795 }
796
797 #[async_trait::async_trait]
798 impl McpSession for MockSession {
799 async fn list_tools(&mut self) -> Result<Vec<McpToolInfo>, String> {
800 Ok(vec![])
801 }
802 async fn call_tool(&mut self, name: &str, arguments: Value) -> Result<Value, String> {
803 self.calls
804 .lock()
805 .unwrap()
806 .push((name.to_string(), arguments.clone()));
807 match name {
808 "write_text" => {
809 let p = arguments["path"].as_str().unwrap().to_string();
810 let c = arguments["content"].as_str().unwrap().to_string();
811 self.files.lock().unwrap().insert(p, c);
812 Ok(Value::String("ok".into()))
813 }
814 "read_text" => {
815 let p = arguments["path"].as_str().unwrap();
816 let c = self
817 .files
818 .lock()
819 .unwrap()
820 .get(p)
821 .cloned()
822 .ok_or_else(|| "not found".to_string())?;
823 Ok(Value::String(c))
825 }
826 "run_command" => Ok(json!({
827 "stdout": "from-vm",
828 "stderr": "",
829 "exit_code": 0
830 })),
831 "read_bytes" => Ok(Value::String("aGk=".into())),
834 "write_bytes" => Ok(Value::String("ok".into())),
835 "pty_start" => Ok(Value::String("pid: 4242\ncols: 80\nrows: 24".into())),
837 "pty_read" => Ok(Value::String("pty-out".into())),
838 "pty_input" | "pty_resize" | "pty_kill" => Ok(Value::String("ok".into())),
839 _ => Err(format!("unknown tool {name}")),
840 }
841 }
842 fn name(&self) -> &str {
843 &self.name
844 }
845 }
846
847 #[tokio::test]
848 async fn mcp_substrate_routes_to_session() {
849 let calls = Arc::new(StdMutex::new(Vec::new()));
850 let files = Arc::new(StdMutex::new(std::collections::HashMap::new()));
851 let mock = MockSession {
852 name: "vm".into(),
853 calls: calls.clone(),
854 files: files.clone(),
855 };
856 let session: Arc<Mutex<dyn McpSession>> = Arc::new(Mutex::new(mock));
857 let sub = McpSubstrate::new(session, "vm");
858
859 assert_eq!(sub.name(), "vm");
860
861 sub.write_text("/tmp/a.txt", "vm-content").await.unwrap();
862 let read = sub.read_text("/tmp/a.txt").await.unwrap();
863 assert_eq!(read, "vm-content");
864
865 let out = sub.run_command("ls", None).await.unwrap();
866 assert_eq!(out.stdout, "from-vm");
867 assert_eq!(out.exit_code, 0);
868
869 let recorded = calls.lock().unwrap();
871 let names: Vec<&str> = recorded.iter().map(|(n, _)| n.as_str()).collect();
872 assert_eq!(names, vec!["write_text", "read_text", "run_command"]);
873 }
874
875 #[tokio::test]
883 async fn mcp_substrate_wire_args_match_canonical_bridge() {
884 let calls = Arc::new(StdMutex::new(Vec::new()));
885 let files = Arc::new(StdMutex::new(std::collections::HashMap::new()));
886 let mock = MockSession {
887 name: "vm".into(),
888 calls: calls.clone(),
889 files: files.clone(),
890 };
891 let session: Arc<Mutex<dyn McpSession>> = Arc::new(Mutex::new(mock));
892 let sub = McpSubstrate::new(session, "vm");
893
894 sub.run_command("ls", Some(5.0)).await.unwrap();
895 let bytes = sub.read_bytes("/f", Some(2), Some(4)).await.unwrap();
896 assert_eq!(bytes, b"hi"); sub.write_bytes("/f", b"hi").await.unwrap();
898 let pid = sub.pty_start("bash").await.unwrap();
900 assert_eq!(pid, "4242");
901 sub.pty_input(&pid, "echo\n").await.unwrap();
902 let out = sub.pty_read(&pid).await.unwrap();
903 assert_eq!(out, "pty-out");
904 sub.pty_resize(&pid, 40, 100).await.unwrap();
905 sub.pty_kill(&pid).await.unwrap();
906
907 let recorded = calls.lock().unwrap();
908 let by_tool =
909 |t: &str| -> Value { recorded.iter().find(|(n, _)| n == t).unwrap().1.clone() };
910
911 assert_eq!(
912 by_tool("run_command"),
913 json!({"command": "ls", "timeout": 5.0})
914 );
915 assert_eq!(
916 by_tool("read_bytes"),
917 json!({"path": "/f", "offset": 2, "length": 4})
918 );
919 let wb = by_tool("write_bytes");
921 assert!(
922 wb.get("content_b64").is_some(),
923 "write_bytes must use content_b64: {wb}"
924 );
925 assert!(
926 wb.get("data").is_none(),
927 "write_bytes must not use legacy `data`: {wb}"
928 );
929 assert_eq!(wb["path"], json!("/f"));
930 assert_eq!(by_tool("pty_start"), json!({"command": "bash"}));
931 assert_eq!(by_tool("pty_input"), json!({"pid": 4242, "data": "echo\n"}));
933 assert_eq!(by_tool("pty_read"), json!({"pid": 4242}));
934 assert_eq!(
935 by_tool("pty_resize"),
936 json!({"pid": 4242, "rows": 40, "cols": 100})
937 );
938 assert_eq!(by_tool("pty_kill"), json!({"pid": 4242}));
939 }
940
941 #[tokio::test]
946 async fn every_bridge_call_with_a_command_or_url_is_in_the_policy_tool_map() {
947 let calls = Arc::new(StdMutex::new(Vec::new()));
948 let mock = MockSession {
949 name: "vm".into(),
950 calls: calls.clone(),
951 files: Arc::new(StdMutex::new(std::collections::HashMap::new())),
952 };
953 let session: Arc<Mutex<dyn McpSession>> = Arc::new(Mutex::new(mock));
954 let sub = McpSubstrate::new(session, "vm");
955
956 sub.write_text("/f", "x").await.unwrap();
957 sub.read_text("/f").await.unwrap();
958 sub.run_command("ls", Some(5.0)).await.unwrap();
959 sub.read_bytes("/f", None, None).await.unwrap();
960 sub.write_bytes("/f", b"hi").await.unwrap();
961 let pid = sub.pty_start("bash").await.unwrap();
962 sub.pty_input(&pid, "echo\n").await.unwrap();
963 sub.pty_read(&pid).await.unwrap();
964 sub.pty_resize(&pid, 40, 100).await.unwrap();
965 sub.pty_kill(&pid).await.unwrap();
966
967 let recorded = calls.lock().unwrap();
968 let carrying: Vec<&str> = recorded
969 .iter()
970 .filter(|(_, args)| ["command", "url"].iter().any(|k| args[*k].is_string()))
971 .map(|(name, _)| name.as_str())
972 .collect();
973 let missing: Vec<&&str> = carrying
974 .iter()
975 .filter(|name| !car_policy::tool_gate::is_accounted_for(name))
976 .collect();
977 assert!(
978 missing.is_empty(),
979 "add these to car_policy::tool_gate::TOOL_MAP, or to UNMAPPED_CAR_TOOLS \
980 with a reason: {missing:?}"
981 );
982 for name in ["run_command", "pty_start"] {
984 assert!(carrying.contains(&name), "{carrying:?}");
985 }
986 }
987
988 fn uuid_like() -> String {
989 use std::time::{SystemTime, UNIX_EPOCH};
990 let nanos = SystemTime::now()
991 .duration_since(UNIX_EPOCH)
992 .unwrap()
993 .as_nanos();
994 format!("{nanos}")
995 }
996
997 struct TimeoutSpySession {
1001 last: Arc<StdMutex<Option<Option<Duration>>>>,
1002 }
1003
1004 #[async_trait::async_trait]
1005 impl McpSession for TimeoutSpySession {
1006 async fn list_tools(&mut self) -> Result<Vec<McpToolInfo>, String> {
1007 Ok(vec![])
1008 }
1009 async fn call_tool(&mut self, _n: &str, _a: Value) -> Result<Value, String> {
1010 *self.last.lock().unwrap() = Some(None);
1013 Ok(json!({"stdout": "x", "stderr": "", "exit_code": 0}))
1014 }
1015 async fn call_tool_with_timeout(
1016 &mut self,
1017 _n: &str,
1018 _a: Value,
1019 t: Option<Duration>,
1020 ) -> Result<Value, String> {
1021 *self.last.lock().unwrap() = Some(t);
1022 Ok(json!({"stdout": "x", "stderr": "", "exit_code": 0}))
1023 }
1024 fn name(&self) -> &str {
1025 "vm"
1026 }
1027 }
1028
1029 #[tokio::test]
1030 async fn run_command_threads_its_timeout_else_backstop() {
1031 let last = Arc::new(StdMutex::new(None));
1032 let sub = McpSubstrate::new(
1033 Arc::new(Mutex::new(TimeoutSpySession { last: last.clone() })),
1034 "vm",
1035 );
1036
1037 sub.run_command("echo hi", Some(120.0)).await.unwrap();
1039 assert_eq!(
1040 *last.lock().unwrap(),
1041 Some(Some(Duration::from_secs_f64(150.0))),
1042 "run_command must thread its own timeout (+margin) to the session",
1043 );
1044
1045 sub.run_command("echo hi", None).await.unwrap();
1047 assert_eq!(*last.lock().unwrap(), Some(None));
1048
1049 let _ = sub.read_text("/p").await;
1051 assert_eq!(*last.lock().unwrap(), Some(None));
1052 }
1053
1054 struct FlakySession {
1056 err: String,
1057 succeed_at: u32,
1058 attempts: Arc<StdMutex<u32>>,
1059 }
1060
1061 #[async_trait::async_trait]
1062 impl McpSession for FlakySession {
1063 async fn list_tools(&mut self) -> Result<Vec<McpToolInfo>, String> {
1064 Ok(vec![])
1065 }
1066 async fn call_tool(&mut self, n: &str, a: Value) -> Result<Value, String> {
1067 self.call_tool_with_timeout(n, a, None).await
1068 }
1069 async fn call_tool_with_timeout(
1070 &mut self,
1071 _n: &str,
1072 _a: Value,
1073 _t: Option<Duration>,
1074 ) -> Result<Value, String> {
1075 let n = {
1076 let mut a = self.attempts.lock().unwrap();
1077 *a += 1;
1078 *a
1079 };
1080 if n >= self.succeed_at {
1081 Ok(json!({"stdout": "ok", "stderr": "", "exit_code": 0}))
1082 } else {
1083 Err(self.err.clone())
1084 }
1085 }
1086 fn name(&self) -> &str {
1087 "vm"
1088 }
1089 }
1090
1091 fn flaky(err: &str, succeed_at: u32) -> (McpSubstrate, Arc<StdMutex<u32>>) {
1092 let attempts = Arc::new(StdMutex::new(0));
1093 let sub = McpSubstrate::new(
1094 Arc::new(Mutex::new(FlakySession {
1095 err: err.to_string(),
1096 succeed_at,
1097 attempts: attempts.clone(),
1098 })),
1099 "vm",
1100 );
1101 (sub, attempts)
1102 }
1103
1104 #[tokio::test]
1105 async fn transient_connection_drop_is_retried_and_recovers() {
1106 let (sub, attempts) = flaky("fetch failed", 2);
1108 let out = sub.run_command("echo hi", None).await.unwrap();
1109 assert_eq!(out.stdout, "ok");
1110 assert_eq!(*attempts.lock().unwrap(), 2, "retried once then succeeded");
1111 }
1112
1113 #[tokio::test]
1114 async fn persistent_connection_drop_exhausts_and_is_tagged() {
1115 let (sub, attempts) = flaky("fetch failed", u32::MAX);
1116 let err = sub.run_command("echo hi", None).await.unwrap_err();
1117 assert!(
1118 err.starts_with(SUBSTRATE_TRANSPORT_ERR_PREFIX),
1119 "tagged: {err}"
1120 );
1121 assert_eq!(*attempts.lock().unwrap(), SUBSTRATE_RETRY_ATTEMPTS);
1122 }
1123
1124 #[tokio::test]
1125 async fn timeout_is_not_retried_but_is_tagged() {
1126 let (sub, attempts) = flaky("MCP request 'tools/call' timed out", u32::MAX);
1128 let err = sub.run_command("echo hi", None).await.unwrap_err();
1129 assert!(
1130 err.starts_with(SUBSTRATE_TRANSPORT_ERR_PREFIX),
1131 "tagged: {err}"
1132 );
1133 assert_eq!(*attempts.lock().unwrap(), 1, "timeout must not be retried");
1134 }
1135
1136 #[tokio::test]
1137 async fn task_error_is_not_retried_or_tagged() {
1138 let (sub, attempts) = flaky("no such file", u32::MAX);
1140 let err = sub.run_command("cat /nope", None).await.unwrap_err();
1141 assert!(
1142 !err.starts_with(SUBSTRATE_TRANSPORT_ERR_PREFIX),
1143 "not tagged: {err}"
1144 );
1145 assert_eq!(
1146 *attempts.lock().unwrap(),
1147 1,
1148 "task errors must not be retried"
1149 );
1150 }
1151}