* test(#4544): failing-first rollback-coverage tests for codex install * test(#4544): scope the rollback suite to its own requires The appended describe used bare describe/test/os/cleanup and the folded block's runCodexInstall — none visible at file scope, so the whole test file failed to load. Wrap it in its own block with local requires and a local harness copy, matching the folded-block idiom. * test(#4544): import beforeEach/afterTest hooks into the suite scope * fix(#4544): restore manifest-tracked files and hooks/ on codex rollback restoreCodexSnapshot and _codexPreConfigRollback knew only the five #3245 targets (config.toml, hooks.json, skills/gsd-*, agents/gsd-*, VERSION). A Codex install also writes hooks/, gsd-core/CHANGELOG.md, gsd-core/.gsd-runtime, scripts/changeset|lib + the standalone scripts, and rewrites gsd-file-manifest.json -- none were captured, so any rollback left the new payload in place for all of them. The pre-install capture now records every path the PRIOR install's own gsd-file-manifest.json lists (bytes when present, absence-marker when not, so a path deleted between installs is re-deleted rather than resurrected), the manifest file itself, and the whole hooks/ tree -- wholesale, because the Codex manifest deliberately omits hooks/ and hooks/ is shared space, so restore returns user files that predated the install and drops everything the failed install staged. Both rollback paths share one restore closure; malformed or missing prior state degrades to today's behavior. Known residual, documented: files the FAILED install adds under manifest-tracked dirs survive a rollback that fires before the new manifest is written (they are named by no prior state). The five original targets and hooks/ have no residual. * test(#4544): align malformed-manifest fixtures with pre-install-state semantics Row 7 seeded VERSION and then asserted its absence -- but a seeded VERSION is pre-install state the fix must restore, not remove. Row 8 asserted a pre-existing array-shaped manifest must not survive, when restoring those exact bytes IS the contract. Both were fixture bugs; the probe-verified installer behavior was correct. * docs(#4544): add Fixed changeset for codex rollback coverage * fix(#4544): harden snapshot per adversarial review — minimal mode, symlinks, clean installs Review (three independent passes) found five defects and one coverage gap in the first cut; all fixed: - BLOCKER: the capture gate is off in minimal mode but the restore call was not, so a minimal-mode rollback wholesale-deleted the user's entire hooks/ directory (empirically confirmed by the reviewer). The restore now consults a captured flag: no snapshot means do nothing. - MAJOR: the hooks/ walk followed file symlinks — a repo-shipped .codex/hooks symlink to a FIFO would hang the installer, to /dev/zero exhaust memory, or to private data copy that data into the snapshot. The walk lstats every entry and captures only true regular files; anything else marks the capture incomplete. - Incomplete captures now downgrade the restore to per-file: put back what was captured, remove only the names GSD itself stages (the hoisted CODEX_HOOKS_TO_COPY set + CommonJS marker), never wholesale- delete a tree the snapshot did not fully see. GSD-owned removal runs before the restore so a name in both sets keeps its pre-install bytes. - A pre-existing hooks FILE (not directory) is left alone instead of deleted. - readInstallManifest now rejects a manifest whose files field is a JSON array (typeof [] === 'object'), which previously produced numeric-key paths. - Clean FIRST installs: with no prior manifest nothing recorded the payload, so a failed clean install rolled back to a half-written tree. Capture now enumerates the same source directories the installer copies plus the two standalone files (CHANGELOG.md from the repo root, generated .gsd-runtime) and records absence — a failed clean install now rolls back to actually nothing. Tests: minimal-mode preservation regression, symlink never-followed regression, helpers.cjs temp dirs, the injected-failure message is asserted, residue assertions made unconditional. * test(#4544): pin symlink-downgrade semantics the final run exposed The symlink itself marks the capture incomplete, so the restore takes the per-file downgrade — which preserves uncaptured pre-install state (the link) rather than wholesale-dropping it. The probe run verified exactly this; the assertion guessed the wholesale branch. Pin the verified behavior: referent untouched, link preserved and resolving, no leak. * docs(#4544): backfill changeset PR number --------- Co-authored-by: sim <sim@local>
This commit is contained in:
@@ -3627,3 +3627,320 @@ describe('#3427 + #3433 — Codex installer avoids duplicate skills and mixed ho
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
{
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { cleanup, createTempDir } = require('./helpers.cjs');
|
||||
const { install: installFor4544 } = require('../bin/install.js');
|
||||
const installModule = require('../bin/install.js');
|
||||
|
||||
// Harness copy of runCodexInstall — the canonical one lives inside a folded
|
||||
// block above and is not visible at this scope.
|
||||
function runCodexInstall(codexHome) {
|
||||
const previousCodexHome = process.env.CODEX_HOME;
|
||||
const previousCwd = process.cwd();
|
||||
const previousHome = process.env.HOME;
|
||||
const previousUserProfile = process.env.USERPROFILE;
|
||||
process.env.CODEX_HOME = codexHome;
|
||||
process.env.HOME = codexHome;
|
||||
process.env.USERPROFILE = codexHome;
|
||||
try {
|
||||
process.chdir(path.join(__dirname, '..'));
|
||||
return installFor4544(true, 'codex');
|
||||
} finally {
|
||||
process.chdir(previousCwd);
|
||||
if (previousCodexHome === undefined) delete process.env.CODEX_HOME;
|
||||
else process.env.CODEX_HOME = previousCodexHome;
|
||||
if (previousHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = previousHome;
|
||||
if (previousUserProfile === undefined) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = previousUserProfile;
|
||||
}
|
||||
}
|
||||
|
||||
describe('#4544 — manifest-driven rollback covers hooks/, scripts/, gsd-core payload, and the manifest', { concurrency: false }, () => {
|
||||
let tmpDir;
|
||||
let codexHome;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-4544-rollback-');
|
||||
codexHome = path.join(tmpDir, 'codex-home');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete installModule.__codexSchemaValidator;
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
/** Seed a shape-valid prior-install manifest listing `relPaths`. */
|
||||
function seedPriorManifest(relPaths) {
|
||||
const files = {};
|
||||
for (const rel of relPaths) files[rel] = 'prior-install-hash';
|
||||
fs.writeFileSync(
|
||||
path.join(codexHome, 'gsd-file-manifest.json'),
|
||||
JSON.stringify({ manifestVersion: 2, version: '1.12.0', files }),
|
||||
'utf8',
|
||||
);
|
||||
}
|
||||
|
||||
function forceValidationFailure() {
|
||||
installModule.__codexSchemaValidator = () => ({
|
||||
ok: false,
|
||||
reason: 'simulated failure for #4544 rollback test',
|
||||
});
|
||||
}
|
||||
|
||||
function runFailingInstall() {
|
||||
let err = null;
|
||||
try { runCodexInstall(codexHome); } catch (e) { err = e; }
|
||||
assert.ok(err, 'install must throw when validation fails');
|
||||
assert.match(
|
||||
String(err && err.message),
|
||||
/post-write Codex schema validation failed/,
|
||||
'the throw must be the injected validation failure, not an unrelated error'
|
||||
);
|
||||
}
|
||||
|
||||
test('restores prior-manifest files the failed install overwrote (#4544 must-have)', () => {
|
||||
fs.mkdirSync(codexHome, { recursive: true });
|
||||
const sentinels = new Map([
|
||||
['gsd-core/CHANGELOG.md', 'SENTINEL-CHANGELOG'],
|
||||
['gsd-core/.gsd-runtime', 'SENTINEL-RUNTIME'],
|
||||
['scripts/lib/drift-scan.cjs', 'SENTINEL-LIB'],
|
||||
]);
|
||||
fs.mkdirSync(path.join(codexHome, 'gsd-core'), { recursive: true });
|
||||
fs.mkdirSync(path.join(codexHome, 'scripts', 'lib'), { recursive: true });
|
||||
for (const [rel, body] of sentinels) {
|
||||
fs.writeFileSync(path.join(codexHome, rel), body, 'utf8');
|
||||
}
|
||||
seedPriorManifest([...sentinels.keys()]);
|
||||
|
||||
forceValidationFailure();
|
||||
runFailingInstall();
|
||||
|
||||
for (const [rel, body] of sentinels) {
|
||||
assert.strictEqual(
|
||||
fs.readFileSync(path.join(codexHome, rel), 'utf8'),
|
||||
body,
|
||||
`rollback must restore the pre-install bytes of ${rel}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('re-deletes a prior-manifest path that was absent before the install', () => {
|
||||
fs.mkdirSync(codexHome, { recursive: true });
|
||||
// Listed by the prior install but deleted before this one: the null
|
||||
// snapshot branch must remove whatever the failed install recreates.
|
||||
seedPriorManifest(['scripts/lib/drift-scan.cjs']);
|
||||
assert.strictEqual(fs.existsSync(path.join(codexHome, 'scripts')), false,
|
||||
'fixture precondition: the path must not exist pre-install');
|
||||
|
||||
forceValidationFailure();
|
||||
runFailingInstall();
|
||||
|
||||
assert.strictEqual(
|
||||
fs.existsSync(path.join(codexHome, 'scripts', 'lib', 'drift-scan.cjs')),
|
||||
false,
|
||||
'rollback must re-delete a prior-manifest path the user had removed'
|
||||
);
|
||||
});
|
||||
|
||||
test('wholesale-restores the hooks/ directory (the Codex manifest omits it)', () => {
|
||||
fs.mkdirSync(path.join(codexHome, 'hooks'), { recursive: true });
|
||||
fs.writeFileSync(path.join(codexHome, 'hooks', 'gsd-check-update.js'),
|
||||
'SENTINEL-USER-EDITED-HOOK', 'utf8');
|
||||
|
||||
forceValidationFailure();
|
||||
runFailingInstall();
|
||||
|
||||
assert.strictEqual(
|
||||
fs.readFileSync(path.join(codexHome, 'hooks', 'gsd-check-update.js'), 'utf8'),
|
||||
'SENTINEL-USER-EDITED-HOOK',
|
||||
'rollback must restore the pre-install hook bytes the install overwrote'
|
||||
);
|
||||
assert.strictEqual(
|
||||
fs.existsSync(path.join(codexHome, 'hooks', 'package.json')),
|
||||
false,
|
||||
'the CommonJS marker the failed install staged must not survive rollback'
|
||||
);
|
||||
assert.strictEqual(
|
||||
fs.existsSync(path.join(codexHome, 'hooks', 'managed-hooks-registry.cjs')),
|
||||
false,
|
||||
'a staged hook file that did not pre-exist must not survive rollback'
|
||||
);
|
||||
assert.strictEqual(
|
||||
fs.existsSync(path.join(codexHome, 'hooks', 'lib')),
|
||||
false,
|
||||
'transitive hooks/lib/ helpers staged by the failed install must not survive rollback'
|
||||
);
|
||||
});
|
||||
|
||||
test('preserves pre-existing user files under hooks/', () => {
|
||||
fs.mkdirSync(path.join(codexHome, 'hooks'), { recursive: true });
|
||||
fs.writeFileSync(path.join(codexHome, 'hooks', 'my-own.sh'), '#!/bin/sh\necho mine\n', 'utf8');
|
||||
|
||||
forceValidationFailure();
|
||||
runFailingInstall();
|
||||
|
||||
assert.strictEqual(
|
||||
fs.readFileSync(path.join(codexHome, 'hooks', 'my-own.sh'), 'utf8'),
|
||||
'#!/bin/sh\necho mine\n',
|
||||
'a user-owned hooks/ file that predated the install must survive rollback'
|
||||
);
|
||||
});
|
||||
|
||||
test('restores the prior gsd-file-manifest.json the failed install rewrote', () => {
|
||||
fs.mkdirSync(codexHome, { recursive: true });
|
||||
const priorManifest = JSON.stringify({
|
||||
manifestVersion: 2, version: '1.12.0',
|
||||
files: { 'gsd-core/CHANGELOG.md': 'prior-hash' },
|
||||
}, null, 2);
|
||||
fs.writeFileSync(path.join(codexHome, 'gsd-file-manifest.json'), priorManifest, 'utf8');
|
||||
fs.mkdirSync(path.join(codexHome, 'gsd-core'), { recursive: true });
|
||||
fs.writeFileSync(path.join(codexHome, 'gsd-core', 'CHANGELOG.md'), 'SENTINEL', 'utf8');
|
||||
|
||||
forceValidationFailure();
|
||||
runFailingInstall();
|
||||
|
||||
assert.strictEqual(
|
||||
fs.readFileSync(path.join(codexHome, 'gsd-file-manifest.json'), 'utf8'),
|
||||
priorManifest,
|
||||
'rollback must restore the prior manifest bytes'
|
||||
);
|
||||
assert.strictEqual(
|
||||
fs.readFileSync(path.join(codexHome, 'gsd-core', 'CHANGELOG.md'), 'utf8'),
|
||||
'SENTINEL',
|
||||
'the manifest-listed file must be restored alongside the manifest itself'
|
||||
);
|
||||
});
|
||||
|
||||
test('clean-first-install rollback leaves no manifest or hooks residue', () => {
|
||||
fs.mkdirSync(codexHome, { recursive: true });
|
||||
|
||||
forceValidationFailure();
|
||||
runFailingInstall();
|
||||
|
||||
assert.strictEqual(
|
||||
fs.existsSync(path.join(codexHome, 'gsd-file-manifest.json')),
|
||||
false,
|
||||
'no prior manifest existed, so the manifest the failed install wrote must be gone'
|
||||
);
|
||||
assert.strictEqual(
|
||||
fs.existsSync(path.join(codexHome, 'hooks')),
|
||||
false,
|
||||
'hooks/ must not exist at all after a clean-first-install rollback (nothing pre-existed)'
|
||||
);
|
||||
});
|
||||
|
||||
test('a malformed prior manifest degrades gracefully', () => {
|
||||
// No VERSION pre-exists, so the five-target restore must still remove the
|
||||
// one the failed install wrote -- an unreadable prior manifest must cost
|
||||
// the original restores nothing.
|
||||
fs.mkdirSync(codexHome, { recursive: true });
|
||||
fs.writeFileSync(path.join(codexHome, 'gsd-file-manifest.json'), 'garbage{{{', 'utf8');
|
||||
|
||||
forceValidationFailure();
|
||||
runFailingInstall();
|
||||
|
||||
assert.strictEqual(fs.existsSync(path.join(codexHome, 'gsd-core', 'VERSION')), false,
|
||||
'VERSION rollback must still work when the prior manifest is unreadable');
|
||||
});
|
||||
|
||||
test('a prior manifest that is a JSON array degrades gracefully', () => {
|
||||
fs.mkdirSync(codexHome, { recursive: true });
|
||||
fs.writeFileSync(path.join(codexHome, 'gsd-file-manifest.json'), '[]', 'utf8');
|
||||
|
||||
forceValidationFailure();
|
||||
runFailingInstall();
|
||||
|
||||
// The array file PRE-EXISTED, so it is pre-install state: rollback
|
||||
// restores those exact bytes rather than deleting them. What must be gone
|
||||
// is any manifest the failed install wrote over it.
|
||||
assert.strictEqual(fs.readFileSync(path.join(codexHome, 'gsd-file-manifest.json'), 'utf8'), '[]',
|
||||
'an array-shaped prior manifest is restored as pre-install state; rollback must not crash on it');
|
||||
});
|
||||
|
||||
test('a traversal-shaped manifest key is skipped, not written', () => {
|
||||
fs.mkdirSync(codexHome, { recursive: true });
|
||||
const canary = path.join(tmpDir, 'evil.txt');
|
||||
fs.writeFileSync(canary, 'do-not-touch', 'utf8');
|
||||
// `../evil.txt` resolves OUTSIDE codexHome — resolveInstallRelativePath
|
||||
// must reject it, and the snapshotter must skip the key entirely.
|
||||
seedPriorManifest(['../evil.txt']);
|
||||
|
||||
forceValidationFailure();
|
||||
runFailingInstall();
|
||||
|
||||
assert.strictEqual(fs.readFileSync(canary, 'utf8'), 'do-not-touch',
|
||||
'a traversal manifest key must never cause a write outside the install root');
|
||||
});
|
||||
|
||||
test('very early failure: the new restores stay idempotent before any capture', () => {
|
||||
fs.mkdirSync(codexHome, { recursive: true });
|
||||
|
||||
forceValidationFailure();
|
||||
runFailingInstall();
|
||||
|
||||
assert.strictEqual(fs.existsSync(path.join(codexHome, 'gsd-file-manifest.json')), false,
|
||||
'an empty home must still be empty after rollback');
|
||||
assert.strictEqual(fs.existsSync(path.join(codexHome, 'hooks')), false,
|
||||
'an empty home must have no hooks/ residue after rollback');
|
||||
});
|
||||
|
||||
test('minimal-mode rollback never touches a pre-existing hooks/ tree (capture gate)', () => {
|
||||
// BLOCKER regression (#4544 review): the capture gate is off in minimal
|
||||
// mode, and the restore must treat "no snapshot" as "do nothing" — never
|
||||
// as "hooks/ was absent". Seeds the profile marker so the in-process
|
||||
// install runs minimal without a validator-unreachable subprocess.
|
||||
fs.mkdirSync(path.join(codexHome, 'hooks'), { recursive: true });
|
||||
fs.writeFileSync(path.join(codexHome, 'hooks', 'gsd-check-update.js'), 'SENTINEL-OLD-HOOK', 'utf8');
|
||||
fs.writeFileSync(path.join(codexHome, 'hooks', 'user-backup.js'), 'PRECIOUS-USER-DATA', 'utf8');
|
||||
fs.writeFileSync(path.join(codexHome, '.gsd-profile'), 'core', 'utf8');
|
||||
|
||||
forceValidationFailure();
|
||||
runFailingInstall();
|
||||
|
||||
assert.strictEqual(
|
||||
fs.readFileSync(path.join(codexHome, 'hooks', 'gsd-check-update.js'), 'utf8'),
|
||||
'SENTINEL-OLD-HOOK',
|
||||
'minimal-mode rollback must preserve the pre-existing hook file'
|
||||
);
|
||||
assert.strictEqual(
|
||||
fs.readFileSync(path.join(codexHome, 'hooks', 'user-backup.js'), 'utf8'),
|
||||
'PRECIOUS-USER-DATA',
|
||||
'minimal-mode rollback must preserve user files under hooks/ (empty snapshot means do nothing)'
|
||||
);
|
||||
});
|
||||
|
||||
test('a symlink under hooks/ is never followed; downgrade preserves it uncaptured', (t) => {
|
||||
const canaryDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'gsd-4544-canary-'));
|
||||
t.after(() => cleanup(canaryDir));
|
||||
const canary = path.join(canaryDir, 'secret.txt');
|
||||
fs.writeFileSync(canary, 'DO-NOT-READ', 'utf8');
|
||||
|
||||
fs.mkdirSync(path.join(codexHome, 'hooks'), { recursive: true });
|
||||
const linkPath = path.join(codexHome, 'hooks', 'evil-link');
|
||||
try {
|
||||
fs.symlinkSync(canary, linkPath);
|
||||
} catch (_) {
|
||||
t.skip('symlinks unavailable on this platform/filesystem');
|
||||
return;
|
||||
}
|
||||
|
||||
forceValidationFailure();
|
||||
runFailingInstall();
|
||||
|
||||
assert.strictEqual(fs.readFileSync(canary, 'utf8'), 'DO-NOT-READ',
|
||||
'the symlink referent must never be read into the snapshot or written over');
|
||||
// The link makes the capture incomplete, so the restore downgrades to
|
||||
// per-file: nothing uncaptured is deleted — the pre-existing link is
|
||||
// pre-install state and survives, still pointing at its referent.
|
||||
assert.strictEqual(fs.readFileSync(linkPath, 'utf8'), 'DO-NOT-READ',
|
||||
'the preserved link must still resolve to the untouched referent');
|
||||
assert.strictEqual(fs.existsSync(path.join(codexHome, 'hooks', 'secret.txt')), false,
|
||||
'the referent bytes must not leak into the install tree as a regular file');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user