lingshu-tools 0.10.0

Tool registry, ToolHandler trait, and 50+ tool implementations
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};

use crate::config_ref::AppConfigRef;
use crate::execution_tmp::BACKEND_TMP_ROOT;
use crate::tools::backends::{BackendKind, DockerBackendConfig, DockerWorkspaceMount};

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TerminalFilesystemRelation {
    SharedPathNamespace,
    SharedContentMapped {
        host_root: PathBuf,
        terminal_root: PathBuf,
    },
    IsolatedBackend,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExecutionFilesystemView {
    workspace_root: PathBuf,
    file_roots: Vec<PathBuf>,
    denied_roots: Vec<PathBuf>,
    file_tools_tmp_root: PathBuf,
    terminal_backend: BackendKind,
    terminal_relation: TerminalFilesystemRelation,
}

impl ExecutionFilesystemView {
    pub fn new(config: &AppConfigRef, cwd: &Path) -> Self {
        let workspace_root = canonical_or_raw(cwd);
        let file_tools_tmp_root = canonical_or_raw(&config.file_tools_tmp_dir());
        let file_roots = collect_roots(
            std::iter::once(cwd.to_path_buf())
                .chain(std::iter::once(file_tools_tmp_root.clone()))
                .chain(
                    config
                        .file_allowed_roots
                        .iter()
                        .map(|root| resolve_root(root, cwd)),
                ),
        );
        let denied_roots = collect_roots(
            config
                .path_restrictions
                .iter()
                .map(|root| resolve_root(root, cwd)),
        );
        let terminal_relation = match config.terminal_backend {
            BackendKind::Local => TerminalFilesystemRelation::SharedPathNamespace,
            BackendKind::Docker => {
                let mount = effective_docker_workspace_mount(cwd, &config.terminal_docker);
                TerminalFilesystemRelation::SharedContentMapped {
                    host_root: canonical_or_raw(Path::new(&mount.host_path)),
                    terminal_root: PathBuf::from(mount.container_path),
                }
            }
            BackendKind::Ssh
            | BackendKind::Modal
            | BackendKind::Daytona
            | BackendKind::Singularity => TerminalFilesystemRelation::IsolatedBackend,
        };

        Self {
            workspace_root,
            file_roots,
            denied_roots,
            file_tools_tmp_root,
            terminal_backend: config.terminal_backend.clone(),
            terminal_relation,
        }
    }

    pub fn render_prompt_block(&self) -> String {
        let file_roots = format_paths(&self.file_roots);
        let denied = if self.denied_roots.is_empty() {
            "none".to_string()
        } else {
            format_paths(&self.denied_roots)
        };
        let tmp_note = format!(
            "File-tool `/tmp/…` and relative `tmp/files/…` are virtual aliases for `{}`. They are not the host-global `/tmp`, so file tools only see Lingshu-owned temp files there.",
            self.file_tools_tmp_root.display()
        );

        let terminal_note = match &self.terminal_relation {
            TerminalFilesystemRelation::SharedPathNamespace => format!(
                "Terminal backend: `{}`. `terminal` uses the host path namespace for normal paths, and Lingshu exports `TMPDIR`, `TMP`, `TEMP`, and `EDGECRAB_TMPDIR` as `{}` so temp-aware programs converge on the same agent-owned temp root. Literal `/tmp/...` in shell commands still targets the real host `/tmp`.",
                self.terminal_backend,
                self.file_tools_tmp_root.display()
            ),
            TerminalFilesystemRelation::SharedContentMapped {
                host_root,
                terminal_root,
            } => format!(
                "Terminal backend: `docker`. The shared host tree `{}` is mounted inside the backend at `{}`. Lingshu also bind-mounts the shared temp root `{}` to container `/tmp`, so file-tool `/tmp`, docker `/tmp`, and temp-aware programs all converge on the same files.",
                host_root.display(),
                terminal_root.display(),
                self.file_tools_tmp_root.display()
            ),
            TerminalFilesystemRelation::IsolatedBackend => format!(
                "Terminal backend: `{}`. `terminal` uses an isolated backend filesystem. Lingshu exports `TMPDIR`, `TMP`, `TEMP`, and `EDGECRAB_TMPDIR` as `{}` inside that backend so temp-aware programs use a stable agent temp root there. Literal `/tmp/...` shell paths remain backend-native and are not shared with host file tools unless you copy them out explicitly.",
                self.terminal_backend, BACKEND_TMP_ROOT
            ),
        };

        let execute_code_note = match &self.terminal_relation {
            TerminalFilesystemRelation::SharedPathNamespace => format!(
                "`execute_code` runs as a host subprocess in `{}`. Lingshu injects `TMPDIR`, `TMP`, `TEMP`, and `EDGECRAB_TMPDIR` as `{}`, so Python/Ruby/Node temp APIs share the file-tool temp root. Literal `/tmp/...` paths in code still target the real host `/tmp`. Prefer file tools when you need workspace-root enforcement and path restriction checks.",
                self.workspace_root.display(),
                self.file_tools_tmp_root.display()
            ),
            TerminalFilesystemRelation::SharedContentMapped {
                host_root: _host_root,
                terminal_root,
            } => format!(
                "`execute_code` runs inside the docker backend. Direct script file I/O and `terminal()` inside `execute_code` share the backend filesystem. Docker `/tmp` is bind-mounted to the shared host temp root `{}`, so temp APIs and literal `/tmp/...` paths converge with file-tool `/tmp`. The shared project tree appears inside docker at `{}`.",
                self.file_tools_tmp_root.display(),
                terminal_root.display()
            ),
            TerminalFilesystemRelation::IsolatedBackend => format!(
                "`execute_code` runs inside the `{}` backend. Lingshu injects `TMPDIR`, `TMP`, `TEMP`, and `EDGECRAB_TMPDIR` as `{}` there, so temp-aware code uses a stable backend temp root. File tools still operate on the host allow-list rooted at {} and map file-tool `/tmp` to `{}`; backend-only files remain unreadable to host file tools unless you explicitly sync them out.",
                self.terminal_backend,
                BACKEND_TMP_ROOT,
                self.file_roots_display(),
                self.file_tools_tmp_root.display()
            ),
        };

        format!(
            "## Execution Filesystem\n\n\
Workspace root: `{}`\n\
File tools (`read_file`, `write_file`, `patch`, `search_files`) may access: {}.\n\
Blocked subtrees from `security.path_restrictions`: {}.\n\
{}\n\
{}\n\
{}",
            self.workspace_root.display(),
            file_roots,
            denied,
            tmp_note,
            terminal_note,
            execute_code_note
        )
    }

    pub fn file_roots_display(&self) -> String {
        format_paths(&self.file_roots)
    }

    pub fn execute_code_terminal_is_safe(&self) -> bool {
        true
    }
}

pub fn describe_execution_filesystem(config: &AppConfigRef, cwd: &Path) -> ExecutionFilesystemView {
    ExecutionFilesystemView::new(config, cwd)
}

pub fn effective_docker_workspace_mount(
    cwd: &Path,
    docker: &DockerBackendConfig,
) -> DockerWorkspaceMount {
    docker
        .workspace_mount
        .clone()
        .unwrap_or_else(|| DockerWorkspaceMount {
            host_path: cwd.to_string_lossy().into_owned(),
            container_path: "/workspace".into(),
            read_only: false,
        })
}

fn resolve_root(root: &Path, cwd: &Path) -> PathBuf {
    if root.is_absolute() {
        root.to_path_buf()
    } else {
        cwd.join(root)
    }
}

fn canonical_or_raw(path: &Path) -> PathBuf {
    path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
}

fn collect_roots<I>(roots: I) -> Vec<PathBuf>
where
    I: IntoIterator<Item = PathBuf>,
{
    let mut seen = BTreeSet::new();
    let mut out = Vec::new();
    for root in roots {
        let resolved = canonical_or_raw(&root);
        if seen.insert(resolved.clone()) {
            out.push(resolved);
        }
    }
    out
}

fn format_paths(paths: &[PathBuf]) -> String {
    paths
        .iter()
        .map(|p| format!("`{}`", p.display()))
        .collect::<Vec<_>>()
        .join(", ")
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    fn config_for(backend: BackendKind) -> AppConfigRef {
        AppConfigRef {
            terminal_backend: backend,
            ..Default::default()
        }
    }

    #[test]
    fn local_view_marks_tmp_outside_file_roots_by_default() {
        let dir = TempDir::new().expect("tmpdir");
        let view = describe_execution_filesystem(&config_for(BackendKind::Local), dir.path());
        let prompt = view.render_prompt_block();
        assert!(prompt.contains("File-tool `/tmp/…`"));
        assert!(prompt.contains("virtual aliases"));
        assert!(prompt.contains("temp-aware programs converge"));
        assert!(
            prompt.contains(
                "Literal `/tmp/...` in shell commands still targets the real host `/tmp`"
            )
        );
    }

    #[test]
    fn docker_view_describes_host_to_container_mapping() {
        let dir = TempDir::new().expect("tmpdir");
        let view = describe_execution_filesystem(&config_for(BackendKind::Docker), dir.path());
        let prompt = view.render_prompt_block();
        assert!(prompt.contains("mounted inside the backend at `/workspace`"));
        assert!(prompt.contains("runs inside the docker backend"));
        assert!(prompt.contains("bind-mounts the shared temp root"));
        assert!(prompt.contains("literal `/tmp/...` paths converge"));
    }

    #[test]
    fn isolated_backend_view_calls_out_execute_code_split() {
        let dir = TempDir::new().expect("tmpdir");
        let view = describe_execution_filesystem(&config_for(BackendKind::Modal), dir.path());
        let prompt = view.render_prompt_block();
        assert!(prompt.contains("isolated backend filesystem"));
        assert!(prompt.contains("runs inside the `modal` backend"));
        assert!(prompt.contains("backend-only files remain unreadable"));
        assert!(prompt.contains("temp-aware programs use a stable agent temp root"));
    }

    #[test]
    fn effective_docker_mount_defaults_to_workspace_mapping() {
        let dir = TempDir::new().expect("tmpdir");
        let mount = effective_docker_workspace_mount(dir.path(), &DockerBackendConfig::default());
        assert_eq!(mount.container_path, "/workspace");
        assert_eq!(Path::new(&mount.host_path), dir.path());
    }
}