Atomic write to ~/.claude/settings.json fails with EROFS/EACCES when the file is a symlink-to-a-symlink

Status Open
Reported on v2.1.211
Maintainer reply ✓ Yes — bcherny
Activity 4 comments · opened Jul 16, 2026
💡 Likely answer: A maintainer (bcherny, collaborator) responded on this thread — see the highlighted reply below.

Atomic write to ~/.claude/settings.json fails with EROFS/EACCES when the file is a symlink-to-a-symlink

Summary

When ~/.claude/settings.json is a symlink whose target is itself a symlink
pointing into a writable directory, Claude Code's atomic save (write
settings.json.tmp.<pid> in the target's directory, then rename) fails.

The writer resolves the symlink one hop and places the temp file next to the
first target. If that first target lives in a read-only directory — even though
the chain ultimately resolves to a writable file — the temp write fails:

Failed to read raw settings from ~/.claude/settings.json:
Error: EROFS: read-only file system, open '.../settings.json.tmp.<pid>'

(EROFS on a read-only filesystem such as the Nix store; EACCES on a merely
read-only directory — same root cause.)

Resolving the symlink fully (realpath) before choosing the temp-file
directory would place the temp file next to the final, writable target and
succeed.

Impact

Any claude plugin operation that persists settings (add marketplace, install
plugin, refresh catalog) aborts partway. Observed effect: the plugin catalog is
rewritten without the affected marketplace's plugins, so their skills stop
loading (Unknown skill: <name>) with no obvious error. This is common on
declaratively-managed dotfiles (Nix home-manager mkOutOfStoreSymlink, and any
setup that symlinks a symlink) where the intermediate hop lands in a read-only
store path.

Minimal reproduction

No Claude Code required — reproduces the exact filesystem operation the atomic
writer performs.

S=$(mktemp -d); cd "$S"
mkdir writable readonly
echo '{}' > writable/settings.json                        # real file, writable dir
ln -s "$S/writable/settings.json" readonly/settings.json  # hop 2: -> writable (link lives in readonly/)
chmod -R a-w readonly                                      # readonly dir (stand-in for the nix store)
ln -s "$S/readonly/settings.json" settings.json           # hop 1: what ~/.claude/settings.json is

# One-hop resolve (current behavior): temp file goes in readonly/ -> FAILS
d=$(dirname "$(readlink settings.json)");  touch "$d/settings.json.tmp"   # Permission denied / EROFS

# Full resolve (proposed fix): temp file goes in writable/ -> succeeds
d=$(dirname "$(readlink -f settings.json)"); touch "$d/settings.json.tmp" # ok

chmod -R u+w readonly  # cleanup
  • readlink settings.json.../readonly/settings.json — read-only dir, temp

write fails.

  • readlink -f settings.json.../writable/settings.json — writable dir,

temp write ok.

Expected

The atomic writer should resolve the symlink chain fully (realpath /
fs.realpath) and write the temp file in the final target's directory, so a
writable destination reached through an intermediate read-only symlink still
saves correctly.

Actual

The temp file is created next to the first-hop target; when that directory is
read-only the write fails with EROFS/EACCES and the settings save is aborted.

Environment

  • Claude Code 2.1.211
  • Linux

View original on GitHub ↗

3 Comments

charlie0228 · 1 month ago

Same one-hop root cause, but a silent variant worth documenting: when the intermediate target lives in a writable directory, the temp write and rename both succeed — and the rename replaces the intermediate symlink with a regular file. No error, no visible malfunction, so the detachment goes unnoticed.

Observed on macOS, Claude Code 2.1.220 (native installer), so this is not Linux-specific.

Chain

<session-profile>/settings.json   →   ~/.claude/settings.json   →   <dotfiles-repo>/claude-code/settings.json
   symlink, created by a               symlink, created by my        regular file, git-tracked
   multi-account launcher              dotfiles sync script
   (shares settings into
    per-account profiles)

Claude Code runs with the session profile as its config dir, so the write path starts at the first symlink — exactly the "symlink whose target is itself a symlink" shape in the original report. Every directory in this chain is writable.

What happens

After a settings write (/effort or /model persisting a default), ~/.claude/settings.json — the middle link — is no longer a symlink:

before:  lrwxr-xr-x  settings.json -> <dotfiles-repo>/claude-code/settings.json
after:   -rw-r--r--  settings.json          (regular file)

stat confirms it is a new inode rather than an in-place edit, and its birth time matches the slash command's execution timestamp to the second (cross-checked against the session transcript):

birth=13:52:04  modify=13:52:04     # /effort executed at 13:52:04

The file content is correct — the setting was persisted as intended. That is precisely what makes this variant dangerous: nothing fails, nothing warns.

Impact: silent, one-way divergence

Once the middle link is replaced, the chain to the final target is severed. From that point on:

  • every subsequent settings change is written only to the now-private regular file, so the git-tracked source of truth silently stops receiving updates
  • the user has no signal — no error, and the CLI keeps behaving correctly
  • the next dotfiles sync run overwrites the local file from the repo, discarding settings changed in the interim (in my case model and effortLevel had diverged before I noticed)

For the multi-account / shared-config case this is the same failure shape as #76561 ("rename() replaces the directory entry itself — it never follows a symlink"), which suggests one shared non-symlink-aware atomic writer behind both.

Reproduction is intermittent

I could not reduce this to a deterministic repro. Within a single session, some /effort writes wrote through the chain correctly (both symlinks preserved, the final target updated), while others replaced the middle link. Same chain, same session, different outcome — so there appear to be at least two write paths, only one of which is symlink-aware. I have not identified what selects between them.

Note on the suggested fix

realpath before choosing the temp-file directory fixes the EROFS/EACCES case, but for this variant that alone is not sufficient: the rename destination must also be the fully-resolved final path. If the temp file is placed next to the final target but renamed onto the intermediate path, that intermediate symlink is still replaced — the directory entry is what rename overwrites. Resolving the whole chain once and using the final path for both the temp location and the rename target addresses both symptoms.

austinbutler · 19 days ago

IMO changes to effort and such in a session should succeed regardless of whether it could update your settings file. Ideally it doesn't show an error message, but even just showing the error but taking effect for the session would be acceptable.

bcherny collaborator · 15 days ago

Reproduced on v2.1.233 (macOS, native installer).

Setup: a settings.json symlink chain where the middle hop lives on a read-only filesystem, but the final target is a writable regular file. On macOS I used a read-only disk image as the stand-in for the Nix store:

  1. mkdir writable && echo '{}' > writable/settings.json (real, writable file)
  2. Put a symlink settings.json -> <abs>/writable/settings.json on a read-only volume (hdiutil create -srcfolder ... -format UDRO ro.dmg && hdiutil attach -readonly -mountpoint ./romnt ro.dmg)
  3. In the Claude config dir: ln -s <abs>/romnt/settings.json settings.json (readlink -f correctly resolves to writable/settings.json)
  4. Start claude, run /effort low

Observed:

❯ /effort low
  ⎿  Failed to set effort level: Failed to read raw settings from
     .../settings.json: Error: EROFS: read-only file system,
     open '.../romnt/settings.json.tmp.16038.06761d827058'

The temp file is created next to the first-hop target (on the read-only volume) instead of next to the fully-resolved target. Nothing is written to writable/settings.json, and the effort level for the running session also stays unchanged.

Expected: the settings save follows the whole symlink chain (like readlink -f) and writes to the final writable file; at minimum the in-session change should still take effect.

Note: with the same chain but the middle hop in a merely read-only directory (chmod a-w, EACCES rather than EROFS), v2.1.233 recovers and the write succeeds through the chain (both symlinks preserved) — so the hard failure is specific to the read-only-filesystem case. The silent middle-link replacement described in the first comment is the same one-hop resolution showing up on the rename side.

Assessment: This looks like a genuine bug. Writing through a symlinked settings.json is explicitly supported (earlier releases fixed related symlink cases for dotfile setups), but the writer only resolves one hop of the chain when picking where to stage the temp file and what to rename onto, and a read-only-filesystem error at that stage is not one of the errors it recovers from. This is not a recent regression — the one-hop behavior has been there since write-through for symlinked settings was added — and it is still present in the current source, so it is not yet fixed. Also worth fixing separately: /effort and /model should apply the change to the running session even when persisting it to disk fails.

🤖 Generated with Claude Code

Showing cached comments. Read the full discussion on GitHub ↗