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, unless
45    /// `repo` names the repository explicitly.
46    pub base: PathBuf,
47    pub mode: WorkspaceMode,
48    /// For `GitWorktree`: the repository to check worktrees out of, when it
49    /// differs from `base`. `None` derives the repo from `base` (the original
50    /// behavior, where worktrees land inside the repo itself). Setting this
51    /// lets worktrees live *outside* the repository — e.g. under a state dir —
52    /// so they never show up as untracked entries in the user's checkout.
53    pub repo: Option<PathBuf>,
54}
55
56impl WorkspaceConfig {
57    pub fn directory(base: impl Into<PathBuf>) -> Self {
58        Self {
59            base: base.into(),
60            mode: WorkspaceMode::Directory,
61            repo: None,
62        }
63    }
64
65    pub fn git_worktree(base: impl Into<PathBuf>) -> Self {
66        Self {
67            base: base.into(),
68            mode: WorkspaceMode::GitWorktree,
69            repo: None,
70        }
71    }
72
73    /// Git worktrees of `repo`, created under `base` (which may be anywhere on
74    /// the filesystem, e.g. `~/.car/coder/worktrees`).
75    pub fn git_worktree_at(repo: impl Into<PathBuf>, base: impl Into<PathBuf>) -> Self {
76        Self {
77            base: base.into(),
78            mode: WorkspaceMode::GitWorktree,
79            repo: Some(repo.into()),
80        }
81    }
82}
83
84/// Sanitize an agent name into a single safe path segment. Non-`[A-Za-z0-9_-]`
85/// chars (including `.` and `/`) collapse to `-`, so no traversal or separator
86/// can escape `base`. Note: distinct names can collide after sanitization (e.g.
87/// `a/b` and `a-b`), sharing a workspace — keep agent names distinct under this
88/// mapping when isolation matters.
89fn sanitize(name: &str) -> String {
90    let s: String = name
91        .chars()
92        .map(|c| {
93            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
94                c
95            } else {
96                '-'
97            }
98        })
99        .collect();
100    if s.is_empty() {
101        "agent".to_string()
102    } else {
103        s
104    }
105}
106
107/// An RAII handle to a provisioned per-agent workspace. The directory (or git
108/// worktree) is removed when this is dropped.
109#[derive(Debug)]
110pub struct AgentWorkspace {
111    path: PathBuf,
112    mode: WorkspaceMode,
113    /// The git repo root, for `git worktree remove` on drop (GitWorktree only).
114    repo_root: Option<PathBuf>,
115}
116
117impl AgentWorkspace {
118    /// Provision an isolated workspace for `agent_name` under `config.base`.
119    pub fn provision(config: &WorkspaceConfig, agent_name: &str) -> Result<Self, String> {
120        let path = config.base.join(sanitize(agent_name));
121        match config.mode {
122            WorkspaceMode::Directory => {
123                std::fs::create_dir_all(&path)
124                    .map_err(|e| format!("create workspace dir {}: {e}", path.display()))?;
125                Ok(Self {
126                    path,
127                    mode: WorkspaceMode::Directory,
128                    repo_root: None,
129                })
130            }
131            WorkspaceMode::GitWorktree => {
132                use std::ffi::OsStr;
133                let repo_hint = config.repo.as_ref().unwrap_or(&config.base);
134                let repo_root = git_repo_root(repo_hint).ok_or_else(|| {
135                    format!(
136                        "git_worktree workspace requires {} to be inside a git repo",
137                        repo_hint.display()
138                    )
139                })?;
140                std::fs::create_dir_all(&config.base)
141                    .map_err(|e| format!("create workspace base {}: {e}", config.base.display()))?;
142                // Self-heal against a worktree leaked by a prior run that didn't
143                // get to clean up (process/runtime teardown): drop any stale
144                // registration for this exact path, prune dangling entries, and
145                // clear the directory before adding.
146                let _ = run_git(
147                    &repo_root,
148                    &[
149                        OsStr::new("worktree"),
150                        OsStr::new("remove"),
151                        OsStr::new("--force"),
152                        path.as_os_str(),
153                    ],
154                );
155                let _ = run_git(&repo_root, &[OsStr::new("worktree"), OsStr::new("prune")]);
156                if path.exists() {
157                    let _ = std::fs::remove_dir_all(&path);
158                }
159                run_git(
160                    &repo_root,
161                    &[
162                        OsStr::new("worktree"),
163                        OsStr::new("add"),
164                        OsStr::new("--detach"),
165                        path.as_os_str(),
166                        OsStr::new("HEAD"),
167                    ],
168                )?;
169                Ok(Self {
170                    path,
171                    mode: WorkspaceMode::GitWorktree,
172                    repo_root: Some(repo_root),
173                })
174            }
175        }
176    }
177
178    /// The provisioned workspace path.
179    pub fn path(&self) -> &Path {
180        &self.path
181    }
182
183    /// Return `spec` with this workspace's path advertised in its metadata.
184    pub fn inject(&self, mut spec: AgentSpec) -> AgentSpec {
185        spec.metadata.insert(
186            WORKSPACE_METADATA_KEY.to_string(),
187            Value::String(self.path.to_string_lossy().into_owned()),
188        );
189        spec
190    }
191}
192
193impl Drop for AgentWorkspace {
194    fn drop(&mut self) {
195        match self.mode {
196            WorkspaceMode::Directory => {
197                let _ = std::fs::remove_dir_all(&self.path);
198            }
199            WorkspaceMode::GitWorktree => {
200                if let Some(root) = &self.repo_root {
201                    use std::ffi::OsStr;
202                    // Best-effort: detach the worktree, then remove the dir.
203                    let _ = run_git(
204                        root,
205                        &[
206                            OsStr::new("worktree"),
207                            OsStr::new("remove"),
208                            OsStr::new("--force"),
209                            self.path.as_os_str(),
210                        ],
211                    );
212                    let _ = std::fs::remove_dir_all(&self.path);
213                }
214            }
215        }
216    }
217}
218
219/// Find the git working-tree root containing `dir`, if any.
220fn git_repo_root(dir: &Path) -> Option<PathBuf> {
221    let out = std::process::Command::new("git")
222        .arg("-C")
223        .arg(dir)
224        .args(["rev-parse", "--show-toplevel"])
225        .output()
226        .ok()?;
227    if !out.status.success() {
228        return None;
229    }
230    let root = String::from_utf8(out.stdout).ok()?.trim().to_string();
231    if root.is_empty() {
232        None
233    } else {
234        Some(PathBuf::from(root))
235    }
236}
237
238fn run_git(repo_root: &Path, args: &[&std::ffi::OsStr]) -> Result<(), String> {
239    let out = std::process::Command::new("git")
240        .arg("-C")
241        .arg(repo_root)
242        .args(args)
243        .output()
244        .map_err(|e| format!("git {:?}: {e}", args))?;
245    if out.status.success() {
246        Ok(())
247    } else {
248        Err(format!(
249            "git {:?} failed: {}",
250            args,
251            String::from_utf8_lossy(&out.stderr).trim()
252        ))
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    fn unique_base(tag: &str) -> PathBuf {
261        // Avoid Math.random/Date in tests; use pid + a static counter.
262        use std::sync::atomic::{AtomicU64, Ordering};
263        static N: AtomicU64 = AtomicU64::new(0);
264        let n = N.fetch_add(1, Ordering::Relaxed);
265        std::env::temp_dir().join(format!("car-ws-{tag}-{}-{n}", std::process::id()))
266    }
267
268    #[test]
269    fn directory_workspace_is_created_injected_and_cleaned() {
270        let base = unique_base("dir");
271        let cfg = WorkspaceConfig::directory(&base);
272        let path;
273        {
274            let ws = AgentWorkspace::provision(&cfg, "alice/../x").unwrap();
275            path = ws.path().to_path_buf();
276            assert!(path.exists() && path.is_dir());
277            // Sanitized: no path traversal segments survive.
278            assert_eq!(path.parent().unwrap(), base);
279            assert!(!path.to_string_lossy().contains(".."));
280
281            let spec = ws.inject(AgentSpec::new("alice", "sys"));
282            assert_eq!(
283                spec.metadata.get(WORKSPACE_METADATA_KEY).unwrap(),
284                &Value::String(path.to_string_lossy().into_owned())
285            );
286        }
287        // Dropped → cleaned up.
288        assert!(!path.exists(), "workspace should be removed on drop");
289        let _ = std::fs::remove_dir_all(&base);
290    }
291
292    #[test]
293    fn git_worktree_at_provisions_outside_the_repo() {
294        // Skip silently when git is unavailable (mirrors CI environments
295        // without a git binary; the mode itself errors clearly there).
296        if std::process::Command::new("git")
297            .arg("--version")
298            .output()
299            .is_err()
300        {
301            return;
302        }
303        let repo = unique_base("repo");
304        std::fs::create_dir_all(&repo).unwrap();
305        for args in [
306            vec!["init", "-q"],
307            vec![
308                "-c",
309                "user.name=t",
310                "-c",
311                "user.email=t@t",
312                "commit",
313                "-q",
314                "--allow-empty",
315                "-m",
316                "init",
317            ],
318        ] {
319            let out = std::process::Command::new("git")
320                .arg("-C")
321                .arg(&repo)
322                .args(&args)
323                .output()
324                .unwrap();
325            assert!(
326                out.status.success(),
327                "git {args:?}: {}",
328                String::from_utf8_lossy(&out.stderr)
329            );
330        }
331
332        let base = unique_base("wt-base");
333        let cfg = WorkspaceConfig::git_worktree_at(&repo, &base);
334        let path;
335        {
336            let ws = AgentWorkspace::provision(&cfg, "session-1").unwrap();
337            path = ws.path().to_path_buf();
338            assert!(
339                path.starts_with(&base),
340                "worktree must live under base, not the repo"
341            );
342            assert!(path.join(".git").exists(), "worktree checkout expected");
343            // The repo's status stays clean — the worktree is elsewhere.
344            let out = std::process::Command::new("git")
345                .arg("-C")
346                .arg(&repo)
347                .args(["status", "--porcelain"])
348                .output()
349                .unwrap();
350            assert!(out.stdout.is_empty(), "repo status must stay clean");
351        }
352        assert!(!path.exists(), "worktree removed on drop");
353        let _ = std::fs::remove_dir_all(&base);
354        let _ = std::fs::remove_dir_all(&repo);
355    }
356
357    #[test]
358    fn distinct_agents_get_distinct_dirs() {
359        let base = unique_base("distinct");
360        let cfg = WorkspaceConfig::directory(&base);
361        let a = AgentWorkspace::provision(&cfg, "a").unwrap();
362        let b = AgentWorkspace::provision(&cfg, "b").unwrap();
363        assert_ne!(a.path(), b.path());
364        drop(a);
365        drop(b);
366        let _ = std::fs::remove_dir_all(&base);
367    }
368}