Skip to main content

agentmux_srv/server/
browser_uploads.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Which files an agent may upload into a browser pane
5//! (docs/specs/SPEC_AGENT_DRIVEN_BROWSER_PANES_2026_10_07.md ยง5.3): only
6//! files inside its own workspace, so a page (or a tricked agent) can't use
7//! an upload field to carry off anything else on the machine.
8
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11use std::sync::{Mutex, OnceLock};
12
13
14/// At most this many files per upload.
15pub(crate) const MAX_UPLOAD_FILES: usize = 10;
16
17fn launch_workspaces() -> &'static Mutex<HashMap<String, PathBuf>> {
18    static W: OnceLock<Mutex<HashMap<String, PathBuf>>> = OnceLock::new();
19    W.get_or_init(Default::default)
20}
21
22/// Remember the workspace the agent on `block_id` was started in: the
23/// `AGENTMUX_AGENT_WORKDIR` srv just put in its spawn env (host agents only;
24/// a container agent has none). Called by every spawn path right after
25/// `agent_handlers::input::carry_agent_workdir_env`.
26///
27/// Uploads are checked against this record, not the block's `cmd:cwd`: any
28/// client with the instance auth key can rewrite that meta, so an agent
29/// could otherwise point its "workspace" at `/` and upload anything.
30pub(crate) fn record_launch_workspace(block_id: &str, env_vars: &HashMap<String, String>) {
31    let mut map = launch_workspaces().lock().unwrap_or_else(|p| p.into_inner());
32    match env_vars.get("AGENTMUX_AGENT_WORKDIR").filter(|w| !w.trim().is_empty()) {
33        Some(w) => {
34            map.insert(block_id.to_string(), PathBuf::from(w));
35        }
36        None => {
37            map.remove(block_id);
38        }
39    }
40}
41
42/// The workspace the agent on `block_id` was launched in, if srv started it
43/// as a host agent.
44pub(crate) fn agent_workspace(block_id: &str) -> Option<PathBuf> {
45    launch_workspaces().lock().unwrap_or_else(|p| p.into_inner()).get(block_id).cloned()
46}
47
48/// `canonicalize` without Windows' `\\?\` verbatim prefix, which the browser
49/// doesn't expect in a file path.
50fn canonical(p: &Path) -> std::io::Result<PathBuf> {
51    let c = std::fs::canonicalize(p)?;
52    let s = c.to_string_lossy();
53    Ok(match s.strip_prefix(r"\\?\") {
54        Some(rest) if !rest.starts_with("UNC\\") => PathBuf::from(rest),
55        _ => c,
56    })
57}
58
59/// Check `paths` (absolute, or relative to the workspace) and return their
60/// canonical forms: each must be an existing file inside `workspace` after
61/// resolving `..` and symlinks.
62pub(crate) fn check_upload_paths(workspace: &Path, paths: &[String]) -> Result<Vec<String>, String> {
63    if paths.is_empty() {
64        return Err("no files given".to_string());
65    }
66    if paths.len() > MAX_UPLOAD_FILES {
67        return Err(format!("at most {MAX_UPLOAD_FILES} files per upload"));
68    }
69    let ws = canonical(workspace)
70        .map_err(|e| format!("your workspace {} can't be read: {e}", workspace.display()))?;
71    let mut out = Vec::with_capacity(paths.len());
72    for p in paths {
73        let raw = Path::new(p.trim());
74        let joined = if raw.is_absolute() { raw.to_path_buf() } else { workspace.join(raw) };
75        let c = canonical(&joined).map_err(|_| format!("no such file: {p}"))?;
76        if !c.starts_with(&ws) {
77            return Err(format!(
78                "{p} is outside your workspace ({}); an agent can upload only files in its own workspace: copy the file there first",
79                ws.display()
80            ));
81        }
82        if !c.is_file() {
83            return Err(format!("{p} is not a file"));
84        }
85        out.push(c.to_string_lossy().into_owned());
86    }
87    Ok(out)
88}
89
90/// At most this many bytes in one upload, all files together. Bigger files
91/// are for the user to pick (`BrowserHandoff`).
92pub(crate) const MAX_UPLOAD_BYTES: u64 = 25 * 1024 * 1024;
93
94/// A file ready to hand to the page: its name, a content type from its
95/// extension, and its bytes.
96pub(crate) struct UploadFile {
97    pub name: String,
98    pub mime: &'static str,
99    pub bytes: Vec<u8>,
100}
101
102/// Read the files `check_upload_paths` approved. Each is opened once, and the
103/// check is repeated on the open handle: the file it really refers to must be
104/// a regular file inside the workspace. The bytes are read from that same
105/// handle and the page is given the bytes, never a path, so replacing a
106/// checked file with a link afterwards changes nothing.
107pub(crate) fn read_upload_files(workspace: &Path, checked: &[String]) -> Result<Vec<UploadFile>, String> {
108    use std::io::Read;
109    let ws = canonical(workspace).map_err(|e| format!("your workspace {} can't be read: {e}", workspace.display()))?;
110    let mut total: u64 = 0;
111    let mut out = Vec::with_capacity(checked.len());
112    for p in checked {
113        let file = std::fs::File::open(p).map_err(|_| format!("no such file: {p}"))?;
114        let real = handle_path(&file).map_err(|e| format!("can't tell where {p} is ({e}), so it isn't uploaded"))?;
115        if !real.starts_with(&ws) {
116            return Err(format!("{p} is outside your workspace ({})", ws.display()));
117        }
118        let meta = file.metadata().map_err(|e| format!("{p}: {e}"))?;
119        if !meta.is_file() {
120            return Err(format!("{p} is not a file"));
121        }
122        total += meta.len();
123        if total > MAX_UPLOAD_BYTES {
124            return Err(format!(
125                "uploads are limited to {} MB in all; for bigger files, ask the user to pick them (BrowserHandoff)",
126                MAX_UPLOAD_BYTES / (1024 * 1024)
127            ));
128        }
129        let mut bytes = Vec::with_capacity(meta.len() as usize);
130        file.take(MAX_UPLOAD_BYTES + 1).read_to_end(&mut bytes).map_err(|e| format!("{p}: {e}"))?;
131        if bytes.len() as u64 != meta.len() {
132            return Err(format!("{p} changed while it was being read; try again"));
133        }
134        let name = real.file_name().and_then(|n| n.to_str()).unwrap_or("file").to_string();
135        out.push(UploadFile { mime: mime_for(&name), name, bytes });
136    }
137    Ok(out)
138}
139
140/// The path the open file really is, after every link: what the OS says
141/// about the handle, not a lookup of the name.
142#[cfg(windows)]
143fn handle_path(file: &std::fs::File) -> std::io::Result<PathBuf> {
144    use std::os::windows::ffi::OsStringExt;
145    use std::os::windows::io::AsRawHandle;
146    use windows_sys::Win32::Storage::FileSystem::{GetFinalPathNameByHandleW, FILE_NAME_NORMALIZED, VOLUME_NAME_DOS};
147    let mut buf = vec![0u16; 1024];
148    loop {
149        let n = unsafe {
150            GetFinalPathNameByHandleW(
151                file.as_raw_handle() as _,
152                buf.as_mut_ptr(),
153                buf.len() as u32,
154                FILE_NAME_NORMALIZED | VOLUME_NAME_DOS,
155            )
156        } as usize;
157        if n == 0 {
158            return Err(std::io::Error::last_os_error());
159        }
160        if n < buf.len() {
161            let p = PathBuf::from(std::ffi::OsString::from_wide(&buf[..n]));
162            let s = p.to_string_lossy();
163            return Ok(match s.strip_prefix(r"\\?\") {
164                Some(rest) if !rest.starts_with("UNC\\") => PathBuf::from(rest),
165                _ => p,
166            });
167        }
168        buf.resize(n + 1, 0);
169    }
170}
171
172#[cfg(target_os = "linux")]
173fn handle_path(file: &std::fs::File) -> std::io::Result<PathBuf> {
174    use std::os::fd::AsRawFd;
175    std::fs::read_link(format!("/proc/self/fd/{}", file.as_raw_fd()))
176}
177
178#[cfg(target_os = "macos")]
179fn handle_path(file: &std::fs::File) -> std::io::Result<PathBuf> {
180    use std::os::fd::AsRawFd;
181    use std::os::unix::ffi::OsStrExt;
182    let mut buf = vec![0u8; libc::PATH_MAX as usize];
183    if unsafe { libc::fcntl(file.as_raw_fd(), libc::F_GETPATH, buf.as_mut_ptr()) } == -1 {
184        return Err(std::io::Error::last_os_error());
185    }
186    let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
187    Ok(PathBuf::from(std::ffi::OsStr::from_bytes(&buf[..end])))
188}
189
190#[cfg(not(any(windows, target_os = "linux", target_os = "macos")))]
191fn handle_path(_file: &std::fs::File) -> std::io::Result<PathBuf> {
192    Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "not supported on this platform"))
193}
194
195/// A content type for the file, from its extension, as a browser's own file
196/// picker would give it. An extension not known here gets no type at all
197/// (empty), as the picker gives one it doesn't know, rather than a forced
198/// generic one a page might reject.
199fn mime_for(name: &str) -> &'static str {
200    let ext = name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()).unwrap_or_default();
201    match ext.as_str() {
202        "txt" | "log" => "text/plain",
203        "md" => "text/markdown",
204        "csv" => "text/csv",
205        "tsv" => "text/tab-separated-values",
206        "htm" | "html" => "text/html",
207        "css" => "text/css",
208        "js" | "mjs" => "text/javascript",
209        "rtf" => "application/rtf",
210        "json" => "application/json",
211        "xml" => "text/xml",
212        "pdf" => "application/pdf",
213        "zip" => "application/zip",
214        "gz" | "tgz" => "application/gzip",
215        "tar" => "application/x-tar",
216        "7z" => "application/x-7z-compressed",
217        "rar" => "application/vnd.rar",
218        "png" => "image/png",
219        "jpg" | "jpeg" => "image/jpeg",
220        "gif" => "image/gif",
221        "webp" => "image/webp",
222        "bmp" => "image/bmp",
223        "ico" => "image/x-icon",
224        "tif" | "tiff" => "image/tiff",
225        "heic" => "image/heic",
226        "avif" => "image/avif",
227        "svg" => "image/svg+xml",
228        "mp4" | "m4v" => "video/mp4",
229        "mov" => "video/quicktime",
230        "webm" => "video/webm",
231        "avi" => "video/x-msvideo",
232        "mkv" => "video/x-matroska",
233        "mpeg" | "mpg" => "video/mpeg",
234        "mp3" => "audio/mpeg",
235        "m4a" => "audio/mp4",
236        "wav" => "audio/wav",
237        "ogg" => "audio/ogg",
238        "flac" => "audio/flac",
239        "doc" => "application/msword",
240        "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
241        "xls" => "application/vnd.ms-excel",
242        "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
243        "ppt" => "application/vnd.ms-powerpoint",
244        "pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
245        "odt" => "application/vnd.oasis.opendocument.text",
246        "ods" => "application/vnd.oasis.opendocument.spreadsheet",
247        "odp" => "application/vnd.oasis.opendocument.presentation",
248        "exe" | "dll" => "application/x-msdownload",
249        "msi" => "application/x-msi",
250        "apk" => "application/vnd.android.package-archive",
251        _ => "",
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use crate::backend::obj::MetaMapType;
259    use serde_json::json;
260
261    fn ws() -> (tempfile::TempDir, PathBuf) {
262        let dir = tempfile::tempdir().unwrap();
263        let ws = dir.path().join("agent-ws");
264        std::fs::create_dir_all(ws.join("sub")).unwrap();
265        std::fs::write(ws.join("report.txt"), "x").unwrap();
266        std::fs::write(ws.join("sub").join("a.bin"), "y").unwrap();
267        std::fs::write(dir.path().join("secret.txt"), "z").unwrap();
268        (dir, ws)
269    }
270
271    #[test]
272    fn files_inside_the_workspace_are_allowed_absolute_or_relative() {
273        let (_d, ws) = ws();
274        let got = check_upload_paths(&ws, &["report.txt".into(), ws.join("sub").join("a.bin").to_string_lossy().into_owned()]).unwrap();
275        assert_eq!(got.len(), 2);
276        assert!(got[0].ends_with("report.txt") && !got[0].starts_with(r"\\?\"), "{got:?}");
277    }
278
279    #[test]
280    fn a_file_outside_the_workspace_is_refused_including_through_dotdot() {
281        let (d, ws) = ws();
282        let e = check_upload_paths(&ws, &[d.path().join("secret.txt").to_string_lossy().into_owned()]).unwrap_err();
283        assert!(e.contains("outside your workspace"), "{e}");
284        let e = check_upload_paths(&ws, &["../secret.txt".into()]).unwrap_err();
285        assert!(e.contains("outside your workspace"), "{e}");
286    }
287
288    #[test]
289    fn missing_files_directories_and_too_many_are_refused() {
290        let (_d, ws) = ws();
291        assert!(check_upload_paths(&ws, &["nope.txt".into()]).unwrap_err().contains("no such file"));
292        assert!(check_upload_paths(&ws, &["sub".into()]).unwrap_err().contains("not a file"));
293        assert!(check_upload_paths(&ws, &[]).is_err());
294        let many: Vec<String> = (0..11).map(|_| "report.txt".to_string()).collect();
295        assert!(check_upload_paths(&ws, &many).unwrap_err().contains("at most"));
296    }
297
298    #[test]
299    fn the_workspace_is_what_srv_launched_the_agent_in() {
300        // Through the real spawn-env helper: host agents get one, a
301        // container agent (or no launch directory) gets none.
302        let spawn = |pairs: &[(&str, &str)]| {
303            let mut m = MetaMapType::new();
304            for (k, v) in pairs {
305                m.insert(k.to_string(), json!(v));
306            }
307            let mut env = HashMap::new();
308            crate::server::agent_handlers::input::carry_agent_workdir_env(&mut env, &m);
309            env
310        };
311        record_launch_workspace("ws-test-a", &spawn(&[("cmd:cwd", "C:/work/agent")]));
312        assert!(agent_workspace("ws-test-a").is_some());
313        record_launch_workspace("ws-test-a", &spawn(&[("cmd:cwd", "C:/work/agent"), ("agentMode", "container")]));
314        assert_eq!(agent_workspace("ws-test-a"), None);
315        assert_eq!(agent_workspace("ws-test-never-launched"), None);
316    }
317
318    #[test]
319    fn checked_files_are_read_from_the_handle_with_a_content_type() {
320        let (_d, ws) = ws();
321        let checked = check_upload_paths(&ws, &["report.txt".into(), "sub/a.bin".into()]).unwrap();
322        let files = read_upload_files(&ws, &checked).unwrap();
323        assert_eq!(files[0].name, "report.txt");
324        assert_eq!(files[0].bytes, b"x");
325        assert_eq!(files[0].mime, "text/plain");
326        // Unknown to the table: no type, as a browser's picker gives it.
327        assert_eq!(files[1].mime, "");
328        assert_eq!(mime_for("Deck.PPTX"), "application/vnd.openxmlformats-officedocument.presentationml.presentation");
329        assert_eq!(mime_for("clip.mov"), "video/quicktime");
330    }
331
332    #[test]
333    fn the_handle_check_refuses_a_file_outside_the_workspace_on_its_own() {
334        // As if the path had been swapped after check_upload_paths: hand the
335        // reader an outside file directly. The open handle, not the name, has
336        // to stop it.
337        let (d, ws) = ws();
338        let outside = d.path().join("secret.txt").to_string_lossy().into_owned();
339        let e = read_upload_files(&ws, &[outside]).err().expect("refused");
340        assert!(e.contains("outside your workspace"), "{e}");
341    }
342
343    #[cfg(unix)]
344    #[test]
345    fn a_link_swapped_in_after_the_check_is_caught_by_the_handle() {
346        let (d, ws) = ws();
347        let checked = check_upload_paths(&ws, &["report.txt".into()]).unwrap();
348        std::fs::remove_file(ws.join("report.txt")).unwrap();
349        std::os::unix::fs::symlink(d.path().join("secret.txt"), ws.join("report.txt")).unwrap();
350        let e = read_upload_files(&ws, &checked).err().expect("refused");
351        assert!(e.contains("outside your workspace"), "{e}");
352    }
353
354    #[test]
355    fn uploads_over_the_size_limit_are_refused() {
356        let (_d, ws) = ws();
357        let big = ws.join("big.bin");
358        let f = std::fs::File::create(&big).unwrap();
359        f.set_len(MAX_UPLOAD_BYTES + 1).unwrap();
360        let checked = check_upload_paths(&ws, &["big.bin".into()]).unwrap();
361        let e = read_upload_files(&ws, &checked).err().expect("refused");
362        assert!(e.contains("limited to"), "{e}");
363    }
364}