fix(#4544): cover hooks/, scripts/, CHANGELOG and the manifest in codex rollback (#4760)

* 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:
Tom Boucher
2026-09-15 02:37:18 -04:00
committed by GitHub
parent aeac47b95a
commit 7efd9032ee
4 changed files with 587 additions and 9 deletions

View File

@@ -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');
});
});
}