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

@@ -319,7 +319,14 @@ function readInstallManifest(configDir: string): InstallManifest {
version: typeof m.version === 'string' ? m.version : null,
timestamp: typeof m.timestamp === 'string' ? m.timestamp : null,
mode: typeof m.mode === 'string' ? m.mode : null,
files: m.files && typeof m.files === 'object' ? m.files as Record<string, string> : {},
// #4544 (review): `typeof [] === 'object'` — a manifest whose `files` is a
// JSON array passed the object-shape guard, and Object.keys() then yielded
// "0","1",... as install-relative file paths. Consumers iterate these keys,
// so an array shape must degrade to the empty set exactly like a
// non-object shape does.
files: m.files && typeof m.files === 'object' && !Array.isArray(m.files)
? m.files as Record<string, string>
: {},
manifestVersion: normalizeManifestVersion(m.manifestVersion),
runtime: normalizeReportedRuntime(rawRuntime),
scope: isInstallScopeId(m.scope) ? m.scope : null,