- `language_model` no longer depends on provider-specific crates such as `anthropic` and `open_ai` (inverted dependency) - `language_model_core` was extracted from `language_model` which contains the types for the provider-specific crates to convert to/from. - `gpui::SharedString` has been extracted into its own crate (still exposed by `gpui`), so `language_model_core` and provider API crates don't have to depend on `gpui`. - Removes some unnecessary `&'static str` | `SharedString` -> `String` -> `SharedString` conversions across the codebase. - Extracts the core logic of the cloud `LanguageModelProvider` into its own crate with simpler dependencies. Release Notes: - N/A --------- Co-authored-by: John Tur <john-tur@outlook.com>
1330 lines
47 KiB
Rust
1330 lines
47 KiB
Rust
use action_log::ActionLog;
|
|
use agent_client_protocol::{self as acp, ToolCallUpdateFields};
|
|
use anyhow::{Context as _, Result, anyhow};
|
|
use futures::FutureExt as _;
|
|
use gpui::{App, Entity, SharedString, Task};
|
|
use indoc::formatdoc;
|
|
use language::Point;
|
|
use language_model::{LanguageModelImage, LanguageModelImageExt, LanguageModelToolResultContent};
|
|
use project::{AgentLocation, ImageItem, Project, WorktreeSettings, image_store};
|
|
use schemars::JsonSchema;
|
|
use serde::{Deserialize, Serialize};
|
|
use settings::Settings;
|
|
use std::sync::Arc;
|
|
use util::markdown::MarkdownCodeBlock;
|
|
|
|
fn tool_content_err(e: impl std::fmt::Display) -> LanguageModelToolResultContent {
|
|
LanguageModelToolResultContent::from(e.to_string())
|
|
}
|
|
|
|
use super::tool_permissions::{
|
|
ResolvedProjectPath, authorize_symlink_access, canonicalize_worktree_roots,
|
|
resolve_project_path,
|
|
};
|
|
use crate::{AgentTool, ToolCallEventStream, ToolInput, outline};
|
|
|
|
/// Reads the content of the given file in the project.
|
|
///
|
|
/// - Never attempt to read a path that hasn't been previously mentioned.
|
|
/// - For large files, this tool returns a file outline with symbol names and line numbers instead of the full content.
|
|
/// This outline IS a successful response - use the line numbers to read specific sections with start_line/end_line.
|
|
/// Do NOT retry reading the same file without line numbers if you receive an outline.
|
|
/// - This tool supports reading image files. Supported formats: PNG, JPEG, WebP, GIF, BMP, TIFF.
|
|
/// Image files are returned as visual content that you can analyze directly.
|
|
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
|
|
pub struct ReadFileToolInput {
|
|
/// The relative path of the file to read.
|
|
///
|
|
/// This path should never be absolute, and the first component of the path should always be a root directory in a project.
|
|
///
|
|
/// <example>
|
|
/// If the project has the following root directories:
|
|
///
|
|
/// - /a/b/directory1
|
|
/// - /c/d/directory2
|
|
///
|
|
/// If you want to access `file.txt` in `directory1`, you should use the path `directory1/file.txt`.
|
|
/// If you want to access `file.txt` in `directory2`, you should use the path `directory2/file.txt`.
|
|
/// </example>
|
|
pub path: String,
|
|
/// Optional line number to start reading on (1-based index)
|
|
#[serde(default)]
|
|
pub start_line: Option<u32>,
|
|
/// Optional line number to end reading on (1-based index, inclusive)
|
|
#[serde(default)]
|
|
pub end_line: Option<u32>,
|
|
}
|
|
|
|
pub struct ReadFileTool {
|
|
project: Entity<Project>,
|
|
action_log: Entity<ActionLog>,
|
|
update_agent_location: bool,
|
|
}
|
|
|
|
impl ReadFileTool {
|
|
pub fn new(
|
|
project: Entity<Project>,
|
|
action_log: Entity<ActionLog>,
|
|
update_agent_location: bool,
|
|
) -> Self {
|
|
Self {
|
|
project,
|
|
action_log,
|
|
update_agent_location,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl AgentTool for ReadFileTool {
|
|
type Input = ReadFileToolInput;
|
|
type Output = LanguageModelToolResultContent;
|
|
|
|
const NAME: &'static str = "read_file";
|
|
|
|
fn kind() -> acp::ToolKind {
|
|
acp::ToolKind::Read
|
|
}
|
|
|
|
fn initial_title(
|
|
&self,
|
|
input: Result<Self::Input, serde_json::Value>,
|
|
cx: &mut App,
|
|
) -> SharedString {
|
|
if let Ok(input) = input
|
|
&& let Some(project_path) = self.project.read(cx).find_project_path(&input.path, cx)
|
|
&& let Some(path) = self
|
|
.project
|
|
.read(cx)
|
|
.short_full_path_for_project_path(&project_path, cx)
|
|
{
|
|
match (input.start_line, input.end_line) {
|
|
(Some(start), Some(end)) => {
|
|
format!("Read file `{path}` (lines {}-{})", start, end,)
|
|
}
|
|
(Some(start), None) => {
|
|
format!("Read file `{path}` (from line {})", start)
|
|
}
|
|
_ => format!("Read file `{path}`"),
|
|
}
|
|
.into()
|
|
} else {
|
|
"Read file".into()
|
|
}
|
|
}
|
|
|
|
fn run(
|
|
self: Arc<Self>,
|
|
input: ToolInput<Self::Input>,
|
|
event_stream: ToolCallEventStream,
|
|
cx: &mut App,
|
|
) -> Task<Result<LanguageModelToolResultContent, LanguageModelToolResultContent>> {
|
|
let project = self.project.clone();
|
|
let action_log = self.action_log.clone();
|
|
cx.spawn(async move |cx| {
|
|
let input = input
|
|
.recv()
|
|
.await
|
|
.map_err(tool_content_err)?;
|
|
let fs = project.read_with(cx, |project, _cx| project.fs().clone());
|
|
let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await;
|
|
|
|
let (project_path, symlink_canonical_target) =
|
|
project.read_with(cx, |project, cx| {
|
|
let resolved =
|
|
resolve_project_path(project, &input.path, &canonical_roots, cx)?;
|
|
anyhow::Ok(match resolved {
|
|
ResolvedProjectPath::Safe(path) => (path, None),
|
|
ResolvedProjectPath::SymlinkEscape {
|
|
project_path,
|
|
canonical_target,
|
|
} => (project_path, Some(canonical_target)),
|
|
})
|
|
}).map_err(tool_content_err)?;
|
|
|
|
let abs_path = project
|
|
.read_with(cx, |project, cx| {
|
|
project.absolute_path(&project_path, cx)
|
|
})
|
|
.ok_or_else(|| {
|
|
anyhow!("Failed to convert {} to absolute path", &input.path)
|
|
}).map_err(tool_content_err)?;
|
|
|
|
// Check settings exclusions synchronously
|
|
project.read_with(cx, |_project, cx| {
|
|
let global_settings = WorktreeSettings::get_global(cx);
|
|
if global_settings.is_path_excluded(&project_path.path) {
|
|
anyhow::bail!(
|
|
"Cannot read file because its path matches the global `file_scan_exclusions` setting: {}",
|
|
&input.path
|
|
);
|
|
}
|
|
|
|
if global_settings.is_path_private(&project_path.path) {
|
|
anyhow::bail!(
|
|
"Cannot read file because its path matches the global `private_files` setting: {}",
|
|
&input.path
|
|
);
|
|
}
|
|
|
|
let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);
|
|
if worktree_settings.is_path_excluded(&project_path.path) {
|
|
anyhow::bail!(
|
|
"Cannot read file because its path matches the worktree `file_scan_exclusions` setting: {}",
|
|
&input.path
|
|
);
|
|
}
|
|
|
|
if worktree_settings.is_path_private(&project_path.path) {
|
|
anyhow::bail!(
|
|
"Cannot read file because its path matches the worktree `private_files` setting: {}",
|
|
&input.path
|
|
);
|
|
}
|
|
|
|
anyhow::Ok(())
|
|
}).map_err(tool_content_err)?;
|
|
|
|
if let Some(canonical_target) = &symlink_canonical_target {
|
|
let authorize = cx.update(|cx| {
|
|
authorize_symlink_access(
|
|
Self::NAME,
|
|
&input.path,
|
|
canonical_target,
|
|
&event_stream,
|
|
cx,
|
|
)
|
|
});
|
|
authorize.await.map_err(tool_content_err)?;
|
|
}
|
|
|
|
let file_path = input.path.clone();
|
|
|
|
cx.update(|_cx| {
|
|
event_stream.update_fields(ToolCallUpdateFields::new().locations(vec![
|
|
acp::ToolCallLocation::new(&abs_path)
|
|
.line(input.start_line.map(|line| line.saturating_sub(1))),
|
|
]));
|
|
});
|
|
|
|
let is_image = project.read_with(cx, |_project, cx| {
|
|
image_store::is_image_file(&project, &project_path, cx)
|
|
});
|
|
|
|
if is_image {
|
|
let image_entity: Entity<ImageItem> = cx
|
|
.update(|cx| {
|
|
self.project.update(cx, |project, cx| {
|
|
project.open_image(project_path.clone(), cx)
|
|
})
|
|
})
|
|
.await.map_err(tool_content_err)?;
|
|
|
|
let image =
|
|
image_entity.read_with(cx, |image_item, _| Arc::clone(&image_item.image));
|
|
|
|
let language_model_image = cx
|
|
.update(|cx| LanguageModelImage::from_image(image, cx))
|
|
.await
|
|
.context("processing image")
|
|
.map_err(tool_content_err)?;
|
|
|
|
event_stream.update_fields(ToolCallUpdateFields::new().content(vec![
|
|
acp::ToolCallContent::Content(acp::Content::new(acp::ContentBlock::Image(
|
|
acp::ImageContent::new(language_model_image.source.clone(), "image/png"),
|
|
))),
|
|
]));
|
|
|
|
return Ok(language_model_image.into());
|
|
}
|
|
|
|
let open_buffer_task = project.update(cx, |project, cx| {
|
|
project.open_buffer(project_path.clone(), cx)
|
|
});
|
|
|
|
let buffer = futures::select! {
|
|
result = open_buffer_task.fuse() => result.map_err(tool_content_err)?,
|
|
_ = event_stream.cancelled_by_user().fuse() => {
|
|
return Err(tool_content_err("File read cancelled by user"));
|
|
}
|
|
};
|
|
if buffer.read_with(cx, |buffer, _| {
|
|
buffer
|
|
.file()
|
|
.as_ref()
|
|
.is_none_or(|file| !file.disk_state().exists())
|
|
}) {
|
|
return Err(tool_content_err(format!("{file_path} not found")));
|
|
}
|
|
|
|
let mut anchor = None;
|
|
|
|
// Check if specific line ranges are provided
|
|
let result = if input.start_line.is_some() || input.end_line.is_some() {
|
|
let result = buffer.read_with(cx, |buffer, _cx| {
|
|
// .max(1) because despite instructions to be 1-indexed, sometimes the model passes 0.
|
|
let start = input.start_line.unwrap_or(1).max(1);
|
|
let start_row = start - 1;
|
|
if start_row <= buffer.max_point().row {
|
|
let column = buffer.line_indent_for_row(start_row).raw_len();
|
|
anchor = Some(buffer.anchor_before(Point::new(start_row, column)));
|
|
}
|
|
|
|
let mut end_row = input.end_line.unwrap_or(u32::MAX);
|
|
if end_row <= start_row {
|
|
end_row = start_row + 1; // read at least one lines
|
|
}
|
|
let start = buffer.anchor_before(Point::new(start_row, 0));
|
|
let end = buffer.anchor_before(Point::new(end_row, 0));
|
|
buffer.text_for_range(start..end).collect::<String>()
|
|
});
|
|
|
|
action_log.update(cx, |log, cx| {
|
|
log.buffer_read(buffer.clone(), cx);
|
|
});
|
|
|
|
Ok(result.into())
|
|
} else {
|
|
// No line ranges specified, so check file size to see if it's too big.
|
|
let buffer_content = outline::get_buffer_content_or_outline(
|
|
buffer.clone(),
|
|
Some(&abs_path.to_string_lossy()),
|
|
cx,
|
|
)
|
|
.await.map_err(tool_content_err)?;
|
|
|
|
action_log.update(cx, |log, cx| {
|
|
log.buffer_read(buffer.clone(), cx);
|
|
});
|
|
|
|
if buffer_content.is_outline {
|
|
Ok(formatdoc! {"
|
|
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.
|
|
|
|
IMPORTANT: Do NOT retry this call without line numbers - you will get the same outline.
|
|
Instead, use the line numbers below to read specific sections by calling this tool again with start_line and end_line parameters.
|
|
|
|
{}
|
|
|
|
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.
|
|
For example, to read a function shown as [L100-150], use start_line: 100 and end_line: 150.", buffer_content.text
|
|
}
|
|
.into())
|
|
} else {
|
|
Ok(buffer_content.text.into())
|
|
}
|
|
};
|
|
|
|
project.update(cx, |project, cx| {
|
|
if self.update_agent_location {
|
|
project.set_agent_location(
|
|
Some(AgentLocation {
|
|
buffer: buffer.downgrade(),
|
|
position: anchor.unwrap_or_else(|| {
|
|
text::Anchor::min_for_buffer(buffer.read(cx).remote_id())
|
|
}),
|
|
}),
|
|
cx,
|
|
);
|
|
}
|
|
if let Ok(LanguageModelToolResultContent::Text(text)) = &result {
|
|
let text: &str = text;
|
|
let markdown = MarkdownCodeBlock {
|
|
tag: &input.path,
|
|
text,
|
|
}
|
|
.to_string();
|
|
event_stream.update_fields(ToolCallUpdateFields::new().content(vec![
|
|
acp::ToolCallContent::Content(acp::Content::new(markdown)),
|
|
]));
|
|
}
|
|
});
|
|
|
|
result
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod test {
|
|
use super::*;
|
|
use agent_client_protocol as acp;
|
|
use fs::Fs as _;
|
|
use gpui::{AppContext, TestAppContext, UpdateGlobal as _};
|
|
use project::{FakeFs, Project};
|
|
use serde_json::json;
|
|
use settings::SettingsStore;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
use util::path;
|
|
|
|
#[gpui::test]
|
|
async fn test_read_nonexistent_file(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
|
|
let fs = FakeFs::new(cx.executor());
|
|
fs.insert_tree(path!("/root"), json!({})).await;
|
|
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
|
|
let action_log = cx.new(|_| ActionLog::new(project.clone()));
|
|
let tool = Arc::new(ReadFileTool::new(project, action_log, true));
|
|
let (event_stream, _) = ToolCallEventStream::test();
|
|
|
|
let result = cx
|
|
.update(|cx| {
|
|
let input = ReadFileToolInput {
|
|
path: "root/nonexistent_file.txt".to_string(),
|
|
start_line: None,
|
|
end_line: None,
|
|
};
|
|
tool.run(ToolInput::resolved(input), event_stream, cx)
|
|
})
|
|
.await;
|
|
assert_eq!(
|
|
error_text(result.unwrap_err()),
|
|
"root/nonexistent_file.txt not found"
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_read_small_file(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
|
|
let fs = FakeFs::new(cx.executor());
|
|
fs.insert_tree(
|
|
path!("/root"),
|
|
json!({
|
|
"small_file.txt": "This is a small file content"
|
|
}),
|
|
)
|
|
.await;
|
|
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
|
|
let action_log = cx.new(|_| ActionLog::new(project.clone()));
|
|
let tool = Arc::new(ReadFileTool::new(project, action_log, true));
|
|
let result = cx
|
|
.update(|cx| {
|
|
let input = ReadFileToolInput {
|
|
path: "root/small_file.txt".into(),
|
|
start_line: None,
|
|
end_line: None,
|
|
};
|
|
tool.run(
|
|
ToolInput::resolved(input),
|
|
ToolCallEventStream::test().0,
|
|
cx,
|
|
)
|
|
})
|
|
.await;
|
|
assert_eq!(result.unwrap(), "This is a small file content".into());
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_read_large_file(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
|
|
let fs = FakeFs::new(cx.executor());
|
|
fs.insert_tree(
|
|
path!("/root"),
|
|
json!({
|
|
"large_file.rs": (0..1000).map(|i| format!("struct Test{} {{\n a: u32,\n b: usize,\n}}", i)).collect::<Vec<_>>().join("\n")
|
|
}),
|
|
)
|
|
.await;
|
|
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
|
|
let language_registry = project.read_with(cx, |project, _| project.languages().clone());
|
|
language_registry.add(language::rust_lang());
|
|
let action_log = cx.new(|_| ActionLog::new(project.clone()));
|
|
let tool = Arc::new(ReadFileTool::new(project, action_log, true));
|
|
let result = cx
|
|
.update(|cx| {
|
|
let input = ReadFileToolInput {
|
|
path: "root/large_file.rs".into(),
|
|
start_line: None,
|
|
end_line: None,
|
|
};
|
|
tool.clone().run(
|
|
ToolInput::resolved(input),
|
|
ToolCallEventStream::test().0,
|
|
cx,
|
|
)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let content = result.to_str().unwrap();
|
|
|
|
assert_eq!(
|
|
content.lines().skip(7).take(6).collect::<Vec<_>>(),
|
|
vec![
|
|
"struct Test0 [L1-4]",
|
|
" a [L2]",
|
|
" b [L3]",
|
|
"struct Test1 [L5-8]",
|
|
" a [L6]",
|
|
" b [L7]",
|
|
]
|
|
);
|
|
|
|
let result = cx
|
|
.update(|cx| {
|
|
let input = ReadFileToolInput {
|
|
path: "root/large_file.rs".into(),
|
|
start_line: None,
|
|
end_line: None,
|
|
};
|
|
tool.run(
|
|
ToolInput::resolved(input),
|
|
ToolCallEventStream::test().0,
|
|
cx,
|
|
)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let content = result.to_str().unwrap();
|
|
let expected_content = (0..1000)
|
|
.flat_map(|i| {
|
|
vec![
|
|
format!("struct Test{} [L{}-{}]", i, i * 4 + 1, i * 4 + 4),
|
|
format!(" a [L{}]", i * 4 + 2),
|
|
format!(" b [L{}]", i * 4 + 3),
|
|
]
|
|
})
|
|
.collect::<Vec<_>>();
|
|
pretty_assertions::assert_eq!(
|
|
content
|
|
.lines()
|
|
.skip(7)
|
|
.take(expected_content.len())
|
|
.collect::<Vec<_>>(),
|
|
expected_content
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_read_file_with_line_range(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
|
|
let fs = FakeFs::new(cx.executor());
|
|
fs.insert_tree(
|
|
path!("/root"),
|
|
json!({
|
|
"multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
|
|
}),
|
|
)
|
|
.await;
|
|
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
|
|
|
|
let action_log = cx.new(|_| ActionLog::new(project.clone()));
|
|
let tool = Arc::new(ReadFileTool::new(project, action_log, true));
|
|
let result = cx
|
|
.update(|cx| {
|
|
let input = ReadFileToolInput {
|
|
path: "root/multiline.txt".to_string(),
|
|
start_line: Some(2),
|
|
end_line: Some(4),
|
|
};
|
|
tool.run(
|
|
ToolInput::resolved(input),
|
|
ToolCallEventStream::test().0,
|
|
cx,
|
|
)
|
|
})
|
|
.await;
|
|
assert_eq!(result.unwrap(), "Line 2\nLine 3\nLine 4\n".into());
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_read_file_line_range_edge_cases(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
|
|
let fs = FakeFs::new(cx.executor());
|
|
fs.insert_tree(
|
|
path!("/root"),
|
|
json!({
|
|
"multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
|
|
}),
|
|
)
|
|
.await;
|
|
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
|
|
let action_log = cx.new(|_| ActionLog::new(project.clone()));
|
|
let tool = Arc::new(ReadFileTool::new(project, action_log, true));
|
|
|
|
// start_line of 0 should be treated as 1
|
|
let result = cx
|
|
.update(|cx| {
|
|
let input = ReadFileToolInput {
|
|
path: "root/multiline.txt".to_string(),
|
|
start_line: Some(0),
|
|
end_line: Some(2),
|
|
};
|
|
tool.clone().run(
|
|
ToolInput::resolved(input),
|
|
ToolCallEventStream::test().0,
|
|
cx,
|
|
)
|
|
})
|
|
.await;
|
|
assert_eq!(result.unwrap(), "Line 1\nLine 2\n".into());
|
|
|
|
// end_line of 0 should result in at least 1 line
|
|
let result = cx
|
|
.update(|cx| {
|
|
let input = ReadFileToolInput {
|
|
path: "root/multiline.txt".to_string(),
|
|
start_line: Some(1),
|
|
end_line: Some(0),
|
|
};
|
|
tool.clone().run(
|
|
ToolInput::resolved(input),
|
|
ToolCallEventStream::test().0,
|
|
cx,
|
|
)
|
|
})
|
|
.await;
|
|
assert_eq!(result.unwrap(), "Line 1\n".into());
|
|
|
|
// when start_line > end_line, should still return at least 1 line
|
|
let result = cx
|
|
.update(|cx| {
|
|
let input = ReadFileToolInput {
|
|
path: "root/multiline.txt".to_string(),
|
|
start_line: Some(3),
|
|
end_line: Some(2),
|
|
};
|
|
tool.clone().run(
|
|
ToolInput::resolved(input),
|
|
ToolCallEventStream::test().0,
|
|
cx,
|
|
)
|
|
})
|
|
.await;
|
|
assert_eq!(result.unwrap(), "Line 3\n".into());
|
|
}
|
|
|
|
fn error_text(content: LanguageModelToolResultContent) -> String {
|
|
match content {
|
|
LanguageModelToolResultContent::Text(text) => text.to_string(),
|
|
other => panic!("Expected text error, got: {other:?}"),
|
|
}
|
|
}
|
|
|
|
fn init_test(cx: &mut TestAppContext) {
|
|
cx.update(|cx| {
|
|
let settings_store = SettingsStore::test(cx);
|
|
cx.set_global(settings_store);
|
|
});
|
|
}
|
|
|
|
fn single_pixel_png() -> Vec<u8> {
|
|
vec![
|
|
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48,
|
|
0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00,
|
|
0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, 0x54, 0x78,
|
|
0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00,
|
|
0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
|
|
]
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_read_file_security(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
|
|
let fs = FakeFs::new(cx.executor());
|
|
|
|
fs.insert_tree(
|
|
path!("/"),
|
|
json!({
|
|
"project_root": {
|
|
"allowed_file.txt": "This file is in the project",
|
|
".mysecrets": "SECRET_KEY=abc123",
|
|
".secretdir": {
|
|
"config": "special configuration"
|
|
},
|
|
".mymetadata": "custom metadata",
|
|
"subdir": {
|
|
"normal_file.txt": "Normal file content",
|
|
"special.privatekey": "private key content",
|
|
"data.mysensitive": "sensitive data"
|
|
}
|
|
},
|
|
"outside_project": {
|
|
"sensitive_file.txt": "This file is outside the project"
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
cx.update(|cx| {
|
|
use gpui::UpdateGlobal;
|
|
use settings::SettingsStore;
|
|
SettingsStore::update_global(cx, |store, cx| {
|
|
store.update_user_settings(cx, |settings| {
|
|
settings.project.worktree.file_scan_exclusions = Some(vec![
|
|
"**/.secretdir".to_string(),
|
|
"**/.mymetadata".to_string(),
|
|
]);
|
|
settings.project.worktree.private_files = Some(
|
|
vec![
|
|
"**/.mysecrets".to_string(),
|
|
"**/*.privatekey".to_string(),
|
|
"**/*.mysensitive".to_string(),
|
|
]
|
|
.into(),
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
let project = Project::test(fs.clone(), [path!("/project_root").as_ref()], cx).await;
|
|
let action_log = cx.new(|_| ActionLog::new(project.clone()));
|
|
let tool = Arc::new(ReadFileTool::new(project, action_log, true));
|
|
|
|
// Reading a file outside the project worktree should fail
|
|
let result = cx
|
|
.update(|cx| {
|
|
let input = ReadFileToolInput {
|
|
path: "/outside_project/sensitive_file.txt".to_string(),
|
|
start_line: None,
|
|
end_line: None,
|
|
};
|
|
tool.clone().run(
|
|
ToolInput::resolved(input),
|
|
ToolCallEventStream::test().0,
|
|
cx,
|
|
)
|
|
})
|
|
.await;
|
|
assert!(
|
|
result.is_err(),
|
|
"read_file_tool should error when attempting to read an absolute path outside a worktree"
|
|
);
|
|
|
|
// Reading a file within the project should succeed
|
|
let result = cx
|
|
.update(|cx| {
|
|
let input = ReadFileToolInput {
|
|
path: "project_root/allowed_file.txt".to_string(),
|
|
start_line: None,
|
|
end_line: None,
|
|
};
|
|
tool.clone().run(
|
|
ToolInput::resolved(input),
|
|
ToolCallEventStream::test().0,
|
|
cx,
|
|
)
|
|
})
|
|
.await;
|
|
assert!(
|
|
result.is_ok(),
|
|
"read_file_tool should be able to read files inside worktrees"
|
|
);
|
|
|
|
// Reading files that match file_scan_exclusions should fail
|
|
let result = cx
|
|
.update(|cx| {
|
|
let input = ReadFileToolInput {
|
|
path: "project_root/.secretdir/config".to_string(),
|
|
start_line: None,
|
|
end_line: None,
|
|
};
|
|
tool.clone().run(
|
|
ToolInput::resolved(input),
|
|
ToolCallEventStream::test().0,
|
|
cx,
|
|
)
|
|
})
|
|
.await;
|
|
assert!(
|
|
result.is_err(),
|
|
"read_file_tool should error when attempting to read files in .secretdir (file_scan_exclusions)"
|
|
);
|
|
|
|
let result = cx
|
|
.update(|cx| {
|
|
let input = ReadFileToolInput {
|
|
path: "project_root/.mymetadata".to_string(),
|
|
start_line: None,
|
|
end_line: None,
|
|
};
|
|
tool.clone().run(
|
|
ToolInput::resolved(input),
|
|
ToolCallEventStream::test().0,
|
|
cx,
|
|
)
|
|
})
|
|
.await;
|
|
assert!(
|
|
result.is_err(),
|
|
"read_file_tool should error when attempting to read .mymetadata files (file_scan_exclusions)"
|
|
);
|
|
|
|
// Reading private files should fail
|
|
let result = cx
|
|
.update(|cx| {
|
|
let input = ReadFileToolInput {
|
|
path: "project_root/.mysecrets".to_string(),
|
|
start_line: None,
|
|
end_line: None,
|
|
};
|
|
tool.clone().run(
|
|
ToolInput::resolved(input),
|
|
ToolCallEventStream::test().0,
|
|
cx,
|
|
)
|
|
})
|
|
.await;
|
|
assert!(
|
|
result.is_err(),
|
|
"read_file_tool should error when attempting to read .mysecrets (private_files)"
|
|
);
|
|
|
|
let result = cx
|
|
.update(|cx| {
|
|
let input = ReadFileToolInput {
|
|
path: "project_root/subdir/special.privatekey".to_string(),
|
|
start_line: None,
|
|
end_line: None,
|
|
};
|
|
tool.clone().run(
|
|
ToolInput::resolved(input),
|
|
ToolCallEventStream::test().0,
|
|
cx,
|
|
)
|
|
})
|
|
.await;
|
|
assert!(
|
|
result.is_err(),
|
|
"read_file_tool should error when attempting to read .privatekey files (private_files)"
|
|
);
|
|
|
|
let result = cx
|
|
.update(|cx| {
|
|
let input = ReadFileToolInput {
|
|
path: "project_root/subdir/data.mysensitive".to_string(),
|
|
start_line: None,
|
|
end_line: None,
|
|
};
|
|
tool.clone().run(
|
|
ToolInput::resolved(input),
|
|
ToolCallEventStream::test().0,
|
|
cx,
|
|
)
|
|
})
|
|
.await;
|
|
assert!(
|
|
result.is_err(),
|
|
"read_file_tool should error when attempting to read .mysensitive files (private_files)"
|
|
);
|
|
|
|
// Reading a normal file should still work, even with private_files configured
|
|
let result = cx
|
|
.update(|cx| {
|
|
let input = ReadFileToolInput {
|
|
path: "project_root/subdir/normal_file.txt".to_string(),
|
|
start_line: None,
|
|
end_line: None,
|
|
};
|
|
tool.clone().run(
|
|
ToolInput::resolved(input),
|
|
ToolCallEventStream::test().0,
|
|
cx,
|
|
)
|
|
})
|
|
.await;
|
|
assert!(result.is_ok(), "Should be able to read normal files");
|
|
assert_eq!(result.unwrap(), "Normal file content".into());
|
|
|
|
// Path traversal attempts with .. should fail
|
|
let result = cx
|
|
.update(|cx| {
|
|
let input = ReadFileToolInput {
|
|
path: "project_root/../outside_project/sensitive_file.txt".to_string(),
|
|
start_line: None,
|
|
end_line: None,
|
|
};
|
|
tool.run(
|
|
ToolInput::resolved(input),
|
|
ToolCallEventStream::test().0,
|
|
cx,
|
|
)
|
|
})
|
|
.await;
|
|
assert!(
|
|
result.is_err(),
|
|
"read_file_tool should error when attempting to read a relative path that resolves to outside a worktree"
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_read_image_symlink_requires_authorization(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
|
|
let fs = FakeFs::new(cx.executor());
|
|
fs.insert_tree(path!("/root"), json!({})).await;
|
|
fs.insert_tree(path!("/outside"), json!({})).await;
|
|
fs.insert_file(path!("/outside/secret.png"), single_pixel_png())
|
|
.await;
|
|
fs.insert_symlink(
|
|
path!("/root/secret.png"),
|
|
PathBuf::from("/outside/secret.png"),
|
|
)
|
|
.await;
|
|
|
|
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
|
|
let action_log = cx.new(|_| ActionLog::new(project.clone()));
|
|
let tool = Arc::new(ReadFileTool::new(project, action_log, true));
|
|
|
|
let (event_stream, mut event_rx) = ToolCallEventStream::test();
|
|
let read_task = cx.update(|cx| {
|
|
tool.run(
|
|
ToolInput::resolved(ReadFileToolInput {
|
|
path: "root/secret.png".to_string(),
|
|
start_line: None,
|
|
end_line: None,
|
|
}),
|
|
event_stream,
|
|
cx,
|
|
)
|
|
});
|
|
|
|
let authorization = event_rx.expect_authorization().await;
|
|
assert!(
|
|
authorization
|
|
.tool_call
|
|
.fields
|
|
.title
|
|
.as_deref()
|
|
.is_some_and(|title| title.contains("points outside the project")),
|
|
"Expected symlink escape authorization before reading the image"
|
|
);
|
|
authorization
|
|
.response
|
|
.send(acp_thread::SelectedPermissionOutcome::new(
|
|
acp::PermissionOptionId::new("allow"),
|
|
acp::PermissionOptionKind::AllowOnce,
|
|
))
|
|
.unwrap();
|
|
|
|
let result = read_task.await;
|
|
assert!(result.is_ok());
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_read_file_with_multiple_worktree_settings(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
|
|
let fs = FakeFs::new(cx.executor());
|
|
|
|
// Create first worktree with its own private_files setting
|
|
fs.insert_tree(
|
|
path!("/worktree1"),
|
|
json!({
|
|
"src": {
|
|
"main.rs": "fn main() { println!(\"Hello from worktree1\"); }",
|
|
"secret.rs": "const API_KEY: &str = \"secret_key_1\";",
|
|
"config.toml": "[database]\nurl = \"postgres://localhost/db1\""
|
|
},
|
|
"tests": {
|
|
"test.rs": "mod tests { fn test_it() {} }",
|
|
"fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));"
|
|
},
|
|
".zed": {
|
|
"settings.json": r#"{
|
|
"file_scan_exclusions": ["**/fixture.*"],
|
|
"private_files": ["**/secret.rs", "**/config.toml"]
|
|
}"#
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
// Create second worktree with different private_files setting
|
|
fs.insert_tree(
|
|
path!("/worktree2"),
|
|
json!({
|
|
"lib": {
|
|
"public.js": "export function greet() { return 'Hello from worktree2'; }",
|
|
"private.js": "const SECRET_TOKEN = \"private_token_2\";",
|
|
"data.json": "{\"api_key\": \"json_secret_key\"}"
|
|
},
|
|
"docs": {
|
|
"README.md": "# Public Documentation",
|
|
"internal.md": "# Internal Secrets and Configuration"
|
|
},
|
|
".zed": {
|
|
"settings.json": r#"{
|
|
"file_scan_exclusions": ["**/internal.*"],
|
|
"private_files": ["**/private.js", "**/data.json"]
|
|
}"#
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
// Set global settings
|
|
cx.update(|cx| {
|
|
SettingsStore::update_global(cx, |store, cx| {
|
|
store.update_user_settings(cx, |settings| {
|
|
settings.project.worktree.file_scan_exclusions =
|
|
Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
|
|
settings.project.worktree.private_files =
|
|
Some(vec!["**/.env".to_string()].into());
|
|
});
|
|
});
|
|
});
|
|
|
|
let project = Project::test(
|
|
fs.clone(),
|
|
[path!("/worktree1").as_ref(), path!("/worktree2").as_ref()],
|
|
cx,
|
|
)
|
|
.await;
|
|
|
|
let action_log = cx.new(|_| ActionLog::new(project.clone()));
|
|
let tool = Arc::new(ReadFileTool::new(project.clone(), action_log.clone(), true));
|
|
|
|
// Test reading allowed files in worktree1
|
|
let result = cx
|
|
.update(|cx| {
|
|
let input = ReadFileToolInput {
|
|
path: "worktree1/src/main.rs".to_string(),
|
|
start_line: None,
|
|
end_line: None,
|
|
};
|
|
tool.clone().run(
|
|
ToolInput::resolved(input),
|
|
ToolCallEventStream::test().0,
|
|
cx,
|
|
)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
result,
|
|
"fn main() { println!(\"Hello from worktree1\"); }".into()
|
|
);
|
|
|
|
// Test reading private file in worktree1 should fail
|
|
let result = cx
|
|
.update(|cx| {
|
|
let input = ReadFileToolInput {
|
|
path: "worktree1/src/secret.rs".to_string(),
|
|
start_line: None,
|
|
end_line: None,
|
|
};
|
|
tool.clone().run(
|
|
ToolInput::resolved(input),
|
|
ToolCallEventStream::test().0,
|
|
cx,
|
|
)
|
|
})
|
|
.await;
|
|
|
|
assert!(result.is_err());
|
|
assert!(
|
|
error_text(result.unwrap_err()).contains("worktree `private_files` setting"),
|
|
"Error should mention worktree private_files setting"
|
|
);
|
|
|
|
// Test reading excluded file in worktree1 should fail
|
|
let result = cx
|
|
.update(|cx| {
|
|
let input = ReadFileToolInput {
|
|
path: "worktree1/tests/fixture.sql".to_string(),
|
|
start_line: None,
|
|
end_line: None,
|
|
};
|
|
tool.clone().run(
|
|
ToolInput::resolved(input),
|
|
ToolCallEventStream::test().0,
|
|
cx,
|
|
)
|
|
})
|
|
.await;
|
|
|
|
assert!(result.is_err());
|
|
assert!(
|
|
error_text(result.unwrap_err()).contains("worktree `file_scan_exclusions` setting"),
|
|
"Error should mention worktree file_scan_exclusions setting"
|
|
);
|
|
|
|
// Test reading allowed files in worktree2
|
|
let result = cx
|
|
.update(|cx| {
|
|
let input = ReadFileToolInput {
|
|
path: "worktree2/lib/public.js".to_string(),
|
|
start_line: None,
|
|
end_line: None,
|
|
};
|
|
tool.clone().run(
|
|
ToolInput::resolved(input),
|
|
ToolCallEventStream::test().0,
|
|
cx,
|
|
)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
result,
|
|
"export function greet() { return 'Hello from worktree2'; }".into()
|
|
);
|
|
|
|
// Test reading private file in worktree2 should fail
|
|
let result = cx
|
|
.update(|cx| {
|
|
let input = ReadFileToolInput {
|
|
path: "worktree2/lib/private.js".to_string(),
|
|
start_line: None,
|
|
end_line: None,
|
|
};
|
|
tool.clone().run(
|
|
ToolInput::resolved(input),
|
|
ToolCallEventStream::test().0,
|
|
cx,
|
|
)
|
|
})
|
|
.await;
|
|
|
|
assert!(result.is_err());
|
|
assert!(
|
|
error_text(result.unwrap_err()).contains("worktree `private_files` setting"),
|
|
"Error should mention worktree private_files setting"
|
|
);
|
|
|
|
// Test reading excluded file in worktree2 should fail
|
|
let result = cx
|
|
.update(|cx| {
|
|
let input = ReadFileToolInput {
|
|
path: "worktree2/docs/internal.md".to_string(),
|
|
start_line: None,
|
|
end_line: None,
|
|
};
|
|
tool.clone().run(
|
|
ToolInput::resolved(input),
|
|
ToolCallEventStream::test().0,
|
|
cx,
|
|
)
|
|
})
|
|
.await;
|
|
|
|
assert!(result.is_err());
|
|
assert!(
|
|
error_text(result.unwrap_err()).contains("worktree `file_scan_exclusions` setting"),
|
|
"Error should mention worktree file_scan_exclusions setting"
|
|
);
|
|
|
|
// Test that files allowed in one worktree but not in another are handled correctly
|
|
// (e.g., config.toml is private in worktree1 but doesn't exist in worktree2)
|
|
let result = cx
|
|
.update(|cx| {
|
|
let input = ReadFileToolInput {
|
|
path: "worktree1/src/config.toml".to_string(),
|
|
start_line: None,
|
|
end_line: None,
|
|
};
|
|
tool.clone().run(
|
|
ToolInput::resolved(input),
|
|
ToolCallEventStream::test().0,
|
|
cx,
|
|
)
|
|
})
|
|
.await;
|
|
|
|
assert!(result.is_err());
|
|
assert!(
|
|
error_text(result.unwrap_err()).contains("worktree `private_files` setting"),
|
|
"Config.toml should be blocked by worktree1's private_files setting"
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_read_file_symlink_escape_requests_authorization(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
|
|
let fs = FakeFs::new(cx.executor());
|
|
fs.insert_tree(
|
|
path!("/root"),
|
|
json!({
|
|
"project": {
|
|
"src": { "main.rs": "fn main() {}" }
|
|
},
|
|
"external": {
|
|
"secret.txt": "SECRET_KEY=abc123"
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
fs.create_symlink(
|
|
path!("/root/project/secret_link.txt").as_ref(),
|
|
PathBuf::from("../external/secret.txt"),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
|
|
cx.executor().run_until_parked();
|
|
|
|
let action_log = cx.new(|_| ActionLog::new(project.clone()));
|
|
let tool = Arc::new(ReadFileTool::new(project.clone(), action_log, true));
|
|
|
|
let (event_stream, mut event_rx) = ToolCallEventStream::test();
|
|
let task = cx.update(|cx| {
|
|
tool.clone().run(
|
|
ToolInput::resolved(ReadFileToolInput {
|
|
path: "project/secret_link.txt".to_string(),
|
|
start_line: None,
|
|
end_line: None,
|
|
}),
|
|
event_stream,
|
|
cx,
|
|
)
|
|
});
|
|
|
|
let auth = event_rx.expect_authorization().await;
|
|
let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
|
|
assert!(
|
|
title.contains("points outside the project"),
|
|
"title: {title}"
|
|
);
|
|
|
|
auth.response
|
|
.send(acp_thread::SelectedPermissionOutcome::new(
|
|
acp::PermissionOptionId::new("allow"),
|
|
acp::PermissionOptionKind::AllowOnce,
|
|
))
|
|
.unwrap();
|
|
|
|
let result = task.await;
|
|
assert!(result.is_ok(), "should succeed after approval: {result:?}");
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_read_file_symlink_escape_denied(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
|
|
let fs = FakeFs::new(cx.executor());
|
|
fs.insert_tree(
|
|
path!("/root"),
|
|
json!({
|
|
"project": {
|
|
"src": { "main.rs": "fn main() {}" }
|
|
},
|
|
"external": {
|
|
"secret.txt": "SECRET_KEY=abc123"
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
fs.create_symlink(
|
|
path!("/root/project/secret_link.txt").as_ref(),
|
|
PathBuf::from("../external/secret.txt"),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
|
|
cx.executor().run_until_parked();
|
|
|
|
let action_log = cx.new(|_| ActionLog::new(project.clone()));
|
|
let tool = Arc::new(ReadFileTool::new(project.clone(), action_log, true));
|
|
|
|
let (event_stream, mut event_rx) = ToolCallEventStream::test();
|
|
let task = cx.update(|cx| {
|
|
tool.clone().run(
|
|
ToolInput::resolved(ReadFileToolInput {
|
|
path: "project/secret_link.txt".to_string(),
|
|
start_line: None,
|
|
end_line: None,
|
|
}),
|
|
event_stream,
|
|
cx,
|
|
)
|
|
});
|
|
|
|
let auth = event_rx.expect_authorization().await;
|
|
drop(auth);
|
|
|
|
let result = task.await;
|
|
assert!(
|
|
result.is_err(),
|
|
"Tool should fail when authorization is denied"
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_read_file_symlink_escape_private_path_no_authorization(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
|
|
let fs = FakeFs::new(cx.executor());
|
|
fs.insert_tree(
|
|
path!("/root"),
|
|
json!({
|
|
"project": {
|
|
"src": { "main.rs": "fn main() {}" }
|
|
},
|
|
"external": {
|
|
"secret.txt": "SECRET_KEY=abc123"
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
fs.create_symlink(
|
|
path!("/root/project/secret_link.txt").as_ref(),
|
|
PathBuf::from("../external/secret.txt"),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
cx.update(|cx| {
|
|
settings::SettingsStore::update_global(cx, |store, cx| {
|
|
store.update_user_settings(cx, |settings| {
|
|
settings.project.worktree.private_files =
|
|
Some(vec!["**/secret_link.txt".to_string()].into());
|
|
});
|
|
});
|
|
});
|
|
|
|
let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
|
|
cx.executor().run_until_parked();
|
|
|
|
let action_log = cx.new(|_| ActionLog::new(project.clone()));
|
|
let tool = Arc::new(ReadFileTool::new(project.clone(), action_log, true));
|
|
|
|
let (event_stream, mut event_rx) = ToolCallEventStream::test();
|
|
let result = cx
|
|
.update(|cx| {
|
|
tool.clone().run(
|
|
ToolInput::resolved(ReadFileToolInput {
|
|
path: "project/secret_link.txt".to_string(),
|
|
start_line: None,
|
|
end_line: None,
|
|
}),
|
|
event_stream,
|
|
cx,
|
|
)
|
|
})
|
|
.await;
|
|
|
|
assert!(
|
|
result.is_err(),
|
|
"Expected read_file to fail on private path"
|
|
);
|
|
let error = error_text(result.unwrap_err());
|
|
assert!(
|
|
error.contains("private_files"),
|
|
"Expected private-files validation error, got: {error}"
|
|
);
|
|
|
|
let event = event_rx.try_recv();
|
|
assert!(
|
|
!matches!(
|
|
event,
|
|
Ok(Ok(crate::thread::ThreadEvent::ToolCallAuthorization(_)))
|
|
),
|
|
"No authorization should be requested when validation fails before read",
|
|
);
|
|
}
|
|
}
|