can1357/oh-my-pi · error
Managed skill "${name}" SKILL.md is a symlink; refusing to o
Error message
Managed skill "${name}" SKILL.md is a symlink; refusing to overwrite it. What it means
openManagedSkillFileForUpdate opens SKILL.md with O_NOFOLLOW, which makes open() fail with ELOOP if the target is a symlink. The ELOOP is converted into this explicit error so an update can never write through a symlink to a path outside the managed directory.
Source
Thrown at packages/coding-agent/src/autolearn/managed-skills.ts:145
const UPDATE_FILE_OPEN_FLAGS = fsConstants.O_WRONLY | fsConstants.O_NOFOLLOW;
function assertManagedSkillFileSafeForUpdate(name: string, fileStat: Stats): void {
if (!fileStat.isFile()) {
throw new Error(`Managed skill "${name}" SKILL.md is not a regular file; refusing to overwrite it.`);
}
if (fileStat.nlink > 1) {
throw new Error(
`Managed skill "${name}" SKILL.md has ${fileStat.nlink} hard links; refusing to overwrite a file that may be user-authored elsewhere.`,
);
}
}
async function openManagedSkillFileForUpdate(name: string, file: string) {
try {
return await fs.open(file, UPDATE_FILE_OPEN_FLAGS);
} catch (err) {
if ((err as { code?: string }).code === "ELOOP") {
throw new Error(`Managed skill "${name}" SKILL.md is a symlink; refusing to overwrite it.`);
}
throw err;
}
}
/** Create or update a managed `SKILL.md`. Returns the resolved file path. */
export async function writeManagedSkill(input: WriteManagedSkillInput): Promise<{ path: string }> {
const name = sanitizeSkillName(input.name);
const description = sanitizeManagedDescription(input.description);
const body = input.body.trim();
// Reject empty content: an all-whitespace/control description sanitizes to ""
// and the `requireDescription` discovery scan then silently drops the skill,
// so the tool would report success for a skill that never appears.
if (!description) {
throw new Error(`Managed skill "${name}" needs a non-empty description.`);
}
if (!body) {
throw new Error(`Managed skill "${name}" needs a non-empty body.`);View on GitHub (pinned to 9690622007)
Solutions
- Replace the symlink with a real file: `rm <path> && cp <target-of-link> <path>`, then retry
- Stop symlinking individual SKILL.md files into managed-skills; place authored skills in ~/.omp/agent/skills instead
- Verify with `ls -la ~/.omp/agent/managed-skills/<name>/`
Example fix
// before (shell) ln -s ~/my-skills/foo.md ~/.omp/agent/managed-skills/foo/SKILL.md // after (shell) rm ~/.omp/agent/managed-skills/foo/SKILL.md && cp ~/my-skills/foo.md ~/.omp/agent/managed-skills/foo/SKILL.md
Defensive patterns
Strategy: validation
Validate before calling
import { lstat } from "node:fs/promises";
const st = await lstat(skillMdPath).catch(() => null);
if (st?.isSymbolicLink()) throw new Error("SKILL.md must not be a symlink"); Type guard
function isNotSymlink(st: { isSymbolicLink(): boolean } | null): boolean {
return st === null || !st.isSymbolicLink();
} Try / catch
try {
await writeManagedSkill({ name, action: "update", ... });
} catch (err) {
if (String((err as Error).message).includes("is a symlink")) {
// replace the symlink with a real file (rm + cp), then retry
} else throw err;
} Prevention
- Never symlink individual SKILL.md files into managed-skills
- Use the user-authored skills dir (~/.omp/agent/skills) for linked content
- Audit with `find ~/.omp/agent/managed-skills -type l` periodically
When it happens
Trigger: writeManagedSkill updates an existing skill whose <name>/SKILL.md is a symbolic link (fs.open returns errno ELOOP).
Common situations: A user or sync tool replaced a managed skill's SKILL.md with a symlink to their own file; dotfile managers stow-link individual files into ~/.omp.
Related errors
- The managed-skills root is a symlink; refusing to operate ou
- Managed skill "${name}" resolves through a symlink; refusing
- Managed skill "${safe}" is a symlink; refusing to delete out
- Security output directory must not be a symbolic link
- Security output directory does not have a canonical identity
AI-assisted analysis of can1357/oh-my-pi@9690622007 (2026-08-31).
Data as JSON: /api/errors/09a805a56644369e.
Report an issue: GitHub.