agentmux_srv\server/
editor_handlers.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4use 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
16// Per-process token written into scratch claim files. Stale claim files from a
17// previous process run have a different token and are ignored during reuse scans,
18// allowing crash-recovery (the scratch becomes reclaimable on restart).
19static 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/// Enumerate drives/mounts to surface as editor file-tree roots alongside
26/// $HOME (via the `geteditorroots` RPC). On **macOS** this returns nothing, so
27/// the file-tree's roots are limited to $HOME — `/`, `/Volumes`, and external
28/// mounts aren't offered as starting points (root scoping for the tree, not a
29/// sandbox; see the note at the macOS arm). On Linux/Windows it exposes the
30/// filesystem root + mounts.
31/// Spec: specs/SPEC_EDITOR_FILE_TREE_2026-05-26.md (multi-root follow-up).
32#[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// macOS: scope the editor file-tree ROOTS to the user's home. HOME is the sole
48// root from GetEditorRoots, so the tree doesn't offer `/`, `/Volumes`, or
49// external mounts as starting points (no nudging the user toward volumes they
50// didn't choose). NOTE: this is root scoping, not a sandbox — listeditordir /
51// readeditorfile still serve any absolute path the frontend sends, and a
52// symlink under HOME can resolve outside it; macOS TCC remains the actual gate
53// for protected locations.
54#[cfg(target_os = "macos")]
55fn list_drives() -> Vec<serde_json::Value> {
56    Vec::new()
57}
58
59// Linux (and any other non-Windows, non-macOS unix): filesystem root + mounts.
60#[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
79/// Prepend global memory bundle content into the `# Memory` section of a
80/// CLAUDE.md string, mirroring the injection done by `write_agent_config_files`
81/// in the `agent.open` RPC path.  If the file has no `# Memory` section, a new
82/// one is inserted before `# Available Skills` (or at the end of the file).
83fn 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    // Inject after the `# Memory\n` heading if it exists.
91    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        // No memory section — insert one before `# Available Skills` or append.
97        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    // writeagentconfig → write config files atomically to agent working directory
111    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                // Resolve to a final on-disk path. For auto-generated
126                // instance paths (`auto_allocate: true`), use the
127                // atomic `<base>-N` allocator so concurrent same-hour
128                // launches don't share a workdir. For user-specified
129                // paths, mkdir-p as before — never rewrite.
130                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                // Canonicalize base ONCE (it exists — allocate_agent_workdir
144                // or the explicit-path mkdir-p created it just above). Used
145                // by the per-file symlink-escape verifier so we catch a
146                // symlinked ancestor like `<base>/.claude -> /tmp/outside`
147                // before fs::write follows it.
148                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                    // Lexical join + traversal check — works on Windows where
154                    // canonicalize() adds the `\\?\` UNC prefix and breaks
155                    // starts_with against not-yet-created files. Catches `..`,
156                    // absolute paths, drive-letter prefixes (root and inner).
157                    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                    // Symlink-escape guard: if any EXISTING ancestor is a
163                    // symlink that resolves outside the workdir, reject.
164                    // No-op for fully-fresh agent dirs (the common case
165                    // where every component is new).
166                    crate::backend::base::verify_no_symlink_escape(&file_path, &canonical_base)
167                        .map_err(|e| format!("path traversal denied: {} ({e})", file.path))?;
168                    // Inject global memory bundles into CLAUDE.md so agents
169                    // launched from the picker receive the same workspace rules
170                    // as agents launched via the agent.open RPC.
171                    let content = if file.path == "CLAUDE.md" {
172                        inject_global_bundles(&file.content, &wstore)
173                    } else {
174                        file.content.clone()
175                    };
176                    // Create parent directories if needed
177                    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                // Return the final path so the caller can patch
189                // `cmd:cwd` if collision resolution changed it.
190                Ok(Some(serde_json::json!({
191                    "working_dir": final_working_dir,
192                })))
193            })
194        }),
195    );
196
197    // readeditorfile → read file from disk for the editor pane
198    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                // Size guard: reject files > 10MB
210                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                // Read bytes and detect the encoding (BOM → UTF-8 → heuristic),
217                // decoding to UTF-8 for the editor. This lets non-UTF-8 files
218                // (Windows-1252 .ini, UTF-16, Shift_JIS, …) open instead of
219                // erroring on read_to_string. The detected encoding/bom/line
220                // ending ride back so save round-trips the original format.
221                // See docs/specs/SPEC_EDITOR_FILE_ENCODINGS_2026_06_17.md.
222                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    // writeeditorfile → write file to disk from the editor pane
240    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                    // Encoding to write back in (defaults preserve old UTF-8
249                    // behavior when a caller doesn't send them).
250                    #[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                // Size guard: match readeditorfile's 10MB limit
261                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                // Path safety: restrict writes to under the user's home directory.
269                // Allowlist approach — safer than an incomplete denylist.
270                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                // Resolve the target path (canonicalize existing, or parent + filename for new files)
276                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                // Re-encode to the file's original encoding (+ BOM + line
291                // endings) so a non-UTF-8 file round-trips instead of being
292                // silently rewritten as UTF-8. Absent fields default to UTF-8.
293                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                // Refuse a lossy write: rather than silently emit
300                // numeric-character-reference replacements (corrupting the
301                // file), fail loudly so the user can save as UTF-8. The UI for
302                // an in-place "Save with Encoding → UTF-8" is Phase 3 of
303                // SPEC_EDITOR_FILE_ENCODINGS.
304                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    // listeditordir → list directory contents for the editor's file-tree pane.
324    // Symlinks are followed (matches VS Code semantics; the frontend marks
325    // followed symlinks with a ↗ overlay).
326    // Spec: specs/SPEC_EDITOR_FILE_TREE_2026-05-26.md
327    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                    // `file_type()` returns the entry's own type — symlinks
345                    // appear as symlinks (no follow). `metadata()` follows
346                    // symlinks, so a symlinked dir reports is_dir=true.
347                    // We need both: is_symlink from file_type, is_dir/size
348                    // from metadata (so a symlink-to-dir reads as a directory,
349                    // matching VS Code).
350                    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                // Folders first, then files; alphabetical, case-insensitive.
380                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    // geteditorhome → OS home directory, used as the editor file-tree default root.
401    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    // geteditorroots → home + (Linux/Windows) the filesystem root and mounts,
415    // rendered as sibling top-level roots. On macOS `list_drives` returns none,
416    // so the editor file-tree is scoped to $HOME only.
417    // Spec: specs/SPEC_EDITOR_FILE_TREE_2026-05-26.md (multi-root follow-up)
418    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    // ── Editor file-tree mutations ─────────────────────────────────────
434    // Spec: specs/SPEC_FILE_TREE_CONTEXT_MENU_2026_06_14.md
435    // All handlers validate the target path is under the user's home directory
436    // before performing any mutation — same policy as writeeditorfile.
437
438    // openinshell → reveal a path in the OS file manager
439    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    // renameeditorfile → rename a file or folder (name only, same parent directory)
473    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    // createeditorfile → create an empty file inside an existing directory
506    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    // createeditordir → create a directory inside an existing directory
536    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    // deleteeditorfile → delete a file or directory
566    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                // Use symlink_metadata (not metadata) to detect symlinks without following them.
583                // Deleting via `canonical` would delete the symlink TARGET; we always remove the
584                // entry at the original path so the symlink itself is unlinked.
585                let meta = std::fs::symlink_metadata(path)
586                    .map_err(|e| format!("deleteeditorfile: {e}"))?;
587                if meta.file_type().is_symlink() {
588                    // On Windows, directory junctions/symlinks must use remove_dir;
589                    // remove_file fails for directory symlinks on that platform.
590                    #[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    // ── Scratch file service ────────────────────────────────────────────
613    // Spec: specs/SPEC_EDITOR_WIDGET_DEFAULT_UX_2026_06_14.md
614
615    // createscratchfile → create a scratch buffer file in ~/.agentmux/cache/scratch/
616    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                // Phase 1: scan .md.meta files, collect candidates and prune expired pairs.
637                // A candidate is: not saved, not expired, not in exclude_scratch_ids.
638                let mut candidates: Vec<(u64, String, String)> = Vec::new(); // (created_at, sid, display_name)
639                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                            // Prune expired pair (all three files: .md, .md.meta, .md.claim).
665                            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                // Phase 2: try to atomically claim the most-recent candidate.
680                // Claim files prevent two concurrent createscratchfile calls from
681                // returning the same backing file to different panes. Each claim file
682                // stores the per-process session token; a claim from a previous process
683                // (crash/restart) has a different token and is cleared so recovery works.
684                candidates.sort_by(|a, b| b.0.cmp(&a.0)); // most recent first
685                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                    // Evict stale claims from a previous process session.
689                    if claim_path.exists() {
690                        if let Ok(tok) = std::fs::read_to_string(&claim_path) {
691                            if tok.trim() == session_token {
692                                // Valid live claim — another concurrent call already owns this.
693                                continue;
694                            }
695                        }
696                        // Stale claim (different session) — remove so we can reclaim.
697                        let _ = std::fs::remove_file(&claim_path);
698                    }
699                    // Try to exclusively create the claim file. If another concurrent
700                    // call wins the race, it will have already created it → we skip.
701                    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                                // Backing file gone (deleted externally) — release claim.
710                                let _ = std::fs::remove_file(&claim_path);
711                            }
712                        }
713                        Err(_) => continue, // Concurrent call claimed this one first.
714                    }
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                // No reusable candidate — mint a fresh UUID pair.
730                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                // Claim the fresh scratch so subsequent calls don't immediately reuse it.
744                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    // movescratchfile → promote a scratch buffer to a real user-chosen path (Save As)
756    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                // Validate scratch_id has no path components.
769                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                // Validate + resolve destination.
778                let expanded_dest = expand_home_dir_safe(&cmd.destination_path);
779                let dest_path = expanded_dest.as_path();
780
781                // Coarse home-boundary check before any filesystem mutation.
782                // We use the unexpanded path here because the dest may not exist yet
783                // (canonicalize fails on non-existent paths).
784                if !dest_path.starts_with(&home) {
785                    return Err("movescratchfile: destination outside home directory".to_string());
786                }
787                // Reject ".." components before create_dir_all — the coarse starts_with
788                // check is purely lexical and passes paths like ~/foo/../../../tmp/evil
789                // because the prefix matches, but create_dir_all would then materialize
790                // directories outside the home boundary before the canonical check fires.
791                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                // Reject existing destination to avoid silent data loss.
795                if dest_path.exists() {
796                    return Err("movescratchfile: destination already exists".to_string());
797                }
798                // Create parent directories if needed.
799                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                // Canonicalize parent (now guaranteed to exist) + append filename.
804                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                // Fine-grained home-boundary check with canonical paths.
809                if !canonical_dest.starts_with(&canonical_home) {
810                    return Err("movescratchfile: destination outside home directory".to_string());
811                }
812                // Copy then remove scratch source (cross-device-safe move).
813                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                // Clean up the .meta sidecar and the claim file.
818                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}