feat(infrastructure,backend,app-tauri,frontend): tools MCP workspace natifs read-only idea_workspace_map/list/read/search, activés par défaut et configurables dans les permissions MCP et les templates — #341 (QA verte)
This commit is contained in:
@ -85,9 +85,9 @@ pub use model_server::{
|
||||
LocalManagedProcess,
|
||||
};
|
||||
pub use orchestrator::mcp::{
|
||||
ManagedTempToolError, ManagedTempToolProvider, McpServer, MemoryTransport, StdioTransport,
|
||||
TemplateToolError, TemplateToolProvider, TicketToolError, TicketToolProvider,
|
||||
ToolPolicyRegistry,
|
||||
FsWorkspaceToolProvider, ManagedTempToolError, ManagedTempToolProvider, McpServer,
|
||||
MemoryTransport, StdioTransport, TemplateToolError, TemplateToolProvider, TicketToolError,
|
||||
TicketToolProvider, ToolPolicyRegistry, WorkspaceToolError, WorkspaceToolProvider,
|
||||
};
|
||||
pub use orchestrator::{
|
||||
process_request_file, FsOrchestratorWatcher, OrchestratorResponse, OrchestratorWatchHandle,
|
||||
|
||||
@ -37,6 +37,7 @@ pub mod templates;
|
||||
pub mod tickets;
|
||||
pub mod tools;
|
||||
pub mod transport;
|
||||
pub mod workspace;
|
||||
|
||||
pub use jsonrpc::{
|
||||
JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError, JSONRPC_VERSION,
|
||||
@ -49,3 +50,4 @@ pub use templates::{TemplateToolError, TemplateToolProvider};
|
||||
pub use tickets::{TicketToolError, TicketToolProvider};
|
||||
pub use tools::{catalogue, map_tool_call, tool_returns_reply, ToolDef, ToolMapError};
|
||||
pub use transport::{MemoryTransport, StdioTransport};
|
||||
pub use workspace::{FsWorkspaceToolProvider, WorkspaceToolError, WorkspaceToolProvider};
|
||||
|
||||
@ -1,15 +1,14 @@
|
||||
//! [`McpServer`] — the IdeA MCP **driving adapter** (cadrage Décision 4).
|
||||
//!
|
||||
//! This is the **pair of [`FsOrchestratorWatcher`](super::super::FsOrchestratorWatcher)**:
|
||||
//! another entry door onto the *same* [`OrchestratorService::dispatch`]. Where the
|
||||
//! watcher reads a JSON file, this server reads a JSON-RPC `tools/call`; both build
|
||||
//! an [`OrchestratorCommand`] and return its [`OrchestratorOutcome`] unchanged. It
|
||||
//! invents **no semantics** and **never re-routes** replies.
|
||||
//! another entry door onto the *same* [`OrchestratorService::dispatch`] for
|
||||
//! orchestration commands. Capability-specific tools are handled by injected
|
||||
//! providers without being disguised as [`OrchestratorCommand`] variants.
|
||||
//!
|
||||
//! It implements the strict MCP minimum over JSON-RPC 2.0:
|
||||
//! - `initialize` → advertise protocol version + tool capability,
|
||||
//! - `tools/list` → the [`tools::catalogue`],
|
||||
//! - `tools/call` → map → `dispatch` → MCP tool result,
|
||||
//! - `tools/call` → dedicated provider or orchestration dispatch → MCP tool result,
|
||||
//! - notifications (e.g. `notifications/initialized`) → accepted, no reply.
|
||||
//!
|
||||
//! It receives `Arc<OrchestratorService>` and the target [`Project`] by injection
|
||||
@ -40,13 +39,14 @@ use super::policy::ToolPolicyRegistry;
|
||||
use super::templates::TemplateToolProvider;
|
||||
use super::tickets::TicketToolProvider;
|
||||
use super::tools::{self, ToolMapError};
|
||||
use super::workspace::WorkspaceToolProvider;
|
||||
|
||||
/// The MCP protocol version this server speaks (advertised on `initialize`).
|
||||
const MCP_PROTOCOL_VERSION: &str = "2024-11-05";
|
||||
|
||||
/// Compact MCP instructions advertised at initialization. The first 512 chars are
|
||||
/// intentionally self-contained because some clients truncate this field.
|
||||
const MCP_INSTRUCTIONS: &str = "Use IdeA tools by intent: ticket work -> idea_ticket_*; ask/delegate to one agent -> idea_ask_agent, several -> idea_ask_agents; durable project facts -> idea_memory_*; project/agent context -> idea_context_*; recurring project maintenance -> idea_scheduled_task_* (never system cron). Then use idea_skill_* for assigned workflows, idea_template_* for agent templates, idea_workstate_* for live status, idea_run_in_background/background tools for long commands, agent lifecycle tools to launch/stop/resume/swap agents, and sprint tools for planning.";
|
||||
const MCP_INSTRUCTIONS: &str = "Use IdeA tools by intent: explore project files -> idea_workspace_*; ticket work -> idea_ticket_*; ask/delegate to one agent -> idea_ask_agent, several -> idea_ask_agents; durable project facts -> idea_memory_*; project/agent context -> idea_context_*; recurring project maintenance -> idea_scheduled_task_* (never system cron). Then use idea_skill_* for assigned workflows, idea_template_* for agent templates, idea_workstate_* for live status, idea_run_in_background/background tools for long commands, agent lifecycle tools to launch/stop/resume/swap agents, and sprint tools for planning.";
|
||||
|
||||
/// The IdeA MCP server: an entry adapter over [`OrchestratorService::dispatch`].
|
||||
///
|
||||
@ -87,6 +87,8 @@ pub struct McpServer {
|
||||
managed_temp_tools: Option<Arc<dyn ManagedTempToolProvider>>,
|
||||
/// Optional public plugin administration provider.
|
||||
plugin_tools: Option<Arc<dyn PluginToolProvider>>,
|
||||
/// Optional read-only project workspace provider.
|
||||
workspace_tools: Option<Arc<dyn WorkspaceToolProvider>>,
|
||||
/// Optional per-requester MCP tool policy registry for constrained sessions.
|
||||
tool_policies: Option<Arc<ToolPolicyRegistry>>,
|
||||
/// Optional durable MCP tool permission store. When wired, absence of project or
|
||||
@ -110,6 +112,7 @@ impl McpServer {
|
||||
template_tools: None,
|
||||
managed_temp_tools: None,
|
||||
plugin_tools: None,
|
||||
workspace_tools: None,
|
||||
tool_policies: None,
|
||||
mcp_tool_permissions: None,
|
||||
}
|
||||
@ -167,6 +170,13 @@ impl McpServer {
|
||||
self
|
||||
}
|
||||
|
||||
/// Attaches the read-only project workspace provider.
|
||||
#[must_use]
|
||||
pub fn with_workspace_tools(mut self, workspace_tools: Arc<dyn WorkspaceToolProvider>) -> Self {
|
||||
self.workspace_tools = Some(workspace_tools);
|
||||
self
|
||||
}
|
||||
|
||||
/// Attaches the requester-scoped MCP tool policy registry.
|
||||
#[must_use]
|
||||
pub fn with_tool_policies(mut self, tool_policies: Arc<ToolPolicyRegistry>) -> Self {
|
||||
@ -204,6 +214,7 @@ impl McpServer {
|
||||
template_tools: self.template_tools.clone(),
|
||||
managed_temp_tools: self.managed_temp_tools.clone(),
|
||||
plugin_tools: self.plugin_tools.clone(),
|
||||
workspace_tools: self.workspace_tools.clone(),
|
||||
tool_policies: self.tool_policies.clone(),
|
||||
mcp_tool_permissions: self.mcp_tool_permissions.clone(),
|
||||
}
|
||||
@ -454,9 +465,8 @@ impl McpServer {
|
||||
))
|
||||
}
|
||||
|
||||
/// `tools/call`: map the tool to an [`OrchestratorCommand`], `dispatch` it, and
|
||||
/// fold the [`OrchestratorOutcome`](application::OrchestratorOutcome) into an MCP
|
||||
/// tool result.
|
||||
/// `tools/call`: route provider-backed tools directly, or map orchestration
|
||||
/// tools to an [`OrchestratorCommand`] and dispatch them.
|
||||
async fn tools_call(&self, params: Value) -> Result<Value, JsonRpcError> {
|
||||
let name = params
|
||||
.get("name")
|
||||
@ -682,6 +692,32 @@ impl McpServer {
|
||||
};
|
||||
}
|
||||
|
||||
if tools::is_workspace_tool(&name) {
|
||||
let result = match &self.workspace_tools {
|
||||
Some(provider) => {
|
||||
provider
|
||||
.handle_workspace_tool(&self.project, &self.requester, &name, arguments)
|
||||
.await
|
||||
}
|
||||
None => Err(super::workspace::WorkspaceToolError::new(
|
||||
"notConfigured",
|
||||
"workspace tools are not configured",
|
||||
)),
|
||||
};
|
||||
self.publish_processed(&name, result.is_ok());
|
||||
return match result {
|
||||
Ok(value) => {
|
||||
let text = serde_json::to_string(&value).unwrap_or_else(|_| "null".to_owned());
|
||||
Ok(tool_result_text(&text, false))
|
||||
}
|
||||
Err(err) => {
|
||||
let text =
|
||||
serde_json::to_string(&err.to_value()).unwrap_or_else(|_| err.to_string());
|
||||
Ok(tool_result_text(&text, true))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// The handshake-provided requester is still passed to the mapper for tools that
|
||||
// need peer identity.
|
||||
let command = match tools::map_tool_call(&name, &arguments, &self.requester) {
|
||||
|
||||
@ -1,12 +1,10 @@
|
||||
//! The IdeA MCP **tool catalogue** and the tool → [`OrchestratorRequest`] mapping.
|
||||
//! The IdeA MCP **tool catalogue** and the tool dispatch classification.
|
||||
//!
|
||||
//! Each tool here is a thin, typed face over an **already-existing**
|
||||
//! [`OrchestratorCommand`] (cadrage Décision 4, principe directeur : « le serveur
|
||||
//! MCP n'invente aucune sémantique »). The mapping builds an
|
||||
//! [`OrchestratorRequest`] from the tool arguments and lets
|
||||
//! [`OrchestratorRequest::validate`] be the **single source of truth** for
|
||||
//! validation — the very same path the filesystem watcher takes. No tool
|
||||
//! validates anything itself; no tool reaches a use case directly.
|
||||
//! Orchestration tools remain thin, typed faces over an **already-existing**
|
||||
//! [`OrchestratorCommand`]. Capability-specific tools (tickets, templates,
|
||||
//! plugins, managed temporary storage, and the project workspace) are routed to
|
||||
//! their dedicated providers by the server. The catalogue itself contains no
|
||||
//! filesystem or application logic.
|
||||
//!
|
||||
//! ## Agent discovery (`idea_list_agents`) and delegation (`idea_ask_agent`)
|
||||
//!
|
||||
@ -63,6 +61,10 @@ pub const READ_ONLY_TOOLS: &[&str] = &[
|
||||
"idea_managed_temp_resolve",
|
||||
"idea_scheduled_task_list",
|
||||
"idea_scheduled_task_read",
|
||||
"idea_workspace_map",
|
||||
"idea_workspace_list",
|
||||
"idea_workspace_read",
|
||||
"idea_workspace_search",
|
||||
];
|
||||
|
||||
/// Canonical write/action MCP tools denied by default.
|
||||
@ -172,6 +174,10 @@ pub fn tool_returns_reply(tool: &str) -> bool {
|
||||
| "idea_scheduled_task_delete"
|
||||
| "idea_scheduled_task_run_now"
|
||||
| "idea_scheduled_task_set_enabled"
|
||||
| "idea_workspace_map"
|
||||
| "idea_workspace_list"
|
||||
| "idea_workspace_read"
|
||||
| "idea_workspace_search"
|
||||
) || is_ticket_tool(tool)
|
||||
|| is_template_tool(tool)
|
||||
|| is_managed_temp_tool(tool)
|
||||
@ -202,6 +208,12 @@ pub fn is_plugin_tool(tool: &str) -> bool {
|
||||
super::plugins::is_plugin_tool(tool)
|
||||
}
|
||||
|
||||
/// Whether `tool` is a public read-only workspace tool.
|
||||
#[must_use]
|
||||
pub fn is_workspace_tool(tool: &str) -> bool {
|
||||
super::workspace::is_workspace_tool(tool)
|
||||
}
|
||||
|
||||
/// The full catalogue advertised on `tools/list`.
|
||||
///
|
||||
/// Exactly the tools whose mapping target already exists as an
|
||||
@ -572,6 +584,7 @@ pub fn catalogue() -> Vec<ToolDef> {
|
||||
tools.extend(super::templates::catalogue());
|
||||
tools.extend(super::managed_temp::catalogue());
|
||||
tools.extend(super::plugins::catalogue());
|
||||
tools.extend(super::workspace::catalogue());
|
||||
tools
|
||||
}
|
||||
|
||||
@ -953,6 +966,22 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_tools_are_read_only_and_return_inline_results() {
|
||||
for tool in [
|
||||
"idea_workspace_map",
|
||||
"idea_workspace_list",
|
||||
"idea_workspace_read",
|
||||
"idea_workspace_search",
|
||||
] {
|
||||
assert_eq!(tool_access(tool), Some(McpToolAccess::Read));
|
||||
assert!(tool_returns_reply(tool));
|
||||
assert!(catalogue()
|
||||
.into_iter()
|
||||
.any(|definition| definition.name == tool));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalogue_tools_have_explicit_read_or_write_access() {
|
||||
let catalogue_names = catalogue()
|
||||
|
||||
980
crates/infrastructure/src/orchestrator/mcp/workspace.rs
Normal file
980
crates/infrastructure/src/orchestrator/mcp/workspace.rs
Normal file
@ -0,0 +1,980 @@
|
||||
//! Read-only MCP tools for exploring the current project workspace.
|
||||
|
||||
use std::cmp::Ordering;
|
||||
use std::fs;
|
||||
use std::io::{self, Read};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::Project;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::tools::ToolDef;
|
||||
|
||||
const MAP_DEFAULT_DEPTH: usize = 2;
|
||||
const MAP_MAX_DEPTH: usize = 4;
|
||||
const MAP_DEFAULT_ENTRIES: usize = 300;
|
||||
const MAP_MAX_ENTRIES: usize = 1_500;
|
||||
const LIST_DEFAULT_DEPTH: usize = 1;
|
||||
const LIST_MAX_DEPTH: usize = 3;
|
||||
const LIST_DEFAULT_ENTRIES: usize = 200;
|
||||
const LIST_MAX_ENTRIES: usize = 1_000;
|
||||
const READ_DEFAULT_BYTES: usize = 200_000;
|
||||
const READ_MAX_BYTES: usize = 1_000_000;
|
||||
const SEARCH_DEFAULT_RESULTS: usize = 100;
|
||||
const SEARCH_MAX_RESULTS: usize = 500;
|
||||
const SEARCH_MAX_FILE_BYTES: u64 = 1_000_000;
|
||||
const SEARCH_MAX_TOTAL_BYTES: u64 = 32_000_000;
|
||||
const SEARCH_MAX_FILES: usize = 10_000;
|
||||
const SAMPLE_BYTES: usize = 512;
|
||||
const SAMPLE_CHARS: usize = 200;
|
||||
|
||||
/// Error returned by a read-only workspace tool.
|
||||
#[derive(Debug, Clone, thiserror::Error)]
|
||||
#[error("{code}: {message}")]
|
||||
pub struct WorkspaceToolError {
|
||||
/// Stable machine-readable code.
|
||||
pub code: &'static str,
|
||||
/// Human-readable message that never exposes a host-absolute path.
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl WorkspaceToolError {
|
||||
pub(crate) fn new(code: &'static str, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialises the error as a camelCase JSON object.
|
||||
#[must_use]
|
||||
pub fn to_value(&self) -> Value {
|
||||
json!({ "code": self.code, "message": self.message })
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider seam used by both MCP and OpenAI-compatible tool dispatch.
|
||||
#[async_trait]
|
||||
pub trait WorkspaceToolProvider: Send + Sync {
|
||||
/// Executes one workspace tool in the supplied project.
|
||||
async fn handle_workspace_tool(
|
||||
&self,
|
||||
project: &Project,
|
||||
requester: &str,
|
||||
name: &str,
|
||||
arguments: Value,
|
||||
) -> Result<Value, WorkspaceToolError>;
|
||||
}
|
||||
|
||||
/// Filesystem-backed, read-only workspace provider.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct FsWorkspaceToolProvider;
|
||||
|
||||
impl FsWorkspaceToolProvider {
|
||||
/// Creates the provider.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WorkspaceToolProvider for FsWorkspaceToolProvider {
|
||||
async fn handle_workspace_tool(
|
||||
&self,
|
||||
project: &Project,
|
||||
_requester: &str,
|
||||
name: &str,
|
||||
arguments: Value,
|
||||
) -> Result<Value, WorkspaceToolError> {
|
||||
let project = project.clone();
|
||||
let name = name.to_owned();
|
||||
tokio::task::spawn_blocking(move || match name.as_str() {
|
||||
"idea_workspace_map" => workspace_map(&project, parse(arguments)?),
|
||||
"idea_workspace_list" => workspace_list(&project, parse(arguments)?),
|
||||
"idea_workspace_read" => workspace_read(&project, parse(arguments)?),
|
||||
"idea_workspace_search" => workspace_search(&project, parse(arguments)?),
|
||||
_ => Err(WorkspaceToolError::new(
|
||||
"notFound",
|
||||
format!("unknown workspace tool `{name}`"),
|
||||
)),
|
||||
})
|
||||
.await
|
||||
.map_err(|_| {
|
||||
WorkspaceToolError::new("readFailed", "workspace operation could not complete")
|
||||
})?
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true when `name` is a public workspace tool.
|
||||
#[must_use]
|
||||
pub fn is_workspace_tool(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"idea_workspace_map"
|
||||
| "idea_workspace_list"
|
||||
| "idea_workspace_read"
|
||||
| "idea_workspace_search"
|
||||
)
|
||||
}
|
||||
|
||||
/// Public workspace tool definitions advertised by `tools/list`.
|
||||
#[must_use]
|
||||
pub fn catalogue() -> Vec<ToolDef> {
|
||||
vec![
|
||||
ToolDef {
|
||||
name: "idea_workspace_map",
|
||||
description: "Build a bounded structural map of the project workspace, including key folders, notable files, and optional short text samples.",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": { "type": "string", "default": "." },
|
||||
"depth": { "type": "integer", "minimum": 0, "maximum": MAP_MAX_DEPTH, "default": MAP_DEFAULT_DEPTH },
|
||||
"includeHidden": { "type": "boolean", "default": false },
|
||||
"maxEntries": { "type": "integer", "minimum": 1, "maximum": MAP_MAX_ENTRIES, "default": MAP_DEFAULT_ENTRIES },
|
||||
"includeSamples": { "type": "boolean", "default": true }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}),
|
||||
},
|
||||
ToolDef {
|
||||
name: "idea_workspace_list",
|
||||
description: "List files, folders, and other elements in the project workspace with bounded depth and result count.",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": { "type": "string", "default": "." },
|
||||
"depth": { "type": "integer", "minimum": 0, "maximum": LIST_MAX_DEPTH, "default": LIST_DEFAULT_DEPTH },
|
||||
"includeHidden": { "type": "boolean", "default": false },
|
||||
"limit": { "type": "integer", "minimum": 1, "maximum": LIST_MAX_ENTRIES, "default": LIST_DEFAULT_ENTRIES }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}),
|
||||
},
|
||||
ToolDef {
|
||||
name: "idea_workspace_read",
|
||||
description: "Read bounded UTF-8 text content from one file in the project workspace.",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": { "type": "string", "minLength": 1 },
|
||||
"maxBytes": { "type": "integer", "minimum": 1, "maximum": READ_MAX_BYTES, "default": READ_DEFAULT_BYTES }
|
||||
},
|
||||
"required": ["path"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
},
|
||||
ToolDef {
|
||||
name: "idea_workspace_search",
|
||||
description: "Search text in project workspace files and return bounded line matches with short previews.",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": { "type": "string", "minLength": 1 },
|
||||
"path": { "type": "string", "default": "." },
|
||||
"maxResults": { "type": "integer", "minimum": 1, "maximum": SEARCH_MAX_RESULTS, "default": SEARCH_DEFAULT_RESULTS },
|
||||
"includeHidden": { "type": "boolean", "default": false }
|
||||
},
|
||||
"required": ["query"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct MapArgs {
|
||||
#[serde(default = "default_path")]
|
||||
path: String,
|
||||
#[serde(default = "default_map_depth")]
|
||||
depth: usize,
|
||||
#[serde(default)]
|
||||
include_hidden: bool,
|
||||
#[serde(default = "default_map_entries")]
|
||||
max_entries: usize,
|
||||
#[serde(default = "default_true")]
|
||||
include_samples: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct ListArgs {
|
||||
#[serde(default = "default_path")]
|
||||
path: String,
|
||||
#[serde(default = "default_list_depth")]
|
||||
depth: usize,
|
||||
#[serde(default)]
|
||||
include_hidden: bool,
|
||||
#[serde(default = "default_list_entries")]
|
||||
limit: usize,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct ReadArgs {
|
||||
path: String,
|
||||
#[serde(default = "default_read_bytes")]
|
||||
max_bytes: usize,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct SearchArgs {
|
||||
query: String,
|
||||
#[serde(default = "default_path")]
|
||||
path: String,
|
||||
#[serde(default = "default_search_results")]
|
||||
max_results: usize,
|
||||
#[serde(default)]
|
||||
include_hidden: bool,
|
||||
}
|
||||
|
||||
fn default_path() -> String {
|
||||
".".to_owned()
|
||||
}
|
||||
fn default_map_depth() -> usize {
|
||||
MAP_DEFAULT_DEPTH
|
||||
}
|
||||
fn default_map_entries() -> usize {
|
||||
MAP_DEFAULT_ENTRIES
|
||||
}
|
||||
fn default_list_depth() -> usize {
|
||||
LIST_DEFAULT_DEPTH
|
||||
}
|
||||
fn default_list_entries() -> usize {
|
||||
LIST_DEFAULT_ENTRIES
|
||||
}
|
||||
fn default_read_bytes() -> usize {
|
||||
READ_DEFAULT_BYTES
|
||||
}
|
||||
fn default_search_results() -> usize {
|
||||
SEARCH_DEFAULT_RESULTS
|
||||
}
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn parse<T: for<'de> Deserialize<'de>>(value: Value) -> Result<T, WorkspaceToolError> {
|
||||
serde_json::from_value(value).map_err(|error| {
|
||||
WorkspaceToolError::new(
|
||||
"invalidArguments",
|
||||
format!("invalid workspace tool arguments: {error}"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_range(
|
||||
value: usize,
|
||||
min: usize,
|
||||
max: usize,
|
||||
field: &str,
|
||||
) -> Result<(), WorkspaceToolError> {
|
||||
if (min..=max).contains(&value) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(WorkspaceToolError::new(
|
||||
"invalidArguments",
|
||||
format!("`{field}` must be between {min} and {max}"),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
struct ResolvedPath {
|
||||
root: PathBuf,
|
||||
target: PathBuf,
|
||||
relative: String,
|
||||
}
|
||||
|
||||
fn resolve(project: &Project, raw: &str) -> Result<ResolvedPath, WorkspaceToolError> {
|
||||
let raw = raw.trim();
|
||||
if raw.is_empty() || raw.contains('\0') || looks_absolute_any_platform(raw) {
|
||||
return Err(WorkspaceToolError::new(
|
||||
"pathNotAllowed",
|
||||
"path must be relative to the project workspace",
|
||||
));
|
||||
}
|
||||
let supplied = Path::new(raw);
|
||||
if supplied.components().any(|component| {
|
||||
matches!(
|
||||
component,
|
||||
Component::ParentDir | Component::RootDir | Component::Prefix(_)
|
||||
)
|
||||
}) || raw.split(['/', '\\']).any(|component| component == "..")
|
||||
{
|
||||
return Err(WorkspaceToolError::new(
|
||||
"pathNotAllowed",
|
||||
"path must stay within the project workspace",
|
||||
));
|
||||
}
|
||||
|
||||
let root = PathBuf::from(project.root.as_str())
|
||||
.canonicalize()
|
||||
.map_err(|_| {
|
||||
WorkspaceToolError::new("workspaceUnavailable", "project workspace is unavailable")
|
||||
})?;
|
||||
let target = root
|
||||
.join(supplied)
|
||||
.canonicalize()
|
||||
.map_err(|error| io_error(error, raw))?;
|
||||
if !target.starts_with(&root) {
|
||||
return Err(WorkspaceToolError::new(
|
||||
"pathNotAllowed",
|
||||
"path must stay within the project workspace",
|
||||
));
|
||||
}
|
||||
let relative = relative_label(&root, &target)?;
|
||||
Ok(ResolvedPath {
|
||||
root,
|
||||
target,
|
||||
relative,
|
||||
})
|
||||
}
|
||||
|
||||
fn looks_absolute_any_platform(raw: &str) -> bool {
|
||||
let bytes = raw.as_bytes();
|
||||
Path::new(raw).is_absolute()
|
||||
|| raw.starts_with('/')
|
||||
|| raw.starts_with('\\')
|
||||
|| (bytes.len() >= 3
|
||||
&& bytes[0].is_ascii_alphabetic()
|
||||
&& bytes[1] == b':'
|
||||
&& matches!(bytes[2], b'/' | b'\\'))
|
||||
}
|
||||
|
||||
fn relative_label(root: &Path, path: &Path) -> Result<String, WorkspaceToolError> {
|
||||
let relative = path.strip_prefix(root).map_err(|_| {
|
||||
WorkspaceToolError::new(
|
||||
"pathNotAllowed",
|
||||
"path must stay within the project workspace",
|
||||
)
|
||||
})?;
|
||||
if relative.as_os_str().is_empty() {
|
||||
return Ok(".".to_owned());
|
||||
}
|
||||
Ok(relative
|
||||
.components()
|
||||
.map(|component| component.as_os_str().to_string_lossy())
|
||||
.collect::<Vec<_>>()
|
||||
.join("/"))
|
||||
}
|
||||
|
||||
fn io_error(error: io::Error, path: &str) -> WorkspaceToolError {
|
||||
let code = if error.kind() == io::ErrorKind::NotFound {
|
||||
"notFound"
|
||||
} else {
|
||||
"readFailed"
|
||||
};
|
||||
WorkspaceToolError::new(code, format!("cannot access workspace path `{path}`"))
|
||||
}
|
||||
|
||||
fn is_hidden(path: &Path) -> bool {
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name.starts_with('.') && name != "." && name != "..")
|
||||
}
|
||||
|
||||
fn sorted_children(path: &Path, include_hidden: bool) -> Result<Vec<PathBuf>, WorkspaceToolError> {
|
||||
let mut children = fs::read_dir(path)
|
||||
.map_err(|_| WorkspaceToolError::new("readFailed", "cannot list workspace directory"))?
|
||||
.filter_map(Result::ok)
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| include_hidden || !is_hidden(path))
|
||||
.collect::<Vec<_>>();
|
||||
children.sort_by(|left, right| stable_path_cmp(left, right));
|
||||
Ok(children)
|
||||
}
|
||||
|
||||
fn stable_path_cmp(left: &Path, right: &Path) -> Ordering {
|
||||
left.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.cmp(&right.file_name().unwrap_or_default().to_string_lossy())
|
||||
}
|
||||
|
||||
fn kind_and_metadata(path: &Path) -> Result<(&'static str, fs::Metadata), WorkspaceToolError> {
|
||||
let metadata = fs::symlink_metadata(path)
|
||||
.map_err(|_| WorkspaceToolError::new("readFailed", "cannot inspect workspace element"))?;
|
||||
let kind = if metadata.file_type().is_symlink() {
|
||||
"other"
|
||||
} else if metadata.is_dir() {
|
||||
"directory"
|
||||
} else if metadata.is_file() {
|
||||
"file"
|
||||
} else {
|
||||
"other"
|
||||
};
|
||||
Ok((kind, metadata))
|
||||
}
|
||||
|
||||
fn workspace_list(project: &Project, args: ListArgs) -> Result<Value, WorkspaceToolError> {
|
||||
validate_range(args.depth, 0, LIST_MAX_DEPTH, "depth")?;
|
||||
validate_range(args.limit, 1, LIST_MAX_ENTRIES, "limit")?;
|
||||
let resolved = resolve(project, &args.path)?;
|
||||
if !resolved.target.is_dir() {
|
||||
return Err(WorkspaceToolError::new(
|
||||
"notDirectory",
|
||||
"workspace path is not a directory",
|
||||
));
|
||||
}
|
||||
let mut entries = Vec::new();
|
||||
let mut truncated = false;
|
||||
collect_list(
|
||||
&resolved.root,
|
||||
&resolved.target,
|
||||
0,
|
||||
args.depth,
|
||||
args.include_hidden,
|
||||
args.limit,
|
||||
&mut entries,
|
||||
&mut truncated,
|
||||
)?;
|
||||
Ok(json!({ "root": resolved.relative, "entries": entries, "truncated": truncated }))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn collect_list(
|
||||
root: &Path,
|
||||
directory: &Path,
|
||||
parent_depth: usize,
|
||||
max_depth: usize,
|
||||
include_hidden: bool,
|
||||
limit: usize,
|
||||
entries: &mut Vec<Value>,
|
||||
truncated: &mut bool,
|
||||
) -> Result<(), WorkspaceToolError> {
|
||||
if parent_depth >= max_depth {
|
||||
return Ok(());
|
||||
}
|
||||
for path in sorted_children(directory, include_hidden)? {
|
||||
if entries.len() >= limit {
|
||||
*truncated = true;
|
||||
return Ok(());
|
||||
}
|
||||
let (kind, metadata) = kind_and_metadata(&path)?;
|
||||
let relative = relative_label(root, &path)?;
|
||||
let name = path.file_name().unwrap_or_default().to_string_lossy();
|
||||
let modified_at_ms = metadata.modified().ok().and_then(epoch_ms);
|
||||
let mut entry = serde_json::Map::from_iter([
|
||||
("path".to_owned(), json!(relative)),
|
||||
("name".to_owned(), json!(name)),
|
||||
("kind".to_owned(), json!(kind)),
|
||||
]);
|
||||
if metadata.is_file() {
|
||||
entry.insert("sizeBytes".to_owned(), json!(metadata.len()));
|
||||
}
|
||||
if let Some(modified_at_ms) = modified_at_ms {
|
||||
entry.insert("modifiedAtMs".to_owned(), json!(modified_at_ms));
|
||||
}
|
||||
entries.push(Value::Object(entry));
|
||||
if kind == "directory" {
|
||||
collect_list(
|
||||
root,
|
||||
&path,
|
||||
parent_depth + 1,
|
||||
max_depth,
|
||||
include_hidden,
|
||||
limit,
|
||||
entries,
|
||||
truncated,
|
||||
)?;
|
||||
if *truncated {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn epoch_ms(time: SystemTime) -> Option<u64> {
|
||||
time.duration_since(UNIX_EPOCH)
|
||||
.ok()
|
||||
.map(|duration| duration.as_millis() as u64)
|
||||
}
|
||||
|
||||
fn workspace_map(project: &Project, args: MapArgs) -> Result<Value, WorkspaceToolError> {
|
||||
validate_range(args.depth, 0, MAP_MAX_DEPTH, "depth")?;
|
||||
validate_range(args.max_entries, 1, MAP_MAX_ENTRIES, "maxEntries")?;
|
||||
let resolved = resolve(project, &args.path)?;
|
||||
if !resolved.target.is_dir() {
|
||||
return Err(WorkspaceToolError::new(
|
||||
"notDirectory",
|
||||
"workspace path is not a directory",
|
||||
));
|
||||
}
|
||||
let mut state = MapState::default();
|
||||
collect_map(&resolved.root, &resolved.target, 0, &args, &mut state)?;
|
||||
Ok(json!({
|
||||
"root": resolved.relative,
|
||||
"generatedAtMs": epoch_ms(SystemTime::now()).unwrap_or(0),
|
||||
"summary": {
|
||||
"directories": state.directories,
|
||||
"files": state.files,
|
||||
"other": state.other,
|
||||
"maxDepthReached": state.max_depth_reached,
|
||||
"notablePaths": state.notable_paths,
|
||||
},
|
||||
"entries": state.entries,
|
||||
"truncated": state.truncated,
|
||||
"limits": { "depth": args.depth, "maxEntries": args.max_entries }
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct MapState {
|
||||
directories: usize,
|
||||
files: usize,
|
||||
other: usize,
|
||||
max_depth_reached: usize,
|
||||
notable_paths: Vec<String>,
|
||||
entries: Vec<Value>,
|
||||
truncated: bool,
|
||||
}
|
||||
|
||||
fn collect_map(
|
||||
root: &Path,
|
||||
directory: &Path,
|
||||
parent_depth: usize,
|
||||
args: &MapArgs,
|
||||
state: &mut MapState,
|
||||
) -> Result<(), WorkspaceToolError> {
|
||||
if parent_depth >= args.depth {
|
||||
return Ok(());
|
||||
}
|
||||
for path in sorted_children(directory, args.include_hidden)? {
|
||||
if state.entries.len() >= args.max_entries {
|
||||
state.truncated = true;
|
||||
return Ok(());
|
||||
}
|
||||
let depth = parent_depth + 1;
|
||||
let (kind, metadata) = kind_and_metadata(&path)?;
|
||||
let relative = relative_label(root, &path)?;
|
||||
let children = (kind == "directory")
|
||||
.then(|| sorted_children(&path, args.include_hidden).map(|items| items.len()))
|
||||
.transpose()?;
|
||||
let sample = (args.include_samples && kind == "file")
|
||||
.then(|| text_sample(&path))
|
||||
.transpose()?
|
||||
.flatten();
|
||||
match kind {
|
||||
"directory" => state.directories += 1,
|
||||
"file" => state.files += 1,
|
||||
_ => state.other += 1,
|
||||
}
|
||||
state.max_depth_reached = state.max_depth_reached.max(depth);
|
||||
if is_notable(&path) && state.notable_paths.len() < 25 {
|
||||
state.notable_paths.push(relative.clone());
|
||||
}
|
||||
let mut entry = serde_json::Map::from_iter([
|
||||
("path".to_owned(), json!(relative)),
|
||||
("kind".to_owned(), json!(kind)),
|
||||
("depth".to_owned(), json!(depth)),
|
||||
]);
|
||||
if let Some(children) = children {
|
||||
entry.insert("children".to_owned(), json!(children));
|
||||
}
|
||||
if metadata.is_file() {
|
||||
entry.insert("sizeBytes".to_owned(), json!(metadata.len()));
|
||||
}
|
||||
if let Some(sample) = sample {
|
||||
entry.insert("sample".to_owned(), json!(sample));
|
||||
}
|
||||
state.entries.push(Value::Object(entry));
|
||||
if kind == "directory" {
|
||||
collect_map(root, &path, depth, args, state)?;
|
||||
if state.truncated {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_notable(path: &Path) -> bool {
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| {
|
||||
matches!(
|
||||
name.to_ascii_lowercase().as_str(),
|
||||
"readme"
|
||||
| "readme.md"
|
||||
| "cargo.toml"
|
||||
| "package.json"
|
||||
| "pyproject.toml"
|
||||
| "go.mod"
|
||||
| "pom.xml"
|
||||
| "build.gradle"
|
||||
| "makefile"
|
||||
| "dockerfile"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn text_sample(path: &Path) -> Result<Option<String>, WorkspaceToolError> {
|
||||
let bytes = read_prefix(path, SAMPLE_BYTES)?;
|
||||
if bytes.contains(&0) {
|
||||
return Ok(None);
|
||||
}
|
||||
let Ok(text) = std::str::from_utf8(&bytes) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let sample = text.chars().take(SAMPLE_CHARS).collect::<String>();
|
||||
Ok((!sample.is_empty()).then_some(sample))
|
||||
}
|
||||
|
||||
fn workspace_read(project: &Project, args: ReadArgs) -> Result<Value, WorkspaceToolError> {
|
||||
validate_range(args.max_bytes, 1, READ_MAX_BYTES, "maxBytes")?;
|
||||
let resolved = resolve(project, &args.path)?;
|
||||
let metadata = fs::metadata(&resolved.target)
|
||||
.map_err(|_| WorkspaceToolError::new("readFailed", "cannot inspect workspace file"))?;
|
||||
if !metadata.is_file() {
|
||||
return Err(WorkspaceToolError::new(
|
||||
"notFile",
|
||||
"workspace path is not a file",
|
||||
));
|
||||
}
|
||||
let mut bytes = read_prefix(&resolved.target, args.max_bytes.saturating_add(1))?;
|
||||
let truncated = metadata.len() > args.max_bytes as u64 || bytes.len() > args.max_bytes;
|
||||
bytes.truncate(args.max_bytes);
|
||||
if bytes.contains(&0) {
|
||||
return Err(WorkspaceToolError::new(
|
||||
"binaryFile",
|
||||
"workspace file is not UTF-8 text",
|
||||
));
|
||||
}
|
||||
let content = match std::str::from_utf8(&bytes) {
|
||||
Ok(text) => text.to_owned(),
|
||||
Err(error) if truncated && error.error_len().is_none() => {
|
||||
bytes.truncate(error.valid_up_to());
|
||||
String::from_utf8(bytes).expect("valid UTF-8 prefix")
|
||||
}
|
||||
Err(_) => {
|
||||
return Err(WorkspaceToolError::new(
|
||||
"binaryFile",
|
||||
"workspace file is not UTF-8 text",
|
||||
));
|
||||
}
|
||||
};
|
||||
Ok(json!({
|
||||
"path": resolved.relative,
|
||||
"content": content,
|
||||
"encoding": "utf8",
|
||||
"truncated": truncated,
|
||||
"sizeBytes": metadata.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn read_prefix(path: &Path, maximum: usize) -> Result<Vec<u8>, WorkspaceToolError> {
|
||||
let file = fs::File::open(path)
|
||||
.map_err(|_| WorkspaceToolError::new("readFailed", "cannot open workspace file"))?;
|
||||
let mut bytes = Vec::with_capacity(maximum.min(64 * 1024));
|
||||
file.take(maximum as u64)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|_| WorkspaceToolError::new("readFailed", "cannot read workspace file"))?;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn workspace_search(project: &Project, args: SearchArgs) -> Result<Value, WorkspaceToolError> {
|
||||
validate_range(args.max_results, 1, SEARCH_MAX_RESULTS, "maxResults")?;
|
||||
if args.query.is_empty() || args.query.len() > 1_000 {
|
||||
return Err(WorkspaceToolError::new(
|
||||
"invalidArguments",
|
||||
"`query` must contain between 1 and 1000 bytes",
|
||||
));
|
||||
}
|
||||
let resolved = resolve(project, &args.path)?;
|
||||
let mut files = Vec::new();
|
||||
let mut truncated = false;
|
||||
collect_search_files(
|
||||
&resolved.target,
|
||||
args.include_hidden,
|
||||
&mut files,
|
||||
&mut truncated,
|
||||
)?;
|
||||
files.sort_by(|left, right| left.to_string_lossy().cmp(&right.to_string_lossy()));
|
||||
let mut matches = Vec::new();
|
||||
let mut scanned_bytes = 0_u64;
|
||||
for path in files {
|
||||
let metadata = match fs::metadata(&path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if metadata.len() > SEARCH_MAX_FILE_BYTES {
|
||||
truncated = true;
|
||||
continue;
|
||||
}
|
||||
if scanned_bytes.saturating_add(metadata.len()) > SEARCH_MAX_TOTAL_BYTES {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
scanned_bytes = scanned_bytes.saturating_add(metadata.len());
|
||||
let bytes = read_prefix(&path, SEARCH_MAX_FILE_BYTES as usize + 1)?;
|
||||
if bytes.contains(&0) {
|
||||
continue;
|
||||
}
|
||||
let Ok(content) = std::str::from_utf8(&bytes) else {
|
||||
continue;
|
||||
};
|
||||
for (index, line) in content.lines().enumerate() {
|
||||
if line.contains(&args.query) {
|
||||
if matches.len() >= args.max_results {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
matches.push(json!({
|
||||
"path": relative_label(&resolved.root, &path)?,
|
||||
"line": index + 1,
|
||||
"preview": bounded_preview(line),
|
||||
}));
|
||||
}
|
||||
}
|
||||
if truncated && matches.len() >= args.max_results {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(json!({ "query": args.query, "matches": matches, "truncated": truncated }))
|
||||
}
|
||||
|
||||
fn collect_search_files(
|
||||
path: &Path,
|
||||
include_hidden: bool,
|
||||
files: &mut Vec<PathBuf>,
|
||||
truncated: &mut bool,
|
||||
) -> Result<(), WorkspaceToolError> {
|
||||
let (kind, _) = kind_and_metadata(path)?;
|
||||
if kind == "file" {
|
||||
files.push(path.to_path_buf());
|
||||
return Ok(());
|
||||
}
|
||||
if kind != "directory" {
|
||||
return Ok(());
|
||||
}
|
||||
for child in sorted_children(path, include_hidden)? {
|
||||
if files.len() >= SEARCH_MAX_FILES {
|
||||
*truncated = true;
|
||||
return Ok(());
|
||||
}
|
||||
collect_search_files(&child, include_hidden, files, truncated)?;
|
||||
if *truncated {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn bounded_preview(line: &str) -> String {
|
||||
const MAX_PREVIEW_CHARS: usize = 500;
|
||||
line.chars().take(MAX_PREVIEW_CHARS).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::{ProjectId, ProjectPath, RemoteRef};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
static NEXT_TEMP: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
struct TempWorkspace(PathBuf);
|
||||
|
||||
impl TempWorkspace {
|
||||
fn new() -> Self {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"idea-workspace-tools-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT_TEMP.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
fs::create_dir_all(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
|
||||
fn project(&self) -> Project {
|
||||
Project::new(
|
||||
ProjectId::new_random(),
|
||||
"Workspace",
|
||||
ProjectPath::new(self.0.to_string_lossy()).unwrap(),
|
||||
RemoteRef::Local,
|
||||
0,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempWorkspace {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_is_bounded_and_rejects_unsafe_or_binary_paths() {
|
||||
let temp = TempWorkspace::new();
|
||||
fs::write(temp.0.join("text.txt"), "éclair and more").unwrap();
|
||||
fs::write(temp.0.join("binary.bin"), [0, 159, 146, 150]).unwrap();
|
||||
let provider = FsWorkspaceToolProvider::new();
|
||||
let value = provider
|
||||
.handle_workspace_tool(
|
||||
&temp.project(),
|
||||
"agent",
|
||||
"idea_workspace_read",
|
||||
json!({"path":"text.txt", "maxBytes": 2}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(value["content"], "é");
|
||||
assert_eq!(value["truncated"], true);
|
||||
assert_eq!(value["sizeBytes"], 16);
|
||||
|
||||
for path in ["../outside", "/tmp/outside", "C:\\outside"] {
|
||||
let error = provider
|
||||
.handle_workspace_tool(
|
||||
&temp.project(),
|
||||
"agent",
|
||||
"idea_workspace_read",
|
||||
json!({"path":path}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error.code, "pathNotAllowed");
|
||||
assert!(!error.message.contains(temp.0.to_string_lossy().as_ref()));
|
||||
}
|
||||
let binary = provider
|
||||
.handle_workspace_tool(
|
||||
&temp.project(),
|
||||
"agent",
|
||||
"idea_workspace_read",
|
||||
json!({"path":"binary.bin"}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(binary.code, "binaryFile");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn explicit_symlink_escape_is_rejected() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let temp = TempWorkspace::new();
|
||||
symlink(std::env::temp_dir(), temp.0.join("outside")).unwrap();
|
||||
let error = FsWorkspaceToolProvider::new()
|
||||
.handle_workspace_tool(
|
||||
&temp.project(),
|
||||
"agent",
|
||||
"idea_workspace_list",
|
||||
json!({"path":"outside"}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error.code, "pathNotAllowed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn map_and_list_are_stable_relative_and_bounded() {
|
||||
let temp = TempWorkspace::new();
|
||||
fs::create_dir_all(temp.0.join("src/deep")).unwrap();
|
||||
fs::write(temp.0.join("README.md"), "Project overview").unwrap();
|
||||
fs::write(temp.0.join("src/a.rs"), "fn alpha() {}").unwrap();
|
||||
fs::write(temp.0.join("src/deep/b.rs"), "fn beta() {}").unwrap();
|
||||
fs::write(temp.0.join("z.txt"), "last").unwrap();
|
||||
let provider = FsWorkspaceToolProvider::new();
|
||||
let map = provider
|
||||
.handle_workspace_tool(
|
||||
&temp.project(),
|
||||
"agent",
|
||||
"idea_workspace_map",
|
||||
json!({"depth": 2, "maxEntries": 3}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(map["truncated"], true);
|
||||
assert_eq!(map["limits"], json!({"depth":2,"maxEntries":3}));
|
||||
assert!(map["entries"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|entry| { !entry["path"].as_str().unwrap().starts_with('/') }));
|
||||
assert_eq!(map["entries"][0]["path"], "README.md");
|
||||
|
||||
let listed = provider
|
||||
.handle_workspace_tool(
|
||||
&temp.project(),
|
||||
"agent",
|
||||
"idea_workspace_list",
|
||||
json!({"depth": 1, "limit": 2}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(listed["truncated"], true);
|
||||
assert_eq!(listed["entries"].as_array().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_skips_hidden_and_binary_files_and_caps_results() {
|
||||
let temp = TempWorkspace::new();
|
||||
fs::write(temp.0.join("a.txt"), "needle one\nneedle two\n").unwrap();
|
||||
fs::write(temp.0.join(".hidden"), "needle hidden").unwrap();
|
||||
fs::write(temp.0.join("binary"), b"needle\0binary").unwrap();
|
||||
let value = FsWorkspaceToolProvider::new()
|
||||
.handle_workspace_tool(
|
||||
&temp.project(),
|
||||
"agent",
|
||||
"idea_workspace_search",
|
||||
json!({"query":"needle", "maxResults":1}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(value["matches"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(value["matches"][0]["path"], "a.txt");
|
||||
assert_eq!(value["matches"][0]["line"], 1);
|
||||
assert_eq!(value["truncated"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn arguments_above_public_limits_are_rejected() {
|
||||
let temp = TempWorkspace::new();
|
||||
fs::write(temp.0.join("text.txt"), "content").unwrap();
|
||||
let provider = FsWorkspaceToolProvider::new();
|
||||
for (name, arguments, field) in [
|
||||
(
|
||||
"idea_workspace_map",
|
||||
json!({"depth": MAP_MAX_DEPTH + 1}),
|
||||
"depth",
|
||||
),
|
||||
(
|
||||
"idea_workspace_map",
|
||||
json!({"maxEntries": MAP_MAX_ENTRIES + 1}),
|
||||
"maxEntries",
|
||||
),
|
||||
(
|
||||
"idea_workspace_list",
|
||||
json!({"depth": LIST_MAX_DEPTH + 1}),
|
||||
"depth",
|
||||
),
|
||||
(
|
||||
"idea_workspace_list",
|
||||
json!({"limit": LIST_MAX_ENTRIES + 1}),
|
||||
"limit",
|
||||
),
|
||||
(
|
||||
"idea_workspace_read",
|
||||
json!({"path":"text.txt", "maxBytes": READ_MAX_BYTES + 1}),
|
||||
"maxBytes",
|
||||
),
|
||||
(
|
||||
"idea_workspace_search",
|
||||
json!({"query":"content", "maxResults": SEARCH_MAX_RESULTS + 1}),
|
||||
"maxResults",
|
||||
),
|
||||
] {
|
||||
let error = provider
|
||||
.handle_workspace_tool(&temp.project(), "agent", name, arguments)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error.code, "invalidArguments");
|
||||
assert!(error.message.contains(field));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user