Skip to main content

agentmux_srv/server/
fs_handlers.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The generic `fs.*` RPC family behind the Files pane ("Hangar").
5//!
6//! Thin handlers over `backend::fs_ops`: each runs its filesystem work on a
7//! blocking thread (`spawn_blocking`, or the trash thread for `fs.trash` /
8//! `fs.restore`), never on an async worker, because on macOS the first
9//! access to a protected folder blocks the calling thread while a privacy
10//! prompt is up (spec §9.1.1 fact 10).
11//!
12//! Reads serve any path the user can read, as the editor's do. Mutations go
13//! through `fs_ops::ProtectedPaths` instead of the editor's home-only rule
14//! (spec §9, "Security").
15//!
16//! Copy and move (`fs.op.*`) are jobs rather than calls: `fs.op.start`
17//! answers once the request is validated, and the work reports through
18//! `files:op` events (`fs_ops::jobs`).
19//!
20//! Spec: docs/specs/SPEC_FILE_BROWSER_PANE_2026_10_01.md §6.3, §7, §9.
21
22use std::sync::Arc;
23
24use crate::backend::fs_ops;
25use crate::backend::rpc::engine::WshRpcEngine;
26use crate::backend::rpc_types::{
27    FsCreateReq, FsDeleteReq, FsEmptyResult, FsListReq, FsOpCancelReq, FsOpResolveReq, FsOpResult,
28    FsOpResults, FsOpStartReq, FsPathReq, FsPlace, FsPlaceKind, FsPlacesReq, FsPlacesResult,
29    FsCreateResult, FsRenameReq, FsRenameResult, FsRestoreReq, FsTrashReq, FsUnwatchReq, FsWatchReq,
30    FsWatchResult,
31};
32
33use super::AppState;
34
35/// Run blocking filesystem work off the async runtime.
36async fn blocking<T, F>(f: F) -> Result<T, String>
37where
38    T: Send + 'static,
39    F: FnOnce() -> T + Send + 'static,
40{
41    tokio::task::spawn_blocking(f).await.map_err(|e| format!("filesystem task failed: {e}"))
42}
43
44/// Pair each requested path with its outcome, in request order.
45fn op_results(paths: Vec<String>, outcomes: Vec<Result<(), String>>) -> FsOpResults {
46    FsOpResults {
47        results: paths
48            .into_iter()
49            .zip(outcomes)
50            .map(|(path, outcome)| FsOpResult { path, ok: outcome.is_ok(), error: outcome.err() })
51            .collect(),
52    }
53}
54
55use crate::backend::fs_ops::remote as fs_remote;
56
57fn remote_of<'a>(conn: &'a str, block_id: Option<&'a str>, auth_key: &'a str) -> fs_remote::Remote<'a> {
58    fs_remote::Remote {
59        connection: conn,
60        block_id,
61        auth_key,
62    }
63}
64
65pub fn register_fs_handlers(engine: &Arc<WshRpcEngine>, state: &AppState) {
66    // fs.list → one page of a folder. The first page opens it; later pages
67    // continue from `cursor`. Spec §6.3.
68    // A request naming an SSH connection runs on that host, through its
69    // helper (`fs_ops::remote`, spec §6.3 of the remote terminals spec).
70    let auth_key = state.auth_key.clone();
71    engine.register_typed("fs.list", move |cmd: FsListReq, _ctx| {
72        let auth_key = auth_key.clone();
73        async move {
74            if let Some(conn) = fs_remote::ssh_connection(cmd.connection.as_deref()) {
75                let r = remote_of(conn, cmd.block_id.as_deref(), &auth_key);
76                return fs_remote::list(r, &cmd.path).await;
77            }
78            fs_ops::ensure_cursor_sweeper();
79            blocking(move || fs_ops::list_page(&cmd)).await
80        }
81    });
82
83    // fs.places → home, known folders, drives and WSL distros. Known folders
84    // are named, never read (spec §9.1.5).
85    engine.register_typed(
86        "fs.places",
87        // `Option<_>` -- see `FsPlacesReq`: both encodings of "no argument".
88        |_req: Option<FsPlacesReq>, _ctx| async move {
89            let distros = crate::backend::remote::wsl::list_cached().await;
90            blocking(move || {
91                let (home, mut places) = fs_ops::home_and_known_places();
92                places.extend(super::editor_handlers::list_drives().into_iter().map(|d| FsPlace {
93                    id: format!("drive:{}", d.path),
94                    label: d.name,
95                    path: d.path,
96                    kind: FsPlaceKind::Drive,
97                }));
98                places.extend(distros.into_iter().map(|d| FsPlace {
99                    id: format!("wsl:{d}"),
100                    path: crate::backend::remote::wsl_fs::share_path(&d, "/"),
101                    label: d,
102                    kind: FsPlaceKind::Wsl,
103                }));
104                FsPlacesResult { home, sep: std::path::MAIN_SEPARATOR.to_string(), places }
105            })
106            .await
107        },
108    );
109
110    // fs.watch / fs.unwatch → live updates for a listed folder, published as
111    // `files:changed` scoped to the block. Spec §6.3.
112    {
113        let watcher = state.files_watcher.clone();
114        engine.register_typed("fs.watch", move |cmd: FsWatchReq, _ctx| {
115            let watcher = watcher.clone();
116            async move {
117                let path = cmd.path.clone();
118                let dir = blocking(move || -> Result<std::path::PathBuf, String> {
119                    let canonical = fs_ops::resolve_request_path(&path)?
120                        .canonicalize()
121                        .map_err(|e| fs_ops::classify_folder_error(&e).1)?;
122                    if !canonical.is_dir() {
123                        return Err("This isn't a folder.".to_string());
124                    }
125                    Ok(canonical)
126                })
127                .await??;
128                Ok(FsWatchResult { watch_id: watcher.watch(&dir, &cmd.block_id) })
129            }
130        });
131    }
132    {
133        let watcher = state.files_watcher.clone();
134        engine.register_typed("fs.unwatch", move |cmd: FsUnwatchReq, _ctx| {
135            let watcher = watcher.clone();
136            async move {
137                watcher.unwatch(&cmd.watch_id);
138                Ok(FsEmptyResult {})
139            }
140        });
141    }
142
143    // ── Mutations. Spec §7, §9 ──────────────────────────────────────────
144
145    let auth_key = state.auth_key.clone();
146    engine.register_typed("fs.rename", move |cmd: FsRenameReq, _ctx| {
147        let auth_key = auth_key.clone();
148        async move {
149            if let Some(conn) = fs_remote::ssh_connection(cmd.connection.as_deref()) {
150                let r = remote_of(conn, cmd.block_id.as_deref(), &auth_key);
151                let new_path = fs_remote::rename(r, &cmd.path, &cmd.new_name).await?;
152                return Ok(FsRenameResult { new_path });
153            }
154            blocking(move || fs_ops::rename(&cmd)).await?
155        }
156    });
157
158    let auth_key = state.auth_key.clone();
159    engine.register_typed("fs.create", move |cmd: FsCreateReq, _ctx| {
160        let auth_key = auth_key.clone();
161        async move {
162            if let Some(conn) = fs_remote::ssh_connection(cmd.connection.as_deref()) {
163                let r = remote_of(conn, cmd.block_id.as_deref(), &auth_key);
164                let path = fs_remote::create(r, &cmd.parent, &cmd.name, cmd.kind).await?;
165                return Ok(FsCreateResult { path });
166            }
167            blocking(move || fs_ops::create(&cmd)).await?
168        }
169    });
170
171    // fs.trash → the OS Trash, on the single trash thread (spec §7.3).
172    engine.register_typed("fs.trash", |cmd: FsTrashReq, _ctx| async move {
173        if let Some(conn) = fs_remote::ssh_connection(cmd.connection.as_deref()) {
174            return Err(fs_remote::no_trash(conn));
175        }
176        let paths = cmd.paths;
177        let for_worker = paths.clone();
178        let outcomes = fs_ops::trash_worker::run(move || for_worker.iter().map(|p| fs_ops::trash(p)).collect()).await?;
179        Ok(op_results(paths, outcomes))
180    });
181
182    // fs.restore → undo of fs.trash; the paths are the original locations.
183    engine.register_typed("fs.restore", |cmd: FsRestoreReq, _ctx| async move {
184        if let Some(conn) = fs_remote::ssh_connection(cmd.connection.as_deref()) {
185            return Err(fs_remote::no_trash(conn));
186        }
187        let paths = cmd.paths;
188        let for_worker = paths.clone();
189        let outcomes = fs_ops::trash_worker::run(move || fs_ops::restore(&for_worker)).await?;
190        Ok(op_results(paths, outcomes))
191    });
192
193    // fs.delete → PERMANENT. The UI confirms first (spec §7.3).
194    let auth_key = state.auth_key.clone();
195    engine.register_typed("fs.delete", move |cmd: FsDeleteReq, _ctx| {
196        let auth_key = auth_key.clone();
197        async move {
198            if let Some(conn) = fs_remote::ssh_connection(cmd.connection.as_deref()) {
199                let r = remote_of(conn, cmd.block_id.as_deref(), &auth_key);
200                return Ok(FsOpResults {
201                    results: fs_remote::delete(r, &cmd.paths).await,
202                });
203            }
204            let paths = cmd.paths;
205            let for_worker = paths.clone();
206            let outcomes =
207                blocking(move || for_worker.iter().map(|p| fs_ops::delete_permanently(p)).collect()).await?;
208            Ok(op_results(paths, outcomes))
209        }
210    });
211
212    // fs.open / fs.reveal → hand a path to the OS default application / file
213    // manager. Not home-scoped: they change nothing.
214    engine.register_typed("fs.open", |cmd: FsPathReq, _ctx| async move {
215        blocking(move || fs_ops::open_or_reveal(&cmd.path, false)).await??;
216        Ok(FsEmptyResult {})
217    });
218    engine.register_typed("fs.reveal", |cmd: FsPathReq, _ctx| async move {
219        blocking(move || fs_ops::open_or_reveal(&cmd.path, true)).await??;
220        Ok(FsEmptyResult {})
221    });
222
223    // ── Copy/move jobs. Spec §7.1, §7.2 ─────────────────────────────────
224
225    // fs.op.start → validate, answer with an op id, then work on the op's
226    // own thread, publishing `files:op` to the block.
227    {
228        let broker = state.broker.clone();
229        let auth_key = state.auth_key.clone();
230        engine.register_typed("fs.op.start", move |cmd: FsOpStartReq, _ctx| {
231            let broker = broker.clone();
232            let auth_key = auth_key.clone();
233            async move {
234                if cmd.block_id.is_empty() {
235                    return Err("No pane was named to report the operation's progress to.".to_string());
236                }
237                let emit = fs_ops::jobs::broker_emitter(broker, cmd.block_id.clone());
238                // Either side on an SSH host: the host transfer (same events,
239                // cancel and questions), its ssh prompts in this pane's window.
240                let from = fs_remote::ssh_connection(cmd.source_connection.as_deref());
241                let to = fs_remote::ssh_connection(cmd.connection.as_deref());
242                if from.is_some() || to.is_some() {
243                    let ask = crate::backend::remote::sessions::AskIn {
244                        block_id: &cmd.block_id,
245                        auth_key: &auth_key,
246                    };
247                    // Both at once: each may first ask the user something over
248                    // ssh, and two in a row could outlast the request.
249                    let connect = |conn: Option<&str>| {
250                        let conn = conn.map(str::to_string);
251                        async move {
252                            match conn {
253                                None => Ok(fs_ops::host_jobs::End::Local),
254                                Some(c) => crate::backend::remote::files::connect(&c, Some(ask))
255                                    .await
256                                    .map(fs_ops::host_jobs::End::host)
257                                    .map_err(|e| e.message),
258                            }
259                        }
260                    };
261                    let (src, dest) = tokio::join!(connect(from), connect(to));
262                    let (src, dest) = (src?, dest?);
263                    return fs_ops::host_jobs::start(cmd.kind, src, cmd.sources, dest, cmd.dest_dir, emit).await;
264                }
265                blocking(move || fs_ops::jobs::JOBS.start(&cmd, emit)).await?
266            }
267        });
268    }
269
270    // fs.op.resolve / fs.op.cancel → touch only the op's in-memory controls,
271    // so they answer at once, on the async thread.
272    engine.register_typed("fs.op.resolve", |cmd: FsOpResolveReq, _ctx| async move {
273        fs_ops::jobs::JOBS.resolve(&cmd.op_id, cmd.choice, cmd.apply_to_all)?;
274        Ok(FsEmptyResult {})
275    });
276    engine.register_typed("fs.op.cancel", |cmd: FsOpCancelReq, _ctx| async move {
277        fs_ops::jobs::JOBS.cancel(&cmd.op_id);
278        Ok(FsEmptyResult {})
279    });
280
281    // fs.git_status → the markers for a listed folder's entries. Never an
282    // error: outside a repository, or if git fails, the pane shows none.
283    engine.register_typed("fs.git_status", |cmd: FsPathReq, _ctx| async move {
284        Ok::<_, String>(fs_ops::git::git_status(&cmd.path).await)
285    });
286}
287
288#[cfg(test)]
289mod tests {
290    use crate::backend::rpc_types::RpcMessage;
291
292    /// Every `fs.*` command records its request and response types, so the
293    /// generated bindings describe the real wire format.
294    #[tokio::test]
295    async fn every_fs_command_records_its_types() {
296        let state = crate::server::tests::test_state();
297        let (engine, _rx) = crate::backend::rpc::engine::WshRpcEngine::new();
298        super::register_fs_handlers(&engine, &state);
299        let schema = engine.schema_json();
300        let rows = schema.as_array().unwrap();
301        let find = |cmd: &str| {
302            rows.iter()
303                .find(|r| r["command"] == cmd)
304                .unwrap_or_else(|| panic!("{cmd} missing from the schema"))
305        };
306        for (cmd, req, resp) in [
307            ("fs.list", "FsListReq", "FsListResult"),
308            ("fs.watch", "FsWatchReq", "FsWatchResult"),
309            ("fs.unwatch", "FsUnwatchReq", "FsEmptyResult"),
310            ("fs.rename", "FsRenameReq", "FsRenameResult"),
311            ("fs.create", "FsCreateReq", "FsCreateResult"),
312            ("fs.trash", "FsTrashReq", "FsOpResults"),
313            ("fs.restore", "FsRestoreReq", "FsOpResults"),
314            ("fs.delete", "FsDeleteReq", "FsOpResults"),
315            ("fs.open", "FsPathReq", "FsEmptyResult"),
316            ("fs.reveal", "FsPathReq", "FsEmptyResult"),
317            ("fs.op.start", "FsOpStartReq", "FsOpStartResult"),
318            ("fs.op.resolve", "FsOpResolveReq", "FsEmptyResult"),
319            ("fs.op.cancel", "FsOpCancelReq", "FsEmptyResult"),
320            ("fs.git_status", "FsPathReq", "FsGitStatus"),
321        ] {
322            let row = find(cmd);
323            assert_eq!(row["requestName"], req, "{cmd} request");
324            assert_eq!(row["responseName"], resp, "{cmd} response");
325        }
326        assert_eq!(find("fs.places")["responseName"], "FsPlacesResult");
327    }
328
329    /// `fs.places` takes no argument; the stub sends `{}`, a client that
330    /// omits `data` sends `null`. Both must work.
331    #[tokio::test]
332    async fn places_accepts_both_encodings_of_no_argument() {
333        let state = crate::server::tests::test_state();
334        let (engine, mut rx) = crate::backend::rpc::engine::WshRpcEngine::new();
335        super::register_fs_handlers(&engine, &state);
336        for (i, payload) in [serde_json::json!({}), serde_json::Value::Null].into_iter().enumerate() {
337            engine.handle_message(RpcMessage {
338                command: "fs.places".to_string(),
339                reqid: format!("places-{i}"),
340                data: Some(payload.clone()),
341                ..Default::default()
342            });
343            let resp = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv()).await.unwrap().unwrap();
344            assert!(resp.error.is_empty(), "fs.places should accept {payload}, got: {}", resp.error);
345            let data = resp.data.unwrap();
346            assert_eq!(data["sep"], std::path::MAIN_SEPARATOR.to_string());
347            assert!(data["places"].as_array().unwrap().iter().any(|p| p["kind"] == "home"));
348        }
349    }
350
351    /// The batch operations answer per path and never fail as a whole for
352    /// one bad path.
353    #[tokio::test]
354    async fn a_batch_answers_per_path() {
355        let state = crate::server::tests::test_state();
356        let (engine, mut rx) = crate::backend::rpc::engine::WshRpcEngine::new();
357        super::register_fs_handlers(&engine, &state);
358        let dir = tempfile::tempdir().unwrap();
359        let file = dir.path().join("doomed.txt");
360        std::fs::write(&file, "x").unwrap();
361        let missing = dir.path().join("missing.txt");
362
363        engine.handle_message(RpcMessage {
364            command: "fs.delete".to_string(),
365            reqid: "del".to_string(),
366            data: Some(serde_json::json!({ "paths": [file.to_string_lossy(), missing.to_string_lossy()] })),
367            ..Default::default()
368        });
369        let resp = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv()).await.unwrap().unwrap();
370        assert!(resp.error.is_empty(), "{}", resp.error);
371        let results = resp.data.unwrap()["results"].as_array().unwrap().clone();
372        assert_eq!(results.len(), 2);
373        assert_eq!(results[0]["ok"], true);
374        assert_eq!(results[1]["ok"], false);
375        assert!(results[1]["error"].is_string());
376        assert!(!file.exists());
377    }
378}