mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-19 06:23:46 +02:00
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.
This commit is contained in:
@@ -140,6 +140,13 @@ function printHumanPlan(plan, dryRun) {
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(plan.reconciledStalePaths) && plan.reconciledStalePaths.length > 0) {
|
||||
console.log('\nRemoved files no longer in the install plan:');
|
||||
for (const removedPath of plan.reconciledStalePaths) {
|
||||
console.log(`- removed ${removedPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!dryRun) {
|
||||
console.log(`\nDone. Install-state written to ${plan.installStatePath}`);
|
||||
}
|
||||
|
||||
@@ -50,6 +50,12 @@ const {
|
||||
completeExcludedPathsReconciliation,
|
||||
prepareExcludedPathsReconciliation,
|
||||
} = require('./excluded-paths-reconciliation');
|
||||
const {
|
||||
completeStaleOperationsReconciliation,
|
||||
describeStaleOperationsPreview,
|
||||
prepareStaleOperationsReconciliation,
|
||||
withoutStaleOperations,
|
||||
} = require('./stale-operations-reconciliation');
|
||||
const { buildInstallIndex, rewriteRelativeLinks } = require('./link-rewrite');
|
||||
const { transformInstallContent } = require('./content-transform');
|
||||
|
||||
@@ -672,9 +678,12 @@ function prepareHookConsentMigration(plan, migration) {
|
||||
|
||||
function previewInstallPlan(plan) {
|
||||
assertOpenCodeHookDeactivationReady(plan);
|
||||
const migration = prepareHookConsentMigration(
|
||||
const migration = prepareStaleOperationsReconciliation(
|
||||
plan,
|
||||
prepareUserOwnedFileGuard(plan, prepareClaudeSkillMigration(plan))
|
||||
prepareHookConsentMigration(
|
||||
plan,
|
||||
prepareUserOwnedFileGuard(plan, prepareClaudeSkillMigration(plan))
|
||||
)
|
||||
);
|
||||
const appliedPlan = {
|
||||
...plan,
|
||||
@@ -686,7 +695,7 @@ function previewInstallPlan(plan) {
|
||||
: [];
|
||||
return {
|
||||
...plan,
|
||||
statePreview: migration.finalState,
|
||||
statePreview: withoutStaleOperations(migration.finalState, migration),
|
||||
plannedOperations: [...plan.operations],
|
||||
operations: migration.appliedOperations,
|
||||
skippedOperations: migration.skippedOperations,
|
||||
@@ -694,6 +703,7 @@ function previewInstallPlan(plan) {
|
||||
...(Array.isArray(plan.warnings) ? plan.warnings : []),
|
||||
...migration.warnings,
|
||||
...hookConsentWarnings,
|
||||
...describeStaleOperationsPreview(migration),
|
||||
],
|
||||
applied: false,
|
||||
};
|
||||
@@ -736,11 +746,14 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
|
||||
assertOpenCodeLeaseCoverage(plan, dependencies.opencodeLease);
|
||||
const legacyActivation = inspectLegacyOpenCodeDeactivation(plan);
|
||||
const activationSnapshot = assertOpenCodeHookDeactivationReady(plan);
|
||||
const migration = prepareExcludedPathsReconciliation(
|
||||
const migration = prepareStaleOperationsReconciliation(
|
||||
plan,
|
||||
prepareHookConsentMigration(
|
||||
prepareExcludedPathsReconciliation(
|
||||
plan,
|
||||
prepareUserOwnedFileGuard(plan, prepareClaudeSkillMigration(plan))
|
||||
prepareHookConsentMigration(
|
||||
plan,
|
||||
prepareUserOwnedFileGuard(plan, prepareClaudeSkillMigration(plan))
|
||||
)
|
||||
)
|
||||
);
|
||||
const appliedPlan = {
|
||||
@@ -902,10 +915,14 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
|
||||
);
|
||||
}
|
||||
|
||||
// Include preserved user configs omitted from the write plan: they must
|
||||
// still be inactive before we record a completed install.
|
||||
// Preserve main's activation safety gate before publishing ownership.
|
||||
assertOpenCodeHookDeactivationReady(plan, { requireInactive: true });
|
||||
finalState = stateWithContentDigests(migration.finalState, appliedPlan);
|
||||
// Stale records leave only the successful final state; the bridge and
|
||||
// failure checkpoint above keep them so a failed install retains ownership.
|
||||
finalState = stateWithContentDigests(
|
||||
withoutStaleOperations(migration.finalState, migration),
|
||||
appliedPlan
|
||||
);
|
||||
if (typeof beforeInstallStateWrite === 'function') {
|
||||
beforeInstallStateWrite({ plan: appliedPlan, state: finalState });
|
||||
}
|
||||
@@ -994,6 +1011,18 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
|
||||
];
|
||||
}
|
||||
|
||||
let stalePathsRemoved = [];
|
||||
let stalePathsWarnings = [];
|
||||
try {
|
||||
const staleReconciliation = completeStaleOperationsReconciliation(migration, appliedPlan);
|
||||
stalePathsRemoved = staleReconciliation.removedPaths;
|
||||
stalePathsWarnings = staleReconciliation.warnings;
|
||||
} catch (error) {
|
||||
stalePathsWarnings = [
|
||||
`Stale install-state reconciliation did not finish: ${error.message}. Files ECC no longer installs were preserved; remove them manually if unwanted.`,
|
||||
];
|
||||
}
|
||||
|
||||
return {
|
||||
...plan,
|
||||
statePreview: finalState,
|
||||
@@ -1001,12 +1030,14 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
|
||||
operations: migration.appliedOperations,
|
||||
skippedOperations: migration.skippedOperations,
|
||||
reconciledExcludedPaths: excludedPathsRemoved,
|
||||
reconciledStalePaths: stalePathsRemoved,
|
||||
warnings: [
|
||||
...(Array.isArray(plan.warnings) ? plan.warnings : []),
|
||||
...migration.warnings,
|
||||
...antigravityMigrationWarnings,
|
||||
...opencodeMigrationWarnings,
|
||||
...excludedPathsWarnings,
|
||||
...stalePathsWarnings,
|
||||
],
|
||||
applied: true,
|
||||
};
|
||||
|
||||
@@ -243,6 +243,7 @@ function createDisabledMigration(plan, previousState) {
|
||||
bridgeState: finalState,
|
||||
finalState,
|
||||
legacyOperationsToRemove: [],
|
||||
retainedLegacyOperations: [],
|
||||
requiresBridgeState: plan.operations.length > 0,
|
||||
};
|
||||
}
|
||||
@@ -380,6 +381,10 @@ function prepareClaudeSkillMigration(plan) {
|
||||
bridgeState: states.bridgeState,
|
||||
finalState: states.finalState,
|
||||
legacyOperationsToRemove: states.legacyOperationsToRemove,
|
||||
// Nested legacy copies this migration deliberately keeps (user-owned flat
|
||||
// conflicts, or skills this plan does not install). Stale-operation
|
||||
// reconciliation must not treat them as obsolete.
|
||||
retainedLegacyOperations: [...classification.retainedLegacyOperations],
|
||||
requiresBridgeState: states.requiresBridgeState,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
'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,
|
||||
};
|
||||
Reference in New Issue
Block a user