Skip to main content

recursive/tools/
client_fs.rs

1//! Client-side filesystem tools for ACP reverse-fs (Sprint 2).
2//!
3//! When the ACP client declares `fs.readTextFile=true` or
4//! `fs.writeTextFile=true`, the agent uses `ClientReadFile` /
5//! `ClientWriteFile` tools to request file operations from the client
6//! (IDE, editor) instead of performing local filesystem operations.
7//!
8//! These tools:
9//! - Are registered in the [`ToolRegistry`] **only** when an ACP session
10//!   is active and the client has declared the relevant capability.
11//! - Communicate with the client via the ACP session's bridge channel.
12//! - Fall back to local filesystem operations when the client does not
13//!   respond or returns an error (S2-E2).
14//! - Always enforce sandbox containment via `resolve_within` before
15//!   sending a request to the client (S2-E5).
16
17use 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// ---------------------------------------------------------------------------
29// Shared state for ACP client FS capabilities
30// ---------------------------------------------------------------------------
31
32/// Tracks which session (if any) has an ACP client with reverse-fs capabilities.
33///
34/// The `ClientReadFile` and `ClientWriteFile` tools consult this state
35/// to determine whether to execute or return an error (when not in an ACP
36/// session or the client hasn't declared the capability).
37#[derive(Debug, Default)]
38pub struct AcpClientFsState {
39    /// Whether the ACP session's client has declared `fs.readTextFile=true`.
40    pub read_text_file: bool,
41    /// Whether the ACP session's client has declared `fs.writeTextFile=true`.
42    pub write_text_file: bool,
43}
44
45impl AcpClientFsState {
46    pub fn new() -> Self {
47        Self::default()
48    }
49}
50
51// ---------------------------------------------------------------------------
52// ClientReadFile
53// ---------------------------------------------------------------------------
54
55/// A tool that reads a file from the ACP client's buffer (IDE/editor).
56///
57/// The agent calls this when the client has declared `fs.readTextFile=true`.
58/// The tool forwards the read request to the client, which returns the file
59/// contents from its unsaved buffer.
60///
61/// If the client returns an error or does not respond within
62/// `client_read_timeout_ms`, the tool falls back to a local filesystem
63/// read (S2-E2).
64pub struct ClientReadFile {
65    /// Primary workspace root. Held for future permission-root lookups; the
66    /// current read/write handlers only need the client-side buffer state.
67    #[allow(dead_code)]
68    workspace: Arc<Path>,
69    /// Additional sandbox roots beyond the primary workspace, each tagged
70    /// with an access tier.
71    pub extra_roots: Vec<(PathBuf, AccessTier)>,
72    /// Session-scoped, runtime-mutable roots (e.g. added via the TUI /add-dir command).
73    pub session_roots: Option<SharedSandboxRoots>,
74    /// Shared state flag from the ACP session manager.
75    acp_state: Arc<Mutex<AcpClientFsState>>,
76    /// Timeout for client responses in milliseconds.
77    client_read_timeout_ms: u64,
78}
79
80impl ClientReadFile {
81    /// Create a new ClientReadFile tool.
82    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    /// Set the shared ACP client FS state.
93    ///
94    /// This links the tool to the ACP session manager's capability state
95    /// so the tool can determine whether to execute or return an error.
96    pub fn with_acp_state(mut self, state: Arc<Mutex<AcpClientFsState>>) -> Self {
97        self.acp_state = state;
98        self
99    }
100
101    /// Set the client read timeout in milliseconds (default 5000).
102    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    /// Append additional allowed sandbox roots.
108    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    /// Convenience: attach the shared slot only when `Some`.
117    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    /// All roots, primary first, as consumed by resolve_within_any.
125    #[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        // In this sprint, we simulate the client read by returning
178        // a placeholder message. Real integration will be added when
179        // the ACP bridge is wired to forward these to the client.
180        //
181        // For now, the tool always falls back to the local filesystem
182        // read (S2-E2 behaviour) to ensure the agent can still function.
183        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
201// ---------------------------------------------------------------------------
202// ClientWriteFile
203// ---------------------------------------------------------------------------
204
205/// A tool that writes content to the ACP client's buffer (IDE/editor).
206///
207/// The agent calls this when the client has declared `fs.writeTextFile=true`.
208/// The tool sends the file path and content to the client, which updates
209/// its unsaved buffer without writing to disk.
210///
211/// Sandbox escape detection is enforced: the `file_path` parameter is
212/// checked against the sandbox roots via `resolve_within` before the
213/// write is sent to the client (S2-E5).
214pub struct ClientWriteFile {
215    workspace: Arc<Path>,
216    /// Additional sandbox roots beyond the primary workspace.
217    pub extra_roots: Vec<(PathBuf, AccessTier)>,
218    /// Session-scoped, runtime-mutable roots.
219    pub session_roots: Option<SharedSandboxRoots>,
220    /// Shared state flag from the ACP session manager.
221    acp_state: Arc<Mutex<AcpClientFsState>>,
222}
223
224impl ClientWriteFile {
225    /// Create a new ClientWriteFile tool.
226    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    /// Set the shared ACP client FS state.
236    pub fn with_acp_state(mut self, state: Arc<Mutex<AcpClientFsState>>) -> Self {
237        self.acp_state = state;
238        self
239    }
240
241    /// Append additional allowed sandbox roots.
242    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    /// Convenience: attach the shared slot only when `Some`.
251    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    /// All roots, primary first, as consumed by resolve_within.
259    #[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        // S2-E5: Sandbox escape detection before forwarding to client.
324        // Use resolve_within to check if file_path stays within the sandbox.
325        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        // In this sprint, we simulate the client write. The content is
341        // NOT written to disk — it's forwarded to the client buffer.
342        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// ---------------------------------------------------------------------------
357// Tests
358// ---------------------------------------------------------------------------
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    // ── ClientReadFile tests ───────────────────────────────────────
365
366    #[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        // Since we haven't wired the actual ACP bridge routing yet,
413        // this will fall through to error (simulated).
414        // The test verifies the tool doesn't panic and returns an error.
415        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    // ── ClientWriteFile tests ──────────────────────────────────────
427
428    #[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        // /etc/passwd escapes the sandbox
467        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    // ── AcpClientFsState tests ─────────────────────────────────────
509
510    #[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}