Skip to main content

car_multi/
workspace.rs

1//! Per-agent filesystem workspace isolation.
2//!
3//! [`task_context`](crate::task_context) isolates an agent's **state** (the
4//! key/value store). This module isolates its **filesystem**: when parallel
5//! agents mutate files, giving each its own working directory prevents them from
6//! clobbering one another — the file-level analogue of the blog's
7//! `isolation: 'worktree'`.
8//!
9//! ## What the runtime can and can't do
10//!
11//! CAR doesn't own process execution — the caller's `AgentRunner` runs the tools.
12//! So the runtime *provisions* an isolated directory (or git worktree) and
13//! *advertises* its path to the agent via `AgentSpec.metadata["workspace"]`; the
14//! runner is responsible for actually running its file tools relative to that
15//! path. The runtime guarantees provisioning and cleanup (RAII); honoring the
16//! path is a cooperative contract with the runner. This is the honest boundary
17//! for a runtime that validates and orchestrates but does not itself exec.
18
19use crate::types::AgentSpec;
20use serde::{Deserialize, Serialize};
21use serde_json::Value;
22use std::path::{Path, PathBuf};
23
24/// Metadata key under which a provisioned workspace path is advertised to the
25/// agent runner.
26pub const WORKSPACE_METADATA_KEY: &str = "workspace";
27
28/// How to provision a per-agent workspace.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum WorkspaceMode {
32    /// A plain empty directory per agent. No VCS; cheapest.
33    Directory,
34    /// A `git worktree` checked out at `base`'s HEAD, so each agent edits an
35    /// isolated copy of the repository. Requires `base` to be inside a git repo
36    /// and a `git` binary on PATH; falls back to an error if either is missing.
37    GitWorktree,
38}
39
40/// Configuration for per-agent workspace provisioning.
41#[derive(Debug, Clone)]
42pub struct WorkspaceConfig {
43    /// Base directory under which per-agent workspaces are created. For
44    /// `GitWorktree` this must be inside (or be) a git working tree.
45    pub base: PathBuf,
46    pub mode: WorkspaceMode,
47}
48
49impl WorkspaceConfig {
50    pub fn directory(base: impl Into<PathBuf>) -> Self {
51        Self {
52            base: base.into(),
53            mode: WorkspaceMode::Directory,
54        }
55    }
56
57    pub fn git_worktree(base: impl Into<PathBuf>) -> Self {
58        Self {
59            base: base.into(),
60            mode: WorkspaceMode::GitWorktree,
61        }
62    }
63}
64
65/// Sanitize an agent name into a single safe path segment. Non-`[A-Za-z0-9_-]`
66/// chars (including `.` and `/`) collapse to `-`, so no traversal or separator
67/// can escape `base`. Note: distinct names can collide after sanitization (e.g.
68/// `a/b` and `a-b`), sharing a workspace — keep agent names distinct under this
69/// mapping when isolation matters.
70fn sanitize(name: &str) -> String {
71    let s: String = name
72        .chars()
73        .map(|c| {
74            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
75                c
76            } else {
77                '-'
78            }
79        })
80        .collect();
81    if s.is_empty() {
82        "agent".to_string()
83    } else {
84        s
85    }
86}
87
88/// An RAII handle to a provisioned per-agent workspace. The directory (or git
89/// worktree) is removed when this is dropped.
90#[derive(Debug)]
91pub struct AgentWorkspace {
92    path: PathBuf,
93    mode: WorkspaceMode,
94    /// The git repo root, for `git worktree remove` on drop (GitWorktree only).
95    repo_root: Option<PathBuf>,
96}
97
98impl AgentWorkspace {
99    /// Provision an isolated workspace for `agent_name` under `config.base`.
100    pub fn provision(config: &WorkspaceConfig, agent_name: &str) -> Result<Self, String> {
101        let path = config.base.join(sanitize(agent_name));
102        match config.mode {
103            WorkspaceMode::Directory => {
104                std::fs::create_dir_all(&path)
105                    .map_err(|e| format!("create workspace dir {}: {e}", path.display()))?;
106                Ok(Self {
107                    path,
108                    mode: WorkspaceMode::Directory,
109                    repo_root: None,
110                })
111            }
112            WorkspaceMode::GitWorktree => {
113                use std::ffi::OsStr;
114                let repo_root = git_repo_root(&config.base).ok_or_else(|| {
115                    format!(
116                        "git_worktree workspace requires {} to be inside a git repo",
117                        config.base.display()
118                    )
119                })?;
120                // Self-heal against a worktree leaked by a prior run that didn't
121                // get to clean up (process/runtime teardown): drop any stale
122                // registration for this exact path, prune dangling entries, and
123                // clear the directory before adding.
124                let _ = run_git(
125                    &repo_root,
126                    &[
127                        OsStr::new("worktree"),
128                        OsStr::new("remove"),
129                        OsStr::new("--force"),
130                        path.as_os_str(),
131                    ],
132                );
133                let _ = run_git(&repo_root, &[OsStr::new("worktree"), OsStr::new("prune")]);
134                if path.exists() {
135                    let _ = std::fs::remove_dir_all(&path);
136                }
137                run_git(
138                    &repo_root,
139                    &[
140                        OsStr::new("worktree"),
141                        OsStr::new("add"),
142                        OsStr::new("--detach"),
143                        path.as_os_str(),
144                        OsStr::new("HEAD"),
145                    ],
146                )?;
147                Ok(Self {
148                    path,
149                    mode: WorkspaceMode::GitWorktree,
150                    repo_root: Some(repo_root),
151                })
152            }
153        }
154    }
155
156    /// The provisioned workspace path.
157    pub fn path(&self) -> &Path {
158        &self.path
159    }
160
161    /// Return `spec` with this workspace's path advertised in its metadata.
162    pub fn inject(&self, mut spec: AgentSpec) -> AgentSpec {
163        spec.metadata.insert(
164            WORKSPACE_METADATA_KEY.to_string(),
165            Value::String(self.path.to_string_lossy().into_owned()),
166        );
167        spec
168    }
169}
170
171impl Drop for AgentWorkspace {
172    fn drop(&mut self) {
173        match self.mode {
174            WorkspaceMode::Directory => {
175                let _ = std::fs::remove_dir_all(&self.path);
176            }
177            WorkspaceMode::GitWorktree => {
178                if let Some(root) = &self.repo_root {
179                    use std::ffi::OsStr;
180                    // Best-effort: detach the worktree, then remove the dir.
181                    let _ = run_git(
182                        root,
183                        &[
184                            OsStr::new("worktree"),
185                            OsStr::new("remove"),
186                            OsStr::new("--force"),
187                            self.path.as_os_str(),
188                        ],
189                    );
190                    let _ = std::fs::remove_dir_all(&self.path);
191                }
192            }
193        }
194    }
195}
196
197/// Find the git working-tree root containing `dir`, if any.
198fn git_repo_root(dir: &Path) -> Option<PathBuf> {
199    let out = std::process::Command::new("git")
200        .arg("-C")
201        .arg(dir)
202        .args(["rev-parse", "--show-toplevel"])
203        .output()
204        .ok()?;
205    if !out.status.success() {
206        return None;
207    }
208    let root = String::from_utf8(out.stdout).ok()?.trim().to_string();
209    if root.is_empty() {
210        None
211    } else {
212        Some(PathBuf::from(root))
213    }
214}
215
216fn run_git(repo_root: &Path, args: &[&std::ffi::OsStr]) -> Result<(), String> {
217    let out = std::process::Command::new("git")
218        .arg("-C")
219        .arg(repo_root)
220        .args(args)
221        .output()
222        .map_err(|e| format!("git {:?}: {e}", args))?;
223    if out.status.success() {
224        Ok(())
225    } else {
226        Err(format!(
227            "git {:?} failed: {}",
228            args,
229            String::from_utf8_lossy(&out.stderr).trim()
230        ))
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    fn unique_base(tag: &str) -> PathBuf {
239        // Avoid Math.random/Date in tests; use pid + a static counter.
240        use std::sync::atomic::{AtomicU64, Ordering};
241        static N: AtomicU64 = AtomicU64::new(0);
242        let n = N.fetch_add(1, Ordering::Relaxed);
243        std::env::temp_dir().join(format!("car-ws-{tag}-{}-{n}", std::process::id()))
244    }
245
246    #[test]
247    fn directory_workspace_is_created_injected_and_cleaned() {
248        let base = unique_base("dir");
249        let cfg = WorkspaceConfig::directory(&base);
250        let path;
251        {
252            let ws = AgentWorkspace::provision(&cfg, "alice/../x").unwrap();
253            path = ws.path().to_path_buf();
254            assert!(path.exists() && path.is_dir());
255            // Sanitized: no path traversal segments survive.
256            assert_eq!(path.parent().unwrap(), base);
257            assert!(!path.to_string_lossy().contains(".."));
258
259            let spec = ws.inject(AgentSpec::new("alice", "sys"));
260            assert_eq!(
261                spec.metadata.get(WORKSPACE_METADATA_KEY).unwrap(),
262                &Value::String(path.to_string_lossy().into_owned())
263            );
264        }
265        // Dropped → cleaned up.
266        assert!(!path.exists(), "workspace should be removed on drop");
267        let _ = std::fs::remove_dir_all(&base);
268    }
269
270    #[test]
271    fn distinct_agents_get_distinct_dirs() {
272        let base = unique_base("distinct");
273        let cfg = WorkspaceConfig::directory(&base);
274        let a = AgentWorkspace::provision(&cfg, "a").unwrap();
275        let b = AgentWorkspace::provision(&cfg, "b").unwrap();
276        assert_ne!(a.path(), b.path());
277        drop(a);
278        drop(b);
279        let _ = std::fs::remove_dir_all(&base);
280    }
281}