Merge pull request #3130 from shoyann/fix/preserve-codex-user-config

fix: preserve edited Codex user configuration during reinstall and repair
This commit is contained in:
Affaan Mustafa
2026-09-19 17:09:20 -04:00
committed by GitHub
5 changed files with 474 additions and 12 deletions
+24 -3
View File
@@ -9,6 +9,8 @@ const { loadInstallManifests } = require('./install-manifests');
const { readInstallState, validateInstallState } = require('./install-state');
const { assertWithinTrustedRoot } = require('./path-safety');
const { createInstallPlanFromRequest } = require('./install/runtime');
const { assertNoNewUserOwnedFile, prepareUserOwnedFileGuard } = require('./install/ownership-guard');
const { isCodexUserConfig } = require('./install/codex-user-config');
const { getRecordedHookConsent } = require('./install/hook-consent');
const {
prepareClaudeSkillMigration,
@@ -1871,7 +1873,7 @@ function assertValidInstallStateForWrite(state, label) {
throw new Error(`Invalid install-state (${label}): ${details}`);
}
function writeRefreshedInstallState(record, statePreview) {
function writeRefreshedInstallState(record, statePreview, writtenPaths = []) {
const trustedStatePreview = buildAdapterDerivedStatePreview(statePreview, record);
const stateWithCurrentDigests = {
...trustedStatePreview,
@@ -1879,6 +1881,19 @@ function writeRefreshedInstallState(record, statePreview) {
if (!operation.destinationPath) {
return { ...operation };
}
// Refreshing a ledger is not a file write. Keep the last installed digest
// for untouched shared configs so a concurrent user edit is never claimed.
if (isCodexUserConfig(record, operation)
&& !writtenPaths.some(writtenPath => path.relative(writtenPath, operation.destinationPath) === '')) {
const previousOperation = (record.state.operations || []).find(previous => (
previous.destinationPath
&& path.relative(previous.destinationPath, operation.destinationPath) === ''
));
const { contentSha256: _plannedDigest, ...operationWithoutDigest } = operation;
return previousOperation && previousOperation.contentSha256
? { ...operationWithoutDigest, contentSha256: previousOperation.contentSha256 }
: operationWithoutDigest;
}
try {
const contentSha256 = crypto.createHash('sha256')
.update(readFileNoFollow(operation.destinationPath))
@@ -1908,7 +1923,10 @@ function prepareRepairMigration(plan, record) {
installStatePath: record.installStatePath,
statePreview: buildAdapterDerivedStatePreview(plan.statePreview, record),
};
const migration = prepareClaudeSkillMigration(trustedPlan);
const skillMigration = prepareClaudeSkillMigration(trustedPlan);
const migration = record.adapter.id === 'codex-home'
? prepareUserOwnedFileGuard(trustedPlan, skillMigration)
: skillMigration;
return {
migration,
plan: {
@@ -2157,6 +2175,9 @@ function repairInstalledStates(options = {}) {
}
for (const operation of repairOperations) {
if (record.adapter.id === 'codex-home') {
assertNoNewUserOwnedFile(migration, operation, desiredPlan);
}
const repairedPath = executeRepairOperation(
context.repoRoot,
operation,
@@ -2192,7 +2213,7 @@ function repairInstalledStates(options = {}) {
installedAt: record.state.installedAt,
source: { ...record.state.source },
};
writeRefreshedInstallState(record, statePreviewToWrite);
writeRefreshedInstallState(record, statePreviewToWrite, repairedPaths);
return {
adapter: record.adapter,
+1 -1
View File
@@ -498,7 +498,7 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
if (typeof beforeOperationWrite === 'function') {
beforeOperationWrite({ plan: appliedPlan, operation });
}
assertNoNewUserOwnedFile(migration, operation);
assertNoNewUserOwnedFile(migration, operation, appliedPlan);
if (
operation.kind === 'update-claude-settings'
+54
View File
@@ -0,0 +1,54 @@
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { assertWithinTrustedRoot } = require('../path-safety');
function isCodexUserConfig(plan, operation) {
if (plan.adapter.id !== 'codex-home' || operation.kind !== 'copy-file') {
return false;
}
const relativePath = path.relative(plan.targetRoot, operation.destinationPath);
const name = process.platform === 'win32' ? relativePath.toLowerCase() : relativePath;
return name === 'config.toml' || name === (process.platform === 'win32' ? 'agents.md' : 'AGENTS.md');
}
function readConfigDigest(plan, destinationPath) {
assertWithinTrustedRoot(destinationPath, plan.targetRoot, 'inspect Codex user configuration');
const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0);
const descriptor = fs.openSync(destinationPath, flags);
try {
const opened = fs.fstatSync(descriptor, { bigint: true });
const current = fs.lstatSync(destinationPath, { bigint: true });
if (!opened.isFile() || !current.isFile() || current.isSymbolicLink()
|| opened.ino !== current.ino || opened.dev !== current.dev) {
throw new Error(`Refusing to inspect changed Codex configuration: ${destinationPath}`);
}
assertWithinTrustedRoot(destinationPath, plan.targetRoot, 'inspect Codex user configuration');
return crypto.createHash('sha256').update(fs.readFileSync(descriptor)).digest('hex');
} finally {
fs.closeSync(descriptor);
}
}
function hasEditedCodexUserConfig(plan, operation, previousOperation) {
if (!isCodexUserConfig(plan, operation)) {
return false;
}
let digest;
try {
digest = readConfigDigest(plan, operation.destinationPath);
} catch (error) {
if (error.code === 'ENOENT') {
return false; // A missing scaffold can still be restored.
}
throw error;
}
// Compare with the bytes ECC actually installed, never the newest template.
// Old ledgers without a digest cannot prove that an existing file is unchanged.
const recorded = previousOperation && previousOperation.contentSha256;
return !/^[a-f0-9]{64}$/i.test(recorded || '') || digest !== recorded.toLowerCase();
}
module.exports = { hasEditedCodexUserConfig, isCodexUserConfig };
+24 -8
View File
@@ -4,6 +4,7 @@ const fs = require('fs');
const path = require('path');
const { readInstallState } = require('../install-state');
const { hasEditedCodexUserConfig } = require('./codex-user-config');
function pathExists(filePath) {
try {
@@ -60,24 +61,32 @@ function prepareUserOwnedFileGuard(plan, migration) {
);
const managedDestinations = new Set(previousManagedOperations.keys());
const appliedOperations = [];
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 (migration && migration.appliedOperations) || []) {
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))
&& (!managedDestinations.has(comparablePath(operation.destinationPath)) || editedConfig)
) {
skippedOperations.push(operation);
warnings.push(
`Skipped user-owned file ${operation.destinationPath}: the existing file is not recorded in ECC install-state.`
);
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;
}
appliedOperations.push(operation);
}
if (skippedOperations.length === 0) {
@@ -87,6 +96,9 @@ function prepareUserOwnedFileGuard(plan, migration) {
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)));
@@ -125,7 +137,11 @@ function prepareUserOwnedFileGuard(plan, migration) {
};
}
function assertNoNewUserOwnedFile(migration, operation) {
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)) {
@@ -0,0 +1,371 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { test } = require('node:test');
const { createInstallPlanFromRequest } = require('../../scripts/lib/install/runtime');
const { applyInstallPlan, previewInstallPlan } = require('../../scripts/lib/install/apply');
const {
buildDoctorReport, repairInstalledStates, uninstallInstalledStates,
} = require('../../scripts/lib/install-lifecycle');
const { readInstallState, writeInstallState } = require('../../scripts/lib/install-state');
const SHARED_FILES = ['config.toml', 'AGENTS.md'];
const TEMPLATES = {
'config.toml': '# ECC defaults\nmodel = "example-model"\n',
'AGENTS.md': '# ECC instructions\n\nFollow the project conventions.\n',
};
function writeFile(filePath, content) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content);
}
function createFixture(t) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-codex-preservation-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const sourceRoot = path.join(root, 'source');
const homeDir = path.join(root, 'home');
const projectRoot = path.join(root, 'project');
const json = (relativePath, value) => writeFile(
path.join(sourceRoot, relativePath), `${JSON.stringify(value, null, 2)}\n`
);
json('package.json', { version: '1.0.0' });
json('manifests/install-modules.json', {
version: 1,
modules: [{
id: 'platform-configs', kind: 'platform', description: 'Codex configuration fixture',
paths: ['.codex'], targets: ['codex'], dependencies: [],
defaultInstall: true, cost: 'light', stability: 'stable',
}, {
id: 'helper-scripts', kind: 'platform', description: 'Independent helper fixture',
paths: ['scripts'], targets: ['codex'], dependencies: [],
defaultInstall: false, cost: 'light', stability: 'stable',
}],
});
json('manifests/install-profiles.json', {
version: 1, profiles: { minimal: { description: 'Fixture', modules: ['platform-configs'] } },
});
for (const name of SHARED_FILES) writeFile(path.join(sourceRoot, '.codex', name), TEMPLATES[name]);
writeFile(path.join(sourceRoot, 'scripts', 'independent-helper.js'), 'module.exports = "helper";\n');
fs.mkdirSync(homeDir, { recursive: true });
fs.mkdirSync(projectRoot, { recursive: true });
const options = { sourceRoot, homeDir, projectRoot, env: {} };
const lifecycleOptions = { repoRoot: sourceRoot, homeDir, projectRoot, targets: ['codex'], env: {} };
const plan = (moduleIds = ['platform-configs']) => createInstallPlanFromRequest({
mode: 'manifest', target: 'codex', profileId: null, moduleIds,
includeComponentIds: [], excludeComponentIds: [], hookConsent: 'declined',
}, options);
return {
sourceRoot,
destination: name => path.join(homeDir, '.codex', name),
statePath: path.join(homeDir, '.codex', 'ecc-install-state.json'),
plan,
install: moduleIds => applyInstallPlan(plan(moduleIds)),
repair: (dryRun = false) => repairInstalledStates({ ...lifecycleOptions, dryRun }),
doctor: () => buildDoctorReport(lifecycleOptions),
uninstall: () => uninstallInstalledStates(lifecycleOptions),
};
}
function lifecycleResult(report) {
assert.equal(report.results.length, 1);
assert.notEqual(report.results[0].status, 'error', report.results[0].error);
return report.results[0];
}
function assertPreserved(fixture, name, content) {
assert.deepEqual(fs.readFileSync(fixture.destination(name)), Buffer.from(content));
}
function assertUnmanaged(fixture, name) {
assert.ok(!readInstallState(fixture.statePath).operations.some(operation => (
operation.destinationPath === fixture.destination(name) && operation.ownership === 'managed'
)), `${name} must not remain managed after preserving user content`);
}
function assertWarning(result, name) {
assert.ok((result.warnings || []).some(warning => (
warning.includes(name) && /skip|preserv|user-owned|modif/i.test(warning)
)), `Expected an explicit preservation warning for ${name}`);
}
function editAfterRepairInspection(fixture, name, content, action) {
const originalOpen = fs.openSync;
const originalClose = fs.closeSync;
const inspectedDescriptors = new Set();
let injected = false;
fs.openSync = function (filePath, ...args) {
const descriptor = originalOpen.call(fs, filePath, ...args);
if (!injected && filePath === fixture.destination(name)
&& new Error().stack.includes('inspectManagedOperation')) {
inspectedDescriptors.add(descriptor);
}
return descriptor;
};
fs.closeSync = function (descriptor) {
const result = originalClose.call(fs, descriptor);
if (!injected && inspectedDescriptors.delete(descriptor)) {
// Inspection has read the previous bytes. Simulate an editor saving next,
// before repair checkpoints or refreshes state; no digest-refresh hook is used.
injected = true;
writeFile(fixture.destination(name), content);
}
return result;
};
try {
return { result: action(), injected };
} finally {
fs.openSync = originalOpen;
fs.closeSync = originalClose;
}
}
for (const name of SHARED_FILES) {
for (const stage of ['bridge', 'no-op refresh']) {
test(`repair ${stage} does not claim a concurrent edit to Codex ${name}`, t => {
const fixture = createFixture(t);
fixture.install();
const previousOperation = readInstallState(fixture.statePath).operations.find(operation => (
operation.destinationPath === fixture.destination(name)
));
if (stage === 'bridge') {
writeFile(path.join(fixture.sourceRoot, '.codex', name),
`${TEMPLATES[name]}\n# Updated upstream template\n`);
}
const content = `${TEMPLATES[name]}\r\n# Saved after repair inspected the file\r\n`;
const { result: report, injected } = editAfterRepairInspection(
fixture, name, content, () => fixture.repair()
);
assert.ok(injected, 'The simulated edit must occur after repair inspection');
assert.equal(report.results.length, 1);
if (stage === 'bridge') {
assert.equal(report.results[0].status, 'error');
assert.match(report.results[0].error, /Refusing.*user configuration.*changed after planning/);
} else {
assert.equal(report.results[0].status, 'ok');
}
assertPreserved(fixture, name, content);
const refreshedOperation = readInstallState(fixture.statePath).operations.find(operation => (
operation.destinationPath === fixture.destination(name) && operation.ownership === 'managed'
));
assert.ok(refreshedOperation,
'Repair must retain the previous ledger entry for configuration it did not write');
assert.equal(refreshedOperation.contentSha256, previousOperation.contentSha256,
'Repair must retain the previous digest for configuration it did not write');
lifecycleResult(fixture.uninstall());
assertPreserved(fixture, name, content);
});
}
test(`selective reinstall releases edited Codex ${name} retained from an earlier module`, t => {
const fixture = createFixture(t);
fixture.install();
const content = `${TEMPLATES[name]}\n# Keep this across unrelated module installations\n`;
writeFile(fixture.destination(name), content);
const selectivePlan = fixture.plan(['helper-scripts']);
assert.ok(!selectivePlan.operations.some(operation => (
operation.destinationPath === fixture.destination(name)
)), 'The edited configuration must not be in the selected module operations');
const result = applyInstallPlan(selectivePlan);
assertPreserved(fixture, name, content);
assertUnmanaged(fixture, name);
assertWarning(result, name);
lifecycleResult(fixture.repair());
assertPreserved(fixture, name, content);
assertUnmanaged(fixture, name);
lifecycleResult(fixture.uninstall());
assertPreserved(fixture, name, content);
});
test(`reinstall rejects a last-minute edit to Codex ${name} without claiming the edited bytes`, t => {
const fixture = createFixture(t);
fixture.install();
const destination = fixture.destination(name);
const previousOperation = readInstallState(fixture.statePath).operations.find(operation => (
operation.destinationPath === destination
));
const rawPlan = fixture.plan();
// Write the other file first to exercise the partial-install checkpoint on failure.
const plan = {
...rawPlan,
operations: [
...rawPlan.operations.filter(operation => operation.destinationPath !== destination),
...rawPlan.operations.filter(operation => operation.destinationPath === destination),
],
};
const content = `${TEMPLATES[name]}\r\n# Saved while ECC was running\r\n`;
let wroteAnotherFile = false;
let injectedEdit = false;
assert.throws(() => applyInstallPlan(plan, {
beforeOperationWrite({ operation }) {
if (operation.destinationPath !== destination) {
wroteAnotherFile = true;
return;
}
assert.ok(wroteAnotherFile, 'The failure must exercise a partial install');
writeFile(destination, content);
injectedEdit = true;
},
}), /Refusing.*user configuration.*changed after planning/);
assert.ok(injectedEdit);
assertPreserved(fixture, name, content);
const checkpointOperation = readInstallState(fixture.statePath).operations.find(operation => (
operation.destinationPath === destination && operation.ownership === 'managed'
));
assert.ok(checkpointOperation,
'A failure checkpoint must retain the previous ledger entry');
assert.equal(checkpointOperation.contentSha256, previousOperation.contentSha256,
'A failure checkpoint must retain the old digest, never adopt the user edit');
lifecycleResult(fixture.uninstall());
assertPreserved(fixture, name, content);
});
test(`repeated repair keeps edited Codex ${name} unmanaged while repairing an ECC script`, t => {
const fixture = createFixture(t);
const scriptName = path.join('scripts', 'ecc-helper.js');
const scriptContent = 'module.exports = "ECC helper";\n';
writeFile(path.join(fixture.sourceRoot, '.codex', scriptName), scriptContent);
fixture.install();
const content = `${TEMPLATES[name]}\n# Keep my preferences\n`;
writeFile(fixture.destination(name), content);
lifecycleResult(fixture.repair());
assertPreserved(fixture, name, content);
assertUnmanaged(fixture, name);
for (let attempt = 0; attempt < 2; attempt += 1) {
const before = lifecycleResult(fixture.doctor());
assert.ok(!before.issues.some(issue => issue.code === 'drifted-managed-files'));
writeFile(fixture.destination(scriptName), 'damaged ECC helper\n');
const damaged = lifecycleResult(fixture.doctor());
assert.ok(damaged.issues.some(issue => issue.code === 'drifted-managed-files'));
const result = lifecycleResult(fixture.repair());
assert.ok(result.repairedPaths.includes(fixture.destination(scriptName)));
assertPreserved(fixture, scriptName, scriptContent);
assertPreserved(fixture, name, content);
assertUnmanaged(fixture, name);
const after = lifecycleResult(fixture.doctor());
assert.ok(!after.issues.some(issue => issue.code === 'drifted-managed-files'));
}
lifecycleResult(fixture.uninstall());
assertPreserved(fixture, name, content);
assert.ok(!fs.existsSync(fixture.destination(scriptName)));
});
for (const action of ['reinstall', 'repair']) {
test(`${action} preserves edited Codex ${name} and leaves it safe to uninstall`, t => {
const fixture = createFixture(t);
fixture.install();
const content = `${TEMPLATES[name]}\r\n# Personal preferences — 保留\r\n`;
writeFile(fixture.destination(name), content);
const result = action === 'reinstall' ? fixture.install() : lifecycleResult(fixture.repair());
assertPreserved(fixture, name, content);
assertWarning(result, name);
assertUnmanaged(fixture, name);
lifecycleResult(fixture.uninstall());
assertPreserved(fixture, name, content);
});
}
test(`dry runs warn about edited Codex ${name} without changing files or state`, t => {
const fixture = createFixture(t);
fixture.install();
const content = `${TEMPLATES[name]}\n# User customization\n`;
writeFile(fixture.destination(name), content);
const previousState = fs.readFileSync(fixture.statePath);
const preview = previewInstallPlan(fixture.plan());
const repairPreview = lifecycleResult(fixture.repair(true));
assertPreserved(fixture, name, content);
assert.deepEqual(fs.readFileSync(fixture.statePath), previousState);
assertWarning(preview, name);
assertWarning(repairPreview, name);
assert.ok(!preview.operations.some(operation => operation.destinationPath === fixture.destination(name)));
assert.ok(!repairPreview.plannedRepairs.includes(fixture.destination(name)));
});
test(`pre-existing Codex ${name} survives install, repair and uninstall`, t => {
const fixture = createFixture(t);
const content = '# Personal file before ECC installation\r\n保持原样\r\n';
writeFile(fixture.destination(name), content);
assertWarning(fixture.install(), name);
assertPreserved(fixture, name, content);
assertUnmanaged(fixture, name);
const repairResult = lifecycleResult(fixture.repair());
assertPreserved(fixture, name, content);
assertWarning(repairResult, name);
assertUnmanaged(fixture, name);
lifecycleResult(fixture.uninstall());
assertPreserved(fixture, name, content);
});
for (const action of ['reinstall', 'repair']) {
test(`${action} preserves Codex ${name} when legacy state lacks its content digest`, t => {
const fixture = createFixture(t);
fixture.install();
const state = readInstallState(fixture.statePath);
writeInstallState(fixture.statePath, {
...state,
operations: state.operations.map(operation => {
if (operation.destinationPath !== fixture.destination(name)) return operation;
const { contentSha256: _contentSha256, ...legacyOperation } = operation;
return legacyOperation;
}),
});
// Even bytes equal to today's template cannot prove ownership without a recorded digest.
const content = fs.readFileSync(fixture.destination(name));
const result = action === 'reinstall' ? fixture.install() : lifecycleResult(fixture.repair());
assertPreserved(fixture, name, content);
assertWarning(result, name);
assertUnmanaged(fixture, name);
lifecycleResult(fixture.uninstall());
assertPreserved(fixture, name, content);
});
}
for (const action of ['reinstall', 'repair']) {
test(`${action} updates unedited Codex ${name} when the template changes`, t => {
const fixture = createFixture(t);
fixture.install();
const updated = `${TEMPLATES[name]}\n# New upstream default\n`;
writeFile(path.join(fixture.sourceRoot, '.codex', name), updated);
if (action === 'reinstall') fixture.install();
else lifecycleResult(fixture.repair());
assertPreserved(fixture, name, updated);
assert.ok(readInstallState(fixture.statePath).operations.some(operation => (
operation.destinationPath === fixture.destination(name) && operation.ownership === 'managed'
)));
lifecycleResult(fixture.uninstall());
assert.ok(!fs.existsSync(fixture.destination(name)));
});
}
test(`repair restores missing managed Codex ${name}`, t => {
const fixture = createFixture(t);
fixture.install();
const installedContent = fs.readFileSync(fixture.destination(name));
fs.unlinkSync(fixture.destination(name));
lifecycleResult(fixture.repair());
assertPreserved(fixture, name, installedContent);
});
}