Skip to main content

agentmux_srv/backend/
agent_config.rs

1// Copyright 2024-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Pure config-building logic for agent definitions.
5//!
6//! Ports the `buildConfigFiles`, `buildMcpConfig`, and `expandTemplate`
7//! functions from `frontend/app/view/agent/agent-model.ts`.
8//! Most functions here are pure — no I/O, no async. The exception is the
9//! `managed skill files manifest` section near the end: real filesystem
10//! I/O shared by the two independent RPC handlers that materialize config
11//! files to disk (`agent.open`'s `write_agent_config_files` in
12//! `server/app_api/agent_open.rs`, and `writeagentconfig` in
13//! `server/editor_handlers.rs` — the latter is the actual "click Launch"
14//! path used on every normal agent launch). Lives here rather than in
15//! either handler file so the two callers can't drift out of sync on the
16//! manifest format or the path-traversal defense (reagent P1, PR #2322 —
17//! `writeagentconfig` initially had no cleanup at all).
18
19use std::collections::{HashMap, HashSet};
20
21use chrono::Utc;
22use serde_json::{json, Value};
23
24use crate::backend::storage::store::{derive_slug, AgentSkill};
25
26/// `skill_type` value that materializes a skill as an Agent Skills-format
27/// `.claude/skills/<slug>/SKILL.md` instead of a `.claude/commands/<trigger>.md`
28/// slash command. Any other value (in practice, always `"prompt"` today) keeps
29/// the pre-existing slash-command behavior. See
30/// `docs/specs/REPORT_ARMORY_BUNDLE_STANDARD_RESEARCH_2026_07_16.md` Phase 0 —
31/// `skill_type` already existed end-to-end but was never branched on before
32/// this, making it the lowest-friction place to hang the format discriminator
33/// rather than adding a new column.
34pub const SKILL_TYPE_AGENT_SKILL: &str = "agent-skill";
35
36/// A single file to be written to the agent working directory.
37#[derive(Debug, Clone)]
38pub struct AgentConfigFile {
39    /// Path relative to the agent working directory (e.g. `"CLAUDE.md"`, `".mcp.json"`).
40    pub filename: String,
41    /// UTF-8 file content.
42    pub content: String,
43}
44
45/// Build the list of config files to write to the agent working directory.
46///
47/// Assembles the startup instructions file (`CLAUDE.md`, `AGENTS.md`,
48/// `GEMINI.md`, ... — resolved per `provider_id` via
49/// `providers::get_provider(provider_id).startup_instructions_filename`;
50/// see docs/specs/SPEC_PROVIDER_AWARE_STARTUP_INSTRUCTIONS_2026_08_24.md)
51/// from `soul` + `agentmd` + `memory` + skills index, writes each skill as
52/// a slash command under `.claude/commands/<trigger>.md`, writes
53/// `.claude/hooks.json` if a `hooks` content entry is present, auto-injects
54/// the AgentMux MCP server entry, and applies `{{VARIABLE}}` template
55/// substitution throughout. An unrecognized `provider_id`, or one with no
56/// confirmed native file (currently only `kimi`), gets no instructions
57/// file written at all — never a silent `CLAUDE.md` fallback.
58///
59/// Mirrors `buildConfigFiles()` in `frontend/app/view/agent/agent-model.ts`.
60pub 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    // Template variables for {{}} substitution
72    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    // DATE in YYYY-MM-DD format, UTC
79    template_vars.insert("DATE".to_string(), Utc::now().format("%Y-%m-%d").to_string());
80
81    // ----------------------------------------------------------------
82    // Build the startup instructions file: Soul + AgentMD + Memory + Skills index
83    // ----------------------------------------------------------------
84    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    // Append skill index with trigger references. Claude Code lists the
101    // skills it has a file for (.claude/commands, .claude/skills) by itself,
102    // so for it the index carries only those without one (LC3, §4.4).
103    // By the resolved provider, so an alias (`claude-code`) counts as Claude.
104    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    // Resolved per-provider — `None` for an unrecognized provider_id or one
126    // with no confirmed native file (kimi) skips writing this file
127    // entirely rather than guessing "CLAUDE.md".
128    // Written even when empty: srv injects the Global Memory into it at write
129    // time (`inject_global_bundles`), and with the skills index gone for
130    // Claude (LC3) an agent with no soul, instructions or memory would
131    // otherwise get no startup file, and no Global Memory in it, at all.
132    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    // ----------------------------------------------------------------
142    // Write each skill as either a slash command
143    // (.claude/commands/{trigger}.md, default) or an Agent Skills-format
144    // SKILL.md (.claude/skills/{slug}/SKILL.md, skill_type ==
145    // SKILL_TYPE_AGENT_SKILL) for native Claude Code consumption.
146    // ----------------------------------------------------------------
147    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    // ----------------------------------------------------------------
169    // Write .claude/hooks.json — always includes a PreToolUse:Bash
170    // entry pointing at `agentmux-bashwrap hook` so the streaming
171    // wrapper is invoked for every Bash tool call, plus two
172    // PreCompact entries (matcher "manual" / "auto") pointing at
173    // `agentmux-bashwrap precompact` so a live "compaction started"
174    // signal reaches the sidecar. User-provided hooks (from
175    // content_map["hooks"]) are merged on top, with the user's
176    // entries winning on key collisions, EXCEPT that our PreToolUse
177    // and PreCompact entries are always appended to any user array
178    // for those keys so streaming / compaction visibility stay on
179    // regardless. See docs/specs/SPEC_STREAMING_BASH_RUNNER_2026_05_11.md
180    // §5 and docs/specs/SPEC_COMPACTION_DETECTION_AND_HANDLING_2026_07_31.md
181    // §4.2.
182    // ----------------------------------------------------------------
183    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    // ----------------------------------------------------------------
193    // Build .mcp.json with auto-injected AgentMux MCP server
194    // ----------------------------------------------------------------
195    // agent_bus_id is not in the function signature; callers that have it
196    // should call build_mcp_config directly and push the result themselves,
197    // or use the variant below.
198    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
209/// Build `.mcp.json` content with the auto-injected AgentMux MCP server entry.
210///
211/// The AgentMux server is always present as `mcpServers.agentmux`.
212/// If `user_mcp_content` is `Some`, its `mcpServers` entries are merged on top
213/// (user entries win over the auto-injected entry if the key collides).
214/// If the user content is not valid JSON the auto-injected-only config is
215/// returned and no error is propagated (mirrors TS behavior).
216///
217/// Returns `None` only if serialization unexpectedly fails (should never happen).
218///
219/// Mirrors `buildMcpConfig()` in `frontend/app/view/agent/agent-model.ts`.
220pub fn build_mcp_config(
221    user_mcp_content: Option<&str>,
222    agent_slug: &str,
223    agent_bus_id: &str,
224) -> Option<String> {
225    // Auto-injected AgentMux MCP server entry.
226    // agent_slug must be the pre-computed stable role slug (e.g. "korp"),
227    // NOT the display name — callers are responsible for passing the right
228    // value so renamed agents always advertise the same routing ID.
229    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    // Merge user-provided MCP config if present
248    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                // User content parsed but isn't an object — skip merge silently
259            }
260            Err(_) => {
261                // Invalid JSON in agent content — keep auto-injected only (mirrors TS behavior)
262                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
277/// Build `.mcp.json` from standalone McpServer ref rows (v1 composable model).
278///
279/// Layering (later wins on key collision, except the reserved `agentmux` key):
280///   1. synthetic `agentmux` entry (always injected)
281///   2. `user_mcp_blob`'s `mcpServers` — the legacy per-agent blob, merged when
282///      the caller passes it (used so a global-only ref set never wipes a
283///      legacy agent's user servers)
284///   3. `servers` ref rows (the agent's own bound servers + globals)
285/// For each ref row the `config` field is a JSON object used as the server
286/// value directly. Falls back to `build_mcp_config` when there are no ref rows
287/// and no blob.
288pub 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    // Layer 2: merge the legacy user blob's servers (skip the reserved key).
316    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    // Layer 3: ref rows (own + global) overlay the blob.
336    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
348/// Convert standalone Skill records into the AgentSkill shape expected by
349/// `build_config_files`. Used when the v1 ref tables are non-empty.
350pub 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
369/// Merge a user-supplied `settings.json`-level hook-array (`PreToolUse`
370/// or `PreCompact`) with whatever is already staged in `hooks_obj`
371/// under `key` (our auto-injected entries, possibly already carrying
372/// legacy `content_map["hooks"]`-merged user entries from the earlier
373/// pass in [`build_settings_with_hooks`]). User entries are PREPENDED
374/// so their matchers/gates get first refusal; AgentMux's own entries
375/// always stay last. A non-array value is warned and dropped rather
376/// than silently discarded — same discipline as every other branch in
377/// this function (reagent P1 on PR #813).
378fn 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
398/// The `SessionStart` hook entry: one `agentmux-bashwrap sessionstart --part N`
399/// command per memory part, `N` = 1..=`HOOK_PARTS`
400/// (`backend::memory_delivery`). The commands run in parallel, and each
401/// prints its own part (or nothing).
402fn 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
409/// Build `.claude/settings.json` content with the auto-injected
410/// PreToolUse Bash hook and PreCompact hooks (under the `"hooks"`
411/// key). PreToolUse redirects Bash invocations into the streaming
412/// wrapper (`agentmux-bashwrap exec`); PreCompact (two entries,
413/// matcher `"manual"` / `"auto"`) pings the sidecar the instant
414/// compaction starts (`agentmux-bashwrap precompact`) — see
415/// `docs/specs/SPEC_COMPACTION_DETECTION_AND_HANDLING_2026_07_31.md`
416/// §4.2. User-supplied settings.json (from the agent's
417/// `content_map["settings"]`) is parsed and merged at the top level;
418/// user-supplied legacy hooks content (from `content_map["hooks"]`)
419/// is merged into `settings.hooks`.
420///
421/// **File location matters.** Claude Code reads project hooks from
422/// `<project>/.claude/settings.json` under the `"hooks"` key.
423/// A standalone `.claude/hooks.json` is NOT a Claude Code
424/// discovery location — that was the v0.33.804 streaming-bug root
425/// cause: the file was written but Claude never read it, so the
426/// PreToolUse hook never fired and live streaming silently failed.
427///
428/// See `docs/specs/SPEC_STREAMING_BASH_RUNNER_2026_05_11.md` §5
429/// and Claude Code docs: https://code.claude.com/docs/en/hooks.md
430pub 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    // `PreCompact` requires an explicit `matcher` (`"manual"` or
445    // `"auto"`) — Claude Code has no confirmed wildcard-all value for
446    // this hook — so two separate entries are registered, each with a
447    // different static `--trigger=` argv baked in so the binary knows
448    // which fired without needing it from stdin (PreCompact's stdin
449    // payload carries no `trigger` field; see `precompact.rs`). See
450    // `docs/specs/SPEC_COMPACTION_DETECTION_AND_HANDLING_2026_07_31.md`
451    // §4.2.
452    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    // `SessionStart`: one command per memory part, so the agent's memory
471    // reaches the model at every new session, `/clear` and compaction
472    // without a visible turn — see
473    // docs/specs/SPEC_GLOBAL_MEMORY_DELIVERY_2026_09_27.md §7 P2. The CLI
474    // delivers about 10,000 characters of `additionalContext` per hook
475    // command, so the memory is split into parts and each command asks the
476    // sidecar for its own. No matcher: the sidecar itself answers "nothing"
477    // for `resume` and `fork` (the conversation is intact, D10).
478    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    // Start with user hooks if present + parseable. Parse failures or
485    // non-Object top-levels are logged at WARN so the diagnostic trail
486    // surfaces — silent swallowing made user hooks disappear with no
487    // signal (reagent P2 on PR #809).
488    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    // Append our entries last so user matchers (deny rules etc.) get a chance
536    // to short-circuit before our rewrite / observation hooks. PreCompact
537    // gets both matcher entries, in matcher order (manual, then auto).
538    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    // Build the settings.json object: start from user-supplied settings.json
547    // (if any), then overlay our hooks key. User keys other than `hooks`
548    // pass through unchanged.
549    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    // Merge: any existing hooks key from user settings is merged with our
571    // additions. For PreToolUse and PreCompact specifically, user matchers
572    // from settings.json are PREPENDED (not dropped) so they short-circuit
573    // before our auto-injected entries — same ordering rule we apply to
574    // legacy content_map["hooks"] entries for these two keys. For other
575    // event types (PostToolUse, Stop, etc.) we keep user's entries
576    // verbatim. Reagent P1 on PR #813 (the `continue` was a silent drop —
577    // caught a real merge bug); PreCompact is deliberately folded into the
578    // same array-merge discipline rather than the generic `or_insert`
579    // below, or a user-supplied PreCompact entry would hit that path and
580    // be silently and permanently dropped the moment PreCompact became
581    // auto-injected too — the exact bug class this file already guards
582    // PreToolUse against. See
583    // `docs/specs/SPEC_COMPACTION_DETECTION_AND_HANDLING_2026_07_31.md`
584    // §4.2. SessionStart joins them for the same reason now that it is
585    // auto-injected too (SPEC_GLOBAL_MEMORY_DELIVERY_2026_09_27.md §7 P2).
586    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    // Claude Code requires the bashwrap exec command (produced by the hook rewrite)
598    // to be in permissions.allow — otherwise it raises a permissions error and the
599    // agent cannot run any bash commands. Merge with any user-supplied allow list
600    // rather than overwriting it.
601    {
602        // Space before * enforces a command-name boundary: matches
603        // "agentmux-bashwrap <args>" only, not other executables that
604        // happen to share the prefix (e.g. agentmux-bashwrapXYZ).
605        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    // No AI attribution in commits or PR descriptions: the repos' checks
625    // (`.github/workflows/no-coauthor-trailers.yml`) reject Co-Authored-By
626    // trailers and "Generated with" lines, and Claude Code adds both by
627    // default. An empty string hides each. An agent's own string `commit` or
628    // `pr` wins; a field it leaves out or sets to a non-string is replaced
629    // with "", and so is a non-object `attribution`, because Claude Code falls
630    // back to its default attribution for any of those.
631    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
653/// Replace `{{VARIABLE}}` placeholders in `content` with values from `vars`.
654///
655/// Placeholders that have no corresponding key in `vars` are left unchanged
656/// (the original `{{VARIABLE}}` text is preserved).
657///
658/// Mirrors `expandTemplate()` in `frontend/app/view/agent/agent-model.ts`.
659pub fn expand_template(content: &str, vars: &HashMap<String, String>) -> String {
660    // Hand-rolled replacement to avoid pulling in a regex dependency.
661    // Scans for `{{`, extracts the key name up to `}}`, and substitutes.
662    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        // Look for '{{'
669        if i + 1 < len && bytes[i] == b'{' && bytes[i + 1] == b'{' {
670            // Find closing '}}'
671            if let Some(rel) = content[i + 2..].find("}}") {
672                let key = &content[i + 2..i + 2 + rel];
673                // Only substitute if key is a simple word (alphanumeric + underscore)
674                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                        // No match — preserve the original placeholder
679                        result.push_str(&content[i..i + 2 + rel + 2]);
680                    }
681                    i += 2 + rel + 2; // skip past '}}'
682                    continue;
683                }
684            }
685        }
686        // Not a placeholder start — copy character verbatim
687        // Safety: i is always on a valid char boundary because we only advance
688        // by 1 when not inside a placeholder, and UTF-8 single-byte characters
689        // are the only ones we index directly.
690        let ch = content[i..].chars().next().unwrap();
691        result.push(ch);
692        i += ch.len_utf8();
693    }
694
695    result
696}
697
698/// Agent Skills spec caps `description` at 1024 characters.
699const SKILL_DESCRIPTION_MAX_LEN: usize = 1024;
700
701/// Truncate `s` to at most `max_units` UTF-16 code units, on a UTF-8 char
702/// boundary. Mirrors JS `.slice(0, n)` semantics (JS strings are indexed in
703/// UTF-16 code units, so a Rust byte-length cap diverges from the TS mirror
704/// for any non-ASCII text — reagent P2, PR #2322).
705fn 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
716/// Render an Agent Skills-format `SKILL.md`: YAML frontmatter with the two
717/// required fields (`name`, `description` — per the spec's six-field
718/// frontmatter; AgentMux doesn't populate the four optional ones today:
719/// `license`, `compatibility`, `metadata`, `allowed-tools`), followed by the
720/// skill's content as the Markdown body. See https://agentskills.io/specification.
721///
722/// `slug` (not the skill's raw display name) is REQUIRED here — the spec
723/// requires `name` be lowercase/hyphenated and match its parent directory;
724/// callers must pass the same value used to build the `.claude/skills/<slug>/`
725/// path (reagent P1, PR #2322). `description` is validated: the spec requires
726/// a non-empty value (falls back to a placeholder) capped at 1024 **UTF-16
727/// code units** — matching the TS mirror's `.slice(0, 1024)` (JS string
728/// indexing is UTF-16-code-unit based), since the UI permits arbitrary-length
729/// free text with no spec-awareness and non-ASCII descriptions previously
730/// truncated to different byte vs. UTF-16 lengths between the two paths
731/// (reagent P2, PR #2322).
732///
733/// YAML double-quoted scalars use JSON-compatible escaping (YAML 1.2
734/// §7.3.1), so `serde_json::to_string` on a plain string produces a valid,
735/// correctly-escaped YAML value — this avoids hand-rolling YAML escaping or
736/// adding a yaml crate dependency (this workspace has neither today) just
737/// for two scalar fields.
738pub(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
753/// For each skill, whether `build_config_files` leaves it a file of its own
754/// that Claude Code lists natively: an Agent Skill's `SKILL.md` (slugs are
755/// made unique), or a command with a usable trigger that no later skill's
756/// command overwrites. Triggers are compared case-insensitively, as a
757/// case-insensitive filesystem would. Trigger uniqueness isn't enforced by
758/// the skill store (Codex on #4131). A non-ASCII trigger never counts as
759/// owning its file: a normalization-insensitive filesystem (macOS) folds
760/// Unicode-equivalent names together, and keeping such a skill in the index
761/// costs at most a repeated line, never a lost skill.
762fn 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            // A non-ASCII trigger maps to no key: it never owns a file here,
779            // and doesn't count as overwriting anyone (it may, harmlessly).
780            !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
786/// Validate a skill's `trigger` is safe to use as a single path segment in
787/// `.claude/commands/<trigger>.md`. `trigger` is free-form user input with
788/// no format validation anywhere upstream (the skill create/update RPCs and
789/// the frontend form all accept it as-is), so a trigger containing a path
790/// separator or a `..` segment previously let the resulting filename
791/// resolve OUTSIDE the agent's working directory -- both for this write and,
792/// once stale-file cleanup existed, for the corresponding delete (reagent
793/// P1, PR #2322). Rejects (returns `None`) anything containing `/` or `\`,
794/// or that is exactly `.`/`..`; callers skip writing that skill's command
795/// file entirely rather than silently rewriting the trigger into something
796/// the user didn't ask for.
797fn 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
807/// Derive a slug for an Agent Skill name that is valid per the Agent Skills
808/// `name` grammar: lowercase letters, digits, and hyphens ONLY (no
809/// underscores). `derive_slug` is shared with agent role-slugs, which
810/// deliberately DO permit underscores, so it isn't spec-valid here as-is —
811/// hyphenate underscores (and re-collapse any resulting run of hyphens)
812/// rather than reusing it directly (Codex P1, PR #2322).
813fn 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
827/// Derive a filesystem-safe, COLLISION-FREE, spec-valid slug for an Agent
828/// Skill name within one caller-scoped `used` set. `skill_name_slug` alone
829/// can produce identical output for distinct names that differ only in
830/// punctuation/whitespace (e.g. "Deploy Checklist" and "Deploy!!!Checklist"
831/// both -> "deploy-checklist"), which would otherwise silently overwrite one
832/// skill's `SKILL.md` with another's on disk (reagent P1, PR #2322). Appends
833/// `-2`, `-3`, ... until the slug is unique within `used`, truncating the
834/// base first so the suffixed result never exceeds the spec's 64-character
835/// max (Codex P2, PR #2322 — a 64-char base plus `-2` was previously 66
836/// chars).
837pub(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
863// ============================================================
864// Managed skill files manifest (I/O — see module doc comment)
865// ============================================================
866
867/// Hidden manifest (relative to the agent working directory) tracking which
868/// skill-derived paths (`.claude/commands/*.md`, `.claude/skills/*/SKILL.md`)
869/// AgentMux itself wrote on the last materialization, so a subsequent one
870/// can delete ones that are no longer current without touching any
871/// user-authored `.claude/commands`/`.claude/skills` content.
872pub const MANAGED_SKILL_FILES_MANIFEST: &str = ".claude/.agentmux-managed-skill-files.json";
873
874/// Compute the subset of `filenames` that are skill-derived managed paths
875/// (the ones tracked in [`MANAGED_SKILL_FILES_MANIFEST`]) — everything else
876/// (`CLAUDE.md`, `.mcp.json`, `.claude/settings.json`, ...) is always fully
877/// regenerated at a fixed path every launch, so it has no staleness problem
878/// to track.
879pub 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
888/// Delete skill-derived files a PREVIOUS materialization wrote (per the
889/// on-disk manifest) but that are no longer part of `new_managed_paths` --
890/// e.g. a skill's format switched between `"prompt"`/`"agent-skill"`, or it
891/// was renamed/removed. Without this, the stale file stays on disk and
892/// Claude keeps treating it as active alongside the newly selected format
893/// (reagent P1 + Codex P1/P2, PR #2322).
894///
895/// MUST be called before writing the new files (so a deletion never races a
896/// write to the same path); callers must call
897/// [`write_managed_skill_file_manifest`] after writing to record the new
898/// set for the next materialization. Best-effort: any individual read/parse
899/// failure is treated as "no prior manifest" (nothing to clean up yet)
900/// rather than propagated, since a missing/corrupt manifest must never
901/// block the write that follows.
902///
903/// Every path is resolved through [`crate::backend::base::safe_join_within_base`]
904/// before deletion — defense in depth against a manifest path that somehow
905/// escapes the working directory (e.g. a future bypass of trigger
906/// sanitization upstream, or manual tampering with the manifest file
907/// itself); such a path is skipped with a warning, never followed.
908pub 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        // Through the workdir: a manifest path outside it, or under a
927        // `.claude` that links out of it, is refused, never deleted.
928        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        // Agent Skills format nests under .claude/skills/<slug>/ -- clean up
941        // the now-empty slug directory too (no-op/fails silently if
942        // anything else still lives there, e.g. a future scripts/ dir).
943        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
949/// Record `new_managed_paths` as the manifest for the NEXT call to
950/// [`cleanup_stale_managed_skill_files`]. Best-effort: a write failure is
951/// logged, not propagated — losing this write only means the next
952/// materialization's stale-file cleanup is skipped once, not a correctness
953/// issue for the current one.
954pub 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
972// ============================================================
973// CLAUDE.md ownership protection (I/O — see module doc comment)
974// ============================================================
975
976/// First line AgentMux writes on any `CLAUDE.md` it fully owns (freshly
977/// created, or regenerated on every launch since). Its ABSENCE on an
978/// EXISTING file is what marks that file as foreign — predates AgentMux
979/// touching this working directory, or a human replaced AgentMux's file
980/// with their own — and is never overwritten again. See
981/// `docs/specs/SPEC_CLAUDE_MD_OWNERSHIP_PROTECTION_2026_08_22.md`.
982pub const CLAUDE_MD_MANAGED_MARKER: &str = "<!-- agentmux:managed-claude-md -->";
983
984/// AgentMux-owned side file carrying the full Soul+AgentMD+Bundle+Skills
985/// composition when the real `CLAUDE.md` is foreign — always safe to
986/// regenerate in place every launch, unlike `CLAUDE.md` itself in that case.
987pub const AGENTMUX_MEMORY_FILENAME: &str = ".claude/AGENTMUX_MEMORY.md";
988
989/// Heading of the bundle-content section in a generated `CLAUDE.md`.
990///
991/// Persisted format marker, NOT an internal identifier: the frontend builder
992/// (`frontend/app/view/agent/agent-config-builder.ts`) emits the same literal,
993/// and `editor_handlers::inject_global_bundles` searches for it to prepend the
994/// global block ahead of per-agent content. All three must agree, so the string
995/// stays `# Memory` until the frontend heading changes in the same release
996/// (SPEC_ARMORY_NAMING_CONSOLIDATION_2026_09_09.md §4 do-not-rename list).
997pub const BUNDLE_SECTION_HEADING: &str = "# Memory";
998
999/// One-time marker recording whether the `@import` line (below) has
1000/// already been offered for this working directory. Checked INSTEAD of
1001/// re-scanning `CLAUDE.md` content on every launch — without it, a user
1002/// who deliberately deletes the import line (opting out of AgentMux
1003/// content entirely) would see it silently reappear on their next launch.
1004const CLAUDE_MD_OWNERSHIP_MARKER_PATH: &str = ".claude/.agentmux-claude-md-ownership.json";
1005
1006/// Where an adopted legacy `CLAUDE.md` is kept (`is_legacy_agentmux_claude_md`).
1007pub const CLAUDE_MD_PRE_ADOPT_BACKUP: &str = ".claude/CLAUDE.md.pre-adopt";
1008
1009/// A skills-index row exactly as `build_config_files` renders it:
1010/// `- **<name>**`, then optionally ` (trigger: /<trigger>)`, then optionally
1011/// ` — <description>` (Codex on #4131: a bare `- **note` is a person's).
1012fn 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
1030/// Whether `content` is exactly what an older AgentMux wrote as `CLAUDE.md`,
1031/// in its order, ignoring blank lines: the `# Available Skills` heading, its
1032/// usage line, at least one skill row (`- **…`; AgentMux wrote the section
1033/// only when there were skills), then optionally the managed import (its
1034/// comment and line, or the line alone). Anything else means a person wrote
1035/// there, and it stays theirs.
1036pub 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
1053/// Comment wrapping the `@import` line so its origin — and how to remove
1054/// it — is unambiguous to anyone reading a foreign `CLAUDE.md` by hand.
1055const 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
1063/// Materialize `generated_content` (the composed Soul+AgentMD+Bundle+Skills
1064/// `CLAUDE.md` body `build_config_files` already produced) against whatever
1065/// is already on disk at `base_path/CLAUDE.md`, WITHOUT ever overwriting a
1066/// foreign file's content:
1067///
1068/// - No file yet, or the existing file starts with
1069///   [`CLAUDE_MD_MANAGED_MARKER`]: AgentMux owns it — write `CLAUDE.md`
1070///   directly (marker + `generated_content`), exactly as every call site
1071///   did unconditionally before this function existed. **This is the one
1072///   path that still hard-fails on an I/O error** (propagated via `?`,
1073///   same as every call site's pre-existing behavior) — AgentMux's own
1074///   config file failing to write is a real launch-blocking problem.
1075/// - Existing file present WITHOUT the marker, **or present but
1076///   unreadable** (permission denied, non-UTF-8 content, transient I/O
1077///   error — a `read_to_string` error here must never collapse into "no
1078///   file yet," or an unreadable foreign file gets silently clobbered,
1079///   defeating this whole function's purpose; reagent P0 + codex P1 on
1080///   PR #2747): treated identically as foreign. `CLAUDE.md` itself is
1081///   never written to again. `generated_content` instead goes to
1082///   [`AGENTMUX_MEMORY_FILENAME`] (always safe to regenerate — it's 100%
1083///   AgentMux's own content), pulled in via a single `@import` line
1084///   appended to the real `CLAUDE.md` — offered at most once per working
1085///   directory (see [`CLAUDE_MD_OWNERSHIP_MARKER_PATH`]'s doc comment).
1086///   If the file is unreadable specifically, the `@import` offer is
1087///   skipped for this launch (nothing to safely append to/check
1088///   idempotency against) — retried on the next launch.
1089///
1090/// Everything in the foreign-file branch is best-effort, in the same
1091/// spirit as [`write_managed_skill_file_manifest`] and unlike the
1092/// AgentMux-owned branch above: an I/O failure writing the side file,
1093/// appending the import line, or writing the ownership marker is logged
1094/// and this function still returns `Ok(())` — losing one of these only
1095/// means the import line isn't offered (or the side file isn't
1096/// refreshed) on this one launch, not a correctness issue serious enough
1097/// to fail the whole agent launch (codex P1 on PR #2747: the original
1098/// version used `?` throughout this branch, contradicting this doc
1099/// comment and propagating a side-file write failure into a hard launch
1100/// failure).
1101///
1102/// The `@import` append is a true O_APPEND write
1103/// (`OpenOptions::append(true)`), never a read-modify-write of the whole
1104/// file — if the foreign file changes between this function's read and
1105/// the append (edited by the user or another process mid-launch), a
1106/// full-file rewrite would silently discard that intervening edit; a
1107/// true append can only ever add bytes at whatever the end happens to be
1108/// (codex P2 on PR #2747).
1109///
1110/// Every new path this function writes ([`AGENTMUX_MEMORY_FILENAME`],
1111/// [`CLAUDE_MD_OWNERSHIP_MARKER_PATH`]) is resolved through
1112/// [`crate::backend::base::safe_join_within_base`] and verified against
1113/// [`crate::backend::base::verify_no_symlink_escape`] before any write —
1114/// both constants are fixed, not user-controllable, but `.claude/` itself
1115/// could exist as a symlink escaping the working directory, and without
1116/// this check a write would silently follow it outside the selected
1117/// project (codex P1 on PR #2747, same defense-in-depth the other
1118/// config-file write loops already apply to their own paths).
1119///
1120/// First line AgentMux writes on any non-`CLAUDE.md` startup-instructions
1121/// file it fully owns (freshly created, or one it wrote on a prior
1122/// launch) — mirrors [`CLAUDE_MD_MANAGED_MARKER`]'s role for `CLAUDE.md`,
1123/// generic across filenames since `AGENTS.md`/`GEMINI.md`/`QWEN.md`/pi's
1124/// `APPEND_SYSTEM.md` don't each need distinct marker text. An HTML
1125/// comment renders invisibly in every markdown viewer and every one of
1126/// these providers reads its instructions file as plain text fed into a
1127/// prompt, so a leading comment line is universally harmless regardless
1128/// of provider — no per-provider syntax needed for the marker itself
1129/// (only the `@import`-equivalent side-file fallback CLAUDE.md also gets
1130/// would need that, and this function deliberately doesn't attempt it —
1131/// see the doc comment below).
1132const STARTUP_INSTRUCTIONS_MANAGED_MARKER: &str = "<!-- agentmux:managed-startup-instructions -->";
1133
1134/// Does this file's content say AgentMux wrote it?
1135///
1136/// There are two markers, not one: `CLAUDE.md` carries
1137/// [`CLAUDE_MD_MANAGED_MARKER`] and every other startup-instructions file
1138/// carries [`STARTUP_INSTRUCTIONS_MANAGED_MARKER`], because the two writers
1139/// grew separately. Anything asking "is this ours" has to know both — a check
1140/// against only the first reports every AgentMux-generated `AGENTS.md`,
1141/// `GEMINI.md`, `QWEN.md` and `.pi/APPEND_SYSTEM.md` as foreign, inverting the
1142/// answer for most providers (Codex, PR #3144).
1143///
1144/// Deliberately content-based rather than path-based: the marker is what makes
1145/// the claim, and a file's name says nothing about who wrote it. Phase 3 of
1146/// `SPEC_INSTRUCTION_AND_MEMORY_PORTABILITY_2026_09_09.md` reuses this to
1147/// label tracked instruction files `agentmux` or `foreign`, which is the one
1148/// field that decides whether a file is ours to touch.
1149///
1150/// **The two writers below deliberately do NOT use this**, and that is not an
1151/// oversight. Each asks a narrower question — "did I write this file, in my
1152/// own format, such that I may rewrite it" — and each answer must stay pinned
1153/// to its own marker. A `CLAUDE.md`-marked file is ours, but it is not
1154/// `write_startup_instructions_respecting_existing`'s to rewrite. This helper
1155/// answers the broader question, which is the one a read-only tracker asks.
1156pub 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
1161/// Write a NON-`CLAUDE.md` startup-instructions file (`AGENTS.md`,
1162/// `GEMINI.md`, `QWEN.md`, `.pi/APPEND_SYSTEM.md`, ...) WITHOUT ever
1163/// overwriting a pre-existing, non-AgentMux-authored file at that path.
1164///
1165/// codex P1, PR #2788: before
1166/// `docs/specs/SPEC_PROVIDER_AWARE_STARTUP_INSTRUCTIONS_2026_08_24.md`,
1167/// every provider's agent got `CLAUDE.md` written regardless of provider —
1168/// wrong, but harmless to a real Codex/Gemini/etc. project, since AgentMux
1169/// was never writing to the filename that project's own real `AGENTS.md`/
1170/// `GEMINI.md` actually lived at. Once `build_config_files` started
1171/// resolving the CORRECT native filename per provider, an unconditional
1172/// write (the plain `std::fs::write` every other config file still uses)
1173/// would silently destroy a pre-existing, user-authored project file the
1174/// moment its name collided with the now-correctly-resolved target — a
1175/// real, novel data-loss regression introduced BY fixing the filename,
1176/// not present before.
1177///
1178/// Mirrors [`write_claude_md_respecting_ownership`]'s OWNED-vs-foreign
1179/// marker check (freely regenerate if AgentMux wrote it, either freshly or
1180/// on a prior launch; never touch it otherwise) — WITHOUT that function's
1181/// `@import`-line side-file fallback offer for the foreign case. That
1182/// fuller mechanism is deliberately Claude-Code-`@import`-syntax-specific;
1183/// whether `AGENTS.md`/`GEMINI.md`/`QWEN.md`/pi's `APPEND_SYSTEM.md`
1184/// support an equivalent include directive their own harness actually
1185/// honors is unverified per-provider research this spec didn't do (§5/§6
1186/// of the spec above). The tradeoff accepted here: if the target file is
1187/// foreign, this agent's Soul/AgentMD/Bundle content is simply not
1188/// delivered via a file for this launch (logged, not silently dropped) —
1189/// a real capability gap, but strictly safer than overwriting a stranger's
1190/// file. Revisit once each provider's own include syntax is confirmed.
1191pub 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    // `None` = genuinely no file yet. `Some(Ok(content))` = read fine.
1199    // `Some(Err(_))` = exists but unreadable — MUST be treated the same as
1200    // "foreign," never as "no file yet" (same reasoning as
1201    // write_claude_md_respecting_ownership's own doc comment).
1202    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        // Through the workdir: never written through a symlink (dangling or
1213        // not), nor under a folder that links out of it (`.pi/` for pi).
1214        // A refusal leaves the file alone, like a foreign one.
1215        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
1236/// Names of the `mcpServers` entries AgentMux wrote into a working
1237/// directory's `.mcp.json` on the last materialization — see
1238/// [`write_mcp_json_respecting_user_servers`]. Lives beside
1239/// [`MANAGED_SKILL_FILES_MANIFEST`] for the same reason: it lets the next
1240/// launch tell entries it owns from entries the user put there.
1241pub const MANAGED_MCP_SERVERS_MANIFEST: &str = ".claude/.agentmux-managed-mcp-servers.json";
1242
1243/// Held (exclusively) for the whole read-merge-write in
1244/// [`write_mcp_json_respecting_user_servers`]. See its doc comment.
1245pub const MCP_JSON_WRITE_LOCK: &str = ".claude/.agentmux-mcp-json.lock";
1246
1247/// Write `.mcp.json` into `base_path` without destroying a user's own MCP
1248/// server entries (issue #3680). Shared by `agent.open` and the
1249/// `WriteAgentConfig` "click Launch" path — the two config-materializing call
1250/// sites this module keeps in sync.
1251///
1252/// An agent's `working_directory` may be the user's own project, where a
1253/// `.mcp.json` often already exists (and is often committed). A plain
1254/// `fs::write` replaced it, dropping every server entry the agent definition
1255/// didn't duplicate. Instead:
1256///
1257/// - **Merge.** Every existing `mcpServers` entry is kept, except the ones a
1258///   previous materialization wrote (per [`MANAGED_MCP_SERVERS_MANIFEST`]),
1259///   which are replaced by this launch's set — so a server unbound in
1260///   Connectors still disappears. Other top-level keys in the existing file are
1261///   kept too. On a name clash this launch's entry wins (the `agentmux` entry
1262///   must always be ours).
1263/// - **No manifest yet** (first launch after this change, or a user's file):
1264///   a file that already has an `agentmux` entry was written by AgentMux, so
1265///   all its entries are treated as managed — the old regenerate-every-launch
1266///   behavior, once. A file without one is the user's; all of it is kept.
1267/// - **Unreadable or not a JSON object** → left untouched and logged, never
1268///   overwritten — the same "never overwrite a stranger's file" rule
1269///   [`write_claude_md_respecting_ownership`] follows. Claude Code couldn't
1270///   parse such a file either.
1271/// - **Owner-only.** The file carries the agent's jekt signing material
1272///   (`inject_jekt_signing_keys_into_mcp_json`), so it is written `0600` on
1273///   Unix, via a temp file renamed into place so it is never briefly visible
1274///   with the default mode.
1275/// - **One launch at a time per directory.** `.mcp.json` and its manifest
1276///   must describe the same launch, or a server AgentMux wrote gets mistaken
1277///   for the user's and is never removed. Neither call site serializes by
1278///   directory (`agent_open`'s lock is per agent; `WriteAgentConfig` has
1279///   none), so the whole read-merge-write holds an exclusive lock on
1280///   [`MCP_JSON_WRITE_LOCK`]. A file lock rather than an in-process mutex, so
1281///   two AgentMux instances pointed at one project are serialized too; it is
1282///   released when the handle drops, including on error or panic.
1283///
1284/// Not addressed here: the signing material being in this file at all
1285/// (the identity plan's M4d/M5 move it out — see
1286/// `SPEC_AGENT_IDENTITY_CARRIED_NOT_DERIVED_2026_09_23.md` §6.5), and a
1287/// user's project not git-ignoring `.mcp.json`.
1288pub 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    // Refused (a symlink, or outside the workdir) before anything is read
1296    // or locked: this file carries the agent's signing keys. A refusal
1297    // leaves the link alone and the launch goes on, as for `CLAUDE.md`
1298    // (ReAgent on #4141). So does a refused ownership manifest (a `.claude`
1299    // linking out): without it the next merge would take the user's own
1300    // servers for AgentMux's and drop them (Codex on #4141).
1301    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    // Serialize concurrent launches into this directory — see the doc comment.
1319    // The exclusive per-directory write lock; released on drop. A `.claude`
1320    // linking out of the workdir refuses the lock file: the write then goes
1321    // ahead unlocked (it is still atomic) rather than failing the launch.
1322    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
1412/// Best-effort, like [`write_managed_skill_file_manifest`]: losing this write
1413/// only means the next launch falls back to the no-manifest rule once.
1414fn 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    // Atomic (the workdir's write is) for the same reason as the .mcp.json
1420    // write itself: concurrent launches into one directory must not
1421    // interleave into one manifest.
1422    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
1435/// Shared by `agent.open` (`server/app_api/agent_open.rs`) and the
1436/// `WriteAgentConfig` "click Launch" path (`server/editor_handlers.rs`) —
1437/// per this module's own doc comment, the two config-materializing call
1438/// sites that must not drift out of sync. See
1439/// `docs/specs/SPEC_CLAUDE_MD_OWNERSHIP_PROTECTION_2026_08_22.md`.
1440pub 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    // `None` = genuinely no file yet. `Some(Ok(content))` = read fine.
1447    // `Some(Err(_))` = exists but unreadable — MUST be treated the same
1448    // as "foreign," never as "no file yet" (see doc comment above).
1449    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    // Every write below goes through the workdir
1459    // (SPEC_WORKDIR_SAFE_WRITES_2026_10_01): a `CLAUDE.md` that is a symlink
1460    // (dangling or not), or a `.claude` linking out of the workspace, is
1461    // refused rather than written through (Codex P1s on #4131).
1462    let wd = crate::backend::workdir_fs::Workdir::open(base_path)?;
1463    let claude_md_writable = wd.resolve("CLAUDE.md").is_ok();
1464
1465    // A CLAUDE.md an older AgentMux wrote, before the managed marker existed:
1466    // nothing in it but the skills index and the managed import line. It was
1467    // taken for the user's and frozen, so its agent kept a stale skills list
1468    // forever. Adopt it, keeping a copy (LC3,
1469    // SPEC_LAUNCH_CONTEXT_WORKSPACE_RULE_AND_STARTUP_FILES_2026_09_30.md §4.4).
1470    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    // Nothing is written to a CLAUDE.md the workdir refuses, not even the
1487    // @import append below: every write would land on a link's target.
1488    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    // Known, accepted TOCTOU window (codex P2, third review round on
1493    // PR #2747): a foreign CLAUDE.md created between the read above and this
1494    // write would be replaced once. Closing it needs file locking across the
1495    // read-decide-write, which no sibling write here has either.
1496    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    // Foreign (or unreadable) file — CLAUDE.md's own content is never
1502    // written to from here on. Everything from here down is best-effort
1503    // (see doc comment): a failure must not fail the whole agent launch.
1504    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    // Unreadable (not just "no marker") — nothing safe to check
1510    // idempotency against or append to. Skip the @import offer this
1511    // launch; the side file above is still fresh and ready whenever the
1512    // file becomes readable again.
1513    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    // Atomic "am I the first to offer this" gate, not a read-then-write
1519    // check: `create_new` reports an existing marker if another concurrent
1520    // call already won this race. Two agents sharing a working directory
1521    // (agent_open.rs's shared-workdir fallback, `~/.agentmux/agents/<slug>`,
1522    // launching concurrently) previously could both read "not yet offered"
1523    // and both append the import line, duplicating it (reagent P2, second
1524    // review round on PR #2747). Only the caller that creates the marker
1525    // proceeds to append; every other caller skips straight past.
1526    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            // Idempotent even having won the race: don't duplicate if
1532            // the import somehow already appears (e.g. a user copied it
1533            // in by hand before AgentMux ever ran here).
1534            if !existing_content.contains(&import_needle) {
1535                let import_block = format!("\n\n{CLAUDE_MD_IMPORT_MARKER_COMMENT}\n{import_needle}\n");
1536                // True append (never a read-modify-write of the whole
1537                // file) — an edit racing this function's earlier read is
1538                // preserved (codex P2 on PR #2747).
1539                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                    // Roll back the marker we just created — winning the
1542                    // race doesn't mean the offer completed; a marker left
1543                    // behind would record "offered" forever with no retry
1544                    // (reagent P1 + codex, third review round on PR #2747).
1545                    let _ = wd.remove_file(CLAUDE_MD_OWNERSHIP_MARKER_PATH);
1546                }
1547            }
1548        }
1549        Ok(false) => {
1550            // Already offered — by us on a prior launch, or a concurrent
1551            // racer that won just now. Nothing to do.
1552        }
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
1561/// Copy a legacy `CLAUDE.md` to [`CLAUDE_MD_PRE_ADOPT_BACKUP`] before it is
1562/// adopted. Whole or not at all (a failed write removes its partial copy);
1563/// a copy left by an earlier attempt counts only if it is this file, whole
1564/// (Codex on #4131).
1565fn 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
1577/// Best-effort: mint-or-reuse `agent_slug`'s jekt/LAN signing keys
1578/// (`SPEC_JEKT_TRUST_LAYER_COMPLETION_2026_08_13.md` §2.2,
1579/// `SPEC_JEKT_LAN_TIER_SIGNING_2026_08_15.md` §2.1) and patch them into a
1580/// `.mcp.json` file's `mcpServers.agentmux.env` block. Returns `None` on
1581/// any parse failure, missing `mcpServers.agentmux.env` object, or if
1582/// neither key could be ensured — callers must keep the original content
1583/// in that case; this must never block writing the agent's config.
1584///
1585/// Shared by `agent.open` (`server/app_api/agent_open.rs`) and the
1586/// `WriteAgentConfig` "click Launch" path (`server/editor_handlers.rs`) —
1587/// per this module's own doc comment, the two `.mcp.json`-materializing
1588/// call sites that must not drift out of sync. Before this function
1589/// existed, only `agent.open` injected these keys — `WriteAgentConfig`
1590/// (the path a normal Launch-button click actually goes through) silently
1591/// never did, so ordinarily-launched agents' jekts rendered
1592/// `TRUST=self-declared` even on builds well past both features shipping.
1593/// See `docs/specs/REPORT_JEKT_SIGNING_KEY_INJECTION_GAP_2026_08_16.md`.
1594pub 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        // SPEC_JEKT_CROSS_CHANNEL_TRUST_2026_09_02.md §D5 (Phase B): the
1614        // cross-channel signature binds the sending channel into the signed
1615        // material, so the MCP process must know its channel by the SAME
1616        // string a receiver reads off this instance's shared registry entry.
1617        // Written explicitly rather than relying on the ambient
1618        // `AGENTMUX_CHANNEL` leaking into the agent's shell: a nested
1619        // portable, or an agent launched with a scrubbed env, would
1620        // otherwise sign as "stable" while registered under its real
1621        // channel, and every cross-channel jekt it sent would fail to verify.
1622        env.insert(
1623            "AGENTMUX_CHANNEL".to_string(),
1624            json!(crate::backend::reactive::registry::local_channel_id()),
1625        );
1626        patched = true;
1627    }
1628    // WAN-tier signing (issue #2586's WAN
1629    // half): a SEPARATE keypair from the LAN one above — see
1630    // storage/agent_wan_keys.rs for why the two tiers' key lifecycles cannot
1631    // be shared. Injected on the same terms and with the same guarantee:
1632    // this agent's env only, never over RPC.
1633    //
1634    // Minted and injected ahead of any verifier existing, deliberately. An
1635    // agent only receives a key at spawn, so an agent that is never respawned
1636    // never gets one — starting the propagation now is what makes WAN
1637    // verification meaningful when it lands, rather than applying to almost
1638    // no live agent on day one. Exactly the same reasoning that made
1639    // un-respawned agents render TRUST=self-declared for months after
1640    // host-tier signing shipped (REPORT_JEKT_SIGNING_KEY_INJECTION_GAP_2026_08_16.md).
1641    //
1642    // W3-S (SPEC_WAN_JEKT_VERIFICATION_2026_09_24.md §2.2): the key and the
1643    // instance both come from the channel-wide `wan.db`, so they survive an
1644    // upgrade. The agent's key from this version's `db_agent_wan_keys` is
1645    // read first (outside any `wan.db` lock) and carried over only the first
1646    // time. With no `wan.db` attached, the agent gets no WAN key at all —
1647    // never one minted somewhere else.
1648    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                // §2.1.2: the WAN signature binds the sending INSTANCE, not
1658                // just the agent. Since W3-S the instance is its
1659                // self-certifying id rather than the live hostname, which
1660                // collides and changes. Written explicitly for the same
1661                // reason `AGENTMUX_CHANNEL` above is: a scrubbed or nested env
1662                // must not sign as a different instance than the one that
1663                // certifies this agent's key.
1664                env.insert("AGENTMUX_HOST_LABEL".to_string(), json!(instance.instance_id));
1665                patched = true;
1666                // D1b: get this key into the cloud directory before the agent
1667                // needs it — until then its WAN jekts go unsigned. Always: only
1668                // the publisher knows the current directory (relay + account),
1669                // and a pass with nothing pending sends nothing.
1670                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// ============================================================
1687// Tests
1688// ============================================================
1689
1690#[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        // reagent P1, PR #2322: a trigger with a path separator or ".."
1723        // must never be allowed to steer .claude/commands/<trigger>.md
1724        // outside the working directory.
1725        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        // reagent P1, PR #2322: build_config_files must not materialize a
1738        // command file for a malicious trigger, not even under a sanitized
1739        // name -- skip it outright.
1740        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    // docs/specs/SPEC_PROVIDER_AWARE_STARTUP_INSTRUCTIONS_2026_08_24.md §7:
1822    // per-provider filename resolution, pinned against §2's researched
1823    // table so it can't silently drift.
1824    #[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        // Confirmed absence (SPEC_PROVIDER_AWARE_STARTUP_INSTRUCTIONS_2026_08_24.md
1859        // §2): Kimi has no native file-based startup-instructions
1860        // discovery — writing one would be inert output nobody reads.
1861        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        // An unrecognized provider_id (typo, not-yet-registered) must not
1876        // silently fall back to "CLAUDE.md" — same no-op path as kimi.
1877        // .claude/settings.json and .mcp.json are still written (they're
1878        // provider-unconditional today, unaffected by this spec — §5's
1879        // ".claude/-namespaced files... out of scope"), so this checks
1880        // specifically for the absence of any known instructions filename,
1881        // not an empty result.
1882        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        // Claude Code lists skills with a file by itself: no index (LC3).
1906        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        // Individual skill command files
1910        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        // Another provider reads only its instructions file: it keeps the index.
1914        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    /// An alias of Claude (`claude-code`) is Claude: no index for skills with
1922    /// files (ReAgent on #4131).
1923    #[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    /// A skill with no file of its own (no usable trigger) is still indexed
1937    /// for Claude: dropping it from the index would hide it entirely.
1938    #[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    /// An agent whose skills all have files, and nothing else, still gets its
1952    /// instructions file: srv puts the Global Memory in it (LC3 live check —
1953    /// without this the launch wrote no CLAUDE.md at all).
1954    #[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    /// Two skills on one trigger write one command file, the later winning:
1970    /// the earlier one has no file and stays in the index (Codex on #4131).
1971    #[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    /// A non-ASCII trigger stays indexed: Unicode-equivalent names can share
1984    /// one file on macOS (Codex on #4131).
1985    #[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        // AgentMux wrote the section only with skills: no rows, not ours (Codex on #4131).
2011        assert!(!is_legacy_agentmux_claude_md("# Available Skills\n\nUse `/<trigger>` to invoke a skill.\n"));
2012        // Its order: skill rows after the import mean someone rearranged it.
2013        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        // Managed from now on; the original copy is never overwritten.
2039        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    /// A `.claude` linked outside the workspace gets no backup written
2045    /// through it, and the file isn't adopted (Codex on #4131).
2046    #[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            // Windows needs privilege for symlinks; the guard is exercised
2056            // wherever one can be made.
2057            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    /// A copy left by an earlier attempt that isn't this file whole (a
2067    /// partial write) blocks adoption; the original stays.
2068    #[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        // The same copy, whole: adopted.
2078        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    /// A `CLAUDE.md` that is a symlink is never adopted: the managed write
2084    /// would follow it (Codex P1 on #4131).
2085    #[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; // Windows needs privilege for symlinks.
2097        }
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    /// A symlinked `CLAUDE.md` whose target already looks managed is not
2103    /// written through either (Codex on #4131).
2104    #[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; // Windows needs privilege for symlinks.
2117        }
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    /// A symlinked non-Claude instructions file is never written through,
2123    /// dangling or pointing at an already-managed file (Codex on #4131).
2124    #[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; // Windows needs privilege for symlinks.
2137        }
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        // Dangling: nothing is created at the link's target either.
2142        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        // Materializes to .claude/skills/<slug>/SKILL.md, not .claude/commands/
2184        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        // YAML frontmatter with the two required Agent Skills fields.
2191        // `name` is the slug (matching its parent directory per the Agent
2192        // Skills spec), NOT the raw display name -- reagent P1 on #2322.
2193        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        // Claude Code lists a SKILL.md itself, so CLAUDE.md doesn't index it (LC3).
2201        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        // Names/descriptions with colons, quotes, or newlines must not
2208        // corrupt the YAML frontmatter -- serde_json's escaping (valid YAML
2209        // double-quoted-scalar syntax per YAML 1.2 §7.3.1) is what protects
2210        // this; regression-test it explicitly rather than trusting the
2211        // dependency silently.
2212        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        // Frontmatter must parse as exactly 3 lines before the closing ---
2226        // (name, description, and nothing else leaking onto a new line).
2227        let end = skill_file.content.find("\n---\n\n").expect("closing frontmatter delimiter");
2228        let frontmatter = &skill_file.content[4..end]; // skip leading "---\n"
2229        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        // reagent P1 (PR #2322): the Agent Skills spec requires `name` be
2236        // lowercase/hyphenated and match its parent directory -- the raw
2237        // display name (e.g. "Deploy Checklist") is spec-invalid.
2238        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        // reagent P1 (PR #2322): the Agent Skills spec requires a non-empty
2249        // description; the UI permits creating a skill with none.
2250        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        // reagent P1 (PR #2322): the Agent Skills spec caps description at
2261        // 1024 characters.
2262        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        // Extract the description value between the quotes on its line.
2268        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; // strip surrounding quotes
2271        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        // reagent P1 (PR #2322): two distinct skill names that derive_slug
2277        // collapses to the same slug must NOT silently overwrite each
2278        // other's SKILL.md file.
2279        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        // Each file's content must correspond to the correct skill (not just
2301        // present -- verify no cross-contamination from the dedup logic).
2302        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        // Codex P1, PR #2322: derive_slug (shared with agent role-slugs) keeps
2313        // underscores, which is spec-invalid for an Agent Skills `name`.
2314        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        // Codex P2, PR #2322: a 64-char base plus "-2" was previously 66 chars.
2321        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        // reagent P2, PR #2322: Rust previously capped by byte length while
2333        // the TS mirror caps by UTF-16 code units (`.slice(0, 1024)`), so
2334        // non-ASCII descriptions truncated to different lengths between the
2335        // two paths. A 3-byte-per-char UTF-8 string (each char = 1 UTF-16
2336        // unit) makes the byte-vs-unit divergence obvious: byte-based
2337        // truncation would cut this off around char 341, not 1024.
2338        let description: String = "\u{4e2d}".repeat(2000); // "中" x2000 (3 bytes each, 1 UTF-16 unit each)
2339        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 = &quoted[1..quoted.len() - 1]; // strip surrounding quotes
2343        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        // PR #813 moved hooks from `.claude/hooks.json` (a Claude Code
2350        // dead-letter path) to `.claude/settings.json` under the
2351        // `"hooks"` key. This test exercises the merge path: user
2352        // PreToolUse entries must be PREPENDED (not silently
2353        // dropped) to the auto-injected bashwrap entry so streaming
2354        // stays on while user-supplied gates fire first.
2355        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        // User's "Read" matcher prepended first, then our Bash matcher.
2371        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        // SPEC_COMPACTION_DETECTION_AND_HANDLING_2026_07_31.md §4.2:
2388        // PreCompact becomes auto-injected (two entries, matcher
2389        // "manual"/"auto") the same way PreToolUse already is. This is
2390        // the exact regression class PR #813's PreToolUse fix guards
2391        // against — a user-supplied PreCompact entry (from the legacy
2392        // content_map["hooks"] path) must survive the merge, not get
2393        // silently and permanently dropped by the generic
2394        // `hooks_obj.entry(k).or_insert(v)` path once PreCompact stops
2395        // being "just another key".
2396        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        // Same regression as above, but exercising the OTHER merge
2437        // branch: a user-supplied PreCompact entry living in
2438        // settings.json's own `"hooks"` key (content_map["settings"])
2439        // rather than the legacy content_map["hooks"] path. Both
2440        // branches in `build_settings_with_hooks` needed the fix.
2441        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        // Settings-level user entries PREPEND before AgentMux's own, per
2473        // this merge path's ordering rule (mirrors PreToolUse).
2474        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    /// SPEC_GLOBAL_MEMORY_DELIVERY_2026_09_27.md §7 P2: one SessionStart
2482    /// entry, a command per memory part, no matcher; a user's own
2483    /// SessionStart hooks survive both merge paths, before ours.
2484    #[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    /// Claude Code adds a Co-Authored-By trailer and a "Generated with" line
2517    /// unless `attribution` is empty; the repos' checks reject both.
2518    #[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        // REPORT_REPO_HEALTH_AUDIT_2026_07_20.md §1.3: AGENT_SLUG/WORKING_DIR
2561        // were missing from build_config_files's template vars (present in
2562        // the TS mirror, `buildConfigFiles` in agent-config-builder.ts), so
2563        // any soul/agentmd content
2564        // using {{AGENT_SLUG}} or {{WORKING_DIR}} was left unexpanded.
2565        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    // docs/specs/REPORT_JEKT_SIGNING_KEY_INJECTION_GAP_2026_08_16.md: this
2584    // function exists specifically so `agent.open` and `WriteAgentConfig`
2585    // (the actual "click Launch" path) can't drift out of sync on jekt/LAN
2586    // key injection the way they did before this fix.
2587    #[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        // WAN-tier signing: a separate keypair
2604        // from the LAN one, injected on the same terms.
2605        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        // §2.1.2: the WAN signature binds the sending instance, so the host
2612        // label must travel with the key. A missing one would make the sender
2613        // sign under a different instance identity than srv publishes.
2614        // W3-S §2.2: that instance identity is now the id of the instance
2615        // that certifies the key, and the key is the one held in wan.db.
2616        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        // SPEC_JEKT_CROSS_CHANNEL_TRUST_2026_09_02.md Phase B: the channel the
2627        // sender signs under must be the one the shared registry publishes.
2628        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    /// W3-S §2.2: without a `wan.db` the agent gets no WAN key and no
2651    /// instance label — never a key minted in `objects.db` instead. Host and
2652    /// LAN keys are unaffected.
2653    #[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    /// W3-S §2.2: an agent's existing `objects.db` WAN key is carried into
2670    /// `wan.db` on first use, so an agent upgraded onto D1 keeps its key.
2671    #[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        // No mcpServers.agentmux.env at all -- nothing to patch into.
2690        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    // ============================================================
2701    // write_startup_instructions_respecting_existing
2702    // (SPEC_PROVIDER_AWARE_STARTUP_INSTRUCTIONS_2026_08_24.md — codex P1, PR #2788)
2703    // ============================================================
2704
2705    #[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        // The exact regression codex P1 (PR #2788) flagged: a real project's
2730        // own AGENTS.md must survive an agent launch untouched, byte for
2731        // byte — no AgentMux marker, no partial merge, nothing.
2732        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        // An empty pre-existing file (no marker) is still foreign — the
2747        // decision hinges on the marker prefix, not on non-emptiness.
2748        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        // pi's target is .pi/APPEND_SYSTEM.md — a nested path with no
2774        // existing parent directory on a fresh working dir.
2775        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    // ============================================================
2783    // write_claude_md_respecting_ownership
2784    // (SPEC_CLAUDE_MD_OWNERSHIP_PROTECTION_2026_08_22.md)
2785    // ============================================================
2786
2787    #[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        // No foreign-file side effects on the common (fresh dir) path.
2796        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        // reagent P0 + codex P1 on PR #2747: read_to_string(...).ok()
2826        // collapsed ANY read error (not just NotFound) into "no file
2827        // yet," so a non-UTF-8 (or otherwise unreadable) foreign file
2828        // got silently clobbered — the exact data-loss bug this whole
2829        // function exists to prevent.
2830        let dir = tempfile::tempdir().unwrap();
2831        let claude_md_path = dir.path().join("CLAUDE.md");
2832        // Invalid UTF-8: a lone continuation byte. read_to_string must
2833        // fail on this (not silently lossy-convert it).
2834        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        // Best-effort side file is still written even though the
2842        // @import offer itself is skipped (nothing safe to append to).
2843        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        // The side file, unlike CLAUDE.md, keeps regenerating freely.
2875        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        // reagent P2 (second review round) on PR #2747: two agents
2882        // sharing a working directory (agent_open.rs's shared-workdir
2883        // fallback) launching concurrently previously could both read
2884        // "not yet offered" and both append the import line — the only
2885        // existing serialization (agent_open_lock) is keyed by
2886        // agent_id, not by working directory. Real threads, not a
2887        // sequential simulation, to actually exercise the race.
2888        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(); // maximize actual overlap
2899                    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        // reagent P1 + codex, third review round on PR #2747: winning the
2919        // create_new race must not permanently record "offered" if the
2920        // append itself then fails -- otherwise every later launch's
2921        // create_new hits AlreadyExists and the import is never offered
2922        // again, with no retry path.
2923        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        // Read-only so the append's .open(...) fails, even though the
2928        // read earlier in the function (which only needs read access)
2929        // succeeds fine.
2930        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        // Restore write access and confirm a later launch actually succeeds.
2940        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        // The whole point of the ownership marker file: once offered, a
2954        // user's deliberate removal of the import line must stick.
2955        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        // User opts out: deletes the import line by hand.
2960        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        // The side file still refreshes even though nothing links to it
2967        // from CLAUDE.md anymore — harmless, and keeps it ready if the
2968        // user re-adds the import line themselves later.
2969        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    /// Pins the persisted heading. `frontend/app/view/agent/agent-config-builder.ts`
2979    /// pushes the identical literal; if this test fails, change both sides in
2980    /// the same release or the global-bundle injector falls back to appending
2981    /// a second heading after the per-agent content.
2982    #[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        // The bug this exists to prevent: a check against CLAUDE.md's marker
2995        // alone reports every generated AGENTS.md / GEMINI.md / QWEN.md /
2996        // .pi/APPEND_SYSTEM.md as foreign (Codex, PR #3144).
2997        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        // The marker has to be at the START — a file that merely mentions it
3010        // further down was not written by us.
3011        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    //! #3680: `.mcp.json` keeps a user's own servers and is owner-only.
3020    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    /// ReAgent on #4141: a symlinked `.mcp.json`, even one pointing inside
3039    /// the workdir, is left alone and the launch goes on.
3040    #[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    /// Codex on #4141: with `.claude` linking outside, the ownership manifest
3058    /// can't be written, so `.mcp.json` isn't either; otherwise the second
3059    /// launch would take the user's own server for AgentMux's and drop it.
3060    #[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        // Next launch: `github` was unbound in Connectors.
3102        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        // A file an older AgentMux wrote: has our `agentmux` entry, no manifest.
3113        // All of it is ours, so a stale server from an old definition goes.
3114        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        // ReAgent P1 on #3803: a per-process temp name let two same-srv writes
3166        // interleave into one temp file before either rename. Many writers of
3167        // differently sized content maximise the chance of interleaving.
3168        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                    // Each "agent" brings its own extra server, so a manifest from
3175                    // one launch paired with the file from another is detectable.
3176                    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        // ReAgent P1 (round 2) on #3803: the manifest must describe the SAME
3196        // launch as the file. With no user servers here, that means exactly
3197        // the file's server names — and exactly one agent's set.
3198        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}