feature/acp-session-list-cursor
claude/github-issue-40-0au5ua
claude/linked-issue-acp-tui-u4vvpa
claude/minor-release-cut-vkugow
claude/sigit-acp-local-chat-cx6380
claude/sigit-cloud-agent-expansion-reox0a
claude/tool-permission-system
claude/zen-feynman-0u78dk
development
feature/acp-cancel-request
feature/acp-session-list-cursor
feature/acp-v1-conformance
feature/agent-tools-multiedit-glob-todos-remember
feature/cargo-workspace
feature/hooks
feature/load-local-model-explicitly
feature/sigit-agent-profile
feature/sigit-code-cloud
feature/xcode-mcp-server
fix/acp-replay-loaded-session-history
fix/acp-replay-reasoning-and-attachments
fix/dsml-doubled-bar-markers
main
release/v1.5.10
release/v1.5.6
release/v1.6.1
| 1 | //! Reading and writing files through the ACP client instead of the disk. |
| 2 | //! |
| 3 | //! An editor that advertises `fs.readTextFile` / `fs.writeTextFile` in its |
| 4 | //! `initialize` request will serve `fs/read_text_file` and `fs/write_text_file` |
| 5 | //! from its own buffers. Going through it is what lets `read_file` see changes |
| 6 | //! the user has not saved yet, and lets `edit_file` land in the open buffer |
| 7 | //! rather than underneath it, where the editor would have to reconcile the |
| 8 | //! file on disk with a buffer the user may have modified. |
| 9 | //! |
| 10 | //! The tool code in `tools.rs` has no access to the connection, so this module |
| 11 | //! is the seam, the same shape as the one `mcp::call_tool` gives MCP: `main.rs` |
| 12 | //! registers a [`ClientFileSystem`] once the client has said what it supports, |
| 13 | //! and the file tools ask [`route_for`] whether a path should go through it. |
| 14 | //! |
| 15 | //! The disk stays the fallback everywhere. Nothing is registered in the TUI or |
| 16 | //! headless modes, a path outside the session's roots is not the editor's to |
| 17 | //! serve, and a request the client fails or never answers is retried on disk, |
| 18 | //! so a client with a partial or broken implementation costs a log line rather |
| 19 | //! than the tool call. |
| 20 | |
| 21 | use std::path::{Path, PathBuf}; |
| 22 | use std::sync::{Arc, RwLock}; |
| 23 | use std::time::Duration; |
| 24 | |
| 25 | use async_trait::async_trait; |
| 26 | |
| 27 | /// How long the client gets to answer one file request before the disk is used |
| 28 | /// instead. A buffer read or write is local work for an editor; a client that |
| 29 | /// takes longer than this has most likely dropped the request. |
| 30 | const CLIENT_FS_TIMEOUT: Duration = Duration::from_secs(30); |
| 31 | |
| 32 | /// The client's side of ACP's file-system methods. Errors are plain strings: |
| 33 | /// they only ever reach the log, since the caller falls back to the disk. |
| 34 | #[async_trait] |
| 35 | pub trait ClientFileSystem: Send + Sync { |
| 36 | async fn read_text_file(&self, session_id: &str, path: &Path) -> Result<String, String>; |
| 37 | async fn write_text_file( |
| 38 | &self, |
| 39 | session_id: &str, |
| 40 | path: &Path, |
| 41 | content: &str, |
| 42 | ) -> Result<(), String>; |
| 43 | } |
| 44 | |
| 45 | #[derive(Clone)] |
| 46 | struct Registered { |
| 47 | fs: Arc<dyn ClientFileSystem>, |
| 48 | read: bool, |
| 49 | write: bool, |
| 50 | } |
| 51 | |
| 52 | /// The connected client's file system, with the two capabilities it |
| 53 | /// advertised. Capabilities are fixed for the connection, and the process |
| 54 | /// serves one connection, so this is set once from `initialize`. |
| 55 | static CLIENT: RwLock<Option<Registered>> = RwLock::new(None); |
| 56 | |
| 57 | /// Record what the client offered in `clientCapabilities.fs`. A client that |
| 58 | /// offered neither method leaves the disk as the only path. |
| 59 | pub fn register(fs: Arc<dyn ClientFileSystem>, read: bool, write: bool) { |
| 60 | let registered = (read || write).then_some(Registered { fs, read, write }); |
| 61 | if let Ok(mut guard) = CLIENT.write() { |
| 62 | *guard = registered; |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | /// `SIGIT_CLIENT_FS=off` keeps every file tool on the disk, for a client whose |
| 67 | /// file-system methods misbehave in a way the fallback cannot see. |
| 68 | pub fn disabled_by_env() -> bool { |
| 69 | std::env::var("SIGIT_CLIENT_FS") |
| 70 | .map(|value| { |
| 71 | matches!( |
| 72 | value.trim().to_ascii_lowercase().as_str(), |
| 73 | "off" | "0" | "false" |
| 74 | ) |
| 75 | }) |
| 76 | .unwrap_or(false) |
| 77 | } |
| 78 | |
| 79 | /// One file's way through the client, for the session that asked. |
| 80 | pub struct Route { |
| 81 | fs: Arc<dyn ClientFileSystem>, |
| 82 | session_id: String, |
| 83 | read: bool, |
| 84 | write: bool, |
| 85 | } |
| 86 | |
| 87 | /// The client route for `path`, or `None` when the tool should use the disk: |
| 88 | /// no client file system is registered, no session is live, or the path is |
| 89 | /// outside the session's roots. `path` must be absolute, which is also what |
| 90 | /// the protocol requires of the paths it carries. |
| 91 | pub fn route_for(session_id: Option<&str>, path: &Path) -> Option<Route> { |
| 92 | let registered = CLIENT.read().ok()?.clone()?; |
| 93 | let session_id = session_id?; |
| 94 | if !path.is_absolute() || !in_roots(path, &crate::workspace::project_dirs()) { |
| 95 | return None; |
| 96 | } |
| 97 | Some(Route { |
| 98 | fs: registered.fs, |
| 99 | session_id: session_id.to_string(), |
| 100 | read: registered.read, |
| 101 | write: registered.write, |
| 102 | }) |
| 103 | } |
| 104 | |
| 105 | impl Route { |
| 106 | /// The file's text: the client's copy when it can read, the disk's when it |
| 107 | /// cannot or the request failed. |
| 108 | pub async fn read(&self, path: &Path) -> std::io::Result<String> { |
| 109 | if self.read { |
| 110 | let request = self.fs.read_text_file(&self.session_id, path); |
| 111 | match tokio::time::timeout(CLIENT_FS_TIMEOUT, request).await { |
| 112 | Ok(Ok(content)) => return Ok(content), |
| 113 | Ok(Err(error)) => log::warn!( |
| 114 | "fs/read_text_file failed for {}: {error}; reading from disk", |
| 115 | path.display() |
| 116 | ), |
| 117 | Err(_) => log::warn!( |
| 118 | "fs/read_text_file timed out for {}; reading from disk", |
| 119 | path.display() |
| 120 | ), |
| 121 | } |
| 122 | } |
| 123 | std::fs::read_to_string(path) |
| 124 | } |
| 125 | |
| 126 | /// Write the file's text through the client when it can write, and to the |
| 127 | /// disk when it cannot or the request failed. |
| 128 | pub async fn write(&self, path: &Path, content: &str) -> std::io::Result<()> { |
| 129 | if self.write { |
| 130 | let request = self.fs.write_text_file(&self.session_id, path, content); |
| 131 | match tokio::time::timeout(CLIENT_FS_TIMEOUT, request).await { |
| 132 | Ok(Ok(())) => return Ok(()), |
| 133 | Ok(Err(error)) => log::warn!( |
| 134 | "fs/write_text_file failed for {}: {error}; writing to disk", |
| 135 | path.display() |
| 136 | ), |
| 137 | Err(_) => log::warn!( |
| 138 | "fs/write_text_file timed out for {}; writing to disk", |
| 139 | path.display() |
| 140 | ), |
| 141 | } |
| 142 | } |
| 143 | std::fs::write(path, content) |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | /// Whether `path` sits inside one of `roots`. |
| 148 | /// |
| 149 | /// Both sides are resolved first, so a root reached through a symlink (macOS |
| 150 | /// spells `/tmp` as `/private/tmp`) still contains its files, and a `..` that |
| 151 | /// climbs out of a root does not pass as being inside it. `client_terminal` |
| 152 | /// uses the same test for a command's directory. |
| 153 | pub(crate) fn in_roots(path: &Path, roots: &[PathBuf]) -> bool { |
| 154 | let path = resolve(path); |
| 155 | roots |
| 156 | .iter() |
| 157 | .any(|root| path.starts_with(crate::workspace::canonical_key(root))) |
| 158 | } |
| 159 | |
| 160 | /// `path` with its deepest existing ancestor canonicalized. A file that is |
| 161 | /// about to be created cannot be canonicalized itself, but its directory can. |
| 162 | fn resolve(path: &Path) -> PathBuf { |
| 163 | let normalized = crate::workspace::normalize(path); |
| 164 | let mut missing: Vec<&std::ffi::OsStr> = Vec::new(); |
| 165 | let mut existing = normalized.as_path(); |
| 166 | loop { |
| 167 | if let Ok(canonical) = existing.canonicalize() { |
| 168 | return missing |
| 169 | .iter() |
| 170 | .rev() |
| 171 | .fold(canonical, |joined, part| joined.join(part)); |
| 172 | } |
| 173 | match (existing.parent(), existing.file_name()) { |
| 174 | (Some(parent), Some(name)) => { |
| 175 | missing.push(name); |
| 176 | existing = parent; |
| 177 | } |
| 178 | _ => return normalized, |
| 179 | } |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | #[cfg(test)] |
| 184 | mod tests { |
| 185 | use super::*; |
| 186 | |
| 187 | /// A client holding one buffer's text, which can be told to fail. |
| 188 | struct FakeClient { |
| 189 | buffer: &'static str, |
| 190 | fail: bool, |
| 191 | written: std::sync::Mutex<Vec<(String, PathBuf, String)>>, |
| 192 | } |
| 193 | |
| 194 | impl FakeClient { |
| 195 | fn new(buffer: &'static str, fail: bool) -> Arc<Self> { |
| 196 | Arc::new(Self { |
| 197 | buffer, |
| 198 | fail, |
| 199 | written: std::sync::Mutex::default(), |
| 200 | }) |
| 201 | } |
| 202 | } |
| 203 | |
| 204 | #[async_trait] |
| 205 | impl ClientFileSystem for FakeClient { |
| 206 | async fn read_text_file(&self, _session_id: &str, _path: &Path) -> Result<String, String> { |
| 207 | if self.fail { |
| 208 | return Err("no such buffer".to_string()); |
| 209 | } |
| 210 | Ok(self.buffer.to_string()) |
| 211 | } |
| 212 | |
| 213 | async fn write_text_file( |
| 214 | &self, |
| 215 | session_id: &str, |
| 216 | path: &Path, |
| 217 | content: &str, |
| 218 | ) -> Result<(), String> { |
| 219 | if self.fail { |
| 220 | return Err("read-only buffer".to_string()); |
| 221 | } |
| 222 | self.written.lock().unwrap().push(( |
| 223 | session_id.to_string(), |
| 224 | path.to_path_buf(), |
| 225 | content.to_string(), |
| 226 | )); |
| 227 | Ok(()) |
| 228 | } |
| 229 | } |
| 230 | |
| 231 | fn route(client: &Arc<FakeClient>, read: bool, write: bool) -> Route { |
| 232 | Route { |
| 233 | fs: Arc::clone(client) as Arc<dyn ClientFileSystem>, |
| 234 | session_id: "session-1".to_string(), |
| 235 | read, |
| 236 | write, |
| 237 | } |
| 238 | } |
| 239 | |
| 240 | #[tokio::test] |
| 241 | async fn reads_and_writes_go_to_the_client_when_it_offers_them() { |
| 242 | let dir = scratch("client"); |
| 243 | let file = dir.join("a.txt"); |
| 244 | std::fs::write(&file, "on disk").unwrap(); |
| 245 | let client = FakeClient::new("in the buffer", false); |
| 246 | let route = route(&client, true, true); |
| 247 | |
| 248 | assert_eq!(route.read(&file).await.unwrap(), "in the buffer"); |
| 249 | route.write(&file, "edited").await.unwrap(); |
| 250 | |
| 251 | // The client owns the write, so the disk is left for it to update. |
| 252 | assert_eq!(std::fs::read_to_string(&file).unwrap(), "on disk"); |
| 253 | assert_eq!( |
| 254 | *client.written.lock().unwrap(), |
| 255 | vec![("session-1".to_string(), file.clone(), "edited".to_string())] |
| 256 | ); |
| 257 | |
| 258 | std::fs::remove_dir_all(&dir).ok(); |
| 259 | } |
| 260 | |
| 261 | #[tokio::test] |
| 262 | async fn a_capability_the_client_did_not_offer_stays_on_disk() { |
| 263 | let dir = scratch("partial"); |
| 264 | let file = dir.join("a.txt"); |
| 265 | std::fs::write(&file, "on disk").unwrap(); |
| 266 | let client = FakeClient::new("in the buffer", false); |
| 267 | let route = route(&client, false, false); |
| 268 | |
| 269 | assert_eq!(route.read(&file).await.unwrap(), "on disk"); |
| 270 | route.write(&file, "edited").await.unwrap(); |
| 271 | |
| 272 | assert_eq!(std::fs::read_to_string(&file).unwrap(), "edited"); |
| 273 | assert!(client.written.lock().unwrap().is_empty()); |
| 274 | |
| 275 | std::fs::remove_dir_all(&dir).ok(); |
| 276 | } |
| 277 | |
| 278 | #[tokio::test] |
| 279 | async fn a_request_the_client_fails_falls_back_to_disk() { |
| 280 | let dir = scratch("failing"); |
| 281 | let file = dir.join("a.txt"); |
| 282 | std::fs::write(&file, "on disk").unwrap(); |
| 283 | let client = FakeClient::new("in the buffer", true); |
| 284 | let route = route(&client, true, true); |
| 285 | |
| 286 | assert_eq!(route.read(&file).await.unwrap(), "on disk"); |
| 287 | route.write(&file, "edited").await.unwrap(); |
| 288 | |
| 289 | assert_eq!(std::fs::read_to_string(&file).unwrap(), "edited"); |
| 290 | |
| 291 | std::fs::remove_dir_all(&dir).ok(); |
| 292 | } |
| 293 | |
| 294 | fn scratch(name: &str) -> PathBuf { |
| 295 | let dir = |
| 296 | std::env::temp_dir().join(format!("sigit-client-fs-{name}-{}", uuid::Uuid::new_v4())); |
| 297 | std::fs::create_dir_all(&dir).unwrap(); |
| 298 | dir |
| 299 | } |
| 300 | |
| 301 | #[test] |
| 302 | fn a_file_inside_a_root_is_in_roots() { |
| 303 | let root = scratch("inside"); |
| 304 | std::fs::write(root.join("a.txt"), "a").unwrap(); |
| 305 | |
| 306 | assert!(in_roots(&root.join("a.txt"), std::slice::from_ref(&root))); |
| 307 | // Not created yet, so only its directory can be resolved. |
| 308 | assert!(in_roots( |
| 309 | &root.join("new").join("b.txt"), |
| 310 | std::slice::from_ref(&root) |
| 311 | )); |
| 312 | |
| 313 | std::fs::remove_dir_all(&root).ok(); |
| 314 | } |
| 315 | |
| 316 | #[test] |
| 317 | fn a_file_outside_every_root_is_not() { |
| 318 | let root = scratch("root"); |
| 319 | let other = scratch("other"); |
| 320 | std::fs::write(other.join("a.txt"), "a").unwrap(); |
| 321 | |
| 322 | assert!(!in_roots(&other.join("a.txt"), std::slice::from_ref(&root))); |
| 323 | // `..` out of the root names the sibling, however the path starts. |
| 324 | let climbing = root |
| 325 | .join("..") |
| 326 | .join(other.file_name().unwrap()) |
| 327 | .join("a.txt"); |
| 328 | assert!(!in_roots(&climbing, std::slice::from_ref(&root))); |
| 329 | assert!(!in_roots(&root.join("a.txt"), &[])); |
| 330 | |
| 331 | std::fs::remove_dir_all(&root).ok(); |
| 332 | std::fs::remove_dir_all(&other).ok(); |
| 333 | } |
| 334 | |
| 335 | #[cfg(unix)] |
| 336 | #[test] |
| 337 | fn a_symlink_out_of_the_root_is_not_inside_it() { |
| 338 | let root = scratch("link-root"); |
| 339 | let outside = scratch("link-outside"); |
| 340 | std::fs::write(outside.join("secret.txt"), "s").unwrap(); |
| 341 | std::os::unix::fs::symlink(&outside, root.join("link")).unwrap(); |
| 342 | |
| 343 | assert!(!in_roots( |
| 344 | &root.join("link").join("secret.txt"), |
| 345 | std::slice::from_ref(&root) |
| 346 | )); |
| 347 | |
| 348 | std::fs::remove_dir_all(&root).ok(); |
| 349 | std::fs::remove_dir_all(&outside).ok(); |
| 350 | } |
| 351 | } |