Skip to content

fix: write settings.json and other critical config atomically - #1643

Open
elhoim wants to merge 1 commit into
danielmiessler:mainfrom
elhoim:fix/settings-atomic-write
Open

fix: write settings.json and other critical config atomically#1643
elhoim wants to merge 1 commit into
danielmiessler:mainfrom
elhoim:fix/settings-atomic-write

Conversation

@elhoim

@elhoim elhoim commented Jul 26, 2026

Copy link
Copy Markdown

The problem

MergeSettings.ts rewrites settings.json with a truncate-then-write. That file is the harness's entire configuration: hooks, permission rules, environment variables, statusline.

It runs at SessionStart as bun SettingsBackport.ts; bun MergeSettings.ts --output <settings> under a 15-second timeout. If the pair overruns — cold bun cache, slow disk, a busy machine — the harness kills the process group. If the kill lands after the file was truncated but before the ~60KB body is flushed, or the disk is full, what remains is partial JSON. The next launch reports invalid settings and every hook, permission rule and env var is gone.

There is no crash needed for this to bite: the window is opened by an ordinary timeout on an ordinary startup path.

The repo already ships the right primitive at PULSE/lib/atomic-write.ts (write to a unique temp file, then renameSync into place), and settings.json even allowlists a temp-file rename in its own permission rules. It just was not used here.

The fix

All critical config writes go through the existing atomic primitive rather than a second implementation. Beyond the two sites originally identified, a search for the same pattern found five more, all now converted:

  • LIFEOS/TOOLS/MergeSettings.ts
  • LIFEOS/TOOLS/SettingsBackport.ts
  • LIFEOS/TOOLS/SyncIdentityToSettings.ts
  • hooks/lib/work-config.ts
  • skills/LifeOS/Tools/DeployComponents.ts
  • skills/LifeOS/Tools/InstallHooks.ts
  • skills/LifeOS/Tools/InstallSettings.ts

atomic-write.ts itself gained failure-path cleanup so a failed write cannot strand a temp file that a later run might mistake for real config.

The content written is unchanged. This is purely about how it lands on disk.

Tests

test/pulse/lib/atomic-write.test.ts and test/tools/MergeSettings.test.ts, 11 cases:

11 pass
0 fail
18 expect() calls

Separately verified with an independent probe that induces a real failure — a read-only target directory, so the rename genuinely cannot complete — rather than mocking it:

✓ normal write lands correct content
✓ no temp file left behind
✓ failed write reports failure
✓ ORIGINAL survives byte-identical
✓ survivor is still valid JSON
✓ no stray temp after failure
✓ contrast: naive open('w') truncates before any write — 0 bytes remained
✓ contrast: truncated file is invalid JSON

The last two cases demonstrate the old behaviour for contrast: open(path, "w") empties the file the instant it is called, before a single byte of new content exists. That is the window this PR closes.

MergeSettings runs at SessionStart as `bun SettingsBackport.ts; bun
MergeSettings.ts --output <settings path>` under a 15s hook timeout. The
write was a truncate-then-write, so if the harness killed the process
group after the file was truncated but before the ~60KB body flushed — or
if the disk was full — settings.json was left as partial JSON. The next
launch reports invalid settings and loses every hook, permission rule,
env var and statusline setting.

Route the settings writers through the existing atomicWriteText primitive
(temp file + rename), which the tree already uses in hooks and TOOLS and
which settings.system.json already allowlists a rename for. renameSync is
atomic within a filesystem, so an interruption or ENOSPC now fails on the
temp file and leaves the previous settings.json untouched. Content written
is byte-for-byte unchanged.

Also harden the primitive itself to remove its temp file when the write or
rename throws, so a failed write leaves nothing behind. This benefits the
five existing PULSE callers too.

Sites converted (all whole-file rewrites of config that breaks the next
launch if truncated):
  LIFEOS/TOOLS/MergeSettings.ts          settings.json + merge snapshot
  LIFEOS/TOOLS/SettingsBackport.ts       settings.user.json (a merge source)
  LIFEOS/TOOLS/SyncIdentityToSettings.ts settings.json
  skills/LifeOS/Tools/InstallSettings.ts settings.json (create + merge)
  skills/LifeOS/Tools/InstallHooks.ts    settings.json hook registration
  skills/LifeOS/Tools/DeployComponents.ts settings.json statusline + keys
  hooks/lib/work-config.ts               repo.json privacy attestation

Tests assert the atomicity property directly — after a failed write the
previous file is byte-identical and still parses — at the primitive and
end to end through MergeSettings. Reverting atomic-write.ts to a plain
writeFileSync fails exactly those three tests and no others.
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