fix(#1875): route installer settings/defaults writes through atomic, lock-guarded primitives (#3966)
* fix(#1875): route writeSettings through atomicWriteFileSync 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/gsd-core#1874 (F5) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(#1875): add changeset fragment Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(#1876): honor the readSettings null contract in the #338 local-merge leg readSettings returns null only for an unparseable file — its documented "preserve existing, don't touch" signal. The #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/gsd-core#1874 (F6) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(#1876): add changeset fragment Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(#1877): lock and atomically write the machine-global ~/.gsd/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 (#1569) and an existing "omit" are preserved, non-canonical values still default to "omit" (#1156), a pre-existing runtime string is preserved (#2395), the malformed-non-object recovery (#1657) stands, and both console lines still print when both keys change. The #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/gsd-core#1874 (F18) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(#1877): add changeset fragment Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(#1874): preserve target mode and create temp files exclusively in atomicWriteFileSync * fix(#1874): write the migration-lock payload through the exclusive descriptor * chore(#1874): bold-led changeset fragments + fragment for the hardening pass * chore(#1874): rename the shadowed lock-release catch binding * test(#1874): fix source-grep/try-finally violations, add missing fault-injection cases - drop the /TypeError/.test(stderr) source-grep assertion (exitCode already proves the installer didn't crash) - convert inline try/finally fs-mock restoration to t.after() across the F5/F18 test suites, per this repo's no-try/finally-in-test-body rule - add a rename-failure fault-injection case for atomicWriteFileSync - add a read-only-.gsd-directory fault-injection case for the F18 lock+write path - extract MAX_TEMP_FILE_ATTEMPTS constant, dedupe the partial-write-then-throw mock into a shared tests/helpers.cjs helper Found during this session's own Standards-axis code-review pass on resurrected PR #3385. * chore(#1874): reset changeset fragments to pr:0 placeholder The resurrected fragments carried the closed PR's number (3385). This is a new PR, so reset to the pr:0 placeholder and backfill the real number once gh pr create returns it, per CONTRIBUTING.md's PR Number Handling. * chore(#1874): backfill changeset PR number (#3966) --------- Co-authored-by: Richard Spiers <1355479+richardspiers@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: sim <sim@local>
This commit is contained in:
@@ -776,6 +776,7 @@ function _runtimeAdapter(runtime) {
|
||||
}
|
||||
}
|
||||
const {
|
||||
acquireInstallMigrationLock,
|
||||
applyInstallerMigrationPlan,
|
||||
discoverInstallerMigrations,
|
||||
MANIFEST_SCHEMA_VERSION,
|
||||
@@ -1483,10 +1484,18 @@ function readSettings(settingsPath) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Write settings.json with proper formatting
|
||||
* Write settings.json with proper formatting.
|
||||
*
|
||||
* Atomic (temp+rename) because hosts discard the ENTIRE settings file on any
|
||||
* parse failure, so a truncated write costs the user every hook, permission,
|
||||
* and statusline they have — not just GSD's entries. This is the sole writer
|
||||
* of that surface for six runtimes.
|
||||
*
|
||||
* `atomicWriteFileSync` is declared further down this file; it is dereferenced
|
||||
* at call time, after module evaluation, so the ordering is safe.
|
||||
*/
|
||||
function writeSettings(settingsPath, settings) {
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
||||
atomicWriteFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
|
||||
}
|
||||
|
||||
// #2875 Part 2 (J8): model-override resolution (readGsdGlobalModelOverrides /
|
||||
@@ -6921,29 +6930,50 @@ function writeNonClaudeDefaults(runtime) {
|
||||
if (_hostBehaviors(runtime).nativeModelAliases || process.env.GSD_TEST_MODE) return;
|
||||
const gsdDir = path.join(os.homedir(), '.gsd');
|
||||
const defaultsPath = path.join(gsdDir, 'defaults.json');
|
||||
let releaseLock = null;
|
||||
try {
|
||||
fs.mkdirSync(gsdDir, { recursive: true });
|
||||
// defaults.json is machine-global — every runtime and project on the box
|
||||
// reads it. Serialize the read-modify-write so two concurrent installs
|
||||
// cannot lose each other's key, and apply both mutations in ONE atomic
|
||||
// write so a crash cannot leave the file truncated (the read path swallows
|
||||
// parse errors and treats a corrupt file as absent, which would silently
|
||||
// degrade model resolution everywhere until repaired by hand).
|
||||
releaseLock = acquireInstallMigrationLock(gsdDir);
|
||||
let defaults = {};
|
||||
try { defaults = JSON.parse(fs.readFileSync(defaultsPath, 'utf8')); } catch { /* new file */ }
|
||||
if (defaults === null || typeof defaults !== 'object' || Array.isArray(defaults)) {
|
||||
defaults = {};
|
||||
}
|
||||
const applied = [];
|
||||
// Three-valued domain: false/absent → aliases; true → full IDs; "omit" → ''.
|
||||
const existing = defaults.resolve_model_ids;
|
||||
const shouldDefaultToOmit = existing !== true && existing !== 'omit';
|
||||
if (shouldDefaultToOmit) {
|
||||
defaults.resolve_model_ids = 'omit';
|
||||
fs.writeFileSync(defaultsPath, JSON.stringify(defaults, null, 2) + '\n');
|
||||
console.log(` ${green}✓${reset} Set resolve_model_ids: "omit" in ~/.gsd/defaults.json`);
|
||||
applied.push(`Set resolve_model_ids: "omit" in ~/.gsd/defaults.json`);
|
||||
}
|
||||
// #2395: persist runtime for non-Claude runtimes.
|
||||
if (defaults.runtime === undefined || defaults.runtime === null || defaults.runtime === '') {
|
||||
defaults.runtime = runtime;
|
||||
fs.writeFileSync(defaultsPath, JSON.stringify(defaults, null, 2) + '\n');
|
||||
console.log(` ${green}✓${reset} Set runtime: "${runtime}" in ~/.gsd/defaults.json`);
|
||||
applied.push(`Set runtime: "${runtime}" in ~/.gsd/defaults.json`);
|
||||
}
|
||||
if (applied.length > 0) {
|
||||
atomicWriteFileSync(defaultsPath, JSON.stringify(defaults, null, 2) + '\n', 'utf8');
|
||||
for (const message of applied) console.log(` ${green}✓${reset} ${message}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(` ${yellow}⚠${reset} Could not write ~/.gsd/defaults.json: ${e.message}`);
|
||||
} finally {
|
||||
if (releaseLock) {
|
||||
try {
|
||||
releaseLock();
|
||||
} catch (releaseError) {
|
||||
// A leaked lock blocks the next install, so surface it rather than
|
||||
// swallowing; the stale-lock reaper clears it once this pid exits.
|
||||
console.log(` ${yellow}⚠${reset} Could not release the ~/.gsd install lock: ${releaseError.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12484,9 +12514,19 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
|
||||
);
|
||||
const hasGsdStatusline = sharedRaw.statusLine && sharedRaw.statusLine.command &&
|
||||
isManagedHookCommand(sharedRaw.statusLine.command, { surface: 'settings-json' });
|
||||
if (hasGsdHooks || hasGsdStatusline) {
|
||||
const needsMigration = hasGsdHooks || hasGsdStatusline;
|
||||
// readSettings returns null ONLY for an unparseable file — its documented
|
||||
// "preserve existing, don't touch" signal. Stand the WHOLE migration down
|
||||
// in that case: skipping just the local merge while still stripping the
|
||||
// shared file below would destroy the GSD entries outright instead of
|
||||
// relocating them. Leaving both files untouched lets the migration retry
|
||||
// once the user repairs the local file.
|
||||
const localRaw = needsMigration ? readSettings(settingsPath) : null;
|
||||
if (needsMigration && localRaw === null) {
|
||||
console.log(' ' + yellow + 'i' + reset + ' Skipping #338 migration — ' + settingsFileName +
|
||||
' could not be parsed. Your existing settings are preserved.');
|
||||
} else if (needsMigration) {
|
||||
// Merge GSD entries into settings.local.json
|
||||
const localRaw = readSettings(settingsPath) || {};
|
||||
if (hasGsdStatusline && !localRaw.statusLine) {
|
||||
localRaw.statusLine = sharedRaw.statusLine;
|
||||
}
|
||||
@@ -12546,7 +12586,10 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
|
||||
if (rawSettings === null) {
|
||||
console.log(' ' + yellow + 'i' + reset + ' Skipping settings.local.json configuration — file could not be parsed (comments or malformed JSON). Your existing settings are preserved.');
|
||||
persistActiveProfileMarker();
|
||||
return;
|
||||
// Callers index this result by `runtime` (installAllRuntimes' statusline
|
||||
// lookup), so every early exit must return the full shape — a bare return
|
||||
// crashes the install rather than skipping one file.
|
||||
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
|
||||
}
|
||||
const settings = validateHookFields(cleanupOrphanedHooks(rawSettings));
|
||||
// #3002 CR / #3662: rewrite legacy `node .../gsd-*.js` command strings (pre-
|
||||
@@ -13724,7 +13767,10 @@ function installAllRuntimes(runtimes, isGlobal, isInteractive) {
|
||||
});
|
||||
};
|
||||
|
||||
if (primaryStatuslineResult) {
|
||||
// `settings` is null on every early exit (unparseable file, skipped runtime),
|
||||
// and handleStatusline dereferences it — an install that declined to touch a
|
||||
// settings file has no statusline to prompt about, so fall through.
|
||||
if (primaryStatuslineResult && primaryStatuslineResult.settings) {
|
||||
handleStatusline(primaryStatuslineResult.settings, isInteractive, continueAfterStatusline);
|
||||
} else if (canInstallBanner) {
|
||||
// No statusline-capable runtime, but at least one runtime can host the
|
||||
@@ -13882,6 +13928,8 @@ module.exports = {
|
||||
cleanupLegacyGsdCc,
|
||||
// #1191 — exported so tests exercise the REAL readSettings, not a replica
|
||||
readSettings,
|
||||
writeSettings,
|
||||
writeNonClaudeDefaults,
|
||||
stripJsonComments,
|
||||
copyWithPathReplacement,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user