Skip to content

fix(installer): stop swapping symlinked config files for detached copies (shared AGENTS.md setups) - #1503

Open
meganemura wants to merge 1 commit into
colbymchenry:mainfrom
meganemura:fix/installer-stop-swapping-symlinked-configs
Open

fix(installer): stop swapping symlinked config files for detached copies (shared AGENTS.md setups)#1503
meganemura wants to merge 1 commit into
colbymchenry:mainfrom
meganemura:fix/installer-stop-swapping-symlinked-configs

Conversation

@meganemura

Copy link
Copy Markdown

Summary

atomicWriteFileSync lands its temp file on the destination via renameSync, which replaces a symlink itself rather than its target. That silently breaks the common multi-agent setup where one shared instructions file is symlinked into each agent's expected location:

ln -sf ~/AGENTS.md ~/.claude/CLAUDE.md
ln -sf ~/AGENTS.md ~/.codex/AGENTS.md
ln -sf ~/AGENTS.md ~/.gemini/GEMINI.md

After codegraph install, whichever of these the installer touched is a regular-file copy: that agent's instructions file stops tracking the shared AGENTS.md, and the copies drift apart on the next edit. With the installer now covering eight agents, the people most likely to run it — users wiring up several agents at once — are exactly the people running this kind of setup. The same failure hits project-local installs (e.g. a repo-root GEMINI.md -> AGENTS.md link — the local Gemini instructions file lives at the project root) and dotfiles-managed configs, and it isn't limited to markdown: every installer write (JSON and TOML configs included) funnels through this helper, so the one fix covers them all.

The fix: resolve the symlink chain first, then run the temp-file-plus-rename against the real target — the write stays atomic on the target's own filesystem, and the link survives. When several selected agents are symlinked to the same shared file, the marker-based upsert then dedupes across them: the first install writes the block, the rest report unchanged (same guarantee gemini.ts already documents for Gemini + Antigravity sharing GEMINI.md, extended through symlinks).

For context, this wasn't always broken: the original installer used a plain writeFileSync (which follows symlinks). The tmp+rename pattern arrived with the crash-safety hardening in 399d78b, and the symlink side effect went unnoticed — a classic pitfall of atomic-save implementations.

Relationship to #433

@0x1306a94 diagnosed this exact problem back in May and proposed the same core fix in #433 — full credit to them for getting there first; the CHANGELOG entry here thanks them accordingly. I'm proposing this PR as an alternative because the two differ in a few places beyond base-branch drift, and I'd argue for these choices:

  • Uniform chain resolution. fix(installer): preserve symlinks when writing agent config files #433 resolves via realpathSync with a single-hop readlinkSync fallback for dangling links, which leaves a multi-hop dangling chain (link → link → not-yet-created target) partially resolved — the rename would still replace the second link. This PR walks the chain manually from the start (32-hop cap, mirroring kernel ELOOP limits), so existing and dangling links take the same code path.
  • Scope: the uninstall cleanup path is deliberately left alone. fix(installer): preserve symlinks when writing agent config files #433 also resolves the symlink in removeMarkedSection's empty-file cleanup, which makes uninstall delete the link's target — a file the user manages in their dotfiles repo — and leaves dangling links behind. This PR keeps the current behavior (the link is removed, the target survives): between the two imperfect options, deleting a user-managed file seemed like the worse failure mode. Happy to adjust if maintainers prefer otherwise.
  • Tests exercise today's API end-to-end. fix(installer): preserve symlinks when writing agent config files #433 predates the instructions-block mechanism from feat(cli+installer): codegraph explore/node CLI + instructions-file block — subagent & non-MCP reach (#704) #819, so its tests drive replaceOrAppendMarkedSection with local marker constants. The tests here reproduce the reported scenario against the real surface: a dotfiles-managed CLAUDE.md symlink through upsertInstructionsEntry → re-run returns unchangedremoveMarkedSection, with the link and user content intact at every step — plus chain, dangling-link, writeJsonFile, no-leftover-tmp-file, and a multi-select case (two agents symlinked to one shared AGENTS.md get the block exactly once; the second upsert is unchanged). All symlink tests are POSIX-gated per the repo's Windows-gated-tests convention.
  • CHANGELOG entry included, as installer changes require.

Test plan

  • npx vitest run __tests__/installer-targets.test.ts — 171 tests pass (165 existing + 6 new symlink tests), macOS
  • tsc --noEmit clean

🤖 Generated with Claude Code

…ies (shared AGENTS.md setups)

atomicWriteFileSync landed its temp file on the destination via
renameSync, which replaces a symlink itself rather than its target.
Setups that symlink one shared instructions file into each agent's
expected location (~/.claude/CLAUDE.md -> ~/AGENTS.md,
~/.codex/AGENTS.md -> ~/AGENTS.md, ...), and dotfiles-managed configs,
silently lost the link: the file became a detached regular-file copy,
and later edits to the shared source never reached the file the agent
actually reads.

Resolve the symlink chain first (manually — realpathSync throws on
dangling links, and creating a dangling link's target must keep
working) and run the temp-file-plus-rename against the real target,
keeping the write atomic on the target's own filesystem. When several
selected agents point at the same shared file, the marker-based upsert
dedupes across them: the guidance block is written exactly once.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant