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 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: Self::Input,
65 event_stream: crate::ToolCallEventStream,
66 cx: &mut App,
67 ) -> Task<Result<Self::Output, Self::Output>> {
68 // If path_or_url turns out to be a path in the project, make it absolute.
69 let abs_path = to_absolute_path(&input.path_or_url, self.project.clone(), cx);
70 let initial_title = self.initial_title(Ok(input.clone()), cx);
71
72 let project = self.project.clone();
73 cx.spawn(async move |cx| {
74 let fs = project.read_with(cx, |project, _cx| project.fs().clone());
75 let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await;
76
77 // Symlink escape authorization replaces (rather than supplements)
78 // the normal tool-permission prompt. The symlink prompt already
79 // requires explicit user approval with the canonical target shown,
80 // which is strictly more security-relevant than a generic confirm.
81 let symlink_escape = project.read_with(cx, |project, cx| {
82 match resolve_project_path(
83 project,
84 PathBuf::from(&input.path_or_url),
85 &canonical_roots,
86 cx,
87 ) {
88 Ok(ResolvedProjectPath::SymlinkEscape {
89 canonical_target, ..
90 }) => Some(canonical_target),
91 _ => None,
92 }
93 });
94
95 let authorize = if let Some(canonical_target) = symlink_escape {
96 cx.update(|cx| {
97 authorize_symlink_access(
98 Self::NAME,
99 &input.path_or_url,
100 &canonical_target,
101 &event_stream,
102 cx,
103 )
104 })
105 } else {
106 cx.update(|cx| {
107 let context = crate::ToolPermissionContext::new(
108 Self::NAME,
109 vec![input.path_or_url.clone()],
110 );
111 event_stream.authorize(initial_title, context, cx)
112 })
113 };
114
115 futures::select! {
116 result = authorize.fuse() => result.map_err(|e| e.to_string())?,
117 _ = event_stream.cancelled_by_user().fuse() => {
118 return Err("Open cancelled by user".to_string());
119 }
120 }
121
122 let path_or_url = input.path_or_url.clone();
123 cx.background_spawn(async move {
124 match abs_path {
125 Some(path) => open::that(path),
126 None => open::that(path_or_url),
127 }
128 .map_err(|e| format!("Failed to open URL or file path: {e}"))
129 })
130 .await?;
131
132 Ok(format!("Successfully opened {}", input.path_or_url))
133 })
134 }
135}
136
137fn to_absolute_path(
138 potential_path: &str,
139 project: Entity<Project>,
140 cx: &mut App,
141) -> Option<PathBuf> {
142 let project = project.read(cx);
143 project
144 .find_project_path(PathBuf::from(potential_path), cx)
145 .and_then(|project_path| project.absolute_path(&project_path, cx))
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151 use gpui::TestAppContext;
152 use project::{FakeFs, Project};
153 use settings::SettingsStore;
154 use std::path::Path;
155 use tempfile::TempDir;
156
157 #[gpui::test]
158 async fn test_to_absolute_path(cx: &mut TestAppContext) {
159 init_test(cx);
160 let temp_dir = TempDir::new().expect("Failed to create temp directory");
161 let temp_path = temp_dir.path().to_string_lossy().into_owned();
162
163 let fs = FakeFs::new(cx.executor());
164 fs.insert_tree(
165 &temp_path,
166 serde_json::json!({
167 "src": {
168 "main.rs": "fn main() {}",
169 "lib.rs": "pub fn lib_fn() {}"
170 },
171 "docs": {
172 "readme.md": "# Project Documentation"
173 }
174 }),
175 )
176 .await;
177
178 // Use the temp_path as the root directory, not just its filename
179 let project = Project::test(fs.clone(), [temp_dir.path()], cx).await;
180
181 // Test cases where the function should return Some
182 cx.update(|cx| {
183 // Project-relative paths should return Some
184 // Create paths using the last segment of the temp path to simulate a project-relative path
185 let root_dir_name = Path::new(&temp_path)
186 .file_name()
187 .unwrap_or_else(|| std::ffi::OsStr::new("temp"))
188 .to_string_lossy();
189
190 assert!(
191 to_absolute_path(&format!("{root_dir_name}/src/main.rs"), project.clone(), cx)
192 .is_some(),
193 "Failed to resolve main.rs path"
194 );
195
196 assert!(
197 to_absolute_path(
198 &format!("{root_dir_name}/docs/readme.md",),
199 project.clone(),
200 cx,
201 )
202 .is_some(),
203 "Failed to resolve readme.md path"
204 );
205
206 // External URL should return None
207 let result = to_absolute_path("https://example.com", project.clone(), cx);
208 assert_eq!(result, None, "External URLs should return None");
209
210 // Path outside project
211 let result = to_absolute_path("../invalid/path", project.clone(), cx);
212 assert_eq!(result, None, "Paths outside the project should return None");
213 });
214 }
215
216 fn init_test(cx: &mut TestAppContext) {
217 cx.update(|cx| {
218 let settings_store = SettingsStore::test(cx);
219 cx.set_global(settings_store);
220 });
221 }
222}