ruvnet/ruflo · error

AI job registry is a symlink (refusing)

Error message

AI job registry is a symlink (refusing): ${path}

What it means

AiJobDedupRegistry enforces 'Invariant 9: registry files must never be symlinks'. Before touching the AI job registry file it lstat()s the path and refuses to proceed if it is a symbolic link. This is a deliberate anti-tampering guard: a symlinked registry lets another user/process swap the file underneath (or point it at a sensitive path), so the registry aborts rather than read or write through the link. ENOENT (file does not exist yet) is allowed; every other stat error is rethrown.

Solutions

  1. Inspect the path from the error message with ls -l <path> and confirm it is a symlink
  2. Replace the symlink with a real file (cp -L <link> <tmp> && rm <link> && mv <tmp> <path>) or remove it so the registry recreates a regular file
  3. If sharing across projects/machines was the goal, point options.baseDir at a real directory on the target filesystem instead of symlinking the file
  4. Do not bypass the check — it is a security invariant; if the symlink was unexpected, investigate who created it

Example fix

# before
~/.claude-flow/ai-jobs/registry.json -> /shared/ai-jobs.json   (symlink)
# constructing AiJobDedupRegistry throws

# after
mkdir -p ~/.claude-flow/ai-jobs
cp -L /shared/ai-jobs.json ~/.claude-flow/ai-jobs/registry.json.tmp 2>/dev/null || true
rm ~/.claude-flow/ai-jobs/registry.json
mv ~/.claude-flow/ai-jobs/registry.json.tmp ~/.claude-flow/ai-jobs/registry.json
Defensive patterns

Strategy: validation

Validate before calling

const registryPath = path.join(baseDir, 'ai-jobs', 'registry.json'); // path used by AiJobDedupRegistry
const st = fs.lstatSync(registryPath); // throws ENOENT if absent — that case is fine
if (st.isSymbolicLink()) {
  throw new Error(`${registryPath} is a symlink; replace it with a real file before enabling AI job dedup`);
}

Type guard

const isRealFile = (p: string): boolean => {
  try { return fs.lstatSync(p).isFile(); } catch (e) { return (e as NodeJS.ErrnoException).code === 'ENOENT'; }
};

Try / catch

try {
  registry = new AiJobDedupRegistry({ baseDir });
} catch (e) {
  if (e instanceof Error && e.message.includes('is a symlink')) {
    // surface to the operator: replace the symlink with a real file; do NOT auto-delete — it may be an attack indicator
  } else throw e;
}

Prevention

When it happens

Trigger: Constructing AiJobDedupRegistry (or any flow that instantiates it) when the registry file under its baseDir (options.baseDir or the default) is a symlink — e.g. ~/.claude-flow/ai-jobs or a nested registry.json managed by GNU stow, dotfiles repos, syncthing/cloud-sync, or a manual 'ln -s' to shared storage.

Common situations: Home directory managed with dotfiles tooling that symlinks config/data paths, multi-agent setups sharing one registry across machines via a symlinked network folder, or a security-conscious environment where the guard is tripping on purpose because something replaced the file.

Related errors


AI-assisted analysis of ruvnet/ruflo@fa13ee4ad6 (2026-08-18). Data as JSON: /api/errors/dd697ec3705e15d9. Report an issue: GitHub.

Appendix: source

Thrown at v3/@claude-flow/cli/src/services/ai-job-dedup.ts:69

}

/** Stable hash of an arbitrary config object (key-sorted JSON). */
export function hashWorkerConfig(config: unknown): string {
  const canonical = JSON.stringify(config, (_k, v) => {
    if (v && typeof v === 'object' && !Array.isArray(v)) {
      return Object.fromEntries(Object.entries(v as Record<string, unknown>).sort(([a], [b]) => a.localeCompare(b)));
    }
    return v;
  });
  return createHash('sha256').update(canonical ?? 'null').digest('hex');
}

/** Invariant 9: registry files must never be symlinks. */
function assertNotSymlink(path: string): void {
  try {
    const st = fs.lstatSync(path);
    if (st.isSymbolicLink()) {
      throw new Error(`AI job registry is a symlink (refusing): ${path}`);
    }
  } catch (e) {
    if ((e as NodeJS.ErrnoException).code === 'ENOENT') return;
    throw e;
  }
}

export class AiJobDedupRegistry {
  private readonly dir: string;
  private readonly file: string;

  constructor(options?: { baseDir?: string }) {
    this.dir = options?.baseDir
      ?? process.env.RUFLO_AI_BUDGET_DIR
      ?? join(homedir(), '.claude-flow');
    this.file = join(this.dir, 'ai-jobs.json');
  }

View on GitHub (pinned to fa13ee4ad6)