1use std::sync::Arc;
5
6use crate::backend::rpc::engine::WshRpcEngine;
7use crate::backend::rpc_types::{
8 COMMAND_WRITE_AGENT_CONFIG,
9 CommandWriteAgentConfigData,
10};
11use crate::backend::base::expand_home_dir_safe;
12use crate::backend::storage::store::Store;
13
14use super::AppState;
15
16static SCRATCH_SESSION_TOKEN: std::sync::OnceLock<String> = std::sync::OnceLock::new();
20
21fn scratch_session_token() -> &'static str {
22 SCRATCH_SESSION_TOKEN.get_or_init(|| uuid::Uuid::new_v4().to_string())
23}
24
25#[cfg(target_os = "windows")]
33fn list_drives() -> Vec<serde_json::Value> {
34 let mut drives = Vec::new();
35 for letter in b'A'..=b'Z' {
36 let path = format!("{}:\\", letter as char);
37 if std::path::Path::new(&path).exists() {
38 drives.push(serde_json::json!({
39 "name": format!("{}:", letter as char),
40 "path": path,
41 }));
42 }
43 }
44 drives
45}
46
47#[cfg(target_os = "macos")]
55fn list_drives() -> Vec<serde_json::Value> {
56 Vec::new()
57}
58
59#[cfg(all(not(target_os = "windows"), not(target_os = "macos")))]
61fn list_drives() -> Vec<serde_json::Value> {
62 let mut drives = vec![serde_json::json!({ "name": "/", "path": "/" })];
63 for mount_dir in ["/mnt", "/media", "/Volumes"] {
64 if let Ok(entries) = std::fs::read_dir(mount_dir) {
65 for entry in entries.flatten() {
66 let path = entry.path();
67 if path.is_dir() {
68 drives.push(serde_json::json!({
69 "name": entry.file_name().to_string_lossy().to_string(),
70 "path": path.to_string_lossy().to_string(),
71 }));
72 }
73 }
74 }
75 }
76 drives
77}
78
79fn inject_global_bundles(claude_md: &str, wstore: &Arc<Store>) -> String {
84 let bundles = wstore.bundle_memory_list_global().unwrap_or_default();
85 let bundle_block = crate::backend::storage::format_global_brain_block(&bundles);
86 if bundle_block.is_empty() {
87 return claude_md.to_string();
88 }
89
90 if let Some(pos) = claude_md.find("\n# Memory\n") {
92 let insert_at = pos + "\n# Memory\n".len();
93 let (before, after) = claude_md.split_at(insert_at);
94 format!("{}{}\n\n---\n\n{}", before, bundle_block, after)
95 } else {
96 let anchor = "\n# Available Skills\n";
98 if let Some(pos) = claude_md.find(anchor) {
99 let (before, after) = claude_md.split_at(pos);
100 format!("{}\n# Memory\n{}{}", before, bundle_block, after)
101 } else {
102 format!("{}\n# Memory\n{}\n", claude_md, bundle_block)
103 }
104 }
105}
106
107pub fn register_editor_handlers(engine: &Arc<WshRpcEngine>, state: &AppState) {
108 let wstore = state.wstore.clone();
109
110 engine.register_handler(
112 COMMAND_WRITE_AGENT_CONFIG,
113 Box::new(move |data, _ctx| {
114 let wstore = wstore.clone();
115 Box::pin(async move {
116 let cmd: CommandWriteAgentConfigData = serde_json::from_value(data)
117 .map_err(|e| format!("writeagentconfig: {e}"))?;
118 tracing::info!(
119 working_dir = %cmd.working_dir,
120 file_count = cmd.files.len(),
121 auto_allocate = cmd.auto_allocate,
122 "WriteAgentConfig"
123 );
124
125 let expanded_working_dir = expand_home_dir_safe(&cmd.working_dir);
131 let final_working_dir = if cmd.auto_allocate {
132 let desired = expanded_working_dir.to_string_lossy().to_string();
133 crate::server::app_api::allocate_agent_workdir(&desired)?
134 } else {
135 let p = expanded_working_dir.as_path();
136 if !p.exists() {
137 std::fs::create_dir_all(p)
138 .map_err(|e| format!("failed to create working dir: {e}"))?;
139 }
140 expanded_working_dir.to_string_lossy().to_string()
141 };
142 let base_path = std::path::Path::new(&final_working_dir);
143 let canonical_base = base_path.canonicalize().map_err(|e| {
149 format!("failed to canonicalize working dir {}: {e}", base_path.display())
150 })?;
151
152 for file in &cmd.files {
153 let file_path = crate::backend::base::safe_join_within_base(
158 base_path,
159 &file.path,
160 )
161 .map_err(|e| format!("path traversal denied: {} ({e})", file.path))?;
162 crate::backend::base::verify_no_symlink_escape(&file_path, &canonical_base)
167 .map_err(|e| format!("path traversal denied: {} ({e})", file.path))?;
168 let content = if file.path == "CLAUDE.md" {
172 inject_global_bundles(&file.content, &wstore)
173 } else {
174 file.content.clone()
175 };
176 if let Some(parent) = file_path.parent() {
178 if !parent.exists() {
179 std::fs::create_dir_all(parent)
180 .map_err(|e| format!("failed to create dir for {}: {e}", file.path))?;
181 }
182 }
183 std::fs::write(&file_path, &content)
184 .map_err(|e| format!("failed to write {}: {e}", file.path))?;
185 tracing::debug!(path = %file_path.display(), "wrote config file");
186 }
187
188 Ok(Some(serde_json::json!({
191 "working_dir": final_working_dir,
192 })))
193 })
194 }),
195 );
196
197 engine.register_handler(
199 "readeditorfile",
200 Box::new(|data, _ctx| {
201 Box::pin(async move {
202 #[derive(serde::Deserialize)]
203 struct Cmd { path: String }
204 let cmd: Cmd = serde_json::from_value(data)
205 .map_err(|e| format!("readeditorfile: {e}"))?;
206 let expanded = expand_home_dir_safe(&cmd.path);
207 let path = expanded.as_path();
208
209 let metadata = std::fs::metadata(path)
211 .map_err(|e| format!("readeditorfile: {e}"))?;
212 if metadata.len() > 10_000_000 {
213 return Err("File too large (>10MB)".to_string());
214 }
215
216 let bytes = std::fs::read(path)
223 .map_err(|e| format!("readeditorfile: {e}"))?;
224 let decoded = crate::backend::text_encoding::decode_file(&bytes);
225 let read_only = metadata.permissions().readonly();
226
227 Ok(Some(serde_json::json!({
228 "content": decoded.content,
229 "encoding": decoded.encoding,
230 "bom": decoded.bom,
231 "line_ending": decoded.line_ending,
232 "had_decode_errors": decoded.had_decode_errors,
233 "read_only": read_only,
234 })))
235 })
236 }),
237 );
238
239 engine.register_handler(
241 "writeeditorfile",
242 Box::new(|data, _ctx| {
243 Box::pin(async move {
244 #[derive(serde::Deserialize)]
245 struct Cmd {
246 path: String,
247 content: String,
248 #[serde(default)]
251 encoding: Option<String>,
252 #[serde(default)]
253 bom: Option<String>,
254 #[serde(default)]
255 line_ending: Option<String>,
256 }
257 let cmd: Cmd = serde_json::from_value(data)
258 .map_err(|e| format!("writeeditorfile: {e}"))?;
259
260 if cmd.content.len() > 10_000_000 {
262 return Err("Content too large (>10MB)".to_string());
263 }
264
265 let expanded = expand_home_dir_safe(&cmd.path);
266 let path = expanded.as_path();
267
268 let home = dirs::home_dir()
271 .ok_or("writeeditorfile: cannot determine home directory")?;
272 let canonical_home = home.canonicalize()
273 .map_err(|e| format!("writeeditorfile: home dir: {e}"))?;
274
275 let canonical = path.canonicalize().or_else(|_| {
277 path.parent()
278 .and_then(|p| p.canonicalize().ok())
279 .map(|p| p.join(path.file_name().unwrap_or_default()))
280 .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "invalid path"))
281 }).map_err(|e| format!("writeeditorfile: {e}"))?;
282
283 if !canonical.starts_with(&canonical_home) {
284 return Err(format!(
285 "writeeditorfile: path {} is outside home directory",
286 canonical.display()
287 ));
288 }
289
290 let (out_bytes, had_unmappable) = crate::backend::text_encoding::encode_file(
294 &cmd.content,
295 cmd.encoding.as_deref().unwrap_or(""),
296 cmd.bom.as_deref().unwrap_or("none"),
297 cmd.line_ending.as_deref().unwrap_or("lf"),
298 );
299 if had_unmappable {
305 let enc = cmd
306 .encoding
307 .as_deref()
308 .filter(|s| !s.is_empty())
309 .unwrap_or("its current encoding");
310 return Err(format!(
311 "writeeditorfile: file contains characters that can't be represented in {enc} — save it as UTF-8 instead"
312 ));
313 }
314 std::fs::write(&canonical, &out_bytes)
315 .map_err(|e| format!("writeeditorfile: {e}"))?;
316 tracing::info!(path = %canonical.display(), bytes = out_bytes.len(), "editor file saved");
317
318 Ok(None)
319 })
320 }),
321 );
322
323 engine.register_handler(
328 "listeditordir",
329 Box::new(|data, _ctx| {
330 Box::pin(async move {
331 #[derive(serde::Deserialize)]
332 struct Cmd { path: String }
333 let cmd: Cmd = serde_json::from_value(data)
334 .map_err(|e| format!("listeditordir: {e}"))?;
335 let expanded = expand_home_dir_safe(&cmd.path);
336 let canonical = expanded.canonicalize()
337 .map_err(|e| format!("listeditordir: {e}"))?;
338 let read_dir = std::fs::read_dir(&canonical)
339 .map_err(|e| format!("listeditordir: {e}"))?;
340
341 let mut entries: Vec<serde_json::Value> = Vec::new();
342 for entry in read_dir.flatten() {
343 let name = entry.file_name().to_string_lossy().to_string();
344 let is_symlink = entry
351 .file_type()
352 .map(|t| t.is_symlink())
353 .unwrap_or(false);
354 let metadata = entry.metadata().ok();
355 let is_dir = metadata.as_ref().map(|m| m.is_dir()).unwrap_or(false);
356 let size = metadata
357 .as_ref()
358 .and_then(|m| if !m.is_dir() { Some(m.len()) } else { None });
359 let mtime = metadata
360 .as_ref()
361 .and_then(|m| m.modified().ok())
362 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
363 .map(|d| d.as_millis() as u64);
364
365 let mut entry_obj = serde_json::json!({
366 "name": name,
367 "is_dir": is_dir,
368 "is_symlink": is_symlink,
369 });
370 if let Some(s) = size {
371 entry_obj["size"] = serde_json::json!(s);
372 }
373 if let Some(m) = mtime {
374 entry_obj["mtime"] = serde_json::json!(m);
375 }
376 entries.push(entry_obj);
377 }
378
379 entries.sort_by(|a, b| {
381 let a_dir = a["is_dir"].as_bool().unwrap_or(false);
382 let b_dir = b["is_dir"].as_bool().unwrap_or(false);
383 let a_name = a["name"].as_str().unwrap_or("").to_lowercase();
384 let b_name = b["name"].as_str().unwrap_or("").to_lowercase();
385 match (a_dir, b_dir) {
386 (true, false) => std::cmp::Ordering::Less,
387 (false, true) => std::cmp::Ordering::Greater,
388 _ => a_name.cmp(&b_name),
389 }
390 });
391
392 Ok(Some(serde_json::json!({
393 "path": canonical.to_string_lossy(),
394 "entries": entries,
395 })))
396 })
397 }),
398 );
399
400 engine.register_handler(
402 "geteditorhome",
403 Box::new(|_data, _ctx| {
404 Box::pin(async move {
405 let home = dirs::home_dir()
406 .ok_or_else(|| "geteditorhome: cannot determine home directory".to_string())?;
407 Ok(Some(serde_json::json!({
408 "home": home.to_string_lossy(),
409 })))
410 })
411 }),
412 );
413
414 engine.register_handler(
419 "geteditorroots",
420 Box::new(|_data, _ctx| {
421 Box::pin(async move {
422 let home = dirs::home_dir()
423 .ok_or_else(|| "geteditorroots: cannot determine home directory".to_string())?;
424 let drives = list_drives();
425 Ok(Some(serde_json::json!({
426 "home": home.to_string_lossy(),
427 "drives": drives,
428 })))
429 })
430 }),
431 );
432
433 engine.register_handler(
440 "openinshell",
441 Box::new(|data, _ctx| {
442 Box::pin(async move {
443 #[derive(serde::Deserialize)]
444 struct Cmd { path: String }
445 let cmd: Cmd = serde_json::from_value(data)
446 .map_err(|e| format!("openinshell: {e}"))?;
447 let expanded = expand_home_dir_safe(&cmd.path);
448 let path = expanded.as_path();
449 let home = dirs::home_dir()
450 .ok_or("openinshell: cannot determine home directory")?;
451 let canonical_home = home.canonicalize()
452 .map_err(|e| format!("openinshell: home: {e}"))?;
453 let canonical = path.canonicalize()
454 .map_err(|e| format!("openinshell: {e}"))?;
455 if !canonical.starts_with(&canonical_home) {
456 return Err(format!("openinshell: path outside home directory"));
457 }
458 #[cfg(target_os = "windows")]
459 { let _ = std::process::Command::new("explorer.exe").arg(format!("/select,{}", canonical.display())).spawn(); }
460 #[cfg(target_os = "macos")]
461 { let _ = std::process::Command::new("open").arg("-R").arg(&canonical).spawn(); }
462 #[cfg(target_os = "linux")]
463 {
464 let target = if canonical.is_dir() { canonical.clone() } else { canonical.parent().unwrap_or(&canonical).to_path_buf() };
465 let _ = std::process::Command::new("xdg-open").arg(&target).spawn();
466 }
467 Ok(None)
468 })
469 }),
470 );
471
472 engine.register_handler(
474 "renameeditorfile",
475 Box::new(|data, _ctx| {
476 Box::pin(async move {
477 #[derive(serde::Deserialize)]
478 struct Cmd { old_path: String, new_name: String }
479 let cmd: Cmd = serde_json::from_value(data)
480 .map_err(|e| format!("renameeditorfile: {e}"))?;
481 let expanded = expand_home_dir_safe(&cmd.old_path);
482 let old_path = expanded.as_path();
483 let home = dirs::home_dir().ok_or("renameeditorfile: cannot determine home")?;
484 let canonical_home = home.canonicalize().map_err(|e| format!("renameeditorfile: home: {e}"))?;
485 let canonical_old = old_path.canonicalize().map_err(|e| format!("renameeditorfile: {e}"))?;
486 if !canonical_old.starts_with(&canonical_home) || canonical_old == canonical_home {
487 return Err("renameeditorfile: path outside or is the home directory".to_string());
488 }
489 if cmd.new_name.contains('/') || cmd.new_name.contains('\\') || cmd.new_name.contains('\0') || cmd.new_name == ".." || cmd.new_name == "." || cmd.new_name.is_empty() {
490 return Err("renameeditorfile: new_name must be a plain filename".to_string());
491 }
492 let new_path = canonical_old.parent()
493 .ok_or("renameeditorfile: no parent directory")?
494 .join(&cmd.new_name);
495 if new_path.exists() {
496 return Err(format!("renameeditorfile: destination already exists"));
497 }
498 std::fs::rename(&canonical_old, &new_path)
499 .map_err(|e| format!("renameeditorfile: {e}"))?;
500 Ok(Some(serde_json::json!({ "new_path": new_path.to_string_lossy() })))
501 })
502 }),
503 );
504
505 engine.register_handler(
507 "createeditorfile",
508 Box::new(|data, _ctx| {
509 Box::pin(async move {
510 #[derive(serde::Deserialize)]
511 struct Cmd { parent_path: String, name: String }
512 let cmd: Cmd = serde_json::from_value(data)
513 .map_err(|e| format!("createeditorfile: {e}"))?;
514 let expanded = expand_home_dir_safe(&cmd.parent_path);
515 let parent = expanded.as_path();
516 let home = dirs::home_dir().ok_or("createeditorfile: cannot determine home")?;
517 let canonical_home = home.canonicalize().map_err(|e| format!("createeditorfile: home: {e}"))?;
518 let canonical_parent = parent.canonicalize().map_err(|e| format!("createeditorfile: {e}"))?;
519 if !canonical_parent.starts_with(&canonical_home) {
520 return Err("createeditorfile: path outside home directory".to_string());
521 }
522 if cmd.name.contains('/') || cmd.name.contains('\\') || cmd.name.contains('\0') || cmd.name == "." || cmd.name == ".." || cmd.name.is_empty() {
523 return Err("createeditorfile: name must be a plain filename".to_string());
524 }
525 let file_path = canonical_parent.join(&cmd.name);
526 if file_path.exists() {
527 return Err("createeditorfile: file already exists".to_string());
528 }
529 std::fs::write(&file_path, "").map_err(|e| format!("createeditorfile: {e}"))?;
530 Ok(Some(serde_json::json!({ "file_path": file_path.to_string_lossy() })))
531 })
532 }),
533 );
534
535 engine.register_handler(
537 "createeditordir",
538 Box::new(|data, _ctx| {
539 Box::pin(async move {
540 #[derive(serde::Deserialize)]
541 struct Cmd { parent_path: String, name: String }
542 let cmd: Cmd = serde_json::from_value(data)
543 .map_err(|e| format!("createeditordir: {e}"))?;
544 let expanded = expand_home_dir_safe(&cmd.parent_path);
545 let parent = expanded.as_path();
546 let home = dirs::home_dir().ok_or("createeditordir: cannot determine home")?;
547 let canonical_home = home.canonicalize().map_err(|e| format!("createeditordir: home: {e}"))?;
548 let canonical_parent = parent.canonicalize().map_err(|e| format!("createeditordir: {e}"))?;
549 if !canonical_parent.starts_with(&canonical_home) {
550 return Err("createeditordir: path outside home directory".to_string());
551 }
552 if cmd.name.contains('/') || cmd.name.contains('\\') || cmd.name.contains('\0') || cmd.name == "." || cmd.name == ".." || cmd.name.is_empty() {
553 return Err("createeditordir: name must be a plain name".to_string());
554 }
555 let dir_path = canonical_parent.join(&cmd.name);
556 if dir_path.exists() {
557 return Err("createeditordir: already exists".to_string());
558 }
559 std::fs::create_dir(&dir_path).map_err(|e| format!("createeditordir: {e}"))?;
560 Ok(Some(serde_json::json!({ "dir_path": dir_path.to_string_lossy() })))
561 })
562 }),
563 );
564
565 engine.register_handler(
567 "deleteeditorfile",
568 Box::new(|data, _ctx| {
569 Box::pin(async move {
570 #[derive(serde::Deserialize)]
571 struct Cmd { path: String, recursive: bool }
572 let cmd: Cmd = serde_json::from_value(data)
573 .map_err(|e| format!("deleteeditorfile: {e}"))?;
574 let expanded = expand_home_dir_safe(&cmd.path);
575 let path = expanded.as_path();
576 let home = dirs::home_dir().ok_or("deleteeditorfile: cannot determine home")?;
577 let canonical_home = home.canonicalize().map_err(|e| format!("deleteeditorfile: home: {e}"))?;
578 let canonical = path.canonicalize().map_err(|e| format!("deleteeditorfile: {e}"))?;
579 if !canonical.starts_with(&canonical_home) || canonical == canonical_home {
580 return Err("deleteeditorfile: path outside or is the home directory".to_string());
581 }
582 let meta = std::fs::symlink_metadata(path)
586 .map_err(|e| format!("deleteeditorfile: {e}"))?;
587 if meta.file_type().is_symlink() {
588 #[cfg(windows)]
591 if path.is_dir() {
592 std::fs::remove_dir(path).map_err(|e| format!("deleteeditorfile: {e}"))?;
593 } else {
594 std::fs::remove_file(path).map_err(|e| format!("deleteeditorfile: {e}"))?;
595 }
596 #[cfg(not(windows))]
597 std::fs::remove_file(path).map_err(|e| format!("deleteeditorfile: {e}"))?;
598 } else if canonical.is_dir() {
599 if cmd.recursive {
600 std::fs::remove_dir_all(&canonical).map_err(|e| format!("deleteeditorfile: {e}"))?;
601 } else {
602 std::fs::remove_dir(&canonical).map_err(|e| format!("deleteeditorfile: directory not empty: {e}"))?;
603 }
604 } else {
605 std::fs::remove_file(&canonical).map_err(|e| format!("deleteeditorfile: {e}"))?;
606 }
607 Ok(None)
608 })
609 }),
610 );
611
612 engine.register_handler(
617 "createscratchfile",
618 Box::new(|data, _ctx| {
619 Box::pin(async move {
620 #[derive(serde::Deserialize)]
621 struct Cmd { display_name: Option<String>, exclude_scratch_ids: Option<Vec<String>> }
622 let cmd: Cmd = serde_json::from_value(data)
623 .map_err(|e| format!("createscratchfile: {e}"))?;
624 let home = dirs::home_dir()
625 .ok_or("createscratchfile: cannot determine home directory")?;
626 let scratch_dir = home.join(".agentmux").join("cache").join("scratch");
627 std::fs::create_dir_all(&scratch_dir)
628 .map_err(|e| format!("createscratchfile: create dir: {e}"))?;
629 let now_ms = std::time::SystemTime::now()
630 .duration_since(std::time::UNIX_EPOCH)
631 .unwrap_or_default()
632 .as_millis() as u64;
633 const THIRTY_DAYS_MS: u64 = 30 * 24 * 60 * 60 * 1000;
634 let session_token = scratch_session_token();
635
636 let mut candidates: Vec<(u64, String, String)> = Vec::new(); if let Ok(entries) = std::fs::read_dir(&scratch_dir) {
640 for entry in entries.flatten() {
641 let meta_path = entry.path();
642 let fname = meta_path
643 .file_name()
644 .and_then(|f| f.to_str())
645 .unwrap_or("")
646 .to_string();
647 if !fname.ends_with(".md.meta") {
648 continue;
649 }
650 let Ok(content) = std::fs::read_to_string(&meta_path) else { continue };
651 let Ok(meta_json) = serde_json::from_str::<serde_json::Value>(&content) else { continue };
652 if meta_json.get("saved_to").map_or(false, |v| !v.is_null()) {
653 continue;
654 }
655 let created_at = meta_json.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0);
656 let sid = meta_json.get("scratch_id").and_then(|v| v.as_str()).unwrap_or("").to_string();
657 if created_at == 0 || sid.is_empty() {
658 continue;
659 }
660 if cmd.exclude_scratch_ids.as_deref().map_or(false, |ex| ex.iter().any(|e| e == &sid)) {
661 continue;
662 }
663 if now_ms.saturating_sub(created_at) > THIRTY_DAYS_MS {
664 let _ = std::fs::remove_file(scratch_dir.join(format!("{}.md", sid)));
666 let _ = std::fs::remove_file(&meta_path);
667 let _ = std::fs::remove_file(scratch_dir.join(format!("{}.md.claim", sid)));
668 continue;
669 }
670 let dname = meta_json
671 .get("display_name")
672 .and_then(|v| v.as_str())
673 .unwrap_or("Untitled")
674 .to_string();
675 candidates.push((created_at, sid, dname));
676 }
677 }
678
679 candidates.sort_by(|a, b| b.0.cmp(&a.0)); let mut chosen: Option<(String, String)> = None;
686 for (_, sid, dname) in &candidates {
687 let claim_path = scratch_dir.join(format!("{}.md.claim", sid));
688 if claim_path.exists() {
690 if let Ok(tok) = std::fs::read_to_string(&claim_path) {
691 if tok.trim() == session_token {
692 continue;
694 }
695 }
696 let _ = std::fs::remove_file(&claim_path);
698 }
699 match std::fs::OpenOptions::new().write(true).create_new(true).open(&claim_path) {
702 Ok(mut f) => {
703 use std::io::Write as _;
704 let _ = f.write_all(session_token.as_bytes());
705 let file_path = scratch_dir.join(format!("{}.md", sid));
706 if file_path.exists() {
707 chosen = Some((sid.clone(), dname.clone()));
708 } else {
709 let _ = std::fs::remove_file(&claim_path);
711 }
712 }
713 Err(_) => continue, }
715 if chosen.is_some() {
716 break;
717 }
718 }
719
720 if let Some((scratch_id, display_name)) = chosen {
721 let file_path = scratch_dir.join(format!("{}.md", scratch_id));
722 return Ok(Some(serde_json::json!({
723 "scratch_id": scratch_id,
724 "file_path": file_path.to_string_lossy(),
725 "display_name": display_name,
726 })));
727 }
728
729 let scratch_id = uuid::Uuid::new_v4().to_string();
731 let display_name = cmd.display_name.unwrap_or_else(|| "Untitled".to_string());
732 let file_path = scratch_dir.join(format!("{}.md", scratch_id));
733 std::fs::write(&file_path, "")
734 .map_err(|e| format!("createscratchfile: {e}"))?;
735 let meta = serde_json::json!({
736 "display_name": display_name,
737 "scratch_id": scratch_id,
738 "created_at": now_ms,
739 });
740 let meta_path = scratch_dir.join(format!("{}.md.meta", scratch_id));
741 std::fs::write(&meta_path, meta.to_string())
742 .map_err(|e| format!("createscratchfile: meta: {e}"))?;
743 let claim_path = scratch_dir.join(format!("{}.md.claim", scratch_id));
745 let _ = std::fs::write(&claim_path, session_token);
746 Ok(Some(serde_json::json!({
747 "scratch_id": scratch_id,
748 "file_path": file_path.to_string_lossy(),
749 "display_name": display_name,
750 })))
751 })
752 }),
753 );
754
755 engine.register_handler(
757 "movescratchfile",
758 Box::new(|data, _ctx| {
759 Box::pin(async move {
760 #[derive(serde::Deserialize)]
761 struct Cmd { scratch_id: String, destination_path: String }
762 let cmd: Cmd = serde_json::from_value(data)
763 .map_err(|e| format!("movescratchfile: {e}"))?;
764 let home = dirs::home_dir()
765 .ok_or("movescratchfile: cannot determine home")?;
766 let canonical_home = home.canonicalize()
767 .map_err(|e| format!("movescratchfile: home: {e}"))?;
768 if cmd.scratch_id.contains('/') || cmd.scratch_id.contains('\\') || cmd.scratch_id.contains("..") {
770 return Err("movescratchfile: invalid scratch_id".to_string());
771 }
772 let scratch_dir = home.join(".agentmux").join("cache").join("scratch");
773 let scratch_path = scratch_dir.join(format!("{}.md", cmd.scratch_id));
774 if !scratch_path.exists() {
775 return Err(format!("movescratchfile: scratch file not found"));
776 }
777 let expanded_dest = expand_home_dir_safe(&cmd.destination_path);
779 let dest_path = expanded_dest.as_path();
780
781 if !dest_path.starts_with(&home) {
785 return Err("movescratchfile: destination outside home directory".to_string());
786 }
787 if dest_path.components().any(|c| c == std::path::Component::ParentDir) {
792 return Err("movescratchfile: destination path must not contain '..' components".to_string());
793 }
794 if dest_path.exists() {
796 return Err("movescratchfile: destination already exists".to_string());
797 }
798 if let Some(parent) = dest_path.parent() {
800 std::fs::create_dir_all(parent)
801 .map_err(|e| format!("movescratchfile: create dirs: {e}"))?;
802 }
803 let canonical_dest = dest_path.parent()
805 .and_then(|p| p.canonicalize().ok())
806 .map(|p| p.join(dest_path.file_name().unwrap_or_default()))
807 .ok_or_else(|| "movescratchfile: invalid destination path".to_string())?;
808 if !canonical_dest.starts_with(&canonical_home) {
810 return Err("movescratchfile: destination outside home directory".to_string());
811 }
812 std::fs::copy(&scratch_path, &canonical_dest)
814 .map_err(|e| format!("movescratchfile: copy: {e}"))?;
815 std::fs::remove_file(&scratch_path)
816 .map_err(|e| format!("movescratchfile: remove scratch: {e}"))?;
817 let meta_path = scratch_dir.join(format!("{}.md.meta", cmd.scratch_id));
819 let _ = std::fs::remove_file(&meta_path);
820 let claim_path = scratch_dir.join(format!("{}.md.claim", cmd.scratch_id));
821 let _ = std::fs::remove_file(&claim_path);
822 Ok(Some(serde_json::json!({ "file_path": canonical_dest.to_string_lossy() })))
823 })
824 }),
825 );
826}