yolop 0.4.0

Yolop — a terminal coding agent built on everruns-runtime
// Shared workspace host for yolop tools that shell out against disk.
//
// everruns 0.17.1 (EVE-660) centralizes VFS addressing in `MountFs` and host
// repointing in `RealDiskFileStore::set_host_root`. File tools see `/workspace`
// through the mount resolver; repo_map, ast_grep, background, and bash still
// run on the host and share one repointable disk handle synced from the
// worktree's active-root lock.

use anyhow::{Context, Result, bail};
use everruns_runtime::RealDiskFileStore;
use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, Mutex, RwLock};

/// Session-mutable workspace root + repointable host disk (EVE-660).
pub struct WorkspaceHost {
    active_root: Arc<RwLock<PathBuf>>,
    disk: Arc<RealDiskFileStore>,
    applied_root: Mutex<PathBuf>,
}

impl WorkspaceHost {
    pub fn new(active_root: Arc<RwLock<PathBuf>>, initial: PathBuf) -> everruns_core::Result<Self> {
        Ok(Self {
            disk: Arc::new(RealDiskFileStore::new(initial.clone())?),
            applied_root: Mutex::new(initial),
            active_root,
        })
    }

    pub fn disk(&self) -> Arc<RealDiskFileStore> {
        self.disk.clone()
    }

    /// Repoint the host disk when the worktree active root changed.
    pub fn sync(&self) -> everruns_core::Result<()> {
        use everruns_core::error::AgentLoopError;

        let current = self
            .active_root
            .read()
            .map_err(|_| AgentLoopError::config("workspace lock poisoned"))?
            .clone();
        let mut applied = self
            .applied_root
            .lock()
            .map_err(|_| AgentLoopError::config("workspace root lock poisoned"))?;
        if *applied != current {
            if current.is_dir() {
                self.disk.set_host_root(current.clone())?;
            }
            *applied = current;
        }
        Ok(())
    }

    pub fn host_root(&self) -> everruns_core::Result<PathBuf> {
        self.sync()?;
        Ok(self.disk.root())
    }

    pub fn spawn_cwd(&self) -> Result<PathBuf, String> {
        let current = self
            .active_root
            .read()
            .map(|guard| guard.clone())
            .map_err(|_| "workspace lock poisoned".to_string())?;
        if !current.is_dir() {
            return Err(format!(
                "workspace directory does not exist: {}",
                current.display()
            ));
        }
        self.sync().map_err(|e| e.to_string())?;
        Ok(self.disk.root())
    }
}

/// Map a model-facing path to a canonical host directory under `root`.
pub fn resolve_host_scope(root: &Path, path: Option<&str>) -> Result<PathBuf> {
    let Some(path) = path else {
        return Ok(root.to_path_buf());
    };
    let trimmed = path.trim();
    let candidate = Path::new(trimmed);
    if candidate.is_absolute() && candidate.starts_with(root) {
        let canonical = candidate
            .canonicalize()
            .with_context(|| format!("path not found: {path}"))?;
        if !canonical.starts_with(root) {
            bail!("`path` must stay inside the workspace");
        }
        return Ok(canonical);
    }

    let normalized = if trimmed == "workspace" || trimmed == "workspace/" {
        "/workspace"
    } else {
        trimmed
    };
    let session = everruns_core::session_path::to_session_path(normalized);
    let relative = session.trim_start_matches('/');
    if relative.is_empty() || relative == "." {
        return Ok(root.to_path_buf());
    }
    if relative.split('/').any(|segment| segment == "..") {
        bail!("`path` must stay inside the workspace");
    }
    let scope = root.join(relative);
    let canonical = scope
        .canonicalize()
        .with_context(|| format!("path not found: {path}"))?;
    if !canonical.starts_with(root) {
        bail!("`path` must stay inside the workspace");
    }
    Ok(canonical)
}

/// Validate that `root` contains no traversal components (for tests/helpers).
#[allow(dead_code)]
pub fn reject_escape(relative: &str) -> Result<()> {
    if Path::new(relative).components().any(|component| {
        matches!(
            component,
            Component::ParentDir | Component::RootDir | Component::Prefix(_)
        )
    }) {
        bail!("`path` must stay inside the workspace");
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;

    #[test]
    fn resolve_host_scope_accepts_workspace_alias() {
        let dir = tempfile::tempdir().expect("tempdir");
        fs::create_dir_all(dir.path().join("pkg")).expect("pkg dir");
        let root = fs::canonicalize(dir.path()).expect("canonical root");

        let scope = resolve_host_scope(&root, Some("/workspace/pkg")).expect("vfs subpath");
        assert_eq!(scope, root.join("pkg"));

        let root_scope = resolve_host_scope(&root, Some("/workspace")).expect("vfs root");
        assert_eq!(root_scope, root);

        let bare = resolve_host_scope(&root, Some("workspace")).expect("bare alias");
        assert_eq!(bare, root);
    }

    #[test]
    fn resolve_host_scope_rejects_escape() {
        let dir = tempfile::tempdir().expect("tempdir");
        let root = fs::canonicalize(dir.path()).expect("canonical root");
        let err = resolve_host_scope(&root, Some("/workspace/../outside")).expect_err("escape");
        assert!(err.to_string().contains("inside the workspace"));
    }

    #[test]
    fn workspace_host_repoints_on_active_root_change() {
        let first = tempfile::tempdir().expect("first");
        let second = tempfile::tempdir().expect("second");
        let active = Arc::new(RwLock::new(first.path().to_path_buf()));
        let host = WorkspaceHost::new(active.clone(), first.path().to_path_buf()).expect("host");

        assert_eq!(host.host_root().expect("root"), host.disk().root());

        *active.write().expect("lock") = second.path().to_path_buf();
        host.sync().expect("sync");
        assert_eq!(
            host.disk().root(),
            fs::canonicalize(second.path()).expect("canonical second")
        );
    }

    #[test]
    fn spawn_cwd_requires_existing_directory() {
        let dir = tempfile::tempdir().expect("tempdir");
        let active = Arc::new(RwLock::new(dir.path().to_path_buf()));
        let host = WorkspaceHost::new(active, dir.path().to_path_buf()).expect("host");
        assert_eq!(host.spawn_cwd().expect("existing dir"), host.disk().root());

        let missing = dir.path().join("removed");
        *host.active_root.write().expect("lock") = missing.clone();
        host.sync().expect("sync");
        let err = host.spawn_cwd().expect_err("missing dir");
        assert!(err.contains("does not exist"));
    }
}