Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T02:34:55.623Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

read_file_tool.rs

2044 lines · 75.0 KB · rust
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
Served at tenant.openagents/omega Member data and write actions are omitted.