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        // The command line may run git; it acts on the repository it runs in,
192        // not one the daemon's environment names (car#4115).
193        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
284// ─────────────────────────────────────────────────────────────────────────
285// McpSubstrate
286// ─────────────────────────────────────────────────────────────────────────
287
288/// Substrate backed by an MCP session (e.g. the `vm` bridge). Maps substrate
289/// methods onto same-named bridge tools (`run_command`, `read_text`,
290/// `write_text`, `read_bytes`, `write_bytes`, `pty_*`).
291///
292/// The JSON wire arg names match the canonical sandbox bridge protocol (the
293/// shared `vm_mcp_server` used by ALE/OpenClaw): `run_command{command,timeout}`,
294/// `read_bytes{path,offset,length}`, `write_bytes{path,content_b64,append?}`,
295/// `pty_*{pid,...}` with a numeric pid. These differ from the Rust trait's
296/// parameter names (`cmd`/`timeout_s`/`len`/`id`), which are kept idiomatic.
297///
298/// Holds the session as `Arc<Mutex<dyn McpSession>>` — the same handle type
299/// `car-connectors` and [`crate::mcp::McpToolExecutor`] already pass around —
300/// because [`McpSession::call_tool`] takes `&mut self`.
301/// Reserved prefix marking an error that originated at the substrate's
302/// *transport* boundary — the MCP session call failed to reach the bridge, or
303/// the bridge returned `isError` with a transport-down signature (e.g. the
304/// canonical `vm` bridge's "fetch failed" when its link to the VM dropped).
305///
306/// A legitimate command failure comes back as `Ok` with a nonzero `exit_code`,
307/// and a tool-reported task error (e.g. "no such file") is left untagged, so a
308/// caller can count genuine infra incidents by matching this prefix instead of
309/// sniffing free-text. `car run-task` uses it for the `run_end.infra_tool_errors`
310/// count and strips it before the text reaches a model.
311pub const SUBSTRATE_TRANSPORT_ERR_PREFIX: &str = "[substrate-transport] ";
312
313/// Heuristic: does an error message look like a transport/environment fault
314/// (as opposed to a tool-reported task error)? Bounded allowlist of the
315/// signatures the canonical bridges and the MCP transport actually emit.
316fn 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
335/// Single-quote a path for the POSIX shell exposed by the canonical VM bridge.
336fn shell_quote(s: &str) -> String {
337    format!("'{}'", s.replace('\'', "'\\''"))
338}
339
340/// Subset of transport errors that are SAFE to auto-retry: connection-drop
341/// signatures that mean the request never reached the server, so re-sending
342/// cannot double-execute a non-idempotent call (e.g. `run_command`).
343///
344/// Deliberately EXCLUDES timeouts ("timed out" / "currently unavailable"): a
345/// timeout may mean the call IS running server-side, so retrying could run a
346/// command twice. Timeouts are still tagged as transport faults (surfaced to the
347/// agent) but not silently retried.
348///
349/// Residual risk: connection-drop signatures are *usually* pre-execution (the
350/// request never reached the server) but not guaranteed — a drop after the
351/// request was sent could re-run a non-idempotent command. Accepted because the
352/// retry is bounded ([`SUBSTRATE_RETRY_ATTEMPTS`]) and a surfaced failure would
353/// make the agent re-run the command anyway, so the net double-execution risk is
354/// no worse than the status quo. Tighten to idempotent-only tools if this bites.
355fn 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
372/// Bounded attempts for a connection-drop retry in [`McpSubstrate::call_timed`].
373const SUBSTRATE_RETRY_ATTEMPTS: u32 = 3;
374
375pub struct McpSubstrate {
376    session: Arc<Mutex<dyn McpSession>>,
377    name: String,
378}
379
380impl McpSubstrate {
381    /// Wrap an MCP session as a substrate. `name` is the environment label
382    /// (e.g. `"vm"`); it is not required to match the session's server name.
383    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    /// Like [`call`], but bounds the MCP response await by `timeout` (else the
395    /// session backstop). Used by `run_command` so a long-running command isn't
396    /// abandoned by CAR while it is still executing on the VM.
397    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            // Re-acquire the lock per attempt (never held across the backoff).
406            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                // Connection-drop (request didn't reach the server) → safe to
415                // retry; a transient blip self-heals and never surfaces to the
416                // model (so it isn't counted as an infra incident either).
417                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                // Exhausted, or a non-retryable error. Tag transport-class faults
425                // (incl. timeouts) so callers count genuine infra incidents
426                // without sniffing free-text; leave tool-reported task errors raw.
427                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        // Unreachable in practice (the loop returns), but keep the tag contract.
437        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        // Bound the MCP await by the command's own timeout + a margin so the
457        // bridge can return its server-side timeout result before CAR's await
458        // fires; commands with no declared timeout fall back to the backstop.
459        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        // The bridge may return a flattened text string (text-content blocks)
463        // or a structured object with stdout/stderr/exit_code.
464        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        // The bridge returns the pid either structured (`{"pid": N}`) or in a
568        // text block like `"pid: 1234\ncols: 80\nrows: 24"`. Substrate ids are
569        // strings; the numeric pid is round-tripped back as a string and parsed
570        // to a number by the other pty_* methods (see `pty_pid`).
571        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
624/// Parse a substrate pty id (a string) back into the numeric `pid` the bridge
625/// expects on its `pty_input`/`pty_read`/`pty_resize`/`pty_kill` tools.
626fn 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
632// ─────────────────────────────────────────────────────────────────────────
633// Minimal standard-alphabet base64 (no padding-stripping leniency beyond the
634// trailing '='). Used only on the MCP byte-transfer path so we don't pull a
635// new crate dependency for phases 1-2 where the local path never touches it.
636// ─────────────────────────────────────────────────────────────────────────
637
638const 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        // Left-align the accumulated bits and emit whole bytes.
687        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        // windowed byte read
738        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    // ── McpSubstrate against a mock McpSession ──
785
786    use crate::mcp::McpToolInfo;
787    use serde_json::Value;
788    use std::sync::Mutex as StdMutex;
789
790    struct MockSession {
791        name: String,
792        // (tool, args) recorded for assertions
793        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                    // Bridge returns flattened text content as a String.
824                    Ok(Value::String(c))
825                }
826                "run_command" => Ok(json!({
827                    "stdout": "from-vm",
828                    "stderr": "",
829                    "exit_code": 0
830                })),
831                // The canonical bridge flattens read_bytes to a base64 text
832                // block; "aGk=" is base64("hi").
833                "read_bytes" => Ok(Value::String("aGk=".into())),
834                "write_bytes" => Ok(Value::String("ok".into())),
835                // pty_start returns a human-readable text block carrying the pid.
836                "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        // Verify the substrate routed to same-named bridge tools.
870        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    /// Pins the exact JSON wire arg keys McpSubstrate sends, which MUST match the
876    /// canonical sandbox bridge (`vm_mcp_server`) zod schemas. A drift here ships
877    /// a silently-broken substrate: the bridge rejects the call with MCP -32602
878    /// ("invalid_type, received undefined") and every VM op fails. This exact
879    /// drift (`cmd`/`timeout_s`/`len`/`data`/`id` vs the bridge's
880    /// `command`/`timeout`/`length`/`content_b64`/`pid`) shipped once because the
881    /// routing test above never asserted arg keys — only tool names.
882    #[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"); // base64("hi") round-trips
897        sub.write_bytes("/f", b"hi").await.unwrap();
898        // pty_start parses the numeric pid out of the bridge's text block.
899        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        // content_b64 carries base64("hi"); assert the key, not the payload.
920        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        // pty_* must send a NUMERIC pid, not a string id.
932        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    /// Every bridge call that carries a `command` or `url` string names a tool
942    /// the policy gate maps onto CAR's vocabulary, so its egress inspection
943    /// can deny. A bridge tool missing from `car_policy::tool_gate::TOOL_MAP`
944    /// would be inspected warn-only (car#3409).
945    #[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        // Positive control: the walk saw the exec tools it exists for.
983        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    /// Records the per-call timeout the substrate threads to the session, so we
998    /// can assert run_command carries its own timeout (+ margin) while other
999    /// ops fall back to the backstop (None).
1000    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            // The default trait path (no timeout) — record None so a regression
1011            // that bypasses call_tool_with_timeout is visible.
1012            *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        // run_command with a declared timeout → MCP await = timeout + 30s margin.
1038        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        // run_command with NO declared timeout → None (session backstop applies).
1046        sub.run_command("echo hi", None).await.unwrap();
1047        assert_eq!(*last.lock().unwrap(), Some(None));
1048
1049        // A non-command op (read_text) → None (fast; backstop is fine).
1050        let _ = sub.read_text("/p").await;
1051        assert_eq!(*last.lock().unwrap(), Some(None));
1052    }
1053
1054    /// Fails with `err` until the `succeed_at`-th attempt; counts attempts.
1055    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        // "fetch failed" once, then success → the call succeeds, untagged.
1107        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        // A timeout may mean the command IS running — must NOT auto-retry.
1127        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        // A tool-reported failure (e.g. file not found) is not transport.
1139        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}