Skill watcher follows directory symlinks and can recurse into huge external trees

Open 💬 2 comments Opened Jul 1, 2026 by VitalyAnkh

What happened?

I hit a reproducible hang/high-CPU case when starting Codex from a repo that had a directory symlink under its project skill root:

.codex/skills/result -> /nix/store/vhv8cnk1sk5pkqzywzrhmn3gavzvki7i-nixos-system-eva-26.05.20260523.64c08a7

That target is a large NixOS system closure. A stuck Codex process had a busy notify-rs inotify loop thread, and removing only that symlink made startup behave normally again.

The important part is not the literal name result; it is that the skills watcher recursively watches the skill root, and the underlying notify watcher follows directory symlinks by default. That can make a small repo-local skill root expand into a huge external tree.

Why this looks like a Codex bug

From the current Rust code:

  • codex-rs/app-server/src/skills_watcher.rs registers non-plugin skill roots as recursive watches.
  • codex-rs/file-watcher/src/lib.rs creates the notify watcher with the default config.
  • notify 8.2 defaults follow_symlinks to true, so recursive watches can traverse directory symlinks.
  • codex-rs/core-skills/src/loader.rs intentionally follows directory symlinks for user/repo/admin skills, and there are tests for loading skills via symlinked skill directories.

So changing only the skill loader policy would be too broad and would likely break valid symlinked skill setups. The issue is specifically the watcher behavior: a force-included skill root should be watched, but directory symlink targets beneath it should not be recursively watched as part of that root.

This is similar in shape to warpdotdev/warp#13252, where a force-included provider directory remains watched but directory symlinks below it are pruned from recursive repo watches.

Expected behavior

Codex should not recursively watch directory symlink targets beneath skill roots such as .codex/skills or .agents/skills.

A normal directory under the skill root should still be watched, and existing symlinked skill loading behavior should remain intact after restart/reload.

Actual behavior

A directory symlink under .codex/skills can cause Codex's recursive watcher to walk and watch the symlink target. If the target is a large tree such as a Nix store closure, startup can hang or burn CPU in the notify/inotify thread.

Minimal reproduction shape

On Linux:

mkdir -p repro/.codex/skills
mkdir -p outside/result-target/deep
ln -s ../../outside/result-target repro/.codex/skills/result
cd repro
codex

The problematic production case used a symlink to a much larger tree under /nix/store, but the shape above is the same.

Suggested fix direction

Use a recursive watch configuration that does not follow directory symlinks. In notify 8.2 this appears to be available as:

notify::Config::default().with_follow_symlinks(false)

This should be applied at the file-watcher layer rather than by disabling loader support for symlinked skill directories.

A regression test should cover:

  1. A recursive watch on skills/.
  2. skills/result as a directory symlink to an outside target.
  3. Changes under the outside target do not produce events for the skills/ subscription.
  4. Changes under a real directory like skills/real/SKILL.md still do produce events.

Related issues

  • Related broader watcher exhaustion issue: #23574
  • Related but different skill symlink loading issue: #11314

View original on GitHub ↗

2 Comments

VitalyAnkh · 1 month ago

I put together a small fix branch here:

https://github.com/VitalyAnkh/codex/tree/fix/skill-watcher-no-follow-symlinks

The patch keeps this at the watcher layer by creating the notify watcher with follow_symlinks(false), so recursive watches still cover real directories under the skill root but do not descend through directory symlinks like .codex/skills/result -> /nix/store/....

I also added a Linux regression test for the skills/result -> outside/result-target shape, while checking that a real skills/real/SKILL.md change still emits an event.

Local verification on the branch:

  • just test -p codex-file-watcher recursive_watch_does_not_follow_directory_symlink_targets
  • just test -p codex-file-watcher
  • just fmt
  • just fix -p codex-file-watcher

If this looks like the right direction, would you be open to a PR from that branch?

abhay-codes07 · 1 month ago

Confirmed and fixed on my end. FileWatcher::new (codex-rs/file-watcher/src/lib.rs) builds the notify watcher with the default config, and notify 8.2 defaults follow_symlinks to true, so a recursive watch on .codex/skills/ descends into any directory symlink under it. A symlink into a large tree like a Nix closure is enough to make the inotify loop spin.

The fix is watcher-only, as you noted it should be: construct the watcher with Config::default().with_follow_symlinks(false). The skill loader keeps resolving symlinks itself; only the recursive watch stops traversing them. Both consumers of this crate (skills watcher and fs watch) are recursive-safe with it, so the global setting is fine.

I deliberately left this as a config change rather than adding a behavioral test. The effect lives inside notify's OS-level recursive traversal, so a real regression test needs live inotify events plus a directory symlink plus a negative timeout assertion, which is flaky and platform-gated. The existing file-watcher suite passes (21/21) and clippy is clean.

Branch: https://github.com/abhay-codes07/codex/tree/fix/file-watcher-no-follow-symlinks

External PRs here are invite-only, so I'm leaving it as a branch; happy to open one.