1use async_trait::async_trait;
18use serde_json::Value;
19use std::path::{Path, PathBuf};
20use std::sync::Arc;
21use tokio::sync::Mutex;
22
23use crate::error::{Error, Result};
24use crate::llm::ToolSpec;
25use crate::tools::dispatch::{AccessTier, SharedSandboxRoots};
26use crate::tools::Tool;
27
28#[derive(Debug, Default)]
38pub struct AcpClientFsState {
39 pub read_text_file: bool,
41 pub write_text_file: bool,
43}
44
45impl AcpClientFsState {
46 pub fn new() -> Self {
47 Self::default()
48 }
49}
50
51pub struct ClientReadFile {
65 #[allow(dead_code)]
68 workspace: Arc<Path>,
69 pub extra_roots: Vec<(PathBuf, AccessTier)>,
72 pub session_roots: Option<SharedSandboxRoots>,
74 acp_state: Arc<Mutex<AcpClientFsState>>,
76 client_read_timeout_ms: u64,
78}
79
80impl ClientReadFile {
81 pub fn new(workspace: &Path) -> Self {
83 Self {
84 workspace: Arc::from(workspace),
85 extra_roots: Vec::new(),
86 session_roots: None,
87 acp_state: Arc::new(Mutex::new(AcpClientFsState::new())),
88 client_read_timeout_ms: 5000,
89 }
90 }
91
92 pub fn with_acp_state(mut self, state: Arc<Mutex<AcpClientFsState>>) -> Self {
97 self.acp_state = state;
98 self
99 }
100
101 pub fn with_client_read_timeout(mut self, timeout_ms: u64) -> Self {
103 self.client_read_timeout_ms = timeout_ms;
104 self
105 }
106
107 pub fn with_extra_roots(
109 mut self,
110 extra: impl IntoIterator<Item = (PathBuf, AccessTier)>,
111 ) -> Self {
112 self.extra_roots.extend(extra);
113 self
114 }
115
116 pub fn with_session_roots_opt(mut self, slot: Option<SharedSandboxRoots>) -> Self {
118 if let Some(s) = slot {
119 self.session_roots = Some(s);
120 }
121 self
122 }
123
124 #[allow(dead_code)]
126 fn all_roots(&self) -> Vec<(PathBuf, AccessTier)> {
127 let mut v: Vec<(PathBuf, AccessTier)> = Vec::with_capacity(self.extra_roots.len() + 1);
128 v.push(((*self.workspace).to_path_buf(), AccessTier::ReadWrite));
129 v.extend(self.extra_roots.iter().cloned());
130 if let Some(slot) = &self.session_roots {
131 if let Ok(roots) = slot.read() {
132 v.extend(roots.iter().cloned());
133 }
134 }
135 v
136 }
137}
138
139#[async_trait]
140impl Tool for ClientReadFile {
141 fn spec(&self) -> ToolSpec {
142 ToolSpec {
143 name: "ClientReadFile".into(),
144 description: "Read a file from the client's (IDE/editor) buffer. Use this when the session client has declared fs.readTextFile=true. Arguments: uri (string) - the file URI to read.".into(),
145 parameters: serde_json::json!({
146 "type": "object",
147 "properties": {
148 "uri": {
149 "type": "string",
150 "description": "The URI of the file to read (e.g. file:///path/to/file)"
151 }
152 },
153 "required": ["uri"]
154 }),
155 }
156 }
157
158 async fn execute(&self, arguments: Value) -> Result<String> {
159 let state = self.acp_state.lock().await;
160 if !state.read_text_file {
161 return Err(Error::Tool {
162 name: "ClientReadFile".into(),
163 call_id: None,
164 message: "Client has not declared fs.readTextFile capability".into(),
165 });
166 }
167 drop(state);
168
169 let uri = arguments
170 .get("uri")
171 .and_then(|v| v.as_str())
172 .ok_or_else(|| Error::BadToolArgs {
173 name: "ClientReadFile".into(),
174 message: "Missing required argument 'uri'".into(),
175 })?;
176
177 tracing::info!(
184 target: "recursive::acp",
185 uri = %uri,
186 "ClientReadFile: forwarding read request to client (simulated)"
187 );
188
189 Err(Error::Tool {
190 name: "ClientReadFile".into(),
191 call_id: None,
192 message: format!("Client not available for read of {uri}, fallback to local read"),
193 })
194 }
195
196 fn kind(&self) -> crate::acp::ToolKind {
197 crate::acp::ToolKind::Read
198 }
199}
200
201pub struct ClientWriteFile {
215 workspace: Arc<Path>,
216 pub extra_roots: Vec<(PathBuf, AccessTier)>,
218 pub session_roots: Option<SharedSandboxRoots>,
220 acp_state: Arc<Mutex<AcpClientFsState>>,
222}
223
224impl ClientWriteFile {
225 pub fn new(workspace: &Path) -> Self {
227 Self {
228 workspace: Arc::from(workspace),
229 extra_roots: Vec::new(),
230 session_roots: None,
231 acp_state: Arc::new(Mutex::new(AcpClientFsState::new())),
232 }
233 }
234
235 pub fn with_acp_state(mut self, state: Arc<Mutex<AcpClientFsState>>) -> Self {
237 self.acp_state = state;
238 self
239 }
240
241 pub fn with_extra_roots(
243 mut self,
244 extra: impl IntoIterator<Item = (PathBuf, AccessTier)>,
245 ) -> Self {
246 self.extra_roots.extend(extra);
247 self
248 }
249
250 pub fn with_session_roots_opt(mut self, slot: Option<SharedSandboxRoots>) -> Self {
252 if let Some(s) = slot {
253 self.session_roots = Some(s);
254 }
255 self
256 }
257
258 #[allow(dead_code)]
260 fn all_roots(&self) -> Vec<(PathBuf, AccessTier)> {
261 let mut v: Vec<(PathBuf, AccessTier)> = Vec::with_capacity(self.extra_roots.len() + 1);
262 v.push(((*self.workspace).to_path_buf(), AccessTier::ReadWrite));
263 v.extend(self.extra_roots.iter().cloned());
264 if let Some(slot) = &self.session_roots {
265 if let Ok(roots) = slot.read() {
266 v.extend(roots.iter().cloned());
267 }
268 }
269 v
270 }
271}
272
273#[async_trait]
274impl Tool for ClientWriteFile {
275 fn spec(&self) -> ToolSpec {
276 ToolSpec {
277 name: "ClientWriteFile".into(),
278 description: "Write content to the client's (IDE/editor) buffer without saving to disk. Use this when the session client has declared fs.writeTextFile=true. The file_path is checked against the sandbox before forwarding.".into(),
279 parameters: serde_json::json!({
280 "type": "object",
281 "properties": {
282 "file_path": {
283 "type": "string",
284 "description": "The path of the file to write"
285 },
286 "content": {
287 "type": "string",
288 "description": "The content to write to the file"
289 }
290 },
291 "required": ["file_path", "content"]
292 }),
293 }
294 }
295
296 async fn execute(&self, arguments: Value) -> Result<String> {
297 let state = self.acp_state.lock().await;
298 if !state.write_text_file {
299 return Err(Error::Tool {
300 name: "ClientWriteFile".into(),
301 call_id: None,
302 message: "Client has not declared fs.writeTextFile capability".into(),
303 });
304 }
305 drop(state);
306
307 let file_path = arguments
308 .get("file_path")
309 .and_then(|v| v.as_str())
310 .ok_or_else(|| Error::BadToolArgs {
311 name: "ClientWriteFile".into(),
312 message: "Missing required argument 'file_path'".into(),
313 })?;
314
315 let _content = arguments
316 .get("content")
317 .and_then(|v| v.as_str())
318 .ok_or_else(|| Error::BadToolArgs {
319 name: "ClientWriteFile".into(),
320 message: "Missing required argument 'content'".into(),
321 })?;
322
323 if let Err(e) = crate::tools::resolve_within(&self.workspace, file_path) {
326 tracing::warn!(
327 target: "recursive::acp",
328 file_path = %file_path,
329 error = %e,
330 "ClientWriteFile: sandbox escape detected via resolve_within"
331 );
332 return Err(Error::PermissionDenied {
333 name: "ClientWriteFile".into(),
334 reason: crate::permissions::DecisionReason::SafetyCheck {
335 path: file_path.to_string(),
336 },
337 });
338 }
339
340 tracing::info!(
343 target: "recursive::acp",
344 file_path = %file_path,
345 "ClientWriteFile: forwarding write to client buffer (simulated)"
346 );
347
348 Ok(format!("Content queued for client write to {file_path}"))
349 }
350
351 fn kind(&self) -> crate::acp::ToolKind {
352 crate::acp::ToolKind::Write
353 }
354}
355
356#[cfg(test)]
361mod tests {
362 use super::*;
363
364 #[tokio::test]
367 async fn client_read_file_missing_capability_returns_error() {
368 let tmp = tempfile::TempDir::new().unwrap();
369 let tool = ClientReadFile::new(tmp.path());
370 let result = tool
371 .execute(serde_json::json!({"uri": "file:///test.txt"}))
372 .await;
373 assert!(
374 result.is_err(),
375 "Must return error when client has not declared readTextFile"
376 );
377 let err = result.unwrap_err().to_string();
378 assert!(
379 err.contains("readTextFile"),
380 "Error must mention the missing capability: {err}"
381 );
382 }
383
384 #[tokio::test]
385 async fn client_read_file_missing_uri_returns_bad_args() {
386 let tmp = tempfile::TempDir::new().unwrap();
387 let state = Arc::new(Mutex::new(AcpClientFsState {
388 read_text_file: true,
389 write_text_file: false,
390 }));
391 let tool = ClientReadFile::new(tmp.path()).with_acp_state(state);
392 let result = tool.execute(serde_json::json!({})).await;
393 assert!(
394 result.is_err(),
395 "Must return error for missing uri argument"
396 );
397 }
398
399 #[tokio::test]
400 async fn client_read_file_with_capability_and_uri_returns_expected() {
401 let tmp = tempfile::TempDir::new().unwrap();
402 let state = Arc::new(Mutex::new(AcpClientFsState {
403 read_text_file: true,
404 write_text_file: false,
405 }));
406 let tool = ClientReadFile::new(tmp.path())
407 .with_acp_state(state)
408 .with_client_read_timeout(100);
409 let result = tool
410 .execute(serde_json::json!({"uri": "file:///test.txt"}))
411 .await;
412 assert!(
416 result.is_err(),
417 "Should error with fallback message (no client integration yet)"
418 );
419 let err = result.unwrap_err().to_string();
420 assert!(
421 err.contains("fallback"),
422 "Error should mention fallback: {err}"
423 );
424 }
425
426 #[tokio::test]
429 async fn client_write_file_missing_capability_returns_error() {
430 let tmp = tempfile::TempDir::new().unwrap();
431 let tool = ClientWriteFile::new(tmp.path());
432 let result = tool
433 .execute(serde_json::json!({"file_path": "/tmp/test.txt", "content": "hello"}))
434 .await;
435 assert!(
436 result.is_err(),
437 "Must return error when client has not declared writeTextFile"
438 );
439 let err = result.unwrap_err().to_string();
440 assert!(
441 err.contains("writeTextFile"),
442 "Error must mention the missing capability: {err}"
443 );
444 }
445
446 #[tokio::test]
447 async fn client_write_file_missing_args_returns_bad_args() {
448 let tmp = tempfile::TempDir::new().unwrap();
449 let state = Arc::new(Mutex::new(AcpClientFsState {
450 read_text_file: false,
451 write_text_file: true,
452 }));
453 let tool = ClientWriteFile::new(tmp.path()).with_acp_state(state);
454 let result = tool.execute(serde_json::json!({})).await;
455 assert!(result.is_err(), "Must return error for missing arguments");
456 }
457
458 #[tokio::test]
459 async fn client_write_file_sandbox_escape_rejected() {
460 let tmp = tempfile::TempDir::new().unwrap();
461 let state = Arc::new(Mutex::new(AcpClientFsState {
462 read_text_file: false,
463 write_text_file: true,
464 }));
465 let tool = ClientWriteFile::new(tmp.path()).with_acp_state(state);
466 let result = tool
468 .execute(serde_json::json!({"file_path": "/etc/passwd", "content": "evil"}))
469 .await;
470 assert!(
471 result.is_err(),
472 "Sandbox escape must be rejected: {:?}",
473 result
474 );
475 let err = result.unwrap_err().to_string();
476 assert!(
477 err.contains("sandbox")
478 || err.contains("escape")
479 || err.contains("PermissionDenied")
480 || err.contains("permission denied"),
481 "Error must mention sandbox/permission: {err}"
482 );
483 }
484
485 #[tokio::test]
486 async fn client_write_file_valid_path_returns_ok() {
487 let tmp = tempfile::TempDir::new().unwrap();
488 let state = Arc::new(Mutex::new(AcpClientFsState {
489 read_text_file: false,
490 write_text_file: true,
491 }));
492 let tool = ClientWriteFile::new(tmp.path()).with_acp_state(state);
493 let test_file = tmp.path().join("valid.txt");
494 let result = tool
495 .execute(serde_json::json!({
496 "file_path": test_file.to_string_lossy(),
497 "content": "hello world"
498 }))
499 .await;
500 assert!(result.is_ok(), "Valid path within sandbox must succeed");
501 let output = result.unwrap();
502 assert!(
503 output.contains("queued"),
504 "Output should indicate client write queueing: {output}"
505 );
506 }
507
508 #[test]
511 fn state_defaults_to_false() {
512 let state = AcpClientFsState::new();
513 assert!(!state.read_text_file);
514 assert!(!state.write_text_file);
515 }
516}