Skip to main content

car_engine/
substrate.rs

1//! Execution substrate — the single environment an agent acts within.
2//!
3//! See `docs/execution-substrate.md`. A coherent agent acts within **one**
4//! environment: every side-effecting tool (files, processes) resolves against
5//! the same machine, while pure tools (`calculate`) are environment-free and
6//! run in-process anywhere.
7//!
8//! The [`Substrate`] trait is deliberately minimal: exec + file I/O (+ optional
9//! PTY). Convenience tools (`list_dir`/`find_files`/`grep_files`/`edit_file`)
10//! are **composed on top** — they are not trait methods. The GUI is a sibling
11//! surface on the same environment, not folded in here.
12//!
13//! ## Variants
14//!
15//! - [`LocalSubstrate`] — host fs/process. This is the lifted, byte-for-byte
16//!   equivalent of the historic `agent_basics` host behavior (absolute paths
17//!   pass through; relative paths join the host process `current_dir()`). It is
18//!   the **default** for every existing consumer, so binding a substrate is
19//!   backward compatible by construction.
20//! - [`McpSubstrate`] — wraps an [`McpSession`] (e.g. the `vm` bridge). The
21//!   bridge becomes *one implementation of the environment*, not a parallel tool
22//!   surface. Routes substrate methods onto same-named bridge tools.
23
24use 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/// Result of a one-shot command execution on a substrate.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct CommandOutput {
34    pub stdout: String,
35    pub stderr: String,
36    pub exit_code: i32,
37}
38
39/// Whether an execution substrate can establish a path's existence without
40/// treating an unreadable file as absent. Guarded file writes may create only a
41/// confirmed-missing path; an unknown state must fail closed.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum PathState {
44    Missing,
45    Exists,
46    Unknown(String),
47}
48
49/// The environment an agent acts within. Bound once to a [`Runtime`](crate::executor::Runtime); the agent
50/// never chooses per-tool where execution lands.
51///
52/// Kept minimal on purpose — exec + file I/O, with PTY as an optional
53/// capability that defaults to "unsupported". `list_dir`/`find`/`grep`/`edit`
54/// are reducible to these primitives and live as convenience tools, mirroring
55/// the `vm` MCP bridge.
56#[async_trait::async_trait]
57pub trait Substrate: Send + Sync {
58    /// Environment name: `"local"`, `"vm"`, `"docker:..."`, etc.
59    fn name(&self) -> &str;
60
61    /// Run a one-shot command, returning stdout/stderr/exit_code.
62    async fn run_command(&self, cmd: &str, timeout_s: Option<f64>)
63        -> Result<CommandOutput, String>;
64
65    /// Read a UTF-8 text file in full.
66    async fn read_text(&self, path: &str) -> Result<String, String>;
67
68    /// Write (truncate) a UTF-8 text file.
69    async fn write_text(&self, path: &str, content: &str) -> Result<(), String>;
70
71    /// Determine whether `path` exists without requiring it to be valid UTF-8.
72    /// The default is intentionally unknown: custom substrates can retain their
73    /// existing trait implementation, while guarded writes fail closed until
74    /// the adapter supplies a trustworthy presence check.
75    async fn path_state(&self, _path: &str) -> PathState {
76        PathState::Unknown("substrate does not expose a path-presence check".into())
77    }
78
79    /// Read raw bytes, optionally a window `[offset, offset+len)`.
80    async fn read_bytes(
81        &self,
82        path: &str,
83        offset: Option<u64>,
84        len: Option<u64>,
85    ) -> Result<Vec<u8>, String>;
86
87    /// Write (truncate) raw bytes.
88    async fn write_bytes(&self, path: &str, bytes: &[u8]) -> Result<(), String>;
89
90    /// Whether this substrate is the host process itself. Convenience tools
91    /// (`list_dir`/`find_files`/`grep_files`) that walk a directory tree use
92    /// this to keep their byte-identical host-fs implementation on the local
93    /// path; non-local environments fall back to composing on `run_command`.
94    fn is_local(&self) -> bool {
95        false
96    }
97
98    /// How a path should appear in tool-result metadata for this environment.
99    /// Pure/synchronous; no I/O. The default echoes the input unchanged. The
100    /// local environment overrides this to reproduce the historic behavior of
101    /// reporting the resolved absolute path (relative paths joined to the host
102    /// CWD). Not a capability — just display canonicalization.
103    fn display_path(&self, path: &str) -> String {
104        path.to_string()
105    }
106
107    // ─── Optional PTY capability (default: unsupported) ───────────────
108
109    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// ─────────────────────────────────────────────────────────────────────────
127// LocalSubstrate
128// ─────────────────────────────────────────────────────────────────────────
129
130/// Host filesystem / process substrate.
131///
132/// Reproduces the historic `agent_basics` host behavior exactly: the entire
133/// host-locality contract is one rule — absolute paths are used verbatim;
134/// relative paths join `std::env::current_dir()`.
135#[derive(Debug, Default, Clone)]
136pub struct LocalSubstrate;
137
138impl LocalSubstrate {
139    pub fn new() -> Self {
140        Self
141    }
142
143    /// The single host-locality seam (formerly `agent_basics::resolve_path`):
144    /// absolute paths pass through; relative paths join the host process CWD.
145    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            c.arg("/C").arg(cmd);
176            // cmd.exe DROPS an over-long PATH, handing the shell an empty one —
177            // see car_engine::win_env. `None` (the usual case) inherits unchanged.
178            if let Some(path) = crate::win_env::cmd_path_override() {
179                c.env("PATH", path);
180            }
181            c
182        } else {
183            let mut c = Command::new("sh");
184            c.arg("-c").arg(cmd);
185            c
186        };
187
188        let fut = command.output();
189        let output = match timeout_s {
190            Some(secs) if secs > 0.0 => {
191                let dur = std::time::Duration::from_secs_f64(secs);
192                match tokio::time::timeout(dur, fut).await {
193                    Ok(res) => res.map_err(|e| format!("failed to run command: {e}"))?,
194                    Err(_) => return Err(format!("command timed out after {secs}s")),
195                }
196            }
197            _ => fut
198                .await
199                .map_err(|e| format!("failed to run command: {e}"))?,
200        };
201
202        Ok(CommandOutput {
203            stdout: String::from_utf8_lossy(&output.stdout).to_string(),
204            stderr: String::from_utf8_lossy(&output.stderr).to_string(),
205            exit_code: output.status.code().unwrap_or(-1),
206        })
207    }
208
209    async fn read_text(&self, path: &str) -> Result<String, String> {
210        let full = Self::resolve_path(path)?;
211        std::fs::read_to_string(&full)
212            .map_err(|e| format!("failed to read file '{}': {e}", full.display()))
213    }
214
215    async fn write_text(&self, path: &str, content: &str) -> Result<(), String> {
216        let full = Self::resolve_path(path)?;
217        if let Some(parent) = full.parent() {
218            std::fs::create_dir_all(parent)
219                .map_err(|e| format!("failed to create parent dir '{}': {e}", parent.display()))?;
220        }
221        std::fs::write(&full, content)
222            .map_err(|e| format!("failed to write file '{}': {e}", full.display()))
223    }
224
225    async fn path_state(&self, path: &str) -> PathState {
226        let full = match Self::resolve_path(path) {
227            Ok(path) => path,
228            Err(error) => return PathState::Unknown(error),
229        };
230        match std::fs::symlink_metadata(&full) {
231            Ok(_) => PathState::Exists,
232            Err(error) if error.kind() == std::io::ErrorKind::NotFound => PathState::Missing,
233            Err(error) => {
234                PathState::Unknown(format!("failed to inspect '{}': {error}", full.display()))
235            }
236        }
237    }
238
239    async fn read_bytes(
240        &self,
241        path: &str,
242        offset: Option<u64>,
243        len: Option<u64>,
244    ) -> Result<Vec<u8>, String> {
245        let full = Self::resolve_path(path)?;
246        let bytes = std::fs::read(&full)
247            .map_err(|e| format!("failed to read file '{}': {e}", full.display()))?;
248        let start = offset.unwrap_or(0) as usize;
249        if start >= bytes.len() {
250            return Ok(Vec::new());
251        }
252        let end = match len {
253            Some(l) => (start + l as usize).min(bytes.len()),
254            None => bytes.len(),
255        };
256        Ok(bytes[start..end].to_vec())
257    }
258
259    async fn write_bytes(&self, path: &str, bytes: &[u8]) -> Result<(), String> {
260        let full = Self::resolve_path(path)?;
261        if let Some(parent) = full.parent() {
262            std::fs::create_dir_all(parent)
263                .map_err(|e| format!("failed to create parent dir '{}': {e}", parent.display()))?;
264        }
265        std::fs::write(&full, bytes)
266            .map_err(|e| format!("failed to write file '{}': {e}", full.display()))
267    }
268
269    fn display_path(&self, path: &str) -> String {
270        match Self::resolve_path(path) {
271            Ok(p) => p.display().to_string(),
272            Err(_) => path.to_string(),
273        }
274    }
275}
276
277// ─────────────────────────────────────────────────────────────────────────
278// McpSubstrate
279// ─────────────────────────────────────────────────────────────────────────
280
281/// Substrate backed by an MCP session (e.g. the `vm` bridge). Maps substrate
282/// methods onto same-named bridge tools (`run_command`, `read_text`,
283/// `write_text`, `read_bytes`, `write_bytes`, `pty_*`).
284///
285/// The JSON wire arg names match the canonical sandbox bridge protocol (the
286/// shared `vm_mcp_server` used by ALE/OpenClaw): `run_command{command,timeout}`,
287/// `read_bytes{path,offset,length}`, `write_bytes{path,content_b64,append?}`,
288/// `pty_*{pid,...}` with a numeric pid. These differ from the Rust trait's
289/// parameter names (`cmd`/`timeout_s`/`len`/`id`), which are kept idiomatic.
290///
291/// Holds the session as `Arc<Mutex<dyn McpSession>>` — the same handle type
292/// `car-connectors` and [`crate::mcp::McpToolExecutor`] already pass around —
293/// because [`McpSession::call_tool`] takes `&mut self`.
294/// Reserved prefix marking an error that originated at the substrate's
295/// *transport* boundary — the MCP session call failed to reach the bridge, or
296/// the bridge returned `isError` with a transport-down signature (e.g. the
297/// canonical `vm` bridge's "fetch failed" when its link to the VM dropped).
298///
299/// A legitimate command failure comes back as `Ok` with a nonzero `exit_code`,
300/// and a tool-reported task error (e.g. "no such file") is left untagged, so a
301/// caller can count genuine infra incidents by matching this prefix instead of
302/// sniffing free-text. `car run-task` uses it for the `run_end.infra_tool_errors`
303/// count and strips it before the text reaches a model.
304pub const SUBSTRATE_TRANSPORT_ERR_PREFIX: &str = "[substrate-transport] ";
305
306/// Heuristic: does an error message look like a transport/environment fault
307/// (as opposed to a tool-reported task error)? Bounded allowlist of the
308/// signatures the canonical bridges and the MCP transport actually emit.
309fn looks_like_transport_error(msg: &str) -> bool {
310    const SIGS: &[&str] = &[
311        "fetch failed",
312        "connection",
313        "econnrefused",
314        "econnreset",
315        "transport",
316        "timed out",
317        "timeout",
318        "socket hang",
319        "broken pipe",
320        "stream closed",
321        "channel closed",
322        "unexpected eof",
323    ];
324    let m = msg.to_ascii_lowercase();
325    SIGS.iter().any(|s| m.contains(s))
326}
327
328/// Single-quote a path for the POSIX shell exposed by the canonical VM bridge.
329fn shell_quote(s: &str) -> String {
330    format!("'{}'", s.replace('\'', "'\\''"))
331}
332
333/// Subset of transport errors that are SAFE to auto-retry: connection-drop
334/// signatures that mean the request never reached the server, so re-sending
335/// cannot double-execute a non-idempotent call (e.g. `run_command`).
336///
337/// Deliberately EXCLUDES timeouts ("timed out" / "currently unavailable"): a
338/// timeout may mean the call IS running server-side, so retrying could run a
339/// command twice. Timeouts are still tagged as transport faults (surfaced to the
340/// agent) but not silently retried.
341///
342/// Residual risk: connection-drop signatures are *usually* pre-execution (the
343/// request never reached the server) but not guaranteed — a drop after the
344/// request was sent could re-run a non-idempotent command. Accepted because the
345/// retry is bounded ([`SUBSTRATE_RETRY_ATTEMPTS`]) and a surfaced failure would
346/// make the agent re-run the command anyway, so the net double-execution risk is
347/// no worse than the status quo. Tighten to idempotent-only tools if this bites.
348fn is_retryable_transport_error(msg: &str) -> bool {
349    const RETRYABLE: &[&str] = &[
350        "fetch failed",
351        "econnrefused",
352        "econnreset",
353        "connection refused",
354        "connection reset",
355        "broken pipe",
356        "stream closed",
357        "channel closed",
358        "unexpected eof",
359        "closed the connection",
360    ];
361    let m = msg.to_ascii_lowercase();
362    RETRYABLE.iter().any(|s| m.contains(s))
363}
364
365/// Bounded attempts for a connection-drop retry in [`McpSubstrate::call_timed`].
366const SUBSTRATE_RETRY_ATTEMPTS: u32 = 3;
367
368pub struct McpSubstrate {
369    session: Arc<Mutex<dyn McpSession>>,
370    name: String,
371}
372
373impl McpSubstrate {
374    /// Wrap an MCP session as a substrate. `name` is the environment label
375    /// (e.g. `"vm"`); it is not required to match the session's server name.
376    pub fn new(session: Arc<Mutex<dyn McpSession>>, name: impl Into<String>) -> Self {
377        Self {
378            session,
379            name: name.into(),
380        }
381    }
382
383    async fn call(&self, tool: &str, args: serde_json::Value) -> Result<serde_json::Value, String> {
384        self.call_timed(tool, args, None).await
385    }
386
387    /// Like [`call`], but bounds the MCP response await by `timeout` (else the
388    /// session backstop). Used by `run_command` so a long-running command isn't
389    /// abandoned by CAR while it is still executing on the VM.
390    async fn call_timed(
391        &self,
392        tool: &str,
393        args: serde_json::Value,
394        timeout: Option<Duration>,
395    ) -> Result<serde_json::Value, String> {
396        let mut last_err = String::new();
397        for attempt in 1..=SUBSTRATE_RETRY_ATTEMPTS {
398            // Re-acquire the lock per attempt (never held across the backoff).
399            let result = {
400                let mut guard = self.session.lock().await;
401                guard
402                    .call_tool_with_timeout(tool, args.clone(), timeout)
403                    .await
404            };
405            match result {
406                Ok(v) => return Ok(v),
407                // Connection-drop (request didn't reach the server) → safe to
408                // retry; a transient blip self-heals and never surfaces to the
409                // model (so it isn't counted as an infra incident either).
410                Err(e)
411                    if is_retryable_transport_error(&e) && attempt < SUBSTRATE_RETRY_ATTEMPTS =>
412                {
413                    last_err = e;
414                    tokio::time::sleep(Duration::from_millis(300 * attempt as u64)).await;
415                    continue;
416                }
417                // Exhausted, or a non-retryable error. Tag transport-class faults
418                // (incl. timeouts) so callers count genuine infra incidents
419                // without sniffing free-text; leave tool-reported task errors raw.
420                Err(e) => {
421                    return Err(if looks_like_transport_error(&e) {
422                        format!("{SUBSTRATE_TRANSPORT_ERR_PREFIX}{e}")
423                    } else {
424                        e
425                    });
426                }
427            }
428        }
429        // Unreachable in practice (the loop returns), but keep the tag contract.
430        Err(format!("{SUBSTRATE_TRANSPORT_ERR_PREFIX}{last_err}"))
431    }
432}
433
434#[async_trait::async_trait]
435impl Substrate for McpSubstrate {
436    fn name(&self) -> &str {
437        &self.name
438    }
439
440    async fn run_command(
441        &self,
442        cmd: &str,
443        timeout_s: Option<f64>,
444    ) -> Result<CommandOutput, String> {
445        let mut args = json!({ "command": cmd });
446        if let Some(secs) = timeout_s {
447            args["timeout"] = json!(secs);
448        }
449        // Bound the MCP await by the command's own timeout + a margin so the
450        // bridge can return its server-side timeout result before CAR's await
451        // fires; commands with no declared timeout fall back to the backstop.
452        let await_timeout = timeout_s.map(|s| Duration::from_secs_f64(s + 30.0));
453        let result = self.call_timed("run_command", args, await_timeout).await?;
454
455        // The bridge may return a flattened text string (text-content blocks)
456        // or a structured object with stdout/stderr/exit_code.
457        match &result {
458            serde_json::Value::String(s) => Ok(CommandOutput {
459                stdout: s.clone(),
460                stderr: String::new(),
461                exit_code: 0,
462            }),
463            serde_json::Value::Object(_) => Ok(CommandOutput {
464                stdout: result
465                    .get("stdout")
466                    .and_then(|v| v.as_str())
467                    .unwrap_or("")
468                    .to_string(),
469                stderr: result
470                    .get("stderr")
471                    .and_then(|v| v.as_str())
472                    .unwrap_or("")
473                    .to_string(),
474                exit_code: result
475                    .get("exit_code")
476                    .and_then(|v| v.as_i64())
477                    .unwrap_or(0) as i32,
478            }),
479            other => Ok(CommandOutput {
480                stdout: other.to_string(),
481                stderr: String::new(),
482                exit_code: 0,
483            }),
484        }
485    }
486
487    async fn read_text(&self, path: &str) -> Result<String, String> {
488        let result = self.call("read_text", json!({ "path": path })).await?;
489        match result {
490            serde_json::Value::String(s) => Ok(s),
491            other => Ok(other
492                .get("content")
493                .and_then(|v| v.as_str())
494                .map(|s| s.to_string())
495                .unwrap_or_else(|| other.to_string())),
496        }
497    }
498
499    async fn write_text(&self, path: &str, content: &str) -> Result<(), String> {
500        self.call("write_text", json!({ "path": path, "content": content }))
501            .await
502            .map(|_| ())
503    }
504
505    async fn path_state(&self, path: &str) -> PathState {
506        let quoted = shell_quote(path);
507        match self
508            .run_command(
509                &format!("if [ -e {quoted} ] || [ -L {quoted} ]; then exit 0; else exit 1; fi"),
510                Some(5.0),
511            )
512            .await
513        {
514            Ok(output) if output.exit_code == 0 => PathState::Exists,
515            Ok(output) if output.exit_code == 1 => PathState::Missing,
516            Ok(output) => PathState::Unknown(format!(
517                "presence check exited {}: {}",
518                output.exit_code,
519                output.stderr.trim()
520            )),
521            Err(error) => PathState::Unknown(error),
522        }
523    }
524
525    async fn read_bytes(
526        &self,
527        path: &str,
528        offset: Option<u64>,
529        len: Option<u64>,
530    ) -> Result<Vec<u8>, String> {
531        let mut args = json!({ "path": path });
532        if let Some(o) = offset {
533            args["offset"] = json!(o);
534        }
535        if let Some(l) = len {
536            args["length"] = json!(l);
537        }
538        let result = self.call("read_bytes", args).await?;
539        let b64 = match &result {
540            serde_json::Value::String(s) => s.clone(),
541            other => other
542                .get("data")
543                .or_else(|| other.get("base64"))
544                .and_then(|v| v.as_str())
545                .map(|s| s.to_string())
546                .ok_or_else(|| "read_bytes: no base64 data in response".to_string())?,
547        };
548        base64_decode(b64.trim()).map_err(|e| format!("read_bytes: invalid base64: {e}"))
549    }
550
551    async fn write_bytes(&self, path: &str, bytes: &[u8]) -> Result<(), String> {
552        let b64 = base64_encode(bytes);
553        self.call("write_bytes", json!({ "path": path, "content_b64": b64 }))
554            .await
555            .map(|_| ())
556    }
557
558    async fn pty_start(&self, cmd: &str) -> Result<String, String> {
559        let result = self.call("pty_start", json!({ "command": cmd })).await?;
560        // The bridge returns the pid either structured (`{"pid": N}`) or in a
561        // text block like `"pid: 1234\ncols: 80\nrows: 24"`. Substrate ids are
562        // strings; the numeric pid is round-tripped back as a string and parsed
563        // to a number by the other pty_* methods (see `pty_pid`).
564        if let Some(pid) = result.get("pid").and_then(|v| v.as_i64()) {
565            return Ok(pid.to_string());
566        }
567        let text = match &result {
568            serde_json::Value::String(s) => s.clone(),
569            other => other.to_string(),
570        };
571        text.lines()
572            .find_map(|l| {
573                l.trim_start()
574                    .strip_prefix("pid:")
575                    .map(|r| r.trim().to_string())
576            })
577            .filter(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
578            .ok_or_else(|| format!("pty_start: no pid in response: {text}"))
579    }
580
581    async fn pty_input(&self, id: &str, data: &str) -> Result<(), String> {
582        self.call("pty_input", json!({ "pid": pty_pid(id)?, "data": data }))
583            .await
584            .map(|_| ())
585    }
586
587    async fn pty_read(&self, id: &str) -> Result<String, String> {
588        let result = self
589            .call("pty_read", json!({ "pid": pty_pid(id)? }))
590            .await?;
591        match result {
592            serde_json::Value::String(s) => Ok(s),
593            other => Ok(other
594                .get("data")
595                .and_then(|v| v.as_str())
596                .map(|s| s.to_string())
597                .unwrap_or_else(|| other.to_string())),
598        }
599    }
600
601    async fn pty_resize(&self, id: &str, rows: u16, cols: u16) -> Result<(), String> {
602        self.call(
603            "pty_resize",
604            json!({ "pid": pty_pid(id)?, "rows": rows, "cols": cols }),
605        )
606        .await
607        .map(|_| ())
608    }
609
610    async fn pty_kill(&self, id: &str) -> Result<(), String> {
611        self.call("pty_kill", json!({ "pid": pty_pid(id)? }))
612            .await
613            .map(|_| ())
614    }
615}
616
617/// Parse a substrate pty id (a string) back into the numeric `pid` the bridge
618/// expects on its `pty_input`/`pty_read`/`pty_resize`/`pty_kill` tools.
619fn pty_pid(id: &str) -> Result<i64, String> {
620    id.trim()
621        .parse::<i64>()
622        .map_err(|_| format!("invalid pty id (expected numeric pid): {id:?}"))
623}
624
625// ─────────────────────────────────────────────────────────────────────────
626// Minimal standard-alphabet base64 (no padding-stripping leniency beyond the
627// trailing '='). Used only on the MCP byte-transfer path so we don't pull a
628// new crate dependency for phases 1-2 where the local path never touches it.
629// ─────────────────────────────────────────────────────────────────────────
630
631const B64_ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
632
633fn base64_encode(bytes: &[u8]) -> String {
634    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
635    for chunk in bytes.chunks(3) {
636        let b0 = chunk[0] as u32;
637        let b1 = *chunk.get(1).unwrap_or(&0) as u32;
638        let b2 = *chunk.get(2).unwrap_or(&0) as u32;
639        let n = (b0 << 16) | (b1 << 8) | b2;
640        out.push(B64_ALPHABET[((n >> 18) & 0x3f) as usize] as char);
641        out.push(B64_ALPHABET[((n >> 12) & 0x3f) as usize] as char);
642        if chunk.len() > 1 {
643            out.push(B64_ALPHABET[((n >> 6) & 0x3f) as usize] as char);
644        } else {
645            out.push('=');
646        }
647        if chunk.len() > 2 {
648            out.push(B64_ALPHABET[(n & 0x3f) as usize] as char);
649        } else {
650            out.push('=');
651        }
652    }
653    out
654}
655
656fn base64_decode(s: &str) -> Result<Vec<u8>, String> {
657    fn val(c: u8) -> Result<u32, String> {
658        match c {
659            b'A'..=b'Z' => Ok((c - b'A') as u32),
660            b'a'..=b'z' => Ok((c - b'a' + 26) as u32),
661            b'0'..=b'9' => Ok((c - b'0' + 52) as u32),
662            b'+' => Ok(62),
663            b'/' => Ok(63),
664            _ => Err(format!("invalid base64 char {:?}", c as char)),
665        }
666    }
667    let clean: Vec<u8> = s
668        .bytes()
669        .filter(|b| !b.is_ascii_whitespace() && *b != b'=')
670        .collect();
671    let mut out = Vec::with_capacity(clean.len() / 4 * 3);
672    for chunk in clean.chunks(4) {
673        let mut n = 0u32;
674        let mut bits = 0;
675        for &c in chunk {
676            n = (n << 6) | val(c)?;
677            bits += 6;
678        }
679        // Left-align the accumulated bits and emit whole bytes.
680        n <<= 24 - bits;
681        let nbytes = bits / 8;
682        for i in 0..nbytes {
683            out.push(((n >> (16 - i * 8)) & 0xff) as u8);
684        }
685    }
686    Ok(out)
687}
688
689#[cfg(test)]
690mod tests {
691    use super::*;
692
693    #[test]
694    fn base64_roundtrip() {
695        for case in [
696            &b""[..],
697            b"f",
698            b"fo",
699            b"foo",
700            b"foob",
701            b"fooba",
702            b"foobar",
703            &[0u8, 1, 2, 255],
704        ] {
705            let enc = base64_encode(case);
706            let dec = base64_decode(&enc).unwrap();
707            assert_eq!(dec, case, "roundtrip failed for {case:?} (enc={enc})");
708        }
709        assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
710        assert_eq!(base64_decode("Zm9vYmFy").unwrap(), b"foobar");
711    }
712
713    #[tokio::test]
714    async fn local_read_write_roundtrip() {
715        let dir = std::env::temp_dir().join(format!("car-substrate-{}", uuid_like()));
716        std::fs::create_dir_all(&dir).unwrap();
717        let path = dir.join("hello.txt");
718        let path_str = path.to_string_lossy().to_string();
719
720        let sub = LocalSubstrate::new();
721        assert_eq!(sub.name(), "local");
722
723        sub.write_text(&path_str, "hello world").await.unwrap();
724        let read = sub.read_text(&path_str).await.unwrap();
725        assert_eq!(read, "hello world");
726
727        let bytes = sub.read_bytes(&path_str, None, None).await.unwrap();
728        assert_eq!(bytes, b"hello world");
729
730        // windowed byte read
731        let window = sub.read_bytes(&path_str, Some(6), Some(5)).await.unwrap();
732        assert_eq!(window, b"world");
733
734        sub.write_bytes(&path_str, b"\x00\x01\x02").await.unwrap();
735        let raw = sub.read_bytes(&path_str, None, None).await.unwrap();
736        assert_eq!(raw, vec![0u8, 1, 2]);
737
738        std::fs::remove_dir_all(&dir).ok();
739    }
740
741    #[cfg(unix)]
742    #[tokio::test]
743    async fn local_path_state_treats_a_dangling_symlink_as_existing() {
744        let dir = std::env::temp_dir().join(format!("car-substrate-state-{}", uuid_like()));
745        std::fs::create_dir_all(&dir).unwrap();
746        let missing = dir.join("missing.txt");
747        let dangling = dir.join("dangling.txt");
748        std::os::unix::fs::symlink(&missing, &dangling).unwrap();
749
750        let sub = LocalSubstrate::new();
751        assert_eq!(
752            sub.path_state(&missing.to_string_lossy()).await,
753            PathState::Missing
754        );
755        assert_eq!(
756            sub.path_state(&dangling.to_string_lossy()).await,
757            PathState::Exists
758        );
759
760        std::fs::remove_dir_all(&dir).ok();
761    }
762
763    #[tokio::test]
764    async fn local_run_command_captures_output() {
765        let sub = LocalSubstrate::new();
766        let out = sub.run_command("echo hi", None).await.unwrap();
767        assert_eq!(out.stdout.trim(), "hi");
768        assert_eq!(out.exit_code, 0);
769    }
770
771    #[tokio::test]
772    async fn local_pty_defaults_to_unsupported() {
773        let sub = LocalSubstrate::new();
774        assert!(sub.pty_start("sh").await.is_err());
775    }
776
777    // ── McpSubstrate against a mock McpSession ──
778
779    use crate::mcp::McpToolInfo;
780    use serde_json::Value;
781    use std::sync::Mutex as StdMutex;
782
783    struct MockSession {
784        name: String,
785        // (tool, args) recorded for assertions
786        calls: Arc<StdMutex<Vec<(String, Value)>>>,
787        files: Arc<StdMutex<std::collections::HashMap<String, String>>>,
788    }
789
790    #[async_trait::async_trait]
791    impl McpSession for MockSession {
792        async fn list_tools(&mut self) -> Result<Vec<McpToolInfo>, String> {
793            Ok(vec![])
794        }
795        async fn call_tool(&mut self, name: &str, arguments: Value) -> Result<Value, String> {
796            self.calls
797                .lock()
798                .unwrap()
799                .push((name.to_string(), arguments.clone()));
800            match name {
801                "write_text" => {
802                    let p = arguments["path"].as_str().unwrap().to_string();
803                    let c = arguments["content"].as_str().unwrap().to_string();
804                    self.files.lock().unwrap().insert(p, c);
805                    Ok(Value::String("ok".into()))
806                }
807                "read_text" => {
808                    let p = arguments["path"].as_str().unwrap();
809                    let c = self
810                        .files
811                        .lock()
812                        .unwrap()
813                        .get(p)
814                        .cloned()
815                        .ok_or_else(|| "not found".to_string())?;
816                    // Bridge returns flattened text content as a String.
817                    Ok(Value::String(c))
818                }
819                "run_command" => Ok(json!({
820                    "stdout": "from-vm",
821                    "stderr": "",
822                    "exit_code": 0
823                })),
824                // The canonical bridge flattens read_bytes to a base64 text
825                // block; "aGk=" is base64("hi").
826                "read_bytes" => Ok(Value::String("aGk=".into())),
827                "write_bytes" => Ok(Value::String("ok".into())),
828                // pty_start returns a human-readable text block carrying the pid.
829                "pty_start" => Ok(Value::String("pid: 4242\ncols: 80\nrows: 24".into())),
830                "pty_read" => Ok(Value::String("pty-out".into())),
831                "pty_input" | "pty_resize" | "pty_kill" => Ok(Value::String("ok".into())),
832                _ => Err(format!("unknown tool {name}")),
833            }
834        }
835        fn name(&self) -> &str {
836            &self.name
837        }
838    }
839
840    #[tokio::test]
841    async fn mcp_substrate_routes_to_session() {
842        let calls = Arc::new(StdMutex::new(Vec::new()));
843        let files = Arc::new(StdMutex::new(std::collections::HashMap::new()));
844        let mock = MockSession {
845            name: "vm".into(),
846            calls: calls.clone(),
847            files: files.clone(),
848        };
849        let session: Arc<Mutex<dyn McpSession>> = Arc::new(Mutex::new(mock));
850        let sub = McpSubstrate::new(session, "vm");
851
852        assert_eq!(sub.name(), "vm");
853
854        sub.write_text("/tmp/a.txt", "vm-content").await.unwrap();
855        let read = sub.read_text("/tmp/a.txt").await.unwrap();
856        assert_eq!(read, "vm-content");
857
858        let out = sub.run_command("ls", None).await.unwrap();
859        assert_eq!(out.stdout, "from-vm");
860        assert_eq!(out.exit_code, 0);
861
862        // Verify the substrate routed to same-named bridge tools.
863        let recorded = calls.lock().unwrap();
864        let names: Vec<&str> = recorded.iter().map(|(n, _)| n.as_str()).collect();
865        assert_eq!(names, vec!["write_text", "read_text", "run_command"]);
866    }
867
868    /// Pins the exact JSON wire arg keys McpSubstrate sends, which MUST match the
869    /// canonical sandbox bridge (`vm_mcp_server`) zod schemas. A drift here ships
870    /// a silently-broken substrate: the bridge rejects the call with MCP -32602
871    /// ("invalid_type, received undefined") and every VM op fails. This exact
872    /// drift (`cmd`/`timeout_s`/`len`/`data`/`id` vs the bridge's
873    /// `command`/`timeout`/`length`/`content_b64`/`pid`) shipped once because the
874    /// routing test above never asserted arg keys — only tool names.
875    #[tokio::test]
876    async fn mcp_substrate_wire_args_match_canonical_bridge() {
877        let calls = Arc::new(StdMutex::new(Vec::new()));
878        let files = Arc::new(StdMutex::new(std::collections::HashMap::new()));
879        let mock = MockSession {
880            name: "vm".into(),
881            calls: calls.clone(),
882            files: files.clone(),
883        };
884        let session: Arc<Mutex<dyn McpSession>> = Arc::new(Mutex::new(mock));
885        let sub = McpSubstrate::new(session, "vm");
886
887        sub.run_command("ls", Some(5.0)).await.unwrap();
888        let bytes = sub.read_bytes("/f", Some(2), Some(4)).await.unwrap();
889        assert_eq!(bytes, b"hi"); // base64("hi") round-trips
890        sub.write_bytes("/f", b"hi").await.unwrap();
891        // pty_start parses the numeric pid out of the bridge's text block.
892        let pid = sub.pty_start("bash").await.unwrap();
893        assert_eq!(pid, "4242");
894        sub.pty_input(&pid, "echo\n").await.unwrap();
895        let out = sub.pty_read(&pid).await.unwrap();
896        assert_eq!(out, "pty-out");
897        sub.pty_resize(&pid, 40, 100).await.unwrap();
898        sub.pty_kill(&pid).await.unwrap();
899
900        let recorded = calls.lock().unwrap();
901        let by_tool =
902            |t: &str| -> Value { recorded.iter().find(|(n, _)| n == t).unwrap().1.clone() };
903
904        assert_eq!(
905            by_tool("run_command"),
906            json!({"command": "ls", "timeout": 5.0})
907        );
908        assert_eq!(
909            by_tool("read_bytes"),
910            json!({"path": "/f", "offset": 2, "length": 4})
911        );
912        // content_b64 carries base64("hi"); assert the key, not the payload.
913        let wb = by_tool("write_bytes");
914        assert!(
915            wb.get("content_b64").is_some(),
916            "write_bytes must use content_b64: {wb}"
917        );
918        assert!(
919            wb.get("data").is_none(),
920            "write_bytes must not use legacy `data`: {wb}"
921        );
922        assert_eq!(wb["path"], json!("/f"));
923        assert_eq!(by_tool("pty_start"), json!({"command": "bash"}));
924        // pty_* must send a NUMERIC pid, not a string id.
925        assert_eq!(by_tool("pty_input"), json!({"pid": 4242, "data": "echo\n"}));
926        assert_eq!(by_tool("pty_read"), json!({"pid": 4242}));
927        assert_eq!(
928            by_tool("pty_resize"),
929            json!({"pid": 4242, "rows": 40, "cols": 100})
930        );
931        assert_eq!(by_tool("pty_kill"), json!({"pid": 4242}));
932    }
933
934    fn uuid_like() -> String {
935        use std::time::{SystemTime, UNIX_EPOCH};
936        let nanos = SystemTime::now()
937            .duration_since(UNIX_EPOCH)
938            .unwrap()
939            .as_nanos();
940        format!("{nanos}")
941    }
942
943    /// Records the per-call timeout the substrate threads to the session, so we
944    /// can assert run_command carries its own timeout (+ margin) while other
945    /// ops fall back to the backstop (None).
946    struct TimeoutSpySession {
947        last: Arc<StdMutex<Option<Option<Duration>>>>,
948    }
949
950    #[async_trait::async_trait]
951    impl McpSession for TimeoutSpySession {
952        async fn list_tools(&mut self) -> Result<Vec<McpToolInfo>, String> {
953            Ok(vec![])
954        }
955        async fn call_tool(&mut self, _n: &str, _a: Value) -> Result<Value, String> {
956            // The default trait path (no timeout) — record None so a regression
957            // that bypasses call_tool_with_timeout is visible.
958            *self.last.lock().unwrap() = Some(None);
959            Ok(json!({"stdout": "x", "stderr": "", "exit_code": 0}))
960        }
961        async fn call_tool_with_timeout(
962            &mut self,
963            _n: &str,
964            _a: Value,
965            t: Option<Duration>,
966        ) -> Result<Value, String> {
967            *self.last.lock().unwrap() = Some(t);
968            Ok(json!({"stdout": "x", "stderr": "", "exit_code": 0}))
969        }
970        fn name(&self) -> &str {
971            "vm"
972        }
973    }
974
975    #[tokio::test]
976    async fn run_command_threads_its_timeout_else_backstop() {
977        let last = Arc::new(StdMutex::new(None));
978        let sub = McpSubstrate::new(
979            Arc::new(Mutex::new(TimeoutSpySession { last: last.clone() })),
980            "vm",
981        );
982
983        // run_command with a declared timeout → MCP await = timeout + 30s margin.
984        sub.run_command("echo hi", Some(120.0)).await.unwrap();
985        assert_eq!(
986            *last.lock().unwrap(),
987            Some(Some(Duration::from_secs_f64(150.0))),
988            "run_command must thread its own timeout (+margin) to the session",
989        );
990
991        // run_command with NO declared timeout → None (session backstop applies).
992        sub.run_command("echo hi", None).await.unwrap();
993        assert_eq!(*last.lock().unwrap(), Some(None));
994
995        // A non-command op (read_text) → None (fast; backstop is fine).
996        let _ = sub.read_text("/p").await;
997        assert_eq!(*last.lock().unwrap(), Some(None));
998    }
999
1000    /// Fails with `err` until the `succeed_at`-th attempt; counts attempts.
1001    struct FlakySession {
1002        err: String,
1003        succeed_at: u32,
1004        attempts: Arc<StdMutex<u32>>,
1005    }
1006
1007    #[async_trait::async_trait]
1008    impl McpSession for FlakySession {
1009        async fn list_tools(&mut self) -> Result<Vec<McpToolInfo>, String> {
1010            Ok(vec![])
1011        }
1012        async fn call_tool(&mut self, n: &str, a: Value) -> Result<Value, String> {
1013            self.call_tool_with_timeout(n, a, None).await
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            let n = {
1022                let mut a = self.attempts.lock().unwrap();
1023                *a += 1;
1024                *a
1025            };
1026            if n >= self.succeed_at {
1027                Ok(json!({"stdout": "ok", "stderr": "", "exit_code": 0}))
1028            } else {
1029                Err(self.err.clone())
1030            }
1031        }
1032        fn name(&self) -> &str {
1033            "vm"
1034        }
1035    }
1036
1037    fn flaky(err: &str, succeed_at: u32) -> (McpSubstrate, Arc<StdMutex<u32>>) {
1038        let attempts = Arc::new(StdMutex::new(0));
1039        let sub = McpSubstrate::new(
1040            Arc::new(Mutex::new(FlakySession {
1041                err: err.to_string(),
1042                succeed_at,
1043                attempts: attempts.clone(),
1044            })),
1045            "vm",
1046        );
1047        (sub, attempts)
1048    }
1049
1050    #[tokio::test]
1051    async fn transient_connection_drop_is_retried_and_recovers() {
1052        // "fetch failed" once, then success → the call succeeds, untagged.
1053        let (sub, attempts) = flaky("fetch failed", 2);
1054        let out = sub.run_command("echo hi", None).await.unwrap();
1055        assert_eq!(out.stdout, "ok");
1056        assert_eq!(*attempts.lock().unwrap(), 2, "retried once then succeeded");
1057    }
1058
1059    #[tokio::test]
1060    async fn persistent_connection_drop_exhausts_and_is_tagged() {
1061        let (sub, attempts) = flaky("fetch failed", u32::MAX);
1062        let err = sub.run_command("echo hi", None).await.unwrap_err();
1063        assert!(
1064            err.starts_with(SUBSTRATE_TRANSPORT_ERR_PREFIX),
1065            "tagged: {err}"
1066        );
1067        assert_eq!(*attempts.lock().unwrap(), SUBSTRATE_RETRY_ATTEMPTS);
1068    }
1069
1070    #[tokio::test]
1071    async fn timeout_is_not_retried_but_is_tagged() {
1072        // A timeout may mean the command IS running — must NOT auto-retry.
1073        let (sub, attempts) = flaky("MCP request 'tools/call' timed out", u32::MAX);
1074        let err = sub.run_command("echo hi", None).await.unwrap_err();
1075        assert!(
1076            err.starts_with(SUBSTRATE_TRANSPORT_ERR_PREFIX),
1077            "tagged: {err}"
1078        );
1079        assert_eq!(*attempts.lock().unwrap(), 1, "timeout must not be retried");
1080    }
1081
1082    #[tokio::test]
1083    async fn task_error_is_not_retried_or_tagged() {
1084        // A tool-reported failure (e.g. file not found) is not transport.
1085        let (sub, attempts) = flaky("no such file", u32::MAX);
1086        let err = sub.run_command("cat /nope", None).await.unwrap_err();
1087        assert!(
1088            !err.starts_with(SUBSTRATE_TRANSPORT_ERR_PREFIX),
1089            "not tagged: {err}"
1090        );
1091        assert_eq!(
1092            *attempts.lock().unwrap(),
1093            1,
1094            "task errors must not be retried"
1095        );
1096    }
1097}