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