1use std::sync::Arc;
5
6use crate::backend::rpc::engine::WshRpcEngine;
7use crate::backend::rpc_types::{
8 COMMAND_WRITE_AGENT_CONFIG,
9 CommandWriteAgentConfigData, CommandWriteAgentConfigResult,
10};
11use crate::backend::agent_config::BUNDLE_SECTION_HEADING;
12use crate::backend::base::expand_home_dir_safe;
13use crate::backend::rpc_types::{
14 UnwatchMediaDirReq, WatchEditorFileReq, WatchMediaDirReq,
15 CommandReadEditorFileData, CommandReadEditorFileResult, CommandWriteEditorFileData,
16 DirEntry, EditorDrive, EditorRootsReq, GetEditorHomeResult, GetEditorRootsResult,
17 ListEditorDirReq, ListEditorDirResult,
18 CreateEditorDirReq, CreateEditorDirResult, CreateEditorFileReq, CreateEditorFileResult,
19 CreateScratchFileReq, CreateScratchFileResult, DeleteEditorFileReq, MoveScratchFileReq,
20 MoveScratchFileResult, OpenInShellReq, RenameEditorFileReq, RenameEditorFileResult,
21};
22use crate::backend::storage::store::Store;
23
24use super::AppState;
25
26static SCRATCH_SESSION_TOKEN: std::sync::OnceLock<String> = std::sync::OnceLock::new();
30
31fn scratch_session_token() -> &'static str {
32 SCRATCH_SESSION_TOKEN.get_or_init(|| uuid::Uuid::new_v4().to_string())
33}
34
35fn editor_may_change(canonical: &std::path::Path, canonical_home: &std::path::Path) -> bool {
40 canonical.starts_with(canonical_home) || in_a_distro_home(canonical)
41}
42
43fn is_a_home(canonical: &std::path::Path, canonical_home: &std::path::Path) -> bool {
45 canonical == canonical_home
46 || crate::backend::remote::wsl_fs::share_of(canonical)
47 .is_some_and(|s| crate::backend::remote::wsl_fs::is_home(&s.linux))
48}
49
50fn in_a_distro_home(path: &std::path::Path) -> bool {
51 crate::backend::remote::wsl_fs::share_of(path)
52 .is_some_and(|s| crate::backend::remote::wsl_fs::in_home(&s.linux))
53}
54
55#[cfg(target_os = "windows")]
65pub(super) fn list_drives() -> Vec<EditorDrive> {
66 let mut drives = Vec::new();
67 for letter in b'A'..=b'Z' {
68 let path = format!("{}:\\", letter as char);
69 if std::path::Path::new(&path).exists() {
70 drives.push(EditorDrive {
71 name: format!("{}:", letter as char),
72 path,
73 });
74 }
75 }
76 drives
77}
78
79#[cfg(target_os = "macos")]
87pub(super) fn list_drives() -> Vec<EditorDrive> {
88 Vec::new()
89}
90
91#[cfg(all(not(target_os = "windows"), not(target_os = "macos")))]
93pub(super) fn list_drives() -> Vec<EditorDrive> {
94 let mut drives = vec![EditorDrive { name: "/".to_string(), path: "/".to_string() }];
95 for mount_dir in ["/mnt", "/media", "/Volumes"] {
96 if let Ok(entries) = std::fs::read_dir(mount_dir) {
97 for entry in entries.flatten() {
98 let path = entry.path();
99 if path.is_dir() {
100 drives.push(EditorDrive {
101 name: entry.file_name().to_string_lossy().to_string(),
102 path: path.to_string_lossy().to_string(),
103 });
104 }
105 }
106 }
107 }
108 drives
109}
110
111fn inject_global_bundles(claude_md: &str, id_store: &Arc<Store>, agent_mode: &str, agent_block: &str) -> String {
118 let bundles = crate::backend::operator_config_seed::global_bundles_for_agent(
119 id_store.bundle_list_global().unwrap_or_default(),
120 agent_mode,
121 );
122 let bundle_block = crate::backend::storage::join_startup_blocks(
123 &crate::backend::storage::format_global_bundle_block(&bundles),
124 agent_block,
125 );
126 if bundle_block.is_empty() {
127 return claude_md.to_string();
128 }
129
130 let heading_line = format!("\n{BUNDLE_SECTION_HEADING}\n");
132 if let Some(pos) = claude_md.find(&heading_line) {
133 let insert_at = pos + heading_line.len();
134 let (before, after) = claude_md.split_at(insert_at);
135 format!("{}{}\n\n---\n\n{}", before, bundle_block, after)
136 } else {
137 let anchor = "\n# Available Skills\n";
139 if let Some(pos) = claude_md.find(anchor) {
140 let (before, after) = claude_md.split_at(pos);
141 format!("{before}{heading_line}{bundle_block}{after}")
142 } else {
143 format!("{claude_md}{heading_line}{bundle_block}\n")
144 }
145 }
146}
147
148use crate::backend::fs_ops::remote as remote_fs;
149
150pub fn register_editor_handlers(engine: &Arc<WshRpcEngine>, state: &AppState) {
151 let id_store = state.id_store.clone();
152 let mstore = state.mstore.clone();
153 let identity_store = state.identity_store.clone();
154 let editor_file_watcher = state.editor_file_watcher.clone();
155 let media_file_watcher = state.media_file_watcher.clone();
156
157 {
166 let watcher = editor_file_watcher.clone();
167 engine.register_typed(
168 "watcheditorfile",
169 move |cmd: WatchEditorFileReq, _ctx| {
170 let watcher = watcher.clone();
171 async move {
172 let expanded = expand_home_dir_safe(&cmd.path);
173 watcher.watch_path(expanded.as_path(), &cmd.block_id);
174 Ok(())
175 }
176 },
177 );
178 }
179
180 {
183 let watcher = editor_file_watcher.clone();
184 engine.register_typed(
185 "unwatcheditorfile",
186 move |cmd: WatchEditorFileReq, _ctx| {
190 let watcher = watcher.clone();
191 async move {
192 let expanded = expand_home_dir_safe(&cmd.path);
193 watcher.unwatch_path(expanded.as_path(), &cmd.block_id);
194 Ok(())
195 }
196 },
197 );
198 }
199
200 {
206 let watcher = media_file_watcher.clone();
207 engine.register_typed(
208 "watchmediadir",
209 move |cmd: WatchMediaDirReq, _ctx| {
210 let watcher = watcher.clone();
211 async move {
212 let expanded = expand_home_dir_safe(&cmd.path);
213 watcher.watch_directory(expanded.as_path(), &cmd.block_id, &cmd.extensions);
214 Ok(())
215 }
216 },
217 );
218 }
219
220 {
223 let watcher = media_file_watcher.clone();
224 engine.register_typed(
225 "unwatchmediadir",
226 move |cmd: UnwatchMediaDirReq, _ctx| {
227 let watcher = watcher.clone();
228 async move {
229 let expanded = expand_home_dir_safe(&cmd.path);
230 watcher.unwatch_directory(expanded.as_path(), &cmd.block_id);
231 Ok(())
232 }
233 },
234 );
235 }
236
237 engine.register_typed(
239 COMMAND_WRITE_AGENT_CONFIG,
240 move |mut cmd: CommandWriteAgentConfigData, _ctx| {
241 let id_store = id_store.clone();
242 let mstore = mstore.clone();
243 let identity_store = identity_store.clone();
244 async move {
245 tracing::info!(
246 working_dir = %cmd.working_dir,
247 file_count = cmd.files.len(),
248 auto_allocate = cmd.auto_allocate,
249 agent_type = cmd.agent_type.as_deref().unwrap_or("host"),
250 "WriteAgentConfig"
251 );
252 let agent_mode = cmd.agent_type.clone().unwrap_or_else(|| "host".to_string());
253 let agent_block = cmd
257 .agent_id
258 .as_deref()
259 .filter(|id| !id.is_empty())
260 .map(|id| {
261 crate::backend::storage::format_agent_bundle_block(
262 &crate::backend::storage::agent_picked_bundles(&mstore, &id_store, id),
263 )
264 })
265 .unwrap_or_default();
266
267 let container = agent_mode == "container";
269 crate::backend::mcp_launch::add_to_files(&mut cmd.files, &mstore, &identity_store, cmd.agent_id.as_deref(), container);
270
271 if let Some(pos) = cmd.files.iter().position(|f| f.path == ".mcp.json") {
282 let agent_slug = serde_json::from_str::<serde_json::Value>(&cmd.files[pos].content)
283 .ok()
284 .and_then(|v| {
285 v.pointer("/mcpServers/agentmux/env/AGENTMUX_AGENT_ID")
286 .and_then(|s| s.as_str())
287 .map(str::to_string)
288 });
289 if let Some(agent_slug) = agent_slug {
290 crate::server::agent_self_keys::record_config_id_drift(
291 "m4d.config_id_drift.write_agent_config",
292 &agent_slug,
293 crate::server::agent_self_keys::single_row_slug_folded(&mstore, &agent_slug).as_deref(),
294 );
295 if let Some(rewritten) = crate::backend::agent_config::inject_jekt_signing_keys_into_mcp_json(
296 &cmd.files[pos].content,
297 &mstore,
298 &agent_slug,
299 ) {
300 cmd.files[pos].content = rewritten;
301 } else {
302 tracing::warn!(
303 agent_slug = %agent_slug,
304 "WriteAgentConfig: jekt/LAN signing keys not injected into .mcp.json — \
305 this agent's jekts will render TRUST=self-declared / TRUST=network-claimed \
306 instead of verified"
307 );
308 }
309 }
310 }
311
312 let expanded_working_dir = expand_home_dir_safe(&cmd.working_dir);
318 let final_working_dir = if cmd.auto_allocate {
319 let desired = expanded_working_dir.to_string_lossy().to_string();
320 crate::server::app_api::allocate_agent_workdir(&desired)?
321 } else {
322 let p = expanded_working_dir.as_path();
323 if !p.exists() {
324 std::fs::create_dir_all(p) .map_err(|e| format!("failed to create working dir: {e}"))?;
326 }
327 expanded_working_dir.to_string_lossy().to_string()
328 };
329 let base_path = std::path::Path::new(&final_working_dir);
330 let canonical_base = base_path.canonicalize().map_err(|e| {
336 format!("failed to canonicalize working dir {}: {e}", base_path.display())
337 })?;
338
339 let wd = crate::backend::workdir_fs::Workdir::open(base_path)
352 .map_err(|e| format!("failed to open working dir {}: {e}", base_path.display()))?;
353 let new_managed_skill_paths = crate::backend::agent_config::managed_skill_file_paths(
354 cmd.files.iter().map(|f| f.path.as_str()),
355 );
356 crate::backend::agent_config::cleanup_stale_managed_skill_files(
357 base_path,
358 &new_managed_skill_paths,
359 );
360
361 for file in &cmd.files {
362 let file_path = crate::backend::base::safe_join_within_base(
367 base_path,
368 &file.path,
369 )
370 .map_err(|e| format!("path traversal denied: {} ({e})", file.path))?;
371 if let Err(e) = crate::backend::base::verify_no_symlink_escape(&file_path, &canonical_base) {
379 tracing::warn!(path = %file.path, error = %e, "writeagentconfig: not writing a config file under a folder linking outside the workdir");
380 continue;
381 }
382 if file.path == "CLAUDE.md" {
386 let content = inject_global_bundles(&file.content, &id_store, &agent_mode, &agent_block);
392 crate::backend::agent_config::write_claude_md_respecting_ownership(
393 base_path, &content,
394 )
395 .map_err(|e| format!("failed to write CLAUDE.md: {e}"))?;
396 tracing::debug!(path = %file_path.display(), "wrote config file (ownership-aware)");
397 continue;
398 }
399 if crate::backend::providers::is_known_startup_instructions_filename(&file.path) {
407 let content = inject_global_bundles(&file.content, &id_store, &agent_mode, &agent_block);
413 crate::backend::agent_config::write_startup_instructions_respecting_existing(
421 base_path, &file.path, &content,
422 )
423 .map_err(|e| format!("failed to write {}: {e}", file.path))?;
424 tracing::debug!(path = %file_path.display(), "wrote config file (exists-guarded)");
425 continue;
426 }
427 if file.path == ".mcp.json" {
431 crate::backend::agent_config::write_mcp_json_respecting_user_servers(
432 base_path,
433 &file.content,
434 )
435 .map_err(|e| format!("failed to write .mcp.json: {e}"))?;
436 tracing::debug!(path = %file_path.display(), "wrote config file (merged, owner-only)");
437 continue;
438 }
439 match wd.write(&file.path, file.content.as_bytes(), false) {
444 Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
445 tracing::warn!(path = %file.path, error = %e, "writeagentconfig: not writing a config file");
446 }
447 other => {
448 other.map_err(|e| format!("failed to write {}: {e}", file.path))?;
449 tracing::debug!(path = %file_path.display(), "wrote config file");
450 }
451 }
452 }
453
454 crate::backend::agent_config::write_managed_skill_file_manifest(
455 base_path,
456 &new_managed_skill_paths,
457 );
458
459 Ok(CommandWriteAgentConfigResult { working_dir: final_working_dir })
462 }
463 },
464 );
465
466 let auth_key = state.auth_key.clone();
468 engine.register_typed(
469 "readeditorfile",
470 move |cmd: CommandReadEditorFileData, _ctx| {
471 let auth_key = auth_key.clone();
472 async move {
473 if let Some(conn) = remote_fs::ssh_connection(cmd.connection.as_deref()) {
476 let r = remote_fs::Remote {
477 connection: conn,
478 block_id: cmd.block_id.as_deref(),
479 auth_key: &auth_key,
480 };
481 let (bytes, read_only) = remote_fs::read_file(r, &cmd.path)
482 .await
483 .map_err(|e| format!("readeditorfile: {e}"))?;
484 let decoded = crate::backend::text_encoding::decode_file(&bytes);
485 return Ok(CommandReadEditorFileResult {
486 content: decoded.content,
487 encoding: decoded.encoding,
488 bom: decoded.bom.to_string(),
489 line_ending: decoded.line_ending.to_string(),
490 had_decode_errors: decoded.had_decode_errors,
491 read_only,
492 });
493 }
494 let expanded = expand_home_dir_safe(&cmd.path);
495 let path = expanded.as_path();
496
497 let metadata = std::fs::metadata(path)
499 .map_err(|e| format!("readeditorfile: {e}"))?;
500 if metadata.len() > 10_000_000 {
501 return Err("File too large (>10MB)".to_string());
502 }
503
504 let bytes = std::fs::read(path)
511 .map_err(|e| format!("readeditorfile: {e}"))?;
512 let decoded = crate::backend::text_encoding::decode_file(&bytes);
513 let read_only = metadata.permissions().readonly();
514
515 Ok(CommandReadEditorFileResult {
516 content: decoded.content,
517 encoding: decoded.encoding,
518 bom: decoded.bom.to_string(),
519 line_ending: decoded.line_ending.to_string(),
520 had_decode_errors: decoded.had_decode_errors,
521 read_only,
522 })
523 }
524 },
525 );
526
527 let auth_key = state.auth_key.clone();
529 engine.register_typed(
530 "writeeditorfile",
531 move |cmd: CommandWriteEditorFileData, _ctx| {
532 let auth_key = auth_key.clone();
533 async move {
534 if cmd.content.len() > 10_000_000 {
536 return Err("Content too large (>10MB)".to_string());
537 }
538
539 if let Some(conn) = remote_fs::ssh_connection(cmd.connection.as_deref()) {
542 let (out_bytes, had_unmappable) = crate::backend::text_encoding::encode_file(
543 &cmd.content,
544 cmd.encoding.as_deref().unwrap_or(""),
545 cmd.bom.as_deref().unwrap_or("none"),
546 cmd.line_ending.as_deref().unwrap_or("lf"),
547 );
548 if had_unmappable {
549 return Err("writeeditorfile: file contains characters that can't be represented in its encoding — save it as UTF-8 instead".to_string());
550 }
551 let r = remote_fs::Remote {
552 connection: conn,
553 block_id: cmd.block_id.as_deref(),
554 auth_key: &auth_key,
555 };
556 return remote_fs::write_file(r, &cmd.path, out_bytes).await;
557 }
558
559 let expanded = expand_home_dir_safe(&cmd.path);
560 let path = expanded.as_path();
561
562 let home = dirs::home_dir()
565 .ok_or("writeeditorfile: cannot determine home directory")?;
566 let canonical_home = home.canonicalize()
567 .map_err(|e| format!("writeeditorfile: home dir: {e}"))?;
568
569 let canonical = path.canonicalize().or_else(|_| {
571 path.parent()
572 .and_then(|p| p.canonicalize().ok())
573 .map(|p| p.join(path.file_name().unwrap_or_default()))
574 .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "invalid path"))
575 }).map_err(|e| format!("writeeditorfile: {e}"))?;
576
577 if !editor_may_change(&canonical, &canonical_home) {
578 return Err(format!(
579 "writeeditorfile: path {} is outside home directory",
580 canonical.display()
581 ));
582 }
583
584 let (out_bytes, had_unmappable) = crate::backend::text_encoding::encode_file(
588 &cmd.content,
589 cmd.encoding.as_deref().unwrap_or(""),
590 cmd.bom.as_deref().unwrap_or("none"),
591 cmd.line_ending.as_deref().unwrap_or("lf"),
592 );
593 if had_unmappable {
599 let enc = cmd
600 .encoding
601 .as_deref()
602 .filter(|s| !s.is_empty())
603 .unwrap_or("its current encoding");
604 return Err(format!(
605 "writeeditorfile: file contains characters that can't be represented in {enc} — save it as UTF-8 instead"
606 ));
607 }
608 std::fs::write(&canonical, &out_bytes)
609 .map_err(|e| format!("writeeditorfile: {e}"))?;
610 tracing::info!(path = %canonical.display(), bytes = out_bytes.len(), "editor file saved");
611
612 Ok(())
613 }
614 },
615 );
616
617 engine.register_typed(
622 "listeditordir",
623 |cmd: ListEditorDirReq, _ctx| {
624 async move {
625 let expanded = expand_home_dir_safe(&cmd.path);
626 let canonical = expanded.canonicalize()
627 .map_err(|e| format!("listeditordir: {e}"))?;
628 let read_dir = std::fs::read_dir(&canonical)
629 .map_err(|e| format!("listeditordir: {e}"))?;
630
631 let mut entries: Vec<DirEntry> = Vec::new();
632 for entry in read_dir.flatten() {
633 let name = entry.file_name().to_string_lossy().to_string();
634 let is_symlink = entry
641 .file_type()
642 .map(|t| t.is_symlink())
643 .unwrap_or(false);
644 let metadata = entry.metadata().ok();
645 let is_dir = metadata.as_ref().map(|m| m.is_dir()).unwrap_or(false);
646 let size = metadata
647 .as_ref()
648 .and_then(|m| if !m.is_dir() { Some(m.len()) } else { None });
649 let mtime = metadata
650 .as_ref()
651 .and_then(|m| m.modified().ok())
652 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
653 .map(|d| d.as_millis() as u64);
654
655 entries.push(DirEntry { name, is_dir, is_symlink, size, mtime });
656 }
657
658 entries.sort_by(|a, b| {
664 match (a.is_dir, b.is_dir) {
665 (true, false) => std::cmp::Ordering::Less,
666 (false, true) => std::cmp::Ordering::Greater,
667 _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
668 }
669 });
670
671 Ok(ListEditorDirResult {
672 path: canonical.to_string_lossy().into_owned(),
673 entries,
674 })
675 }
676 },
677 );
678
679 engine.register_typed(
681 "geteditorhome",
682 |_req: Option<EditorRootsReq>, _ctx| {
685 async move {
686 let home = dirs::home_dir()
687 .ok_or_else(|| "geteditorhome: cannot determine home directory".to_string())?;
688 Ok(GetEditorHomeResult {
689 home: home.to_string_lossy().into_owned(),
690 })
691 }
692 },
693 );
694
695 engine.register_typed(
700 "geteditorroots",
701 |_req: Option<EditorRootsReq>, _ctx| {
702 async move {
703 let home = dirs::home_dir()
704 .ok_or_else(|| "geteditorroots: cannot determine home directory".to_string())?;
705 let mut drives = list_drives();
707 drives.extend(crate::backend::remote::wsl::list_cached().await.into_iter().map(|d| {
708 EditorDrive {
709 path: crate::backend::remote::wsl_fs::share_path(&d, "/"),
710 name: format!("{d} (WSL)"),
711 }
712 }));
713 Ok(GetEditorRootsResult {
714 home: home.to_string_lossy().into_owned(),
715 drives,
716 })
717 }
718 },
719 );
720
721 engine.register_typed(
728 "openinshell",
729 |cmd: OpenInShellReq, _ctx| {
730 async move {
731 let expanded = expand_home_dir_safe(&cmd.path);
732 let path = expanded.as_path();
733 let home = dirs::home_dir()
734 .ok_or("openinshell: cannot determine home directory")?;
735 let canonical_home = home.canonicalize()
736 .map_err(|e| format!("openinshell: home: {e}"))?;
737 let canonical = path.canonicalize()
738 .map_err(|e| format!("openinshell: {e}"))?;
739 if !editor_may_change(&canonical, &canonical_home) {
740 return Err(format!("openinshell: path outside home directory"));
741 }
742 #[cfg(target_os = "windows")]
748 {
749 let mut cmd = std::process::Command::new("explorer.exe");
750 crate::backend::pane_env::sanitize_external_std_command(&mut cmd);
751 let _ = cmd.arg(format!("/select,{}", canonical.display())).spawn();
752 }
753 #[cfg(target_os = "macos")]
754 {
755 let mut cmd = std::process::Command::new("open");
756 crate::backend::pane_env::sanitize_external_std_command(&mut cmd);
757 let _ = cmd.arg("-R").arg(&canonical).spawn();
758 }
759 #[cfg(target_os = "linux")]
760 {
761 let target = if canonical.is_dir() { canonical.clone() } else { canonical.parent().unwrap_or(&canonical).to_path_buf() };
762 let mut cmd = std::process::Command::new("xdg-open");
763 crate::backend::pane_env::sanitize_external_std_command(&mut cmd);
764 let _ = cmd.arg(&target).spawn();
765 }
766 Ok(())
767 }
768 },
769 );
770
771 engine.register_typed(
773 "renameeditorfile",
774 |cmd: RenameEditorFileReq, _ctx| {
775 async move {
776 let expanded = expand_home_dir_safe(&cmd.old_path);
777 let old_path = expanded.as_path();
778 let home = dirs::home_dir().ok_or("renameeditorfile: cannot determine home")?;
779 let canonical_home = home.canonicalize().map_err(|e| format!("renameeditorfile: home: {e}"))?;
780 let canonical_old = old_path.canonicalize().map_err(|e| format!("renameeditorfile: {e}"))?;
781 if !editor_may_change(&canonical_old, &canonical_home) || is_a_home(&canonical_old, &canonical_home) {
782 return Err("renameeditorfile: path outside or is the home directory".to_string());
783 }
784 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() {
785 return Err("renameeditorfile: new_name must be a plain filename".to_string());
786 }
787 let new_path = canonical_old.parent()
788 .ok_or("renameeditorfile: no parent directory")?
789 .join(&cmd.new_name);
790 if new_path.exists() {
791 return Err(format!("renameeditorfile: destination already exists"));
792 }
793 std::fs::rename(&canonical_old, &new_path)
794 .map_err(|e| format!("renameeditorfile: {e}"))?;
795 Ok(RenameEditorFileResult { new_path: new_path.to_string_lossy().into_owned() })
796 }
797 },
798 );
799
800 engine.register_typed(
802 "createeditorfile",
803 |cmd: CreateEditorFileReq, _ctx| {
804 async move {
805 let expanded = expand_home_dir_safe(&cmd.parent_path);
806 let parent = expanded.as_path();
807 let home = dirs::home_dir().ok_or("createeditorfile: cannot determine home")?;
808 let canonical_home = home.canonicalize().map_err(|e| format!("createeditorfile: home: {e}"))?;
809 let canonical_parent = parent.canonicalize().map_err(|e| format!("createeditorfile: {e}"))?;
810 if !editor_may_change(&canonical_parent, &canonical_home) {
811 return Err("createeditorfile: path outside home directory".to_string());
812 }
813 if cmd.name.contains('/') || cmd.name.contains('\\') || cmd.name.contains('\0') || cmd.name == "." || cmd.name == ".." || cmd.name.is_empty() {
814 return Err("createeditorfile: name must be a plain filename".to_string());
815 }
816 let file_path = canonical_parent.join(&cmd.name);
817 if file_path.exists() {
818 return Err("createeditorfile: file already exists".to_string());
819 }
820 std::fs::write(&file_path, "").map_err(|e| format!("createeditorfile: {e}"))?;
821 Ok(CreateEditorFileResult { file_path: file_path.to_string_lossy().into_owned() })
822 }
823 },
824 );
825
826 engine.register_typed(
828 "createeditordir",
829 |cmd: CreateEditorDirReq, _ctx| {
830 async move {
831 let expanded = expand_home_dir_safe(&cmd.parent_path);
832 let parent = expanded.as_path();
833 let home = dirs::home_dir().ok_or("createeditordir: cannot determine home")?;
834 let canonical_home = home.canonicalize().map_err(|e| format!("createeditordir: home: {e}"))?;
835 let canonical_parent = parent.canonicalize().map_err(|e| format!("createeditordir: {e}"))?;
836 if !editor_may_change(&canonical_parent, &canonical_home) {
837 return Err("createeditordir: path outside home directory".to_string());
838 }
839 if cmd.name.contains('/') || cmd.name.contains('\\') || cmd.name.contains('\0') || cmd.name == "." || cmd.name == ".." || cmd.name.is_empty() {
840 return Err("createeditordir: name must be a plain name".to_string());
841 }
842 let dir_path = canonical_parent.join(&cmd.name);
843 if dir_path.exists() {
844 return Err("createeditordir: already exists".to_string());
845 }
846 std::fs::create_dir(&dir_path).map_err(|e| format!("createeditordir: {e}"))?;
847 Ok(CreateEditorDirResult { dir_path: dir_path.to_string_lossy().into_owned() })
848 }
849 },
850 );
851
852 engine.register_typed(
854 "deleteeditorfile",
855 |cmd: DeleteEditorFileReq, _ctx| {
856 async move {
857 let expanded = expand_home_dir_safe(&cmd.path);
858 let path = expanded.as_path();
859 let home = dirs::home_dir().ok_or("deleteeditorfile: cannot determine home")?;
860 let canonical_home = home.canonicalize().map_err(|e| format!("deleteeditorfile: home: {e}"))?;
861 let canonical = path.canonicalize().map_err(|e| format!("deleteeditorfile: {e}"))?;
862 if !editor_may_change(&canonical, &canonical_home) || is_a_home(&canonical, &canonical_home) {
863 return Err("deleteeditorfile: path outside or is the home directory".to_string());
864 }
865 let meta = std::fs::symlink_metadata(path)
869 .map_err(|e| format!("deleteeditorfile: {e}"))?;
870 if meta.file_type().is_symlink() {
871 #[cfg(windows)]
874 if path.is_dir() {
875 std::fs::remove_dir(path).map_err(|e| format!("deleteeditorfile: {e}"))?;
876 } else {
877 std::fs::remove_file(path).map_err(|e| format!("deleteeditorfile: {e}"))?;
878 }
879 #[cfg(not(windows))]
880 std::fs::remove_file(path).map_err(|e| format!("deleteeditorfile: {e}"))?;
881 } else if canonical.is_dir() {
882 if cmd.recursive {
883 std::fs::remove_dir_all(&canonical).map_err(|e| format!("deleteeditorfile: {e}"))?;
884 } else {
885 std::fs::remove_dir(&canonical).map_err(|e| format!("deleteeditorfile: directory not empty: {e}"))?;
886 }
887 } else {
888 std::fs::remove_file(&canonical).map_err(|e| format!("deleteeditorfile: {e}"))?;
889 }
890 Ok(())
891 }
892 },
893 );
894
895 engine.register_typed(
900 "createscratchfile",
901 |cmd: CreateScratchFileReq, _ctx| {
902 async move {
903 let home = dirs::home_dir()
904 .ok_or("createscratchfile: cannot determine home directory")?;
905 let scratch_dir = home.join(".agentmux").join("cache").join("scratch");
906 std::fs::create_dir_all(&scratch_dir)
907 .map_err(|e| format!("createscratchfile: create dir: {e}"))?;
908 let now_ms = agentmux_common::time::now_ms_u64();
909 const THIRTY_DAYS_MS: u64 = 30 * 24 * 60 * 60 * 1000;
910 let session_token = scratch_session_token();
911
912 let mut candidates: Vec<(u64, String, String)> = Vec::new(); if let Ok(entries) = std::fs::read_dir(&scratch_dir) {
916 for entry in entries.flatten() {
917 let meta_path = entry.path();
918 let fname = meta_path
919 .file_name()
920 .and_then(|f| f.to_str())
921 .unwrap_or("")
922 .to_string();
923 if !fname.ends_with(".md.meta") {
924 continue;
925 }
926 let Ok(content) = std::fs::read_to_string(&meta_path) else { continue };
927 let Ok(meta_json) = serde_json::from_str::<serde_json::Value>(&content) else { continue };
928 if meta_json.get("saved_to").map_or(false, |v| !v.is_null()) {
929 continue;
930 }
931 let created_at = meta_json.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0);
932 let sid = meta_json.get("scratch_id").and_then(|v| v.as_str()).unwrap_or("").to_string();
933 if created_at == 0 || sid.is_empty() {
934 continue;
935 }
936 if cmd.exclude_scratch_ids.as_deref().map_or(false, |ex| ex.iter().any(|e| e == &sid)) {
937 continue;
938 }
939 if now_ms.saturating_sub(created_at) > THIRTY_DAYS_MS {
940 let _ = std::fs::remove_file(scratch_dir.join(format!("{}.md", sid)));
942 let _ = std::fs::remove_file(&meta_path);
943 let _ = std::fs::remove_file(scratch_dir.join(format!("{}.md.claim", sid)));
944 continue;
945 }
946 let dname = meta_json
947 .get("display_name")
948 .and_then(|v| v.as_str())
949 .unwrap_or("Untitled")
950 .to_string();
951 candidates.push((created_at, sid, dname));
952 }
953 }
954
955 candidates.sort_by(|a, b| b.0.cmp(&a.0)); let mut chosen: Option<(String, String)> = None;
962 for (_, sid, dname) in &candidates {
963 let claim_path = scratch_dir.join(format!("{}.md.claim", sid));
964 if claim_path.exists() {
966 if let Ok(tok) = std::fs::read_to_string(&claim_path) {
967 if tok.trim() == session_token {
968 continue;
970 }
971 }
972 let _ = std::fs::remove_file(&claim_path);
974 }
975 match std::fs::OpenOptions::new().write(true).create_new(true).open(&claim_path) {
978 Ok(mut f) => {
979 use std::io::Write as _;
980 let _ = f.write_all(session_token.as_bytes());
981 let file_path = scratch_dir.join(format!("{}.md", sid));
982 if file_path.exists() {
983 chosen = Some((sid.clone(), dname.clone()));
984 } else {
985 let _ = std::fs::remove_file(&claim_path);
987 }
988 }
989 Err(_) => continue, }
991 if chosen.is_some() {
992 break;
993 }
994 }
995
996 if let Some((scratch_id, display_name)) = chosen {
997 let file_path = scratch_dir.join(format!("{}.md", scratch_id));
998 return Ok(CreateScratchFileResult {
999 scratch_id,
1000 file_path: file_path.to_string_lossy().into_owned(),
1001 display_name,
1002 });
1003 }
1004
1005 let scratch_id = uuid::Uuid::new_v4().to_string();
1007 let display_name = cmd.display_name.unwrap_or_else(|| "Untitled".to_string());
1008 let file_path = scratch_dir.join(format!("{}.md", scratch_id));
1009 std::fs::write(&file_path, "")
1010 .map_err(|e| format!("createscratchfile: {e}"))?;
1011 let meta = serde_json::json!({
1012 "display_name": display_name,
1013 "scratch_id": scratch_id,
1014 "created_at": now_ms,
1015 });
1016 let meta_path = scratch_dir.join(format!("{}.md.meta", scratch_id));
1017 std::fs::write(&meta_path, meta.to_string())
1018 .map_err(|e| format!("createscratchfile: meta: {e}"))?;
1019 let claim_path = scratch_dir.join(format!("{}.md.claim", scratch_id));
1021 let _ = std::fs::write(&claim_path, session_token);
1022 Ok(CreateScratchFileResult {
1023 scratch_id,
1024 file_path: file_path.to_string_lossy().into_owned(),
1025 display_name,
1026 })
1027 }
1028 },
1029 );
1030
1031 engine.register_typed(
1033 "movescratchfile",
1034 |cmd: MoveScratchFileReq, _ctx| {
1035 async move {
1036 let home = dirs::home_dir()
1037 .ok_or("movescratchfile: cannot determine home")?;
1038 let canonical_home = home.canonicalize()
1039 .map_err(|e| format!("movescratchfile: home: {e}"))?;
1040 if cmd.scratch_id.contains('/') || cmd.scratch_id.contains('\\') || cmd.scratch_id.contains("..") {
1042 return Err("movescratchfile: invalid scratch_id".to_string());
1043 }
1044 let scratch_dir = home.join(".agentmux").join("cache").join("scratch");
1045 let scratch_path = scratch_dir.join(format!("{}.md", cmd.scratch_id));
1046 if !scratch_path.exists() {
1047 return Err(format!("movescratchfile: scratch file not found"));
1048 }
1049 let expanded_dest = expand_home_dir_safe(&cmd.destination_path);
1051 let dest_path = expanded_dest.as_path();
1052
1053 if !dest_path.starts_with(&home) && !in_a_distro_home(dest_path) {
1057 return Err("movescratchfile: destination outside home directory".to_string());
1058 }
1059 if dest_path.components().any(|c| c == std::path::Component::ParentDir) {
1064 return Err("movescratchfile: destination path must not contain '..' components".to_string());
1065 }
1066 if dest_path.exists() {
1068 return Err("movescratchfile: destination already exists".to_string());
1069 }
1070 if let Some(parent) = dest_path.parent() {
1072 std::fs::create_dir_all(parent)
1073 .map_err(|e| format!("movescratchfile: create dirs: {e}"))?;
1074 }
1075 let canonical_dest = dest_path.parent()
1077 .and_then(|p| p.canonicalize().ok())
1078 .map(|p| p.join(dest_path.file_name().unwrap_or_default()))
1079 .ok_or_else(|| "movescratchfile: invalid destination path".to_string())?;
1080 if !editor_may_change(&canonical_dest, &canonical_home) {
1082 return Err("movescratchfile: destination outside home directory".to_string());
1083 }
1084 std::fs::copy(&scratch_path, &canonical_dest)
1086 .map_err(|e| format!("movescratchfile: copy: {e}"))?;
1087 std::fs::remove_file(&scratch_path)
1088 .map_err(|e| format!("movescratchfile: remove scratch: {e}"))?;
1089 let meta_path = scratch_dir.join(format!("{}.md.meta", cmd.scratch_id));
1091 let _ = std::fs::remove_file(&meta_path);
1092 let claim_path = scratch_dir.join(format!("{}.md.claim", cmd.scratch_id));
1093 let _ = std::fs::remove_file(&claim_path);
1094 Ok(MoveScratchFileResult { file_path: canonical_dest.to_string_lossy().into_owned() })
1095 }
1096 },
1097 );
1098}
1099
1100#[cfg(test)]
1101mod tests {
1102 use crate::backend::rpc_types::AgentConfigFile;
1103 use crate::backend::rpc_types::{
1104 CommandReadEditorFileResult, CreateScratchFileReq, DeleteEditorFileReq, DirEntry,
1105 };
1106
1107 #[test]
1111 fn the_startup_file_gets_global_memory_then_the_picked_bundles() {
1112 use crate::backend::storage::store::{Bundle, Store};
1113 let id_store = std::sync::Arc::new(Store::open_in_memory().unwrap());
1114 let file = "# Agent\n\n# Memory\nown notes\n";
1115 let picks = "# [Bundle] Writer\n\nwrite plainly";
1116
1117 let only_picks = super::inject_global_bundles(file, &id_store, "host", picks);
1118 assert_eq!(only_picks, "# Agent\n\n# Memory\n# [Bundle] Writer\n\nwrite plainly\n\n---\n\nown notes\n");
1119
1120 id_store
1121 .bundle_upsert(&Bundle {
1122 id: "g1".into(),
1123 name: "Rules".into(),
1124 description: String::new(),
1125 is_blank: false,
1126 is_global: true,
1127 provider: String::new(),
1128 model: String::new(),
1129 instructions: "global rule".into(),
1130 instructions_by_provider: "{}".into(),
1131 context_files: "[]".into(),
1132 mcp_servers: "[]".into(),
1133 skills: "[]".into(),
1134 sort_order: 0,
1135 created_at: 0,
1136 updated_at: 0,
1137 is_system: false,
1138 })
1139 .unwrap();
1140 let both = super::inject_global_bundles(file, &id_store, "host", picks);
1141 let global = both.find("# [Workspace] Rules").unwrap();
1142 let writer = both.find("# [Bundle] Writer").unwrap();
1143 let own = both.find("own notes").unwrap();
1144 assert!(global < writer && writer < own, "{both}");
1145 assert_eq!(super::inject_global_bundles(file, &id_store, "host", ""), both.replace("\n\n---\n\n# [Bundle] Writer\n\nwrite plainly", ""));
1146 }
1147
1148 #[cfg(windows)]
1153 #[test]
1154 fn the_editor_may_change_files_under_a_distro_home_and_nowhere_else_in_it() {
1155 use std::path::Path;
1156 let home = Path::new(r"C:\Users\me");
1157 let may = |p: &str| super::editor_may_change(Path::new(p), home);
1158 assert!(may(r"\\?\UNC\wsl.localhost\Ubuntu\home\u\proj\main.rs"));
1159 assert!(may(r"\\wsl.localhost\Ubuntu\root\notes.md"));
1160 assert!(may(r"\\wsl.localhost\Ubuntu\home\u"), "a parent for a new file");
1161 assert!(!may(r"\\wsl.localhost\Ubuntu\etc\passwd"));
1162 assert!(!may(r"\\wsl.localhost\Ubuntu\srv\app\x"));
1163 assert!(
1164 !may(r"\\wsl.localhost\Ubuntu\mnt\c\Windows\x"),
1165 "a Windows drive through the distro is not a way around the home rule"
1166 );
1167 assert!(super::is_a_home(Path::new(r"\\wsl.localhost\Ubuntu\home\u"), home));
1168 assert!(super::is_a_home(Path::new(r"\\wsl.localhost\Ubuntu\root"), home));
1169 assert!(!super::is_a_home(Path::new(r"\\wsl.localhost\Ubuntu\home\u\x"), home));
1170 }
1171
1172 #[tokio::test]
1183 async fn register_typed_records_the_four_watchers() {
1184 let state = crate::server::tests::test_state();
1185 let (engine, _rx) = crate::backend::rpc::engine::WshRpcEngine::new();
1186 super::register_editor_handlers(&engine, &state);
1187 let schema = engine.schema_json();
1188 let rows = schema.as_array().unwrap();
1189 let find = |cmd: &str| {
1190 rows.iter()
1191 .find(|r| r["command"] == cmd)
1192 .unwrap_or_else(|| panic!("{cmd} missing from the schema"))
1193 };
1194
1195 for (cmd, req) in [
1196 ("watcheditorfile", "WatchEditorFileReq"),
1197 ("unwatcheditorfile", "WatchEditorFileReq"),
1198 ("watchmediadir", "WatchMediaDirReq"),
1199 ("unwatchmediadir", "UnwatchMediaDirReq"),
1200 ] {
1201 let row = find(cmd);
1202 assert_eq!(row["requestName"], req, "{cmd} request");
1203 assert_eq!(row["responseName"], "()", "{cmd} answers nothing");
1204 }
1205
1206 let wac = find(crate::backend::rpc_types::COMMAND_WRITE_AGENT_CONFIG);
1211 assert_eq!(wac["requestName"], "CommandWriteAgentConfigData");
1212 assert_eq!(wac["responseName"], "CommandWriteAgentConfigResult");
1213 }
1214
1215 #[tokio::test]
1219 async fn register_typed_records_the_editor_read_commands() {
1220 let state = crate::server::tests::test_state();
1221 let (engine, _rx) = crate::backend::rpc::engine::WshRpcEngine::new();
1222 super::register_editor_handlers(&engine, &state);
1223 let schema = engine.schema_json();
1224 let rows = schema.as_array().unwrap();
1225 let find = |cmd: &str| {
1226 rows.iter()
1227 .find(|r| r["command"] == cmd)
1228 .unwrap_or_else(|| panic!("{cmd} missing from the schema"))
1229 };
1230
1231 for (cmd, resp) in [
1232 ("readeditorfile", "CommandReadEditorFileResult"),
1233 ("writeeditorfile", "()"),
1234 ("listeditordir", "ListEditorDirResult"),
1235 ("geteditorhome", "GetEditorHomeResult"),
1236 ("geteditorroots", "GetEditorRootsResult"),
1237 ] {
1238 assert_eq!(find(cmd)["responseName"], resp, "{cmd} response");
1239 }
1240 assert_eq!(find("readeditorfile")["requestName"], "CommandReadEditorFileData");
1241 assert_eq!(find("writeeditorfile")["requestName"], "CommandWriteEditorFileData");
1242 assert_eq!(find("listeditordir")["requestName"], "ListEditorDirReq");
1243 }
1244
1245 #[tokio::test]
1250 async fn the_no_argument_read_commands_accept_both_encodings() {
1251 let state = crate::server::tests::test_state();
1252 let (engine, mut rx) = crate::backend::rpc::engine::WshRpcEngine::new();
1253 super::register_editor_handlers(&engine, &state);
1254
1255 for cmd in ["geteditorhome", "geteditorroots"] {
1256 for (i, payload) in [serde_json::json!({}), serde_json::Value::Null]
1257 .into_iter()
1258 .enumerate()
1259 {
1260 engine.handle_message(crate::backend::rpc_types::RpcMessage {
1261 command: cmd.to_string(),
1262 reqid: format!("{cmd}-{i}"),
1263 data: Some(payload.clone()),
1264 ..Default::default()
1265 });
1266 let resp = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
1267 .await
1268 .unwrap()
1269 .unwrap();
1270 assert!(
1271 resp.error.is_empty(),
1272 "{cmd} should accept {payload}, got error: {}",
1273 resp.error,
1274 );
1275 }
1276 }
1277 }
1278
1279 #[test]
1285 fn readeditorfile_always_reports_the_encoding_fields() {
1286 let decoded = crate::backend::text_encoding::decode_file(b"hello");
1287 let v = serde_json::to_value(CommandReadEditorFileResult {
1288 content: decoded.content,
1289 encoding: decoded.encoding,
1290 bom: decoded.bom.to_string(),
1291 line_ending: decoded.line_ending.to_string(),
1292 had_decode_errors: decoded.had_decode_errors,
1293 read_only: false,
1294 })
1295 .unwrap();
1296 for key in ["content", "encoding", "bom", "line_ending", "had_decode_errors", "read_only"] {
1297 assert!(v.get(key).is_some(), "{key} must always be present, got {v}");
1298 }
1299 assert_eq!(v["encoding"], "UTF-8");
1300 assert_eq!(v["bom"], "none");
1301 assert_eq!(v["line_ending"], "lf");
1302 }
1303
1304 #[test]
1309 fn a_directory_entry_omits_size_rather_than_sending_zero() {
1310 let dir = DirEntry {
1311 name: "src".to_string(),
1312 is_dir: true,
1313 is_symlink: false,
1314 size: None,
1315 mtime: None,
1316 };
1317 let v = serde_json::to_value(&dir).unwrap();
1318 assert!(v.get("size").is_none(), "expected size omitted, got {v}");
1319 assert!(v.get("mtime").is_none(), "expected mtime omitted, got {v}");
1320
1321 let file = DirEntry {
1322 name: "main.rs".to_string(),
1323 is_dir: false,
1324 is_symlink: false,
1325 size: Some(0),
1326 mtime: Some(1_700_000_000_000),
1327 };
1328 let v = serde_json::to_value(&file).unwrap();
1329 assert_eq!(v["size"], 0, "a real empty file still reports its size");
1330 assert_eq!(v["mtime"], 1_700_000_000_000u64);
1331 }
1332
1333 #[tokio::test]
1343 async fn register_typed_records_the_editor_mutation_commands() {
1344 let state = crate::server::tests::test_state();
1345 let (engine, _rx) = crate::backend::rpc::engine::WshRpcEngine::new();
1346 super::register_editor_handlers(&engine, &state);
1347 let schema = engine.schema_json();
1348 let rows = schema.as_array().unwrap();
1349 let find = |cmd: &str| {
1350 rows.iter()
1351 .find(|r| r["command"] == cmd)
1352 .unwrap_or_else(|| panic!("{cmd} missing from the schema — did it fall back to register_handler?"))
1353 };
1354
1355 for (cmd, req, resp) in [
1356 ("openinshell", "OpenInShellReq", "()"),
1357 ("renameeditorfile", "RenameEditorFileReq", "RenameEditorFileResult"),
1358 ("createeditorfile", "CreateEditorFileReq", "CreateEditorFileResult"),
1359 ("createeditordir", "CreateEditorDirReq", "CreateEditorDirResult"),
1360 ("deleteeditorfile", "DeleteEditorFileReq", "()"),
1361 ("createscratchfile", "CreateScratchFileReq", "CreateScratchFileResult"),
1362 ("movescratchfile", "MoveScratchFileReq", "MoveScratchFileResult"),
1363 ] {
1364 let row = find(cmd);
1365 assert_eq!(row["requestName"], req, "{cmd} request");
1366 assert_eq!(row["responseName"], resp, "{cmd} response");
1367 }
1368 }
1369
1370 #[test]
1380 fn deleteeditorfile_will_not_deserialize_without_recursive() {
1381 let err = serde_json::from_value::<DeleteEditorFileReq>(
1382 serde_json::json!({ "path": "/home/u/x" }),
1383 )
1384 .expect_err("recursive must not be defaultable");
1385 assert!(
1386 err.to_string().contains("recursive"),
1387 "expected the error to name the field, got {err}",
1388 );
1389
1390 for recursive in [true, false] {
1392 let req: DeleteEditorFileReq = serde_json::from_value(
1393 serde_json::json!({ "path": "/home/u/x", "recursive": recursive }),
1394 )
1395 .unwrap();
1396 assert_eq!(req.recursive, recursive);
1397 }
1398 }
1399
1400 #[test]
1405 fn createscratchfile_accepts_an_empty_request() {
1406 let req: CreateScratchFileReq = serde_json::from_value(serde_json::json!({}))
1407 .expect("both fields are optional");
1408 assert!(req.display_name.is_none());
1409 assert!(req.exclude_scratch_ids.is_none());
1410
1411 let full: CreateScratchFileReq = serde_json::from_value(serde_json::json!({
1412 "display_name": "Notes",
1413 "exclude_scratch_ids": ["a", "b"],
1414 }))
1415 .unwrap();
1416 assert_eq!(full.display_name.as_deref(), Some("Notes"));
1417 assert_eq!(full.exclude_scratch_ids.unwrap().len(), 2);
1418 }
1419
1420 #[test]
1430 fn writeagentconfig_files_produce_the_expected_managed_skill_paths() {
1431 let files = vec![
1432 AgentConfigFile { path: "CLAUDE.md".to_string(), content: "soul".to_string() },
1433 AgentConfigFile {
1434 path: ".claude/commands/deploy.md".to_string(),
1435 content: "deploy".to_string(),
1436 },
1437 AgentConfigFile {
1438 path: ".claude/skills/review/SKILL.md".to_string(),
1439 content: "review".to_string(),
1440 },
1441 AgentConfigFile { path: ".mcp.json".to_string(), content: "{}".to_string() },
1442 ];
1443 let managed = crate::backend::agent_config::managed_skill_file_paths(
1444 files.iter().map(|f| f.path.as_str()),
1445 );
1446 assert_eq!(managed.len(), 2, "expected exactly the two skill-derived paths: {managed:?}");
1447 assert!(managed.contains(".claude/commands/deploy.md"));
1448 assert!(managed.contains(".claude/skills/review/SKILL.md"));
1449 assert!(!managed.contains("CLAUDE.md"));
1450 assert!(!managed.contains(".mcp.json"));
1451 }
1452
1453 #[test]
1459 fn stale_skill_file_is_removed_across_two_materializations() {
1460 let work_dir = tempfile::tempdir().unwrap();
1461 let base_path = work_dir.path();
1462
1463 let launch1 = vec![AgentConfigFile {
1465 path: ".claude/skills/deploy/SKILL.md".to_string(),
1466 content: "body".to_string(),
1467 }];
1468 let managed1 = crate::backend::agent_config::managed_skill_file_paths(
1469 launch1.iter().map(|f| f.path.as_str()),
1470 );
1471 crate::backend::agent_config::cleanup_stale_managed_skill_files(base_path, &managed1);
1472 for f in &launch1 {
1473 let p = base_path.join(&f.path);
1474 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
1475 std::fs::write(&p, &f.content).unwrap();
1476 }
1477 crate::backend::agent_config::write_managed_skill_file_manifest(base_path, &managed1);
1478 let skill_md = base_path.join(".claude/skills/deploy/SKILL.md");
1479 assert!(skill_md.exists());
1480
1481 let launch2 = vec![AgentConfigFile {
1483 path: ".claude/commands/deploy.md".to_string(),
1484 content: "body".to_string(),
1485 }];
1486 let managed2 = crate::backend::agent_config::managed_skill_file_paths(
1487 launch2.iter().map(|f| f.path.as_str()),
1488 );
1489 crate::backend::agent_config::cleanup_stale_managed_skill_files(base_path, &managed2);
1490 for f in &launch2 {
1491 let p = base_path.join(&f.path);
1492 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
1493 std::fs::write(&p, &f.content).unwrap();
1494 }
1495 crate::backend::agent_config::write_managed_skill_file_manifest(base_path, &managed2);
1496
1497 assert!(
1498 !skill_md.exists(),
1499 "stale .claude/skills/deploy/SKILL.md must be removed by the writeagentconfig path too"
1500 );
1501 assert!(base_path.join(".claude/commands/deploy.md").exists());
1502 }
1503
1504 #[test]
1515 fn writeagentconfig_extracts_slug_and_injects_signing_keys_into_the_real_mcp_json_shape() {
1516 let store = crate::backend::storage::store::Store::open_in_memory().unwrap();
1517 let (_wan_dir, wan) = crate::backend::storage::wan_identity::attach_temp_wan_identity(&store);
1518 let files = vec![AgentConfigFile {
1519 path: ".mcp.json".to_string(),
1520 content: serde_json::to_string(&serde_json::json!({
1521 "mcpServers": {
1522 "agentmux": {
1523 "type": "stdio",
1524 "command": "agentmux-mcp",
1525 "args": [],
1526 "env": { "AGENTMUX_AGENT_ID": "aria" }
1527 }
1528 }
1529 }))
1530 .unwrap(),
1531 }];
1532
1533 let pos = files.iter().position(|f| f.path == ".mcp.json").unwrap();
1534 let agent_slug = serde_json::from_str::<serde_json::Value>(&files[pos].content)
1535 .ok()
1536 .and_then(|v| {
1537 v.pointer("/mcpServers/agentmux/env/AGENTMUX_AGENT_ID")
1538 .and_then(|s| s.as_str())
1539 .map(str::to_string)
1540 })
1541 .expect("must extract the agent slug from the real .mcp.json shape");
1542 assert_eq!(agent_slug, "aria");
1543
1544 let rewritten = crate::backend::agent_config::inject_jekt_signing_keys_into_mcp_json(
1545 &files[pos].content,
1546 &store,
1547 &agent_slug,
1548 )
1549 .expect("keys must ensure successfully against a fresh in-memory store");
1550 let parsed: serde_json::Value = serde_json::from_str(&rewritten).unwrap();
1551 let env = &parsed["mcpServers"]["agentmux"]["env"];
1552 assert_eq!(env["AGENTMUX_AGENT_ID"], "aria");
1553 assert!(env["AGENTMUX_JEKT_KEY"].is_string() && !env["AGENTMUX_JEKT_KEY"].as_str().unwrap().is_empty());
1554 assert!(env["AGENTMUX_LAN_KEY"].is_string() && !env["AGENTMUX_LAN_KEY"].as_str().unwrap().is_empty());
1555 assert!(env["AGENTMUX_WAN_KEY"].is_string() && !env["AGENTMUX_WAN_KEY"].as_str().unwrap().is_empty());
1560 assert_ne!(env["AGENTMUX_WAN_KEY"], env["AGENTMUX_LAN_KEY"], "WAN and LAN keys must be independent");
1561 assert_eq!(
1566 env["AGENTMUX_HOST_LABEL"],
1567 serde_json::json!(wan.instance_ensure("unused").unwrap().instance_id),
1568 "the MCP env must carry the instance id that certifies this agent's WAN key"
1569 );
1570
1571 }
1572}