Files
msd-core/src/installer-migrations/002-codex-legacy-hooks-json.cts
Tom Boucher cdd78bd2aa fix(#670): self-healing recovery for installer-migration checksum drift (#675)
Editing the body of an already-released installer migration drifts its computed
checksum (it hashes plan.toString()). The integrity guard then hard-aborted
every prior install on upgrade with "applied migration checksum changed" — a
100% reproducible blocker (v1.3.0, all platforms).

Already-applied migrations are filtered out of `pending` and never re-run, so
a drifted checksum is functionally inert. ADR-0008 anticipates checksum-mismatch
state as something the install-state layer must handle gracefully (plan -> apply
-> recover/report), not abort on.

This supersedes the published-checksum allowlist merged in #674 (per-release
maintenance debt — every historical checksum hand-pinned, still throws for any
unregistered value) with a general, self-healing recovery:

- Replace the throwing guard with non-fatal `collectAppliedChecksumDrift`,
  surfaced on `plan.checksumDrift`.
- Reconcile drifted stored checksums durably on the next state write
  (`reconcileDriftedChecksums`), idempotently (no perpetual writes).
- Relocate the "shipped migration bodies are immutable" rule to a CI baseline
  test that locks every shipped migration's checksum and fails on body drift —
  where #615 should have been caught, instead of blocking users.

Removes #674's legacyChecksums field, per-migration checksum pins,
published-checksums.json fixture, and compat test.

Fixes #670

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 13:44:05 -04:00

139 lines
4.0 KiB
TypeScript

/**
* Installer migration: remove legacy Codex hooks.json GSD hook registrations.
*
* ADR-457 build-at-publish: the hand-written
* bin/lib/installer-migrations/002-codex-legacy-hooks-json.cjs collapsed to a
* TypeScript source of truth. Behaviour is preserved byte-for-behaviour from
* the prior hand-written .cjs; only types are added.
*/
import { isManagedHookCommand } from '../shell-command-projection.cjs';
type JsonValue =
| string
| number
| boolean
| null
| undefined
| JsonValue[]
| { [key: string]: JsonValue };
interface PruneResult {
value: JsonValue;
changed: boolean;
}
interface HooksJsonRead {
exists: boolean;
error?: boolean;
value?: JsonValue;
}
interface MigrationAction {
type: string;
relPath: string;
value: JsonValue;
deleteIfEmpty: boolean;
reason: string;
ownershipEvidence: string;
}
interface MigrationPlanContext {
configDir: string;
readJson(relPath: string): HooksJsonRead;
}
interface InstallerMigration {
id: string;
title: string;
description: string;
introducedIn: string;
runtimes: string[];
scopes: string[];
destructive: boolean;
runtimeContract: string;
plan: (ctx: MigrationPlanContext) => MigrationAction[];
}
function isStructurallyEmpty(value: JsonValue): boolean {
if (value === null || value === undefined) return true;
if (Array.isArray(value)) return value.length === 0;
if (typeof value !== 'object') return false;
for (const _key in value) return false;
return true;
}
function isManagedCodexHookCommand(command: unknown, configDir: string): boolean {
return isManagedHookCommand(command, {
surface: 'codex-hooks-json',
includeLegacyAliases: true,
configDir,
});
}
function pruneLegacyCodexHooksJsonValue(value: JsonValue, configDir: string): PruneResult {
if (Array.isArray(value)) {
let changed = false;
const next: JsonValue[] = [];
for (const item of value) {
const pruned = pruneLegacyCodexHooksJsonValue(item, configDir);
if (pruned.changed) changed = true;
if (pruned.changed && isStructurallyEmpty(pruned.value)) changed = true;
else next.push(pruned.value);
}
return { value: next, changed };
}
if (value && typeof value === 'object' && !Array.isArray(value)) {
const valueObj = value as Record<string, JsonValue>;
const command = valueObj['command'];
if (isManagedCodexHookCommand(command, configDir)) {
return { value: null, changed: true };
}
let changed = false;
const next: { [key: string]: JsonValue } = {};
for (const [key, child] of Object.entries(valueObj)) {
const pruned = pruneLegacyCodexHooksJsonValue(child, configDir);
if (pruned.changed) changed = true;
if (pruned.changed && isStructurallyEmpty(pruned.value)) changed = true;
else next[key] = pruned.value;
}
return { value: next, changed };
}
return { value, changed: false };
}
const migration: InstallerMigration = {
id: '2026-05-11-codex-legacy-hooks-json',
title: 'Remove legacy Codex hooks.json GSD hook registrations',
description: 'Remove legacy Codex hooks.json GSD hook registrations after config.toml migration.',
introducedIn: '1.50.0',
runtimes: ['codex'],
scopes: ['global', 'local'],
destructive: true,
runtimeContract: 'docs/installer-migrations.md#runtime-configuration-contract-registry Codex row',
plan: (ctx: MigrationPlanContext): MigrationAction[] => {
const { configDir } = ctx;
const hooksJson = ctx.readJson('hooks.json');
if (!hooksJson.exists || hooksJson.error) return [];
const pruned = pruneLegacyCodexHooksJsonValue(hooksJson.value, configDir);
if (!pruned.changed) return [];
return [
{
type: 'rewrite-json',
relPath: 'hooks.json',
value: pruned.value,
deleteIfEmpty: true,
reason: 'legacy Codex hooks.json GSD registration retired by installer migration',
ownershipEvidence: 'pruned command matches generated GSD hook command under the install hooks directory',
},
];
},
};
export = migration;