1use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11use std::sync::{Mutex, OnceLock};
12
13
14pub(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
22pub(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
42pub(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
48fn 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
59pub(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
90pub(crate) const MAX_UPLOAD_BYTES: u64 = 25 * 1024 * 1024;
93
94pub(crate) struct UploadFile {
97 pub name: String,
98 pub mime: &'static str,
99 pub bytes: Vec<u8>,
100}
101
102pub(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#[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
195fn 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 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 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 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}