fix(#3929): seed install-time capability validation with the merged registry (#4691)

* test(#3929): regression tests for singleton-map install validation

* fix(#3929): seed install-time cross-capability validation with the merged registry

* fix(#3929): seed install-time cross-capability validation with the merged registry

* fix(#3929): drop a seed overlay whose suite run throws, mirroring load

* test(#3929): match the issue repro tier so the live tier-monotone check passes it

* test(#3929): give the poisoned step its required onError field

* test(#3929): planted overlays must satisfy the full manifest contract

* chore(#3929): backfill changeset PR number (4691)

* fix(#3929): honor the generator override in central keys and skip reserved-id overlays in the seed

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-09-13 08:12:54 -04:00
committed by GitHub
parent 0763326ced
commit 4f487e4e75
4 changed files with 321 additions and 7 deletions

View File

@@ -461,6 +461,139 @@ function ledgerOverlayIds(ledger: LedgerModule, rootDir: string): {
return { pending, committed };
}
// ─── #3929: install-time cross-capability validation seed ──────────────────
/** First-party capabilities from the frozen registry, keyed by id. */
function firstPartyCaps(): Record<string, unknown> {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const base = require('./capability-registry.cjs') as { capabilities?: Record<string, unknown> };
return base.capabilities ?? {};
}
/**
* Central config-schema validKeys for the ownership-exclusivity check — the
* same source `loadRegistry`'s generator path reads, with the same
* missing-schema fallback (empty set). Memoized per process: the manifest is
* static for the lifetime of the runtime.
*/
let _centralKeysMemo: Set<string> | null = null;
function centralConfigKeys(): Set<string> {
if (_centralKeysMemo === null) {
// Same generator seam loadRegistry uses (including the test override), so
// install-time and load-time central keys cannot diverge when the
// generator is stubbed.
if (_generatorOverride) {
_centralKeysMemo = _generatorOverride.loadCentralConfigKeys();
} else {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const mod = require('../../../scripts/gen-capability-registry.cjs') as {
loadCentralConfigKeys: () => Set<string>;
};
_centralKeysMemo = mod.loadCentralConfigKeys();
} catch {
_centralKeysMemo = new Set<string>();
}
}
}
return _centralKeysMemo;
}
/**
* #3929: the validation seed `capability-source.stageValidated` runs the
* cross-capability suite against. Built the way `loadRegistry` builds its
* accepted map: first-party registry capabilities, then each COMMITTED overlay
* of the TARGET (global) install scope accepted INCREMENTALLY — structural
* validation, engines.gsd against the running host, then the FULL
* cross-capability suite; an overlay joins only if the suite stays clean
* after adding it (load's invariant: first-party alone is clean, so any new
* error is that overlay's fault — skip it, never fail the seed). The seed is
* therefore CLEAN BY CONSTRUCTION: pre-existing junk in the install scope
* (colliding entries, shape-invalid manifests, engines-incompatible bundles)
* is skipped exactly as load skips it and can never fail or skew a
* candidate's install decision — a repo-planted ledger cannot veto installs
* (#1459 CB-3).
*
* Scope: only the install TARGET scope is walked — `stageValidated` promotes
* into `${gsdHome}/.gsd/capabilities`, so target scope == global.
* Project-scope overlays are deliberately NOT seeded (they additionally
* require user consent to activate at load; the issue asks for overlays "in
* the target scope"). The candidate is added by the caller LAST, mirroring
* `acceptedMap.set(id, cap)`.
*
* Exported (not inlined in the installer) so the semantics this reuses —
* `overlayRoots`, `ledgerOverlayIds`, the shared bounded manifest reader, and
* the incremental-accept rules — have exactly one owner and cannot drift from
* the loader's load-time rules.
*/
export function crossValidationSeed(
cwd: string,
gsdHome: string,
hostVersion: string,
validator: ValidatorModule,
semver: SemverModule,
): { capMap: Map<string, unknown>; centralKeys: Set<string> } {
const fp = firstPartyCaps();
const capMap = new Map<string, unknown>(Object.entries(fp));
const centralKeys = centralConfigKeys();
// eslint-disable-next-line @typescript-eslint/no-require-imports
const ledger: LedgerModule = require('./capability-ledger.cjs') as LedgerModule;
for (const root of overlayRoots(cwd, gsdHome)) {
if (root.scope !== 'global') continue; // seed the TARGET scope only
const { committed } = ledgerOverlayIds(ledger, root.dir);
for (const id of committed) {
// First-party always wins (CONTEXT.md capability-loader entry): an
// overlay claiming a first-party id — or a reserved first-party prefix
// — is rejected at load, so it must not join the validation set.
if (Object.prototype.hasOwnProperty.call(fp, id)) continue;
if (RESERVED_ID_PREFIX.test(id)) continue;
let cap: unknown;
try {
const manifestPath = path.join(root.dir, id, 'capability.json');
const raw = ledger.readSmallRegularFile(manifestPath, MANIFEST_MAX_BYTES);
if (raw === null) continue; // missing/non-regular/oversized — skip fail-closed
cap = JSON.parse(raw);
} catch {
continue; // unreadable overlay — skip (same rule as load)
}
// Same per-overlay pre-filters load applies before the cross suite:
// structural validity, then engines.gsd against the running host.
// validateCapability itself is not total over malformed array entries
// (#1461 finding 1) — a throw skips the overlay, as load skips it.
try {
if (validator.validateCapability(cap, id).length > 0) continue;
} catch {
continue;
}
const engines = (cap as Record<string, unknown>)['engines'];
if (engines && typeof engines === 'object' && !Array.isArray(engines)) {
const range = (engines as Record<string, unknown>)['gsd'];
if (typeof range === 'string' && range && !semver.semverSatisfies(hostVersion, range)) continue;
}
// Incremental accept: the overlay joins only if the FULL suite stays
// clean after adding it; any error is that overlay's fault — skip it.
capMap.set(id, cap);
let errs: string[] = [];
try {
errs = [
...validator.validateConsumesGlobal(capMap),
...validator.validateCrossCapability(capMap, centralKeys),
];
} catch {
// The cross validators are not total over arbitrary shapes (#1461
// finding 1 — e.g. the duplicate-producer invariant throws). A
// throwing overlay must be REMOVED here, exactly as load's
// acceptedMap.delete(id) does — retaining it would leave poisoning
// content in the seed and attribute pre-existing junk to the
// candidate.
capMap.delete(id);
}
if (errs.length > 0) capMap.delete(id);
}
}
return { capMap, centralKeys };
}
/** Shallow-attach overlay diagnostics WITHOUT mutating the frozen registry module. */
function withOverlayMeta(reg: Registry, meta: OverlayMeta): Registry {
return Object.assign({}, reg, { _overlay: meta });
@@ -902,4 +1035,4 @@ export function loadRegistry(options: LoadRegistryOptions = {}): Registry {
}
// readHostVersion is exported for the #1920 regression (VERSION-first host-version resolution).
module.exports = { loadRegistry, readHostVersion, _setValidatorForTest, _setGeneratorForTest };
module.exports = { loadRegistry, readHostVersion, crossValidationSeed, _setValidatorForTest, _setGeneratorForTest };

View File

@@ -833,8 +833,39 @@ function stageValidated(opts: {
}
// Cross-capability validations (contract, consumes, cross-capability).
const capMap = new Map<string, unknown>([[id, cap]]);
const centralKeys = new Set<string>();
//
// #3929: seed the validation set the way the loader builds its accepted
// map — frozen first-party registry, then each committed overlay of the
// TARGET (global) install scope accepted incrementally (structural +
// engines + full-suite-clean), then the candidate LAST (mirroring
// `acceptedMap.set(id, cap)`). The singleton seed
// `new Map([[id, cap]])` this replaced made every non-empty `requires`
// unsatisfiable (membership is checked against the map) and left cycles,
// tier-monotone and central config-key exclusivity vacuous at install.
// The seed builder lives in capability-loader so the overlay semantics
// have one owner and are clean by construction — pre-existing junk in the
// install scope is skipped, never attributed to the candidate. No swallow:
// if the loader cannot build the seed the install fails loudly — silently
// degrading to the singleton map would re-hide #3929.
/* eslint-disable @typescript-eslint/no-require-imports */
const seedLoader = require('./capability-loader.cjs') as {
crossValidationSeed: (
cwd: string,
gsdHome: string,
hostVersion: string,
validator: ValidatorModule,
semver: { semverSatisfies: (version: unknown, range: unknown) => boolean },
) => { capMap: Map<string, unknown>; centralKeys: Set<string> };
};
/* eslint-enable @typescript-eslint/no-require-imports */
const { capMap, centralKeys } = seedLoader.crossValidationSeed(
process.cwd(),
gsdHome,
hostVersion,
capValidator,
semverMod,
);
capMap.set(id, cap);
const crossErrs = [
...capValidator.validateAgainstContract(cap, id),
...capValidator.validateConsumesGlobal(capMap),