open_tool.rs

  1use super::tool_permissions::{
  2    ResolvedProjectPath, authorize_symlink_access, canonicalize_worktree_roots,
  3    resolve_project_path,
  4};
  5use crate::AgentTool;
  6use agent_client_protocol::ToolKind;
  7use anyhow::{Context as _, Result};
  8use futures::FutureExt as _;
  9use gpui::{App, AppContext as _, Entity, SharedString, Task};
 10use project::Project;
 11use schemars::JsonSchema;
 12use serde::{Deserialize, Serialize};
 13use std::{path::PathBuf, sync::Arc};
 14use util::markdown::MarkdownEscaped;
 15
 16/// This tool opens a file or URL with the default application associated with it on the user's operating system:
 17///
 18/// - On macOS, it's equivalent to the `open` command
 19/// - On Windows, it's equivalent to `start`
 20/// - On Linux, it uses something like `xdg-open`, `gio open`, `gnome-open`, `kde-open`, `wslview` as appropriate
 21///
 22/// For example, it can open a web browser with a URL, open a PDF file with the default PDF viewer, etc.
 23///
 24/// You MUST ONLY use this tool when the user has explicitly requested opening something. You MUST NEVER assume that the user would like for you to use this tool.
 25#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
 26pub struct OpenToolInput {
 27    /// The path or URL to open with the default application.
 28    path_or_url: String,
 29}
 30
 31pub struct OpenTool {
 32    project: Entity<Project>,
 33}
 34
 35impl OpenTool {
 36    pub fn new(project: Entity<Project>) -> Self {
 37        Self { project }
 38    }
 39}
 40
 41impl AgentTool for OpenTool {
 42    type Input = OpenToolInput;
 43    type Output = String;
 44
 45    const NAME: &'static str = "open";
 46
 47    fn kind() -> ToolKind {
 48        ToolKind::Execute
 49    }
 50
 51    fn initial_title(
 52        &self,
 53        input: Result<Self::Input, serde_json::Value>,
 54        _cx: &mut App,
 55    ) -> SharedString {
 56        if let Ok(input) = input {
 57            format!("Open `{}`", MarkdownEscaped(&input.path_or_url)).into()
 58        } else {
 59            "Open file or URL".into()
 60        }
 61    }
 62
 63    fn run(
 64        self: Arc<Self>,
 65        input: Self::Input,
 66        event_stream: crate::ToolCallEventStream,
 67        cx: &mut App,
 68    ) -> Task<Result<Self::Output>> {
 69        // If path_or_url turns out to be a path in the project, make it absolute.
 70        let abs_path = to_absolute_path(&input.path_or_url, self.project.clone(), cx);
 71        let initial_title = self.initial_title(Ok(input.clone()), cx);
 72
 73        let project = self.project.clone();
 74        cx.spawn(async move |cx| {
 75            let fs = project.read_with(cx, |project, _cx| project.fs().clone());
 76            let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await;
 77
 78            // Symlink escape authorization replaces (rather than supplements)
 79            // the normal tool-permission prompt. The symlink prompt already
 80            // requires explicit user approval with the canonical target shown,
 81            // which is strictly more security-relevant than a generic confirm.
 82            let symlink_escape = project.read_with(cx, |project, cx| {
 83                match resolve_project_path(
 84                    project,
 85                    PathBuf::from(&input.path_or_url),
 86                    &canonical_roots,
 87                    cx,
 88                ) {
 89                    Ok(ResolvedProjectPath::SymlinkEscape {
 90                        canonical_target, ..
 91                    }) => Some(canonical_target),
 92                    _ => None,
 93                }
 94            });
 95
 96            let authorize = if let Some(canonical_target) = symlink_escape {
 97                cx.update(|cx| {
 98                    authorize_symlink_access(
 99                        Self::NAME,
100                        &input.path_or_url,
101                        &canonical_target,
102                        &event_stream,
103                        cx,
104                    )
105                })
106            } else {
107                cx.update(|cx| {
108                    let context = crate::ToolPermissionContext::new(
109                        Self::NAME,
110                        vec![input.path_or_url.clone()],
111                    );
112                    event_stream.authorize(initial_title, context, cx)
113                })
114            };
115
116            futures::select! {
117                result = authorize.fuse() => result?,
118                _ = event_stream.cancelled_by_user().fuse() => {
119                    anyhow::bail!("Open cancelled by user");
120                }
121            }
122
123            let path_or_url = input.path_or_url.clone();
124            cx.background_spawn(async move {
125                match abs_path {
126                    Some(path) => open::that(path),
127                    None => open::that(path_or_url),
128                }
129                .context("Failed to open URL or file path")
130            })
131            .await?;
132
133            Ok(format!("Successfully opened {}", input.path_or_url))
134        })
135    }
136}
137
138fn to_absolute_path(
139    potential_path: &str,
140    project: Entity<Project>,
141    cx: &mut App,
142) -> Option<PathBuf> {
143    let project = project.read(cx);
144    project
145        .find_project_path(PathBuf::from(potential_path), cx)
146        .and_then(|project_path| project.absolute_path(&project_path, cx))
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use gpui::TestAppContext;
153    use project::{FakeFs, Project};
154    use settings::SettingsStore;
155    use std::path::Path;
156    use tempfile::TempDir;
157
158    #[gpui::test]
159    async fn test_to_absolute_path(cx: &mut TestAppContext) {
160        init_test(cx);
161        let temp_dir = TempDir::new().expect("Failed to create temp directory");
162        let temp_path = temp_dir.path().to_string_lossy().into_owned();
163
164        let fs = FakeFs::new(cx.executor());
165        fs.insert_tree(
166            &temp_path,
167            serde_json::json!({
168                "src": {
169                    "main.rs": "fn main() {}",
170                    "lib.rs": "pub fn lib_fn() {}"
171                },
172                "docs": {
173                    "readme.md": "# Project Documentation"
174                }
175            }),
176        )
177        .await;
178
179        // Use the temp_path as the root directory, not just its filename
180        let project = Project::test(fs.clone(), [temp_dir.path()], cx).await;
181
182        // Test cases where the function should return Some
183        cx.update(|cx| {
184            // Project-relative paths should return Some
185            // Create paths using the last segment of the temp path to simulate a project-relative path
186            let root_dir_name = Path::new(&temp_path)
187                .file_name()
188                .unwrap_or_else(|| std::ffi::OsStr::new("temp"))
189                .to_string_lossy();
190
191            assert!(
192                to_absolute_path(&format!("{root_dir_name}/src/main.rs"), project.clone(), cx)
193                    .is_some(),
194                "Failed to resolve main.rs path"
195            );
196
197            assert!(
198                to_absolute_path(
199                    &format!("{root_dir_name}/docs/readme.md",),
200                    project.clone(),
201                    cx,
202                )
203                .is_some(),
204                "Failed to resolve readme.md path"
205            );
206
207            // External URL should return None
208            let result = to_absolute_path("https://example.com", project.clone(), cx);
209            assert_eq!(result, None, "External URLs should return None");
210
211            // Path outside project
212            let result = to_absolute_path("../invalid/path", project.clone(), cx);
213            assert_eq!(result, None, "Paths outside the project should return None");
214        });
215    }
216
217    fn init_test(cx: &mut TestAppContext) {
218        cx.update(|cx| {
219            let settings_store = SettingsStore::test(cx);
220            cx.set_global(settings_store);
221        });
222    }
223}