Skip to content

fix(atomic-json): write through symlinked destinations - #266

Open
Moret84 wants to merge 1 commit into
slkiser:mainfrom
Moret84:fix/atomic-write-preserves-symlink
Open

fix(atomic-json): write through symlinked destinations#266
Moret84 wants to merge 1 commit into
slkiser:mainfrom
Moret84:fix/atomic-write-preserves-symlink

Conversation

@Moret84

@Moret84 Moret84 commented Sep 9, 2026

Copy link
Copy Markdown

Summary

writeTextAtomic() finished with rename(tmp, path). rename(2) replaces the path itself rather than following it, so a symlinked destination was destroyed and replaced by a regular file. Since init-installer.ts writes opencode.json through writeJsonAtomic, any config write silently detached an opencode.json that was symlinked into a dotfiles repository, and every later edit stopped reaching the original target. The retryable-rename fallback compounded it by calling safeRm(path) on the link first.

Temp-file-plus-rename is the right pattern; the defect was only the missing symlink resolution. This change resolves the destination through its symlink chain before choosing the temp path and renaming, so the temp file stays in the real target's directory and the write remains atomic on the same filesystem. A missing destination, a dangling link, or a cycle beyond MAX_SYMLINK_DEPTH resolves to the last path reached and is written as a regular file, matching the previous behavior for those cases.

Scope: src/lib/atomic-json.ts only, plus tests. No public signature changes, no behavior change for non-symlinked destinations.

Tests

  • tests/lib.atomic-json.symlink.test.ts (new) exercises the real filesystem rather than mocks, since the defect is precisely about inode identity: write-through, a symlink chain, a dangling link, and a plain missing destination as a control. Three of the four fail on main; the control passes, which is what makes it a regression test rather than a behavior change.
  • tests/lib.atomic-json.test.ts gains lstat/readlink in its existing fs/promises mock. Its assertions are unchanged.

Linked Issue

Fixes #265

OpenCode Validation

  • Current production released OpenCode version tested: 1.18.29
  • Why this version is relevant to the fix: this is the version whose plugin cache resolved @slkiser/opencode-quota@latest to 4.4.1, where I hit the bug on a real symlinked ~/.config/opencode/opencode.json. I also reproduced it directly against that published 4.4.1 artifact, and confirmed the patched code writes through the link. The fix is in the plugin's own file writer and does not depend on OpenCode internals.

Quality Checklist

  • I ran pnpm run typecheck
  • I ran pnpm run build
  • I ran pnpm test
  • This change is focused and avoids unrelated behavior changes
  • I updated or added tests when behavior changed
  • I updated docs when user-facing workflow, command, or config behavior changed - not applicable, no user-facing command, config, or workflow change
  • For provider changes, I followed Provider Changes, or this does not apply - not a provider change

pnpm verify passes end to end (Biome, pinned TypeScript check, history/privacy, typecheck, build, 2284 tests, four-surface parity, package contents) on Node 24.18.1, macOS.

writeTextAtomic ended in rename(tmp, path), which replaces the path
itself rather than following it. When the destination was a symlink -
the usual shape for a config file linked into a dotfiles repository -
the rename detached the link and left a plain file behind, so every
later edit stopped reaching the original target. The retryable-rename
fallback made it worse by unlinking the destination first.

Resolve the destination through its symlink chain before choosing the
temp path and renaming. The temp file stays in the real target's
directory, so the write remains atomic on the same filesystem. A
missing destination, a dangling link, or a cycle resolves to the last
path reached and is written as a regular file.

Add real-filesystem regression tests; three of the four fail without
this change.
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.

[bug]: atomic config writes replace a symlinked opencode.json with a regular file

1 participant