[BUG] Settings writer mis-resolves relative symlinked `~/.claude/settings.json` (readlink + logical-dirname join) — `/effort` fails with ENOENT

Resolved 💬 0 comments Opened Jun 10, 2026 by mingcenwei Closed Jun 16, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

Changing the effort level via /effort fails with ENOENT when ~/.claude/settings.json is a relative symlink and ~/.claude itself is also a symlink (a standard GNU stow layout). Settings reads work fine — sessions load the file normally — so the failure looks mysterious.

I have only reproduced /effort directly. The failure sits in the shared settings-write helper (see Additional Information), so other surfaces that persist user settings through it (/model, /config, permission grants, …) are expected to be affected as well — untested, inference from the code path only.

What Should Happen?

/effort should persist the chosen level into the real settings file. The kernel resolves the symlink chain correctly (reads through it work; readlink -f returns the real file), so the writer should resolve it the same way — canonicalize the path (e.g. fs.realpathSync) — and write to the target.

Error Messages/Logs

Failed to set effort level: Failed to read raw settings from /home/username/.claude/settings.json:
Error: ENOENT: no such file or directory, open '/path/to/config-repo/settings.json'


Note the mangled path in the inner error: `/home/username` is missing from the front. (The message also says "read", but the failing operation is the write — see Additional Information.)

Steps to Reproduce

Minimal example, no stow needed. Back up your real ~/.claude first:

  1. mv ~/.claude ~/.claude.bak
  2. mkdir -p ~/.dotfiles/claude/.claude ~/path/to/config-repo
  3. echo '{}' > ~/path/to/config-repo/settings.json — this is the real settings file
  4. ln -s .dotfiles/claude/.claude ~/.claude~/.claude is now a symlink, as a stow fold would create
  5. ln -s ../../../path/to/config-repo/settings.json ~/.dotfiles/claude/.claude/settings.jsonsettings.json is a relative symlink: 3 levels up from its physical dir (~/.dotfiles/claude/.claude/, 3 components under $HOME) lands back in $HOME, so the kernel resolves it to ~/path/to/config-repo/settings.json
  6. (If you use file-based credentials: cp ~/.claude.bak/.credentials.json ~/.dotfiles/claude/.claude/ to stay logged in)
  7. Sanity-check reads: readlink -f ~/.claude/settings.json prints /home/<you>/path/to/config-repo/settings.json, and cat ~/.claude/settings.json prints {}
  8. Start claude — settings load fine (reads resolve through the kernel)
  9. Run /effort and pick any level
  10. → the ENOENT error above, with the mangled path; the real settings file is unchanged

Restore afterwards: rm ~/.claude && mv ~/.claude.bak ~/.claude (and remove the scratch dirs from steps 2–3).

Claude Model

Other

Is this a regression?

I don't know

Last Working Version

_No response_

Claude Code Version

2.1.170 (Claude Code)

Platform

Anthropic API

Operating System

Other Linux

Terminal/Shell

Other

Additional Information

Root cause (read from the 2.1.170 binary). The symlink-aware atomic write helper (sync b3$ / async Ry1 in the minified bundle) resolves the link target manually:

if (q.allowSymlink) try {
  let O = K.readlinkSync(H);
  f = FA.isAbsolute(O) ? O : FA.resolve(FA.dirname(H), O)   // ← bug
  ...

Two problems:

  1. **Relative targets are resolved against the logical parent (dirname("/home/username/.claude/settings.json") = /home/username/.claude, 3 components from root), but POSIX resolves a relative symlink target against the physical** directory containing the link (/home/username/.dotfiles/claude/.claude/). With the layout above, resolve("/home/username/.claude", "../../../path/to/config-repo/settings.json") walks up to the filesystem root and yields /path/to/config-repo/settings.json — while the kernel correctly resolves the same link to /home/username/path/to/config-repo/settings.json.
  2. Single hopreadlinkSync is called once; a chained symlink isn't followed.

The bad path f then fails the atomic temp-file write (openSync(f + ".tmp...", O_CREAT|O_EXCL) → ENOENT), the non-atomic fallback openSync(f, O_CREAT) fails the same way, and the error surfaces wrapped as "Failed to read raw settings" (the message says read, but the write path is what failed).

Suggested fix. Replace the manual readlinkSync + resolve(dirname(...)) dance with fs.realpathSync(H) — one call handles relative targets, symlink chains, and logical/physical parent divergence. (Bonus: reword the catch-all error, which currently blames "read" for write failures.)

Workaround. Make the inner symlink absolute — the isAbsolute(O) branch bypasses the broken join. But relative links are the default for stow-like dotfiles tooling, so this bites real setups out of the box.

Environment.

  • Platform: Arch Linux
  • Version: 2.1.170 (first observed on 2.1.166/167; code inspection confirms the logic is present in 2.1.170)
  • ~/.claude managed by GNU stow; settings.json symlinked to a dotfiles repo

Related issues (not duplicates).

  • #55485 — symlinked settings via NixOS/home-manager, but the failure there is EACCES on a read-only nix-store target (the writer opens the resolved target read-write); this report is ENOENT from mis-resolution of a relative link. Same allowSymlink code path, different bug. Closed as stale without triage.
  • #52525 — sandbox/bwrap failure with symlinked settings (sandbox adapter, not the settings writer). Closed as stale.
  • #48668 — a permissions key ignored; symlinked settings incidental. Closed as stale.

View original on GitHub ↗