mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-15 04:35:29 +02:00
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.
291 lines
11 KiB
JavaScript
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,
|
|
};
|