diff --git a/scripts/lib/install-lifecycle.js b/scripts/lib/install-lifecycle.js index ec9cb1d80..b2f967b91 100644 --- a/scripts/lib/install-lifecycle.js +++ b/scripts/lib/install-lifecycle.js @@ -13,6 +13,10 @@ const { getRecordedHookConsent } = require('./install/hook-consent'); const { prepareClaudeSkillMigration, } = require('./install/claude-skill-migration'); +const { + prepareLegacyAgentsMigration, + removeLegacyAgentsFiles, +} = require('./install/legacy-agents-migration'); const { getLegacyAntigravityLocation, inspectLegacyAntigravityState, @@ -1908,7 +1912,10 @@ function prepareRepairMigration(plan, record) { installStatePath: record.installStatePath, statePreview: buildAdapterDerivedStatePreview(plan.statePreview, record), }; - const migration = prepareClaudeSkillMigration(trustedPlan); + const migration = prepareLegacyAgentsMigration( + trustedPlan, + prepareClaudeSkillMigration(trustedPlan) + ); return { migration, plan: { @@ -2131,10 +2138,16 @@ function repairInstalledStates(options = {}) { const legacyMigrationPaths = migration.legacyOperationsToRemove.map( operation => operation.destinationPath ); + const legacyAgentsMigrationPaths = migration.legacyAgentsOperationsToRemove.map( + operation => operation.destinationPath + ); + const hasLegacyAgentsStateMigration = migration.legacyAgentsOperationsToDetach.length > 0; const plannedRepairs = [...new Set([ ...(needsOpencodeBuild ? [opencodeBuildRepairPath] : []), ...repairOperations.map(operation => operation.destinationPath), ...legacyMigrationPaths, + ...legacyAgentsMigrationPaths, + ...(hasLegacyAgentsStateMigration ? [record.installStatePath] : []), ])]; if (options.dryRun) { @@ -2150,7 +2163,8 @@ function repairInstalledStates(options = {}) { }; } - const hasLegacyMigration = migration.legacyOperationsToRemove.length > 0; + const hasLegacyMigration = migration.legacyOperationsToRemove.length > 0 + || hasLegacyAgentsStateMigration; const repairedPaths = needsOpencodeBuild ? [opencodeBuildRepairPath] : []; if (migration.requiresBridgeState && (repairOperations.length > 0 || hasLegacyMigration)) { writeRefreshedInstallState(record, migration.bridgeState); @@ -2181,6 +2195,8 @@ function repairInstalledStates(options = {}) { repairedPaths.push(removedPath); } } + removeLegacyAgentsFiles(migration, record.targetRoot); + repairedPaths.push(...legacyAgentsMigrationPaths.filter(filePath => !fs.existsSync(filePath))); } const changedInstalledBytes = repairOperations.length > 0 || needsOpencodeBuild diff --git a/scripts/lib/install/apply.js b/scripts/lib/install/apply.js index fbab1293b..7ecc05fa0 100644 --- a/scripts/lib/install/apply.js +++ b/scripts/lib/install/apply.js @@ -22,6 +22,10 @@ const { } = require('./claude-settings'); const { filterMcpConfig, parseDisabledMcpServers } = require('../mcp-config'); const { assertWithinTrustedRoot } = require('../path-safety'); +const { + prepareLegacyAgentsMigration, + removeLegacyAgentsFiles, +} = require('./legacy-agents-migration'); const { assertSafeClaudeSkillOperation, prepareClaudeSkillMigration, @@ -398,7 +402,10 @@ function prepareHookConsentMigration(plan, migration) { function previewInstallPlan(plan) { const migration = prepareHookConsentMigration( plan, - prepareUserOwnedFileGuard(plan, prepareClaudeSkillMigration(plan)) + prepareUserOwnedFileGuard( + plan, + prepareLegacyAgentsMigration(plan, prepareClaudeSkillMigration(plan)) + ) ); const appliedPlan = { ...plan, @@ -451,7 +458,10 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals } const migration = prepareHookConsentMigration( plan, - prepareUserOwnedFileGuard(plan, prepareClaudeSkillMigration(plan)) + prepareUserOwnedFileGuard( + plan, + prepareLegacyAgentsMigration(plan, prepareClaudeSkillMigration(plan)) + ) ); const appliedPlan = { ...plan, @@ -590,6 +600,7 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals if (hasLegacyMigration) { removeLegacyClaudeSkillFiles(migration, plan.targetRoot); } + removeLegacyAgentsFiles(migration, plan.targetRoot); if (shouldSetClaudeCommitAttributionPreference(appliedPlan)) { writeClaudeCommitAttributionPreference( diff --git a/scripts/lib/install/legacy-agents-migration.js b/scripts/lib/install/legacy-agents-migration.js new file mode 100644 index 000000000..1e613da83 --- /dev/null +++ b/scripts/lib/install/legacy-agents-migration.js @@ -0,0 +1,172 @@ +'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 HOME_TARGET_IDS = new Set(['claude-home', 'codex-home']); + +function comparablePath(filePath) { + const resolvedPath = path.resolve(filePath); + return process.platform === 'win32' ? resolvedPath.toLowerCase() : resolvedPath; +} + +function normalizeRelativePath(relativePath) { + return String(relativePath || '').replace(/\\/g, '/'); +} + +function isLegacyAgentsOperation(plan, operation) { + if ( + !operation + || operation.kind !== 'copy-file' + || operation.ownership !== 'managed' + || typeof operation.destinationPath !== 'string' + ) { + return false; + } + + const sourceRelativePath = normalizeRelativePath(operation.sourceRelativePath); + if (!sourceRelativePath.startsWith('.agents/')) { + return false; + } + + const expectedDestination = path.join( + plan.targetRoot, + ...sourceRelativePath.split('/') + ); + return comparablePath(operation.destinationPath) === comparablePath(expectedDestination); +} + +function inspectLegacyAgentsOperation(plan, operation) { + assertWithinTrustedRoot( + operation.destinationPath, + plan.targetRoot, + 'inspect legacy managed .agents file' + ); + + let stat; + try { + stat = fs.lstatSync(operation.destinationPath); + } catch (error) { + if (error && error.code === 'ENOENT') { + return { removable: false, warning: null }; + } + throw error; + } + + if (!stat.isFile() || stat.isSymbolicLink()) { + return { + removable: false, + warning: `Preserved legacy .agents path ${operation.destinationPath}: it is not a regular file.`, + }; + } + + if (!/^[a-f0-9]{64}$/i.test(operation.contentSha256 || '')) { + return { + removable: false, + warning: `Preserved legacy .agents file ${operation.destinationPath}: install-state has no verified content digest.`, + }; + } + + const contentSha256 = crypto.createHash('sha256') + .update(fs.readFileSync(operation.destinationPath)) + .digest('hex'); + if (contentSha256 !== operation.contentSha256.toLowerCase()) { + return { + removable: false, + warning: `Preserved modified legacy .agents file ${operation.destinationPath} and released it from ECC management.`, + }; + } + + return { removable: true, warning: null }; +} + +function prepareLegacyAgentsMigration(plan, migration) { + if (!HOME_TARGET_IDS.has(plan && plan.adapter && plan.adapter.id)) { + return { + ...migration, + legacyAgentsOperationsToDetach: [], + legacyAgentsOperationsToRemove: [], + }; + } + + const previousState = fs.existsSync(plan.installStatePath) + ? readInstallState(plan.installStatePath) + : null; + const legacyOperations = ((previousState && previousState.operations) || []) + .filter(operation => isLegacyAgentsOperation(plan, operation)); + if (legacyOperations.length === 0) { + return { + ...migration, + legacyAgentsOperationsToDetach: [], + legacyAgentsOperationsToRemove: [], + }; + } + + const inspections = legacyOperations.map(operation => ({ + operation, + ...inspectLegacyAgentsOperation(plan, operation), + })); + const legacyDestinations = new Set( + legacyOperations.map(operation => comparablePath(operation.destinationPath)) + ); + const withoutLegacyAgents = state => ({ + ...state, + operations: (state.operations || []).filter(operation => ( + !legacyDestinations.has(comparablePath(operation.destinationPath)) + )), + }); + + return { + ...migration, + finalState: withoutLegacyAgents(migration.finalState), + legacyAgentsOperationsToDetach: legacyOperations, + legacyAgentsOperationsToRemove: inspections + .filter(inspection => inspection.removable) + .map(inspection => inspection.operation), + requiresBridgeState: true, + warnings: [ + ...(migration.warnings || []), + ...inspections.map(inspection => inspection.warning).filter(Boolean), + ], + }; +} + +function cleanupEmptyAgentsParents(filePath, targetRoot) { + const agentsRoot = path.join(targetRoot, '.agents'); + let currentPath = path.dirname(filePath); + + while (comparablePath(currentPath) !== comparablePath(targetRoot)) { + assertWithinTrustedRoot(currentPath, targetRoot, 'clean legacy managed .agents directory'); + if (!fs.existsSync(currentPath) || fs.readdirSync(currentPath).length > 0) { + return; + } + fs.rmdirSync(currentPath); + if (comparablePath(currentPath) === comparablePath(agentsRoot)) { + return; + } + currentPath = path.dirname(currentPath); + } +} + +function removeLegacyAgentsFiles(migration, targetRoot) { + for (const operation of migration.legacyAgentsOperationsToRemove || []) { + const latestInspection = inspectLegacyAgentsOperation( + { targetRoot }, + operation + ); + if (!latestInspection.removable) { + continue; + } + fs.rmSync(operation.destinationPath, { force: true }); + cleanupEmptyAgentsParents(operation.destinationPath, targetRoot); + } +} + +module.exports = { + prepareLegacyAgentsMigration, + removeLegacyAgentsFiles, +};