Skip to main content

agentmux_srv/backend/
project_instructions.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! What an agent will actually read as project instructions.
5//!
6//! Phase 3 of `SPEC_INSTRUCTION_AND_MEMORY_PORTABILITY_2026_09_09.md`. Until
7//! now AgentMux could say what it *wrote* to an agent's working directory and
8//! nothing about what the agent would *read* there. A repository's own
9//! `CLAUDE.md` or `AGENTS.md` can be the majority of what an agent is told,
10//! and §2.4 established that the only thing ever recorded about one was a
11//! single boolean — no path, no hash, no content.
12//!
13//! **Read-only, permanently.** This module opens files and hashes them. It
14//! never writes one, and nothing downstream of it may either: the ownership
15//! protection in `SPEC_CLAUDE_MD_OWNERSHIP_PROTECTION_2026_08_22.md` exists
16//! because a foreign instruction file is not ours, and a portability feature
17//! must not become a back door into overwriting one (spec §5.2).
18//!
19//! The file set comes from the provider registry's `native_instruction_sources`
20//! — every path a provider picks up on its own — rather than from
21//! `startup_instructions_filename`, which names only the single file AgentMux
22//! writes. For Copilot those differ by three files.
23
24use std::path::{Path, PathBuf};
25
26use serde::Serialize;
27
28use super::agent_config::{is_agentmux_managed_instructions, AGENTMUX_MEMORY_FILENAME};
29use super::memory_record::sha256_hex;
30use super::providers;
31
32/// Per-file read ceiling.
33///
34/// Matches native memory's own cap (`MAX_MEMORY_FILE_BYTES` in
35/// `native_memory_handlers.rs`) rather than the far larger ABF one: this
36/// content is surfaced in a UI and hashed on every observation, and an
37/// instruction file that large is pathological regardless. Oversized files are still *reported*, with `content` empty and
38/// `truncated` set — the operator needs to know the file is there even when
39/// showing it is impractical, which is the whole point of the feature.
40pub const MAX_INSTRUCTION_FILE_BYTES: u64 = 10 * 1024 * 1024;
41
42/// Who wrote an instruction file.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
44#[serde(rename_all = "lowercase")]
45pub enum InstructionOwner {
46    /// Carries one of AgentMux's managed markers — regenerated on launch.
47    Agentmux,
48    /// The repository's own. Read and tracked; never written.
49    Foreign,
50}
51
52/// One resolved project-instruction file.
53#[derive(Debug, Clone, Serialize)]
54pub struct ProjectInstructionFile {
55    /// Working-directory-relative, exactly as the registry declares it.
56    pub path: String,
57    /// False when the provider would look here and find nothing. Reported
58    /// rather than filtered out: "the file a provider reads is absent" is
59    /// itself the answer to what the agent will read.
60    pub exists: bool,
61    pub size_bytes: u64,
62    /// SHA-256 of the file's bytes, or empty when absent/unreadable. The
63    /// tracking half of Phase 3 compares this across observations to detect a
64    /// foreign file changing under a running agent.
65    pub content_hash: String,
66    pub owner: InstructionOwner,
67    /// Empty when absent, unreadable, or over [`MAX_INSTRUCTION_FILE_BYTES`].
68    pub content: String,
69    /// True when the file exists but was too large to read.
70    pub truncated: bool,
71    /// Set when the file exists but could not be read — a permissions problem,
72    /// or bytes that are not UTF-8. Distinguished from absence on purpose:
73    /// `write_claude_md_respecting_ownership` treats unreadable as foreign
74    /// rather than as absent for the same reason (its doc comment in
75    /// `agent_config.rs`), and silently reporting "no instructions" for a file
76    /// that exists would be the same class of lie this feature exists to
77    /// remove.
78    pub error: Option<String>,
79}
80
81/// Every path this provider reads, plus AgentMux's own side file.
82///
83/// `.claude/AGENTMUX_MEMORY.md` is included for providers whose write target
84/// is `CLAUDE.md`, because that is the file AgentMux falls back to when the
85/// repository owns `CLAUDE.md`
86/// (`SPEC_CLAUDE_MD_OWNERSHIP_PROTECTION_2026_08_22.md`) — it is genuinely
87/// read, via an `@`-import line, and an instruction set that omitted it would
88/// be missing AgentMux's own contribution. It is not in the registry's
89/// `native_instruction_sources` because the provider does not discover it
90/// natively; the import line is ours.
91fn paths_for_provider(provider_id: &str, working_dir: &Path) -> Vec<String> {
92    let Some(provider) = providers::get_provider(provider_id) else {
93        return Vec::new();
94    };
95    let mut paths: Vec<String> = provider
96        .native_instruction_sources
97        .iter()
98        .map(|s| s.to_string())
99        .collect();
100
101    // The side file is only read when a `CLAUDE.md` currently imports it.
102    //
103    // A `CLAUDE.md` write target is not enough on its own: when AgentMux owns
104    // that file it writes the content directly and adds no import, and an
105    // import appended on some earlier launch can be removed by hand while the
106    // side file stays on disk. Including it unconditionally reports stale
107    // content as instructions the agent is consuming, which is the same class
108    // of confident-but-wrong answer this module exists to prevent (Codex, PR
109    // #3156). Testing the actual `@` line is the only honest condition.
110    if provider.startup_instructions_filename == Some("CLAUDE.md") {
111        let import_needle = format!("@{AGENTMUX_MEMORY_FILENAME}");
112        let imported = std::fs::read_to_string(working_dir.join("CLAUDE.md"))
113            .map(|c| c.contains(&import_needle))
114            .unwrap_or(false);
115        let side_file = AGENTMUX_MEMORY_FILENAME.to_string();
116        if imported && !paths.contains(&side_file) {
117            paths.push(side_file);
118        }
119    }
120    paths
121}
122
123/// The directory this agent will actually run in.
124///
125/// Must match `agent.open` exactly, or the answer describes a directory the
126/// agent never uses (Codex, PR #3156): a blank `working_directory` means the
127/// per-agent default, and a `~` path is expanded at launch. Reproducing both
128/// here rather than reading the stored value raw is the difference between
129/// reporting an agent's instructions and reporting nothing at all, since the
130/// default-workdir case is the common one
131/// (`SPEC_FIX_PERSONAL_MEMORY_EMPTY_WORKDIR_2026_09_01.md`).
132pub fn effective_working_dir(working_directory: &str, agent_name: &str) -> String {
133    let raw = if working_directory.trim().is_empty() {
134        crate::backend::storage::agents::default_agent_working_dir(agent_name)
135    } else {
136        working_directory.to_string()
137    };
138    if raw.starts_with("~/") || raw == "~" {
139        if let Ok(home) = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")) {
140            return format!("{}/{}", home, raw.trim_start_matches("~/").trim_start_matches('~'));
141        }
142    }
143    raw
144}
145
146/// Who owns this file — by marker, or by construction.
147///
148/// Most files are classified by their managed marker, which is the honest test
149/// for a path AgentMux may or may not have written.
150///
151/// `.claude/AGENTMUX_MEMORY.md` is the exception, and it has to be: AgentMux
152/// writes that file's content **raw**, with no marker
153/// (`write_claude_md_respecting_ownership` in `agent_config.rs`), because it is
154/// not a file anyone else was ever going to own — that function's doc comment
155/// calls it "100% AgentMux's own content". Testing it by marker returns
156/// `foreign` every time, which is backwards for the one field that decides
157/// whether a file is ours to touch (#3156).
158///
159/// Classifying it by path is not a guess: AgentMux creates that exact path,
160/// regenerates it on every launch, and the ownership protection exists
161/// precisely because the *other* file at that location is not ours.
162fn classify_owner(rel_path: &str, content: &str) -> InstructionOwner {
163    if rel_path == AGENTMUX_MEMORY_FILENAME {
164        return InstructionOwner::Agentmux;
165    }
166    if is_agentmux_managed_instructions(content) {
167        InstructionOwner::Agentmux
168    } else {
169        InstructionOwner::Foreign
170    }
171}
172
173/// Read one instruction file, whatever state it is in.
174fn read_one(working_dir: &Path, rel_path: &str) -> ProjectInstructionFile {
175    let full = working_dir.join(rel_path);
176    let mut out = ProjectInstructionFile {
177        path: rel_path.to_string(),
178        exists: false,
179        size_bytes: 0,
180        content_hash: String::new(),
181        owner: InstructionOwner::Foreign,
182        content: String::new(),
183        truncated: false,
184        error: None,
185    };
186
187    let meta = match std::fs::metadata(&full) {
188        Ok(m) => m,
189        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return out,
190        Err(e) => {
191            out.exists = true;
192            out.error = Some(format!("stat failed: {e}"));
193            return out;
194        }
195    };
196    if !meta.is_file() {
197        // A directory where the provider expects a file: it exists and it is
198        // not readable as instructions. Saying so beats reporting absence.
199        out.exists = true;
200        out.error = Some("not a regular file".to_string());
201        return out;
202    }
203
204    out.exists = true;
205    out.size_bytes = meta.len();
206    if meta.len() > MAX_INSTRUCTION_FILE_BYTES {
207        out.truncated = true;
208        out.error = Some(format!(
209            "file is {} bytes, over the {MAX_INSTRUCTION_FILE_BYTES}-byte read limit",
210            meta.len()
211        ));
212        return out;
213    }
214
215    match std::fs::read(&full) {
216        Ok(bytes) => {
217            // Hash the bytes, not the string: the hash has to be stable for a
218            // file that is not valid UTF-8, which is exactly the case where
219            // `content` cannot be populated.
220            out.content_hash = sha256_hex(&bytes);
221            match String::from_utf8(bytes) {
222                Ok(text) => {
223                    out.owner = classify_owner(rel_path, &text);
224                    out.content = text;
225                }
226                Err(_) => {
227                    out.error = Some("not valid UTF-8".to_string());
228                }
229            }
230        }
231        Err(e) => out.error = Some(format!("read failed: {e}")),
232    }
233    out
234}
235
236/// Ceiling on files discovered by directory scan, per scan entry.
237///
238/// A scanned directory is repository-controlled and unbounded — nothing stops
239/// a repo holding thousands of `*.instructions.md` files. The declared paths
240/// above are a fixed handful, so only this side needs a limit. On overflow the
241/// scan reports what it found plus an explicit marker entry, rather than
242/// silently returning a prefix: a truncated list that looks complete is the
243/// failure mode this module exists to remove.
244pub const MAX_SCANNED_INSTRUCTION_FILES: usize = 200;
245
246/// Ceiling on the total content this resolver will return in one call.
247///
248/// The per-file cap and the file-count cap do not bound the payload between
249/// them: 200 files just under the per-file limit is roughly 2 GiB, assembled
250/// in memory before serialization (Codex, PR #3156). Past this budget a file
251/// is still listed with its hash and metadata; only its content is dropped.
252pub const MAX_TOTAL_INSTRUCTION_BYTES: u64 = 8 * 1024 * 1024;
253
254/// Collect qualifying files under one declared scan directory.
255///
256/// Sorted by path so the answer is stable between calls — directory iteration
257/// order is not. Symlinked directories are not followed: a link out of the
258/// working directory would silently widen what this reports beyond the
259/// project, and `read_one` still reads a symlinked *file* normally.
260/// Ceiling on directories entered during one scan.
261///
262/// The file cap alone does not bound a deep or wide tree that contains few
263/// matches — the walk still has to enter every directory to find that out.
264const MAX_SCANNED_DIRECTORIES: usize = 500;
265
266/// Collect qualifying files under one declared scan directory.
267///
268/// **Bounded during traversal, not after it.** Collecting everything and then
269/// truncating leaves the advertised ceiling doing nothing about the cost it
270/// exists to bound: a repository-controlled directory with a very large number
271/// of matches still gets fully walked, allocated and sorted first (Codex, PR
272/// #3156). The walk now stops as soon as it has one file more than the cap,
273/// which is exactly enough to know the limit was hit.
274///
275/// Entries are sorted at each level before descending, so stopping early still
276/// yields the same set every time. Without that, an early stop would return
277/// whatever the filesystem happened to hand back first.
278///
279/// Symlinks are skipped outright: `file_type()` does not follow the link,
280/// unlike `metadata()`, which resolves the target and reports
281/// `is_symlink() == false` for every symlink — so an earlier guard written
282/// against `metadata()` never fired at all. A scanned directory is
283/// repository-controlled, and nothing about "follow this link" can be verified
284/// from here; the declared literal paths are still read through links, which
285/// is an ordinary layout.
286///
287/// Returns true when the cap was reached.
288fn scan_dir(
289    working_dir: &Path,
290    scan: &providers::InstructionDirScan,
291    out: &mut Vec<String>,
292) -> ScanOutcome {
293    fn walk(
294        root: &Path,
295        dir: &Path,
296        suffix: &str,
297        recursive: bool,
298        found: &mut Vec<String>,
299        dirs_visited: &mut usize,
300    ) {
301        if found.len() > MAX_SCANNED_INSTRUCTION_FILES || *dirs_visited >= MAX_SCANNED_DIRECTORIES {
302            return;
303        }
304        *dirs_visited += 1;
305
306        let Ok(entries) = std::fs::read_dir(dir) else {
307            // An unreadable or absent scan directory is the ordinary case —
308            // most repositories have none. Nothing to report.
309            return;
310        };
311        // Deterministic order, so an early stop is reproducible.
312        let mut entries: Vec<std::fs::DirEntry> = entries.flatten().collect();
313        entries.sort_by_key(|e| e.file_name());
314
315        for entry in entries {
316            if found.len() > MAX_SCANNED_INSTRUCTION_FILES {
317                return;
318            }
319            let path = entry.path();
320            let Ok(file_type) = entry.file_type() else { continue };
321            if file_type.is_symlink() {
322                continue;
323            }
324            if file_type.is_dir() {
325                if recursive {
326                    walk(root, &path, suffix, recursive, found, dirs_visited);
327                }
328                continue;
329            }
330            if !path.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.ends_with(suffix)) {
331                continue;
332            }
333            if let Ok(rel) = path.strip_prefix(root) {
334                // Forward slashes so the reported path matches the
335                // registry's own spelling on every platform.
336                found.push(rel.to_string_lossy().replace('\\', "/"));
337            }
338        }
339    }
340
341    let scan_root = working_dir.join(scan.dir);
342
343    // **Check the scan ROOT, not just what is inside it.** Skipping symlinked
344    // child entries does nothing if `.github/instructions` — or any ancestor
345    // of it — is itself a link: `read_dir` follows it transparently, the
346    // resulting paths still `strip_prefix` cleanly, and the outside file's
347    // content goes out over an authenticated RPC (ReAgent P0, PR #3156).
348    //
349    // Canonicalizing both sides closes the whole class at once — a symlinked
350    // final component, a symlinked ancestor, and `..` traversal — rather than
351    // testing the one shape that was reported.
352    let Ok(root_canon) = working_dir.canonicalize() else {
353        return ScanOutcome { hit_cap: false, escaped: false };
354    };
355    let Ok(scan_canon) = scan_root.canonicalize() else {
356        // Almost always "no such directory", which is the ordinary case.
357        return ScanOutcome { hit_cap: false, escaped: false };
358    };
359    if !scan_canon.starts_with(&root_canon) {
360        return ScanOutcome { hit_cap: false, escaped: true };
361    }
362
363    let mut found = Vec::new();
364    let mut dirs_visited = 0usize;
365    walk(
366        working_dir,
367        &scan_root,
368        scan.suffix,
369        scan.recursive,
370        &mut found,
371        &mut dirs_visited,
372    );
373    let hit_cap = found.len() > MAX_SCANNED_INSTRUCTION_FILES;
374    found.truncate(MAX_SCANNED_INSTRUCTION_FILES);
375    found.sort();
376    out.extend(found);
377    ScanOutcome { hit_cap, escaped: false }
378}
379
380/// What a scan ran into, beyond the files it found.
381struct ScanOutcome {
382    /// The file ceiling was reached and the walk stopped early.
383    hit_cap: bool,
384    /// The scan directory resolves outside the working directory, so it was
385    /// not read at all. Reported rather than silently skipped: a repository
386    /// that has done this is exactly the case an operator should see.
387    escaped: bool,
388}
389
390/// Everything `provider_id` will read as instructions from `working_directory`.
391///
392/// Declared paths first, in registry order, then anything found by directory
393/// scan — Copilot's `.github/instructions/**/*.instructions.md` being the case
394/// that needs the second half (Codex, PR #3154). Ordering is stable between
395/// calls.
396///
397/// Returns an empty list for an unknown provider or a blank working directory
398/// — there is no directory to look in, which is not an error.
399pub fn resolve_project_instructions(
400    provider_id: &str,
401    working_directory: &str,
402) -> Vec<ProjectInstructionFile> {
403    if working_directory.trim().is_empty() {
404        return Vec::new();
405    }
406    let root = PathBuf::from(working_directory);
407    let mut files: Vec<ProjectInstructionFile> = paths_for_provider(provider_id, &root)
408        .into_iter()
409        .map(|rel| read_one(&root, &rel))
410        .collect();
411
412    let Some(provider) = providers::get_provider(provider_id) else {
413        return files;
414    };
415    for scan in provider.native_instruction_dirs {
416        let mut scanned: Vec<String> = Vec::new();
417        // The walk stops at the cap rather than collecting everything first,
418        // so it reports *that* the limit was hit, not how far past it the
419        // repository goes — counting the rest would mean doing the traversal
420        // the cap exists to avoid.
421        let outcome = scan_dir(&root, scan, &mut scanned);
422
423        for rel in scanned {
424            // A declared path could also match a scan; report it once.
425            if files.iter().any(|f| f.path == rel) {
426                continue;
427            }
428            let mut file = read_one(&root, &rel);
429            // Per-file and per-count ceilings do not bound the whole payload:
430            // 200 files just under the per-file cap is roughly 2 GiB of
431            // response, built in memory before serialization (Codex, PR
432            // #3156). Past the aggregate budget a file is still LISTED, with
433            // its hash and metadata — only its content is dropped. Reporting
434            // that the file is there costs nothing; shipping its bytes is
435            // what does.
436            let used: u64 = files.iter().map(|f| f.content.len() as u64).sum();
437            if used.saturating_add(file.content.len() as u64) > MAX_TOTAL_INSTRUCTION_BYTES {
438                file.content = String::new();
439                file.truncated = true;
440                file.error = Some(format!(
441                    "content omitted — the {MAX_TOTAL_INSTRUCTION_BYTES}-byte total budget for this response is exhausted"
442                ));
443            }
444            files.push(file);
445        }
446        if outcome.escaped {
447            files.push(ProjectInstructionFile {
448                path: scan.dir.to_string(),
449                exists: true,
450                size_bytes: 0,
451                content_hash: String::new(),
452                owner: InstructionOwner::Foreign,
453                content: String::new(),
454                truncated: false,
455                error: Some(format!(
456                    "{} resolves outside the working directory — not read",
457                    scan.dir
458                )),
459            });
460        }
461        if outcome.hit_cap {
462            files.push(ProjectInstructionFile {
463                path: format!("{}/…", scan.dir),
464                exists: true,
465                size_bytes: 0,
466                content_hash: String::new(),
467                owner: InstructionOwner::Foreign,
468                content: String::new(),
469                truncated: true,
470                error: Some(format!(
471                    "more than {MAX_SCANNED_INSTRUCTION_FILES} files match {}/**/*{} — only the first {MAX_SCANNED_INSTRUCTION_FILES} are listed",
472                    scan.dir, scan.suffix
473                )),
474            });
475        }
476    }
477    files
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483    use crate::backend::agent_config::CLAUDE_MD_MANAGED_MARKER;
484
485    fn write(dir: &Path, rel: &str, content: &str) {
486        let full = dir.join(rel);
487        if let Some(parent) = full.parent() {
488            std::fs::create_dir_all(parent).unwrap();
489        }
490        std::fs::write(full, content).unwrap();
491    }
492
493    #[test]
494    fn copilot_resolves_all_four_of_its_sources_not_just_the_write_target() {
495        // The case that motivated the whole field: resolving only
496        // `startup_instructions_filename` would report one file out of four.
497        let dir = tempfile::tempdir().unwrap();
498        write(dir.path(), "AGENTS.md", "agents");
499        write(dir.path(), ".github/copilot-instructions.md", "copilot");
500        write(dir.path(), "CLAUDE.md", "claude");
501        // GEMINI.md deliberately absent.
502
503        let files = resolve_project_instructions("copilot", dir.path().to_str().unwrap());
504        let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
505        assert!(paths.contains(&"AGENTS.md"), "{paths:?}");
506        assert!(paths.contains(&".github/copilot-instructions.md"), "{paths:?}");
507        assert!(paths.contains(&"CLAUDE.md"), "{paths:?}");
508        assert!(paths.contains(&"GEMINI.md"), "{paths:?}");
509
510        let gemini = files.iter().find(|f| f.path == "GEMINI.md").unwrap();
511        assert!(!gemini.exists, "an absent source must be reported, not dropped");
512        assert!(gemini.content_hash.is_empty());
513    }
514
515    #[test]
516    fn a_repo_owned_file_reads_as_foreign_and_an_agentmux_one_does_not() {
517        let dir = tempfile::tempdir().unwrap();
518        write(dir.path(), "CLAUDE.md", "# My project\n\nHouse rules.\n");
519        let files = resolve_project_instructions("claude", dir.path().to_str().unwrap());
520        let claude_md = files.iter().find(|f| f.path == "CLAUDE.md").unwrap();
521        assert_eq!(claude_md.owner, InstructionOwner::Foreign);
522        assert_eq!(claude_md.content, "# My project\n\nHouse rules.\n");
523        assert!(!claude_md.content_hash.is_empty());
524
525        write(
526            dir.path(),
527            "CLAUDE.md",
528            &format!("{CLAUDE_MD_MANAGED_MARKER}\n\ngenerated\n"),
529        );
530        let files = resolve_project_instructions("claude", dir.path().to_str().unwrap());
531        let claude_md = files.iter().find(|f| f.path == "CLAUDE.md").unwrap();
532        assert_eq!(claude_md.owner, InstructionOwner::Agentmux);
533    }
534
535    #[test]
536    fn claude_includes_the_agentmux_side_file_the_import_line_points_at() {
537        let dir = tempfile::tempdir().unwrap();
538        write(dir.path(), AGENTMUX_MEMORY_FILENAME, "side file");
539        // The side file is read only when CLAUDE.md actually imports it —
540        // see `the_side_file_is_only_reported_when_claude_md_actually_imports_it`.
541        write(
542            dir.path(),
543            "CLAUDE.md",
544            &format!("# Repo rules
545
546@{AGENTMUX_MEMORY_FILENAME}
547"),
548        );
549        let files = resolve_project_instructions("claude", dir.path().to_str().unwrap());
550        let side = files
551            .iter()
552            .find(|f| f.path == AGENTMUX_MEMORY_FILENAME)
553            .expect("the @-imported side file is genuinely read");
554        assert!(side.exists);
555        assert_eq!(side.content, "side file");
556        // AgentMux writes this file's content raw, with no marker
557        // (`write_claude_md_respecting_ownership` in `agent_config.rs`), so a
558        // marker test would call the one file that is unambiguously ours
559        // `foreign` (#3156).
560        assert_eq!(
561            side.owner,
562            InstructionOwner::Agentmux,
563            "the side file is AgentMux's by construction, marker or not"
564        );
565    }
566
567    #[test]
568    fn only_the_side_file_gets_ownership_by_path() {
569        // The path rule must not leak into the marker rule: an unmarked
570        // CLAUDE.md is the repository's, which is the entire premise of the
571        // ownership protection.
572        let dir = tempfile::tempdir().unwrap();
573        write(dir.path(), "CLAUDE.md", "no marker here");
574        let files = resolve_project_instructions("claude", dir.path().to_str().unwrap());
575        assert_eq!(
576            files.iter().find(|f| f.path == "CLAUDE.md").unwrap().owner,
577            InstructionOwner::Foreign
578        );
579    }
580
581    #[test]
582    fn a_provider_without_a_native_convention_resolves_to_nothing() {
583        let dir = tempfile::tempdir().unwrap();
584        write(dir.path(), "CLAUDE.md", "not read by kimi");
585        // kimi has no confirmed file convention at all.
586        assert!(resolve_project_instructions("kimi", dir.path().to_str().unwrap()).is_empty());
587    }
588
589    #[test]
590    fn a_blank_working_directory_or_unknown_provider_is_empty_not_an_error() {
591        assert!(resolve_project_instructions("claude", "").is_empty());
592        assert!(resolve_project_instructions("claude", "   ").is_empty());
593        let dir = tempfile::tempdir().unwrap();
594        assert!(resolve_project_instructions("nope", dir.path().to_str().unwrap()).is_empty());
595    }
596
597    #[test]
598    fn the_hash_changes_when_the_file_does() {
599        let dir = tempfile::tempdir().unwrap();
600        write(dir.path(), "CLAUDE.md", "before");
601        let before = resolve_project_instructions("claude", dir.path().to_str().unwrap());
602        let h1 = before.iter().find(|f| f.path == "CLAUDE.md").unwrap().content_hash.clone();
603
604        write(dir.path(), "CLAUDE.md", "after");
605        let after = resolve_project_instructions("claude", dir.path().to_str().unwrap());
606        let h2 = &after.iter().find(|f| f.path == "CLAUDE.md").unwrap().content_hash;
607
608        assert_ne!(&h1, h2, "a changed file must produce a different hash");
609    }
610
611    #[test]
612    fn copilots_path_scoped_instructions_are_found_by_scan() {
613        // Codex on #3154: these reach the agent, and resolving only the
614        // declared literals would miss every one of them.
615        let dir = tempfile::tempdir().unwrap();
616        write(dir.path(), ".github/instructions/rust.instructions.md", "rust rules");
617        write(dir.path(), ".github/instructions/deep/ts.instructions.md", "ts rules");
618        // Not an instructions file — must not be picked up.
619        write(dir.path(), ".github/instructions/README.md", "readme");
620
621        let files = resolve_project_instructions("copilot", dir.path().to_str().unwrap());
622        let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
623        assert!(
624            paths.contains(&".github/instructions/rust.instructions.md"),
625            "{paths:?}"
626        );
627        assert!(
628            paths.contains(&".github/instructions/deep/ts.instructions.md"),
629            "recursive, per the `**`: {paths:?}"
630        );
631        assert!(
632            !paths.contains(&".github/instructions/README.md"),
633            "only the declared suffix qualifies: {paths:?}"
634        );
635
636        let rust = files
637            .iter()
638            .find(|f| f.path.ends_with("rust.instructions.md"))
639            .unwrap();
640        assert_eq!(rust.content, "rust rules");
641        assert!(!rust.content_hash.is_empty());
642    }
643
644    #[test]
645    fn a_provider_with_no_scan_directory_ignores_one_that_exists() {
646        let dir = tempfile::tempdir().unwrap();
647        write(dir.path(), ".github/instructions/x.instructions.md", "not for claude");
648        let files = resolve_project_instructions("claude", dir.path().to_str().unwrap());
649        assert!(
650            !files.iter().any(|f| f.path.contains(".github/instructions")),
651            "claude declares no scan directory"
652        );
653    }
654
655    #[test]
656    fn an_oversized_scan_reports_the_overflow_instead_of_a_silent_prefix() {
657        let dir = tempfile::tempdir().unwrap();
658        for i in 0..(MAX_SCANNED_INSTRUCTION_FILES + 5) {
659            write(
660                dir.path(),
661                &format!(".github/instructions/f{i}.instructions.md"),
662                "x",
663            );
664        }
665        let files = resolve_project_instructions("copilot", dir.path().to_str().unwrap());
666        let scanned = files
667            .iter()
668            .filter(|f| f.path.starts_with(".github/instructions/") && !f.path.ends_with('…'))
669            .count();
670        assert_eq!(scanned, MAX_SCANNED_INSTRUCTION_FILES);
671        let marker = files
672            .iter()
673            .find(|f| f.path.ends_with('…'))
674            .expect("overflow must be reported, not silently truncated");
675        assert!(marker.truncated);
676        assert!(marker.error.as_deref().unwrap_or("").contains("only the first"));
677    }
678
679    #[test]
680    fn scan_results_are_ordered_the_same_way_every_time() {
681        let dir = tempfile::tempdir().unwrap();
682        for name in ["c", "a", "b"] {
683            write(
684                dir.path(),
685                &format!(".github/instructions/{name}.instructions.md"),
686                "x",
687            );
688        }
689        let first = resolve_project_instructions("copilot", dir.path().to_str().unwrap());
690        let second = resolve_project_instructions("copilot", dir.path().to_str().unwrap());
691        let p1: Vec<&str> = first.iter().map(|f| f.path.as_str()).collect();
692        let p2: Vec<&str> = second.iter().map(|f| f.path.as_str()).collect();
693        assert_eq!(p1, p2, "directory iteration order must not leak through");
694    }
695
696    #[test]
697    fn a_directory_in_the_files_place_is_reported_not_silently_absent() {
698        let dir = tempfile::tempdir().unwrap();
699        std::fs::create_dir_all(dir.path().join("CLAUDE.md")).unwrap();
700        let files = resolve_project_instructions("claude", dir.path().to_str().unwrap());
701        let claude_md = files.iter().find(|f| f.path == "CLAUDE.md").unwrap();
702        assert!(claude_md.exists);
703        assert!(claude_md.error.is_some(), "must not read as absent");
704        assert!(claude_md.content.is_empty());
705    }
706}
707
708#[cfg(test)]
709mod review_fix_tests {
710    use super::*;
711    use crate::backend::agent_config::CLAUDE_MD_MANAGED_MARKER;
712
713    fn write(dir: &Path, rel: &str, content: &str) {
714        let full = dir.join(rel);
715        if let Some(parent) = full.parent() {
716            std::fs::create_dir_all(parent).unwrap();
717        }
718        std::fs::write(full, content).unwrap();
719    }
720
721    #[test]
722    fn the_side_file_is_only_reported_when_claude_md_actually_imports_it() {
723        // A CLAUDE.md write target does not by itself mean the side file is
724        // read: when AgentMux owns CLAUDE.md it writes the content directly
725        // and adds no import, and a previously appended import can be removed
726        // by hand while the side file stays behind (Codex, PR #3156).
727        let dir = tempfile::tempdir().unwrap();
728        write(dir.path(), AGENTMUX_MEMORY_FILENAME, "stale side file");
729        write(
730            dir.path(),
731            "CLAUDE.md",
732            &format!("{CLAUDE_MD_MANAGED_MARKER}\n\nowned by agentmux, no import\n"),
733        );
734
735        let files = resolve_project_instructions("claude", dir.path().to_str().unwrap());
736        assert!(
737            !files.iter().any(|f| f.path == AGENTMUX_MEMORY_FILENAME),
738            "no @-import line means the agent does not read it"
739        );
740
741        // Now the import exists, so it genuinely is read.
742        write(
743            dir.path(),
744            "CLAUDE.md",
745            &format!("# Repo rules\n\n@{AGENTMUX_MEMORY_FILENAME}\n"),
746        );
747        let files = resolve_project_instructions("claude", dir.path().to_str().unwrap());
748        let side = files
749            .iter()
750            .find(|f| f.path == AGENTMUX_MEMORY_FILENAME)
751            .expect("an active import means it is read");
752        assert_eq!(side.owner, InstructionOwner::Agentmux);
753    }
754
755    #[test]
756    fn a_symlinked_directory_inside_a_scan_is_not_followed() {
757        // `metadata()` follows the link and reports is_symlink() == false, so
758        // the original guard never fired at all — a repository could point a
759        // link out of the working directory, or build a cycle (Codex, #3156).
760        let dir = tempfile::tempdir().unwrap();
761        let outside = tempfile::tempdir().unwrap();
762        write(outside.path(), "secret.instructions.md", "not part of this project");
763        std::fs::create_dir_all(dir.path().join(".github/instructions")).unwrap();
764        write(dir.path(), ".github/instructions/own.instructions.md", "ours");
765
766        let link = dir.path().join(".github/instructions/elsewhere");
767        #[cfg(unix)]
768        let made = std::os::unix::fs::symlink(outside.path(), &link).is_ok();
769        #[cfg(windows)]
770        let made = std::os::windows::fs::symlink_dir(outside.path(), &link).is_ok();
771        if !made {
772            // Windows needs privilege for symlinks; the guard is still
773            // exercised on any platform that allows creating one.
774            return;
775        }
776
777        let files = resolve_project_instructions("copilot", dir.path().to_str().unwrap());
778        assert!(
779            files.iter().any(|f| f.path.ends_with("own.instructions.md")),
780            "the real file must still be found"
781        );
782        assert!(
783            !files.iter().any(|f| f.path.contains("secret")),
784            "a symlink must not carry the scan outside the working directory"
785        );
786    }
787
788    #[test]
789    fn a_symlinked_scan_root_is_refused_and_reported() {
790        // ReAgent P0 on #3156: guarding child entries does nothing if the scan
791        // directory ITSELF is a link — `read_dir` follows it transparently,
792        // the paths still strip_prefix cleanly, and the outside file's content
793        // goes out over an authenticated RPC. The nested-symlink test could
794        // never have caught this, because it only ever links a child.
795        let dir = tempfile::tempdir().unwrap();
796        let outside = tempfile::tempdir().unwrap();
797        write(outside.path(), "secret.instructions.md", "not part of this project");
798
799        std::fs::create_dir_all(dir.path().join(".github")).unwrap();
800        let link = dir.path().join(".github/instructions");
801        #[cfg(unix)]
802        let made = std::os::unix::fs::symlink(outside.path(), &link).is_ok();
803        #[cfg(windows)]
804        let made = std::os::windows::fs::symlink_dir(outside.path(), &link).is_ok();
805        if !made {
806            return; // Windows needs privilege to create symlinks.
807        }
808
809        let files = resolve_project_instructions("copilot", dir.path().to_str().unwrap());
810        assert!(
811            !files.iter().any(|f| f.path.contains("secret")),
812            "a symlinked scan root must not be read: {:?}",
813            files.iter().map(|f| &f.path).collect::<Vec<_>>()
814        );
815        let refused = files
816            .iter()
817            .find(|f| f.path == ".github/instructions")
818            .expect("the refusal must be reported, not silent");
819        assert!(refused
820            .error
821            .as_deref()
822            .unwrap_or("")
823            .contains("outside the working directory"));
824    }
825
826    #[test]
827    fn an_overflowing_scan_stops_early_and_still_returns_the_same_set() {
828        // The cap is now applied during the walk, not after it (Codex, PR
829        // #3156) — collecting everything first left the ceiling doing nothing
830        // about the cost it exists to bound. Stopping early only stays honest
831        // because entries are ordered before descending; without that, two
832        // calls would return different prefixes.
833        let dir = tempfile::tempdir().unwrap();
834        for i in 0..(MAX_SCANNED_INSTRUCTION_FILES + 25) {
835            write(
836                dir.path(),
837                &format!(".github/instructions/f{i:04}.instructions.md"),
838                "x",
839            );
840        }
841
842        let first = resolve_project_instructions("copilot", dir.path().to_str().unwrap());
843        let second = resolve_project_instructions("copilot", dir.path().to_str().unwrap());
844        let p1: Vec<&str> = first.iter().map(|f| f.path.as_str()).collect();
845        let p2: Vec<&str> = second.iter().map(|f| f.path.as_str()).collect();
846        assert_eq!(p1, p2, "an early stop must still be reproducible");
847
848        assert!(
849            first.iter().any(|f| f.path.ends_with('…')),
850            "hitting the cap must be reported"
851        );
852        let listed = first
853            .iter()
854            .filter(|f| f.path.starts_with(".github/instructions/") && !f.path.ends_with('…'))
855            .count();
856        assert_eq!(listed, MAX_SCANNED_INSTRUCTION_FILES);
857    }
858
859    #[test]
860    fn a_blank_working_directory_resolves_to_the_launch_default() {
861        // The common case: launch uses default_agent_working_dir when the
862        // stored value is blank, so reading the raw value reported nothing at
863        // all for those agents (Codex, PR #3156).
864        let resolved = effective_working_dir("", "My Agent");
865        assert!(!resolved.trim().is_empty(), "must not resolve to nothing");
866        // The default is itself a `~` path, so the blank case exercises the
867        // expansion too — reading the stored value raw gave neither.
868        let default_raw = crate::backend::storage::agents::default_agent_working_dir("My Agent");
869        assert!(default_raw.starts_with('~'), "precondition: the default is a tilde path");
870        assert!(!resolved.starts_with('~'), "must be expanded: {resolved}");
871        assert!(
872            resolved.ends_with(default_raw.trim_start_matches("~/")),
873            "must be the same directory, expanded: {resolved} vs {default_raw}"
874        );
875    }
876
877    #[test]
878    fn a_tilde_path_is_expanded_the_way_launch_expands_it() {
879        let expanded = effective_working_dir("~/projects/x", "ignored");
880        assert!(!expanded.starts_with('~'), "got {expanded}");
881        assert!(expanded.ends_with("projects/x"), "got {expanded}");
882    }
883
884    #[test]
885    fn an_explicit_working_directory_is_left_alone() {
886        let dir = tempfile::tempdir().unwrap();
887        let p = dir.path().to_str().unwrap();
888        assert_eq!(effective_working_dir(p, "ignored"), p);
889    }
890}