Skip to repository content2044 lines · 75.0 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T02:34:55.623Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
read_file_tool.rs
1use action_log::ActionLog;
2use agent_client_protocol::schema::v1 as acp;
3use anyhow::{Context as _, Result, anyhow};
4use futures::FutureExt as _;
5use gpui::{App, Entity, SharedString, Task};
6use indoc::formatdoc;
7use language::Point;
8use language_model::{LanguageModelImage, LanguageModelImageExt, LanguageModelToolResultContent};
9use project::{AgentLocation, ImageItem, Project, WorktreeSettings, image_store};
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12use settings::Settings;
13use std::path::Path;
14use std::sync::Arc;
15use util::markdown::MarkdownCodeBlock;
16
17fn tool_content_err(e: impl std::fmt::Display) -> LanguageModelToolResultContent {
18 LanguageModelToolResultContent::from(e.to_string())
19}
20
21/// Resolves the optional `start_line` / `end_line` inputs from the tool schema
22/// to a concrete 1-indexed, inclusive `(start, end)` line range:
23///
24/// - `start` defaults to 1 and is clamped to `>= 1` (the model occasionally passes
25/// `0` despite instructions to be 1-indexed).
26/// - `end` defaults to `u32::MAX` and is clamped to `>= start`, so callers always
27/// read at least one line even when the model passes `end < start`.
28///
29/// Callers translate this 1-indexed inclusive range to whichever coordinate
30/// system their slicing API wants (e.g. 0-indexed exclusive row ranges for
31/// `Buffer::text_for_range`).
32fn resolve_line_range(start_line: Option<u32>, end_line: Option<u32>) -> (u32, u32) {
33 let start = start_line.unwrap_or(1).max(1);
34 let end = end_line.unwrap_or(u32::MAX).max(start);
35 (start, end)
36}
37
38/// Prefixes each line of `text` with its line number in `cat -n` format:
39/// the line number is right-aligned in a 6-character field, followed by a
40/// single tab, followed by the line's original content (including its
41/// trailing newline if present). Numbering starts at `start_line`.
42///
43/// This format matches what the model expects in the edit tool, where the
44/// line number prefix is `line number + tab` and everything after the tab is
45/// the actual file content to match.
46fn format_with_line_numbers(text: &str, start_line: u32) -> String {
47 if text.is_empty() {
48 return String::new();
49 }
50
51 let mut output = String::with_capacity(text.len() + text.len() / 4);
52 write_lines_numbered(&mut output, std::iter::once(text), start_line);
53 output
54}
55
56/// Streams `cat -n`-style line-numbered output directly into `output` from an
57/// iterator of string slices. Chunks do not need to align to line boundaries:
58/// a single chunk may contain multiple newlines, span multiple lines, or end
59/// mid-line. This lets callers consume `Buffer::text_for_range`'s `Chunks`
60/// iterator without materializing the unnumbered text first.
61fn write_lines_numbered<'a>(
62 output: &mut String,
63 chunks: impl IntoIterator<Item = &'a str>,
64 start_line: u32,
65) {
66 use std::fmt::Write as _;
67
68 let mut line_number = start_line;
69 let mut at_line_start = true;
70 for chunk in chunks {
71 let mut rest = chunk;
72 while !rest.is_empty() {
73 if at_line_start {
74 // Writes to a `String` are infallible, so the `Result` can be ignored.
75 let _ = write!(output, "{line_number:>6}\t");
76 at_line_start = false;
77 }
78 match rest.find('\n') {
79 Some(nl) => {
80 let (head, tail) = rest.split_at(nl + 1);
81 output.push_str(head);
82 line_number = line_number.saturating_add(1);
83 at_line_start = true;
84 rest = tail;
85 }
86 None => {
87 output.push_str(rest);
88 break;
89 }
90 }
91 }
92 }
93}
94
95/// Read a file under the global skills directory directly via the filesystem,
96/// bypassing project/worktree resolution. Used for skill resources that live
97/// outside any worktree.
98///
99/// Skill resources are expected to be plain text (Markdown, scripts, configs).
100/// Image rendering, the action log, and the buffer-backed outline path are
101/// intentionally not exercised here — those are project concerns.
102async fn read_global_skill_file(
103 canonical_path: &Path,
104 fs: &dyn fs::Fs,
105 start_line: Option<u32>,
106 end_line: Option<u32>,
107 requested_path: &str,
108 event_stream: &ToolCallEventStream,
109) -> Result<LanguageModelToolResultContent, LanguageModelToolResultContent> {
110 let content = fs.load(canonical_path).await.map_err(tool_content_err)?;
111
112 event_stream.update_fields(acp::ToolCallUpdateFields::new().locations(vec![
113 acp::ToolCallLocation::new(canonical_path)
114 .line(start_line.map(|line| line.saturating_sub(1))),
115 ]));
116
117 let (raw_text, first_line_number) = if start_line.is_some() || end_line.is_some() {
118 // `split_inclusive` keeps each line's terminator attached, so CRLF stays
119 // CRLF and the trailing newline of the last returned line is preserved —
120 // matching `Buffer::text_for_range` in the buffer-backed path.
121 let (start, end) = resolve_line_range(start_line, end_line);
122 let lines: Vec<&str> = content.split_inclusive('\n').collect();
123 let start_idx = (start as usize).saturating_sub(1).min(lines.len());
124 let end_idx = (end as usize).min(lines.len()).max(start_idx);
125 (lines[start_idx..end_idx].concat(), start)
126 } else {
127 (content, 1)
128 };
129
130 let result_text = format_with_line_numbers(&raw_text, first_line_number);
131
132 let markdown = MarkdownCodeBlock {
133 tag: requested_path,
134 text: &result_text,
135 }
136 .to_string();
137 event_stream.update_fields(acp::ToolCallUpdateFields::new().content(vec![
138 acp::ToolCallContent::Content(acp::Content::new(markdown)),
139 ]));
140
141 Ok(result_text.into())
142}
143
144use super::tool_permissions::{
145 ResolvedProjectPath, authorize_symlink_access, canonicalize_worktree_roots,
146 resolve_global_skill_path, resolve_project_path,
147};
148use crate::{AgentTool, ToolCallEventStream, ToolInput, outline};
149
150/// Reads the content of the given file in the project.
151///
152/// - Never attempt to read a path that hasn't been previously mentioned.
153/// - For large files, this tool returns a file outline with symbol names and line numbers instead of the full content.
154/// This outline IS a successful response - use the line numbers to read specific sections with start_line/end_line.
155/// Do NOT retry reading the same file without line numbers if you receive an outline.
156/// - This tool supports reading image files. Supported formats: PNG, JPEG, WebP, GIF, BMP, TIFF.
157/// Image files are returned as visual content that you can analyze directly.
158///
159/// The only supported path outside the project is `~/.agents/skills` or a descendant, for global agent skills.
160#[derive(Debug, Serialize, Deserialize, JsonSchema)]
161pub struct ReadFileToolInput {
162 /// The relative path of the file to read.
163 ///
164 /// This path should never be absolute, and the first component of the path should always be a root directory in a project, unless it's a global agent skill under `~/.agents/skills`.
165 ///
166 /// <example>
167 /// If the project has the following root directories:
168 ///
169 /// - /a/b/directory1
170 /// - /c/d/directory2
171 ///
172 /// If you want to access `file.txt` in `directory1`, you should use the path `directory1/file.txt`.
173 /// If you want to access `file.txt` in `directory2`, you should use the path `directory2/file.txt`.
174 /// </example>
175 ///
176 /// <example>
177 /// To read a global agent skill file, you may provide a path under `~/.agents/skills`, such as `~/.agents/skills/my-skill/SKILL.md`.
178 /// </example>
179 pub path: String,
180 /// Optional line number to start reading on (1-based index)
181 #[serde(default)]
182 pub start_line: Option<u32>,
183 /// Optional line number to end reading on (1-based index, inclusive)
184 #[serde(default)]
185 pub end_line: Option<u32>,
186}
187
188pub struct ReadFileTool {
189 project: Entity<Project>,
190 action_log: Entity<ActionLog>,
191 update_agent_location: bool,
192}
193
194impl ReadFileTool {
195 pub fn new(
196 project: Entity<Project>,
197 action_log: Entity<ActionLog>,
198 update_agent_location: bool,
199 ) -> Self {
200 Self {
201 project,
202 action_log,
203 update_agent_location,
204 }
205 }
206}
207
208impl AgentTool for ReadFileTool {
209 type Input = ReadFileToolInput;
210 type Output = LanguageModelToolResultContent;
211
212 const NAME: &'static str = "read_file";
213
214 fn kind() -> acp::ToolKind {
215 acp::ToolKind::Read
216 }
217
218 fn initial_title(
219 &self,
220 input: Result<Self::Input, serde_json::Value>,
221 cx: &mut App,
222 ) -> SharedString {
223 if let Ok(input) = input
224 && let Some(project_path) = self.project.read(cx).find_project_path(&input.path, cx)
225 && let Some(path) = self
226 .project
227 .read(cx)
228 .short_full_path_for_project_path(&project_path, cx)
229 {
230 match (input.start_line, input.end_line) {
231 (Some(start), Some(end)) => {
232 format!("Read file `{path}` (lines {}-{})", start, end,)
233 }
234 (Some(start), None) => {
235 format!("Read file `{path}` (from line {})", start)
236 }
237 _ => format!("Read file `{path}`"),
238 }
239 .into()
240 } else {
241 "Read file".into()
242 }
243 }
244
245 fn run(
246 self: Arc<Self>,
247 input: ToolInput<Self::Input>,
248 event_stream: ToolCallEventStream,
249 cx: &mut App,
250 ) -> Task<Result<LanguageModelToolResultContent, LanguageModelToolResultContent>> {
251 let project = self.project.clone();
252 let action_log = self.action_log.clone();
253 cx.spawn(async move |cx| {
254 let input = input
255 .recv()
256 .await
257 .map_err(tool_content_err)?;
258 let fs = project.read_with(cx, |project, _cx| project.fs().clone());
259
260 // Fast path: if the model passes a path that resolves under the
261 // global skills directory, read it directly via the
262 // filesystem. Global skills live outside any worktree, so the
263 // standard project-path machinery would refuse them.
264 if let Some(skill_path) =
265 resolve_global_skill_path(Path::new(&input.path), fs.as_ref()).await
266 {
267 return read_global_skill_file(
268 &skill_path,
269 fs.as_ref(),
270 input.start_line,
271 input.end_line,
272 &input.path,
273 &event_stream,
274 )
275 .await;
276 }
277
278 let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await;
279
280 let (project_path, symlink_canonical_target) =
281 project.read_with(cx, |project, cx| {
282 let resolved =
283 resolve_project_path(project, &input.path, &canonical_roots, cx)?;
284 anyhow::Ok(match resolved {
285 ResolvedProjectPath::Safe(path) => (path, None),
286 ResolvedProjectPath::SymlinkEscape {
287 project_path,
288 canonical_target,
289 } => (project_path, Some(canonical_target)),
290 })
291 }).map_err(tool_content_err)?;
292
293 let abs_path = project
294 .read_with(cx, |project, cx| {
295 project.absolute_path(&project_path, cx)
296 })
297 .ok_or_else(|| {
298 anyhow!("Failed to convert {} to absolute path", &input.path)
299 }).map_err(tool_content_err)?;
300
301 // Check settings exclusions synchronously
302 project.read_with(cx, |_project, cx| {
303 let global_settings = WorktreeSettings::get_global(cx);
304 if global_settings.is_path_excluded(&project_path.path) {
305 anyhow::bail!(
306 "Cannot read file because its path matches the global `file_scan_exclusions` setting: {}",
307 &input.path
308 );
309 }
310
311 if global_settings.is_path_private(&project_path.path) {
312 anyhow::bail!(
313 "Cannot read file because its path matches the global `private_files` setting: {}",
314 &input.path
315 );
316 }
317
318 let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);
319 if worktree_settings.is_path_excluded(&project_path.path) {
320 anyhow::bail!(
321 "Cannot read file because its path matches the worktree `file_scan_exclusions` setting: {}",
322 &input.path
323 );
324 }
325
326 if worktree_settings.is_path_private(&project_path.path) {
327 anyhow::bail!(
328 "Cannot read file because its path matches the worktree `private_files` setting: {}",
329 &input.path
330 );
331 }
332
333 anyhow::Ok(())
334 }).map_err(tool_content_err)?;
335
336 if fs.is_dir(&abs_path).await {
337 return Err(tool_content_err(format!(
338 "{} is a directory, not a file. Use the list_directory tool to explore directory contents.",
339 &input.path
340 )));
341 }
342
343 if let Some(canonical_target) = &symlink_canonical_target {
344 let authorize = cx.update(|cx| {
345 authorize_symlink_access(
346 Self::NAME,
347 &input.path,
348 canonical_target,
349 &event_stream,
350 cx,
351 )
352 });
353 authorize.await.map_err(tool_content_err)?;
354 }
355
356 let file_path = input.path.clone();
357
358 cx.update(|_cx| {
359 event_stream.update_fields(acp::ToolCallUpdateFields::new().locations(vec![
360 acp::ToolCallLocation::new(&abs_path)
361 .line(input.start_line.map(|line| line.saturating_sub(1))),
362 ]));
363 });
364
365 let is_image = project.read_with(cx, |_project, cx| {
366 image_store::is_image_file(&project, &project_path, cx)
367 });
368
369 if is_image {
370 let image_entity: Entity<ImageItem> = cx
371 .update(|cx| {
372 self.project.update(cx, |project, cx| {
373 project.open_image(project_path.clone(), cx)
374 })
375 })
376 .await.map_err(tool_content_err)?;
377
378 let image =
379 image_entity.read_with(cx, |image_item, _| Arc::clone(&image_item.image));
380
381 let language_model_image = cx
382 .update(|cx| LanguageModelImage::from_image(image, cx))
383 .await
384 .context("processing image")
385 .map_err(tool_content_err)?;
386
387 event_stream.update_fields(acp::ToolCallUpdateFields::new().content(vec![
388 acp::ToolCallContent::Content(acp::Content::new(acp::ContentBlock::Image(
389 acp::ImageContent::new(language_model_image.source.clone(), "image/png"),
390 ))),
391 ]));
392
393 return Ok(language_model_image.into());
394 }
395
396 let open_buffer_task = project.update(cx, |project, cx| {
397 project.open_buffer(project_path.clone(), cx)
398 });
399
400 let buffer = futures::select! {
401 result = open_buffer_task.fuse() => result.map_err(tool_content_err)?,
402 _ = event_stream.cancelled_by_user().fuse() => {
403 return Err(tool_content_err("File read cancelled by user"));
404 }
405 };
406 if buffer.read_with(cx, |buffer, _| {
407 buffer
408 .file()
409 .as_ref()
410 .is_none_or(|file| !file.disk_state().exists())
411 }) {
412 return Err(tool_content_err(format!("{file_path} not found")));
413 }
414
415 let mut anchor = None;
416 let mut is_outline_response = false;
417
418 // Check if specific line ranges are provided
419 let result = if input.start_line.is_some() || input.end_line.is_some() {
420 let result_text = buffer.read_with(cx, |buffer, _cx| {
421 let (start, end) = resolve_line_range(input.start_line, input.end_line);
422 let start_row = start - 1;
423 if start_row <= buffer.max_point().row {
424 let column = buffer.line_indent_for_row(start_row).raw_len();
425 anchor = Some(buffer.anchor_before(Point::new(start_row, column)));
426 }
427
428 // `end` is 1-indexed inclusive; `Point` rows are 0-indexed.
429 // Using `end` directly as the (exclusive) end row is the
430 // standard inclusive→exclusive translation, and since
431 // `resolve_line_range` guarantees `end >= start`, we always
432 // read at least one line.
433 let start_anchor = buffer.anchor_before(Point::new(start_row, 0));
434 let end_anchor = buffer.anchor_before(Point::new(end, 0));
435 // Stream the numbered output directly from the buffer's
436 // chunk iterator so the unnumbered range is never
437 // materialized as its own `String`.
438 let mut output = String::new();
439 write_lines_numbered(
440 &mut output,
441 buffer.text_for_range(start_anchor..end_anchor),
442 start,
443 );
444 output
445 });
446
447 action_log.update(cx, |log, cx| {
448 log.buffer_read(buffer.clone(), cx);
449 });
450
451 Ok(result_text.into())
452 } else {
453 // No line ranges specified, so check file size to see if it's too big.
454 let buffer_content = outline::get_buffer_content_or_outline(
455 buffer.clone(),
456 Some(&abs_path.to_string_lossy()),
457 cx,
458 )
459 .await.map_err(tool_content_err)?;
460
461 action_log.update(cx, |log, cx| {
462 log.buffer_read(buffer.clone(), cx);
463 });
464
465
466 is_outline_response = buffer_content.is_synthetic;
467
468 if buffer_content.is_synthetic {
469 Ok(formatdoc! {"
470 SUCCESS: File outline retrieved. This file is too large to read all at once, so the outline below shows the file's structure with line numbers.
471
472 IMPORTANT: Do NOT retry this call without line numbers - you will get the same outline.
473 Instead, use the line numbers below to read specific sections by calling this tool again with start_line and end_line parameters.
474
475 {}
476
477 NEXT STEPS: To read a specific symbol's implementation, call read_file with the same path plus start_line and end_line from the outline above.
478 For example, to read a function shown as [L100-150], use start_line: 100 and end_line: 150.", buffer_content.text
479 }
480 .into())
481 } else {
482 Ok(format_with_line_numbers(&buffer_content.text, 1).into())
483 }
484 };
485
486 project.update(cx, |project, cx| {
487 if self.update_agent_location {
488 project.set_agent_location(
489 Some(AgentLocation {
490 buffer: buffer.downgrade(),
491 position: anchor.unwrap_or_else(|| {
492 text::Anchor::min_for_buffer(buffer.read(cx).remote_id())
493 }),
494 }),
495 cx,
496 );
497 }
498 if let Ok(LanguageModelToolResultContent::Text(text)) = &result {
499 let text: &str = text;
500 // For outline responses, omit the path tag so the markdown renderer
501 // does not invoke tree-sitter syntax highlighting against pseudo-code
502 // outline text. The outline is not valid source for the file's language,
503 // so highlighting would be both expensive and incorrect.
504 let tag: &str = if is_outline_response { "" } else { &input.path };
505 let markdown = MarkdownCodeBlock { tag, text }.to_string();
506 event_stream.update_fields(acp::ToolCallUpdateFields::new().content(vec![
507 acp::ToolCallContent::Content(acp::Content::new(markdown)),
508 ]));
509 }
510 });
511
512 result
513 })
514 }
515
516 fn replay(
517 &self,
518 input: Self::Input,
519 output: Self::Output,
520 event_stream: ToolCallEventStream,
521 _cx: &mut App,
522 ) -> Result<()> {
523 if let LanguageModelToolResultContent::Text(text) = output {
524 let markdown = MarkdownCodeBlock {
525 tag: &input.path,
526 text: &text,
527 }
528 .to_string();
529 event_stream.update_fields(acp::ToolCallUpdateFields::new().content(vec![
530 acp::ToolCallContent::Content(acp::Content::new(markdown)),
531 ]));
532 }
533
534 Ok(())
535 }
536}
537
538#[cfg(test)]
539mod test {
540 use super::*;
541 use fs::Fs as _;
542 use gpui::{AppContext, TestAppContext, UpdateGlobal as _};
543 use project::{FakeFs, Project};
544 use serde_json::json;
545 use settings::SettingsStore;
546 use std::path::PathBuf;
547 use std::sync::Arc;
548 use util::path;
549
550 #[gpui::test]
551 async fn test_read_directory_path(cx: &mut TestAppContext) {
552 init_test(cx);
553
554 let fs = FakeFs::new(cx.executor());
555 fs.insert_tree(
556 path!("/root"),
557 json!({
558 "some_dir": {}
559 }),
560 )
561 .await;
562 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
563 let action_log = cx.new(|_| ActionLog::new(project.clone()));
564 let tool = Arc::new(ReadFileTool::new(project, action_log, true));
565 let (event_stream, _) = ToolCallEventStream::test();
566
567 let result = cx
568 .update(|cx| {
569 let input = ReadFileToolInput {
570 path: "root/some_dir".to_string(),
571 start_line: None,
572 end_line: None,
573 };
574 tool.run(ToolInput::resolved(input), event_stream, cx)
575 })
576 .await;
577 assert_eq!(
578 error_text(result.unwrap_err()),
579 "root/some_dir is a directory, not a file. Use the list_directory tool to explore directory contents."
580 );
581 }
582
583 #[gpui::test]
584 async fn test_read_nonexistent_file(cx: &mut TestAppContext) {
585 init_test(cx);
586
587 let fs = FakeFs::new(cx.executor());
588 fs.insert_tree(path!("/root"), json!({})).await;
589 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
590 let action_log = cx.new(|_| ActionLog::new(project.clone()));
591 let tool = Arc::new(ReadFileTool::new(project, action_log, true));
592 let (event_stream, _) = ToolCallEventStream::test();
593
594 let result = cx
595 .update(|cx| {
596 let input = ReadFileToolInput {
597 path: "root/nonexistent_file.txt".to_string(),
598 start_line: None,
599 end_line: None,
600 };
601 tool.run(ToolInput::resolved(input), event_stream, cx)
602 })
603 .await;
604 assert_eq!(
605 error_text(result.unwrap_err()),
606 "root/nonexistent_file.txt not found"
607 );
608 }
609
610 #[gpui::test]
611 async fn test_read_small_file(cx: &mut TestAppContext) {
612 init_test(cx);
613
614 let fs = FakeFs::new(cx.executor());
615 fs.insert_tree(
616 path!("/root"),
617 json!({
618 "small_file.txt": "This is a small file content"
619 }),
620 )
621 .await;
622 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
623 let action_log = cx.new(|_| ActionLog::new(project.clone()));
624 let tool = Arc::new(ReadFileTool::new(project, action_log, true));
625 let result = cx
626 .update(|cx| {
627 let input = ReadFileToolInput {
628 path: "root/small_file.txt".into(),
629 start_line: None,
630 end_line: None,
631 };
632 tool.run(
633 ToolInput::resolved(input),
634 ToolCallEventStream::test().0,
635 cx,
636 )
637 })
638 .await;
639 assert_eq!(
640 result.unwrap(),
641 " 1\tThis is a small file content".into()
642 );
643 }
644
645 #[gpui::test]
646 async fn test_read_large_file(cx: &mut TestAppContext) {
647 init_test(cx);
648
649 let fs = FakeFs::new(cx.executor());
650 fs.insert_tree(
651 path!("/root"),
652 json!({
653 "large_file.rs": (0..1000).map(|i| format!("struct Test{} {{\n a: u32,\n b: usize,\n}}", i)).collect::<Vec<_>>().join("\n")
654 }),
655 )
656 .await;
657 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
658 let language_registry = project.read_with(cx, |project, _| project.languages().clone());
659 language_registry.add(language::rust_lang());
660 let action_log = cx.new(|_| ActionLog::new(project.clone()));
661 let tool = Arc::new(ReadFileTool::new(project, action_log, true));
662 let result = cx
663 .update(|cx| {
664 let input = ReadFileToolInput {
665 path: "root/large_file.rs".into(),
666 start_line: None,
667 end_line: None,
668 };
669 tool.clone().run(
670 ToolInput::resolved(input),
671 ToolCallEventStream::test().0,
672 cx,
673 )
674 })
675 .await
676 .unwrap();
677 let content = result.to_str().unwrap();
678
679 assert_eq!(
680 content.lines().skip(7).take(6).collect::<Vec<_>>(),
681 vec![
682 "struct Test0 [L1-4]",
683 " a [L2]",
684 " b [L3]",
685 "struct Test1 [L5-8]",
686 " a [L6]",
687 " b [L7]",
688 ]
689 );
690
691 let result = cx
692 .update(|cx| {
693 let input = ReadFileToolInput {
694 path: "root/large_file.rs".into(),
695 start_line: None,
696 end_line: None,
697 };
698 tool.run(
699 ToolInput::resolved(input),
700 ToolCallEventStream::test().0,
701 cx,
702 )
703 })
704 .await
705 .unwrap();
706 let content = result.to_str().unwrap();
707 let expected_content = (0..1000)
708 .flat_map(|i| {
709 vec![
710 format!("struct Test{} [L{}-{}]", i, i * 4 + 1, i * 4 + 4),
711 format!(" a [L{}]", i * 4 + 2),
712 format!(" b [L{}]", i * 4 + 3),
713 ]
714 })
715 .collect::<Vec<_>>();
716 pretty_assertions::assert_eq!(
717 content
718 .lines()
719 .skip(7)
720 .take(expected_content.len())
721 .collect::<Vec<_>>(),
722 expected_content
723 );
724 }
725
726 // The outline returned for a large file is not valid source for the file's
727 // language, so the UI-side markdown wrapping must omit the path tag.
728 // Otherwise the markdown renderer routes the fenced block through
729 // `CodeBlockKind::FencedSrc`, resolves the file's language, and runs
730 // tree-sitter against pseudo-code outline text on every paint.
731 #[gpui::test]
732 async fn test_outline_response_uses_untagged_code_block(cx: &mut TestAppContext) {
733 init_test(cx);
734
735 let fs = FakeFs::new(cx.executor());
736 fs.insert_tree(
737 path!("/root"),
738 json!({
739 "large_file.rs": (0..1000).map(|i| format!("struct Test{} {{\n a: u32,\n b: usize,\n}}", i)).collect::<Vec<_>>().join("\n")
740 }),
741 )
742 .await;
743 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
744 let language_registry = project.read_with(cx, |project, _| project.languages().clone());
745 language_registry.add(language::rust_lang());
746 let action_log = cx.new(|_| ActionLog::new(project.clone()));
747 let tool = Arc::new(ReadFileTool::new(project, action_log, true));
748 let (event_stream, mut rx) = ToolCallEventStream::test();
749
750 let result = cx
751 .update(|cx| {
752 let input = ReadFileToolInput {
753 path: "root/large_file.rs".into(),
754 start_line: None,
755 end_line: None,
756 };
757 tool.clone()
758 .run(ToolInput::resolved(input), event_stream, cx)
759 })
760 .await
761 .unwrap();
762
763 // Sanity-check: the file is large enough to trigger the outline branch.
764 assert!(
765 result
766 .to_str()
767 .unwrap()
768 .starts_with("SUCCESS: File outline retrieved."),
769 "expected outline response, got: {:?}",
770 result.to_str().unwrap()
771 );
772
773 // The first update carries the location; the second carries the
774 // markdown content destined for the tool-call UI.
775 let _location_update = rx.expect_update_fields().await;
776 let content_update = rx.expect_update_fields().await;
777 let content_blocks = content_update.content.expect("expected content update");
778 let acp::ToolCallContent::Content(content) = content_blocks
779 .first()
780 .expect("expected at least one content block")
781 else {
782 panic!("expected ContentBlock, got {:?}", content_blocks.first());
783 };
784 let acp::ContentBlock::Text(text) = &content.content else {
785 panic!("expected text content block, got {:?}", content.content);
786 };
787
788 assert!(
789 text.text.starts_with("```\n"),
790 "outline response must use an untagged fenced code block; got first line: {:?}",
791 text.text.lines().next()
792 );
793 assert!(
794 !text.text.starts_with("```root/"),
795 "outline response must not include the file path as a code block tag"
796 );
797 }
798
799 // The full-file (non-outline) response should still tag the code block
800 // with the file path so the markdown renderer can resolve the file's
801 // language for syntax highlighting.
802 #[gpui::test]
803 async fn test_full_file_response_keeps_path_tag(cx: &mut TestAppContext) {
804 init_test(cx);
805
806 let fs = FakeFs::new(cx.executor());
807 fs.insert_tree(
808 path!("/root"),
809 json!({
810 "small_file.rs": "fn main() {}"
811 }),
812 )
813 .await;
814 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
815 let action_log = cx.new(|_| ActionLog::new(project.clone()));
816 let tool = Arc::new(ReadFileTool::new(project, action_log, true));
817 let (event_stream, mut rx) = ToolCallEventStream::test();
818
819 cx.update(|cx| {
820 let input = ReadFileToolInput {
821 path: "root/small_file.rs".into(),
822 start_line: None,
823 end_line: None,
824 };
825 tool.clone()
826 .run(ToolInput::resolved(input), event_stream, cx)
827 })
828 .await
829 .unwrap();
830
831 let _location_update = rx.expect_update_fields().await;
832 let content_update = rx.expect_update_fields().await;
833 let content_blocks = content_update.content.expect("expected content update");
834 let acp::ToolCallContent::Content(content) = content_blocks
835 .first()
836 .expect("expected at least one content block")
837 else {
838 panic!("expected ContentBlock, got {:?}", content_blocks.first());
839 };
840 let acp::ContentBlock::Text(text) = &content.content else {
841 panic!("expected text content block, got {:?}", content.content);
842 };
843
844 assert!(
845 text.text.starts_with("```root/small_file.rs\n"),
846 "full-file response must tag the code block with the file path; got first line: {:?}",
847 text.text.lines().next()
848 );
849 }
850
851 // When a worktree is named "foo" and contains a subdirectory also named "foo",
852 // read_file({"path": "foo/test.txt"}) should return the file at the worktree
853 // root (as the tool schema promises), not the one inside the foo/ subdirectory.
854 #[gpui::test]
855 async fn test_read_file_worktree_root_not_shadowed_by_subdir(cx: &mut TestAppContext) {
856 init_test(cx);
857
858 let fs = FakeFs::new(cx.executor());
859 fs.insert_tree(
860 path!("/foo"),
861 json!({
862 "test.txt": "root content",
863 "foo": {
864 "test.txt": "subdir content"
865 }
866 }),
867 )
868 .await;
869 let project = Project::test(fs.clone(), [path!("/foo").as_ref()], cx).await;
870 let action_log = cx.new(|_| ActionLog::new(project.clone()));
871 let tool = Arc::new(ReadFileTool::new(project, action_log, true));
872
873 // The tool schema says the first component must be the worktree root name,
874 // so "foo/test.txt" means test.txt at the root of the "foo" worktree.
875 let result = cx
876 .update(|cx| {
877 let input = ReadFileToolInput {
878 path: "foo/test.txt".into(),
879 start_line: None,
880 end_line: None,
881 };
882 tool.run(
883 ToolInput::resolved(input),
884 ToolCallEventStream::test().0,
885 cx,
886 )
887 })
888 .await;
889 assert_eq!(result.unwrap(), " 1\troot content".into());
890 }
891
892 #[gpui::test]
893 async fn test_read_file_with_line_range(cx: &mut TestAppContext) {
894 init_test(cx);
895
896 let fs = FakeFs::new(cx.executor());
897 fs.insert_tree(
898 path!("/root"),
899 json!({
900 "multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
901 }),
902 )
903 .await;
904 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
905
906 let action_log = cx.new(|_| ActionLog::new(project.clone()));
907 let tool = Arc::new(ReadFileTool::new(project, action_log, true));
908 let result = cx
909 .update(|cx| {
910 let input = ReadFileToolInput {
911 path: "root/multiline.txt".to_string(),
912 start_line: Some(2),
913 end_line: Some(4),
914 };
915 tool.run(
916 ToolInput::resolved(input),
917 ToolCallEventStream::test().0,
918 cx,
919 )
920 })
921 .await;
922 assert_eq!(
923 result.unwrap(),
924 " 2\tLine 2\n 3\tLine 3\n 4\tLine 4\n".into()
925 );
926 }
927
928 #[gpui::test]
929 async fn test_read_file_line_range_edge_cases(cx: &mut TestAppContext) {
930 init_test(cx);
931
932 let fs = FakeFs::new(cx.executor());
933 fs.insert_tree(
934 path!("/root"),
935 json!({
936 "multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
937 }),
938 )
939 .await;
940 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
941 let action_log = cx.new(|_| ActionLog::new(project.clone()));
942 let tool = Arc::new(ReadFileTool::new(project, action_log, true));
943
944 // start_line of 0 should be treated as 1
945 let result = cx
946 .update(|cx| {
947 let input = ReadFileToolInput {
948 path: "root/multiline.txt".to_string(),
949 start_line: Some(0),
950 end_line: Some(2),
951 };
952 tool.clone().run(
953 ToolInput::resolved(input),
954 ToolCallEventStream::test().0,
955 cx,
956 )
957 })
958 .await;
959 assert_eq!(result.unwrap(), " 1\tLine 1\n 2\tLine 2\n".into());
960
961 // end_line of 0 should result in at least 1 line
962 let result = cx
963 .update(|cx| {
964 let input = ReadFileToolInput {
965 path: "root/multiline.txt".to_string(),
966 start_line: Some(1),
967 end_line: Some(0),
968 };
969 tool.clone().run(
970 ToolInput::resolved(input),
971 ToolCallEventStream::test().0,
972 cx,
973 )
974 })
975 .await;
976 assert_eq!(result.unwrap(), " 1\tLine 1\n".into());
977
978 // when start_line > end_line, should still return at least 1 line
979 let result = cx
980 .update(|cx| {
981 let input = ReadFileToolInput {
982 path: "root/multiline.txt".to_string(),
983 start_line: Some(3),
984 end_line: Some(2),
985 };
986 tool.clone().run(
987 ToolInput::resolved(input),
988 ToolCallEventStream::test().0,
989 cx,
990 )
991 })
992 .await;
993 assert_eq!(result.unwrap(), " 3\tLine 3\n".into());
994 }
995
996 fn error_text(content: LanguageModelToolResultContent) -> String {
997 match content {
998 LanguageModelToolResultContent::Text(text) => text.to_string(),
999 other => panic!("Expected text error, got: {other:?}"),
1000 }
1001 }
1002
1003 fn init_test(cx: &mut TestAppContext) {
1004 cx.update(|cx| {
1005 let settings_store = SettingsStore::test(cx);
1006 cx.set_global(settings_store);
1007 });
1008 }
1009
1010 fn single_pixel_png() -> Vec<u8> {
1011 vec![
1012 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48,
1013 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00,
1014 0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, 0x54, 0x78,
1015 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00,
1016 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
1017 ]
1018 }
1019
1020 #[gpui::test]
1021 async fn test_read_file_security(cx: &mut TestAppContext) {
1022 init_test(cx);
1023
1024 let fs = FakeFs::new(cx.executor());
1025
1026 fs.insert_tree(
1027 path!("/"),
1028 json!({
1029 "project_root": {
1030 "allowed_file.txt": "This file is in the project",
1031 ".mysecrets": "SECRET_KEY=abc123",
1032 ".secretdir": {
1033 "config": "special configuration"
1034 },
1035 ".mymetadata": "custom metadata",
1036 "subdir": {
1037 "normal_file.txt": "Normal file content",
1038 "special.privatekey": "private key content",
1039 "data.mysensitive": "sensitive data"
1040 }
1041 },
1042 "outside_project": {
1043 "sensitive_file.txt": "This file is outside the project"
1044 }
1045 }),
1046 )
1047 .await;
1048
1049 cx.update(|cx| {
1050 use gpui::UpdateGlobal;
1051 use settings::SettingsStore;
1052 SettingsStore::update_global(cx, |store, cx| {
1053 store.update_user_settings(cx, |settings| {
1054 settings.project.worktree.file_scan_exclusions = Some(vec![
1055 "**/.secretdir".to_string(),
1056 "**/.mymetadata".to_string(),
1057 ]);
1058 settings.project.worktree.private_files = Some(
1059 vec![
1060 "**/.mysecrets".to_string(),
1061 "**/*.privatekey".to_string(),
1062 "**/*.mysensitive".to_string(),
1063 ]
1064 .into(),
1065 );
1066 });
1067 });
1068 });
1069
1070 let project = Project::test(fs.clone(), [path!("/project_root").as_ref()], cx).await;
1071 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1072 let tool = Arc::new(ReadFileTool::new(project, action_log, true));
1073
1074 // Reading a file outside the project worktree should fail
1075 let result = cx
1076 .update(|cx| {
1077 let input = ReadFileToolInput {
1078 path: "/outside_project/sensitive_file.txt".to_string(),
1079 start_line: None,
1080 end_line: None,
1081 };
1082 tool.clone().run(
1083 ToolInput::resolved(input),
1084 ToolCallEventStream::test().0,
1085 cx,
1086 )
1087 })
1088 .await;
1089 assert!(
1090 result.is_err(),
1091 "read_file_tool should error when attempting to read an absolute path outside a worktree"
1092 );
1093
1094 // Reading a file within the project should succeed
1095 let result = cx
1096 .update(|cx| {
1097 let input = ReadFileToolInput {
1098 path: "project_root/allowed_file.txt".to_string(),
1099 start_line: None,
1100 end_line: None,
1101 };
1102 tool.clone().run(
1103 ToolInput::resolved(input),
1104 ToolCallEventStream::test().0,
1105 cx,
1106 )
1107 })
1108 .await;
1109 assert!(
1110 result.is_ok(),
1111 "read_file_tool should be able to read files inside worktrees"
1112 );
1113
1114 // Reading files that match file_scan_exclusions should fail
1115 let result = cx
1116 .update(|cx| {
1117 let input = ReadFileToolInput {
1118 path: "project_root/.secretdir/config".to_string(),
1119 start_line: None,
1120 end_line: None,
1121 };
1122 tool.clone().run(
1123 ToolInput::resolved(input),
1124 ToolCallEventStream::test().0,
1125 cx,
1126 )
1127 })
1128 .await;
1129 assert!(
1130 result.is_err(),
1131 "read_file_tool should error when attempting to read files in .secretdir (file_scan_exclusions)"
1132 );
1133
1134 let result = cx
1135 .update(|cx| {
1136 let input = ReadFileToolInput {
1137 path: "project_root/.mymetadata".to_string(),
1138 start_line: None,
1139 end_line: None,
1140 };
1141 tool.clone().run(
1142 ToolInput::resolved(input),
1143 ToolCallEventStream::test().0,
1144 cx,
1145 )
1146 })
1147 .await;
1148 assert!(
1149 result.is_err(),
1150 "read_file_tool should error when attempting to read .mymetadata files (file_scan_exclusions)"
1151 );
1152
1153 // Reading private files should fail
1154 let result = cx
1155 .update(|cx| {
1156 let input = ReadFileToolInput {
1157 path: "project_root/.mysecrets".to_string(),
1158 start_line: None,
1159 end_line: None,
1160 };
1161 tool.clone().run(
1162 ToolInput::resolved(input),
1163 ToolCallEventStream::test().0,
1164 cx,
1165 )
1166 })
1167 .await;
1168 assert!(
1169 result.is_err(),
1170 "read_file_tool should error when attempting to read .mysecrets (private_files)"
1171 );
1172
1173 let result = cx
1174 .update(|cx| {
1175 let input = ReadFileToolInput {
1176 path: "project_root/subdir/special.privatekey".to_string(),
1177 start_line: None,
1178 end_line: None,
1179 };
1180 tool.clone().run(
1181 ToolInput::resolved(input),
1182 ToolCallEventStream::test().0,
1183 cx,
1184 )
1185 })
1186 .await;
1187 assert!(
1188 result.is_err(),
1189 "read_file_tool should error when attempting to read .privatekey files (private_files)"
1190 );
1191
1192 let result = cx
1193 .update(|cx| {
1194 let input = ReadFileToolInput {
1195 path: "project_root/subdir/data.mysensitive".to_string(),
1196 start_line: None,
1197 end_line: None,
1198 };
1199 tool.clone().run(
1200 ToolInput::resolved(input),
1201 ToolCallEventStream::test().0,
1202 cx,
1203 )
1204 })
1205 .await;
1206 assert!(
1207 result.is_err(),
1208 "read_file_tool should error when attempting to read .mysensitive files (private_files)"
1209 );
1210
1211 // Reading a normal file should still work, even with private_files configured
1212 let result = cx
1213 .update(|cx| {
1214 let input = ReadFileToolInput {
1215 path: "project_root/subdir/normal_file.txt".to_string(),
1216 start_line: None,
1217 end_line: None,
1218 };
1219 tool.clone().run(
1220 ToolInput::resolved(input),
1221 ToolCallEventStream::test().0,
1222 cx,
1223 )
1224 })
1225 .await;
1226 assert!(result.is_ok(), "Should be able to read normal files");
1227 assert_eq!(result.unwrap(), " 1\tNormal file content".into());
1228
1229 // Path traversal attempts with .. should fail
1230 let result = cx
1231 .update(|cx| {
1232 let input = ReadFileToolInput {
1233 path: "project_root/../outside_project/sensitive_file.txt".to_string(),
1234 start_line: None,
1235 end_line: None,
1236 };
1237 tool.run(
1238 ToolInput::resolved(input),
1239 ToolCallEventStream::test().0,
1240 cx,
1241 )
1242 })
1243 .await;
1244 assert!(
1245 result.is_err(),
1246 "read_file_tool should error when attempting to read a relative path that resolves to outside a worktree"
1247 );
1248 }
1249
1250 #[gpui::test]
1251 async fn test_read_image_symlink_requires_authorization(cx: &mut TestAppContext) {
1252 init_test(cx);
1253
1254 let fs = FakeFs::new(cx.executor());
1255 fs.insert_tree(path!("/root"), json!({})).await;
1256 fs.insert_tree(path!("/outside"), json!({})).await;
1257 fs.insert_file(path!("/outside/secret.png"), single_pixel_png())
1258 .await;
1259 fs.insert_symlink(
1260 path!("/root/secret.png"),
1261 PathBuf::from("/outside/secret.png"),
1262 )
1263 .await;
1264
1265 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1266 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1267 let tool = Arc::new(ReadFileTool::new(project, action_log, true));
1268
1269 let (event_stream, mut event_rx) = ToolCallEventStream::test();
1270 let read_task = cx.update(|cx| {
1271 tool.run(
1272 ToolInput::resolved(ReadFileToolInput {
1273 path: "root/secret.png".to_string(),
1274 start_line: None,
1275 end_line: None,
1276 }),
1277 event_stream,
1278 cx,
1279 )
1280 });
1281
1282 let authorization = event_rx.expect_authorization().await;
1283 assert!(
1284 authorization
1285 .tool_call
1286 .fields
1287 .title
1288 .as_deref()
1289 .is_some_and(|title| title.contains("points outside the project")),
1290 "Expected symlink escape authorization before reading the image"
1291 );
1292 authorization
1293 .response
1294 .send(acp_thread::SelectedPermissionOutcome::new(
1295 acp::PermissionOptionId::new("allow"),
1296 acp::PermissionOptionKind::AllowOnce,
1297 ))
1298 .unwrap();
1299
1300 let result = read_task.await;
1301 assert!(result.is_ok());
1302 }
1303
1304 #[gpui::test]
1305 async fn test_read_file_with_multiple_worktree_settings(cx: &mut TestAppContext) {
1306 init_test(cx);
1307
1308 let fs = FakeFs::new(cx.executor());
1309
1310 // Create first worktree with its own private_files setting
1311 fs.insert_tree(
1312 path!("/worktree1"),
1313 json!({
1314 "src": {
1315 "main.rs": "fn main() { println!(\"Hello from worktree1\"); }",
1316 "secret.rs": "const API_KEY: &str = \"secret_key_1\";",
1317 "config.toml": "[database]\nurl = \"postgres://localhost/db1\""
1318 },
1319 "tests": {
1320 "test.rs": "mod tests { fn test_it() {} }",
1321 "fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));"
1322 },
1323 ".zed": {
1324 "settings.json": r#"{
1325 "file_scan_exclusions": ["**/fixture.*"],
1326 "private_files": ["**/secret.rs", "**/config.toml"]
1327 }"#
1328 }
1329 }),
1330 )
1331 .await;
1332
1333 // Create second worktree with different private_files setting
1334 fs.insert_tree(
1335 path!("/worktree2"),
1336 json!({
1337 "lib": {
1338 "public.js": "export function greet() { return 'Hello from worktree2'; }",
1339 "private.js": "const SECRET_TOKEN = \"private_token_2\";",
1340 "data.json": "{\"api_key\": \"json_secret_key\"}"
1341 },
1342 "docs": {
1343 "README.md": "# Public Documentation",
1344 "internal.md": "# Internal Secrets and Configuration"
1345 },
1346 ".zed": {
1347 "settings.json": r#"{
1348 "file_scan_exclusions": ["**/internal.*"],
1349 "private_files": ["**/private.js", "**/data.json"]
1350 }"#
1351 }
1352 }),
1353 )
1354 .await;
1355
1356 // Set global settings
1357 cx.update(|cx| {
1358 SettingsStore::update_global(cx, |store, cx| {
1359 store.update_user_settings(cx, |settings| {
1360 settings.project.worktree.file_scan_exclusions =
1361 Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
1362 settings.project.worktree.private_files =
1363 Some(vec!["**/.env".to_string()].into());
1364 });
1365 });
1366 });
1367
1368 let project = Project::test(
1369 fs.clone(),
1370 [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()],
1371 cx,
1372 )
1373 .await;
1374
1375 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1376 let tool = Arc::new(ReadFileTool::new(project.clone(), action_log.clone(), true));
1377
1378 // Test reading allowed files in worktree1
1379 let result = cx
1380 .update(|cx| {
1381 let input = ReadFileToolInput {
1382 path: "worktree1/src/main.rs".to_string(),
1383 start_line: None,
1384 end_line: None,
1385 };
1386 tool.clone().run(
1387 ToolInput::resolved(input),
1388 ToolCallEventStream::test().0,
1389 cx,
1390 )
1391 })
1392 .await
1393 .unwrap();
1394
1395 assert_eq!(
1396 result,
1397 " 1\tfn main() { println!(\"Hello from worktree1\"); }".into()
1398 );
1399
1400 // Test reading private file in worktree1 should fail
1401 let result = cx
1402 .update(|cx| {
1403 let input = ReadFileToolInput {
1404 path: "worktree1/src/secret.rs".to_string(),
1405 start_line: None,
1406 end_line: None,
1407 };
1408 tool.clone().run(
1409 ToolInput::resolved(input),
1410 ToolCallEventStream::test().0,
1411 cx,
1412 )
1413 })
1414 .await;
1415
1416 assert!(result.is_err());
1417 assert!(
1418 error_text(result.unwrap_err()).contains("worktree `private_files` setting"),
1419 "Error should mention worktree private_files setting"
1420 );
1421
1422 // Test reading excluded file in worktree1 should fail
1423 let result = cx
1424 .update(|cx| {
1425 let input = ReadFileToolInput {
1426 path: "worktree1/tests/fixture.sql".to_string(),
1427 start_line: None,
1428 end_line: None,
1429 };
1430 tool.clone().run(
1431 ToolInput::resolved(input),
1432 ToolCallEventStream::test().0,
1433 cx,
1434 )
1435 })
1436 .await;
1437
1438 assert!(result.is_err());
1439 assert!(
1440 error_text(result.unwrap_err()).contains("worktree `file_scan_exclusions` setting"),
1441 "Error should mention worktree file_scan_exclusions setting"
1442 );
1443
1444 // Test reading allowed files in worktree2
1445 let result = cx
1446 .update(|cx| {
1447 let input = ReadFileToolInput {
1448 path: "worktree2/lib/public.js".to_string(),
1449 start_line: None,
1450 end_line: None,
1451 };
1452 tool.clone().run(
1453 ToolInput::resolved(input),
1454 ToolCallEventStream::test().0,
1455 cx,
1456 )
1457 })
1458 .await
1459 .unwrap();
1460
1461 assert_eq!(
1462 result,
1463 " 1\texport function greet() { return 'Hello from worktree2'; }".into()
1464 );
1465
1466 // Test reading private file in worktree2 should fail
1467 let result = cx
1468 .update(|cx| {
1469 let input = ReadFileToolInput {
1470 path: "worktree2/lib/private.js".to_string(),
1471 start_line: None,
1472 end_line: None,
1473 };
1474 tool.clone().run(
1475 ToolInput::resolved(input),
1476 ToolCallEventStream::test().0,
1477 cx,
1478 )
1479 })
1480 .await;
1481
1482 assert!(result.is_err());
1483 assert!(
1484 error_text(result.unwrap_err()).contains("worktree `private_files` setting"),
1485 "Error should mention worktree private_files setting"
1486 );
1487
1488 // Test reading excluded file in worktree2 should fail
1489 let result = cx
1490 .update(|cx| {
1491 let input = ReadFileToolInput {
1492 path: "worktree2/docs/internal.md".to_string(),
1493 start_line: None,
1494 end_line: None,
1495 };
1496 tool.clone().run(
1497 ToolInput::resolved(input),
1498 ToolCallEventStream::test().0,
1499 cx,
1500 )
1501 })
1502 .await;
1503
1504 assert!(result.is_err());
1505 assert!(
1506 error_text(result.unwrap_err()).contains("worktree `file_scan_exclusions` setting"),
1507 "Error should mention worktree file_scan_exclusions setting"
1508 );
1509
1510 // Test that files allowed in one worktree but not in another are handled correctly
1511 // (e.g., config.toml is private in worktree1 but doesn't exist in worktree2)
1512 let result = cx
1513 .update(|cx| {
1514 let input = ReadFileToolInput {
1515 path: "worktree1/src/config.toml".to_string(),
1516 start_line: None,
1517 end_line: None,
1518 };
1519 tool.clone().run(
1520 ToolInput::resolved(input),
1521 ToolCallEventStream::test().0,
1522 cx,
1523 )
1524 })
1525 .await;
1526
1527 assert!(result.is_err());
1528 assert!(
1529 error_text(result.unwrap_err()).contains("worktree `private_files` setting"),
1530 "Config.toml should be blocked by worktree1's private_files setting"
1531 );
1532 }
1533
1534 #[gpui::test]
1535 async fn test_read_file_symlink_escape_requests_authorization(cx: &mut TestAppContext) {
1536 init_test(cx);
1537
1538 let fs = FakeFs::new(cx.executor());
1539 fs.insert_tree(
1540 path!("/root"),
1541 json!({
1542 "project": {
1543 "src": { "main.rs": "fn main() {}" }
1544 },
1545 "external": {
1546 "secret.txt": "SECRET_KEY=abc123"
1547 }
1548 }),
1549 )
1550 .await;
1551
1552 fs.create_symlink(
1553 path!("/root/project/secret_link.txt").as_ref(),
1554 PathBuf::from("../external/secret.txt"),
1555 )
1556 .await
1557 .unwrap();
1558
1559 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
1560 cx.executor().run_until_parked();
1561
1562 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1563 let tool = Arc::new(ReadFileTool::new(project.clone(), action_log, true));
1564
1565 let (event_stream, mut event_rx) = ToolCallEventStream::test();
1566 let task = cx.update(|cx| {
1567 tool.clone().run(
1568 ToolInput::resolved(ReadFileToolInput {
1569 path: "project/secret_link.txt".to_string(),
1570 start_line: None,
1571 end_line: None,
1572 }),
1573 event_stream,
1574 cx,
1575 )
1576 });
1577
1578 let auth = event_rx.expect_authorization().await;
1579 let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
1580 assert!(
1581 title.contains("points outside the project"),
1582 "title: {title}"
1583 );
1584
1585 auth.response
1586 .send(acp_thread::SelectedPermissionOutcome::new(
1587 acp::PermissionOptionId::new("allow"),
1588 acp::PermissionOptionKind::AllowOnce,
1589 ))
1590 .unwrap();
1591
1592 let result = task.await;
1593 assert!(result.is_ok(), "should succeed after approval: {result:?}");
1594 }
1595
1596 #[gpui::test]
1597 async fn test_read_file_symlink_escape_denied(cx: &mut TestAppContext) {
1598 init_test(cx);
1599
1600 let fs = FakeFs::new(cx.executor());
1601 fs.insert_tree(
1602 path!("/root"),
1603 json!({
1604 "project": {
1605 "src": { "main.rs": "fn main() {}" }
1606 },
1607 "external": {
1608 "secret.txt": "SECRET_KEY=abc123"
1609 }
1610 }),
1611 )
1612 .await;
1613
1614 fs.create_symlink(
1615 path!("/root/project/secret_link.txt").as_ref(),
1616 PathBuf::from("../external/secret.txt"),
1617 )
1618 .await
1619 .unwrap();
1620
1621 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
1622 cx.executor().run_until_parked();
1623
1624 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1625 let tool = Arc::new(ReadFileTool::new(project.clone(), action_log, true));
1626
1627 let (event_stream, mut event_rx) = ToolCallEventStream::test();
1628 let task = cx.update(|cx| {
1629 tool.clone().run(
1630 ToolInput::resolved(ReadFileToolInput {
1631 path: "project/secret_link.txt".to_string(),
1632 start_line: None,
1633 end_line: None,
1634 }),
1635 event_stream,
1636 cx,
1637 )
1638 });
1639
1640 let auth = event_rx.expect_authorization().await;
1641 drop(auth);
1642
1643 let result = task.await;
1644 assert!(
1645 result.is_err(),
1646 "Tool should fail when authorization is denied"
1647 );
1648 }
1649
1650 #[gpui::test]
1651 async fn test_read_file_symlink_escape_private_path_no_authorization(cx: &mut TestAppContext) {
1652 init_test(cx);
1653
1654 let fs = FakeFs::new(cx.executor());
1655 fs.insert_tree(
1656 path!("/root"),
1657 json!({
1658 "project": {
1659 "src": { "main.rs": "fn main() {}" }
1660 },
1661 "external": {
1662 "secret.txt": "SECRET_KEY=abc123"
1663 }
1664 }),
1665 )
1666 .await;
1667
1668 fs.create_symlink(
1669 path!("/root/project/secret_link.txt").as_ref(),
1670 PathBuf::from("../external/secret.txt"),
1671 )
1672 .await
1673 .unwrap();
1674
1675 cx.update(|cx| {
1676 settings::SettingsStore::update_global(cx, |store, cx| {
1677 store.update_user_settings(cx, |settings| {
1678 settings.project.worktree.private_files =
1679 Some(vec!["**/secret_link.txt".to_string()].into());
1680 });
1681 });
1682 });
1683
1684 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
1685 cx.executor().run_until_parked();
1686
1687 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1688 let tool = Arc::new(ReadFileTool::new(project.clone(), action_log, true));
1689
1690 let (event_stream, mut event_rx) = ToolCallEventStream::test();
1691 let result = cx
1692 .update(|cx| {
1693 tool.clone().run(
1694 ToolInput::resolved(ReadFileToolInput {
1695 path: "project/secret_link.txt".to_string(),
1696 start_line: None,
1697 end_line: None,
1698 }),
1699 event_stream,
1700 cx,
1701 )
1702 })
1703 .await;
1704
1705 assert!(
1706 result.is_err(),
1707 "Expected read_file to fail on private path"
1708 );
1709 let error = error_text(result.unwrap_err());
1710 assert!(
1711 error.contains("private_files"),
1712 "Expected private-files validation error, got: {error}"
1713 );
1714
1715 let event = event_rx.try_recv();
1716 assert!(
1717 !matches!(
1718 event,
1719 Ok(Ok(crate::thread::ThreadEvent::ToolCallAuthorization(_)))
1720 ),
1721 "No authorization should be requested when validation fails before read",
1722 );
1723 }
1724
1725 #[gpui::test]
1726 async fn test_read_global_skill_file(cx: &mut TestAppContext) {
1727 init_test(cx);
1728
1729 // Set up a project that does NOT contain the skills tree, plus a
1730 // global skill file outside the worktree.
1731 let fs = FakeFs::new(cx.executor());
1732 fs.insert_tree(
1733 path!("/root"),
1734 json!({
1735 "src": { "main.rs": "fn main() {}" }
1736 }),
1737 )
1738 .await;
1739
1740 let skill_md_path = agent_skills::global_skills_dir()
1741 .join("my-skill")
1742 .join("references")
1743 .join("spec.md");
1744 fs.create_dir(skill_md_path.parent().unwrap())
1745 .await
1746 .unwrap();
1747 fs.insert_file(&skill_md_path, b"# Spec\n\nReference body.".to_vec())
1748 .await;
1749
1750 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1751 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1752 let tool = Arc::new(ReadFileTool::new(project, action_log, true));
1753
1754 let result = cx
1755 .update(|cx| {
1756 let input = ReadFileToolInput {
1757 path: skill_md_path.to_string_lossy().into_owned(),
1758 start_line: None,
1759 end_line: None,
1760 };
1761 tool.run(
1762 ToolInput::resolved(input),
1763 ToolCallEventStream::test().0,
1764 cx,
1765 )
1766 })
1767 .await;
1768
1769 let content = result.unwrap();
1770 let LanguageModelToolResultContent::Text(text) = content else {
1771 panic!("expected text content");
1772 };
1773 assert_eq!(
1774 text.as_ref(),
1775 " 1\t# Spec\n 2\t\n 3\tReference body."
1776 );
1777 }
1778
1779 #[gpui::test]
1780 async fn test_read_global_skill_file_with_line_range(cx: &mut TestAppContext) {
1781 init_test(cx);
1782
1783 let fs = FakeFs::new(cx.executor());
1784 fs.insert_tree(path!("/root"), json!({})).await;
1785
1786 let skill_md_path = agent_skills::global_skills_dir()
1787 .join("my-skill")
1788 .join("references")
1789 .join("long.md");
1790 fs.create_dir(skill_md_path.parent().unwrap())
1791 .await
1792 .unwrap();
1793 fs.insert_file(
1794 &skill_md_path,
1795 b"line one\nline two\nline three\nline four\n".to_vec(),
1796 )
1797 .await;
1798
1799 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1800 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1801 let tool = Arc::new(ReadFileTool::new(project, action_log, true));
1802
1803 let result = cx
1804 .update(|cx| {
1805 let input = ReadFileToolInput {
1806 path: skill_md_path.to_string_lossy().into_owned(),
1807 start_line: Some(2),
1808 end_line: Some(3),
1809 };
1810 tool.run(
1811 ToolInput::resolved(input),
1812 ToolCallEventStream::test().0,
1813 cx,
1814 )
1815 })
1816 .await;
1817
1818 let LanguageModelToolResultContent::Text(text) = result.unwrap() else {
1819 panic!("expected text content");
1820 };
1821 // Mirrors the buffer-backed path: lines 2-3 inclusive, WITH trailing
1822 // newline of the last returned line.
1823 assert_eq!(text.as_ref(), " 2\tline two\n 3\tline three\n");
1824 }
1825
1826 #[gpui::test]
1827 async fn test_read_global_skill_file_line_range_zero_start(cx: &mut TestAppContext) {
1828 init_test(cx);
1829
1830 let fs = FakeFs::new(cx.executor());
1831 fs.insert_tree(path!("/root"), json!({})).await;
1832
1833 let skill_md_path = agent_skills::global_skills_dir()
1834 .join("my-skill")
1835 .join("references")
1836 .join("long.md");
1837 fs.create_dir(skill_md_path.parent().unwrap())
1838 .await
1839 .unwrap();
1840 fs.insert_file(
1841 &skill_md_path,
1842 b"Line 1\nLine 2\nLine 3\nLine 4\nLine 5".to_vec(),
1843 )
1844 .await;
1845
1846 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1847 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1848 let tool = Arc::new(ReadFileTool::new(project, action_log, true));
1849
1850 let result = cx
1851 .update(|cx| {
1852 let input = ReadFileToolInput {
1853 path: skill_md_path.to_string_lossy().into_owned(),
1854 start_line: Some(0),
1855 end_line: Some(2),
1856 };
1857 tool.run(
1858 ToolInput::resolved(input),
1859 ToolCallEventStream::test().0,
1860 cx,
1861 )
1862 })
1863 .await;
1864
1865 let LanguageModelToolResultContent::Text(text) = result.unwrap() else {
1866 panic!("expected text content");
1867 };
1868 assert_eq!(text.as_ref(), " 1\tLine 1\n 2\tLine 2\n");
1869 }
1870
1871 #[gpui::test]
1872 async fn test_read_global_skill_file_line_range_zero_end(cx: &mut TestAppContext) {
1873 init_test(cx);
1874
1875 let fs = FakeFs::new(cx.executor());
1876 fs.insert_tree(path!("/root"), json!({})).await;
1877
1878 let skill_md_path = agent_skills::global_skills_dir()
1879 .join("my-skill")
1880 .join("references")
1881 .join("long.md");
1882 fs.create_dir(skill_md_path.parent().unwrap())
1883 .await
1884 .unwrap();
1885 fs.insert_file(
1886 &skill_md_path,
1887 b"Line 1\nLine 2\nLine 3\nLine 4\nLine 5".to_vec(),
1888 )
1889 .await;
1890
1891 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1892 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1893 let tool = Arc::new(ReadFileTool::new(project, action_log, true));
1894
1895 let result = cx
1896 .update(|cx| {
1897 let input = ReadFileToolInput {
1898 path: skill_md_path.to_string_lossy().into_owned(),
1899 start_line: Some(1),
1900 end_line: Some(0),
1901 };
1902 tool.run(
1903 ToolInput::resolved(input),
1904 ToolCallEventStream::test().0,
1905 cx,
1906 )
1907 })
1908 .await;
1909
1910 let LanguageModelToolResultContent::Text(text) = result.unwrap() else {
1911 panic!("expected text content");
1912 };
1913 assert_eq!(text.as_ref(), " 1\tLine 1\n");
1914 }
1915
1916 #[gpui::test]
1917 async fn test_read_global_skill_file_line_range_inverted(cx: &mut TestAppContext) {
1918 init_test(cx);
1919
1920 let fs = FakeFs::new(cx.executor());
1921 fs.insert_tree(path!("/root"), json!({})).await;
1922
1923 let skill_md_path = agent_skills::global_skills_dir()
1924 .join("my-skill")
1925 .join("references")
1926 .join("long.md");
1927 fs.create_dir(skill_md_path.parent().unwrap())
1928 .await
1929 .unwrap();
1930 fs.insert_file(
1931 &skill_md_path,
1932 b"Line 1\nLine 2\nLine 3\nLine 4\nLine 5".to_vec(),
1933 )
1934 .await;
1935
1936 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1937 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1938 let tool = Arc::new(ReadFileTool::new(project, action_log, true));
1939
1940 let result = cx
1941 .update(|cx| {
1942 let input = ReadFileToolInput {
1943 path: skill_md_path.to_string_lossy().into_owned(),
1944 start_line: Some(3),
1945 end_line: Some(2),
1946 };
1947 tool.run(
1948 ToolInput::resolved(input),
1949 ToolCallEventStream::test().0,
1950 cx,
1951 )
1952 })
1953 .await;
1954
1955 let LanguageModelToolResultContent::Text(text) = result.unwrap() else {
1956 panic!("expected text content");
1957 };
1958 assert_eq!(text.as_ref(), " 3\tLine 3\n");
1959 }
1960
1961 #[gpui::test]
1962 async fn test_read_global_skill_file_line_range_crlf(cx: &mut TestAppContext) {
1963 init_test(cx);
1964
1965 let fs = FakeFs::new(cx.executor());
1966 fs.insert_tree(path!("/root"), json!({})).await;
1967
1968 let skill_md_path = agent_skills::global_skills_dir()
1969 .join("my-skill")
1970 .join("references")
1971 .join("long.md");
1972 fs.create_dir(skill_md_path.parent().unwrap())
1973 .await
1974 .unwrap();
1975 fs.insert_file(
1976 &skill_md_path,
1977 b"line one\r\nline two\r\nline three\r\n".to_vec(),
1978 )
1979 .await;
1980
1981 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1982 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1983 let tool = Arc::new(ReadFileTool::new(project, action_log, true));
1984
1985 let result = cx
1986 .update(|cx| {
1987 let input = ReadFileToolInput {
1988 path: skill_md_path.to_string_lossy().into_owned(),
1989 start_line: Some(1),
1990 end_line: Some(2),
1991 };
1992 tool.run(
1993 ToolInput::resolved(input),
1994 ToolCallEventStream::test().0,
1995 cx,
1996 )
1997 })
1998 .await;
1999
2000 let LanguageModelToolResultContent::Text(text) = result.unwrap() else {
2001 panic!("expected text content");
2002 };
2003 assert_eq!(text.as_ref(), " 1\tline one\r\n 2\tline two\r\n");
2004 }
2005
2006 #[gpui::test]
2007 async fn test_read_outside_skills_dir_still_rejected(cx: &mut TestAppContext) {
2008 init_test(cx);
2009
2010 // A path that's neither in the worktree nor under the global skills
2011 // dir should still fail — the fast path is gated, not a backdoor for
2012 // arbitrary external reads.
2013 let fs = FakeFs::new(cx.executor());
2014 fs.insert_tree(path!("/root"), json!({})).await;
2015 fs.create_dir(path!("/etc").as_ref()).await.unwrap();
2016 fs.insert_file(path!("/etc/secret"), b"top secret".to_vec())
2017 .await;
2018
2019 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2020 let action_log = cx.new(|_| ActionLog::new(project.clone()));
2021 let tool = Arc::new(ReadFileTool::new(project, action_log, true));
2022
2023 let result = cx
2024 .update(|cx| {
2025 let input = ReadFileToolInput {
2026 path: path!("/etc/secret").to_string(),
2027 start_line: None,
2028 end_line: None,
2029 };
2030 tool.run(
2031 ToolInput::resolved(input),
2032 ToolCallEventStream::test().0,
2033 cx,
2034 )
2035 })
2036 .await;
2037
2038 assert!(
2039 result.is_err(),
2040 "path outside skills dir should be rejected"
2041 );
2042 }
2043}
2044