Files
ECC/scripts/lib/install/stale-operations-reconciliation.js
T
haelyra e9f8c3ca66 Merge PR #3256: reconcile obsolete managed installer files safely
Preserve main OpenCode lease and activation gates and native Claude namespace receipts. Detect same-size in-place edits using bigint modification and change times during hashing and before deletion; verify both races and actual public CLI reinstall lifecycles.
2026-10-10 17:49:10 -04:00

291 lines
11 KiB
JavaScript

'use strict';
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { assertWithinTrustedRoot } = require('../path-safety');
const { isHookRuntimeOperation } = require('./hook-consent');
/**
* Stale managed copy-file reconciliation (issue #3232).
*
* Install-state is cumulative: every reinstall merges the previous managed
* operations with the new plan so selective `--modules` installs keep earlier
* ownership. Without reconciliation, a copy-file operation that a selected
* module no longer ships (a removed source, or a file whose kind/destination
* changed) stays in install-state forever and doctor reports it as missing or
* drifted on every run.
*
* prepareStaleOperationsReconciliation identifies those records: managed
* copy-file operations whose module is selected by the current plan but whose
* destination is no longer planned by any operation. The records stay in the
* bridge/checkpoint states so a failed install keeps its ownership;
* withoutStaleOperations drops them only from the successful final state and
* the dry-run preview. completeStaleOperationsReconciliation runs after the
* final state is written and deletes a destination only when it is an
* unchanged regular file with a recorded digest, reached without symlinks,
* inside the target root. Anything else is preserved with one orphan warning.
*/
const SHA256_PATTERN = /^[a-f0-9]{64}$/i;
function comparablePath(filePath) {
const resolvedPath = path.resolve(filePath);
return process.platform === 'win32' ? resolvedPath.toLowerCase() : resolvedPath;
}
function destinationKeys(operations) {
return new Set((operations || [])
.filter(operation => operation && typeof operation.destinationPath === 'string')
.map(operation => comparablePath(operation.destinationPath)));
}
function materializesWholeModules(plan) {
// Legacy language installs are cumulative inside one module: pure legacy
// plans share a module id across languages and Antigravity legacy-compat
// filters rules-core by language, so an absent operation is not stale there.
if (plan.mode === 'legacy') {
return false;
}
const request = plan.statePreview && plan.statePreview.request;
return !(request && request.legacyMode && plan.target === 'antigravity');
}
function hooksDeclined(plan) {
const request = plan.statePreview && plan.statePreview.request;
return plan.hookConsent === 'declined' || Boolean(request && request.hookConsent === 'declined');
}
function prepareStaleOperationsReconciliation(plan, migration) {
const unchanged = { ...migration, staleOperationCandidates: [] };
const finalOperations = migration && migration.finalState && migration.finalState.operations;
const resolution = plan && plan.statePreview && plan.statePreview.resolution;
if (!Array.isArray(finalOperations) || !resolution || !materializesWholeModules(plan)) {
return unchanged;
}
const selectedModules = new Set(resolution.selectedModules || []);
const plannedDestinations = destinationKeys([
...(plan.operations || []),
...(plan.statePreview.operations || []),
]);
// Claude flat-skill migration deliberately keeps nested legacy copies that
// conflict with user-owned flat skills; those records are not stale.
const retainedLegacyDestinations = destinationKeys(migration.retainedLegacyOperations);
const skipHookRuntime = hooksDeclined(plan);
const seen = new Set();
const staleOperationCandidates = finalOperations.filter(operation => {
if (
!operation
|| operation.kind !== 'copy-file'
|| operation.ownership !== 'managed'
|| typeof operation.destinationPath !== 'string'
|| !selectedModules.has(operation.moduleId)
// Declining hooks strips hook files without the module dropping them.
|| (skipHookRuntime && isHookRuntimeOperation(operation))
) {
return false;
}
const key = comparablePath(operation.destinationPath);
if (plannedDestinations.has(key) || retainedLegacyDestinations.has(key) || seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
return { ...migration, staleOperationCandidates };
}
function withoutStaleOperations(state, migration) {
const staleDestinations = destinationKeys(migration && migration.staleOperationCandidates);
if (!state || !Array.isArray(state.operations) || staleDestinations.size === 0) {
return state;
}
return {
...state,
operations: state.operations.filter(operation => !(
operation
&& operation.kind === 'copy-file'
&& typeof operation.destinationPath === 'string'
&& staleDestinations.has(comparablePath(operation.destinationPath))
)),
};
}
function describeStaleOperationsPreview(migration) {
const count = ((migration && migration.staleOperationCandidates) || []).length;
if (count === 0) {
return [];
}
return [
`${count} previously managed file(s) are no longer part of this install plan; `
+ 'applying it removes their install-state records and deletes only unchanged copies.',
];
}
class PreservedStaleFile extends Error {}
function lstatOrNull(filePath) {
try {
return fs.lstatSync(filePath, { bigint: true });
} catch (error) {
if (error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')) {
return null;
}
throw error;
}
}
// Returns the destination's lstat, or null when it is absent. Every segment
// from the root to the destination is checked with lstat so an in-root
// symlink can never redirect the delete onto its target.
function inspectDestinationPath(destinationPath, targetRoot) {
const resolvedRoot = path.resolve(targetRoot);
const resolvedTarget = path.resolve(destinationPath);
const relativePath = path.relative(resolvedRoot, resolvedTarget);
if (
!relativePath
|| relativePath === '..'
|| relativePath.startsWith(`..${path.sep}`)
|| path.isAbsolute(relativePath)
) {
throw new PreservedStaleFile('it is outside the install root');
}
assertWithinTrustedRoot(resolvedTarget, resolvedRoot, 'reconcile stale install file');
const segments = relativePath.split(path.sep);
let currentPath = resolvedRoot;
for (let index = 0; index <= segments.length; index += 1) {
if (index > 0) {
currentPath = path.join(currentPath, segments[index - 1]);
}
const stat = lstatOrNull(currentPath);
if (!stat) {
return null;
}
if (stat.isSymbolicLink()) {
throw new PreservedStaleFile(index === segments.length
? 'it is a symlink'
: `its path passes through the symlink ${currentPath}`);
}
if (index < segments.length && !stat.isDirectory()) {
throw new PreservedStaleFile(`${currentPath} is not a directory`);
}
if (index === segments.length && !stat.isFile()) {
throw new PreservedStaleFile('it is not a regular file');
}
if (index === segments.length) {
return stat;
}
}
return null;
}
function hashFileNoFollow(filePath) {
const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0);
const descriptor = fs.openSync(filePath, flags);
try {
const before = fs.fstatSync(descriptor, { bigint: true });
if (!before.isFile()) {
throw new PreservedStaleFile('it is not a regular file');
}
const content = fs.readFileSync(descriptor);
const after = fs.fstatSync(descriptor, { bigint: true });
if (!sameFile(before, after)) {
throw new PreservedStaleFile('it changed while being verified');
}
return {
digest: crypto.createHash('sha256').update(content).digest('hex'),
stat: after,
};
} finally {
fs.closeSync(descriptor);
}
}
function sameFile(left, right) {
return Boolean(left && right)
&& left.ino === right.ino
&& left.dev === right.dev
&& left.size === right.size
&& left.mtimeNs === right.mtimeNs
&& left.ctimeNs === right.ctimeNs;
}
function removeEmptyParents(filePath, targetRoot) {
const resolvedRoot = path.resolve(targetRoot);
let currentPath = path.dirname(path.resolve(filePath));
while (
comparablePath(currentPath) !== comparablePath(resolvedRoot)
&& comparablePath(currentPath).startsWith(`${comparablePath(resolvedRoot)}${path.sep}`)
) {
const stat = lstatOrNull(currentPath);
if (!stat || stat.isSymbolicLink() || !stat.isDirectory() || fs.readdirSync(currentPath).length > 0) {
return;
}
fs.rmdirSync(currentPath);
currentPath = path.dirname(currentPath);
}
}
// Removes one stale destination. Returns true when removed, false when it was
// already absent, and throws PreservedStaleFile when it must be kept.
function removeUnchangedStaleFile(candidate, targetRoot) {
const initialStat = inspectDestinationPath(candidate.destinationPath, targetRoot);
if (!initialStat) {
return false;
}
if (typeof candidate.contentSha256 !== 'string' || !SHA256_PATTERN.test(candidate.contentSha256)) {
throw new PreservedStaleFile('its record has no content digest, so it cannot be verified unchanged');
}
const destinationPath = path.resolve(candidate.destinationPath);
const hashed = hashFileNoFollow(destinationPath);
if (hashed.digest !== candidate.contentSha256.toLowerCase()) {
throw new PreservedStaleFile('its content changed after install');
}
// Revalidate right before unlinking; the path must still name the exact
// regular file that was hashed.
const finalStat = inspectDestinationPath(destinationPath, targetRoot);
if (!sameFile(initialStat, hashed.stat) || !sameFile(finalStat, hashed.stat)) {
throw new PreservedStaleFile('it changed while being verified');
}
fs.unlinkSync(destinationPath);
try {
removeEmptyParents(destinationPath, targetRoot);
} catch (_error) {
// Pruning emptied directories is best effort; the stale file is gone.
}
return true;
}
function completeStaleOperationsReconciliation(migration, plan) {
const removedPaths = [];
const warnings = [];
for (const candidate of (migration && migration.staleOperationCandidates) || []) {
try {
if (removeUnchangedStaleFile(candidate, plan.targetRoot)) {
removedPaths.push(path.resolve(candidate.destinationPath));
}
} catch (error) {
const reason = error instanceof PreservedStaleFile
? error.message
: `it could not be verified or removed (${error.message})`;
warnings.push(
`Preserved orphaned file ${candidate.destinationPath}: ECC no longer installs it and `
+ `stopped tracking it, but ${reason}; remove it manually if unwanted.`
);
}
}
return { removedPaths, warnings };
}
module.exports = {
completeStaleOperationsReconciliation,
describeStaleOperationsPreview,
prepareStaleOperationsReconciliation,
withoutStaleOperations,
};