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            // The helper supplies `/S /C` and the command line. NOT `.arg(cmd)`,
176            // whose MSVCRT quoting cmd does not follow, and not a bare `/C`,
177            // which rewrites a line that begins with a quote. See
178            // `car_winenv::push_shell_command`.
179            crate::win_env::push_shell_command(c.as_std_mut(), cmd);
180            // cmd.exe DROPS an over-long PATH, handing the shell an empty one —
181            // see car_engine::win_env. `None` (the usual case) inherits unchanged.
182            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
281// ─────────────────────────────────────────────────────────────────────────
282// McpSubstrate
283// ─────────────────────────────────────────────────────────────────────────
284
285/// Substrate backed by an MCP session (e.g. the `vm` bridge). Maps substrate
286/// methods onto same-named bridge tools (`run_command`, `read_text`,
287/// `write_text`, `read_bytes`, `write_bytes`, `pty_*`).
288///
289/// The JSON wire arg names match the canonical sandbox bridge protocol (the
290/// shared `vm_mcp_server` used by ALE/OpenClaw): `run_command{command,timeout}`,
291/// `read_bytes{path,offset,length}`, `write_bytes{path,content_b64,append?}`,
292/// `pty_*{pid,...}` with a numeric pid. These differ from the Rust trait's
293/// parameter names (`cmd`/`timeout_s`/`len`/`id`), which are kept idiomatic.
294///
295/// Holds the session as `Arc<Mutex<dyn McpSession>>` — the same handle type
296/// `car-connectors` and [`crate::mcp::McpToolExecutor`] already pass around —
297/// because [`McpSession::call_tool`] takes `&mut self`.
298/// Reserved prefix marking an error that originated at the substrate's
299/// *transport* boundary — the MCP session call failed to reach the bridge, or
300/// the bridge returned `isError` with a transport-down signature (e.g. the
301/// canonical `vm` bridge's "fetch failed" when its link to the VM dropped).
302///
303/// A legitimate command failure comes back as `Ok` with a nonzero `exit_code`,
304/// and a tool-reported task error (e.g. "no such file") is left untagged, so a
305/// caller can count genuine infra incidents by matching this prefix instead of
306/// sniffing free-text. `car run-task` uses it for the `run_end.infra_tool_errors`
307/// count and strips it before the text reaches a model.
308pub const SUBSTRATE_TRANSPORT_ERR_PREFIX: &str = "[substrate-transport] ";
309
310/// Heuristic: does an error message look like a transport/environment fault
311/// (as opposed to a tool-reported task error)? Bounded allowlist of the
312/// signatures the canonical bridges and the MCP transport actually emit.
313fn 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
332/// Single-quote a path for the POSIX shell exposed by the canonical VM bridge.
333fn shell_quote(s: &str) -> String {
334    format!("'{}'", s.replace('\'', "'\\''"))
335}
336
337/// Subset of transport errors that are SAFE to auto-retry: connection-drop
338/// signatures that mean the request never reached the server, so re-sending
339/// cannot double-execute a non-idempotent call (e.g. `run_command`).
340///
341/// Deliberately EXCLUDES timeouts ("timed out" / "currently unavailable"): a
342/// timeout may mean the call IS running server-side, so retrying could run a
343/// command twice. Timeouts are still tagged as transport faults (surfaced to the
344/// agent) but not silently retried.
345///
346/// Residual risk: connection-drop signatures are *usually* pre-execution (the
347/// request never reached the server) but not guaranteed — a drop after the
348/// request was sent could re-run a non-idempotent command. Accepted because the
349/// retry is bounded ([`SUBSTRATE_RETRY_ATTEMPTS`]) and a surfaced failure would
350/// make the agent re-run the command anyway, so the net double-execution risk is
351/// no worse than the status quo. Tighten to idempotent-only tools if this bites.
352fn 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
369/// Bounded attempts for a connection-drop retry in [`McpSubstrate::call_timed`].
370const SUBSTRATE_RETRY_ATTEMPTS: u32 = 3;
371
372pub struct McpSubstrate {
373    session: Arc<Mutex<dyn McpSession>>,
374    name: String,
375}
376
377impl McpSubstrate {
378    /// Wrap an MCP session as a substrate. `name` is the environment label
379    /// (e.g. `"vm"`); it is not required to match the session's server name.
380    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    /// Like [`call`], but bounds the MCP response await by `timeout` (else the
392    /// session backstop). Used by `run_command` so a long-running command isn't
393    /// abandoned by CAR while it is still executing on the VM.
394    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            // Re-acquire the lock per attempt (never held across the backoff).
403            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                // Connection-drop (request didn't reach the server) → safe to
412                // retry; a transient blip self-heals and never surfaces to the
413                // model (so it isn't counted as an infra incident either).
414                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                // Exhausted, or a non-retryable error. Tag transport-class faults
422                // (incl. timeouts) so callers count genuine infra incidents
423                // without sniffing free-text; leave tool-reported task errors raw.
424                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        // Unreachable in practice (the loop returns), but keep the tag contract.
434        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        // Bound the MCP await by the command's own timeout + a margin so the
454        // bridge can return its server-side timeout result before CAR's await
455        // fires; commands with no declared timeout fall back to the backstop.
456        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        // The bridge may return a flattened text string (text-content blocks)
460        // or a structured object with stdout/stderr/exit_code.
461        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        // The bridge returns the pid either structured (`{"pid": N}`) or in a
565        // text block like `"pid: 1234\ncols: 80\nrows: 24"`. Substrate ids are
566        // strings; the numeric pid is round-tripped back as a string and parsed
567        // to a number by the other pty_* methods (see `pty_pid`).
568        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
621/// Parse a substrate pty id (a string) back into the numeric `pid` the bridge
622/// expects on its `pty_input`/`pty_read`/`pty_resize`/`pty_kill` tools.
623fn 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
629// ─────────────────────────────────────────────────────────────────────────
630// Minimal standard-alphabet base64 (no padding-stripping leniency beyond the
631// trailing '='). Used only on the MCP byte-transfer path so we don't pull a
632// new crate dependency for phases 1-2 where the local path never touches it.
633// ─────────────────────────────────────────────────────────────────────────
634
635const 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        // Left-align the accumulated bits and emit whole bytes.
684        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        // windowed byte read
735        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    // ── McpSubstrate against a mock McpSession ──
782
783    use crate::mcp::McpToolInfo;
784    use serde_json::Value;
785    use std::sync::Mutex as StdMutex;
786
787    struct MockSession {
788        name: String,
789        // (tool, args) recorded for assertions
790        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                    // Bridge returns flattened text content as a String.
821                    Ok(Value::String(c))
822                }
823                "run_command" => Ok(json!({
824                    "stdout": "from-vm",
825                    "stderr": "",
826                    "exit_code": 0
827                })),
828                // The canonical bridge flattens read_bytes to a base64 text
829                // block; "aGk=" is base64("hi").
830                "read_bytes" => Ok(Value::String("aGk=".into())),
831                "write_bytes" => Ok(Value::String("ok".into())),
832                // pty_start returns a human-readable text block carrying the pid.
833                "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        // Verify the substrate routed to same-named bridge tools.
867        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    /// Pins the exact JSON wire arg keys McpSubstrate sends, which MUST match the
873    /// canonical sandbox bridge (`vm_mcp_server`) zod schemas. A drift here ships
874    /// a silently-broken substrate: the bridge rejects the call with MCP -32602
875    /// ("invalid_type, received undefined") and every VM op fails. This exact
876    /// drift (`cmd`/`timeout_s`/`len`/`data`/`id` vs the bridge's
877    /// `command`/`timeout`/`length`/`content_b64`/`pid`) shipped once because the
878    /// routing test above never asserted arg keys — only tool names.
879    #[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"); // base64("hi") round-trips
894        sub.write_bytes("/f", b"hi").await.unwrap();
895        // pty_start parses the numeric pid out of the bridge's text block.
896        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        // content_b64 carries base64("hi"); assert the key, not the payload.
917        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        // pty_* must send a NUMERIC pid, not a string id.
929        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    /// Records the per-call timeout the substrate threads to the session, so we
948    /// can assert run_command carries its own timeout (+ margin) while other
949    /// ops fall back to the backstop (None).
950    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            // The default trait path (no timeout) — record None so a regression
961            // that bypasses call_tool_with_timeout is visible.
962            *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        // run_command with a declared timeout → MCP await = timeout + 30s margin.
988        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        // run_command with NO declared timeout → None (session backstop applies).
996        sub.run_command("echo hi", None).await.unwrap();
997        assert_eq!(*last.lock().unwrap(), Some(None));
998
999        // A non-command op (read_text) → None (fast; backstop is fine).
1000        let _ = sub.read_text("/p").await;
1001        assert_eq!(*last.lock().unwrap(), Some(None));
1002    }
1003
1004    /// Fails with `err` until the `succeed_at`-th attempt; counts attempts.
1005    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        // "fetch failed" once, then success → the call succeeds, untagged.
1057        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        // A timeout may mean the command IS running — must NOT auto-retry.
1077        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        // A tool-reported failure (e.g. file not found) is not transport.
1089        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}