Skip to main content

agentmux_srv/backend/fs_ops/
remote.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Hangar's file operations on an SSH connection (spec ยง6.3 of
5//! SPEC_REMOTE_TERMINALS_AND_DURABLE_SESSIONS_2026_10_02.md): the same
6//! requests and results as a local folder, carried out by the host's helper
7//! (`remote::files`). Paths there are the host's POSIX paths; `~` is its home.
8//!
9//! The same rules as a WSL distro's folders hold (`wsl_fs`): a host's system
10//! folders, its root and the home folder itself are never changed, only read.
11//! There is no Trash on a remote host: deleting there is permanent, and the
12//! UI says so before it does.
13
14use agentmux_remote::fsproto::{Entry, ErrKind, Kind};
15
16use crate::backend::remote::files::{self, RemoteError, RemoteFiles};
17use crate::backend::remote::sessions::AskIn;
18use crate::backend::rpc_types::{
19    FsCreateKind, FsEntry, FsError, FsErrorKind, FsListResult, FsOpResult,
20};
21
22/// Folders on a host that are read, never changed (Linux and macOS).
23const SYSTEM_DIRS: &[&str] = &[
24    "/usr",
25    "/bin",
26    "/sbin",
27    "/etc",
28    "/var",
29    "/boot",
30    "/lib",
31    "/lib32",
32    "/lib64",
33    "/proc",
34    "/sys",
35    "/dev",
36    "/mnt",
37    "/run",
38    "/opt",
39    "/System",
40    "/Library",
41    "/Applications",
42    "/private",
43    "/cores",
44];
45
46/// Where a request runs: an SSH connection's name and the pane asking (whose
47/// window shows any ssh prompt).
48#[derive(Debug, Clone, Copy)]
49pub struct Remote<'a> {
50    pub connection: &'a str,
51    pub block_id: Option<&'a str>,
52    pub auth_key: &'a str,
53}
54
55/// The SSH connection a request names, if it names one: local and WSL
56/// requests (and none at all) go the usual way.
57pub fn ssh_connection(connection: Option<&str>) -> Option<&str> {
58    let c = connection?.trim();
59    matches!(
60        crate::backend::remote::ConnTarget::parse(c),
61        Ok(crate::backend::remote::ConnTarget::Ssh(_))
62    )
63    .then_some(c)
64}
65
66/// The host's home as a tidy absolute path: the helper sends it as `$HOME`
67/// says (a trailing `/`, or `/` when unset), and every comparison with it must
68/// see one spelling.
69fn home_of(f: &RemoteFiles) -> String {
70    resolve("/", &f.home)
71}
72
73async fn open(r: Remote<'_>) -> Result<std::sync::Arc<RemoteFiles>, String> {
74    let ask = r.block_id.filter(|b| !b.is_empty()).map(|block_id| AskIn {
75        block_id,
76        auth_key: r.auth_key,
77    });
78    files::connect(r.connection, ask)
79        .await
80        .map_err(|e| e.message)
81}
82
83/// `path` on the host, absolute and tidy: `~` and a relative path under
84/// `home`, `.` and empty parts dropped, `..` taken back a level (never above
85/// `/`), no trailing `/`.
86pub fn resolve(home: &str, path: &str) -> String {
87    let path = path.trim();
88    let full = if path.is_empty() || path == "~" {
89        home.to_string()
90    } else if let Some(rest) = path.strip_prefix("~/") {
91        format!("{home}/{rest}")
92    } else if path.starts_with('/') {
93        path.to_string()
94    } else {
95        format!("{home}/{path}")
96    };
97    let mut parts: Vec<&str> = Vec::new();
98    for part in full.split('/') {
99        match part {
100            "" | "." => {}
101            ".." => {
102                parts.pop();
103            }
104            p => parts.push(p),
105        }
106    }
107    format!("/{}", parts.join("/"))
108}
109
110fn parent(path: &str) -> Option<&str> {
111    if path == "/" {
112        return None;
113    }
114    Some(match path.rfind('/') {
115        Some(0) => "/",
116        Some(i) => &path[..i],
117        None => "/",
118    })
119}
120
121fn join(dir: &str, name: &str) -> String {
122    if dir.ends_with('/') {
123        format!("{dir}{name}")
124    } else {
125        format!("{dir}/{name}")
126    }
127}
128
129/// Whether changing `path` (absolute, [`resolve`]d) is refused: the root, a
130/// system folder or anything in one, or the home folder itself. Anything
131/// inside home is the user's, even where home is under a system folder
132/// (`/var/home/<user>` on Fedora Silverblue, a home under `/opt`).
133pub fn protected(home: &str, path: &str) -> bool {
134    // (Not when home is `/`, a host with no $HOME: everything would be
135    // "inside" it, system folders included.)
136    if home != "/" && path != home && path.starts_with(&format!("{}/", home.trim_end_matches('/')))
137    {
138        return false;
139    }
140    path == "/"
141        || path == home
142        || SYSTEM_DIRS
143            .iter()
144            .any(|d| path == *d || path.starts_with(&format!("{d}/")))
145}
146
147fn refusal(conn: &str, path: &str) -> String {
148    format!("AgentMux doesn't change {path} on {conn}: it's a system folder or your home folder.")
149}
150
151fn entry(e: Entry) -> FsEntry {
152    FsEntry {
153        hidden: e.name.starts_with('.'),
154        // No write bit for anyone: read-only (0 is "unknown", not read-only).
155        readonly: e.mode != 0 && e.mode & 0o222 == 0,
156        is_dir: e.kind == Kind::Dir,
157        is_symlink: e.symlink,
158        size: (e.kind != Kind::Dir).then_some(e.size),
159        mtime: (e.mtime_ms > 0).then_some(e.mtime_ms as u64),
160        link_target: (!e.link_target.is_empty()).then_some(e.link_target),
161        error: None,
162        name: e.name,
163    }
164}
165
166fn list_error(e: &RemoteError) -> FsError {
167    FsError {
168        kind: match e.kind {
169            Some(ErrKind::NotFound) => FsErrorKind::NotFound,
170            Some(ErrKind::PermissionDenied) => FsErrorKind::PermissionDenied,
171            Some(ErrKind::NotADirectory) => FsErrorKind::NotADirectory,
172            _ => FsErrorKind::Other,
173        },
174        message: e.message.clone(),
175    }
176}
177
178/// `fs.list` on a host: the whole folder in one page (the helper pages it
179/// underneath), in its canonical form.
180pub async fn list(r: Remote<'_>, path: &str) -> Result<FsListResult, String> {
181    let f = open(r).await?;
182    let home = home_of(&f);
183    let dir = resolve(&home, path);
184    Ok(match f.list(&dir).await {
185        Ok(entries) => FsListResult {
186            path: dir,
187            entries: entries.into_iter().map(entry).collect(),
188            cursor: None,
189            error: None,
190        },
191        Err(e) => FsListResult {
192            path: dir,
193            entries: Vec::new(),
194            cursor: None,
195            error: Some(list_error(&e)),
196        },
197    })
198}
199
200fn valid_name(name: &str) -> Result<(), String> {
201    if name.is_empty() || name == "." || name == ".." || name.contains('/') || name.contains('\0') {
202        return Err("That name can't be used.".to_string());
203    }
204    Ok(())
205}
206
207fn friendly(e: RemoteError, what: &str, name: &str) -> String {
208    match e.kind {
209        Some(ErrKind::AlreadyExists) => format!("\"{name}\" already exists here."),
210        Some(ErrKind::NotFound) => "It isn't there anymore.".to_string(),
211        Some(ErrKind::PermissionDenied) => format!("You don't have permission to {what} that."),
212        _ => format!("Couldn't {what} it: {}", e.message),
213    }
214}
215
216/// `fs.rename` on a host; the new path.
217pub async fn rename(r: Remote<'_>, path: &str, new_name: &str) -> Result<String, String> {
218    valid_name(new_name)?;
219    let f = open(r).await?;
220    let home = home_of(&f);
221    let src = resolve(&home, path);
222    let dir = parent(&src).ok_or_else(|| refusal(r.connection, &src))?;
223    let dest = join(dir, new_name);
224    for p in [&src, &dest] {
225        if protected(&home, p) {
226            return Err(refusal(r.connection, p));
227        }
228    }
229    if src == dest {
230        return Ok(dest);
231    }
232    let result = f
233        .rename(&src, &dest)
234        .await
235        .map_err(|e| friendly(e, "rename", new_name));
236    tracing::info!(op = "fs.rename", connection = %r.connection, from = %src, to = %dest,
237        outcome = if result.is_ok() { "ok" } else { "error" }, "remote fs mutation");
238    result.map(|()| dest)
239}
240
241/// `fs.create` on a host; the new path.
242pub async fn create(
243    r: Remote<'_>,
244    parent_dir: &str,
245    name: &str,
246    kind: FsCreateKind,
247) -> Result<String, String> {
248    valid_name(name)?;
249    let f = open(r).await?;
250    let home = home_of(&f);
251    let dir = resolve(&home, parent_dir);
252    let target = join(&dir, name);
253    if protected(&home, &target) {
254        return Err(refusal(r.connection, &target));
255    }
256    match f.stat(&target).await {
257        Ok(_) => return Err(format!("\"{name}\" already exists here.")),
258        Err(e) if e.is_not_found() => {}
259        Err(e) => return Err(friendly(e, "create", name)),
260    }
261    let result = match kind {
262        FsCreateKind::Dir => f.mkdir(&target, false).await,
263        FsCreateKind::File => f.write(&target, Vec::new()).await,
264    }
265    .map_err(|e| friendly(e, "create", name));
266    tracing::info!(op = "fs.create", connection = %r.connection, path = %target,
267        outcome = if result.is_ok() { "ok" } else { "error" }, "remote fs mutation");
268    result.map(|()| target)
269}
270
271/// `fs.delete` on a host: PERMANENT, each path on its own. A link is
272/// removed, never followed (the helper's rule).
273pub async fn delete(r: Remote<'_>, paths: &[String]) -> Vec<FsOpResult> {
274    let f = match open(r).await {
275        Ok(f) => f,
276        Err(e) => {
277            return paths
278                .iter()
279                .map(|p| FsOpResult {
280                    path: p.clone(),
281                    ok: false,
282                    error: Some(e.clone()),
283                })
284                .collect()
285        }
286    };
287    let home = home_of(&f);
288    let mut out = Vec::with_capacity(paths.len());
289    for p in paths {
290        let target = resolve(&home, p);
291        let result = if protected(&home, &target) {
292            Err(refusal(r.connection, &target))
293        } else {
294            f.delete(&target, true)
295                .await
296                .map_err(|e| friendly(e, "delete", &target))
297        };
298        tracing::info!(op = "fs.delete", connection = %r.connection, path = %target,
299            outcome = if result.is_ok() { "ok" } else { "error" }, "remote fs mutation");
300        out.push(FsOpResult {
301            path: p.clone(),
302            ok: result.is_ok(),
303            error: result.err(),
304        });
305    }
306    out
307}
308
309/// The editor's largest file, as for a local one.
310pub const EDITOR_MAX: u64 = 10_000_000;
311
312/// The editor reading a file on a host: its bytes (at most [`EDITOR_MAX`]),
313/// and whether nobody may write it.
314pub async fn read_file(r: Remote<'_>, path: &str) -> Result<(Vec<u8>, bool), String> {
315    let f = open(r).await?;
316    let home = home_of(&f);
317    let target = resolve(&home, path);
318    let entry = f.stat(&target).await.map_err(|e| e.message)?;
319    if entry.kind == Kind::Dir {
320        return Err(format!("{target} is a folder"));
321    }
322    if entry.size > EDITOR_MAX {
323        return Err("File too large (>10MB)".to_string());
324    }
325    let bytes = f.read(&target, EDITOR_MAX).await.map_err(|e| e.message)?;
326    let read_only = entry.mode != 0 && entry.mode & 0o222 == 0;
327    Ok((bytes, read_only))
328}
329
330/// Whether the editor may write `path` on a host: as on this computer, only
331/// inside the user's home there, and never in a system folder.
332pub fn editor_may_write(home: &str, path: &str) -> bool {
333    home != "/"
334        && path.starts_with(&format!("{}/", home.trim_end_matches('/')))
335        && !protected(home, path)
336}
337
338/// The editor saving a file on a host, atomically there.
339pub async fn write_file(r: Remote<'_>, path: &str, bytes: Vec<u8>) -> Result<(), String> {
340    let f = open(r).await?;
341    let home = home_of(&f);
342    let target = resolve(&home, path);
343    if !editor_may_write(&home, &target) {
344        return Err(format!(
345            "writeeditorfile: {target} on {} is outside your home folder there",
346            r.connection
347        ));
348    }
349    let size = bytes.len();
350    let result = f
351        .write(&target, bytes)
352        .await
353        .map_err(|e| format!("writeeditorfile: {}", e.message));
354    tracing::info!(connection = %r.connection, path = %target, bytes = size,
355        outcome = if result.is_ok() { "ok" } else { "error" }, "remote editor file saved");
356    result
357}
358
359/// What `fs.trash` (and `fs.restore`) say for a remote path: there is no
360/// Trash there.
361pub fn no_trash(connection: &str) -> String {
362    format!("{connection} has no Trash: delete permanently instead.")
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    #[test]
370    fn paths_resolve_under_home_and_stay_tidy() {
371        let h = "/home/u";
372        assert_eq!(resolve(h, "~"), "/home/u");
373        assert_eq!(resolve(h, ""), "/home/u");
374        assert_eq!(resolve(h, "~/a/./b/"), "/home/u/a/b");
375        assert_eq!(resolve(h, "src"), "/home/u/src");
376        assert_eq!(resolve(h, "/etc/../srv//x"), "/srv/x");
377        assert_eq!(resolve(h, "/../.."), "/");
378        assert_eq!(parent("/home/u/a"), Some("/home/u"));
379        assert_eq!(parent("/a"), Some("/"));
380        assert_eq!(parent("/"), None);
381        assert_eq!(join("/", "a"), "/a");
382        assert_eq!(join("/home/u", "a"), "/home/u/a");
383    }
384
385    #[test]
386    fn system_folders_root_and_home_are_never_changed() {
387        let h = "/home/u";
388        for p in [
389            "/",
390            "/home/u",
391            "/etc",
392            "/etc/hosts",
393            "/usr/bin/x",
394            "/System/Library",
395            "/opt",
396        ] {
397            assert!(protected(h, p), "{p}");
398        }
399        for p in [
400            "/home/u/a",
401            "/srv/data",
402            "/tmp/x",
403            "/home/other/a",
404            "/etcetera",
405        ] {
406            assert!(!protected(h, p), "{p}");
407        }
408        // A home under a system folder: inside it is the user's, the home
409        // itself and the rest of /var still are not.
410        let h = "/var/home/u";
411        assert!(!protected(h, "/var/home/u/proj"));
412        assert!(protected(h, "/var/home/u"));
413        assert!(protected(h, "/var/log"));
414        assert!(protected(h, "/var/home/uv"));
415        // Home `/` (no $HOME on the host): system folders stay protected.
416        assert!(protected("/", "/etc"));
417        assert!(protected("/", "/"));
418        assert!(!protected("/", "/srv/data"));
419    }
420
421    #[test]
422    fn the_editor_writes_only_inside_home_on_a_host() {
423        assert!(editor_may_write("/home/u", "/home/u/src/main.rs"));
424        assert!(editor_may_write("/var/home/u", "/var/home/u/a"));
425        assert!(!editor_may_write("/home/u", "/home/u"));
426        assert!(!editor_may_write("/home/u", "/etc/hosts"));
427        assert!(!editor_may_write("/home/u", "/home/other/a"));
428        assert!(!editor_may_write("/", "/srv/a"));
429    }
430
431    #[test]
432    fn helper_entries_read_like_local_ones() {
433        let e = entry(Entry {
434            name: ".bashrc".into(),
435            kind: Kind::File,
436            size: 10,
437            mtime_ms: 1_700_000_000_000,
438            mode: 0o444,
439            symlink: true,
440            link_target: "dotfiles/bashrc".into(),
441        });
442        assert!(e.hidden && e.readonly && e.is_symlink && !e.is_dir);
443        assert_eq!((e.size, e.mtime), (Some(10), Some(1_700_000_000_000)));
444        assert_eq!(e.link_target.as_deref(), Some("dotfiles/bashrc"));
445        let d = entry(Entry {
446            name: "src".into(),
447            kind: Kind::Dir,
448            size: 4096,
449            mtime_ms: 0,
450            mode: 0,
451            symlink: false,
452            link_target: String::new(),
453        });
454        assert!(d.is_dir && !d.readonly && !d.hidden);
455        assert_eq!((d.size, d.mtime, d.link_target), (None, None, None));
456    }
457
458    /// Hangar's operations end to end against the real helper (in this
459    /// process, through the pool as over ssh). Linux: a host's paths are
460    /// POSIX, so the test home's must be, and macOS keeps temp folders under
461    /// /var, which is a system folder here.
462    #[cfg(target_os = "linux")]
463    #[tokio::test]
464    async fn hangar_lists_creates_renames_and_deletes_on_a_host() {
465        let home = tempfile::tempdir().unwrap();
466        let h = home.path().to_string_lossy().into_owned();
467        let conn = "fsremote-test-host";
468        files::connect_in_process(conn, home.path().to_path_buf()).await;
469        let r = Remote {
470            connection: conn,
471            block_id: None,
472            auth_key: "",
473        };
474
475        let dir = create(r, "~", "proj", FsCreateKind::Dir).await.unwrap();
476        assert_eq!(dir, format!("{h}/proj"));
477        let file = create(r, "~/proj", "a.txt", FsCreateKind::File)
478            .await
479            .unwrap();
480        assert!(create(r, "~/proj", "a.txt", FsCreateKind::File)
481            .await
482            .unwrap_err()
483            .contains("already exists"));
484        assert!(create(r, "~/proj", "../x", FsCreateKind::File)
485            .await
486            .is_err());
487
488        let listed = list(r, "proj/").await.unwrap();
489        assert_eq!(listed.path, format!("{h}/proj"));
490        assert_eq!(listed.entries.len(), 1);
491        assert_eq!(listed.entries[0].name, "a.txt");
492        assert_eq!(listed.entries[0].size, Some(0));
493
494        let moved = rename(r, &file, "b.txt").await.unwrap();
495        assert_eq!(moved, format!("{h}/proj/b.txt"));
496        let missing = list(r, "~/nope").await.unwrap();
497        assert_eq!(missing.error.unwrap().kind, FsErrorKind::NotFound);
498
499        // Home itself and the root are never changed.
500        assert!(rename(r, "~", "elsewhere")
501            .await
502            .unwrap_err()
503            .contains("doesn't change"));
504        let out = delete(r, &["~".into(), "/etc".into(), "~/proj".into()]).await;
505        assert_eq!(
506            out.iter().map(|o| o.ok).collect::<Vec<_>>(),
507            vec![false, false, true]
508        );
509        assert!(!home.path().join("proj").exists());
510        assert!(home.path().exists());
511
512        // The editor: saves inside home and reads it back, never outside it.
513        write_file(r, "~/doc.txt", b"hello".to_vec()).await.unwrap();
514        let (bytes, read_only) = read_file(r, "~/doc.txt").await.unwrap();
515        assert_eq!((bytes, read_only), (b"hello".to_vec(), false));
516        assert!(write_file(r, "/etc/x", b"x".to_vec())
517            .await
518            .unwrap_err()
519            .contains("outside your home"));
520        assert!(read_file(r, "~").await.unwrap_err().contains("folder"));
521    }
522
523    #[test]
524    fn only_ssh_connections_go_remote() {
525        assert_eq!(ssh_connection(Some("user@box")), Some("user@box"));
526        assert_eq!(ssh_connection(Some(" box:2222 ")), Some("box:2222"));
527        assert_eq!(ssh_connection(Some("local")), None);
528        assert_eq!(ssh_connection(Some("wsl://Ubuntu")), None);
529        assert_eq!(ssh_connection(Some("")), None);
530        assert_eq!(ssh_connection(None), None);
531    }
532}