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 (or at
35    /// [`WorkspaceConfig::rev`] when set), so each agent edits an
36    /// isolated copy of the repository. Requires `base` to be inside a git repo
37    /// and a `git` binary on PATH; falls back to an error if either is missing.
38    GitWorktree,
39}
40
41/// Configuration for per-agent workspace provisioning.
42#[derive(Debug, Clone)]
43pub struct WorkspaceConfig {
44    /// Base directory under which per-agent workspaces are created. For
45    /// `GitWorktree` this must be inside (or be) a git working tree, unless
46    /// `repo` names the repository explicitly.
47    pub base: PathBuf,
48    pub mode: WorkspaceMode,
49    /// For `GitWorktree`: the repository to check worktrees out of, when it
50    /// differs from `base`. `None` derives the repo from `base` (the original
51    /// behavior, where worktrees land inside the repo itself). Setting this
52    /// lets worktrees live *outside* the repository — e.g. under a state dir —
53    /// so they never show up as untracked entries in the user's checkout.
54    pub repo: Option<PathBuf>,
55    /// For `GitWorktree`: the commit-ish to check the worktree out at. `None`
56    /// is the repository's `HEAD`, the original behavior. Setting it lets a
57    /// session start from work that is not checked out — another developer's
58    /// published branch, say — without touching the user's checkout.
59    pub rev: Option<String>,
60}
61
62impl WorkspaceConfig {
63    pub fn directory(base: impl Into<PathBuf>) -> Self {
64        Self {
65            base: base.into(),
66            mode: WorkspaceMode::Directory,
67            repo: None,
68            rev: None,
69        }
70    }
71
72    pub fn git_worktree(base: impl Into<PathBuf>) -> Self {
73        Self {
74            base: base.into(),
75            mode: WorkspaceMode::GitWorktree,
76            repo: None,
77            rev: None,
78        }
79    }
80
81    /// Git worktrees of `repo`, created under `base` (which may be anywhere on
82    /// the filesystem, e.g. `~/.car/coder/worktrees`).
83    pub fn git_worktree_at(repo: impl Into<PathBuf>, base: impl Into<PathBuf>) -> Self {
84        Self {
85            base: base.into(),
86            mode: WorkspaceMode::GitWorktree,
87            repo: Some(repo.into()),
88            rev: None,
89        }
90    }
91
92    /// Check the worktree out at `rev` instead of `HEAD`.
93    pub fn with_rev(mut self, rev: impl Into<String>) -> Self {
94        self.rev = Some(rev.into());
95        self
96    }
97}
98
99/// Sanitize an agent name into a single safe path segment. Non-`[A-Za-z0-9_-]`
100/// chars (including `.` and `/`) collapse to `-`, so no traversal or separator
101/// can escape `base`. Note: distinct names can collide after sanitization (e.g.
102/// `a/b` and `a-b`), sharing a workspace — keep agent names distinct under this
103/// mapping when isolation matters.
104fn sanitize(name: &str) -> String {
105    let s: String = name
106        .chars()
107        .map(|c| {
108            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
109                c
110            } else {
111                '-'
112            }
113        })
114        .collect();
115    if s.is_empty() {
116        "agent".to_string()
117    } else {
118        s
119    }
120}
121
122/// An RAII handle to a provisioned per-agent workspace. The directory (or git
123/// worktree) is removed when this is dropped.
124#[derive(Debug)]
125pub struct AgentWorkspace {
126    path: PathBuf,
127    mode: WorkspaceMode,
128    /// The git repo root, for `git worktree remove` on drop (GitWorktree only).
129    repo_root: Option<PathBuf>,
130    cleanup_on_drop: bool,
131}
132
133impl AgentWorkspace {
134    /// Reopen an existing linked worktree without resetting or deleting it.
135    /// Retained data survives an early error or dropped handle; the caller may
136    /// enable cleanup only after successful delivery.
137    pub fn reopen_git_worktree(repo: &Path, path: &Path) -> Result<Self, String> {
138        let repo = repo.canonicalize().map_err(|e| e.to_string())?;
139        let path = path.canonicalize().map_err(|e| e.to_string())?;
140        let root = git_repo_root(&path)
141            .ok_or("retained directory is not a git worktree")?
142            .canonicalize()
143            .map_err(|e| e.to_string())?;
144        if root != path || repo == path || !path.join(".git").is_file() {
145            return Err(
146                "retained path must be a linked worktree root, not the user's checkout".into(),
147            );
148        }
149        let common = |dir: &Path| -> Result<PathBuf, String> {
150            let out = std::process::Command::new("git")
151                .arg("-C")
152                .arg(dir)
153                .args(["rev-parse", "--path-format=absolute", "--git-common-dir"])
154                .output()
155                .map_err(|e| e.to_string())?;
156            if !out.status.success() {
157                return Err("cannot identify retained worktree repository".into());
158            }
159            PathBuf::from(
160                String::from_utf8(out.stdout)
161                    .map_err(|e| e.to_string())?
162                    .trim(),
163            )
164            .canonicalize()
165            .map_err(|e| e.to_string())
166        };
167        if common(&repo)? != common(&path)? {
168            return Err("retained worktree belongs to another repository".into());
169        }
170        Ok(Self {
171            path,
172            mode: WorkspaceMode::GitWorktree,
173            repo_root: Some(repo),
174            cleanup_on_drop: false,
175        })
176    }
177
178    /// Allow a successfully delivered retained workspace to be removed.
179    pub fn enable_cleanup(&mut self) {
180        self.cleanup_on_drop = true;
181    }
182
183    /// Provision an isolated workspace for `agent_name` under `config.base`.
184    pub fn provision(config: &WorkspaceConfig, agent_name: &str) -> Result<Self, String> {
185        let path = config.base.join(sanitize(agent_name));
186        match config.mode {
187            WorkspaceMode::Directory => {
188                std::fs::create_dir_all(&path)
189                    .map_err(|e| format!("create workspace dir {}: {e}", path.display()))?;
190                Ok(Self {
191                    path,
192                    mode: WorkspaceMode::Directory,
193                    repo_root: None,
194                    cleanup_on_drop: true,
195                })
196            }
197            WorkspaceMode::GitWorktree => {
198                use std::ffi::OsStr;
199                let rev = config.rev.as_deref().unwrap_or("HEAD");
200                // git parses options after positionals, so a rev beginning with
201                // `-` would be read as a flag to `worktree add`, not a commit.
202                if rev.is_empty() || rev.starts_with('-') {
203                    return Err(format!("invalid worktree revision {rev:?}"));
204                }
205                let repo_hint = config.repo.as_ref().unwrap_or(&config.base);
206                let repo_root = git_repo_root(repo_hint).ok_or_else(|| {
207                    format!(
208                        "git_worktree workspace requires {} to be inside a git repo",
209                        repo_hint.display()
210                    )
211                })?;
212                std::fs::create_dir_all(&config.base)
213                    .map_err(|e| format!("create workspace base {}: {e}", config.base.display()))?;
214                // Self-heal against a worktree leaked by a prior run that didn't
215                // get to clean up (process/runtime teardown): drop any stale
216                // registration for this exact path, prune dangling entries, and
217                // clear the directory before adding.
218                let _ = run_git(
219                    &repo_root,
220                    &[
221                        OsStr::new("worktree"),
222                        OsStr::new("remove"),
223                        OsStr::new("--force"),
224                        path.as_os_str(),
225                    ],
226                );
227                let _ = run_git(&repo_root, &[OsStr::new("worktree"), OsStr::new("prune")]);
228                if path.exists() {
229                    let _ = std::fs::remove_dir_all(&path);
230                }
231                run_git(
232                    &repo_root,
233                    &[
234                        OsStr::new("worktree"),
235                        OsStr::new("add"),
236                        OsStr::new("--detach"),
237                        path.as_os_str(),
238                        OsStr::new(rev),
239                    ],
240                )?;
241                Ok(Self {
242                    path,
243                    mode: WorkspaceMode::GitWorktree,
244                    repo_root: Some(repo_root),
245                    cleanup_on_drop: true,
246                })
247            }
248        }
249    }
250
251    /// The provisioned workspace path.
252    pub fn path(&self) -> &Path {
253        &self.path
254    }
255
256    /// Return `spec` with this workspace's path advertised in its metadata.
257    pub fn inject(&self, mut spec: AgentSpec) -> AgentSpec {
258        spec.metadata.insert(
259            WORKSPACE_METADATA_KEY.to_string(),
260            Value::String(self.path.to_string_lossy().into_owned()),
261        );
262        spec
263    }
264}
265
266impl Drop for AgentWorkspace {
267    fn drop(&mut self) {
268        if !self.cleanup_on_drop {
269            return;
270        }
271        match self.mode {
272            WorkspaceMode::Directory => {
273                let _ = std::fs::remove_dir_all(&self.path);
274            }
275            WorkspaceMode::GitWorktree => {
276                if let Some(root) = &self.repo_root {
277                    use std::ffi::OsStr;
278                    // Best-effort: detach the worktree, then remove the dir.
279                    let _ = run_git(
280                        root,
281                        &[
282                            OsStr::new("worktree"),
283                            OsStr::new("remove"),
284                            OsStr::new("--force"),
285                            self.path.as_os_str(),
286                        ],
287                    );
288                    let _ = std::fs::remove_dir_all(&self.path);
289                }
290            }
291        }
292    }
293}
294
295/// Find the git working-tree root containing `dir`, if any.
296fn git_repo_root(dir: &Path) -> Option<PathBuf> {
297    let out = std::process::Command::new("git")
298        .arg("-C")
299        .arg(dir)
300        .args(["rev-parse", "--show-toplevel"])
301        .output()
302        .ok()?;
303    if !out.status.success() {
304        return None;
305    }
306    let root = String::from_utf8(out.stdout).ok()?.trim().to_string();
307    if root.is_empty() {
308        None
309    } else {
310        Some(PathBuf::from(root))
311    }
312}
313
314fn run_git(repo_root: &Path, args: &[&std::ffi::OsStr]) -> Result<(), String> {
315    let out = std::process::Command::new("git")
316        .arg("-C")
317        .arg(repo_root)
318        .args(args)
319        .output()
320        .map_err(|e| format!("git {:?}: {e}", args))?;
321    if out.status.success() {
322        Ok(())
323    } else {
324        Err(format!(
325            "git {:?} failed: {}",
326            args,
327            String::from_utf8_lossy(&out.stderr).trim()
328        ))
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    fn unique_base(tag: &str) -> PathBuf {
337        // Avoid Math.random/Date in tests; use pid + a static counter.
338        use std::sync::atomic::{AtomicU64, Ordering};
339        static N: AtomicU64 = AtomicU64::new(0);
340        let n = N.fetch_add(1, Ordering::Relaxed);
341        std::env::temp_dir().join(format!("car-ws-{tag}-{}-{n}", std::process::id()))
342    }
343
344    #[test]
345    fn directory_workspace_is_created_injected_and_cleaned() {
346        let base = unique_base("dir");
347        let cfg = WorkspaceConfig::directory(&base);
348        let path;
349        {
350            let ws = AgentWorkspace::provision(&cfg, "alice/../x").unwrap();
351            path = ws.path().to_path_buf();
352            assert!(path.exists() && path.is_dir());
353            // Sanitized: no path traversal segments survive.
354            assert_eq!(path.parent().unwrap(), base);
355            assert!(!path.to_string_lossy().contains(".."));
356
357            let spec = ws.inject(AgentSpec::new("alice", "sys"));
358            assert_eq!(
359                spec.metadata.get(WORKSPACE_METADATA_KEY).unwrap(),
360                &Value::String(path.to_string_lossy().into_owned())
361            );
362        }
363        // Dropped → cleaned up.
364        assert!(!path.exists(), "workspace should be removed on drop");
365        let _ = std::fs::remove_dir_all(&base);
366    }
367
368    #[test]
369    fn git_worktree_at_provisions_outside_the_repo() {
370        // Skip silently when git is unavailable (mirrors CI environments
371        // without a git binary; the mode itself errors clearly there).
372        if std::process::Command::new("git")
373            .arg("--version")
374            .output()
375            .is_err()
376        {
377            return;
378        }
379        let repo = unique_base("repo");
380        std::fs::create_dir_all(&repo).unwrap();
381        for args in [
382            vec!["init", "-q"],
383            vec![
384                "-c",
385                "user.name=t",
386                "-c",
387                "user.email=t@t",
388                "commit",
389                "-q",
390                "--allow-empty",
391                "-m",
392                "init",
393            ],
394        ] {
395            let out = std::process::Command::new("git")
396                .arg("-C")
397                .arg(&repo)
398                .args(&args)
399                .output()
400                .unwrap();
401            assert!(
402                out.status.success(),
403                "git {args:?}: {}",
404                String::from_utf8_lossy(&out.stderr)
405            );
406        }
407
408        let base = unique_base("wt-base");
409        let cfg = WorkspaceConfig::git_worktree_at(&repo, &base);
410        let path;
411        {
412            let ws = AgentWorkspace::provision(&cfg, "session-1").unwrap();
413            path = ws.path().to_path_buf();
414            assert!(
415                path.starts_with(&base),
416                "worktree must live under base, not the repo"
417            );
418            assert!(path.join(".git").exists(), "worktree checkout expected");
419            // The repo's status stays clean — the worktree is elsewhere.
420            let out = std::process::Command::new("git")
421                .arg("-C")
422                .arg(&repo)
423                .args(["status", "--porcelain"])
424                .output()
425                .unwrap();
426            assert!(out.stdout.is_empty(), "repo status must stay clean");
427        }
428        assert!(!path.exists(), "worktree removed on drop");
429        let _ = std::fs::remove_dir_all(&base);
430        let _ = std::fs::remove_dir_all(&repo);
431    }
432
433    fn git_out(dir: &Path, args: &[&str]) -> String {
434        let out = std::process::Command::new("git")
435            .arg("-C")
436            .arg(dir)
437            .args(args)
438            .output()
439            .unwrap();
440        assert!(
441            out.status.success(),
442            "git {args:?}: {}",
443            String::from_utf8_lossy(&out.stderr)
444        );
445        String::from_utf8(out.stdout).unwrap().trim().to_string()
446    }
447
448    #[test]
449    fn reopening_retained_work_is_nondestructive_and_repository_bound() {
450        let repo = unique_base("reopen-repo");
451        let other = unique_base("reopen-other");
452        let base = unique_base("reopen-worktrees");
453        for root in [&repo, &other] {
454            std::fs::create_dir_all(root).unwrap();
455            git_out(root, &["init", "-q"]);
456            git_out(
457                root,
458                &[
459                    "-c",
460                    "user.name=t",
461                    "-c",
462                    "user.email=t@t",
463                    "commit",
464                    "--allow-empty",
465                    "-qm",
466                    "initial",
467                ],
468            );
469        }
470        let workspace =
471            AgentWorkspace::provision(&WorkspaceConfig::git_worktree_at(&repo, &base), "retained")
472                .unwrap();
473        let path = workspace.path().to_path_buf();
474        std::fs::write(path.join("partial.txt"), "unfinished").unwrap();
475        std::mem::forget(workspace);
476        assert!(AgentWorkspace::reopen_git_worktree(&other, &path).is_err());
477        assert!(AgentWorkspace::reopen_git_worktree(&repo, &repo).is_err());
478        let reopened = AgentWorkspace::reopen_git_worktree(&repo, &path).unwrap();
479        drop(reopened);
480        assert_eq!(
481            std::fs::read_to_string(path.join("partial.txt")).unwrap(),
482            "unfinished"
483        );
484        let mut delivered = AgentWorkspace::reopen_git_worktree(&repo, &path).unwrap();
485        delivered.enable_cleanup();
486        drop(delivered);
487        assert!(!path.exists());
488        assert!(repo.is_dir());
489        for root in [repo, other, base] {
490            let _ = std::fs::remove_dir_all(root);
491        }
492    }
493
494    #[test]
495    fn git_worktree_checks_out_the_requested_rev_not_head() {
496        if std::process::Command::new("git")
497            .arg("--version")
498            .output()
499            .is_err()
500        {
501            return;
502        }
503        let repo = unique_base("rev-repo");
504        std::fs::create_dir_all(&repo).unwrap();
505        let commit = ["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q"];
506        git_out(&repo, &["init", "-q"]);
507        git_out(
508            &repo,
509            &[&commit[..], &["--allow-empty", "-m", "one"]].concat(),
510        );
511        let first = git_out(&repo, &["rev-parse", "HEAD"]);
512        git_out(
513            &repo,
514            &[&commit[..], &["--allow-empty", "-m", "two"]].concat(),
515        );
516        let head = git_out(&repo, &["rev-parse", "HEAD"]);
517        assert_ne!(first, head);
518
519        let base = unique_base("rev-wt");
520        // Positive control: without a rev the worktree is at HEAD, so the
521        // assertion below distinguishes the two.
522        let at_head =
523            AgentWorkspace::provision(&WorkspaceConfig::git_worktree_at(&repo, &base), "h")
524                .unwrap();
525        assert_eq!(git_out(at_head.path(), &["rev-parse", "HEAD"]), head);
526        drop(at_head);
527
528        let cfg = WorkspaceConfig::git_worktree_at(&repo, &base).with_rev(first.clone());
529        let ws = AgentWorkspace::provision(&cfg, "r").unwrap();
530        assert_eq!(git_out(ws.path(), &["rev-parse", "HEAD"]), first);
531        drop(ws);
532        let _ = std::fs::remove_dir_all(&base);
533        let _ = std::fs::remove_dir_all(&repo);
534    }
535
536    #[test]
537    fn git_worktree_refuses_a_rev_that_git_would_parse_as_a_flag() {
538        let base = unique_base("rev-flag");
539        for rev in ["", "-b", "--orphan=x"] {
540            let cfg = WorkspaceConfig::git_worktree_at(&base, &base).with_rev(rev);
541            let err = AgentWorkspace::provision(&cfg, "x").unwrap_err();
542            assert!(err.contains("invalid worktree revision"), "{rev:?}: {err}");
543        }
544    }
545
546    #[test]
547    fn distinct_agents_get_distinct_dirs() {
548        let base = unique_base("distinct");
549        let cfg = WorkspaceConfig::directory(&base);
550        let a = AgentWorkspace::provision(&cfg, "a").unwrap();
551        let b = AgentWorkspace::provision(&cfg, "b").unwrap();
552        assert_ne!(a.path(), b.path());
553        drop(a);
554        drop(b);
555        let _ = std::fs::remove_dir_all(&base);
556    }
557}