fix(buzz-agent): follow symlinks when discovering skill directories (#1295)

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1fgdl5qqnh3k3f2xkqrvt7cujalhm623x4s7fdjdj5yrtp5fzjl9qrjpucw <4a1bfa0013bc6d14a8d600d8bf6392efefbd2a26ac3c96c9b2a106b0d12297ca@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Will Pfleger
2026-06-25 16:44:10 -04:00
committed by GitHub
co-authored by npub1fgdl5qqnh3k3f2xkqrvt7cujalhm623x4s7fdjdj5yrtp5fzjl9qrjpucw
parent dfa864fc45
commit 8717ddf2eb
2 changed files with 64 additions and 3 deletions
+12 -3
View File
@@ -113,7 +113,14 @@ fn scan_skill_dir(dir: &Path, seen: &mut HashSet<String>, skills: &mut Vec<Skill
};
let mut subdirs: Vec<PathBuf> = entries
.filter_map(|e| e.ok())
.filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
// Use std::fs::metadata (follows symlinks) rather than DirEntry::file_type
// (which returns FileType::Symlink for symlinks, causing is_dir() to return
// false even when the symlink target is a directory).
.filter(|e| {
std::fs::metadata(e.path())
.map(|m| m.is_dir())
.unwrap_or(false)
})
.map(|e| e.path())
.collect();
subdirs.sort();
@@ -164,8 +171,10 @@ fn collect_supporting_files_impl(current: &Path, out: &mut Vec<PathBuf>) {
for entry in items {
let path = entry.path();
let ft = match entry.file_type() {
Ok(ft) => ft,
// Use std::fs::metadata (follows symlinks) so symlinked subdirs and files
// inside a skill directory are handled correctly.
let ft = match std::fs::metadata(&path) {
Ok(m) => m,
Err(_) => continue,
};
if ft.is_dir() {
@@ -460,6 +460,58 @@ async fn global_skills_loaded_and_project_wins() {
h.shutdown().await;
}
/// Skill directories that are symlinks (e.g. managed by ai-rules) are discovered
/// correctly — `DirEntry::file_type()` returns `FileType::Symlink` for symlinks,
/// so the old `is_dir()` check silently dropped them. We now use
/// `std::fs::metadata()` which follows the symlink.
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn symlinked_skill_dir_is_discovered() {
let real_skill_root = tempfile::TempDir::new().unwrap();
let real_skill_dir = real_skill_root.path().join("symlinked-skill");
std::fs::create_dir_all(&real_skill_dir).unwrap();
std::fs::write(
real_skill_dir.join("SKILL.md"),
"---\nname: symlinked-skill\ndescription: A symlinked skill\n---\nSYMLINK_SKILL_BODY_42\n",
)
.unwrap();
let tmp = tempfile::TempDir::new().unwrap();
let cwd = tmp.path();
let skills_dir = cwd.join(".agents/skills");
std::fs::create_dir_all(&skills_dir).unwrap();
// Create a symlink: .agents/skills/symlinked-skill -> real_skill_dir
std::os::unix::fs::symlink(&real_skill_dir, skills_dir.join("symlinked-skill")).unwrap();
let llm = spawn_capturing_llm(vec![openai_text("done")]).await;
let mut h = Harness::spawn_with_env(&llm.url, &[]).await;
let sid = init_session(&mut h, cwd.to_str().unwrap()).await;
let p = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}),
)
.await;
let _ = h.recv_until(|v| v["id"] == json!(p)).await;
let captured = llm.captured.lock().await;
assert!(!captured.is_empty(), "no LLM request captured");
let system = captured[0]["messages"][0]["content"].as_str().unwrap_or("");
// The symlinked skill name must appear in the metadata listing.
assert!(
system.contains("symlinked-skill"),
"system prompt missing symlinked skill name: {system}"
);
// Body must NOT be inlined.
assert!(
!system.contains("SYMLINK_SKILL_BODY_42"),
"symlinked skill body must not be inlined in system prompt: {system}"
);
h.shutdown().await;
}
/// `load_skill` tool is advertised when skills exist, and returns the skill body.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn load_skill_tool_returns_body() {