Files
my-pi/pi-subagents/test/skill-loader.test.ts

238 lines
9.9 KiB
TypeScript

import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { preloadSkills } from "../src/skill-loader.js";
describe("preloadSkills", () => {
let tmpDir: string;
let originalAgentDir: string | undefined;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "pi-skill-test-"));
originalAgentDir = process.env.PI_CODING_AGENT_DIR;
process.env.PI_CODING_AGENT_DIR = join(tmpDir, "user-agent-dir");
});
afterEach(() => {
if (originalAgentDir === undefined) {
delete process.env.PI_CODING_AGENT_DIR;
} else {
process.env.PI_CODING_AGENT_DIR = originalAgentDir;
}
rmSync(tmpDir, { recursive: true, force: true });
});
const projectRoot = () => join(tmpDir, ".pi", "skills");
const globalRoot = () => join(process.env.PI_CODING_AGENT_DIR!, "skills");
function writeFlat(root: string, name: string, content: string, ext = ".md") {
mkdirSync(root, { recursive: true });
writeFileSync(join(root, name + ext), content);
}
function writeSkillDir(root: string, name: string, content: string) {
const dir = join(root, name);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "SKILL.md"), content);
}
it("returns empty array for empty skill list", () => {
expect(preloadSkills([], tmpDir)).toEqual([]);
});
it("loads a top-level flat .md skill from project", () => {
writeFlat(projectRoot(), "api-conventions", "# API Conventions");
const result = preloadSkills(["api-conventions"], tmpDir);
expect(result[0].content).toContain("API Conventions");
});
it("ignores .txt files (only .md is supported)", () => {
writeFlat(projectRoot(), "error-handling", "should not load", ".txt");
expect(preloadSkills(["error-handling"], tmpDir)[0].content).toContain("not found");
});
it("ignores extensionless files (only .md is supported)", () => {
writeFlat(projectRoot(), "bare-skill", "should not load", "");
expect(preloadSkills(["bare-skill"], tmpDir)[0].content).toContain("not found");
});
it("loads a top-level <name>/SKILL.md from project", () => {
writeSkillDir(projectRoot(), "writing-go", "# Writing Go");
expect(preloadSkills(["writing-go"], tmpDir)[0].content).toContain("Writing Go");
});
it("loads a top-level <name>/SKILL.md from getAgentDir()/skills", () => {
writeSkillDir(globalRoot(), "writing-python", "# Writing Python");
expect(preloadSkills(["writing-python"], tmpDir)[0].content).toContain("Writing Python");
});
it("loads a flat .md from getAgentDir()/skills", () => {
writeFlat(globalRoot(), "shell-tips", "use rg");
expect(preloadSkills(["shell-tips"], tmpDir)[0].content).toBe("use rg");
});
it("finds nested <subdir>/<name>/SKILL.md in getAgentDir()/skills", () => {
writeSkillDir(join(globalRoot(), "dev-tools"), "using-modern-cli", "# Modern CLI");
expect(preloadSkills(["using-modern-cli"], tmpDir)[0].content).toContain("Modern CLI");
});
it("loads <name>/SKILL.md from project .agents/skills (Agent Skills spec)", () => {
writeSkillDir(join(tmpDir, ".agents", "skills"), "writing-rust", "# Writing Rust");
expect(preloadSkills(["writing-rust"], tmpDir)[0].content).toContain("Writing Rust");
});
it("prefers .pi/skills over .agents/skills in the same project", () => {
writeSkillDir(projectRoot(), "shared", "from-pi");
writeSkillDir(join(tmpDir, ".agents", "skills"), "shared", "from-agents");
expect(preloadSkills(["shared"], tmpDir)[0].content).toBe("from-pi");
});
it("finds nested <subdir>/<name>/SKILL.md", () => {
writeSkillDir(join(projectRoot(), "dev-tools"), "using-modern-cli", "# Modern CLI");
expect(preloadSkills(["using-modern-cli"], tmpDir)[0].content).toContain("Modern CLI");
});
it("prefers project over global", () => {
writeSkillDir(projectRoot(), "shared", "from-project");
writeSkillDir(globalRoot(), "shared", "from-global");
expect(preloadSkills(["shared"], tmpDir)[0].content).toBe("from-project");
});
it("prefers shallower match (lex tie-break)", () => {
// Different depths — shallower wins.
writeSkillDir(join(projectRoot(), "z-deep", "nested"), "collide", "deep");
writeSkillDir(join(projectRoot(), "a-shallow"), "collide", "shallow");
expect(preloadSkills(["collide"], tmpDir)[0].content).toBe("shallow");
// Same depth — alphabetical wins.
writeSkillDir(join(projectRoot(), "b-sibling"), "tie", "b");
writeSkillDir(join(projectRoot(), "a-sibling"), "tie", "a");
expect(preloadSkills(["tie"], tmpDir)[0].content).toBe("a");
});
it("descends past a same-named dir that lacks SKILL.md to find a deeper match", () => {
// .pi/skills/foo exists empty; .pi/skills/foo/inner/foo/SKILL.md is the real skill.
mkdirSync(join(projectRoot(), "foo"), { recursive: true });
writeSkillDir(join(projectRoot(), "foo", "inner"), "foo", "deeper");
expect(preloadSkills(["foo"], tmpDir)[0].content).toBe("deeper");
});
it("does not descend into a sibling skill directory (skills don't nest)", () => {
// .pi/skills/outer is itself a skill; .pi/skills/outer/target/SKILL.md must NOT be found.
writeSkillDir(projectRoot(), "outer", "outer-skill");
writeSkillDir(join(projectRoot(), "outer"), "target", "hidden");
expect(preloadSkills(["target"], tmpDir)[0].content).toContain("not found");
});
it("skips node_modules during recursion", () => {
writeSkillDir(join(projectRoot(), "node_modules", "some-pkg"), "leaked", "should not load");
expect(preloadSkills(["leaked"], tmpDir)[0].content).toContain("not found");
});
it("skips dotfile directories during recursion", () => {
writeSkillDir(join(projectRoot(), ".hidden-tree"), "buried", "should not load");
expect(preloadSkills(["buried"], tmpDir)[0].content).toContain("not found");
});
it("returns fallback for missing skills", () => {
const result = preloadSkills(["nonexistent"], tmpDir);
expect(result[0].name).toBe("nonexistent");
expect(result[0].content).toContain("not found");
});
it("loads multiple skills", () => {
writeFlat(projectRoot(), "a", "Content A");
writeSkillDir(projectRoot(), "b", "Content B");
const result = preloadSkills(["a", "b"], tmpDir);
expect(result.map((r) => r.content)).toEqual(["Content A", expect.stringContaining("Content B")]);
});
it("skips skill names with path traversal (..)", () => {
expect(preloadSkills(["../../etc/passwd"], tmpDir)[0].content).toContain("path traversal");
});
it("skips skill names with forward slash", () => {
expect(preloadSkills(["sub/dir"], tmpDir)[0].content).toContain("path traversal");
});
it("skips skill names with backslash", () => {
expect(preloadSkills(["sub\\dir"], tmpDir)[0].content).toContain("path traversal");
});
it("skips skill names with spaces", () => {
expect(preloadSkills(["my skill"], tmpDir)[0].content).toContain("path traversal");
});
it("skips skill names starting with a dot", () => {
expect(preloadSkills([".hidden"], tmpDir)[0].content).toContain("path traversal");
});
it("skips empty skill names", () => {
expect(preloadSkills([""], tmpDir)[0].content).toContain("path traversal");
});
it("skips skill names exceeding 128 characters", () => {
const longName = "a".repeat(129);
expect(preloadSkills([longName], tmpDir)[0].content).toContain("path traversal");
});
it("loads valid skills alongside skipped unsafe ones", () => {
writeFlat(projectRoot(), "legit", "Good content");
const result = preloadSkills(["../evil", "legit"], tmpDir);
expect(result[0].content).toContain("path traversal");
expect(result[1].content).toBe("Good content");
});
it("rejects symlinked flat .md files", () => {
mkdirSync(projectRoot(), { recursive: true });
const secret = join(tmpDir, "secret.md");
writeFileSync(secret, "TOP SECRET");
symlinkSync(secret, join(projectRoot(), "evil.md"));
const result = preloadSkills(["evil"], tmpDir);
expect(result[0].content).toContain("not found");
expect(result[0].content).not.toContain("TOP SECRET");
});
it("rejects symlinked skill directories", () => {
mkdirSync(projectRoot(), { recursive: true });
const realDir = join(tmpDir, "real-skill");
mkdirSync(realDir, { recursive: true });
writeFileSync(join(realDir, "SKILL.md"), "TOP SECRET");
symlinkSync(realDir, join(projectRoot(), "evil-dir"));
const result = preloadSkills(["evil-dir"], tmpDir);
expect(result[0].content).toContain("not found");
expect(result[0].content).not.toContain("TOP SECRET");
});
it("rejects symlinked skill root", () => {
// <cwd>/.pi/skills → symlink to a directory that holds real-looking skills.
const realRoot = join(tmpDir, "elsewhere");
mkdirSync(realRoot, { recursive: true });
writeFileSync(join(realRoot, "leaked-flat.md"), "TOP SECRET FLAT");
mkdirSync(join(realRoot, "leaked-dir"), { recursive: true });
writeFileSync(join(realRoot, "leaked-dir", "SKILL.md"), "TOP SECRET DIR");
mkdirSync(join(tmpDir, ".pi"), { recursive: true });
symlinkSync(realRoot, projectRoot());
const flatResult = preloadSkills(["leaked-flat"], tmpDir)[0].content;
expect(flatResult).toContain("not found");
expect(flatResult).not.toContain("TOP SECRET");
const dirResult = preloadSkills(["leaked-dir"], tmpDir)[0].content;
expect(dirResult).toContain("not found");
expect(dirResult).not.toContain("TOP SECRET");
});
it("rejects symlinked SKILL.md inside a real skill directory", () => {
const skillDir = join(projectRoot(), "evil-inner");
mkdirSync(skillDir, { recursive: true });
const secret = join(tmpDir, "secret.md");
writeFileSync(secret, "TOP SECRET");
symlinkSync(secret, join(skillDir, "SKILL.md"));
const result = preloadSkills(["evil-inner"], tmpDir);
expect(result[0].content).toContain("not found");
expect(result[0].content).not.toContain("TOP SECRET");
});
});