1use std::collections::{HashMap, HashSet};
20
21use chrono::Utc;
22use serde_json::{json, Value};
23
24use crate::backend::storage::store::{derive_slug, AgentSkill};
25
26pub const SKILL_TYPE_AGENT_SKILL: &str = "agent-skill";
35
36#[derive(Debug, Clone)]
38pub struct AgentConfigFile {
39 pub filename: String,
41 pub content: String,
43}
44
45pub fn build_config_files(
61 content_map: &HashMap<String, String>,
62 skills: &[AgentSkill],
63 agent_name: &str,
64 agent_id: &str,
65 agent_slug: &str,
66 working_directory: &str,
67 provider_id: &str,
68) -> Vec<AgentConfigFile> {
69 let mut files: Vec<AgentConfigFile> = Vec::new();
70
71 let mut template_vars: HashMap<String, String> = HashMap::new();
73 template_vars.insert("AGENT".to_string(), agent_name.to_string());
74 template_vars.insert("AGENT_DISPLAY".to_string(), agent_name.to_string());
75 template_vars.insert("AGENT_SLUG".to_string(), agent_slug.to_string());
76 template_vars.insert("AGENT_ID".to_string(), agent_id.to_string());
77 template_vars.insert("WORKING_DIR".to_string(), working_directory.to_string());
78 template_vars.insert("DATE".to_string(), Utc::now().format("%Y-%m-%d").to_string());
80
81 let mut instructions_parts: Vec<String> = Vec::new();
85
86 if let Some(soul) = content_map.get("soul") {
87 instructions_parts.push(expand_template(soul, &template_vars));
88 }
89 if let Some(agentmd) = content_map.get("agentmd") {
90 if !instructions_parts.is_empty() {
91 instructions_parts.push("\n---\n".to_string());
92 }
93 instructions_parts.push(expand_template(agentmd, &template_vars));
94 }
95 if let Some(memory) = content_map.get("memory") {
96 instructions_parts.push(format!("\n{BUNDLE_SECTION_HEADING}\n"));
97 instructions_parts.push(memory.clone());
98 }
99
100 let is_claude = crate::backend::providers::get_provider(provider_id).is_some_and(|p| p.id == "claude");
105 let own_file = skills_with_their_own_file(skills);
106 let indexed: Vec<_> = skills.iter().enumerate().filter(|(i, _)| !is_claude || !own_file[*i]).map(|(_, s)| s).collect();
107 if !indexed.is_empty() {
108 instructions_parts.push("\n# Available Skills\n\n".to_string());
109 instructions_parts.push("Use `/<trigger>` to invoke a skill.\n\n".to_string());
110 for skill in indexed {
111 let trigger_part = if skill.trigger.is_empty() {
112 String::new()
113 } else {
114 format!(" (trigger: /{})", skill.trigger)
115 };
116 let desc_part = if skill.description.is_empty() {
117 String::new()
118 } else {
119 format!(" \u{2014} {}", skill.description)
120 };
121 instructions_parts.push(format!("- **{}**{}{}\n", skill.name, trigger_part, desc_part));
122 }
123 }
124
125 let instructions_filename = crate::backend::providers::get_provider(provider_id)
133 .and_then(|p| p.startup_instructions_filename);
134 if let Some(filename) = instructions_filename {
135 files.push(AgentConfigFile {
136 filename: filename.to_string(),
137 content: instructions_parts.join(""),
138 });
139 }
140
141 let mut used_skill_slugs: HashSet<String> = HashSet::new();
148 for skill in skills {
149 if skill.content.is_empty() {
150 continue;
151 }
152 if skill.skill_type == SKILL_TYPE_AGENT_SKILL {
153 let slug = unique_skill_slug(&skill.name, &mut used_skill_slugs);
154 let content = expand_template(&skill.content, &template_vars);
155 files.push(AgentConfigFile {
156 filename: format!(".claude/skills/{slug}/SKILL.md"),
157 content: render_skill_md(&slug, &skill.description, &content),
158 });
159 } else if let Some(safe_trigger) = sanitize_trigger(&skill.trigger) {
160 let content = expand_template(&skill.content, &template_vars);
161 files.push(AgentConfigFile {
162 filename: format!(".claude/commands/{safe_trigger}.md"),
163 content,
164 });
165 }
166 }
167
168 let user_hooks = content_map.get("hooks").map(|s| s.as_str());
184 let user_settings = content_map.get("settings").map(|s| s.as_str());
185 if let Some(settings_json) = build_settings_with_hooks(user_settings, user_hooks) {
186 files.push(AgentConfigFile {
187 filename: ".claude/settings.json".to_string(),
188 content: settings_json,
189 });
190 }
191
192 let mcp_content = content_map.get("mcp").map(|s| s.as_str());
199 if let Some(mcp_json) = build_mcp_config(mcp_content, agent_slug, "") {
200 files.push(AgentConfigFile {
201 filename: ".mcp.json".to_string(),
202 content: mcp_json,
203 });
204 }
205
206 files
207}
208
209pub fn build_mcp_config(
221 user_mcp_content: Option<&str>,
222 agent_slug: &str,
223 agent_bus_id: &str,
224) -> Option<String> {
225 let mut env_map = serde_json::Map::new();
230 if !agent_slug.is_empty() {
231 env_map.insert("AGENTMUX_AGENT_ID".to_string(), json!(agent_slug));
232 }
233 if !agent_bus_id.is_empty() {
234 env_map.insert("AGENTMUX_AGENT_BUS_ID".to_string(), json!(agent_bus_id));
235 }
236
237 let agentmux_server = json!({
238 "type": "stdio",
239 "command": "agentmux-mcp",
240 "args": [],
241 "env": Value::Object(env_map),
242 });
243
244 let mut mcp_servers = serde_json::Map::new();
245 mcp_servers.insert("agentmux".to_string(), agentmux_server);
246
247 if let Some(raw) = user_mcp_content {
249 match serde_json::from_str::<Value>(raw) {
250 Ok(Value::Object(user_obj)) => {
251 if let Some(Value::Object(user_servers)) = user_obj.get("mcpServers") {
252 for (k, v) in user_servers {
253 mcp_servers.insert(k.clone(), v.clone());
254 }
255 }
256 }
257 Ok(_) => {
258 }
260 Err(_) => {
261 tracing::error!("agent_config: invalid MCP JSON in agent content, using auto-injected only");
263 }
264 }
265 }
266
267 let result = json!({ "mcpServers": Value::Object(mcp_servers) });
268 match serde_json::to_string_pretty(&result) {
269 Ok(s) => Some(s),
270 Err(e) => {
271 tracing::error!("agent_config: failed to serialize MCP config: {e}");
272 None
273 }
274 }
275}
276
277pub fn build_mcp_config_from_refs(
289 servers: &[crate::backend::storage::McpServer],
290 user_mcp_blob: Option<&str>,
291 agent_slug: &str,
292 agent_bus_id: &str,
293) -> Option<String> {
294 if servers.is_empty() {
295 return build_mcp_config(user_mcp_blob, agent_slug, agent_bus_id);
296 }
297
298 let mut env_map = serde_json::Map::new();
299 if !agent_slug.is_empty() {
300 env_map.insert("AGENTMUX_AGENT_ID".to_string(), json!(agent_slug));
301 }
302 if !agent_bus_id.is_empty() {
303 env_map.insert("AGENTMUX_AGENT_BUS_ID".to_string(), json!(agent_bus_id));
304 }
305 let agentmux_server = json!({
306 "type": "stdio",
307 "command": "agentmux-mcp",
308 "args": [],
309 "env": Value::Object(env_map),
310 });
311
312 let mut mcp_servers = serde_json::Map::new();
313 mcp_servers.insert("agentmux".to_string(), agentmux_server);
314
315 if let Some(raw) = user_mcp_blob {
317 match serde_json::from_str::<Value>(raw) {
318 Ok(Value::Object(user_obj)) => {
319 if let Some(Value::Object(user_servers)) = user_obj.get("mcpServers") {
320 for (k, v) in user_servers {
321 if k == "agentmux" {
322 continue;
323 }
324 mcp_servers.insert(k.clone(), v.clone());
325 }
326 }
327 }
328 Ok(_) => {}
329 Err(_) => {
330 tracing::error!("agent_config: invalid MCP JSON in legacy blob; skipping blob merge");
331 }
332 }
333 }
334
335 crate::backend::mcp_launch::insert_server_rows(&mut mcp_servers, servers);
337
338 let result = json!({ "mcpServers": Value::Object(mcp_servers) });
339 match serde_json::to_string_pretty(&result) {
340 Ok(s) => Some(s),
341 Err(e) => {
342 tracing::error!("agent_config: failed to serialize ref-based MCP config: {e}");
343 None
344 }
345 }
346}
347
348pub fn skills_to_agent_skills(
351 skills: &[crate::backend::storage::Skill],
352 agent_id: &str,
353) -> Vec<crate::backend::storage::AgentSkill> {
354 skills
355 .iter()
356 .map(|s| crate::backend::storage::AgentSkill {
357 id: s.id.clone(),
358 agent_id: agent_id.to_string(),
359 name: s.name.clone(),
360 trigger: s.trigger.clone(),
361 skill_type: s.skill_type.clone(),
362 description: s.description.clone(),
363 content: s.content.clone(),
364 created_at: s.created_at,
365 })
366 .collect()
367}
368
369fn prepend_user_hook_array(
379 hooks_obj: &mut serde_json::Map<String, Value>,
380 key: &str,
381 user_value: Value,
382) {
383 match user_value {
384 Value::Array(mut user_arr) => {
385 if let Some(Value::Array(ours)) = hooks_obj.remove(key) {
386 user_arr.extend(ours);
387 }
388 hooks_obj.insert(key.to_string(), Value::Array(user_arr));
389 }
390 _ => {
391 tracing::warn!(
392 "agent_config: user settings.hooks.{key} is not an array; dropped"
393 );
394 }
395 }
396}
397
398fn agentmux_sessionstart_entry() -> serde_json::Value {
403 let commands: Vec<serde_json::Value> = (1..=crate::backend::memory_delivery::HOOK_PARTS)
404 .map(|n| json!({ "type": "command", "command": format!("agentmux-bashwrap sessionstart --part {n}") }))
405 .collect();
406 json!({ "hooks": commands })
407}
408
409pub fn build_settings_with_hooks(
431 user_settings_content: Option<&str>,
432 user_hooks_content: Option<&str>,
433) -> Option<String> {
434 use serde_json::Value;
435 let agentmux_pretooluse = json!({
436 "matcher": "^(Bash|.*[Bb]ash.*)$",
437 "hooks": [
438 {
439 "type": "command",
440 "command": "agentmux-bashwrap hook"
441 }
442 ]
443 });
444 let agentmux_precompact_manual = json!({
453 "matcher": "manual",
454 "hooks": [
455 {
456 "type": "command",
457 "command": "agentmux-bashwrap precompact --trigger=manual"
458 }
459 ]
460 });
461 let agentmux_precompact_auto = json!({
462 "matcher": "auto",
463 "hooks": [
464 {
465 "type": "command",
466 "command": "agentmux-bashwrap precompact --trigger=auto"
467 }
468 ]
469 });
470 let agentmux_sessionstart = agentmux_sessionstart_entry();
479 let mut hooks_obj = serde_json::Map::new();
480 let mut pretooluse_entries: Vec<Value> = Vec::new();
481 let mut precompact_entries: Vec<Value> = Vec::new();
482 let mut sessionstart_entries: Vec<Value> = Vec::new();
483
484 if let Some(raw) = user_hooks_content {
489 match serde_json::from_str::<Value>(raw) {
490 Ok(Value::Object(user_obj)) => {
491 for (k, v) in user_obj {
492 if k == "PreToolUse" {
493 if let Value::Array(arr) = v {
494 pretooluse_entries.extend(arr);
495 } else {
496 tracing::warn!(
497 "agent_config: user hooks.PreToolUse is not an array; dropped"
498 );
499 }
500 } else if k == "PreCompact" {
501 if let Value::Array(arr) = v {
502 precompact_entries.extend(arr);
503 } else {
504 tracing::warn!(
505 "agent_config: user hooks.PreCompact is not an array; dropped"
506 );
507 }
508 } else if k == "SessionStart" {
509 if let Value::Array(arr) = v {
510 sessionstart_entries.extend(arr);
511 } else {
512 tracing::warn!(
513 "agent_config: user hooks.SessionStart is not an array; dropped"
514 );
515 }
516 } else {
517 hooks_obj.insert(k, v);
518 }
519 }
520 }
521 Ok(other) => {
522 tracing::warn!(
523 kind = ?other,
524 "agent_config: user hooks top-level value is not an object; dropped"
525 );
526 }
527 Err(e) => {
528 tracing::warn!(
529 error = %e,
530 "agent_config: failed to parse user hooks JSON; dropped"
531 );
532 }
533 }
534 }
535 pretooluse_entries.push(agentmux_pretooluse);
539 hooks_obj.insert("PreToolUse".to_string(), Value::Array(pretooluse_entries));
540 precompact_entries.push(agentmux_precompact_manual);
541 precompact_entries.push(agentmux_precompact_auto);
542 hooks_obj.insert("PreCompact".to_string(), Value::Array(precompact_entries));
543 sessionstart_entries.push(agentmux_sessionstart);
544 hooks_obj.insert("SessionStart".to_string(), Value::Array(sessionstart_entries));
545
546 let mut settings_obj = serde_json::Map::new();
550 if let Some(raw) = user_settings_content {
551 match serde_json::from_str::<Value>(raw) {
552 Ok(Value::Object(user_obj)) => {
553 for (k, v) in user_obj {
554 settings_obj.insert(k, v);
555 }
556 }
557 Ok(_other) => {
558 tracing::warn!(
559 "agent_config: user settings.json top-level is not an object; dropped"
560 );
561 }
562 Err(e) => {
563 tracing::warn!(
564 error = %e,
565 "agent_config: failed to parse user settings.json; dropped"
566 );
567 }
568 }
569 }
570 if let Some(Value::Object(existing_hooks)) = settings_obj.get("hooks").cloned() {
587 for (k, v) in existing_hooks {
588 if k == "PreToolUse" || k == "PreCompact" || k == "SessionStart" {
589 prepend_user_hook_array(&mut hooks_obj, &k, v);
590 continue;
591 }
592 hooks_obj.entry(k).or_insert(v);
593 }
594 }
595 settings_obj.insert("hooks".to_string(), Value::Object(hooks_obj));
596
597 {
602 let bashwrap_allow = Value::String("Bash(agentmux-bashwrap *)".to_string());
606 let mut allow_arr = match settings_obj.get("permissions") {
607 Some(Value::Object(perms)) => match perms.get("allow") {
608 Some(Value::Array(arr)) => arr.clone(),
609 _ => Vec::new(),
610 },
611 _ => Vec::new(),
612 };
613 if !allow_arr.iter().any(|v| v == &bashwrap_allow) {
614 allow_arr.push(bashwrap_allow);
615 }
616 let mut perms_obj = match settings_obj.remove("permissions") {
617 Some(Value::Object(obj)) => obj,
618 _ => serde_json::Map::new(),
619 };
620 perms_obj.insert("allow".to_string(), Value::Array(allow_arr));
621 settings_obj.insert("permissions".to_string(), Value::Object(perms_obj));
622 }
623
624 let attribution = settings_obj.entry("attribution").or_insert_with(|| json!({}));
632 if !attribution.is_object() {
633 *attribution = json!({});
634 }
635 if let Value::Object(fields) = attribution {
636 for field in ["commit", "pr"] {
637 let value = fields.entry(field).or_insert_with(|| Value::String(String::new()));
638 if !value.is_string() {
639 *value = Value::String(String::new());
640 }
641 }
642 }
643
644 match serde_json::to_string_pretty(&Value::Object(settings_obj)) {
645 Ok(s) => Some(s),
646 Err(e) => {
647 tracing::error!("agent_config: failed to serialize settings.json: {e}");
648 None
649 }
650 }
651}
652
653pub fn expand_template(content: &str, vars: &HashMap<String, String>) -> String {
660 let mut result = String::with_capacity(content.len());
663 let bytes = content.as_bytes();
664 let len = bytes.len();
665 let mut i = 0;
666
667 while i < len {
668 if i + 1 < len && bytes[i] == b'{' && bytes[i + 1] == b'{' {
670 if let Some(rel) = content[i + 2..].find("}}") {
672 let key = &content[i + 2..i + 2 + rel];
673 if key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') {
675 if let Some(val) = vars.get(key) {
676 result.push_str(val);
677 } else {
678 result.push_str(&content[i..i + 2 + rel + 2]);
680 }
681 i += 2 + rel + 2; continue;
683 }
684 }
685 }
686 let ch = content[i..].chars().next().unwrap();
691 result.push(ch);
692 i += ch.len_utf8();
693 }
694
695 result
696}
697
698const SKILL_DESCRIPTION_MAX_LEN: usize = 1024;
700
701fn truncate_utf16_units(s: &str, max_units: usize) -> String {
706 let mut units = 0usize;
707 for (byte_idx, ch) in s.char_indices() {
708 units += ch.len_utf16();
709 if units > max_units {
710 return s[..byte_idx].to_string();
711 }
712 }
713 s.to_string()
714}
715
716pub(crate) fn render_skill_md(slug: &str, description: &str, body: &str) -> String {
739 let owned_description;
740 let description = if description.trim().is_empty() {
741 "No description provided."
742 } else {
743 owned_description = truncate_utf16_units(description, SKILL_DESCRIPTION_MAX_LEN);
744 owned_description.as_str()
745 };
746 let name_yaml =
747 serde_json::to_string(slug).expect("string serialization is infallible");
748 let description_yaml =
749 serde_json::to_string(description).expect("string serialization is infallible");
750 format!("---\nname: {name_yaml}\ndescription: {description_yaml}\n---\n\n{body}")
751}
752
753fn skills_with_their_own_file(skills: &[AgentSkill]) -> Vec<bool> {
763 let command_key = |s: &AgentSkill| {
764 (!s.content.is_empty() && s.skill_type != SKILL_TYPE_AGENT_SKILL)
765 .then(|| sanitize_trigger(&s.trigger).filter(|t| t.is_ascii()).map(str::to_lowercase))
766 .flatten()
767 };
768 let mut last_writer: HashMap<String, usize> = HashMap::new();
769 for (i, s) in skills.iter().enumerate() {
770 if let Some(key) = command_key(s) {
771 last_writer.insert(key, i);
772 }
773 }
774 skills
775 .iter()
776 .enumerate()
777 .map(|(i, s)| {
778 !s.content.is_empty()
781 && (s.skill_type == SKILL_TYPE_AGENT_SKILL || command_key(s).is_some_and(|k| last_writer.get(&k) == Some(&i)))
782 })
783 .collect()
784}
785
786fn sanitize_trigger(trigger: &str) -> Option<&str> {
798 if trigger.is_empty() || trigger == "." || trigger == ".." {
799 return None;
800 }
801 if trigger.contains('/') || trigger.contains('\\') {
802 return None;
803 }
804 Some(trigger)
805}
806
807fn skill_name_slug(name: &str) -> String {
814 let base = derive_slug(name).replace('_', "-");
815 let collapsed: String = base
816 .split('-')
817 .filter(|s| !s.is_empty())
818 .collect::<Vec<_>>()
819 .join("-");
820 if collapsed.is_empty() {
821 "skill".to_string()
822 } else {
823 collapsed
824 }
825}
826
827pub(crate) fn unique_skill_slug(name: &str, used: &mut HashSet<String>) -> String {
838 const MAX_LEN: usize = 64;
839 let base = skill_name_slug(name);
840 if used.insert(base.clone()) {
841 return base;
842 }
843 let mut n: u32 = 2;
844 loop {
845 let suffix = format!("-{n}");
846 let max_base_len = MAX_LEN.saturating_sub(suffix.len());
847 let mut truncated_base = base.clone();
848 if truncated_base.len() > max_base_len {
849 let mut end = max_base_len;
850 while end > 0 && !truncated_base.is_char_boundary(end) {
851 end -= 1;
852 }
853 truncated_base.truncate(end);
854 }
855 let candidate = format!("{truncated_base}{suffix}");
856 if used.insert(candidate.clone()) {
857 return candidate;
858 }
859 n += 1;
860 }
861}
862
863pub const MANAGED_SKILL_FILES_MANIFEST: &str = ".claude/.agentmux-managed-skill-files.json";
873
874pub fn managed_skill_file_paths<'a>(
880 filenames: impl Iterator<Item = &'a str>,
881) -> std::collections::BTreeSet<String> {
882 filenames
883 .filter(|f| f.starts_with(".claude/commands/") || f.starts_with(".claude/skills/"))
884 .map(|f| f.to_string())
885 .collect()
886}
887
888pub fn cleanup_stale_managed_skill_files(
909 base_path: &std::path::Path,
910 new_managed_paths: &std::collections::BTreeSet<String>,
911) {
912 let manifest_path = base_path.join(MANAGED_SKILL_FILES_MANIFEST);
913 let Ok(raw) = std::fs::read_to_string(&manifest_path) else {
914 return;
915 };
916 let Ok(old_paths) = serde_json::from_str::<Vec<String>>(&raw) else {
917 return;
918 };
919 let Ok(wd) = crate::backend::workdir_fs::Workdir::open(base_path) else {
920 return;
921 };
922 for old in &old_paths {
923 if new_managed_paths.contains(old) {
924 continue;
925 }
926 match wd.remove_file(old) {
929 Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
930 tracing::warn!(
931 work_dir = %base_path.display(),
932 path = %old,
933 error = %e,
934 "cleanup_stale_managed_skill_files: refusing to delete a manifest path outside the working directory"
935 );
936 continue;
937 }
938 _ => {}
939 }
940 if let Some(parent) = std::path::Path::new(old).parent().and_then(|p| p.to_str()).filter(|p| !p.is_empty()) {
944 let _ = wd.remove_dir(parent);
945 }
946 }
947}
948
949pub fn write_managed_skill_file_manifest(
955 base_path: &std::path::Path,
956 new_managed_paths: &std::collections::BTreeSet<String>,
957) {
958 if let Ok(manifest_json) = serde_json::to_string(new_managed_paths) {
959 let written = crate::backend::workdir_fs::Workdir::open(base_path)
960 .and_then(|wd| wd.write(MANAGED_SKILL_FILES_MANIFEST, manifest_json.as_bytes(), false));
961 if let Err(e) = written {
962 tracing::warn!(
963 work_dir = %base_path.display(),
964 error = %e,
965 "write_managed_skill_file_manifest: failed to write manifest; \
966 stale file cleanup may be skipped on the next materialization"
967 );
968 }
969 }
970}
971
972pub const CLAUDE_MD_MANAGED_MARKER: &str = "<!-- agentmux:managed-claude-md -->";
983
984pub const AGENTMUX_MEMORY_FILENAME: &str = ".claude/AGENTMUX_MEMORY.md";
988
989pub const BUNDLE_SECTION_HEADING: &str = "# Memory";
998
999const CLAUDE_MD_OWNERSHIP_MARKER_PATH: &str = ".claude/.agentmux-claude-md-ownership.json";
1005
1006pub const CLAUDE_MD_PRE_ADOPT_BACKUP: &str = ".claude/CLAUDE.md.pre-adopt";
1008
1009fn is_generated_skill_row(line: &str) -> bool {
1013 let Some(rest) = line.strip_prefix("- **") else { return false };
1014 let Some(close) = rest.find("**") else { return false };
1015 if close == 0 {
1016 return false;
1017 }
1018 let mut rest = &rest[close + 2..];
1019 if let Some(after) = rest.strip_prefix(" (trigger: /") {
1020 let Some(end) = after.find(')') else { return false };
1021 if end == 0 {
1022 return false;
1023 }
1024 rest = &after[end + 1..];
1025 }
1026 rest.is_empty() || rest.strip_prefix(" \u{2014} ").is_some_and(|d| !d.is_empty())
1027}
1028
1029
1030pub fn is_legacy_agentmux_claude_md(content: &str) -> bool {
1037 let import_line = format!("@{AGENTMUX_MEMORY_FILENAME}");
1038 let mut lines = content.lines().map(str::trim_end).filter(|l| !l.trim().is_empty()).peekable();
1039 if lines.next() != Some("# Available Skills") || lines.next() != Some("Use `/<trigger>` to invoke a skill.") {
1040 return false;
1041 }
1042 let mut skill_rows = 0;
1043 while lines.next_if(|l| is_generated_skill_row(l)).is_some() {
1044 skill_rows += 1;
1045 }
1046 let tail: Vec<&str> = lines.collect();
1047 skill_rows > 0
1048 && (tail.is_empty()
1049 || tail == [import_line.as_str()]
1050 || tail == [CLAUDE_MD_IMPORT_MARKER_COMMENT, import_line.as_str()])
1051}
1052
1053const CLAUDE_MD_IMPORT_MARKER_COMMENT: &str =
1056 "<!-- agentmux:managed-import (safe to delete this line to opt out) -->";
1057
1058#[derive(serde::Serialize, serde::Deserialize, Default)]
1059struct ClaudeMdOwnershipMarker {
1060 import_line_offered: bool,
1061}
1062
1063const STARTUP_INSTRUCTIONS_MANAGED_MARKER: &str = "<!-- agentmux:managed-startup-instructions -->";
1133
1134pub fn is_agentmux_managed_instructions(content: &str) -> bool {
1157 content.starts_with(CLAUDE_MD_MANAGED_MARKER)
1158 || content.starts_with(STARTUP_INSTRUCTIONS_MANAGED_MARKER)
1159}
1160
1161pub fn write_startup_instructions_respecting_existing(
1192 base_path: &std::path::Path,
1193 filename: &str,
1194 content: &str,
1195) -> std::io::Result<()> {
1196 let path = base_path.join(filename);
1197
1198 let existing = match std::fs::read_to_string(&path) {
1203 Ok(content) => Some(Ok(content)),
1204 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
1205 Err(e) => Some(Err(e)),
1206 };
1207 let agentmux_owns_it =
1208 matches!(&existing, Some(Ok(content)) if content.starts_with(STARTUP_INSTRUCTIONS_MANAGED_MARKER));
1209
1210 if agentmux_owns_it || existing.is_none() {
1211 let marked_content = format!("{STARTUP_INSTRUCTIONS_MANAGED_MARKER}\n\n{content}");
1212 let written = crate::backend::workdir_fs::Workdir::open(base_path)
1216 .and_then(|wd| wd.write(filename, marked_content.as_bytes(), false));
1217 return match written {
1218 Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
1219 tracing::warn!(path = %path.display(), error = %e, "write_startup_instructions_respecting_existing: not writing");
1220 Ok(())
1221 }
1222 other => other,
1223 };
1224 }
1225
1226 tracing::warn!(
1227 path = %path.display(),
1228 "write_startup_instructions_respecting_existing: pre-existing, \
1229 non-AgentMux-authored file — leaving it untouched; this agent's \
1230 Global Bundle/Soul/AgentMD content is not delivered via this file \
1231 for this launch"
1232 );
1233 Ok(())
1234}
1235
1236pub const MANAGED_MCP_SERVERS_MANIFEST: &str = ".claude/.agentmux-managed-mcp-servers.json";
1242
1243pub const MCP_JSON_WRITE_LOCK: &str = ".claude/.agentmux-mcp-json.lock";
1246
1247pub fn write_mcp_json_respecting_user_servers(
1289 base_path: &std::path::Path,
1290 generated_content: &str,
1291) -> std::io::Result<()> {
1292 use serde_json::{Map, Value};
1293
1294 let wd = crate::backend::workdir_fs::Workdir::open(base_path)?;
1295 let path = match wd.resolve(MANAGED_MCP_SERVERS_MANIFEST).and_then(|_| wd.resolve(".mcp.json")) {
1302 Ok(path) => path,
1303 Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
1304 tracing::warn!(error = %e, "write_mcp_json_respecting_user_servers: not writing .mcp.json");
1305 return Ok(());
1306 }
1307 Err(e) => return Err(e),
1308 };
1309 let generated: Value = serde_json::from_str(generated_content).map_err(|e| {
1310 std::io::Error::new(std::io::ErrorKind::InvalidData, format!("generated .mcp.json is not JSON: {e}"))
1311 })?;
1312 let Some(generated_obj) = generated.as_object() else {
1313 return Err(std::io::Error::new(
1314 std::io::ErrorKind::InvalidData,
1315 "generated .mcp.json is not a JSON object",
1316 ));
1317 };
1318 let _dir_lock = match wd.open_lock_file(MCP_JSON_WRITE_LOCK) {
1323 Ok(file) => {
1324 file.lock()?;
1325 Some(file)
1326 }
1327 Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
1328 tracing::warn!(error = %e, "write_mcp_json_respecting_user_servers: writing .mcp.json without the directory lock");
1329 None
1330 }
1331 Err(e) => return Err(e),
1332 };
1333
1334 let ours: Map<String, Value> = generated_obj
1335 .get("mcpServers")
1336 .and_then(Value::as_object)
1337 .cloned()
1338 .unwrap_or_default();
1339
1340 let existing: Option<Map<String, Value>> = match std::fs::read_to_string(&path) {
1341 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
1342 Err(e) => {
1343 tracing::warn!(
1344 path = %path.display(),
1345 error = %e,
1346 "write_mcp_json_respecting_user_servers: existing .mcp.json is unreadable — \
1347 leaving it untouched; this agent's MCP servers are not written for this launch"
1348 );
1349 return Ok(());
1350 }
1351 Ok(raw) => match serde_json::from_str::<Value>(&raw) {
1352 Ok(Value::Object(obj))
1353 if obj.get("mcpServers").map_or(true, Value::is_object) =>
1354 {
1355 Some(obj)
1356 }
1357 _ => {
1358 tracing::warn!(
1359 path = %path.display(),
1360 "write_mcp_json_respecting_user_servers: existing .mcp.json is not a JSON \
1361 object with an object `mcpServers` — leaving it untouched; this agent's MCP \
1362 servers are not written for this launch"
1363 );
1364 return Ok(());
1365 }
1366 },
1367 };
1368
1369 let merged = match existing {
1370 None => generated_obj.clone(),
1371 Some(mut obj) => {
1372 let existing_servers = obj
1373 .get("mcpServers")
1374 .and_then(Value::as_object)
1375 .cloned()
1376 .unwrap_or_default();
1377 let previously_managed: std::collections::BTreeSet<String> =
1378 match read_managed_mcp_server_names(base_path) {
1379 Some(names) => names,
1380 None if existing_servers.contains_key("agentmux") => {
1381 existing_servers.keys().cloned().collect()
1382 }
1383 None => Default::default(),
1384 };
1385 let mut servers: Map<String, Value> = existing_servers
1386 .into_iter()
1387 .filter(|(name, _)| !previously_managed.contains(name) && !ours.contains_key(name))
1388 .collect();
1389 servers.extend(ours.clone());
1390 for (k, v) in generated_obj {
1391 if k != "mcpServers" {
1392 obj.insert(k.clone(), v.clone());
1393 }
1394 }
1395 obj.insert("mcpServers".to_string(), Value::Object(servers));
1396 obj
1397 }
1398 };
1399
1400 let body = serde_json::to_string_pretty(&Value::Object(merged))
1401 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
1402 wd.write(".mcp.json", body.as_bytes(), true)?;
1403 write_managed_mcp_server_names(&wd, ours.keys());
1404 Ok(())
1405}
1406
1407fn read_managed_mcp_server_names(base_path: &std::path::Path) -> Option<std::collections::BTreeSet<String>> {
1408 let raw = std::fs::read_to_string(base_path.join(MANAGED_MCP_SERVERS_MANIFEST)).ok()?;
1409 serde_json::from_str(&raw).ok()
1410}
1411
1412fn write_managed_mcp_server_names<'a>(
1415 wd: &crate::backend::workdir_fs::Workdir,
1416 names: impl Iterator<Item = &'a String>,
1417) {
1418 let names: std::collections::BTreeSet<&String> = names.collect();
1419 let result = serde_json::to_string(&names)
1423 .map_err(std::io::Error::other)
1424 .and_then(|json| wd.write(MANAGED_MCP_SERVERS_MANIFEST, json.as_bytes(), true));
1425 if let Err(e) = result {
1426 tracing::warn!(
1427 work_dir = %wd.base().display(),
1428 error = %e,
1429 "write_managed_mcp_server_names: failed to write manifest; the next launch \
1430 falls back to treating an agentmux-bearing .mcp.json as fully managed"
1431 );
1432 }
1433}
1434
1435pub fn write_claude_md_respecting_ownership(
1441 base_path: &std::path::Path,
1442 generated_content: &str,
1443) -> std::io::Result<()> {
1444 let claude_md_path = base_path.join("CLAUDE.md");
1445
1446 let existing = match std::fs::read_to_string(&claude_md_path) {
1450 Ok(content) => Some(Ok(content)),
1451 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
1452 Err(e) => Some(Err(e)),
1453 };
1454
1455 let mut agentmux_owns_it =
1456 matches!(&existing, Some(Ok(content)) if content.starts_with(CLAUDE_MD_MANAGED_MARKER));
1457
1458 let wd = crate::backend::workdir_fs::Workdir::open(base_path)?;
1463 let claude_md_writable = wd.resolve("CLAUDE.md").is_ok();
1464
1465 if let Some(Ok(content)) = &existing {
1471 if !agentmux_owns_it && claude_md_writable && is_legacy_agentmux_claude_md(content) {
1472 match back_up_legacy_claude_md(&wd, content) {
1473 Ok(()) => {
1474 tracing::info!(path = %claude_md_path.display(), "CLAUDE.md written by an older AgentMux: adopting it as managed");
1475 agentmux_owns_it = true;
1476 }
1477 Err(e) => tracing::warn!(
1478 path = %claude_md_path.display(),
1479 error = %e,
1480 "write_claude_md_respecting_ownership: couldn't back up a legacy CLAUDE.md; leaving it as is"
1481 ),
1482 }
1483 }
1484 }
1485
1486 if !claude_md_writable {
1489 tracing::warn!(path = %claude_md_path.display(), "write_claude_md_respecting_ownership: CLAUDE.md is a symlink or outside the workspace; not writing through it");
1490 return Ok(());
1491 }
1492 if agentmux_owns_it || existing.is_none() {
1497 let content = format!("{CLAUDE_MD_MANAGED_MARKER}\n\n{generated_content}");
1498 return wd.write("CLAUDE.md", content.as_bytes(), false);
1499 }
1500
1501 if let Err(e) = wd.write(AGENTMUX_MEMORY_FILENAME, generated_content.as_bytes(), false) {
1505 tracing::warn!(work_dir = %base_path.display(), error = %e, "write_claude_md_respecting_ownership: failed to write AGENTMUX_MEMORY.md this launch");
1506 return Ok(());
1507 }
1508
1509 let Some(Ok(existing_content)) = existing else {
1514 tracing::warn!(path = %claude_md_path.display(), "write_claude_md_respecting_ownership: existing CLAUDE.md unreadable; treating as foreign, skipping @import offer this launch");
1515 return Ok(());
1516 };
1517
1518 let marker_json = serde_json::to_string(&ClaudeMdOwnershipMarker { import_line_offered: true })
1527 .unwrap_or_default();
1528 match wd.create_new(CLAUDE_MD_OWNERSHIP_MARKER_PATH, marker_json.as_bytes()) {
1529 Ok(true) => {
1530 let import_needle = format!("@{AGENTMUX_MEMORY_FILENAME}");
1531 if !existing_content.contains(&import_needle) {
1535 let import_block = format!("\n\n{CLAUDE_MD_IMPORT_MARKER_COMMENT}\n{import_needle}\n");
1536 if let Err(e) = wd.append("CLAUDE.md", import_block.as_bytes()) {
1540 tracing::warn!(path = %claude_md_path.display(), error = %e, "write_claude_md_respecting_ownership: failed to append the @import line this launch");
1541 let _ = wd.remove_file(CLAUDE_MD_OWNERSHIP_MARKER_PATH);
1546 }
1547 }
1548 }
1549 Ok(false) => {
1550 }
1553 Err(e) => {
1554 tracing::warn!(work_dir = %base_path.display(), error = %e, "write_claude_md_respecting_ownership: failed to create ownership marker");
1555 }
1556 }
1557
1558 Ok(())
1559}
1560
1561fn back_up_legacy_claude_md(wd: &crate::backend::workdir_fs::Workdir, content: &str) -> std::io::Result<()> {
1566 if wd.create_new(CLAUDE_MD_PRE_ADOPT_BACKUP, content.as_bytes())? {
1567 return Ok(());
1568 }
1569 let existing = std::fs::read_to_string(wd.resolve(CLAUDE_MD_PRE_ADOPT_BACKUP)?)?;
1570 if existing == content {
1571 Ok(())
1572 } else {
1573 Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "a different .pre-adopt copy already exists"))
1574 }
1575}
1576
1577pub fn inject_jekt_signing_keys_into_mcp_json(
1595 content: &str,
1596 mstore: &crate::backend::storage::store::Store,
1597 agent_slug: &str,
1598) -> Option<String> {
1599 let mut mcp_json: Value = serde_json::from_str(content).ok()?;
1600 let env = mcp_json
1601 .pointer_mut("/mcpServers/agentmux/env")
1602 .and_then(|v| v.as_object_mut())?;
1603
1604 let mut patched = false;
1605 if let Ok(key) = mstore.agent_jekt_key_ensure(agent_slug) {
1606 use base64::Engine as _;
1607 let key_b64 = base64::engine::general_purpose::STANDARD.encode(&key);
1608 env.insert("AGENTMUX_JEKT_KEY".to_string(), json!(key_b64));
1609 patched = true;
1610 }
1611 if let Ok(keypair) = mstore.agent_lan_key_ensure(agent_slug) {
1612 env.insert("AGENTMUX_LAN_KEY".to_string(), json!(keypair.private_key));
1613 env.insert(
1623 "AGENTMUX_CHANNEL".to_string(),
1624 json!(crate::backend::reactive::registry::local_channel_id()),
1625 );
1626 patched = true;
1627 }
1628 if let Some(wan) = mstore.wan_identity() {
1649 let legacy = mstore.agent_wan_key_load(agent_slug).ok().flatten();
1650 let import = legacy.as_ref().map(|k| (k.public_key.as_str(), k.private_key.as_str()));
1651 match (
1652 wan.instance_ensure(&crate::backend::reactive::registry::local_host_label()),
1653 wan.agent_key_ensure(agent_slug, import),
1654 ) {
1655 (Ok(instance), Ok(key)) => {
1656 env.insert("AGENTMUX_WAN_KEY".to_string(), json!(key.private_key));
1657 env.insert("AGENTMUX_HOST_LABEL".to_string(), json!(instance.instance_id));
1665 patched = true;
1666 crate::muxbus::wan_publish::nudge();
1671 }
1672 (instance, key) => tracing::warn!(
1673 agent = agent_slug,
1674 instance_error = ?instance.err(),
1675 key_error = ?key.err(),
1676 "wan identity: could not provision this agent's WAN key — its WAN jekts go unsigned"
1677 ),
1678 }
1679 }
1680 if !patched {
1681 return None;
1682 }
1683 serde_json::to_string_pretty(&mcp_json).ok()
1684}
1685
1686#[cfg(test)]
1691mod tests {
1692 use super::*;
1693
1694 fn make_skill(name: &str, trigger: &str, description: &str, content: &str) -> AgentSkill {
1695 AgentSkill {
1696 id: format!("skill-{}", trigger),
1697 agent_id: "agent-1".to_string(),
1698 name: name.to_string(),
1699 trigger: trigger.to_string(),
1700 skill_type: "prompt".to_string(),
1701 description: description.to_string(),
1702 content: content.to_string(),
1703 created_at: 0,
1704 }
1705 }
1706
1707 fn make_agent_skill(name: &str, description: &str, content: &str) -> AgentSkill {
1708 AgentSkill {
1709 id: format!("skill-{}", derive_slug(name)),
1710 agent_id: "agent-1".to_string(),
1711 name: name.to_string(),
1712 trigger: String::new(),
1713 skill_type: SKILL_TYPE_AGENT_SKILL.to_string(),
1714 description: description.to_string(),
1715 content: content.to_string(),
1716 created_at: 0,
1717 }
1718 }
1719
1720 #[test]
1721 fn test_sanitize_trigger_rejects_path_traversal() {
1722 assert_eq!(sanitize_trigger("../../../../.ssh/authorized_keys"), None);
1726 assert_eq!(sanitize_trigger("../evil"), None);
1727 assert_eq!(sanitize_trigger("sub/evil"), None);
1728 assert_eq!(sanitize_trigger("sub\\evil"), None);
1729 assert_eq!(sanitize_trigger(".."), None);
1730 assert_eq!(sanitize_trigger("."), None);
1731 assert_eq!(sanitize_trigger(""), None);
1732 assert_eq!(sanitize_trigger("deploy"), Some("deploy"));
1733 }
1734
1735 #[test]
1736 fn test_build_config_files_skips_prompt_skill_with_traversal_trigger() {
1737 let content_map = HashMap::new();
1741 let skills = vec![make_skill(
1742 "Evil",
1743 "../../../../.ssh/authorized_keys",
1744 "desc",
1745 "malicious content",
1746 )];
1747 let files = build_config_files(&content_map, &skills, "Aria", "agent-1", "aria", "/tmp/aria", "claude");
1748 assert!(
1749 files.iter().all(|f| !f.filename.contains("..")),
1750 "no config file path may contain '..': {:?}",
1751 files.iter().map(|f| &f.filename).collect::<Vec<_>>()
1752 );
1753 assert!(!files.iter().any(|f| f.filename.starts_with(".claude/commands/")));
1754 }
1755
1756 #[test]
1757 fn test_expand_template_basic() {
1758 let mut vars = HashMap::new();
1759 vars.insert("AGENT".to_string(), "Aria".to_string());
1760 vars.insert("DATE".to_string(), "2026-04-10".to_string());
1761
1762 let out = expand_template("Hello {{AGENT}}, today is {{DATE}}.", &vars);
1763 assert_eq!(out, "Hello Aria, today is 2026-04-10.");
1764 }
1765
1766 #[test]
1767 fn test_expand_template_unknown_placeholder_preserved() {
1768 let vars = HashMap::new();
1769 let out = expand_template("Value: {{UNKNOWN}}", &vars);
1770 assert_eq!(out, "Value: {{UNKNOWN}}");
1771 }
1772
1773 #[test]
1774 fn test_expand_template_empty_vars() {
1775 let vars = HashMap::new();
1776 let out = expand_template("No placeholders here.", &vars);
1777 assert_eq!(out, "No placeholders here.");
1778 }
1779
1780 #[test]
1781 fn test_build_mcp_config_no_user_content() {
1782 let result = build_mcp_config(None, "aria", "bus-42").unwrap();
1783 let parsed: Value = serde_json::from_str(&result).unwrap();
1784 let servers = &parsed["mcpServers"];
1785 assert!(servers["agentmux"].is_object());
1786 assert_eq!(servers["agentmux"]["command"], "agentmux-mcp");
1787 assert_eq!(servers["agentmux"]["env"]["AGENTMUX_AGENT_ID"], "aria");
1788 assert_eq!(servers["agentmux"]["env"]["AGENTMUX_AGENT_BUS_ID"], "bus-42");
1789 }
1790
1791 #[test]
1792 fn test_build_mcp_config_merges_user_servers() {
1793 let user_mcp = r#"{"mcpServers": {"mytool": {"type": "stdio", "command": "mytool"}}}"#;
1794 let result = build_mcp_config(Some(user_mcp), "aria", "").unwrap();
1795 let parsed: Value = serde_json::from_str(&result).unwrap();
1796 let servers = &parsed["mcpServers"];
1797 assert!(servers["agentmux"].is_object());
1798 assert!(servers["mytool"].is_object());
1799 }
1800
1801 #[test]
1802 fn test_build_mcp_config_invalid_user_json_uses_auto_injected() {
1803 let result = build_mcp_config(Some("not json {{"), "aria", "").unwrap();
1804 let parsed: Value = serde_json::from_str(&result).unwrap();
1805 assert!(parsed["mcpServers"]["agentmux"].is_object());
1806 }
1807
1808 #[test]
1809 fn test_build_config_files_claude_md_assembled() {
1810 let mut content_map = HashMap::new();
1811 content_map.insert("soul".to_string(), "You are {{AGENT}}.".to_string());
1812 content_map.insert("agentmd".to_string(), "## Instructions\nDo stuff.".to_string());
1813
1814 let files = build_config_files(&content_map, &[], "Aria", "agent-1", "aria", "/tmp/aria", "claude");
1815 let claude_md = files.iter().find(|f| f.filename == "CLAUDE.md").unwrap();
1816 assert!(claude_md.content.contains("You are Aria."));
1817 assert!(claude_md.content.contains("---"));
1818 assert!(claude_md.content.contains("## Instructions"));
1819 }
1820
1821 #[test]
1825 fn test_build_config_files_resolves_provider_specific_filename() {
1826 let cases: &[(&str, &str)] = &[
1827 ("claude", "CLAUDE.md"),
1828 ("codex", "AGENTS.md"),
1829 ("gemini", "GEMINI.md"),
1830 ("qwen", "QWEN.md"),
1831 ("copilot", "AGENTS.md"),
1832 ("openclaw", "AGENTS.md"),
1833 ("pi", "AGENTS.md"),
1834 ("antigravity", "GEMINI.md"),
1835 ("muxcode", "CLAUDE.md"),
1836 ];
1837 for (provider_id, expected_filename) in cases {
1838 let mut content_map = HashMap::new();
1839 content_map.insert("soul".to_string(), "You are Aria.".to_string());
1840 let files = build_config_files(&content_map, &[], "Aria", "agent-1", "aria", "/tmp/aria", provider_id);
1841 let instructions_files: Vec<&str> = files
1842 .iter()
1843 .filter(|f| f.filename == *expected_filename)
1844 .map(|f| f.content.as_str())
1845 .collect();
1846 assert_eq!(
1847 instructions_files.len(),
1848 1,
1849 "provider '{provider_id}' should produce exactly one file named '{expected_filename}': {:?}",
1850 files.iter().map(|f| &f.filename).collect::<Vec<_>>()
1851 );
1852 assert!(instructions_files[0].contains("You are Aria."));
1853 }
1854 }
1855
1856 #[test]
1857 fn test_build_config_files_kimi_gets_no_instructions_file() {
1858 let mut content_map = HashMap::new();
1862 content_map.insert("soul".to_string(), "You are Aria.".to_string());
1863 content_map.insert("agentmd".to_string(), "## Instructions".to_string());
1864 content_map.insert("memory".to_string(), "Some memory content".to_string());
1865 let files = build_config_files(&content_map, &[], "Aria", "agent-1", "aria", "/tmp/aria", "kimi");
1866 assert!(
1867 files.iter().all(|f| !f.filename.ends_with(".md") || f.filename.starts_with(".claude/")),
1868 "kimi must get no top-level instructions file, even with soul/agentmd/memory content present: {:?}",
1869 files.iter().map(|f| &f.filename).collect::<Vec<_>>()
1870 );
1871 }
1872
1873 #[test]
1874 fn test_build_config_files_unrecognized_provider_gets_no_instructions_file() {
1875 let mut content_map = HashMap::new();
1883 content_map.insert("soul".to_string(), "You are Aria.".to_string());
1884 let files = build_config_files(&content_map, &[], "Aria", "agent-1", "aria", "/tmp/aria", "not-a-real-provider");
1885 let known_instructions_filenames = [
1886 "CLAUDE.md", "AGENTS.md", "GEMINI.md", "QWEN.md", ".pi/APPEND_SYSTEM.md",
1887 ];
1888 assert!(
1889 !files.iter().any(|f| known_instructions_filenames.contains(&f.filename.as_str())),
1890 "no instructions file should be written for an unrecognized provider: {:?}",
1891 files.iter().map(|f| &f.filename).collect::<Vec<_>>()
1892 );
1893 }
1894
1895 #[test]
1896 fn test_build_config_files_skills_index_and_commands() {
1897 let content_map = HashMap::new();
1898 let skills = vec![
1899 make_skill("Deploy", "deploy", "Deploy the app", "Run: deploy all"),
1900 make_skill("Test", "test", "Run tests", "Run: test suite"),
1901 ];
1902
1903 let files = build_config_files(&content_map, &skills, "Aria", "agent-1", "aria", "/tmp/aria", "claude");
1904
1905 let claude_md = files.iter().find(|f| f.filename == "CLAUDE.md");
1907 assert!(claude_md.is_none_or(|f| !f.content.contains("Available Skills")), "{claude_md:?}");
1908
1909 assert!(files.iter().any(|f| f.filename == ".claude/commands/deploy.md"));
1911 assert!(files.iter().any(|f| f.filename == ".claude/commands/test.md"));
1912
1913 let codex = build_config_files(&content_map, &skills, "Aria", "agent-1", "aria", "/tmp/aria", "codex");
1915 let agents_md = codex.iter().find(|f| f.filename == "AGENTS.md").unwrap();
1916 assert!(agents_md.content.contains("Available Skills"));
1917 assert!(agents_md.content.contains("/deploy"));
1918 assert!(agents_md.content.contains("/test"));
1919 }
1920
1921 #[test]
1924 fn a_claude_alias_gets_no_index_for_file_backed_skills() {
1925 let skills = vec![make_skill("Deploy", "deploy", "Deploy the app", "Run: deploy all")];
1926 for alias in ["claude-code", "claude_code"] {
1927 if crate::backend::providers::get_provider(alias).is_none() {
1928 continue;
1929 }
1930 let files = build_config_files(&HashMap::new(), &skills, "Aria", "agent-1", "aria", "/tmp/aria", alias);
1931 let md = files.iter().find(|f| f.filename == "CLAUDE.md").unwrap();
1932 assert!(!md.content.contains("Available Skills"), "{alias}");
1933 }
1934 }
1935
1936 #[test]
1939 fn claude_still_indexes_a_skill_that_has_no_file() {
1940 let skills = vec![
1941 make_skill("Deploy", "deploy", "Deploy the app", "Run: deploy all"),
1942 make_skill("Notes", "", "Read the notes", "Look in NOTES.md"),
1943 ];
1944 let files = build_config_files(&HashMap::new(), &skills, "Aria", "agent-1", "aria", "/tmp/aria", "claude");
1945 let claude_md = files.iter().find(|f| f.filename == "CLAUDE.md").unwrap();
1946 assert!(claude_md.content.contains("Available Skills"));
1947 assert!(claude_md.content.contains("**Notes**"));
1948 assert!(!claude_md.content.contains("/deploy"), "Deploy has a command file");
1949 }
1950
1951 #[test]
1955 fn the_instructions_file_is_written_even_when_empty() {
1956 let skills = vec![make_skill("Deploy", "deploy", "Deploy the app", "Run: deploy all")];
1957 let files = build_config_files(&HashMap::new(), &skills, "Aria", "agent-1", "aria", "/tmp/aria", "claude");
1958 assert!(files.iter().any(|f| f.filename == "CLAUDE.md"), "{:?}", files.iter().map(|f| &f.filename).collect::<Vec<_>>());
1959 let kimi = build_config_files(&HashMap::new(), &skills, "Aria", "agent-1", "aria", "/tmp/aria", "kimi");
1960 assert!(!kimi.iter().any(|f| f.filename == "CLAUDE.md" || f.filename == "AGENTS.md"), "kimi has no file");
1961 }
1962
1963 const LEGACY_CLAUDE_MD: &str = "\n# Available Skills\n\nUse `/<trigger>` to invoke a skill.\n\n\
1964 - **Test-Driven Development** (trigger: /tdd) \u{2014} write the test first.\n\
1965 - **Code Review** (trigger: /code-review) \u{2014} review.\n\n\n\
1966 <!-- agentmux:managed-import (safe to delete this line to opt out) -->\n\
1967 @.claude/AGENTMUX_MEMORY.md\n";
1968
1969 #[test]
1972 fn a_skill_whose_command_file_a_later_one_overwrites_stays_indexed() {
1973 let skills = vec![
1974 make_skill("Deploy", "deploy", "Deploy the app", "Run: deploy all"),
1975 make_skill("Deploy Staging", "Deploy", "Deploy staging", "Run: deploy staging"),
1976 ];
1977 let files = build_config_files(&HashMap::new(), &skills, "Aria", "agent-1", "aria", "/tmp/aria", "claude");
1978 let md = files.iter().find(|f| f.filename == "CLAUDE.md").unwrap();
1979 assert!(md.content.contains("**Deploy**"), "the overwritten one is indexed");
1980 assert!(!md.content.contains("**Deploy Staging**"), "the one that owns the file isn't");
1981 }
1982
1983 #[test]
1986 fn a_skill_with_a_non_ascii_trigger_stays_indexed() {
1987 let skills = vec![make_skill("Caf\u{e9}", "caf\u{e9}", "coffee", "brew")];
1988 let files = build_config_files(&HashMap::new(), &skills, "Aria", "agent-1", "aria", "/tmp/aria", "claude");
1989 let md = files.iter().find(|f| f.filename == "CLAUDE.md").unwrap();
1990 assert!(md.content.contains("Caf\u{e9}"), "{}", md.content);
1991 }
1992
1993 #[test]
1994 fn only_rows_as_agentmux_rendered_them_count() {
1995 assert!(is_generated_skill_row("- **TDD** (trigger: /tdd) \u{2014} write the test first."));
1996 assert!(is_generated_skill_row("- **TDD** (trigger: /tdd)"));
1997 assert!(is_generated_skill_row("- **TDD** \u{2014} write it first"));
1998 assert!(is_generated_skill_row("- **TDD**"));
1999 assert!(!is_generated_skill_row("- **this is my hand-written note"));
2000 assert!(!is_generated_skill_row("- **TDD** and also a note"));
2001 assert!(!is_generated_skill_row("- **TDD** (trigger: /tdd"));
2002 assert!(!is_legacy_agentmux_claude_md(
2003 "# Available Skills\nUse `/<trigger>` to invoke a skill.\n- **this is my hand-written note\n"
2004 ));
2005 }
2006
2007 #[test]
2008 fn recognises_only_exactly_what_an_older_agentmux_wrote() {
2009 assert!(is_legacy_agentmux_claude_md(LEGACY_CLAUDE_MD));
2010 assert!(!is_legacy_agentmux_claude_md("# Available Skills\n\nUse `/<trigger>` to invoke a skill.\n"));
2012 assert!(!is_legacy_agentmux_claude_md(
2014 "# Available Skills\nUse `/<trigger>` to invoke a skill.\n@.claude/AGENTMUX_MEMORY.md\n- **A** x\n"
2015 ));
2016 assert!(is_legacy_agentmux_claude_md(
2017 "# Available Skills\nUse `/<trigger>` to invoke a skill.\n- **A** \u{2014} x\n@.claude/AGENTMUX_MEMORY.md\n"
2018 ));
2019 assert!(is_legacy_agentmux_claude_md("# Available Skills\n\nUse `/<trigger>` to invoke a skill.\n\n- **A** \u{2014} x\n"));
2020 assert!(!is_legacy_agentmux_claude_md(&format!("{LEGACY_CLAUDE_MD}\nAlways run the tests.\n")), "a person's line");
2021 assert!(!is_legacy_agentmux_claude_md(&format!("{CLAUDE_MD_MANAGED_MARKER}\n\n# Memory\n")));
2022 assert!(!is_legacy_agentmux_claude_md("# Project\n\n# Available Skills\n"));
2023 assert!(!is_legacy_agentmux_claude_md(""));
2024 }
2025
2026 #[test]
2027 fn a_legacy_claude_md_is_adopted_with_a_backup() {
2028 let dir = tempfile::tempdir().unwrap();
2029 std::fs::write(dir.path().join("CLAUDE.md"), LEGACY_CLAUDE_MD).unwrap();
2030 write_claude_md_respecting_ownership(dir.path(), "# Memory\nfresh\n").unwrap();
2031
2032 let now = std::fs::read_to_string(dir.path().join("CLAUDE.md")).unwrap();
2033 assert!(now.starts_with(CLAUDE_MD_MANAGED_MARKER), "{now}");
2034 assert!(now.contains("fresh"));
2035 let backup = std::fs::read_to_string(dir.path().join(CLAUDE_MD_PRE_ADOPT_BACKUP)).unwrap();
2036 assert_eq!(backup, LEGACY_CLAUDE_MD);
2037
2038 write_claude_md_respecting_ownership(dir.path(), "# Memory\nnewer\n").unwrap();
2040 assert!(std::fs::read_to_string(dir.path().join("CLAUDE.md")).unwrap().contains("newer"));
2041 assert_eq!(std::fs::read_to_string(dir.path().join(CLAUDE_MD_PRE_ADOPT_BACKUP)).unwrap(), LEGACY_CLAUDE_MD);
2042 }
2043
2044 #[test]
2047 fn a_legacy_claude_md_is_not_adopted_through_a_symlinked_claude_dir() {
2048 let dir = tempfile::tempdir().unwrap();
2049 let outside = tempfile::tempdir().unwrap();
2050 #[cfg(unix)]
2051 let made = std::os::unix::fs::symlink(outside.path(), dir.path().join(".claude")).is_ok();
2052 #[cfg(windows)]
2053 let made = std::os::windows::fs::symlink_dir(outside.path(), dir.path().join(".claude")).is_ok();
2054 if !made {
2055 return;
2058 }
2059 std::fs::write(dir.path().join("CLAUDE.md"), LEGACY_CLAUDE_MD).unwrap();
2060 write_claude_md_respecting_ownership(dir.path(), "# Memory\nfresh\n").unwrap();
2061 assert!(!outside.path().join("CLAUDE.md.pre-adopt").exists(), "no backup outside the workspace");
2062 let now = std::fs::read_to_string(dir.path().join("CLAUDE.md")).unwrap();
2063 assert!(!now.starts_with(CLAUDE_MD_MANAGED_MARKER), "not adopted: {now}");
2064 }
2065
2066 #[test]
2069 fn a_mismatched_backup_blocks_adoption() {
2070 let dir = tempfile::tempdir().unwrap();
2071 std::fs::write(dir.path().join("CLAUDE.md"), LEGACY_CLAUDE_MD).unwrap();
2072 std::fs::create_dir_all(dir.path().join(".claude")).unwrap();
2073 std::fs::write(dir.path().join(CLAUDE_MD_PRE_ADOPT_BACKUP), &LEGACY_CLAUDE_MD[..20]).unwrap();
2074 write_claude_md_respecting_ownership(dir.path(), "# Memory\nfresh\n").unwrap();
2075 assert_eq!(std::fs::read_to_string(dir.path().join("CLAUDE.md")).unwrap(), LEGACY_CLAUDE_MD);
2076
2077 std::fs::write(dir.path().join(CLAUDE_MD_PRE_ADOPT_BACKUP), LEGACY_CLAUDE_MD).unwrap();
2079 write_claude_md_respecting_ownership(dir.path(), "# Memory\nfresh\n").unwrap();
2080 assert!(std::fs::read_to_string(dir.path().join("CLAUDE.md")).unwrap().starts_with(CLAUDE_MD_MANAGED_MARKER));
2081 }
2082
2083 #[test]
2086 fn a_symlinked_legacy_claude_md_is_not_adopted() {
2087 let dir = tempfile::tempdir().unwrap();
2088 let outside = tempfile::tempdir().unwrap();
2089 let target = outside.path().join("CLAUDE.md");
2090 std::fs::write(&target, LEGACY_CLAUDE_MD).unwrap();
2091 #[cfg(unix)]
2092 let made = std::os::unix::fs::symlink(&target, dir.path().join("CLAUDE.md")).is_ok();
2093 #[cfg(windows)]
2094 let made = std::os::windows::fs::symlink_file(&target, dir.path().join("CLAUDE.md")).is_ok();
2095 if !made {
2096 return; }
2098 write_claude_md_respecting_ownership(dir.path(), "# Memory\nfresh\n").unwrap();
2099 assert_eq!(std::fs::read_to_string(&target).unwrap(), LEGACY_CLAUDE_MD, "the outside file is untouched");
2100 }
2101
2102 #[test]
2105 fn a_symlinked_managed_claude_md_is_not_written_through() {
2106 let dir = tempfile::tempdir().unwrap();
2107 let outside = tempfile::tempdir().unwrap();
2108 let target = outside.path().join("CLAUDE.md");
2109 let managed = format!("{CLAUDE_MD_MANAGED_MARKER}\n\n# Memory\nold\n");
2110 std::fs::write(&target, &managed).unwrap();
2111 #[cfg(unix)]
2112 let made = std::os::unix::fs::symlink(&target, dir.path().join("CLAUDE.md")).is_ok();
2113 #[cfg(windows)]
2114 let made = std::os::windows::fs::symlink_file(&target, dir.path().join("CLAUDE.md")).is_ok();
2115 if !made {
2116 return; }
2118 write_claude_md_respecting_ownership(dir.path(), "# Memory\nfresh\n").unwrap();
2119 assert_eq!(std::fs::read_to_string(&target).unwrap(), managed, "the outside file is untouched");
2120 }
2121
2122 #[test]
2125 fn a_symlinked_startup_instructions_file_is_not_written_through() {
2126 let dir = tempfile::tempdir().unwrap();
2127 let outside = tempfile::tempdir().unwrap();
2128 let target = outside.path().join("AGENTS.md");
2129 let managed = format!("{STARTUP_INSTRUCTIONS_MANAGED_MARKER}\n\nold\n");
2130 std::fs::write(&target, &managed).unwrap();
2131 #[cfg(unix)]
2132 let made = std::os::unix::fs::symlink(&target, dir.path().join("AGENTS.md")).is_ok();
2133 #[cfg(windows)]
2134 let made = std::os::windows::fs::symlink_file(&target, dir.path().join("AGENTS.md")).is_ok();
2135 if !made {
2136 return; }
2138 write_startup_instructions_respecting_existing(dir.path(), "AGENTS.md", "fresh").unwrap();
2139 assert_eq!(std::fs::read_to_string(&target).unwrap(), managed, "the outside file is untouched");
2140
2141 let gone = outside.path().join("GEMINI.md");
2143 #[cfg(unix)]
2144 let made = std::os::unix::fs::symlink(&gone, dir.path().join("GEMINI.md")).is_ok();
2145 #[cfg(windows)]
2146 let made = std::os::windows::fs::symlink_file(&gone, dir.path().join("GEMINI.md")).is_ok();
2147 if made {
2148 write_startup_instructions_respecting_existing(dir.path(), "GEMINI.md", "fresh").unwrap();
2149 assert!(!gone.exists(), "no file created through a dangling link");
2150 }
2151 }
2152
2153 #[test]
2154 fn a_plain_startup_instructions_file_is_still_written() {
2155 let dir = tempfile::tempdir().unwrap();
2156 write_startup_instructions_respecting_existing(dir.path(), "AGENTS.md", "fresh").unwrap();
2157 assert!(std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap().contains("fresh"));
2158 write_startup_instructions_respecting_existing(dir.path(), ".pi/APPEND_SYSTEM.md", "pi").unwrap();
2159 assert!(std::fs::read_to_string(dir.path().join(".pi/APPEND_SYSTEM.md")).unwrap().contains("pi"));
2160 }
2161
2162 #[test]
2163 fn a_users_claude_md_is_still_left_alone() {
2164 let dir = tempfile::tempdir().unwrap();
2165 let mine = format!("{LEGACY_CLAUDE_MD}\nAlways run the tests.\n");
2166 std::fs::write(dir.path().join("CLAUDE.md"), &mine).unwrap();
2167 write_claude_md_respecting_ownership(dir.path(), "# Memory\nfresh\n").unwrap();
2168 assert_eq!(std::fs::read_to_string(dir.path().join("CLAUDE.md")).unwrap(), mine);
2169 assert!(!dir.path().join(CLAUDE_MD_PRE_ADOPT_BACKUP).exists());
2170 }
2171
2172 #[test]
2173 fn test_build_config_files_agent_skill_format_writes_skill_md() {
2174 let content_map = HashMap::new();
2175 let skills = vec![make_agent_skill(
2176 "Deploy Checklist",
2177 "Runs the pre-deploy checklist",
2178 "1. Run tests\n2. Check migrations\n3. Deploy",
2179 )];
2180
2181 let files = build_config_files(&content_map, &skills, "Aria", "agent-1", "aria", "/tmp/aria", "claude");
2182
2183 let skill_file = files
2185 .iter()
2186 .find(|f| f.filename == ".claude/skills/deploy-checklist/SKILL.md")
2187 .expect("expected .claude/skills/deploy-checklist/SKILL.md");
2188 assert!(!files.iter().any(|f| f.filename.starts_with(".claude/commands/")));
2189
2190 assert!(skill_file.content.starts_with("---\n"));
2194 assert!(skill_file.content.contains("name: \"deploy-checklist\""));
2195 assert!(skill_file
2196 .content
2197 .contains("description: \"Runs the pre-deploy checklist\""));
2198 assert!(skill_file.content.contains("---\n\n1. Run tests"));
2199
2200 let claude_md = files.iter().find(|f| f.filename == "CLAUDE.md");
2202 assert!(claude_md.is_none_or(|f| !f.content.contains("Deploy Checklist")), "{claude_md:?}");
2203 }
2204
2205 #[test]
2206 fn test_build_config_files_agent_skill_format_escapes_yaml_special_chars() {
2207 let content_map = HashMap::new();
2213 let skills = vec![make_agent_skill(
2214 "Weird: \"Name\"",
2215 "Has a colon: and \"quotes\"",
2216 "body",
2217 )];
2218
2219 let files = build_config_files(&content_map, &skills, "Aria", "agent-1", "aria", "/tmp/aria", "claude");
2220 let skill_file = files
2221 .iter()
2222 .find(|f| f.filename.starts_with(".claude/skills/") && f.filename.ends_with("SKILL.md"))
2223 .expect("expected a SKILL.md file");
2224
2225 let end = skill_file.content.find("\n---\n\n").expect("closing frontmatter delimiter");
2228 let frontmatter = &skill_file.content[4..end]; let lines: Vec<&str> = frontmatter.lines().collect();
2230 assert_eq!(lines.len(), 2, "frontmatter should be exactly name + description lines, got: {lines:?}");
2231 }
2232
2233 #[test]
2234 fn test_build_config_files_agent_skill_format_name_is_the_slug_not_display_name() {
2235 let content_map = HashMap::new();
2239 let skills = vec![make_agent_skill("Deploy Checklist", "desc", "body")];
2240 let files = build_config_files(&content_map, &skills, "Aria", "agent-1", "aria", "/tmp/aria", "claude");
2241 let skill_file = files.iter().find(|f| f.filename.ends_with("SKILL.md")).unwrap();
2242 assert!(skill_file.content.contains("name: \"deploy-checklist\""));
2243 assert!(!skill_file.content.contains("name: \"Deploy Checklist\""));
2244 }
2245
2246 #[test]
2247 fn test_build_config_files_agent_skill_format_empty_description_gets_fallback() {
2248 let content_map = HashMap::new();
2251 let skills = vec![make_agent_skill("Deploy Checklist", "", "body")];
2252 let files = build_config_files(&content_map, &skills, "Aria", "agent-1", "aria", "/tmp/aria", "claude");
2253 let skill_file = files.iter().find(|f| f.filename.ends_with("SKILL.md")).unwrap();
2254 assert!(!skill_file.content.contains("description: \"\""), "empty description must not reach the spec-invalid empty string: {}", skill_file.content);
2255 assert!(skill_file.content.contains("description: \"No description provided.\""));
2256 }
2257
2258 #[test]
2259 fn test_build_config_files_agent_skill_format_truncates_long_description() {
2260 let content_map = HashMap::new();
2263 let long_description = "x".repeat(2000);
2264 let skills = vec![make_agent_skill("Deploy Checklist", &long_description, "body")];
2265 let files = build_config_files(&content_map, &skills, "Aria", "agent-1", "aria", "/tmp/aria", "claude");
2266 let skill_file = files.iter().find(|f| f.filename.ends_with("SKILL.md")).unwrap();
2267 let desc_line = skill_file.content.lines().find(|l| l.starts_with("description: ")).unwrap();
2269 let quoted = desc_line.trim_start_matches("description: ");
2270 let inner_len = quoted.len() - 2; assert!(inner_len <= 1024, "description exceeds spec max of 1024 chars: {inner_len}");
2272 }
2273
2274 #[test]
2275 fn test_build_config_files_agent_skill_format_dedupes_colliding_slugs() {
2276 let content_map = HashMap::new();
2280 let skills = vec![
2281 make_agent_skill("Deploy Checklist", "First skill", "body one"),
2282 make_agent_skill("Deploy!!!Checklist", "Second skill", "body two"),
2283 make_agent_skill("Deploy Checklist", "Third skill", "body three"),
2284 ];
2285
2286 let files = build_config_files(&content_map, &skills, "Aria", "agent-1", "aria", "/tmp/aria", "claude");
2287
2288 let skill_files: Vec<&AgentConfigFile> = files
2289 .iter()
2290 .filter(|f| f.filename.starts_with(".claude/skills/") && f.filename.ends_with("SKILL.md"))
2291 .collect();
2292 assert_eq!(skill_files.len(), 3, "all three skills must materialize to distinct files");
2293
2294 let filenames: HashSet<&str> = skill_files.iter().map(|f| f.filename.as_str()).collect();
2295 assert_eq!(filenames.len(), 3, "filenames must be unique: {filenames:?}");
2296 assert!(filenames.contains(".claude/skills/deploy-checklist/SKILL.md"));
2297 assert!(filenames.contains(".claude/skills/deploy-checklist-2/SKILL.md"));
2298 assert!(filenames.contains(".claude/skills/deploy-checklist-3/SKILL.md"));
2299
2300 let first = skill_files.iter().find(|f| f.filename.ends_with("deploy-checklist/SKILL.md")).unwrap();
2303 assert!(first.content.contains("body one"));
2304 let second = skill_files.iter().find(|f| f.filename.ends_with("deploy-checklist-2/SKILL.md")).unwrap();
2305 assert!(second.content.contains("body two"));
2306 let third = skill_files.iter().find(|f| f.filename.ends_with("deploy-checklist-3/SKILL.md")).unwrap();
2307 assert!(third.content.contains("body three"));
2308 }
2309
2310 #[test]
2311 fn test_unique_skill_slug_replaces_underscores_with_hyphens() {
2312 let mut used = HashSet::new();
2315 assert_eq!(unique_skill_slug("code_review", &mut used), "code-review");
2316 }
2317
2318 #[test]
2319 fn test_unique_skill_slug_suffixed_slug_stays_within_64_chars() {
2320 let mut used = HashSet::new();
2322 let long = "a".repeat(100);
2323 let first = unique_skill_slug(&long, &mut used);
2324 assert_eq!(first.len(), 64);
2325 let second = unique_skill_slug(&long, &mut used);
2326 assert!(second.len() <= 64, "suffixed slug exceeds 64 chars: {second} ({})", second.len());
2327 assert!(second.ends_with("-2"));
2328 }
2329
2330 #[test]
2331 fn test_render_skill_md_truncates_by_utf16_units_matching_ts_slice() {
2332 let description: String = "\u{4e2d}".repeat(2000); let md = render_skill_md("slug", &description, "body");
2340 let desc_line = md.lines().find(|l| l.starts_with("description: ")).unwrap();
2341 let quoted = desc_line.trim_start_matches("description: ");
2342 let inner = "ed[1..quoted.len() - 1]; let utf16_len: usize = inner.chars().map(|c| c.len_utf16()).sum();
2344 assert_eq!(utf16_len, 1024, "expected exactly 1024 UTF-16 units, got {utf16_len}");
2345 }
2346
2347 #[test]
2348 fn test_build_config_files_settings_merges_user_hooks() {
2349 let mut content_map = HashMap::new();
2356 content_map.insert(
2357 "hooks".to_string(),
2358 r#"{"PreToolUse":[{"matcher":"Read","hooks":[{"type":"command","command":"my-audit"}]}]}"#
2359 .to_string(),
2360 );
2361 let files = build_config_files(&content_map, &[], "Aria", "agent-1", "aria", "/tmp/aria", "claude");
2362 let settings = files
2363 .iter()
2364 .find(|f| f.filename == ".claude/settings.json")
2365 .expect("settings.json emitted");
2366 let parsed: Value = serde_json::from_str(&settings.content).unwrap();
2367 let pre_tool_use = parsed["hooks"]["PreToolUse"]
2368 .as_array()
2369 .expect("PreToolUse is an array");
2370 assert!(
2372 pre_tool_use
2373 .iter()
2374 .any(|e| e["matcher"].as_str() == Some("Read")),
2375 "user-supplied PreToolUse:Read must survive the merge"
2376 );
2377 assert!(
2378 pre_tool_use
2379 .iter()
2380 .any(|e| e["matcher"].as_str().unwrap_or("").contains("Bash")),
2381 "auto-injected PreToolUse:Bash must still be present"
2382 );
2383 }
2384
2385 #[test]
2386 fn test_build_config_files_settings_merges_user_precompact_hooks() {
2387 let mut content_map = HashMap::new();
2397 content_map.insert(
2398 "hooks".to_string(),
2399 r#"{"PreCompact":[{"matcher":"manual","hooks":[{"type":"command","command":"my-precompact-audit"}]}]}"#
2400 .to_string(),
2401 );
2402 let files = build_config_files(&content_map, &[], "Aria", "agent-1", "aria", "/tmp/aria", "claude");
2403 let settings = files
2404 .iter()
2405 .find(|f| f.filename == ".claude/settings.json")
2406 .expect("settings.json emitted");
2407 let parsed: Value = serde_json::from_str(&settings.content).unwrap();
2408 let pre_compact = parsed["hooks"]["PreCompact"]
2409 .as_array()
2410 .expect("PreCompact is an array");
2411 assert_eq!(
2412 pre_compact.len(),
2413 3,
2414 "expected user entry + 2 auto-injected (manual, auto) entries, got {pre_compact:?}"
2415 );
2416 assert!(
2417 pre_compact
2418 .iter()
2419 .any(|e| e["hooks"][0]["command"].as_str() == Some("my-precompact-audit")),
2420 "user-supplied PreCompact entry must survive the merge"
2421 );
2422 assert!(
2423 pre_compact.iter().any(|e| e["hooks"][0]["command"].as_str()
2424 == Some("agentmux-bashwrap precompact --trigger=manual")),
2425 "auto-injected PreCompact manual entry must still be present"
2426 );
2427 assert!(
2428 pre_compact.iter().any(|e| e["hooks"][0]["command"].as_str()
2429 == Some("agentmux-bashwrap precompact --trigger=auto")),
2430 "auto-injected PreCompact auto entry must still be present"
2431 );
2432 }
2433
2434 #[test]
2435 fn test_build_config_files_settings_json_merges_user_precompact_hooks() {
2436 let mut content_map = HashMap::new();
2442 content_map.insert(
2443 "settings".to_string(),
2444 r#"{"hooks":{"PreCompact":[{"matcher":"auto","hooks":[{"type":"command","command":"my-settings-precompact"}]}]}}"#
2445 .to_string(),
2446 );
2447 let files = build_config_files(&content_map, &[], "Aria", "agent-1", "aria", "/tmp/aria", "claude");
2448 let settings = files
2449 .iter()
2450 .find(|f| f.filename == ".claude/settings.json")
2451 .expect("settings.json emitted");
2452 let parsed: Value = serde_json::from_str(&settings.content).unwrap();
2453 let pre_compact = parsed["hooks"]["PreCompact"]
2454 .as_array()
2455 .expect("PreCompact is an array");
2456 assert!(
2457 pre_compact
2458 .iter()
2459 .any(|e| e["hooks"][0]["command"].as_str() == Some("my-settings-precompact")),
2460 "user-supplied settings.json PreCompact entry must survive the merge"
2461 );
2462 assert!(
2463 pre_compact.iter().any(|e| e["hooks"][0]["command"].as_str()
2464 == Some("agentmux-bashwrap precompact --trigger=manual")),
2465 "auto-injected PreCompact manual entry must still be present"
2466 );
2467 assert!(
2468 pre_compact.iter().any(|e| e["hooks"][0]["command"].as_str()
2469 == Some("agentmux-bashwrap precompact --trigger=auto")),
2470 "auto-injected PreCompact auto entry must still be present"
2471 );
2472 assert_eq!(
2475 pre_compact[0]["hooks"][0]["command"].as_str(),
2476 Some("my-settings-precompact"),
2477 "user PreCompact entries must prepend before AgentMux's own"
2478 );
2479 }
2480
2481 #[test]
2485 fn test_build_settings_injects_sessionstart_memory_parts_and_keeps_user_hooks() {
2486 let ours: Vec<String> = (1..=crate::backend::memory_delivery::HOOK_PARTS)
2487 .map(|n| format!("agentmux-bashwrap sessionstart --part {n}"))
2488 .collect();
2489 let commands = |entry: &Value| -> Vec<String> {
2490 entry["hooks"].as_array().unwrap().iter().map(|h| h["command"].as_str().unwrap().to_string()).collect()
2491 };
2492 let session_start = |settings: Option<&str>, hooks: Option<&str>| -> Vec<Value> {
2493 let parsed: Value = serde_json::from_str(&build_settings_with_hooks(settings, hooks).unwrap()).unwrap();
2494 parsed["hooks"]["SessionStart"].as_array().unwrap().clone()
2495 };
2496
2497 let plain = session_start(None, None);
2498 assert_eq!(plain.len(), 1);
2499 assert!(plain[0].get("matcher").is_none(), "the sidecar answers per source; no matcher");
2500 assert_eq!(commands(&plain[0]), ours);
2501
2502 let legacy = session_start(None, Some(r#"{"SessionStart":[{"hooks":[{"type":"command","command":"my-start"}]}]}"#));
2503 assert_eq!(legacy.len(), 2);
2504 assert_eq!(commands(&legacy[0]), vec!["my-start"]);
2505 assert_eq!(commands(&legacy[1]), ours);
2506
2507 let settings = session_start(
2508 Some(r#"{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"my-settings-start"}]}]}}"#),
2509 None,
2510 );
2511 assert_eq!(settings.len(), 2, "a settings.json SessionStart hook is kept, not dropped");
2512 assert_eq!(commands(&settings[0]), vec!["my-settings-start"]);
2513 assert_eq!(commands(&settings[1]), ours);
2514 }
2515
2516 #[test]
2519 fn test_build_settings_hides_attribution_unless_the_agent_sets_it() {
2520 let attribution = |settings: Option<&str>| -> Value {
2521 let parsed: Value = serde_json::from_str(&build_settings_with_hooks(settings, None).unwrap()).unwrap();
2522 parsed["attribution"].clone()
2523 };
2524 assert_eq!(attribution(None), json!({ "commit": "", "pr": "" }));
2525 assert_eq!(attribution(Some(r#"{"model":"opus"}"#)), json!({ "commit": "", "pr": "" }));
2526 assert_eq!(
2527 attribution(Some(r#"{"attribution":{"commit":"Agent: Aria","pr":"Agent: Aria"}}"#)),
2528 json!({ "commit": "Agent: Aria", "pr": "Agent: Aria" }),
2529 "an agent's own attribution wins"
2530 );
2531 assert_eq!(
2532 attribution(Some(r#"{"attribution":{"commit":"Agent: Aria"}}"#)),
2533 json!({ "commit": "Agent: Aria", "pr": "" }),
2534 "a field the agent leaves out is still hidden, not Claude Code's default"
2535 );
2536 for malformed in [r#"null"#, r#"[]"#, r#""text""#, r#"{"commit":null,"pr":7}"#] {
2537 assert_eq!(
2538 attribution(Some(&format!(r#"{{"attribution":{malformed}}}"#))),
2539 json!({ "commit": "", "pr": "" }),
2540 "a malformed attribution ({malformed}) is replaced, not passed through"
2541 );
2542 }
2543 assert_eq!(
2544 attribution(Some(r#"{"attribution":{"commit":"Agent: Aria","pr":false}}"#)),
2545 json!({ "commit": "Agent: Aria", "pr": "" }),
2546 );
2547 }
2548
2549 #[test]
2550 fn test_build_config_files_mcp_written() {
2551 let content_map = HashMap::new();
2552 let files = build_config_files(&content_map, &[], "Aria", "agent-1", "aria", "/tmp/aria", "claude");
2553 let mcp = files.iter().find(|f| f.filename == ".mcp.json").unwrap();
2554 let parsed: Value = serde_json::from_str(&mcp.content).unwrap();
2555 assert!(parsed["mcpServers"]["agentmux"].is_object());
2556 }
2557
2558 #[test]
2559 fn test_build_config_files_expands_agent_slug_and_working_dir() {
2560 let mut content_map = HashMap::new();
2566 content_map.insert(
2567 "soul".to_string(),
2568 "I am {{AGENT_SLUG}}, working in {{WORKING_DIR}}.".to_string(),
2569 );
2570 let files = build_config_files(
2571 &content_map,
2572 &[],
2573 "Aria",
2574 "agent-1",
2575 "aria",
2576 "/home/user/my-project",
2577 "claude",
2578 );
2579 let claude_md = files.iter().find(|f| f.filename == "CLAUDE.md").unwrap();
2580 assert!(claude_md.content.contains("I am aria, working in /home/user/my-project."));
2581 }
2582
2583 #[test]
2588 fn inject_jekt_signing_keys_into_mcp_json_patches_both_keys_into_the_env_block() {
2589 let store = crate::backend::storage::store::Store::open_in_memory().unwrap();
2590 let (_wan_dir, wan) = crate::backend::storage::wan_identity::attach_temp_wan_identity(&store);
2591 let content = serde_json::to_string(&json!({
2592 "mcpServers": { "agentmux": { "type": "stdio", "command": "agentmux-mcp", "env": { "AGENTMUX_AGENT_ID": "aria" } } }
2593 }))
2594 .unwrap();
2595
2596 let rewritten = inject_jekt_signing_keys_into_mcp_json(&content, &store, "aria")
2597 .expect("both keys should ensure successfully against a fresh in-memory store");
2598 let parsed: Value = serde_json::from_str(&rewritten).unwrap();
2599 let env = &parsed["mcpServers"]["agentmux"]["env"];
2600 assert_eq!(env["AGENTMUX_AGENT_ID"], "aria", "existing fields must survive the patch");
2601 assert!(env["AGENTMUX_JEKT_KEY"].is_string() && !env["AGENTMUX_JEKT_KEY"].as_str().unwrap().is_empty());
2602 assert!(env["AGENTMUX_LAN_KEY"].is_string() && !env["AGENTMUX_LAN_KEY"].as_str().unwrap().is_empty());
2603 assert!(env["AGENTMUX_WAN_KEY"].is_string() && !env["AGENTMUX_WAN_KEY"].as_str().unwrap().is_empty());
2606 assert_ne!(
2607 env["AGENTMUX_WAN_KEY"], env["AGENTMUX_LAN_KEY"],
2608 "WAN and LAN keys must be independently minted — see storage/agent_wan_keys.rs for why \
2609 sharing one keypair across the two tiers is not an option"
2610 );
2611 assert_eq!(
2617 env["AGENTMUX_HOST_LABEL"],
2618 json!(wan.instance_ensure("unused").unwrap().instance_id),
2619 "the MCP env must carry the instance id that certifies this agent's WAN key"
2620 );
2621 assert_eq!(
2622 env["AGENTMUX_WAN_KEY"],
2623 json!(wan.agent_key_load("aria").unwrap().unwrap().private_key),
2624 "the WAN key must be the one held in the channel-wide wan.db"
2625 );
2626 assert_eq!(
2629 env["AGENTMUX_CHANNEL"],
2630 json!(crate::backend::reactive::registry::local_channel_id()),
2631 "the MCP env must carry the same channel id the shared registry entry is written under"
2632 );
2633 }
2634
2635 #[test]
2636 fn inject_jekt_signing_keys_into_mcp_json_reuses_the_same_key_on_a_second_call() {
2637 let store = crate::backend::storage::store::Store::open_in_memory().unwrap();
2638 let content = serde_json::to_string(&json!({
2639 "mcpServers": { "agentmux": { "env": { "AGENTMUX_AGENT_ID": "aria" } } }
2640 }))
2641 .unwrap();
2642
2643 let first = inject_jekt_signing_keys_into_mcp_json(&content, &store, "aria").unwrap();
2644 let second = inject_jekt_signing_keys_into_mcp_json(&content, &store, "aria").unwrap();
2645 let first_key = serde_json::from_str::<Value>(&first).unwrap()["mcpServers"]["agentmux"]["env"]["AGENTMUX_JEKT_KEY"].clone();
2646 let second_key = serde_json::from_str::<Value>(&second).unwrap()["mcpServers"]["agentmux"]["env"]["AGENTMUX_JEKT_KEY"].clone();
2647 assert_eq!(first_key, second_key, "minted-on-first-use key must be reused, not re-minted, on every materialization");
2648 }
2649
2650 #[test]
2654 fn inject_jekt_signing_keys_gives_no_wan_key_without_a_wan_identity_store() {
2655 let store = crate::backend::storage::store::Store::open_in_memory().unwrap();
2656 let content = serde_json::to_string(&json!({
2657 "mcpServers": { "agentmux": { "env": { "AGENTMUX_AGENT_ID": "aria" } } }
2658 }))
2659 .unwrap();
2660 let rewritten = inject_jekt_signing_keys_into_mcp_json(&content, &store, "aria").unwrap();
2661 let env = serde_json::from_str::<Value>(&rewritten).unwrap()["mcpServers"]["agentmux"]["env"].clone();
2662 assert!(env["AGENTMUX_JEKT_KEY"].is_string());
2663 assert!(env["AGENTMUX_LAN_KEY"].is_string());
2664 assert!(env.get("AGENTMUX_WAN_KEY").is_none());
2665 assert!(env.get("AGENTMUX_HOST_LABEL").is_none());
2666 assert!(store.agent_wan_key_load("aria").unwrap().is_none(), "nothing minted in objects.db");
2667 }
2668
2669 #[test]
2672 fn inject_jekt_signing_keys_imports_the_legacy_wan_key_once() {
2673 let store = crate::backend::storage::store::Store::open_in_memory().unwrap();
2674 let legacy = store.agent_wan_key_ensure("aria").unwrap();
2675 let (_wan_dir, wan) = crate::backend::storage::wan_identity::attach_temp_wan_identity(&store);
2676 let content = serde_json::to_string(&json!({
2677 "mcpServers": { "agentmux": { "env": { "AGENTMUX_AGENT_ID": "aria" } } }
2678 }))
2679 .unwrap();
2680 let rewritten = inject_jekt_signing_keys_into_mcp_json(&content, &store, "aria").unwrap();
2681 let env = serde_json::from_str::<Value>(&rewritten).unwrap()["mcpServers"]["agentmux"]["env"].clone();
2682 assert_eq!(env["AGENTMUX_WAN_KEY"], json!(legacy.private_key));
2683 assert!(wan.agent_key_load("aria").unwrap().unwrap().imported);
2684 }
2685
2686 #[test]
2687 fn inject_jekt_signing_keys_into_mcp_json_returns_none_when_theres_no_env_object_to_patch() {
2688 let store = crate::backend::storage::store::Store::open_in_memory().unwrap();
2689 let content = serde_json::to_string(&json!({"hello": "world"})).unwrap();
2691 assert!(inject_jekt_signing_keys_into_mcp_json(&content, &store, "aria").is_none());
2692 }
2693
2694 #[test]
2695 fn inject_jekt_signing_keys_into_mcp_json_returns_none_on_malformed_json() {
2696 let store = crate::backend::storage::store::Store::open_in_memory().unwrap();
2697 assert!(inject_jekt_signing_keys_into_mcp_json("not json", &store, "aria").is_none());
2698 }
2699
2700 #[test]
2706 fn startup_instructions_fresh_working_dir_writes_directly_with_the_marker() {
2707 let dir = tempfile::tempdir().unwrap();
2708 write_startup_instructions_respecting_existing(dir.path(), "AGENTS.md", "Soul + AgentMD content")
2709 .unwrap();
2710
2711 let content = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap();
2712 assert!(content.starts_with(STARTUP_INSTRUCTIONS_MANAGED_MARKER));
2713 assert!(content.contains("Soul + AgentMD content"));
2714 }
2715
2716 #[test]
2717 fn startup_instructions_agentmux_owned_file_is_freely_regenerated() {
2718 let dir = tempfile::tempdir().unwrap();
2719 write_startup_instructions_respecting_existing(dir.path(), "QWEN.md", "first version").unwrap();
2720 write_startup_instructions_respecting_existing(dir.path(), "QWEN.md", "second version").unwrap();
2721
2722 let content = std::fs::read_to_string(dir.path().join("QWEN.md")).unwrap();
2723 assert!(content.contains("second version"));
2724 assert!(!content.contains("first version"), "regeneration must replace, not accumulate");
2725 }
2726
2727 #[test]
2728 fn startup_instructions_foreign_file_content_is_never_touched() {
2729 let dir = tempfile::tempdir().unwrap();
2733 let human_content = "# My real project\n\nHand-written AGENTS.md, no AgentMux involvement.";
2734 std::fs::write(dir.path().join("AGENTS.md"), human_content).unwrap();
2735
2736 write_startup_instructions_respecting_existing(dir.path(), "AGENTS.md", "AgentMux's generated content")
2737 .unwrap();
2738
2739 let content = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap();
2740 assert_eq!(content, human_content, "every byte of the original file must survive");
2741 assert!(!content.contains("AgentMux's generated content"));
2742 }
2743
2744 #[test]
2745 fn startup_instructions_foreign_empty_file_is_never_touched() {
2746 let dir = tempfile::tempdir().unwrap();
2749 std::fs::write(dir.path().join("GEMINI.md"), "").unwrap();
2750
2751 write_startup_instructions_respecting_existing(dir.path(), "GEMINI.md", "AgentMux's generated content")
2752 .unwrap();
2753
2754 let content = std::fs::read_to_string(dir.path().join("GEMINI.md")).unwrap();
2755 assert_eq!(content, "");
2756 }
2757
2758 #[test]
2759 fn startup_instructions_non_utf8_file_is_treated_as_foreign_not_missing() {
2760 let dir = tempfile::tempdir().unwrap();
2761 let path = dir.path().join("AGENTS.md");
2762 std::fs::write(&path, [0x23, 0x20, 0xFF, 0xFE, 0x0A]).unwrap();
2763 let raw_before = std::fs::read(&path).unwrap();
2764
2765 write_startup_instructions_respecting_existing(dir.path(), "AGENTS.md", "generated content").unwrap();
2766
2767 let raw_after = std::fs::read(&path).unwrap();
2768 assert_eq!(raw_before, raw_after, "an unreadable file must be treated as foreign, never as absent");
2769 }
2770
2771 #[test]
2772 fn startup_instructions_creates_parent_directories_for_nested_paths() {
2773 let dir = tempfile::tempdir().unwrap();
2776 write_startup_instructions_respecting_existing(dir.path(), ".pi/APPEND_SYSTEM.md", "content").unwrap();
2777
2778 let content = std::fs::read_to_string(dir.path().join(".pi/APPEND_SYSTEM.md")).unwrap();
2779 assert!(content.contains("content"));
2780 }
2781
2782 #[test]
2788 fn claude_md_fresh_working_dir_writes_directly_with_the_marker() {
2789 let dir = tempfile::tempdir().unwrap();
2790 write_claude_md_respecting_ownership(dir.path(), "Soul + AgentMD + Memory + Skills").unwrap();
2791
2792 let content = std::fs::read_to_string(dir.path().join("CLAUDE.md")).unwrap();
2793 assert!(content.starts_with(CLAUDE_MD_MANAGED_MARKER));
2794 assert!(content.contains("Soul + AgentMD + Memory + Skills"));
2795 assert!(!dir.path().join(AGENTMUX_MEMORY_FILENAME).exists());
2797 }
2798
2799 #[test]
2800 fn claude_md_agentmux_owned_file_is_freely_regenerated() {
2801 let dir = tempfile::tempdir().unwrap();
2802 write_claude_md_respecting_ownership(dir.path(), "first version").unwrap();
2803 write_claude_md_respecting_ownership(dir.path(), "second version").unwrap();
2804
2805 let content = std::fs::read_to_string(dir.path().join("CLAUDE.md")).unwrap();
2806 assert!(content.contains("second version"));
2807 assert!(!content.contains("first version"), "regeneration must replace, not accumulate");
2808 }
2809
2810 #[test]
2811 fn claude_md_foreign_file_content_is_never_touched() {
2812 let dir = tempfile::tempdir().unwrap();
2813 let human_content = "# My real project\n\nHand-written rules, no AgentMux marker.";
2814 std::fs::write(dir.path().join("CLAUDE.md"), human_content).unwrap();
2815
2816 write_claude_md_respecting_ownership(dir.path(), "AgentMux's generated content").unwrap();
2817
2818 let content = std::fs::read_to_string(dir.path().join("CLAUDE.md")).unwrap();
2819 assert!(content.starts_with(human_content), "every byte of the original file must survive, at the start");
2820 assert!(!content.contains("AgentMux's generated content"), "generated content must never land in the real file");
2821 }
2822
2823 #[test]
2824 fn claude_md_non_utf8_file_is_treated_as_foreign_not_missing() {
2825 let dir = tempfile::tempdir().unwrap();
2831 let claude_md_path = dir.path().join("CLAUDE.md");
2832 std::fs::write(&claude_md_path, [0x23, 0x20, 0xFF, 0xFE, 0x0A]).unwrap();
2835 let raw_before = std::fs::read(&claude_md_path).unwrap();
2836
2837 write_claude_md_respecting_ownership(dir.path(), "AgentMux's generated content").unwrap();
2838
2839 let raw_after = std::fs::read(&claude_md_path).unwrap();
2840 assert_eq!(raw_before, raw_after, "an unreadable foreign file's bytes must be completely untouched, not overwritten");
2841 let side_file = std::fs::read_to_string(dir.path().join(AGENTMUX_MEMORY_FILENAME)).unwrap();
2844 assert_eq!(side_file, "AgentMux's generated content");
2845 }
2846
2847 #[test]
2848 fn claude_md_foreign_file_gets_a_side_file_and_one_import_line() {
2849 let dir = tempfile::tempdir().unwrap();
2850 std::fs::write(dir.path().join("CLAUDE.md"), "# Real project\n").unwrap();
2851
2852 write_claude_md_respecting_ownership(dir.path(), "the generated body").unwrap();
2853
2854 let side_file = std::fs::read_to_string(dir.path().join(AGENTMUX_MEMORY_FILENAME)).unwrap();
2855 assert_eq!(side_file, "the generated body");
2856
2857 let claude_md = std::fs::read_to_string(dir.path().join("CLAUDE.md")).unwrap();
2858 assert!(claude_md.contains(&format!("@{AGENTMUX_MEMORY_FILENAME}")));
2859 assert!(claude_md.contains(CLAUDE_MD_IMPORT_MARKER_COMMENT));
2860 }
2861
2862 #[test]
2863 fn claude_md_import_line_is_never_duplicated_across_launches() {
2864 let dir = tempfile::tempdir().unwrap();
2865 std::fs::write(dir.path().join("CLAUDE.md"), "# Real project\n").unwrap();
2866
2867 write_claude_md_respecting_ownership(dir.path(), "v1").unwrap();
2868 write_claude_md_respecting_ownership(dir.path(), "v2").unwrap();
2869 write_claude_md_respecting_ownership(dir.path(), "v3").unwrap();
2870
2871 let claude_md = std::fs::read_to_string(dir.path().join("CLAUDE.md")).unwrap();
2872 let import_needle = format!("@{AGENTMUX_MEMORY_FILENAME}");
2873 assert_eq!(claude_md.matches(&import_needle).count(), 1, "the import line must appear exactly once, no matter how many launches");
2874 let side_file = std::fs::read_to_string(dir.path().join(AGENTMUX_MEMORY_FILENAME)).unwrap();
2876 assert_eq!(side_file, "v3");
2877 }
2878
2879 #[test]
2880 fn claude_md_concurrent_launches_against_a_shared_workdir_never_duplicate_the_import() {
2881 let dir = tempfile::tempdir().unwrap();
2889 std::fs::write(dir.path().join("CLAUDE.md"), "# Real project\n").unwrap();
2890 let dir_path = dir.path().to_path_buf();
2891
2892 let barrier = std::sync::Arc::new(std::sync::Barrier::new(8));
2893 let handles: Vec<_> = (0..8)
2894 .map(|i| {
2895 let dir_path = dir_path.clone();
2896 let barrier = barrier.clone();
2897 std::thread::spawn(move || {
2898 barrier.wait(); write_claude_md_respecting_ownership(&dir_path, &format!("content-{i}")).unwrap();
2900 })
2901 })
2902 .collect();
2903 for h in handles {
2904 h.join().unwrap();
2905 }
2906
2907 let claude_md = std::fs::read_to_string(dir.path().join("CLAUDE.md")).unwrap();
2908 let import_needle = format!("@{AGENTMUX_MEMORY_FILENAME}");
2909 assert_eq!(
2910 claude_md.matches(&import_needle).count(),
2911 1,
2912 "8 concurrent launches against the same foreign CLAUDE.md must still produce exactly one import line, not one per racer"
2913 );
2914 }
2915
2916 #[test]
2917 fn claude_md_marker_is_rolled_back_when_the_append_fails() {
2918 let dir = tempfile::tempdir().unwrap();
2924 let claude_md_path = dir.path().join("CLAUDE.md");
2925 std::fs::write(&claude_md_path, "# Real project\n").unwrap();
2926
2927 let mut perms = std::fs::metadata(&claude_md_path).unwrap().permissions();
2931 perms.set_readonly(true);
2932 std::fs::set_permissions(&claude_md_path, perms).unwrap();
2933
2934 write_claude_md_respecting_ownership(dir.path(), "v1").unwrap();
2935
2936 let marker_path = dir.path().join(CLAUDE_MD_OWNERSHIP_MARKER_PATH);
2937 assert!(!marker_path.exists(), "a failed append must roll back the ownership marker so a future launch can retry");
2938
2939 let mut perms = std::fs::metadata(&claude_md_path).unwrap().permissions();
2941 #[allow(clippy::permissions_set_readonly_false)]
2942 perms.set_readonly(false);
2943 std::fs::set_permissions(&claude_md_path, perms).unwrap();
2944
2945 write_claude_md_respecting_ownership(dir.path(), "v2").unwrap();
2946 let claude_md = std::fs::read_to_string(&claude_md_path).unwrap();
2947 let import_needle = format!("@{AGENTMUX_MEMORY_FILENAME}");
2948 assert_eq!(claude_md.matches(&import_needle).count(), 1, "retry after the file becomes writable again must succeed");
2949 }
2950
2951 #[test]
2952 fn claude_md_user_deleted_import_line_is_never_reinserted() {
2953 let dir = tempfile::tempdir().unwrap();
2956 std::fs::write(dir.path().join("CLAUDE.md"), "# Real project\n").unwrap();
2957 write_claude_md_respecting_ownership(dir.path(), "v1").unwrap();
2958
2959 std::fs::write(dir.path().join("CLAUDE.md"), "# Real project\n").unwrap();
2961
2962 write_claude_md_respecting_ownership(dir.path(), "v2").unwrap();
2963
2964 let claude_md = std::fs::read_to_string(dir.path().join("CLAUDE.md")).unwrap();
2965 assert_eq!(claude_md, "# Real project\n", "import line must not silently reappear after the user removed it");
2966 let side_file = std::fs::read_to_string(dir.path().join(AGENTMUX_MEMORY_FILENAME)).unwrap();
2970 assert_eq!(side_file, "v2");
2971 }
2972}
2973
2974#[cfg(test)]
2975mod bundle_section_heading_tests {
2976 use super::BUNDLE_SECTION_HEADING;
2977
2978 #[test]
2983 fn heading_matches_frontend_builder_literal() {
2984 assert_eq!(BUNDLE_SECTION_HEADING, "# Memory");
2985 }
2986}
2987
2988#[cfg(test)]
2989mod is_agentmux_managed_instructions_tests {
2990 use super::*;
2991
2992 #[test]
2993 fn recognizes_both_marker_classes() {
2994 assert!(is_agentmux_managed_instructions(&format!(
2998 "{CLAUDE_MD_MANAGED_MARKER}\n\n# Memory\n"
2999 )));
3000 assert!(is_agentmux_managed_instructions(&format!(
3001 "{STARTUP_INSTRUCTIONS_MANAGED_MARKER}\n\nBe helpful.\n"
3002 )));
3003 }
3004
3005 #[test]
3006 fn a_foreign_file_is_not_ours_however_it_starts() {
3007 assert!(!is_agentmux_managed_instructions("# My project\n\nRules.\n"));
3008 assert!(!is_agentmux_managed_instructions(""));
3009 assert!(!is_agentmux_managed_instructions(&format!(
3012 "Someone pasted this below:\n{CLAUDE_MD_MANAGED_MARKER}\n"
3013 )));
3014 }
3015}
3016
3017#[cfg(test)]
3018mod mcp_json_tests {
3019 use super::*;
3021
3022 fn mcp_servers_of(dir: &std::path::Path) -> serde_json::Map<String, serde_json::Value> {
3023 let raw = std::fs::read_to_string(dir.join(".mcp.json")).unwrap();
3024 let v: serde_json::Value = serde_json::from_str(&raw).unwrap();
3025 v["mcpServers"].as_object().unwrap().clone()
3026 }
3027
3028 const GENERATED_MCP: &str = r#"{"mcpServers":{"agentmux":{"command":"agentmux-mcp","env":{"AGENTMUX_AGENT_ID":"a1"}},"github":{"command":"gh-mcp"}}}"#;
3029
3030 #[test]
3031 fn mcp_json_fresh_dir_is_written_as_generated() {
3032 let dir = tempfile::tempdir().unwrap();
3033 write_mcp_json_respecting_user_servers(dir.path(), GENERATED_MCP).unwrap();
3034 let servers = mcp_servers_of(dir.path());
3035 assert_eq!(servers.keys().collect::<Vec<_>>(), vec!["agentmux", "github"]);
3036 }
3037
3038 #[test]
3041 fn mcp_json_a_symlinked_file_is_left_alone_without_failing_the_launch() {
3042 let dir = tempfile::tempdir().unwrap();
3043 let target = dir.path().join("my-mcp.json");
3044 std::fs::write(&target, r#"{"mcpServers":{}}"#).unwrap();
3045 #[cfg(unix)]
3046 let made = std::os::unix::fs::symlink(&target, dir.path().join(".mcp.json")).is_ok();
3047 #[cfg(windows)]
3048 let made = std::os::windows::fs::symlink_file(&target, dir.path().join(".mcp.json")).is_ok();
3049 if !made {
3050 return;
3051 }
3052 write_mcp_json_respecting_user_servers(dir.path(), GENERATED_MCP).unwrap();
3053 assert!(std::fs::symlink_metadata(dir.path().join(".mcp.json")).unwrap().file_type().is_symlink());
3054 assert_eq!(std::fs::read_to_string(&target).unwrap(), r#"{"mcpServers":{}}"#);
3055 }
3056
3057 #[test]
3061 fn mcp_json_a_users_server_survives_two_launches_with_claude_linking_outside() {
3062 let dir = tempfile::tempdir().unwrap();
3063 let outside = tempfile::tempdir().unwrap();
3064 #[cfg(unix)]
3065 let made = std::os::unix::fs::symlink(outside.path(), dir.path().join(".claude")).is_ok();
3066 #[cfg(windows)]
3067 let made = std::os::windows::fs::symlink_dir(outside.path(), dir.path().join(".claude")).is_ok();
3068 if !made {
3069 return;
3070 }
3071 let users = r#"{"mcpServers":{"mine":{"command":"my-mcp"}}}"#;
3072 std::fs::write(dir.path().join(".mcp.json"), users).unwrap();
3073 write_mcp_json_respecting_user_servers(dir.path(), GENERATED_MCP).unwrap();
3074 write_mcp_json_respecting_user_servers(dir.path(), GENERATED_MCP).unwrap();
3075 assert!(mcp_servers_of(dir.path()).contains_key("mine"));
3076 assert!(std::fs::read_dir(outside.path()).unwrap().next().is_none(), "nothing written outside");
3077 }
3078
3079 #[test]
3080 fn mcp_json_a_users_own_servers_and_keys_survive_the_launch() {
3081 let dir = tempfile::tempdir().unwrap();
3082 std::fs::write(
3083 dir.path().join(".mcp.json"),
3084 r#"{"mcpServers":{"postgres":{"command":"pg-mcp"}},"someOtherKey":true}"#,
3085 )
3086 .unwrap();
3087 write_mcp_json_respecting_user_servers(dir.path(), GENERATED_MCP).unwrap();
3088 let servers = mcp_servers_of(dir.path());
3089 assert!(servers.contains_key("postgres"), "the user's server must survive");
3090 assert!(servers.contains_key("agentmux"));
3091 assert!(servers.contains_key("github"));
3092 let raw = std::fs::read_to_string(dir.path().join(".mcp.json")).unwrap();
3093 assert!(raw.contains("someOtherKey"), "other top-level keys must survive");
3094 }
3095
3096 #[test]
3097 fn mcp_json_a_server_unbound_since_the_last_launch_is_removed_but_the_users_is_not() {
3098 let dir = tempfile::tempdir().unwrap();
3099 std::fs::write(dir.path().join(".mcp.json"), r#"{"mcpServers":{"postgres":{"command":"pg-mcp"}}}"#).unwrap();
3100 write_mcp_json_respecting_user_servers(dir.path(), GENERATED_MCP).unwrap();
3101 write_mcp_json_respecting_user_servers(dir.path(), r#"{"mcpServers":{"agentmux":{"command":"agentmux-mcp"}}}"#)
3103 .unwrap();
3104 let servers = mcp_servers_of(dir.path());
3105 assert!(!servers.contains_key("github"), "a server AgentMux wrote and no longer wants must go");
3106 assert!(servers.contains_key("postgres"), "the user's server must still be there");
3107 assert!(servers.contains_key("agentmux"));
3108 }
3109
3110 #[test]
3111 fn mcp_json_without_a_manifest_an_agentmux_written_file_is_regenerated_as_before() {
3112 let dir = tempfile::tempdir().unwrap();
3115 std::fs::write(
3116 dir.path().join(".mcp.json"),
3117 r#"{"mcpServers":{"agentmux":{"command":"old"},"stale":{"command":"x"}}}"#,
3118 )
3119 .unwrap();
3120 write_mcp_json_respecting_user_servers(dir.path(), GENERATED_MCP).unwrap();
3121 let servers = mcp_servers_of(dir.path());
3122 assert!(!servers.contains_key("stale"));
3123 assert_eq!(servers["agentmux"]["command"], "agentmux-mcp");
3124 }
3125
3126 #[test]
3127 fn mcp_json_our_entry_wins_a_name_clash() {
3128 let dir = tempfile::tempdir().unwrap();
3129 std::fs::write(dir.path().join(".mcp.json"), r#"{"mcpServers":{"github":{"command":"users-own"}}}"#).unwrap();
3130 write_mcp_json_respecting_user_servers(dir.path(), GENERATED_MCP).unwrap();
3131 assert_eq!(mcp_servers_of(dir.path())["github"]["command"], "gh-mcp");
3132 }
3133
3134 #[test]
3135 fn mcp_json_a_file_that_is_not_a_json_object_is_left_untouched() {
3136 for body in ["{ not json", "[1,2]", r#"{"mcpServers":[]}"#] {
3137 let dir = tempfile::tempdir().unwrap();
3138 std::fs::write(dir.path().join(".mcp.json"), body).unwrap();
3139 write_mcp_json_respecting_user_servers(dir.path(), GENERATED_MCP).unwrap();
3140 assert_eq!(std::fs::read_to_string(dir.path().join(".mcp.json")).unwrap(), body);
3141 }
3142 }
3143
3144 #[test]
3145 fn mcp_json_rejects_generated_content_that_is_not_json() {
3146 let dir = tempfile::tempdir().unwrap();
3147 assert!(write_mcp_json_respecting_user_servers(dir.path(), "nope").is_err());
3148 assert!(!dir.path().join(".mcp.json").exists());
3149 }
3150
3151 #[test]
3152 fn mcp_json_leaves_no_temp_file_behind() {
3153 let dir = tempfile::tempdir().unwrap();
3154 write_mcp_json_respecting_user_servers(dir.path(), GENERATED_MCP).unwrap();
3155 let leftovers: Vec<_> = std::fs::read_dir(dir.path())
3156 .unwrap()
3157 .filter_map(|e| e.ok())
3158 .filter(|e| e.file_name().to_string_lossy().contains("agentmux-tmp"))
3159 .collect();
3160 assert!(leftovers.is_empty());
3161 }
3162
3163 #[test]
3164 fn mcp_json_concurrent_launches_into_one_dir_never_publish_corrupt_json() {
3165 let dir = tempfile::tempdir().unwrap();
3169 let base = dir.path().to_path_buf();
3170 let handles: Vec<_> = (0..16)
3171 .map(|i| {
3172 let base = base.clone();
3173 std::thread::spawn(move || {
3174 let padding = "x".repeat(i * 4096);
3177 let content = format!(
3178 r#"{{"mcpServers":{{"agentmux":{{"command":"agentmux-mcp","env":{{"AGENTMUX_AGENT_ID":"a{i}","PAD":"{padding}"}}}},"srv{i}":{{"command":"s{i}"}}}}}}"#
3179 );
3180 for _ in 0..10 {
3181 write_mcp_json_respecting_user_servers(&base, &content).unwrap();
3182 }
3183 })
3184 })
3185 .collect();
3186 for h in handles {
3187 h.join().unwrap();
3188 }
3189 let raw = std::fs::read_to_string(base.join(".mcp.json")).unwrap();
3190 let v: serde_json::Value = serde_json::from_str(&raw).expect("published .mcp.json must be valid JSON");
3191 assert!(v["mcpServers"]["agentmux"].is_object());
3192 let manifest = std::fs::read_to_string(base.join(MANAGED_MCP_SERVERS_MANIFEST)).unwrap();
3193 let managed: std::collections::BTreeSet<String> =
3194 serde_json::from_str(&manifest).expect("the manifest must not be interleaved either");
3195 let on_disk: std::collections::BTreeSet<String> =
3199 v["mcpServers"].as_object().unwrap().keys().cloned().collect();
3200 assert_eq!(managed, on_disk, "manifest and .mcp.json describe different launches");
3201 assert_eq!(on_disk.len(), 2, "exactly one launch's servers (agentmux + its own), got {on_disk:?}");
3202 for d in [base.clone(), base.join(".claude")] {
3203 let leftovers = std::fs::read_dir(&d)
3204 .unwrap()
3205 .filter_map(|e| e.ok())
3206 .filter(|e| e.file_name().to_string_lossy().contains("agentmux-tmp"))
3207 .count();
3208 assert_eq!(leftovers, 0, "temp files left in {}", d.display());
3209 }
3210 }
3211
3212 #[cfg(unix)]
3213 #[test]
3214 fn mcp_json_is_owner_only_on_unix_even_when_replacing_a_0644_file() {
3215 use std::os::unix::fs::PermissionsExt;
3216 let dir = tempfile::tempdir().unwrap();
3217 let path = dir.path().join(".mcp.json");
3218 std::fs::write(&path, r#"{"mcpServers":{}}"#).unwrap();
3219 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
3220 write_mcp_json_respecting_user_servers(dir.path(), GENERATED_MCP).unwrap();
3221 assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
3222 }
3223}