mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-20 16:47:59 +02:00
fix: reconcile pre-exclusion .agents installs on claude and codex home upgrades
This commit is contained in:
@@ -34,6 +34,10 @@ const {
|
||||
preserveUnwrittenFiles,
|
||||
} = require('./ownership-guard');
|
||||
const { cleanupLegacyOpencodeInstall } = require('./opencode-legacy-migration');
|
||||
const {
|
||||
completeExcludedPathsReconciliation,
|
||||
prepareExcludedPathsReconciliation,
|
||||
} = require('./excluded-paths-reconciliation');
|
||||
const { buildInstallIndex, rewriteRelativeLinks } = require('./link-rewrite');
|
||||
const { adaptAntigravityAgent } = require('./antigravity-agent');
|
||||
|
||||
@@ -449,9 +453,12 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
|
||||
if (typeof beforeInstallStateRead === 'function') {
|
||||
beforeInstallStateRead({ plan });
|
||||
}
|
||||
const migration = prepareHookConsentMigration(
|
||||
const migration = prepareExcludedPathsReconciliation(
|
||||
plan,
|
||||
prepareUserOwnedFileGuard(plan, prepareClaudeSkillMigration(plan))
|
||||
prepareHookConsentMigration(
|
||||
plan,
|
||||
prepareUserOwnedFileGuard(plan, prepareClaudeSkillMigration(plan))
|
||||
)
|
||||
);
|
||||
const appliedPlan = {
|
||||
...plan,
|
||||
@@ -666,17 +673,31 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
|
||||
];
|
||||
}
|
||||
|
||||
let excludedPathsRemoved = [];
|
||||
let excludedPathsWarnings = [];
|
||||
try {
|
||||
const excludedReconciliation = completeExcludedPathsReconciliation(migration, appliedPlan);
|
||||
excludedPathsRemoved = excludedReconciliation.removedPaths;
|
||||
excludedPathsWarnings = excludedReconciliation.warnings;
|
||||
} catch (error) {
|
||||
excludedPathsWarnings = [
|
||||
`Excluded-paths reconciliation did not finish: ${error.message}. Previously managed files under excluded source paths were preserved; remove them manually or rerun the install.`,
|
||||
];
|
||||
}
|
||||
|
||||
return {
|
||||
...plan,
|
||||
statePreview: finalState,
|
||||
plannedOperations: [...plan.operations],
|
||||
operations: migration.appliedOperations,
|
||||
skippedOperations: migration.skippedOperations,
|
||||
reconciledExcludedPaths: excludedPathsRemoved,
|
||||
warnings: [
|
||||
...(Array.isArray(plan.warnings) ? plan.warnings : []),
|
||||
...migration.warnings,
|
||||
...antigravityMigrationWarnings,
|
||||
...opencodeMigrationWarnings,
|
||||
...excludedPathsWarnings,
|
||||
],
|
||||
applied: true,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { readInstallState } = require('../install-state');
|
||||
const { assertWithinTrustedRoot } = require('../path-safety');
|
||||
const { getInstallTargetAdapter } = require('../install-targets/registry');
|
||||
|
||||
/**
|
||||
* Upgrade reconciliation for excluded source paths (issue #3116).
|
||||
*
|
||||
* Adapters can declare `excludedSourcePaths` (today: `.agents` for the Claude
|
||||
* and Codex home targets). The exclusion stops new copy operations from being
|
||||
* planned, but a home install created before the exclusion still has the
|
||||
* copied files on disk and the copy operations recorded in install-state, so
|
||||
* doctor keeps reporting drift and repair keeps restoring files the target
|
||||
* never reads.
|
||||
*
|
||||
* prepareExcludedPathsReconciliation runs before the new state is written: it
|
||||
* reads the previous install-state and drops the recorded managed operations
|
||||
* whose source path is now excluded. completeExcludedPathsReconciliation runs
|
||||
* after a successful apply: it removes the files those operations recorded,
|
||||
* but only when the recorded content digest still matches, and prunes the
|
||||
* emptied directories. Files the state does not own, modified files,
|
||||
* symlinks, and anything outside the target root are preserved with a
|
||||
* warning.
|
||||
*/
|
||||
|
||||
function comparablePath(filePath) {
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
return process.platform === 'win32' ? resolvedPath.toLowerCase() : resolvedPath;
|
||||
}
|
||||
|
||||
function getReconcilingAdapter(plan) {
|
||||
if (!plan || typeof plan.target !== 'string') {
|
||||
return null;
|
||||
}
|
||||
let adapter;
|
||||
try {
|
||||
adapter = getInstallTargetAdapter(plan.target);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return adapter && typeof adapter.excludesSourcePath === 'function' ? adapter : null;
|
||||
}
|
||||
|
||||
function isRecordedExcludedManagedOperation(adapter, operation) {
|
||||
return Boolean(
|
||||
operation
|
||||
&& operation.ownership === 'managed'
|
||||
&& typeof operation.destinationPath === 'string'
|
||||
&& typeof operation.sourceRelativePath === 'string'
|
||||
&& adapter.excludesSourcePath(operation.sourceRelativePath)
|
||||
);
|
||||
}
|
||||
|
||||
function filterStateOperations(state, shouldDrop) {
|
||||
if (!state || !Array.isArray(state.operations)) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
operations: state.operations.filter(operation => !shouldDrop(operation)),
|
||||
};
|
||||
}
|
||||
|
||||
function prepareExcludedPathsReconciliation(plan, migration) {
|
||||
const adapter = getReconcilingAdapter(plan);
|
||||
if (!adapter || !fs.existsSync(plan.installStatePath)) {
|
||||
return { ...migration, excludedPathCandidates: [] };
|
||||
}
|
||||
|
||||
const previousState = readInstallState(plan.installStatePath);
|
||||
const candidates = ((previousState && previousState.operations) || [])
|
||||
.filter(operation => isRecordedExcludedManagedOperation(adapter, operation));
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return { ...migration, excludedPathCandidates: [] };
|
||||
}
|
||||
|
||||
const droppedDestinations = new Set(
|
||||
candidates.map(operation => comparablePath(operation.destinationPath))
|
||||
);
|
||||
const shouldDrop = operation => Boolean(
|
||||
operation
|
||||
&& typeof operation.destinationPath === 'string'
|
||||
&& droppedDestinations.has(comparablePath(operation.destinationPath))
|
||||
&& typeof operation.sourceRelativePath === 'string'
|
||||
&& adapter.excludesSourcePath(operation.sourceRelativePath)
|
||||
);
|
||||
|
||||
return {
|
||||
...migration,
|
||||
bridgeState: filterStateOperations(migration.bridgeState, shouldDrop),
|
||||
finalState: filterStateOperations(migration.finalState, shouldDrop),
|
||||
excludedPathCandidates: candidates,
|
||||
};
|
||||
}
|
||||
|
||||
function pathExists(filePath) {
|
||||
try {
|
||||
fs.lstatSync(filePath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error && error.code === 'ENOENT') {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
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 Error(`Refusing to read a non-file at ${filePath}`);
|
||||
}
|
||||
const content = fs.readFileSync(descriptor);
|
||||
const after = fs.fstatSync(descriptor, { bigint: true });
|
||||
const finalPathStat = fs.lstatSync(filePath, { bigint: true });
|
||||
const unchanged = before.dev === after.dev
|
||||
&& before.ino === after.ino
|
||||
&& before.size === after.size
|
||||
&& after.dev === finalPathStat.dev
|
||||
&& after.ino === finalPathStat.ino
|
||||
&& after.size === finalPathStat.size;
|
||||
if (finalPathStat.isSymbolicLink() || !finalPathStat.isFile() || !unchanged) {
|
||||
throw new Error(`Refusing to read a file that changed during validation: ${filePath}`);
|
||||
}
|
||||
return crypto.createHash('sha256').update(content).digest('hex');
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function removeEmptyParents(startPath, targetRoot) {
|
||||
let currentPath = path.dirname(startPath);
|
||||
while (comparablePath(currentPath) !== comparablePath(targetRoot)) {
|
||||
const safePath = assertWithinTrustedRoot(
|
||||
currentPath,
|
||||
targetRoot,
|
||||
'reconcile excluded install paths'
|
||||
);
|
||||
if (!pathExists(safePath)) {
|
||||
currentPath = path.dirname(safePath);
|
||||
continue;
|
||||
}
|
||||
const stat = fs.lstatSync(safePath);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink() || fs.readdirSync(safePath).length > 0) {
|
||||
return;
|
||||
}
|
||||
fs.rmdirSync(safePath);
|
||||
currentPath = path.dirname(safePath);
|
||||
}
|
||||
}
|
||||
|
||||
function completeExcludedPathsReconciliation(migration, plan) {
|
||||
const candidates = (migration && migration.excludedPathCandidates) || [];
|
||||
const removedPaths = [];
|
||||
const warnings = [];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.kind !== 'copy-file') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let safePath;
|
||||
try {
|
||||
safePath = assertWithinTrustedRoot(
|
||||
candidate.destinationPath,
|
||||
plan.targetRoot,
|
||||
'reconcile excluded install paths'
|
||||
);
|
||||
} catch (error) {
|
||||
warnings.push(
|
||||
`Preserved previously managed file ${candidate.destinationPath}: ${error.message}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!pathExists(safePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const stat = fs.lstatSync(safePath);
|
||||
if (stat.isSymbolicLink() || !stat.isFile()) {
|
||||
warnings.push(
|
||||
`Preserved previously managed file ${safePath}: it is not a regular file; remove it manually if unwanted.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof candidate.contentSha256 !== 'string') {
|
||||
warnings.push(
|
||||
`Preserved previously managed file ${safePath}: the recorded operation has no content digest, so the file cannot be verified unchanged; remove it manually if unwanted.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let currentDigest;
|
||||
try {
|
||||
currentDigest = hashFileNoFollow(safePath);
|
||||
} catch (error) {
|
||||
warnings.push(`Preserved previously managed file ${safePath}: ${error.message}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentDigest !== candidate.contentSha256.toLowerCase()) {
|
||||
warnings.push(
|
||||
`Preserved previously managed file ${safePath}: content changed after install; remove it manually if unwanted.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
fs.unlinkSync(safePath);
|
||||
removedPaths.push(safePath);
|
||||
removeEmptyParents(safePath, plan.targetRoot);
|
||||
}
|
||||
|
||||
return { removedPaths, warnings };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
completeExcludedPathsReconciliation,
|
||||
prepareExcludedPathsReconciliation,
|
||||
};
|
||||
Reference in New Issue
Block a user