Prune stale Codex skill symlinks

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta
2026-03-18 14:07:24 -05:00
parent 55165f116d
commit 3689992965
2 changed files with 88 additions and 3 deletions

View File

@@ -80,11 +80,17 @@ async function isLikelyPaperclipRepoRoot(candidate: string): Promise<boolean> {
return hasWorkspace && hasPackageJson && hasServerDir && hasAdapterUtilsDir; return hasWorkspace && hasPackageJson && hasServerDir && hasAdapterUtilsDir;
} }
async function isLikelyPaperclipRuntimeSkillSource(candidate: string, skillName: string): Promise<boolean> { async function isLikelyPaperclipRuntimeSkillPath(
candidate: string,
skillName: string,
options: { requireSkillMarkdown?: boolean } = {},
): Promise<boolean> {
if (path.basename(candidate) !== skillName) return false; if (path.basename(candidate) !== skillName) return false;
const skillsRoot = path.dirname(candidate); const skillsRoot = path.dirname(candidate);
if (path.basename(skillsRoot) !== "skills") return false; if (path.basename(skillsRoot) !== "skills") return false;
if (!(await pathExists(path.join(candidate, "SKILL.md")))) return false; if (options.requireSkillMarkdown !== false && !(await pathExists(path.join(candidate, "SKILL.md")))) {
return false;
}
let cursor = path.dirname(skillsRoot); let cursor = path.dirname(skillsRoot);
for (let depth = 0; depth < 6; depth += 1) { for (let depth = 0; depth < 6; depth += 1) {
@@ -97,6 +103,39 @@ async function isLikelyPaperclipRuntimeSkillSource(candidate: string, skillName:
return false; return false;
} }
async function pruneBrokenUnavailablePaperclipSkillSymlinks(
skillsHome: string,
allowedSkillNames: Iterable<string>,
onLog: AdapterExecutionContext["onLog"],
) {
const allowed = new Set(Array.from(allowedSkillNames));
const entries = await fs.readdir(skillsHome, { withFileTypes: true }).catch(() => []);
for (const entry of entries) {
if (allowed.has(entry.name) || !entry.isSymbolicLink()) continue;
const target = path.join(skillsHome, entry.name);
const linkedPath = await fs.readlink(target).catch(() => null);
if (!linkedPath) continue;
const resolvedLinkedPath = path.resolve(path.dirname(target), linkedPath);
if (await pathExists(resolvedLinkedPath)) continue;
if (
!(await isLikelyPaperclipRuntimeSkillPath(resolvedLinkedPath, entry.name, {
requireSkillMarkdown: false,
}))
) {
continue;
}
await fs.unlink(target).catch(() => {});
await onLog(
"stdout",
`[paperclip] Removed stale Codex skill "${entry.name}" from ${skillsHome}\n`,
);
}
}
type EnsureCodexSkillsInjectedOptions = { type EnsureCodexSkillsInjectedOptions = {
skillsHome?: string; skillsHome?: string;
skillsEntries?: Array<{ key: string; runtimeName: string; source: string }>; skillsEntries?: Array<{ key: string; runtimeName: string; source: string }>;
@@ -141,7 +180,7 @@ export async function ensureCodexSkillsInjected(
if ( if (
resolvedLinkedPath && resolvedLinkedPath &&
resolvedLinkedPath !== entry.source && resolvedLinkedPath !== entry.source &&
(await isLikelyPaperclipRuntimeSkillSource(resolvedLinkedPath, entry.runtimeName)) (await isLikelyPaperclipRuntimeSkillPath(resolvedLinkedPath, entry.runtimeName))
) { ) {
await fs.unlink(target); await fs.unlink(target);
if (linkSkill) { if (linkSkill) {
@@ -171,6 +210,12 @@ export async function ensureCodexSkillsInjected(
); );
} }
} }
await pruneBrokenUnavailablePaperclipSkillSymlinks(
skillsHome,
skillsEntries.map((entry) => entry.runtimeName),
onLog,
);
} }
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> { export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {

View File

@@ -102,4 +102,44 @@ describe("codex local adapter skill injection", () => {
await fs.realpath(path.join(customRoot, "custom", "paperclip")), await fs.realpath(path.join(customRoot, "custom", "paperclip")),
); );
}); });
it("prunes broken symlinks for unavailable Paperclip repo skills before Codex starts", async () => {
const currentRepo = await makeTempDir("paperclip-codex-current-");
const oldRepo = await makeTempDir("paperclip-codex-old-");
const skillsHome = await makeTempDir("paperclip-codex-home-");
cleanupDirs.add(currentRepo);
cleanupDirs.add(oldRepo);
cleanupDirs.add(skillsHome);
await createPaperclipRepoSkill(currentRepo, "paperclip");
await createPaperclipRepoSkill(oldRepo, "agent-browser");
const staleTarget = path.join(oldRepo, "skills", "agent-browser");
await fs.symlink(staleTarget, path.join(skillsHome, "agent-browser"));
await fs.rm(staleTarget, { recursive: true, force: true });
const logs: Array<{ stream: "stdout" | "stderr"; chunk: string }> = [];
await ensureCodexSkillsInjected(
async (stream, chunk) => {
logs.push({ stream, chunk });
},
{
skillsHome,
skillsEntries: [{
key: paperclipKey,
runtimeName: "paperclip",
source: path.join(currentRepo, "skills", "paperclip"),
}],
},
);
await expect(fs.lstat(path.join(skillsHome, "agent-browser"))).rejects.toMatchObject({
code: "ENOENT",
});
expect(logs).toContainEqual(
expect.objectContaining({
stream: "stdout",
chunk: expect.stringContaining('Removed stale Codex skill "agent-browser"'),
}),
);
});
}); });