From 368999296564cffd269d6a512a1a33c6f2d127bf Mon Sep 17 00:00:00 2001 From: dotta Date: Wed, 18 Mar 2026 14:07:24 -0500 Subject: [PATCH] Prune stale Codex skill symlinks Co-Authored-By: Paperclip --- .../codex-local/src/server/execute.ts | 51 +++++++++++++++++-- .../codex-local-skill-injection.test.ts | 40 +++++++++++++++ 2 files changed, 88 insertions(+), 3 deletions(-) diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index 2a6595a0..6fe475dd 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -80,11 +80,17 @@ async function isLikelyPaperclipRepoRoot(candidate: string): Promise { return hasWorkspace && hasPackageJson && hasServerDir && hasAdapterUtilsDir; } -async function isLikelyPaperclipRuntimeSkillSource(candidate: string, skillName: string): Promise { +async function isLikelyPaperclipRuntimeSkillPath( + candidate: string, + skillName: string, + options: { requireSkillMarkdown?: boolean } = {}, +): Promise { if (path.basename(candidate) !== skillName) return false; const skillsRoot = path.dirname(candidate); 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); for (let depth = 0; depth < 6; depth += 1) { @@ -97,6 +103,39 @@ async function isLikelyPaperclipRuntimeSkillSource(candidate: string, skillName: return false; } +async function pruneBrokenUnavailablePaperclipSkillSymlinks( + skillsHome: string, + allowedSkillNames: Iterable, + 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 = { skillsHome?: string; skillsEntries?: Array<{ key: string; runtimeName: string; source: string }>; @@ -141,7 +180,7 @@ export async function ensureCodexSkillsInjected( if ( resolvedLinkedPath && resolvedLinkedPath !== entry.source && - (await isLikelyPaperclipRuntimeSkillSource(resolvedLinkedPath, entry.runtimeName)) + (await isLikelyPaperclipRuntimeSkillPath(resolvedLinkedPath, entry.runtimeName)) ) { await fs.unlink(target); 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 { diff --git a/server/src/__tests__/codex-local-skill-injection.test.ts b/server/src/__tests__/codex-local-skill-injection.test.ts index 24ac9c3f..27c1b406 100644 --- a/server/src/__tests__/codex-local-skill-injection.test.ts +++ b/server/src/__tests__/codex-local-skill-injection.test.ts @@ -102,4 +102,44 @@ describe("codex local adapter skill injection", () => { 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"'), + }), + ); + }); });