Files
ECC/scripts/lib/install/ownership-guard.js
T

176 lines
7.2 KiB
JavaScript

'use strict';
const fs = require('fs');
const path = require('path');
const { readInstallState } = require('../install-state');
const { hasEditedCodexUserConfig } = require('./codex-user-config');
function pathExists(filePath) {
try {
fs.lstatSync(filePath);
return true;
} catch (error) {
if (error && error.code === 'ENOENT') {
return false;
}
throw error;
}
}
function comparablePath(filePath) {
const resolvedPath = path.resolve(filePath);
return process.platform === 'win32' ? resolvedPath.toLowerCase() : resolvedPath;
}
/**
* #2964: the shared copy path used to write every copy-file operation
* unconditionally and record the destination as `ownership: 'managed'` even
* when the file already existed and was authored by the user. The visible
* symptom is a lost edit; the dangerous one is the install-state record,
* which makes a later uninstall delete the user's file.
*
* This guard generalises the Claude flat-skill migration conflict pattern to
* every adapter copy operation: when a destination exists and is NOT recorded
* as an ECC-managed operation in the previous install-state, the operation is
* skipped with a warning instead of overwriting and claiming ownership.
*
* All managed targets share this ownership boundary (#2964).
*/
function prepareUserOwnedFileGuard(plan, migration) {
const previousState = pathExists(plan.installStatePath)
? readInstallState(plan.installStatePath)
: null;
if (previousState && (
previousState.target.id !== plan.adapter.id
|| comparablePath(previousState.target.root) !== comparablePath(plan.targetRoot)
|| comparablePath(previousState.target.installStatePath) !== comparablePath(plan.installStatePath)
)) {
throw new Error(`Refusing install: install-state target does not match the current plan at ${plan.installStatePath}.`);
}
// Recorded files remain updateable by reinstall/repair. Preserve their prior
// digests if an attempt fails before writing them so uninstall detects drift.
const previousManagedOperations = new Map(
((previousState && previousState.operations) || [])
.filter(operation => (
operation
&& operation.ownership === 'managed'
&& operation.destinationPath
))
.map(operation => [comparablePath(operation.destinationPath), operation])
);
const managedDestinations = new Set(previousManagedOperations.keys());
const plannedOperations = (migration && migration.appliedOperations) || [];
const plannedDestinations = new Set(plannedOperations.map(operation => comparablePath(operation.destinationPath)));
// Selective reinstalls retain earlier modules in the ledger. Inspect those
// entries too, without turning them into additional writes in this install.
const retainedOperations = ((migration.finalState && migration.finalState.operations) || [])
.filter(operation => !plannedDestinations.has(comparablePath(operation.destinationPath))
&& previousManagedOperations.has(comparablePath(operation.destinationPath)));
const skippedOperations = [];
const warnings = [];
for (const operation of [...plannedOperations, ...retainedOperations]) {
const previousOperation = previousManagedOperations.get(comparablePath(operation.destinationPath));
const editedConfig = previousOperation
&& hasEditedCodexUserConfig(plan, operation, previousOperation);
if (
operation
&& operation.kind === 'copy-file'
&& operation.destinationPath
&& pathExists(operation.destinationPath)
&& (!managedDestinations.has(comparablePath(operation.destinationPath)) || editedConfig)
) {
skippedOperations.push(operation);
warnings.push(editedConfig
? `Preserved user configuration ${operation.destinationPath}: changed or unverifiable since installation. ECC no longer manages this file; apply future configuration updates manually.`
: `Skipped user-owned file ${operation.destinationPath}: the existing file is not recorded in ECC install-state.`);
continue;
}
}
if (skippedOperations.length === 0) {
return { ...migration, managedDestinations, previousManagedOperations };
}
const skippedDestinations = new Set(
skippedOperations.map(operation => comparablePath(operation.destinationPath))
);
const appliedOperations = plannedOperations.filter(operation => (
!skippedDestinations.has(comparablePath(operation.destinationPath))
));
const filterStateOperations = operations => (operations || [])
.filter(operation => !skippedDestinations.has(comparablePath(operation.destinationPath)));
// Never leave a skipped destination inside the install-state: recording it
// would claim ownership of a file ECC did not create and make uninstall
// delete it (#2964).
const bridgeState = migration.bridgeState
? {
...migration.bridgeState,
operations: filterStateOperations(migration.bridgeState.operations),
}
: migration.bridgeState;
const finalState = migration.finalState
? {
...migration.finalState,
operations: filterStateOperations(migration.finalState.operations),
}
: migration.finalState;
return {
...migration,
managedDestinations,
previousManagedOperations,
appliedOperations,
skippedOperations: [
...((migration && migration.skippedOperations) || []),
...skippedOperations,
],
warnings: [...((migration && migration.warnings) || []), ...warnings],
bridgeState,
finalState,
// Only keep bridge persistence when operations actually remain; a fully
// skipped plan installs nothing and must not claim anything.
requiresBridgeState: Boolean(migration.requiresBridgeState)
&& appliedOperations.length > 0,
};
}
function assertNoNewUserOwnedFile(migration, operation, plan) {
const previousOperation = migration.previousManagedOperations.get(comparablePath(operation.destinationPath));
if (plan && hasEditedCodexUserConfig(plan, operation, previousOperation)) {
throw new Error(`Refusing to overwrite user configuration changed after planning: ${operation.destinationPath}. Rerun to preserve it.`);
}
if (operation.kind !== 'copy-file'
|| migration.managedDestinations.has(comparablePath(operation.destinationPath))
|| !pathExists(operation.destinationPath)) {
return;
}
throw new Error(`Refusing install: a user-owned file appeared at ${operation.destinationPath} after planning. Rerun the installer to preserve it.`);
}
function preserveUnwrittenFiles(state, migration, writtenDestinations) {
const writtenPaths = new Set([...writtenDestinations].map(comparablePath));
return {
...state,
operations: state.operations.filter(operation => (
operation.kind !== 'copy-file'
|| migration.managedDestinations.has(comparablePath(operation.destinationPath))
|| writtenPaths.has(comparablePath(operation.destinationPath))
|| !pathExists(operation.destinationPath)
)).map(operation => {
const destination = comparablePath(operation.destinationPath);
return operation.kind === 'copy-file' && !writtenPaths.has(destination)
? migration.previousManagedOperations.get(destination) || operation
: operation;
}),
};
}
module.exports = {
assertNoNewUserOwnedFile,
prepareUserOwnedFileGuard,
preserveUnwrittenFiles,
};