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:
5
.changeset/gallant-mice-snooze.md
Normal file
5
.changeset/gallant-mice-snooze.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 3966
|
||||
---
|
||||
**~/.gsd/defaults.json is written under the install-migration lock and in a single atomic write** — concurrent installs for different runtimes can no longer lose each other's settings, and a crash mid-write can no longer truncate this machine-global file (which the read path treats as absent, silently degrading model resolution for every project on the machine). An install that changes nothing no longer rewrites the file.
|
||||
5
.changeset/mindful-otters-guard.md
Normal file
5
.changeset/mindful-otters-guard.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Security
|
||||
pr: 3966
|
||||
---
|
||||
**Atomic config writes preserve hardened file permissions and create temp files exclusively** — a chmod 600 on settings.json, settings.local.json, or defaults.json now survives the temp+rename write instead of silently resetting to the umask default; temp files are opened with O_EXCL so a symlink pre-planted at the predictable temp path is never followed; and the install-migration lock writes its payload through the exclusively-created descriptor, closing a symlink-swap window between create and write.
|
||||
5
.changeset/nimble-cranes-rest.md
Normal file
5
.changeset/nimble-cranes-rest.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 3966
|
||||
---
|
||||
**A malformed settings.local.json survives the #338 migration** — readSettings()'s null "could not parse, preserve existing" signal is now honored and the whole migration stands down, so the shared GSD entries are not stripped either. Also fixes two crashes on that path: an unparseable settings file previously aborted the install with a TypeError instead of skipping the file.
|
||||
5
.changeset/plucky-birds-hum.md
Normal file
5
.changeset/plucky-birds-hum.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 3966
|
||||
---
|
||||
**The installer writes settings.json and settings.local.json atomically (temp+rename)** — a crash mid-write can no longer truncate the file. Hosts discard the entire settings file on a parse failure, so a truncated write previously cost users every hook, permission, env var, and statusline they had — not just GSD's.
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -586,17 +586,20 @@ function acquireInstallMigrationLock(
|
||||
let lockCreatedByUs = false;
|
||||
try {
|
||||
fd = fs.openSync(lockPath, 'wx');
|
||||
// Close the open descriptor before writing so the file handle is
|
||||
// released on Windows before the release closure unlinks it.
|
||||
// Write payload via writeFileSync with the path (not the fd) so we
|
||||
// don't hold an open fd across the lifetime of the lock.
|
||||
fs.closeSync(fd);
|
||||
fd = null;
|
||||
lockCreatedByUs = true; // we own the file; clean it up on any subsequent error
|
||||
fs.writeFileSync(lockPath, JSON.stringify({
|
||||
// Write the payload through the exclusively-created descriptor: a
|
||||
// second open-by-path here would be a TOCTOU window (CWE-367) where a
|
||||
// co-writer of the directory could symlink-swap the just-created empty
|
||||
// lock file before the payload lands.
|
||||
fs.writeFileSync(fd, JSON.stringify({
|
||||
pid: process.pid,
|
||||
acquiredAt: new Date().toISOString(),
|
||||
}) + '\n');
|
||||
// Close before returning so no handle stays open across the lock's
|
||||
// lifetime — Windows cannot unlink a file with an open handle when the
|
||||
// release closure runs.
|
||||
fs.closeSync(fd);
|
||||
fd = null;
|
||||
lockCreatedByUs = false; // release closure owns cleanup from here
|
||||
return () => {
|
||||
const failures: Error[] = [];
|
||||
|
||||
@@ -194,20 +194,56 @@ function _capabilityTitle(runtime: string): string {
|
||||
let __atomicWriteCounter = 0;
|
||||
// Set<string> — absolute paths of .tmp-<pid>-<n> files this process created.
|
||||
const __atomicWrittenTmps: Set<string> = new Set();
|
||||
// Retry budget for the EEXIST (squatted temp path) branch in atomicWriteFileSync.
|
||||
const MAX_TEMP_FILE_ATTEMPTS = 4;
|
||||
|
||||
function atomicWriteFileSync(target: string, data: string, options: fs.WriteFileOptions): void {
|
||||
__atomicWriteCounter += 1;
|
||||
const tmp = `${target}.tmp-${process.pid}-${__atomicWriteCounter}`;
|
||||
__atomicWrittenTmps.add(tmp);
|
||||
// A pre-existing target's permission bits must survive the rewrite:
|
||||
// rename() swaps the temp file's inode into place, so without an explicit
|
||||
// carry a user-hardened chmod (e.g. 600 on a secrets-bearing settings.json)
|
||||
// would silently reset to the umask default.
|
||||
let priorMode: number | undefined;
|
||||
try {
|
||||
fs.writeFileSync(tmp, data, options);
|
||||
shellCmdProjection.retryRenameSync(tmp, target);
|
||||
// Successful rename: the tmp path no longer exists, but leave it in the
|
||||
// Set so _cleanTmpFiles can recognise it as installer-owned if it somehow
|
||||
// lingers (e.g. a rename succeeded but left a stale entry on some FS).
|
||||
} catch (e) {
|
||||
try { fs.rmSync(tmp, { force: true }); } catch { /* ignore */ }
|
||||
throw e;
|
||||
const st = fs.statSync(target);
|
||||
if (st.isFile()) priorMode = st.mode & 0o7777;
|
||||
} catch { /* no pre-existing target: default creation mode applies */ }
|
||||
|
||||
// 'wx' (O_EXCL) refuses to follow a symlink pre-planted at the predictable
|
||||
// temp path and refuses to reuse a foreign file already sitting there; on
|
||||
// EEXIST the write retries under a fresh counter value.
|
||||
const exclusiveOptions: fs.WriteFileOptions =
|
||||
typeof options === 'string' || options == null
|
||||
? { encoding: options ?? null, flag: 'wx' }
|
||||
: { ...options, flag: 'wx' };
|
||||
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
__atomicWriteCounter += 1;
|
||||
const tmp = `${target}.tmp-${process.pid}-${__atomicWriteCounter}`;
|
||||
__atomicWrittenTmps.add(tmp);
|
||||
try {
|
||||
fs.writeFileSync(tmp, data, exclusiveOptions);
|
||||
} catch (e) {
|
||||
if ((e as NodeJS.ErrnoException).code === 'EEXIST') {
|
||||
// The file at tmp is not ours — never rmSync it.
|
||||
if (attempt < MAX_TEMP_FILE_ATTEMPTS) continue;
|
||||
throw e;
|
||||
}
|
||||
try { fs.rmSync(tmp, { force: true }); } catch { /* ignore */ }
|
||||
throw e;
|
||||
}
|
||||
try {
|
||||
// chmod rather than options.mode: open(2) masks mode with the process
|
||||
// umask, chmod applies the preserved bits exactly.
|
||||
if (priorMode !== undefined) fs.chmodSync(tmp, priorMode);
|
||||
shellCmdProjection.retryRenameSync(tmp, target);
|
||||
// Successful rename: the tmp path no longer exists, but leave it in the
|
||||
// Set so _cleanTmpFiles can recognise it as installer-owned if it somehow
|
||||
// lingers (e.g. a rename succeeded but left a stale entry on some FS).
|
||||
} catch (e) {
|
||||
try { fs.rmSync(tmp, { force: true }); } catch { /* ignore */ }
|
||||
throw e;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
84
tests/atomic-write-rename-failure.test.cjs
Normal file
84
tests/atomic-write-rename-failure.test.cjs
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* #1874 F5 hardening — atomicWriteFileSync (src/runtime-hooks-surface.cts)
|
||||
* rename-failure fault injection.
|
||||
*
|
||||
* The temp-file + rename primitive documents a specific contract for a
|
||||
* failure mid-swap (see the catch block wrapping shellCmdProjection.
|
||||
* retryRenameSync in atomicWriteFileSync): the temp file is force-removed
|
||||
* and the original error is rethrown, un-transformed. This exercises that
|
||||
* contract directly rather than through an install() integration path, and
|
||||
* asserts the pre-existing target is left byte-identical.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
const { atomicWriteFileSync } = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs');
|
||||
|
||||
describe('#1874 F5: atomicWriteFileSync rename-failure fault injection', () => {
|
||||
test('a renameSync failure mid-swap removes the temp file, leaves an existing target untouched, and rethrows', (t) => {
|
||||
const dir = createTempDir('gsd-1874-f5-rename-fail-');
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
const target = path.join(dir, 'hooks.json');
|
||||
const prior = '{"version":1,"hooks":{}}\n';
|
||||
fs.writeFileSync(target, prior);
|
||||
|
||||
const origRenameSync = fs.renameSync;
|
||||
fs.renameSync = (src, dst) => {
|
||||
if (dst === target) {
|
||||
// A non-retryable code (see RENAME_RETRY_ERRNOS in
|
||||
// shell-command-projection.cts) so the failure is immediate, not
|
||||
// masked behind retryRenameSync's bounded Windows-lock retry loop.
|
||||
throw Object.assign(new Error('simulated rename failure mid-swap'), { code: 'ENOSPC' });
|
||||
}
|
||||
return origRenameSync(src, dst);
|
||||
};
|
||||
t.after(() => { fs.renameSync = origRenameSync; });
|
||||
|
||||
assert.throws(
|
||||
() => atomicWriteFileSync(target, '{"version":1,"hooks":{"new":true}}\n', 'utf8'),
|
||||
(e) => e.code === 'ENOSPC' && /simulated rename failure mid-swap/.test(e.message),
|
||||
'the rename error must propagate un-transformed'
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
fs.readFileSync(target, 'utf8'),
|
||||
prior,
|
||||
'the pre-existing target must be left byte-identical when rename fails'
|
||||
);
|
||||
|
||||
const residue = fs.readdirSync(dir).filter((n) => n !== 'hooks.json');
|
||||
assert.deepStrictEqual(residue, [], 'no atomic temp file may survive a failed rename');
|
||||
});
|
||||
|
||||
test('a renameSync failure when no target pre-exists leaves no target and no temp residue', (t) => {
|
||||
const dir = createTempDir('gsd-1874-f5-rename-fail-new-');
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
const target = path.join(dir, 'hooks.json');
|
||||
|
||||
const origRenameSync = fs.renameSync;
|
||||
fs.renameSync = (src, dst) => {
|
||||
if (dst === target) {
|
||||
throw Object.assign(new Error('simulated rename failure, no prior target'), { code: 'ENOSPC' });
|
||||
}
|
||||
return origRenameSync(src, dst);
|
||||
};
|
||||
t.after(() => { fs.renameSync = origRenameSync; });
|
||||
|
||||
assert.throws(
|
||||
() => atomicWriteFileSync(target, '{"version":1,"hooks":{}}\n', 'utf8'),
|
||||
(e) => e.code === 'ENOSPC',
|
||||
'the rename error must propagate un-transformed'
|
||||
);
|
||||
|
||||
assert.strictEqual(fs.existsSync(target), false, 'no target file may be created when rename fails');
|
||||
assert.deepStrictEqual(fs.readdirSync(dir), [], 'no atomic temp file may survive a failed rename');
|
||||
});
|
||||
});
|
||||
@@ -3332,7 +3332,11 @@ test('writeNonClaudeDefaults function exists and is a no-op for Claude (#2834)',
|
||||
const src = fs.readFileSync(INSTALL_JS, 'utf8');
|
||||
const fnIdx = src.indexOf('function writeNonClaudeDefaults(');
|
||||
assert.ok(fnIdx !== -1, 'writeNonClaudeDefaults must be defined as a function');
|
||||
const fnBody = src.slice(fnIdx, fnIdx + 1200);
|
||||
// Bound the slice by the next top-level declaration rather than a fixed
|
||||
// character count, so adding a comment or a guard inside the function cannot
|
||||
// push the asserted tokens out of the window and red this test spuriously.
|
||||
const nextFnIdx = src.indexOf('\nfunction ', fnIdx + 1);
|
||||
const fnBody = src.slice(fnIdx, nextFnIdx === -1 ? undefined : nextFnIdx);
|
||||
assert.ok(/nativeModelAliases/.test(fnBody), 'writeNonClaudeDefaults must early-return for Claude (nativeModelAliases check)');
|
||||
assert.ok(/resolve_model_ids/.test(fnBody), 'writeNonClaudeDefaults must write resolve_model_ids');
|
||||
assert.ok(/defaults\.runtime/.test(fnBody), 'writeNonClaudeDefaults must write runtime');
|
||||
|
||||
@@ -543,6 +543,41 @@ function readFileNormalized(filePath) {
|
||||
return fs.readFileSync(filePath, 'utf-8').replace(/\r\n/g, '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fault-injection helper for durable-write tests (#1874): monkeypatches
|
||||
* `fsModule.writeFileSync` so any call matching `matches(target)` writes
|
||||
* only the first `bytesBeforeThrow` bytes of `data` (a faithful crash
|
||||
* window — the partial bytes DO land on disk, mirroring a real ENOSPC/EIO
|
||||
* mid-write) and then throws. Calls not matching `matches` pass through to
|
||||
* the real implementation unchanged.
|
||||
*
|
||||
* fs-method override rather than chmod: root bypasses mode bits, so a
|
||||
* permission-based fault injection silently passes with zero coverage in
|
||||
* root CI (CLAUDE.md §4 / CONTRIBUTING.md).
|
||||
*
|
||||
* Returns a restore function — call it via `t.after(...)`, never a manual
|
||||
* try/finally in the test body.
|
||||
*
|
||||
* @param {typeof import('fs')} fsModule
|
||||
* @param {(target: unknown) => boolean} matches - defaults to matching every write
|
||||
* @param {number} bytesBeforeThrow - byte count of `data` that lands before the throw
|
||||
* @param {{code?: string, message?: string}} [options]
|
||||
* @returns {() => void} restore function
|
||||
*/
|
||||
function mockPartialWriteThenThrow(fsModule, matches, bytesBeforeThrow, options = {}) {
|
||||
const { code = 'ENOSPC', message = `${code}: simulated partial write failure` } = options;
|
||||
const shouldMatch = typeof matches === 'function' ? matches : () => true;
|
||||
const origWriteFileSync = fsModule.writeFileSync;
|
||||
fsModule.writeFileSync = (target, data, writeOptions) => {
|
||||
if (!shouldMatch(target)) {
|
||||
return origWriteFileSync.call(fsModule, target, data, writeOptions);
|
||||
}
|
||||
origWriteFileSync.call(fsModule, target, String(data).slice(0, bytesBeforeThrow), writeOptions);
|
||||
throw Object.assign(new Error(message), { code });
|
||||
};
|
||||
return () => { fsModule.writeFileSync = origWriteFileSync; };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a workflow .md file plus every .md file under its sibling
|
||||
* `<workflow-basename>/steps/` directory, concatenated in document order
|
||||
@@ -1076,7 +1111,7 @@ function sandboxHome(t, dir) {
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { runGsdTools, createTempDir, createTempProject, createTempGitProject, cleanup, tmpRootCandidates, readFileNormalized, readWorkflowCombined, parseFrontmatter, isUsageOutput, captureConsole, toPosixPath, absPlanningPath, runNpm, isolatedNpmEnv, withIsolatedProcessState, delay, waitFor, resetRuntimeWarningCaches, SESSION_ENV_KEYS, saveSessionEnv, restoreSessionEnv, clearSessionEnv, isolateWorkstreamEnv, restoreWorkstreamEnv, TOOLS_PATH, SESSION_IDENTITY_ENV_KEYS, scrubConfigLocationEnv, installSpawnEnv, installSpawnHome, sandboxHome, TEST_HOME_SANDBOX_MARKER };
|
||||
module.exports = { runGsdTools, createTempDir, createTempProject, createTempGitProject, cleanup, tmpRootCandidates, readFileNormalized, readWorkflowCombined, parseFrontmatter, isUsageOutput, captureConsole, toPosixPath, absPlanningPath, runNpm, isolatedNpmEnv, withIsolatedProcessState, delay, waitFor, resetRuntimeWarningCaches, SESSION_ENV_KEYS, saveSessionEnv, restoreSessionEnv, clearSessionEnv, isolateWorkstreamEnv, restoreWorkstreamEnv, TOOLS_PATH, SESSION_IDENTITY_ENV_KEYS, scrubConfigLocationEnv, installSpawnEnv, installSpawnHome, sandboxHome, TEST_HOME_SANDBOX_MARKER, mockPartialWriteThenThrow };
|
||||
|
||||
// Lazy, for the reason builtLib() is lazy: reading either of these is what
|
||||
// forces the built-lib require, so a test file that needs neither can still
|
||||
|
||||
@@ -23,7 +23,7 @@ const { runNode } = require('./helpers/process-seam.cjs');
|
||||
const { throwIfFailed } = require('./helpers/git-fixture.cjs');
|
||||
const { INSTALL_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
||||
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
const { createTempDir, cleanup, mockPartialWriteThenThrow } = require('./helpers.cjs');
|
||||
const {
|
||||
loadSkillsManifest,
|
||||
resolveProfile,
|
||||
@@ -1848,3 +1848,310 @@ describe('#3664 — config-dir foreign-agent destination warning', () => {
|
||||
assert.ok(!lines.some((l) => l.includes('(#3664)')), `unresolvable layout must degrade silently: ${lines.join(' | ')}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #1874 F6 — malformed settings.local.json is preserved by the #338 migration ───
|
||||
|
||||
describe('#1874 F6 (#338 migration): a malformed settings.local.json is preserved', () => {
|
||||
test('local file stays byte-identical and shared GSD entries are not stripped', (t) => {
|
||||
const root = createTempDir('gsd-1874-f6-');
|
||||
t.after(() => cleanup(root));
|
||||
|
||||
const claudeDir = path.join(root, '.claude');
|
||||
fs.mkdirSync(claudeDir, { recursive: true });
|
||||
|
||||
// Shared settings carries GSD-shaped entries, so the #338 migration branch fires.
|
||||
const sharedSettingsPath = path.join(claudeDir, 'settings.json');
|
||||
fs.writeFileSync(sharedSettingsPath, JSON.stringify({
|
||||
hooks: {
|
||||
SessionStart: [
|
||||
{ hooks: [{ type: 'command', command: `${process.execPath} ${path.join(claudeDir, 'hooks', 'gsd-check-update.js')}` }] },
|
||||
],
|
||||
},
|
||||
}, null, 2) + '\n');
|
||||
|
||||
// Unparseable local settings — the stray brace defeats both the JSON and the
|
||||
// JSONC path — carrying user content that must survive.
|
||||
const localSettingsPath = path.join(claudeDir, 'settings.local.json');
|
||||
const malformedLocal = '{\n "permissions": { "allow": ["Bash(npm test)"] },\n}}\n';
|
||||
fs.writeFileSync(localSettingsPath, malformedLocal);
|
||||
|
||||
const env = { ...process.env, HOME: root, USERPROFILE: root };
|
||||
delete env.GSD_TEST_MODE;
|
||||
const result = runNode(
|
||||
[INSTALL_SCRIPT, '--claude', '--local'],
|
||||
{ cwd: root, env, timeoutMs: 60_000 },
|
||||
);
|
||||
assert.strictEqual(result.exitCode, 0,
|
||||
`installer exited ${result.exitCode}\n${result.stdout}\n${result.stderr}`);
|
||||
|
||||
// Byte-equality, not parse-equality: the file is unparseable by construction.
|
||||
assert.strictEqual(
|
||||
fs.readFileSync(localSettingsPath, 'utf8'),
|
||||
malformedLocal,
|
||||
'a malformed settings.local.json must be left byte-for-byte intact'
|
||||
);
|
||||
|
||||
// Aborting only the local write would still strip the shared file below,
|
||||
// destroying the GSD entries outright instead of relocating them. The whole
|
||||
// migration must stand down so it can retry once the user fixes the file.
|
||||
const sharedAfter = JSON.parse(fs.readFileSync(sharedSettingsPath, 'utf8'));
|
||||
const sessionStart = (sharedAfter.hooks && sharedAfter.hooks.SessionStart) || [];
|
||||
assert.ok(
|
||||
sessionStart.some(
|
||||
entry => entry && entry.hooks && Array.isArray(entry.hooks) &&
|
||||
entry.hooks.some(h => h && h.command && h.command.includes('gsd-check-update'))
|
||||
),
|
||||
'GSD entries must remain in settings.json when the migration is skipped'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #1874 F6 (adjacent): a malformed settings file must not crash the install ───
|
||||
// The bare `return;` in the unparseable-settings guard returned undefined while
|
||||
// every sibling early exit returns the full result shape, so installAllRuntimes'
|
||||
// statusline lookup (results.find(r => r.runtime)) threw. Reachable on its own —
|
||||
// no #338 migration required.
|
||||
|
||||
describe('#1874 F6 adjacent: malformed settings.local.json does not crash the install', () => {
|
||||
test('installer exits 0 and preserves the malformed file when no migration applies', (t) => {
|
||||
const root = createTempDir('gsd-1874-f6-nocrash-');
|
||||
t.after(() => cleanup(root));
|
||||
|
||||
const claudeDir = path.join(root, '.claude');
|
||||
fs.mkdirSync(claudeDir, { recursive: true });
|
||||
const localSettingsPath = path.join(claudeDir, 'settings.local.json');
|
||||
const malformedLocal = '{\n "permissions": { "allow": ["Bash(npm test)"] },\n}}\n';
|
||||
fs.writeFileSync(localSettingsPath, malformedLocal);
|
||||
|
||||
const env = { ...process.env, HOME: root, USERPROFILE: root };
|
||||
delete env.GSD_TEST_MODE;
|
||||
const result = runNode(
|
||||
[INSTALL_SCRIPT, '--claude', '--local'],
|
||||
{ cwd: root, env, timeoutMs: 60_000 },
|
||||
);
|
||||
|
||||
assert.strictEqual(result.exitCode, 0,
|
||||
`installer must not crash on an unparseable settings file\n${result.stdout}\n${result.stderr}`);
|
||||
assert.strictEqual(
|
||||
fs.readFileSync(localSettingsPath, 'utf8'),
|
||||
malformedLocal,
|
||||
'the unparseable file must be left intact'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #1874 F18 — ~/.gsd/defaults.json is machine-global: lock it, write it once ───
|
||||
//
|
||||
// Every runtime and project on the box reads this file. The read-modify-write
|
||||
// took no lock (so concurrent installs lost each other's key) and issued two
|
||||
// separate whole-file writes (so a crash could truncate it, and the second
|
||||
// write bought nothing).
|
||||
|
||||
describe('#1874 F18: ~/.gsd/defaults.json read-modify-write is locked and atomic', () => {
|
||||
const { writeNonClaudeDefaults } = installExports || {};
|
||||
|
||||
// The function early-returns under GSD_TEST_MODE, and resolves its target via
|
||||
// os.homedir() — which reads HOME on POSIX and USERPROFILE on Windows.
|
||||
function withRealInstallHome(root, fn) {
|
||||
const saved = {
|
||||
testMode: process.env.GSD_TEST_MODE,
|
||||
home: process.env.HOME,
|
||||
userProfile: process.env.USERPROFILE,
|
||||
};
|
||||
delete process.env.GSD_TEST_MODE;
|
||||
process.env.HOME = root;
|
||||
process.env.USERPROFILE = root;
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
if (saved.testMode === undefined) delete process.env.GSD_TEST_MODE;
|
||||
else process.env.GSD_TEST_MODE = saved.testMode;
|
||||
if (saved.home === undefined) delete process.env.HOME; else process.env.HOME = saved.home;
|
||||
if (saved.userProfile === undefined) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = saved.userProfile;
|
||||
}
|
||||
}
|
||||
|
||||
test('a clean install writes defaults.json exactly once, not twice', (t) => {
|
||||
const root = createTempDir('gsd-1874-f18-once-');
|
||||
t.after(() => cleanup(root));
|
||||
|
||||
const defaultsPath = path.join(root, '.gsd', 'defaults.json');
|
||||
const writes = [];
|
||||
const origWriteFileSync = fs.writeFileSync;
|
||||
fs.writeFileSync = (target, ...rest) => {
|
||||
// Count writes aimed at defaults.json, including the atomic temp sibling.
|
||||
const resolved = path.resolve(String(target));
|
||||
if (resolved === defaultsPath || resolved.startsWith(`${defaultsPath}.tmp-`)) writes.push(resolved);
|
||||
return origWriteFileSync.call(fs, target, ...rest);
|
||||
};
|
||||
t.after(() => { fs.writeFileSync = origWriteFileSync; });
|
||||
|
||||
withRealInstallHome(root, () => {
|
||||
const origLog = console.log;
|
||||
console.log = () => {};
|
||||
t.after(() => { console.log = origLog; });
|
||||
writeNonClaudeDefaults('codex');
|
||||
});
|
||||
|
||||
// Both keys change on a clean install; that is one file state, so one write.
|
||||
assert.strictEqual(writes.length, 1,
|
||||
`defaults.json must be written once per install, got ${writes.length}`);
|
||||
|
||||
const after = JSON.parse(fs.readFileSync(defaultsPath, 'utf8'));
|
||||
assert.strictEqual(after.resolve_model_ids, 'omit');
|
||||
assert.strictEqual(after.runtime, 'codex');
|
||||
});
|
||||
|
||||
test('the read-modify-write holds the install-migration lock', (t) => {
|
||||
const root = createTempDir('gsd-1874-f18-lock-');
|
||||
t.after(() => cleanup(root));
|
||||
|
||||
const gsdDir = path.join(root, '.gsd');
|
||||
const lockPath = path.join(gsdDir, 'gsd-install-migration.lock');
|
||||
const defaultsPath = path.join(gsdDir, 'defaults.json');
|
||||
let lockHeldDuringWrite = null;
|
||||
|
||||
const origWriteFileSync = fs.writeFileSync;
|
||||
fs.writeFileSync = (target, ...rest) => {
|
||||
const resolved = path.resolve(String(target));
|
||||
if (resolved === defaultsPath || resolved.startsWith(`${defaultsPath}.tmp-`)) {
|
||||
lockHeldDuringWrite = fs.existsSync(lockPath);
|
||||
}
|
||||
return origWriteFileSync.call(fs, target, ...rest);
|
||||
};
|
||||
t.after(() => { fs.writeFileSync = origWriteFileSync; });
|
||||
|
||||
withRealInstallHome(root, () => {
|
||||
const origLog = console.log;
|
||||
console.log = () => {};
|
||||
t.after(() => { console.log = origLog; });
|
||||
writeNonClaudeDefaults('codex');
|
||||
});
|
||||
|
||||
assert.strictEqual(lockHeldDuringWrite, true,
|
||||
'the lock must be held while defaults.json is being written');
|
||||
assert.strictEqual(fs.existsSync(lockPath), false,
|
||||
'the lock must be released when the write completes');
|
||||
});
|
||||
|
||||
test('a failure mid-write leaves the previous defaults.json intact and parseable', (t) => {
|
||||
const root = createTempDir('gsd-1874-f18-crash-');
|
||||
t.after(() => cleanup(root));
|
||||
|
||||
const gsdDir = path.join(root, '.gsd');
|
||||
fs.mkdirSync(gsdDir, { recursive: true });
|
||||
const defaultsPath = path.join(gsdDir, 'defaults.json');
|
||||
// A pre-existing file carrying settings other installs depend on.
|
||||
const prior = JSON.stringify({ model_profile: 'balanced', resolve_model_ids: true }, null, 2) + '\n';
|
||||
fs.writeFileSync(defaultsPath, prior);
|
||||
|
||||
// Faithful crash window: the bytes written before the failure DO land, then
|
||||
// the call fails.
|
||||
t.after(mockPartialWriteThenThrow(
|
||||
fs,
|
||||
(target) => {
|
||||
const resolved = path.resolve(String(target));
|
||||
return resolved === defaultsPath || resolved.startsWith(`${defaultsPath}.tmp-`);
|
||||
},
|
||||
10,
|
||||
{ code: 'ENOSPC', message: 'ENOSPC: no space left on device' },
|
||||
));
|
||||
|
||||
withRealInstallHome(root, () => {
|
||||
const origLog = console.log;
|
||||
console.log = () => {};
|
||||
t.after(() => { console.log = origLog; });
|
||||
// The installer treats this as best-effort and logs rather than throwing.
|
||||
writeNonClaudeDefaults('codex');
|
||||
});
|
||||
|
||||
assert.strictEqual(fs.readFileSync(defaultsPath, 'utf8'), prior,
|
||||
'defaults.json must be byte-identical to its pre-write contents');
|
||||
// The read path swallows parse errors and treats a corrupt file as absent,
|
||||
// so a truncated write would silently degrade model resolution box-wide.
|
||||
const recovered = JSON.parse(fs.readFileSync(defaultsPath, 'utf8'));
|
||||
assert.strictEqual(recovered.model_profile, 'balanced');
|
||||
assert.strictEqual(recovered.resolve_model_ids, true);
|
||||
|
||||
assert.deepStrictEqual(
|
||||
fs.readdirSync(gsdDir).filter(n => n.startsWith('defaults.json.tmp-')),
|
||||
[],
|
||||
'no atomic temp residue may survive a failed write'
|
||||
);
|
||||
assert.strictEqual(fs.existsSync(path.join(gsdDir, 'gsd-install-migration.lock')), false,
|
||||
'the lock must be released even when the write fails');
|
||||
});
|
||||
|
||||
test('an install that changes nothing does not rewrite the file', (t) => {
|
||||
const root = createTempDir('gsd-1874-f18-noop-');
|
||||
t.after(() => cleanup(root));
|
||||
|
||||
const gsdDir = path.join(root, '.gsd');
|
||||
fs.mkdirSync(gsdDir, { recursive: true });
|
||||
const defaultsPath = path.join(gsdDir, 'defaults.json');
|
||||
const prior = JSON.stringify({ resolve_model_ids: 'omit', runtime: 'codex' }, null, 2) + '\n';
|
||||
fs.writeFileSync(defaultsPath, prior);
|
||||
|
||||
const writes = [];
|
||||
const origWriteFileSync = fs.writeFileSync;
|
||||
fs.writeFileSync = (target, ...rest) => {
|
||||
const resolved = path.resolve(String(target));
|
||||
if (resolved === defaultsPath || resolved.startsWith(`${defaultsPath}.tmp-`)) writes.push(resolved);
|
||||
return origWriteFileSync.call(fs, target, ...rest);
|
||||
};
|
||||
t.after(() => { fs.writeFileSync = origWriteFileSync; });
|
||||
withRealInstallHome(root, () => {
|
||||
const origLog = console.log;
|
||||
console.log = () => {};
|
||||
t.after(() => { console.log = origLog; });
|
||||
writeNonClaudeDefaults('codex');
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(writes, [], 'an unchanged defaults.json must not be rewritten');
|
||||
assert.strictEqual(fs.readFileSync(defaultsPath, 'utf8'), prior);
|
||||
});
|
||||
|
||||
test('a read-only .gsd directory blocks the lock and the RMW never partially applies', (t) => {
|
||||
const root = createTempDir('gsd-1874-f18-readonly-');
|
||||
t.after(() => cleanup(root));
|
||||
|
||||
const gsdDir = path.join(root, '.gsd');
|
||||
fs.mkdirSync(gsdDir, { recursive: true });
|
||||
const lockPath = path.join(gsdDir, 'gsd-install-migration.lock');
|
||||
const defaultsPath = path.join(gsdDir, 'defaults.json');
|
||||
// A pre-existing file the RMW must never touch if the lock cannot be taken.
|
||||
const prior = JSON.stringify({ model_profile: 'balanced', resolve_model_ids: true }, null, 2) + '\n';
|
||||
fs.writeFileSync(defaultsPath, prior);
|
||||
|
||||
// fs-method override rather than chmod — root bypasses mode bits, so a
|
||||
// permission-based test silently passes with zero coverage in root CI.
|
||||
// Simulates a read-only .gsd directory: the exclusive lock-file create
|
||||
// fails with EACCES before any read-modify-write is attempted.
|
||||
const origOpenSync = fs.openSync;
|
||||
fs.openSync = (target, flags, ...rest) => {
|
||||
if (path.resolve(String(target)) === lockPath) {
|
||||
throw Object.assign(new Error('EACCES: permission denied, open ' + lockPath), { code: 'EACCES' });
|
||||
}
|
||||
return origOpenSync.call(fs, target, flags, ...rest);
|
||||
};
|
||||
t.after(() => { fs.openSync = origOpenSync; });
|
||||
|
||||
let threw = false;
|
||||
withRealInstallHome(root, () => {
|
||||
const origLog = console.log;
|
||||
console.log = () => {};
|
||||
t.after(() => { console.log = origLog; });
|
||||
// writeNonClaudeDefaults treats lock/write failure as best-effort and
|
||||
// must not let it escape as an uncaught exception.
|
||||
try { writeNonClaudeDefaults('codex'); } catch { threw = true; }
|
||||
});
|
||||
|
||||
assert.strictEqual(threw, false,
|
||||
'a blocked lock must not crash the installer — writeNonClaudeDefaults degrades gracefully');
|
||||
assert.strictEqual(fs.readFileSync(defaultsPath, 'utf8'), prior,
|
||||
'defaults.json must be untouched when the lock could not be acquired — no partial RMW');
|
||||
assert.strictEqual(fs.existsSync(lockPath), false,
|
||||
'no lock file may be left behind when its creation itself failed');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -141,8 +141,17 @@ function captureConsole(fn) {
|
||||
|
||||
function withWriteFailure(matchPath, fn) {
|
||||
const originalWriteFileSync = fs.writeFileSync;
|
||||
const resolvedMatch = path.resolve(matchPath);
|
||||
// Atomic writers (atomicWriteFileSync) never write the destination directly —
|
||||
// they write `<target>.tmp-<pid>-<n>` and rename. Matching only the final path
|
||||
// would make this injection silently stop firing for any write that becomes
|
||||
// atomic, turning a rollback assertion into a vacuous pass.
|
||||
const targetsMatch = (filePath) => {
|
||||
const resolved = path.resolve(String(filePath));
|
||||
return resolved === resolvedMatch || resolved.startsWith(`${resolvedMatch}.tmp-`);
|
||||
};
|
||||
fs.writeFileSync = (filePath, ...args) => {
|
||||
if (path.resolve(String(filePath)) === path.resolve(matchPath)) {
|
||||
if (targetsMatch(filePath)) {
|
||||
throw new Error(`injected write failure for ${path.basename(matchPath)}`);
|
||||
}
|
||||
return originalWriteFileSync.call(fs, filePath, ...args);
|
||||
|
||||
@@ -19,6 +19,8 @@ const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const { cleanup, mockPartialWriteThenThrow } = require('./helpers.cjs');
|
||||
|
||||
// ─── load real install.js exports once ───────────────────────────────────────
|
||||
//
|
||||
// install.js prints a banner at module-load time (outside its GSD_TEST_MODE
|
||||
@@ -40,7 +42,7 @@ let installExports;
|
||||
process.stdout.write = _origWrite;
|
||||
}
|
||||
}
|
||||
const { readSettings, stripJsonComments } = installExports;
|
||||
const { readSettings, writeSettings, stripJsonComments } = installExports;
|
||||
|
||||
// ─── tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -264,3 +266,160 @@ describe('readSettings: JSON null coalesced to empty, malformed warns (#1191)',
|
||||
assert.strictEqual(warnCalls.length, 0, 'no warning expected for absent file');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── writeSettings durability (#1874 F5) ─────────────────────────────────────
|
||||
//
|
||||
// Claude Code discards 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. writeSettings is the sole writer of this
|
||||
// surface for six runtimes, so it must never leave a partial file behind.
|
||||
|
||||
describe('writeSettings durability (#1874 F5)', () => {
|
||||
|
||||
// The user's existing settings: entries GSD does not own and must not lose.
|
||||
const PRIOR = JSON.stringify({
|
||||
permissions: { allow: ['Bash(npm test)'] },
|
||||
statusLine: { command: '/usr/local/bin/my-statusline' },
|
||||
env: { MY_TOKEN: 'keep-me' },
|
||||
}, null, 2) + '\n';
|
||||
|
||||
function withTmpDir(fn) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-write-settings-'));
|
||||
try { return fn(dir); } finally { cleanup(dir); }
|
||||
}
|
||||
|
||||
test('a failure mid-write leaves the previous settings file intact', (t) => {
|
||||
withTmpDir((dir) => {
|
||||
const settingsPath = path.join(dir, 'settings.json');
|
||||
fs.writeFileSync(settingsPath, PRIOR);
|
||||
|
||||
// Simulate the crash window faithfully: the bytes that were written
|
||||
// before the failure DO land on disk, then the call fails. A mock that
|
||||
// merely throws would pass against a non-atomic writer, proving nothing.
|
||||
t.after(mockPartialWriteThenThrow(fs, undefined, 12, {
|
||||
code: 'ENOSPC',
|
||||
message: 'ENOSPC: no space left on device',
|
||||
}));
|
||||
|
||||
let threw = false;
|
||||
try {
|
||||
writeSettings(settingsPath, { hooks: { SessionStart: [] } });
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
|
||||
assert.ok(threw, 'the write failure must propagate, not be swallowed');
|
||||
assert.strictEqual(
|
||||
fs.readFileSync(settingsPath, 'utf8'),
|
||||
PRIOR,
|
||||
'settings.json must be byte-identical to its pre-write contents'
|
||||
);
|
||||
// The real cost of the bug: a truncated file is discarded wholesale by
|
||||
// the host, so assert the user's non-GSD entries actually survive.
|
||||
const recovered = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
assert.deepStrictEqual(recovered.permissions.allow, ['Bash(npm test)']);
|
||||
assert.strictEqual(recovered.env.MY_TOKEN, 'keep-me');
|
||||
});
|
||||
});
|
||||
|
||||
test('a failure mid-write leaves no temp file behind', (t) => {
|
||||
withTmpDir((dir) => {
|
||||
const settingsPath = path.join(dir, 'settings.json');
|
||||
fs.writeFileSync(settingsPath, PRIOR);
|
||||
|
||||
t.after(mockPartialWriteThenThrow(fs, undefined, 12, {
|
||||
code: 'EIO',
|
||||
message: 'simulated mid-write failure',
|
||||
}));
|
||||
try {
|
||||
writeSettings(settingsPath, { hooks: {} });
|
||||
} catch { /* expected */ }
|
||||
|
||||
assert.deepStrictEqual(
|
||||
fs.readdirSync(dir).sort(),
|
||||
['settings.json'],
|
||||
'no .tmp-* residue may survive a failed write'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('a successful write produces the same bytes as before (format contract)', () => {
|
||||
withTmpDir((dir) => {
|
||||
const settingsPath = path.join(dir, 'settings.json');
|
||||
const settings = { hooks: { SessionStart: [{ hooks: [{ command: 'x' }] }] }, env: { A: '1' } };
|
||||
|
||||
writeSettings(settingsPath, settings);
|
||||
|
||||
// Two-space indent + trailing newline is the on-disk contract other
|
||||
// tooling (and users' diffs) depend on; atomicity must not disturb it.
|
||||
assert.strictEqual(
|
||||
fs.readFileSync(settingsPath, 'utf8'),
|
||||
JSON.stringify(settings, null, 2) + '\n'
|
||||
);
|
||||
assert.deepStrictEqual(readSettings(settingsPath), settings, 'must round-trip through readSettings');
|
||||
assert.deepStrictEqual(fs.readdirSync(dir), ['settings.json'], 'no temp residue on success');
|
||||
});
|
||||
});
|
||||
|
||||
test('hardened permissions survive the rewrite', () => {
|
||||
withTmpDir((dir) => {
|
||||
const settingsPath = path.join(dir, 'settings.json');
|
||||
fs.writeFileSync(settingsPath, PRIOR);
|
||||
// 0o600 is the hardened-secrets posture: settings.json can carry env
|
||||
// tokens, and rename() would otherwise swap in a umask-default inode.
|
||||
fs.chmodSync(settingsPath, 0o600);
|
||||
|
||||
writeSettings(settingsPath, { hooks: {}, env: { MY_TOKEN: 'keep-me' } });
|
||||
|
||||
assert.deepStrictEqual(
|
||||
readSettings(settingsPath),
|
||||
{ hooks: {}, env: { MY_TOKEN: 'keep-me' } },
|
||||
'the write must land through a chmod-hardened target on every OS'
|
||||
);
|
||||
if (process.platform !== 'win32') {
|
||||
assert.strictEqual(
|
||||
fs.statSync(settingsPath).mode & 0o7777,
|
||||
0o600,
|
||||
'a pre-existing non-default mode must survive the temp+rename write'
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('the temp file is created exclusively — a pre-planted symlink is not followed', { skip: process.platform === 'win32' }, (t) => {
|
||||
withTmpDir((dir) => {
|
||||
const settingsPath = path.join(dir, 'settings.json');
|
||||
fs.writeFileSync(settingsPath, PRIOR);
|
||||
const victimPath = path.join(dir, 'victim');
|
||||
fs.writeFileSync(victimPath, 'victim-bytes');
|
||||
|
||||
// Squat every plausible near-future temp path with a symlink to the
|
||||
// victim; an O_EXCL writer must skip them all instead of writing
|
||||
// through one.
|
||||
const planted = [];
|
||||
const origWriteFileSync = fs.writeFileSync;
|
||||
fs.writeFileSync = (target, data, options) => {
|
||||
const resolved = String(target);
|
||||
if (/\.tmp-\d+-\d+$/.test(resolved) && !fs.existsSync(resolved)) {
|
||||
try {
|
||||
fs.symlinkSync(victimPath, resolved);
|
||||
planted.push(resolved);
|
||||
} catch { /* already there */ }
|
||||
}
|
||||
return origWriteFileSync(target, data, options);
|
||||
};
|
||||
t.after(() => { fs.writeFileSync = origWriteFileSync; });
|
||||
assert.throws(
|
||||
() => writeSettings(settingsPath, { hooks: {} }),
|
||||
(e) => e.code === 'EEXIST',
|
||||
'an exclusive create must refuse every squatted temp path'
|
||||
);
|
||||
|
||||
assert.strictEqual(fs.readFileSync(victimPath, 'utf8'), 'victim-bytes',
|
||||
'the symlink target must never receive the settings payload');
|
||||
assert.strictEqual(fs.readFileSync(settingsPath, 'utf8'), PRIOR,
|
||||
'the settings file must be untouched when every temp path is squatted');
|
||||
for (const link of planted) fs.unlinkSync(link);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user