rho-coding-agent 1.26.0

A lightweight agent harness inspired by Pi
Documentation
use std::{
    collections::BTreeSet,
    path::{Path, PathBuf},
};

use rho_sdk::{tool::ToolContext, CapabilityRequest, CapabilitySource, PathScope};

use super::{
    executable_candidates, path_scope, project_agent_catalogs_trusted, AppWorkflowToolService,
};

impl AppWorkflowToolService {
    pub(super) async fn authorized_config(
        &self,
        context: &ToolContext,
        path: &Path,
    ) -> anyhow::Result<crate::config::Config> {
        self.authorize_read(context, path, PathScope::UnrestrictedFilesystem)
            .await?;
        let opened =
            match crate::workflow::VerifiedPath::open(path, crate::workflow::ContentHash::Skip) {
                Ok(opened) => opened,
                Err(crate::workflow::WorkflowError::Io(error))
                    if error.kind() == std::io::ErrorKind::NotFound =>
                {
                    return Ok(crate::config::Config::default());
                }
                Err(error) => return Err(error.into()),
            };
        self.authorize_opened_identity(context, &opened.identity)
            .await?;
        let text = opened.read_utf8()?;
        crate::config::Config::parse_settings(&text)
    }

    pub(super) async fn authorize_path(
        &self,
        context: &ToolContext,
        path: &Path,
    ) -> anyhow::Result<crate::workflow::VerifiedPath> {
        let lexical = if path.is_absolute() {
            path.to_path_buf()
        } else {
            self.cwd.join(path)
        };
        if !lexical.starts_with(&self.cwd) {
            anyhow::bail!("workflow source is outside the workspace");
        }
        context
            .authorize(CapabilityRequest::read_path(
                &lexical,
                PathScope::PrimaryWorkspace,
                CapabilitySource::built_in_tool("workflow"),
            ))
            .await
            .map_err(|error| anyhow::anyhow!(error.to_string()))?;
        let workspace = context
            .workspace()
            .ok_or_else(|| anyhow::anyhow!("workflow tool requires a workspace"))?;
        let resolved = workspace
            .resolve_for_read(path)
            .map_err(|error| anyhow::anyhow!(error.to_string()))?;
        if !resolved.path().starts_with(&self.cwd) {
            anyhow::bail!("workflow source is outside the workspace");
        }
        context
            .authorize(CapabilityRequest::read_path(
                resolved.path(),
                resolved.scope().clone(),
                CapabilitySource::built_in_tool("workflow"),
            ))
            .await
            .map_err(|error| anyhow::anyhow!(error.to_string()))?;
        if resolved.path() != lexical {
            return Err(crate::workflow::WorkflowError::SourceSymlink { path: lexical }.into());
        }
        let opened = crate::workflow::VerifiedPath::open(
            resolved.path(),
            crate::workflow::ContentHash::Skip,
        )?;
        self.authorize_opened_identity(context, &opened.identity)
            .await?;
        if Path::new(&opened.identity.canonical_path) != resolved.path() {
            return Err(crate::workflow::WorkflowError::SourceSymlink {
                path: resolved.path().to_path_buf(),
            }
            .into());
        }
        Ok(opened)
    }

    pub(super) async fn authorized_agent_catalog(
        &self,
        context: &ToolContext,
        workflow_entry: &Path,
    ) -> anyhow::Result<crate::agent::AgentCatalog> {
        let home = crate::paths::home_dir();
        let mut sources = crate::agent::AgentCatalogSources::default();
        if let Some(home) = home.as_deref() {
            sources.agents_home = self
                .authorized_agent_sources(context, &home.join(".agents/agents"))
                .await?;
            sources.rho_home = self
                .authorized_agent_sources(context, &home.join(".rho/agents"))
                .await?;
        }
        if project_agent_catalogs_trusted() {
            for root in crate::workspace::project_ancestor_dirs(&self.cwd)
                .into_iter()
                .map(|path| path.join(".agents/agents"))
            {
                sources
                    .project
                    .push(self.authorized_agent_sources(context, &root).await?);
            }
        }
        let workflow_agents = {
            let root = crate::agent::workflow_local_agents_root(workflow_entry);
            if root.is_absolute() {
                root
            } else {
                self.cwd.join(root)
            }
        };
        sources.workflow = self
            .authorized_agent_sources(context, &workflow_agents)
            .await?;
        crate::agent::AgentCatalog::from_authorized_sources(sources).map_err(Into::into)
    }

    async fn authorized_agent_sources(
        &self,
        context: &ToolContext,
        root: &Path,
    ) -> anyhow::Result<Vec<(PathBuf, String)>> {
        let scope = path_scope(&self.cwd, root);
        self.authorize_read(context, root, scope.clone()).await?;
        let opened_root = match crate::workflow::open_verified_directory(root) {
            Ok(opened) => opened,
            Err(crate::workflow::WorkflowError::Io(error))
                if error.kind() == std::io::ErrorKind::NotFound =>
            {
                return Ok(Vec::new());
            }
            Err(error) => return Err(error.into()),
        };
        self.authorize_opened_identity(context, &opened_root.identity)
            .await?;
        let mut names = crate::workflow::opened_directory_names(&opened_root)?;
        names.retain(|name| {
            Path::new(name)
                .extension()
                .is_some_and(|extension| extension == "md")
        });
        names.sort();
        let mut sources = Vec::new();
        for name in names {
            let path = Path::new(&opened_root.identity.canonical_path).join(&name);
            self.authorize_read(context, &path, scope.clone()).await?;
            let opened = crate::workflow::open_verified_file_in_directory(
                &opened_root,
                Path::new(&name),
                crate::workflow::ContentHash::Skip,
            )?;
            self.authorize_opened_identity(context, &opened.identity)
                .await?;
            let source = opened.read_utf8()?;
            sources.push((path, source));
        }
        Ok(sources)
    }

    pub(super) async fn authorize_node_resolution_reads(
        &self,
        graph: &crate::workflow::WorkflowGraph,
        catalog: &crate::agent::AgentCatalog,
        context: &ToolContext,
    ) -> anyhow::Result<std::collections::BTreeMap<String, crate::workflow::ExecutableIdentity>>
    {
        let mut executables = BTreeSet::new();
        let mut directories = BTreeSet::new();
        for node in graph.nodes.values() {
            match &node.execution {
                crate::workflow::NodeExecution::Command(command) => {
                    let (executable, cwd) = match command {
                        crate::workflow::CommandNode::Direct {
                            executable, cwd, ..
                        }
                        | crate::workflow::CommandNode::Shell {
                            executable, cwd, ..
                        } => (executable, cwd),
                    };
                    executables.insert(executable.clone());
                    directories.insert(self.cwd.join(cwd));
                }
                crate::workflow::NodeExecution::Agent(agent) => {
                    let entry = catalog.find(&agent.agent)?;
                    if matches!(
                        entry.definition.runtime,
                        crate::agent::AgentRuntimeSpec::ClaudeCli(_)
                    ) {
                        executables.insert("claude".to_owned());
                    }
                }
            }
        }
        for directory in directories {
            self.authorize_workspace_identity_path(context, &directory)
                .await?;
        }
        let mut identities = std::collections::BTreeMap::new();
        for executable in executables {
            let opened = self
                .authorize_opened_executable_resolution(context, &executable)
                .await?;
            let interpreter = match opened.interpreter_request.as_ref() {
                Some(crate::workflow::ExecutableInterpreterRequest::Absolute(path)) => {
                    Some(self.authorize_opened_executable_path(context, path).await?)
                }
                Some(crate::workflow::ExecutableInterpreterRequest::Search(program)) => Some(
                    self.authorize_opened_executable_resolution(context, program)
                        .await?,
                ),
                None => None,
            };
            let interpreter = interpreter
                .map(crate::workflow::OpenedExecutable::into_binary)
                .transpose()?;
            identities.insert(
                executable,
                crate::workflow::freeze_opened_executable(opened, interpreter)?,
            );
        }
        Ok(identities)
    }

    async fn authorize_opened_executable_resolution(
        &self,
        context: &ToolContext,
        executable: &str,
    ) -> anyhow::Result<crate::workflow::OpenedExecutable> {
        let path = Path::new(executable);
        if path.components().count() == 1 {
            let candidates = executable_candidates(executable);
            for candidate in &candidates {
                self.authorize_read(context, candidate, path_scope(&self.cwd, candidate))
                    .await?;
            }
            for candidate in candidates {
                match crate::workflow::open_executable_candidate(&candidate) {
                    Ok(Some(opened)) => {
                        self.authorize_read(context, &candidate, path_scope(&self.cwd, &candidate))
                            .await?;
                        self.authorize_opened_identity(context, opened.identity())
                            .await?;
                        return Ok(opened);
                    }
                    Ok(None) => {}
                    Err(crate::workflow::WorkflowError::Io(error))
                        if matches!(
                            error.kind(),
                            std::io::ErrorKind::NotFound | std::io::ErrorKind::PermissionDenied
                        ) => {}
                    Err(error) => return Err(error.into()),
                }
            }
            anyhow::bail!("executable '{executable}' was not found on PATH");
        } else {
            let lexical = if path.is_absolute() {
                path.to_path_buf()
            } else {
                self.cwd.join(path)
            };
            self.authorize_read(context, &lexical, path_scope(&self.cwd, &lexical))
                .await?;
            let opened = crate::workflow::open_executable(&lexical)?;
            self.authorize_opened_identity(context, opened.identity())
                .await?;
            Ok(opened)
        }
    }

    async fn authorize_opened_executable_path(
        &self,
        context: &ToolContext,
        path: &Path,
    ) -> anyhow::Result<crate::workflow::OpenedExecutable> {
        self.authorize_read(context, path, path_scope(&self.cwd, path))
            .await?;
        let opened = crate::workflow::open_executable(path)?;
        self.authorize_opened_identity(context, opened.identity())
            .await?;
        Ok(opened)
    }

    async fn authorize_workspace_identity_path(
        &self,
        context: &ToolContext,
        path: &Path,
    ) -> anyhow::Result<()> {
        self.authorize_identity_path(context, path, PathScope::PrimaryWorkspace)
            .await
    }

    async fn authorize_identity_path(
        &self,
        context: &ToolContext,
        path: &Path,
        scope: PathScope,
    ) -> anyhow::Result<()> {
        self.authorize_read(context, path, scope).await?;
        let canonical = path.canonicalize()?;
        if canonical != path {
            self.authorize_read(context, &canonical, path_scope(&self.cwd, &canonical))
                .await?;
        }
        Ok(())
    }

    async fn authorize_opened_identity(
        &self,
        context: &ToolContext,
        identity: &crate::workflow::FrozenPathIdentity,
    ) -> anyhow::Result<()> {
        let canonical = Path::new(&identity.canonical_path);
        self.authorize_read(context, canonical, path_scope(&self.cwd, canonical))
            .await?;
        Ok(())
    }

    async fn authorize_read(
        &self,
        context: &ToolContext,
        path: &Path,
        scope: PathScope,
    ) -> anyhow::Result<()> {
        context
            .authorize(CapabilityRequest::read_path(
                path,
                scope,
                CapabilitySource::built_in_tool("workflow"),
            ))
            .await
            .map(|_| ())
            .map_err(|error| anyhow::anyhow!(error.to_string()))
    }
}