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}