fix(#1874): make installer settings and defaults.json writes atomic, lock-guarded, and null-contract-safe - #3385
Conversation
writeSettings is the sole writer of settings.json/settings.local.json for six runtimes and wrote them with a naked fs.writeFileSync. Hosts discard the entire settings file on any parse failure, so a crash mid-write cost the user every hook, permission, env var, and statusline they had — not just GSD's. Route it through the atomicWriteFileSync (temp+rename) already used elsewhere in the installer and already bound in this file. withWriteFailure in the migration integration harness matched only the final destination path, so an atomic write bypassed the injection entirely and turned a rollback assertion into a vacuous pass. It now also matches the .tmp- sibling. Refs open-gsd#1874 (F5) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…gsd#338 local-merge leg readSettings returns null only for an unparseable file — its documented "preserve existing, don't touch" signal. The open-gsd#338 migration coerced that null to {} and wrote the result back, so a settings.local.json with one stray comma lost all its non-GSD content on the next install. The guard stands the whole migration down rather than just the local write: skipping the merge while still stripping the shared file would destroy the GSD entries outright instead of relocating them. Aborting here reaches a pre-existing latent crash that the clobber had been masking. Both are fixed, with their own regression test: - the unparseable-settings guard returned bare `undefined` while all five sibling early exits return the full result shape, so installAllRuntimes' statusline lookup (results.find(r => r.runtime)) threw; - handleStatusline dereferences result.settings, which is null on every early exit, so the call site now falls through to the banner branch. Both crashes reproduce on unmodified next with a malformed settings.local.json and no migration involved. Refs open-gsd#1874 (F6) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…sd/defaults.json Every non-Claude install read-modify-writes ~/.gsd/defaults.json with no lock and two separate naked whole-file writes. The file is read by every runtime and project on the machine, so concurrent installs lost each other's key, and a crash in either write window truncated it — silently, because the read path swallows parse errors and treats a corrupt file as absent. Take the existing acquireInstallMigrationLock around the read-modify-write and apply both mutations in one atomicWriteFileSync. An install that changes nothing no longer rewrites the file at all. Existing semantics are unchanged: the explicit resolve_model_ids:true opt-in (open-gsd#1569) and an existing "omit" are preserved, non-canonical values still default to "omit" (open-gsd#1156), a pre-existing runtime string is preserved (open-gsd#2395), the malformed-non-object recovery (open-gsd#1657) stands, and both console lines still print when both keys change. The open-gsd#2834 structural test sliced a fixed 1200-character window from the function source; the added lock comment pushed an asserted token past it. The window now tracks the function body, so a comment or guard cannot red it spuriously. Refs open-gsd#1874 (F18) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
8aeafe0 to
097d650
Compare
trek-e
left a comment
There was a problem hiding this comment.
Code review: clean. Two process items to resolve before merge.
The diff itself is sound — I traced all three fixes (F5's atomicWriteFileSync reuse, F6's null-contract restoration plus the two pre-existing crash paths it correctly exposes-and-fixes, F18's lock-guarded single-write RMW) and independently confirmed: no leaked locks on any exit path, no reentrant-deadlock risk between the two acquireInstallMigrationLock call sites (different lock scopes — targetDir vs gsdDir), the new bare-return; fix matches the byte-identical shape of 5 sibling early-returns and its sole consumer already guards the one field it omits. Tests use proper fs-method fault injection (not chmod, correctly avoiding the root-bypass vacuous-pass trap) with genuine RED-before/GREEN-after evidence, including a crash-window mock that writes partial bytes before throwing rather than a bare throw. Zero findings from Memtrace's deterministic pass (find_code_review_issues, find_cross_module_issues, find_yaml_rule_matches all clean).
Two things needed before this merges, neither about the code:
1. The PR body's authorization claim. "Per maintainer direction on this PR, all three findings land together as one PR" — I checked every comment on this PR and on #1874–#1878, and found no such direction anywhere before now. I'm not disputing the underlying call: F5/F6/F18 are tightly coupled (same file, same epic, and #1875/#1876/#1877 did carry confirmed-bug before being consolidated into #1874 by maintainers), so bundling them makes sense and I'm authorizing it now, in this review — sequencing three separate PRs against the same install.js regions would just create merge churn for no real isolation benefit. But please correct the body's phrasing; it should describe this review's authorization, not an unverifiable prior one. Same resolution covers the linked-issue gate: #1874 doesn't itself carry confirmed-bug, but its consolidated children did, and that satisfies the template's intent here.
2. CI hasn't actually run. Tests and Install Smoke — the two checks that exercise your new regression suite — are still action_required, along with 4 other required checks. I'm releasing all 6 now since I found no code-level blocker; once they come back green this is ready for a final look.
Memtrace Evidence
get_impact(acquireInstallMigrationLock,atomicWriteFileSync): both CRITICAL / ~180-190 affected symbols — this reflects the primitives' pre-existing fan-out across the module graph, not new exposure; this PR adds callers, doesn't change either function's contract (confirmed viaget_timeline: 3 versions, identical AST hash, doc/whitespace churn only).get_symbol_context(acquireInstallMigrationLock): 1 caller (runInstallerMigrations), communityAcquireClockInstall.recall_decision(query: installer atomic writes, settings.json/defaults.json durability, lock guarding): no on-point recorded decision — CannotProve, not confirmed-clean, but nothing contradicts this approach either.find_code_review_issues(review_mode: "online",max_candidates: 40): 0 issues.
Minor, not blocking: writeNonClaudeDefaults's single catch funnels a lock-acquisition timeout into the same generic "could not write" log line as an actual write failure — the real error text still surfaces, just imprecisely labeled.
Verdict
Comment — not yet approved, no code changes needed. Please fix the PR body's authorization phrasing per above. Releasing CI now; once Tests/Install Smoke come back green I'll do a final pass and this should be a quick approve.
trek-e
left a comment
There was a problem hiding this comment.
Summary
Three data-loss/durability fixes bundled under epic #1874, all in bin/install.js:
- F5 - writeSettings (settings.json / settings.local.json writer for 6 runtimes) now writes via the existing atomicWriteFileSync (temp+rename) instead of a naked fs.writeFileSync, so a crash/ENOSPC mid-write can no longer truncate the file (which hosts discard wholesale on any parse failure).
- F6 - the #338 migration's readSettings(settingsPath) || {} coerced the documented "unparseable - preserve, don't touch" null signal into {}, clobbering a malformed settings.local.json. Now the whole migration (both the local merge and the shared-file GSD-entry strip) stands down when local settings are unparseable. Also fixes an adjacent crash: an early-exit that used to return; (undefined) now returns the full result shape, since a caller indexes results by .settings.
- F18 - writeNonClaudeDefaults's read-modify-write of the machine-global ~/.gsd/defaults.json is now wrapped in the existing acquireInstallMigrationLock, and its two separate fs.writeFileSync calls are collapsed into one atomicWriteFileSync, gated so a no-op install doesn't rewrite the file.
F19 (the epic's fourth finding) is deliberately excluded - verified independently that issue #2875 (open, epic #2866 phase 6) already incorporates it, and maintainer davesienkowski's 2026-08-11 comment on #1874 itself confirms F19 "is now owned by #2866 phase 6," corroborating the PR body's claim on-platform rather than relying on the PR's own assertion alone.
Classification & gate compliance
RULESET.CONTRIB.CLASSIFY.enhancement=requires approved-enhancement before implementation - issue #1874 carries approved-enhancement + ready-for-agent (not confirmed-bug), so this is the Feature/Enhancement track despite the fix(#1874) title - an "enhancement that fixes latent bugs" pattern (F5/F6/F18 were independently re-verified live by a maintainer on 2026-07-22 and 2026-08-11 before this PR). Classification gate: satisfied.
ADR gate (3B.1): the primitives this PR routes through (atomicWriteFileSync, acquireInstallMigrationLock) are already governed by existing ADRs (docs/adr/0009-shell-command-projection-module.md, docs/adr/0010-file-operation-engine-module.md, and the Installer Migration Module's ADR-0008) - no new architecture is introduced, so no new ADR is required. Satisfied.
Documentation gate (3B.2): all three changesets are type: Fixed, which is exempt from the docs/ update requirement (Added/Changed/Deprecated/Removed require it; Fixed/Security don't). Satisfied, exempt.
PR title fix(#1874): ... matches the enforced type(#issue): summary convention. Linked-issue gate satisfied (Fixes #1874 in body, issue open, correctly labeled).
Functional checklist (against #1874's "Done when")
- writeSettings routes through atomicWriteFileSync (F5)
- the #338 migration aborts-this-file on readSettings()===null (F6)
- ~/.gsd/defaults.json RMW is lock-guarded + atomic (F18)
- each child ships a regression test that reds on a simulated crash/interleave window
- F19 - out of scope for this PR by design, tracked in #2875 (verified above)
Root-cause verdict: each fix's code was read directly (not inferred from the PR description) and matches its stated root cause. readSettings was confirmed to return {} for an absent file and null only for an existing-but-unparseable file (bin/install.js:1392-1408), so the F6 guard's localRaw === null check is semantically correct, not a false-null trap on first-install. acquireInstallMigrationLock's current implementation (src/installer-migrations.cts:528-610) already performs same-process and dead-PID stale-lock reclamation, so wrapping writeNonClaudeDefaults in it does not introduce a fresh deadlock risk for the sequential-per-runtime call pattern in installAllRuntimes.
Blast radius + risk tier
get_impact (upstream, gsd-core): writeSettings - risk MEDIUM, 19 affected symbols across bin/install.js + 3 test files (finishInstall, install, installAllRuntimes, printSummaries, and several test harness functions at depth 2-4). writeNonClaudeDefaults - risk MEDIUM, 17 affected symbols, same shape (finishInstall, install, installAllRuntimes, configureAntigravityPermissions, uninstall at depth 1). Neither writeSettings/writeNonClaudeDefaults/acquireInstallMigrationLock appears in find_central_symbols (top-40, PageRank) or find_bridge_symbols (top-40, Tarjan articulation points) - this PR does not touch an architectural chokepoint. get_function_quality_metrics: writeNonClaudeDefaults complexity=13/cognitive=12/risk_level=medium (the most complex touched function, proportionate to now doing lock+RMW+atomic-write+release in one place); writeSettings complexity=1/risk_level=low.
Co-change completeness
get_cochange_context on bin/install.js surfaces generic historical partners (CONTEXT.md, docs/INVENTORY-MANIFEST.json, docs/INVENTORY.md, eslint.config.mjs, src/runtime-artifact-conversion.cts, several tests/fixtures/install-tree/*.json) - none present in this diff. Assessed as not a completeness gap: this PR changes write-safety mechanics only, not installed-artifact content, and the PR's own claim of "no golden/install fixture shift" is consistent with that (install-tree fixtures capture what gets installed, unaffected by how safely it's written).
Memtrace Evidence
- get_impact - run on writeSettings and writeNonClaudeDefaults (upstream): both risk MEDIUM, 19 and 17 affected symbols respectively; neither is a central or bridge symbol (cross-checked against find_central_symbols/find_bridge_symbols top-40).
- get_symbol_context - run on acquireInstallMigrationLock and writeNonClaudeDefaults; confirmed caller/callee shape (1 caller / 4 callees for the lock function: readLockFile, isPidAlive, clock seam) and cross-checked against the source directly via get_source_window.
- recall_decision - attempted 3 times (queries covering atomic write/lock durability and the readSettings null contract). Unavailable all three times: unavailable=true, reason="MemCortex IPC circuit open: the sidecar failed a fast liveness probe after a previous request timed out". This is an infrastructure outage, not a CannotProve verdict or a clean bill of health - reporting it verbatim rather than asserting "no recorded decision governs this change." why_is_this_here/verify_intent/governing_contracts route through the same sidecar and were not independently re-attempted given the confirmed outage.
- find_code_review_issues - review_mode "online", max_candidates 40, repo_id gsd-core. Result: 0 issues, _graph_state "ready" (not stale), _raw_count 0 across ast/cross_module/online/yaml sources. Corroborated by a second independent pass, review_github_pr (post=false, graphMode=strict, reviewMode=online, minSeverity=low, maxComments=30) - also 0 comments, graphState "ready".
Additional graph-backed checks run: find_yaml_rule_matches (0 issues), get_timeline on writeNonClaudeDefaults (1 version indexed, no repeated-fix pattern - this is a first-time fix, consistent with the maintainer's "no PR ever landed any" note on the issue).
Index freshness note: get_repository_stats returned an anomalous hollow result (total_nodes=1, total_edges=0) inconsistent with list_indexed_repositories' reported 24094 nodes / 61432 edges for the same repo_id. Cross-checked against find_code, find_symbol, get_impact, and get_function_quality_metrics, all of which returned real, correctly-lined, verifiable results matching the actual current file contents (spot-checked by direct get_source_window reads) - treating this as an isolated anomaly in that one tool call rather than a mis-bound store per the workspace-binding guard, since every other graph tool demonstrably resolved against live, accurate data throughout this review.
Findings by severity
Blockers:
- (adversarial pass) File-permission regression on settings.json / settings.local.json / defaults.json. atomicWriteFileSync (src/runtime-hooks-surface.cts lines 193-207) creates its temp sibling via fs.writeFileSync(tmp, data, options) with no mode in options for either new call site in this diff (writeSettings and writeNonClaudeDefaults both pass only the encoding string 'utf8', not an options object carrying mode) - so the temp file gets Node's default mode (0o666 minus umask, typically 0o644). retryRenameSync / atomicRenameWithRetry (src/shell-command-projection.cts lines 882-897, confirmed by direct read) is a bare fs.renameSync wrapped only in Windows-transient-errno retry logic, with no stat/chmod step to carry the target's prior mode onto the replacement. POSIX rename() replaces the destination directory entry with the source inode, so the destination's permission bits after the write are the temp file's default-mode bits, not the target's original bits. The PRE-fix naked fs.writeFileSync(settingsPath, ...) wrote to the EXISTING path directly, and POSIX open() with O_CREAT ignores the mode argument when the target already exists - so the old writer preserved whatever permissions the file already had. This PR's switch from in-place overwrite to temp+rename is the textbook "atomic write silently resets file permissions" gotcha. settings.json/settings.local.json can carry secrets - this PR's own new test fixture in tests/settings-jsonc.test.cjs sets env: { MY_TOKEN: 'keep-me' } as exactly that scenario. A user who hardened permissions on that file (e.g. chmod 600) on a shared/multi-user machine has that protection silently reset to the umask default on the very next install/update run. ~/.gsd/defaults.json (F18) has the same mechanism, lower severity since it is not typically secrets-bearing. No test anywhere in this diff asserts a rewritten file's mode survives the rewrite - the existing byte-identical/round-trip assertions only check content, never fs.statSync(...).mode. This is silent (no error, no log line) and is a regression this PR specifically introduces, not a pre-existing condition. Fix: capture the target's existing mode before the temp write (when the target pre-exists) and either pass it as options.mode to the temp file's fs.writeFileSync, or fs.chmodSync(tmp, existingMode) before the rename.
Majors: none.
Minors:
- (code-review pass) Changeset format deviation - all three .changeset/*.md fragments open with plain text ("Installer: ...") instead of the bold-led format both CONTRIBUTING.md and .changeset/README.md document as canonical (bold summary clause, em-dash, explanation). Not CI-gated (scripts/changeset/parse.cjs doesn't check for it), but an explicit, unambiguous spec deviation. Easy fix: reformat the opening clause of each fragment.
- (code-review pass) PR bundles F5+F6+F18 in one PR against #1874's own "Agent execution guide," which explicitly says to ship one PR per finding ("don't fold everything into one mega-PR"). The PR body's justification cites an off-platform Discord conversation with maintainers, which is not independently verifiable on GitHub (no PR/issue comment corroborates a single-PR blessing specifically - davesienkowski's on-platform comments discuss finding status, not PR granularity). Substantively low-risk on its own (coherent theme, per-finding changesets and test blocks preserved) - flagging for maintainer awareness/confirmation, not as a defect in the code itself.
- (security pass) src/installer-migrations.cts lines 541-552 - acquireInstallMigrationLock creates the lock via exclusive openSync('wx') + closeSync, then writes the JSON payload via a second, non-exclusive fs.writeFileSync(lockPath, ...) by path. Narrow TOCTOU window (CWE-367 pattern) where a co-writer of the same directory could symlink-swap the just-created empty lock file before the second write lands. Pre-existing in unmodified shared infrastructure (this PR only newly calls this function), gated on the attacker already having write access to the same config directory. Recommend writing the payload via the already-open wx file descriptor instead of reopening by path.
- (security pass) src/runtime-hooks-surface.cts lines 193-207 - same atomicWriteFileSync function as the Blocker above also has a deterministic/guessable temp path (target.tmp-pid-n, always .tmp-pid-1 for the first write in a process) opened with default 'w' (no O_EXCL), so on a shared-write-access directory a pre-planted symlink at that path would be followed and the subsequent rename would leave the real target as a dangling symlink to an attacker-chosen file. Pre-existing primitive (already backs Cursor/Codex hooks.json); this PR newly routes two more sensitive files through it. Recommend opening the temp file with wx and retrying under a fresh counter on EEXIST - and while touching this function for the mode-preservation Blocker fix above, address this in the same pass since both fixes touch the same few lines.
- (QA pass) No test pins the "lock genuinely unavailable / timeout" degrade-gracefully path for the new F18 lock-guarded write (distinct from the already-covered same-process/dead-PID reclaim cases). Informational - the function's pre-existing best-effort/non-throwing contract is unchanged by this PR, just not freshly re-asserted for the new lock-guarded branch specifically.
Nits:
- bin/install.js - writeNonClaudeDefaults's new lock-release catch(e) shadows the outer catch(e) from a few lines up. Valid JS, no functional bug, but a rename (e.g. releaseError) would improve readability.
Two independent adversarial passes were run on this diff. The first (inline, this reviewer) returned clean. A second, separately-dispatched adversarial reviewer found the file-permission regression above, which was then independently verified by this reviewer by reading src/runtime-hooks-surface.cts and src/shell-command-projection.cts directly and reasoning through POSIX rename()/open() mode semantics - confirmed real, not a false positive. This is reported as the authoritative adversarial-pass result since it is verified and the first pass simply missed it.
Verdict
Request changes. This is a real Blocker, not a nitpick: the diff's core mechanism (converting settings.json/settings.local.json/defaults.json writes from in-place overwrite to temp+rename) silently drops any non-default file permissions on every write going forward, and settings.json is a file this very PR's own test fixtures acknowledge can carry secrets. The underlying approach (atomic writes via the existing atomicWriteFileSync/acquireInstallMigrationLock primitives) is correct and well-justified for F5/F6/F18's stated root causes - this is a fixable gap in the shared primitive's mode handling, not a reason to abandon the approach.
Required before merge:
- Fix the mode-preservation gap in atomicWriteFileSync (or add explicit mode-capture at the two new call sites) and add a regression test asserting fs.statSync(...).mode survives a rewrite of a pre-existing, non-default-permission file.
- Reformat the three changeset fragments to the documented bold-led format.
- Apply or explicitly decline the two lock/atomic-write hardening recommendations (TOCTOU on lock-payload write, guessable/non-exclusive temp-file path) - the second one is adjacent to the mode-preservation fix and can likely be addressed in the same edit.
- Confirm with a maintainer, in a PR comment, that landing F5+F6+F18 together was intentionally authorized given the epic's own written guidance to ship separately.
CI note: per the zero-tolerance policy and the Blocker found above, queued fork CI runs are NOT released this pass (pending=6, all still action_required) - releasing is gated on a no-Blocker verdict, which this review does not carry.
…-write-durability # Conflicts: # tests/install-regressions.test.cjs
…ively in atomicWriteFileSync
|
All review items addressed. The branch is also merged up to current 1. Blocker — mode preservation: fixed in the shared primitive. 2. Changeset fragments: reformatted. All three now use the documented bold-led format, and a fourth 3a. Temp-path exclusivity (minor 4): applied. Temp files now open with 3b. Lock-payload TOCTOU (minor 3): applied. Minor 5 (lock-timeout degrade-path test): declined for this PR. Per the review's own note the best-effort non-throwing contract is unchanged here. Nit: fixed — the shadowed lock-release 4. Bundling confirmation. @davesienkowski — could you confirm on-platform whether landing F5+F6+F18 as a single PR is acceptable, given the epic's one-PR-per-finding guidance? The prior blessing was on Discord, which the review correctly flags as unverifiable here. Checks: |
trek-e
left a comment
There was a problem hiding this comment.
Summary
The prior round's findings are genuinely resolved — I checked each one rather than taking the blanket "all review items addressed" at face value. The mode-preservation Blocker, the changeset format, the lock TOCTOU, the wx temp path, and the shadowed catch binding are all really fixed, and Minor 5 was explicitly declined rather than silently dropped. The lock rewrite in installer-migrations.cts is correct: writing through the exclusively-created descriptor closes the symlink-swap window, and closing before return preserves the Windows unlink requirement.
One blocker remains, and it is the same class as last round's mode finding — a property of the pre-existing file that temp+rename does not carry across.
Classification & gate compliance
- Linked issue #1874, state
OPEN, labelsenhancement, approved-enhancement, area: installer, ready-for-agent, good first epic→ Feature/Enhancement Track. RULESET.CONTRIB.CLASSIFY.enhancement=requires approved-enhancement before implementation— satisfied.META.RULE.canonical-source-precedence=CONTRIBUTING.md > docs/adr/* > CONTEXT.md > agent memory.- Docs gate PASSES. All four changesets are
FixedorSecurity, all carrypr: 3385, and all four are correctly bold-led perCONTRIBUTING.md:207.Fixed/Securityare exempt from the docs-required rule, so the absence of adocs/change is correct — recording this explicitly so it is not raised as a finding. - Four fragments on one PR is defensible here: they are four facets of one epic (#1874 install-path write durability), and CLAUDE.md's defect rule overrides
RULESET.PR-SCOPE.one-concern-per-prfor defects found inline. mergeable=MERGEABLE,mergeStateStatus=BEHIND(83 commits). No conflicts.- CI has never substantively run on this head. Rollup shows green, but the head-SHA-scoped
action_requiredquery returnspending=7—Tests,Mutation Testing,Install Smoke,Docs Required,Changeset Required,Default Flip Documentation,Validate Branch Nameare all queued awaiting maintainer approval. The green rollup reflects 7 lightweight checks, not the matrix. See the CI note at the end. - Competing PRs for #1874: none.
Blast radius & risk tier
get_impact(target=atomicWriteFileSync, direction=upstream, depth=4) → risk MEDIUM, total_affected=14, graph_incomplete=false, spanning three depth levels:
- depth 1:
ensureCodexHooksJsonSessionStart,ensureCodexHooksJsonEvent,reconcileCodexHooksJsonEvent,reconcileCursorHooksJson,reconcileWindsurfHooksJson,writeKimiHooksToml,removeKimiHooksToml - depth 2:
writeCursorHooksJson,writeWindsurfHooksJson,removeCursorHooksJson,removeWindsurfHooksJson,reconcileCodexHooksJsonSessionStart,removeCodexHooksJsonEvent,removeWindsurfHooksJson - depth 3:
removeCodexHooksJsonSessionStart
This materially widens Blocker 1: the symlink defect is not confined to settings.json / defaults.json. Every Cursor, Windsurf, Codex, and Kimi hooks-file write goes through this helper.
Memtrace Evidence
get_impact— targetatomicWriteFileSync,direction=upstream, depth 4. Risk MEDIUM, 14 affected symbols,graph_incomplete=false. Finding: enumerated above — the blast radius is what turns Blocker 1 from asettings.jsonbug into a bug affecting the hooks files of four runtimes.get_symbol_context— targetatomicWriteFileSync(src/runtime-hooks-surface.cts:195), communityruntime-hooks-surface. Finding: the same file, ~40 lines below the helper, already documents this exact bug class from #2544 — "fs.existsSyncFOLLOWS symlinks …classifyMarkeruseslstat+isFile(), so a symlink or a directory at the marker path is classifiedforeignand left strictly alone." The repo learned this lesson and codified it in this file; the new atomic-write helper directly above does not apply it.recall_decision— query "atomic write temp rename settings.json durability fsync symlink preserve permissions installer".fact_status: "statistically_ranked". Top-ranked hit is directly governing: "If rename ultimately fails, surface the error instead of a destructive in-place write." Also retrieved: "platformWriteSync retries transient rename locks instead of truncating readers (#1541)". Finding: there is recorded intent around this rename path, and it is about not destroying user state on the failure path — which is the principle Blocker 1 violates on the success path.find_code_review_issues—review_mode=online,graph_mode=strict,max_candidates=60, run against thesrc/*.ctshunks._raw_count=0,_filtered_count=0,_graph_state=**ready**,_source_counts={ast:0,cross_module:0,yaml:0,online:0}. A genuine clean result — and a useful illustration of the detectors' limits: symlink-vs-rename semantics, a missingfsync, and a set-insertion ordering bug are all invisible to AST and YAML rules. Every finding below is a judgment/graph finding, verified by execution. Do not read this zero as corroboration.
Blocker 1 — temp+rename silently converts a symlinked config file into a regular file
src/runtime-hooks-surface.cts:195-241
const st = fs.statSync(target); // FOLLOWS the symlink → target's mode
if (st.isFile()) priorMode = st.mode & 0o7777;
...
shellCmdProjection.retryRenameSync(tmp, target); // REPLACES the symlink itselfstatSync follows the link, so priorMode is read from the link target. rename(2) does not follow the final path component, so it replaces the symlink with a regular file. There is no lstat, no realpath, and no isSymbolicLink() check anywhere in the helper; assertDestWithinConfigHome is purely lexical and does not resolve links.
Reproduced directly:
after rename, home/settings.json still a symlink? NO -- LINK DESTROYED
target real/settings.json still contains: {"orig":true}
home/settings.json now contains: {"new":true}
The pre-change in-place writeFileSync(target, …) followed the link and updated the target. So this is a behavioral regression introduced by the atomicity work.
Who this breaks: anyone who manages ~/.claude/settings.json, ~/.gsd/defaults.json, or a Cursor/Windsurf/Codex/Kimi hooks file out of a dotfiles repo via symlink — a very common setup. After one install their dotfiles copy silently stops receiving updates and stops being the source of truth, with no error and no message. That is the same shape as last round's mode finding: a property of the user's existing file that temp+rename does not carry across.
The fix pattern is already in this file (classifyMarker's lstat + isFile(), #2544). Resolve the target with fs.realpathSync and rename onto the resolved path, or lstat and fall back to an in-place write when the target is a symlink — and either way add a regression test with a symlinked target.
Major
- M1 — secrets window.
exclusiveOptions(:207-210) carries nomode, so the temp file is created at the umask default (typically0644) and the full payload — including anything secret-bearing insettings.json— sits world-readable at a predictable path until thechmodat:230. Passmode: priorMode ?? 0o600in the create options rather than chmod-after-write;open(2)masking by umask is why the comment prefers chmod, but creating restrictive-then-widening is the safe order, not the reverse. - M2 — no
fsync, while two changesets claim crash-truncation immunity.plucky-birds-hum.mdsays "a crash mid-write can no longer truncate the file" andgallant-mice-snooze.mdsays "a crash mid-write can no longer truncate this machine-global file".temp+renamealone gives atomicity, not durability: after a crash the rename can be visible while the data is not, yielding an empty or short file — precisely the truncation the fragments promise is gone. The repo already has the recipe in three places (fsyncContainingDiratsrc/capability-ledger.cts:527andsrc/capability-consent.cts:595,fsyncDiratsrc/capability-lifecycle.cts:258). Either fsync the file and the parent directory, or soften both changeset claims to what temp+rename actually delivers. - M3 — the new 5-attempt EEXIST cap has no boundary coverage.
if (attempt < 4) continue;is a limit, andRULESET.TESTS.boundary-coverage=tests MUST exercise inputs at and near the threshold/limit … N ∈ {limit-1, limit, limit+1}applies. The one test never assertsplanted.length, so it passes identically with a cap of 1 or with an unbounded loop. Add limit-1 / limit / limit+1 and assert the number of planted files actually consumed. - M4 —
__atomicWrittenTmps.add(tmp)precedes the exclusive write (:213-215). OnEEXISTthe code comments "The file at tmp is not ours — never rmSync it" and correctly skips thermSync— but the path was already registered as installer-owned one line earlier, so_cleanTmpFilesunlinks the foreign file anyway (bin/install.js:11928,:10589). The refusal is defeated by the bookkeeping. Move theaddto after the write succeeds. - M5 — dropped
|| {}atbin/install.js:12404. Asettings.local.jsonwhose parsed value is0,false, or""now reaches:12420and throws aTypeError(reproduced; the file is non-strict). This is a regression against the very null-contract robustnessnimble-cranes-rest.mdclaims to deliver. - M6 — PR body inaccuracies. The body still claims off-platform Discord authorization — item 1 of the prior review's explicit "Two things needed before this merges", covered only by the blanket "All review items addressed". It also still says "three
type: Fixedfragments" when four now ship (threeFixed+ oneSecurity). Please correct both; the authorization item in particular is not something a blanket statement can close.
Verdict
Changes requested. Blocking: the symlink conversion. M1–M6 also gate approval under the zero-tolerance policy, but M1/M4/M5 are each a few lines, and M2 is a choice between adding fsync or softening two sentences.
Credit where it is due — the lock-descriptor rewrite is exactly right, the mode-preservation carry is right in intent, and the wx retry is a real improvement over the previous unguarded temp path. The remaining blocker is the third instance of one pattern: temp+rename replaces an inode, so every property of the file that lived on the old inode or on the path must be explicitly carried across. Mode was the first, symlink-ness is the second, and durability (M2) is the third. Worth handling as one idea rather than three patches.
CI
Per the review policy I am not releasing the 7 queued workflow runs while a blocker stands — a full fork matrix spent confirming a known-wrong diff buries the finding in CI noise. Push the symlink fix and I will release them on the next pass, so you get a real signal to push against. Note that until then this PR is not merge-eligible regardless of the green rollup: Tests, Mutation Testing, and Install Smoke have never executed on this head.
Rebase too — 83 commits behind origin/next.
Fix PR
Linked Issue
Fixes #1874
What was broken
settings.jsoncaused the loss of all the user's hooks, permissions, and settings.settings.local.jsonfile and destroyed the user's settings.TypeError.~/.gsd/defaults.json.What this fix does
~/.gsd/defaults.jsonand writes it one time with an atomic write.Every fix routes an unsafe write through a primitive that already exists in this repo (
atomicWriteFileSync,acquireInstallMigrationLock) — no new primitives, per the epic's design.Root cause
F5 — naked
fs.writeFileSyncin the sole settings writer for six runtimeswriteSettings()is the sole writer ofsettings.json/settings.local.jsonfor claude, gemini, antigravity, qwen, hermes, and codebuddy, and wrote with no temp file and no rename. Hosts discard the entire settings file on any parse failure (the installer's own comment documents this), so a crash,ENOSPC, or kill mid-write did not merely lose GSD's entries — it silently cost the user every hook, permission, env var, and statusline they had. The fix routes through theatomicWriteFileSyncalready bound inbin/install.jsand already used for Codex/Cursorhooks.json.writeSettingsis now exported, following the#1191precedent onreadSettings.F6 — the #338 local-merge leg coerced the "do not touch" signal to
{}readSettings()returnsnullonly for an unparseable file — its documented "warn and preserve; do not touch" signal. The migration's local-merge leg threw that away withreadSettings(settingsPath) || {}, mutated the result, and wrote it back, so one stray comma insettings.local.jsonlost all its non-GSD content on the next install. Scoped to the local-merge leg per the maintainers' re-validation; the sharedsettings.jsonread was already null-guarded.The guard stands the whole migration down, not just the local write: skipping the merge while still executing the "remove GSD entries from shared settings.json" step below would destroy those entries instead of relocating them. Leaving both files untouched lets the migration retry once the user repairs the file.
Aborting reaches a path the clobber had been hiding, exposing two pre-existing crashes (repro verified against unmodified
next): the unparseable-settings guard ended in a barereturn;while every sibling early exit returns the full result shape, soinstallAllRuntimes' statusline lookup threw aTypeError; and its call site guardedprimaryStatuslineResultbut not.settings, which isnullon every early exit. Both are fixed here — landing F6 alone would trade silent data loss for a hard crash.F18 — unlocked, non-atomic, double-write RMW of a machine-global file
Every non-Claude install read-modify-writes
~/.gsd/defaults.json— read by every runtime and project on the box — with no lock and two separate naked whole-file writes (the second added by#2900, widening the window the audit originally cited). Concurrent installs lost each other's key (last write wins); a crash in either window truncated the JSON, which the read path swallows (catch { /* new file */ }), silently degrading model resolution box-wide.The fix takes the existing
acquireInstallMigrationLockaround the whole read-modify-write, collapses the two writes into one after both mutations, and routes it throughatomicWriteFileSync. An install that changes nothing no longer rewrites the file. The block's five encoded prior behaviors are preserved unchanged:#1569(explicittrueopt-in and existing"omit"kept),#1156(non-canonical values default to"omit"),#2395(pre-existingruntimekept; same-runtime reinstall still a no-op, keeping its mtime-based idempotence test green),#1657(malformed non-object recovery),#2834(still called beforeinstallCodexConfig).Testing
How I verified the fix
Each finding carries a regression test that was verified RED against the unfixed code:
tests/settings-jsonc.test.cjs) — the crash window is simulated faithfully: the mock writes the bytes that would have landed before the failure, then throws (a mock that merely threw would pass against a non-atomic writer). RED result pre-fix: the file was left as the 12-byte fragment{\n "hooks":. Three cases: prior file byte-identical after a failed write; no.tmp-*residue; format-contract round-trip through the realreadSettings.tests/install-regressions.test.cjs) — the clobber (malformed local + GSD entries in shared → local file byte-identical, and shared GSD entries still present, which is what catches a half-fix) and the crash (malformed local, no migration → exit 0, noTypeError, file intact). Subprocess spawns go through therunNodeprocess seam.tests/install-regressions.test.cjs, driving the now-exportedwriteNonClaudeDefaults) — exactly one write per clean install (was 2, counting the atomic temp sibling); the lock is held during the write and released after; crash window leaves the prior file byte-identical and parseable with no temp residue; a no-op install does not rewrite the file. RED result pre-fix: 2 writes, no lock, file truncated to{\n "model.Fault injection is by
fs-method override restored per-test — notchmod, since root bypasses mode bits and a permission-based test passes vacuously in root CI (per the repo's cross-platform IO-injection rule).Two test-harness adaptations, called out for review:
withWriteFailureintests/installer-migration-install.integration.test.cjsnow also matches the<target>.tmp-sibling (matching only the final path would make the injection silently stop firing for an atomic writer, turning a rollback assertion vacuous — the rollback behavior itself is unchanged); and the#2834structural test intests/codex-config.test.cjsnow bounds its source slice at the next top-levelfunctioninstead of a fixed 1200-character window (no assertion weakened — the same three tokens are required over a correctly-bounded slice).Suites run on this branch:
tests/install-regressions.test.cjs50/50;settings-jsonc+installer-migration-install.integration+codex-config503/503; fullnpm run test:installgreen with no golden/install fixture shift (the epic predicted one for this writer; none occurred);npm run lint:cigreen.Regression test added?
Platforms tested
Runtimes tested
writeSettingsis the shared writer for all six settings-file runtimes andwriteNonClaudeDefaultsis exercised directly; the full install suite covers the per-runtime adaptersChecklist
Fixes #NNN— PR will be auto-closed if missingconfirmed-buglabel — carried at epic level:#1874isapproved-enhancement/ready-for-agent, and each finding was independently re-verified by maintainers (2026-07-22 and 2026-08-11 sweeps) after the originalconfirmed-bugchildren were consolidated into itopen-gsd/gsd-core#2875)npm testinstall suite + touched suites +lint:ci).changeset/fragment added if this is a user-facing fix — threetype: Fixedfragments, one per findingBreaking changes
None. On success every write is byte-for-byte identical to before (the defaults.json double-write excepted, which collapses two identical-content writes into one). The observable deltas are all in failure windows: truncation → previous file preserved; malformed local settings → preserved and skipped instead of clobbered or crashed; concurrent installs → serialized instead of last-write-wins.
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.