From c0ee74778925a24a844357095d6722d476a41600 Mon Sep 17 00:00:00 2001 From: Yann Roberto <1922498827@qq.com> Date: Tue, 15 Sep 2026 14:47:27 +0800 Subject: [PATCH 1/2] fix: preserve edited Codex user configuration --- scripts/lib/install-lifecycle.js | 27 +- scripts/lib/install/apply.js | 2 +- scripts/lib/install/codex-user-config.js | 54 +++ scripts/lib/install/ownership-guard.js | 32 +- .../install-codex-config-preservation.test.js | 371 ++++++++++++++++++ 5 files changed, 474 insertions(+), 12 deletions(-) create mode 100644 scripts/lib/install/codex-user-config.js create mode 100644 tests/lib/install-codex-config-preservation.test.js diff --git a/scripts/lib/install-lifecycle.js b/scripts/lib/install-lifecycle.js index ec9cb1d80..7da0ae78a 100644 --- a/scripts/lib/install-lifecycle.js +++ b/scripts/lib/install-lifecycle.js @@ -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, diff --git a/scripts/lib/install/apply.js b/scripts/lib/install/apply.js index fbab1293b..0ff5c0280 100644 --- a/scripts/lib/install/apply.js +++ b/scripts/lib/install/apply.js @@ -491,7 +491,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' diff --git a/scripts/lib/install/codex-user-config.js b/scripts/lib/install/codex-user-config.js new file mode 100644 index 000000000..5b7aa612c --- /dev/null +++ b/scripts/lib/install/codex-user-config.js @@ -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 }; diff --git a/scripts/lib/install/ownership-guard.js b/scripts/lib/install/ownership-guard.js index 962c62e88..965761cdc 100644 --- a/scripts/lib/install/ownership-guard.js +++ b/scripts/lib/install/ownership-guard.js @@ -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)) { diff --git a/tests/lib/install-codex-config-preservation.test.js b/tests/lib/install-codex-config-preservation.test.js new file mode 100644 index 000000000..f8b9d12fe --- /dev/null +++ b/tests/lib/install-codex-config-preservation.test.js @@ -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' + )); + if (refreshedOperation) { + 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' + )); + if (checkpointOperation) { + 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); + }); +} From 08094a34f960714f63e3dd7a28e0df2438ec98f2 Mon Sep 17 00:00:00 2001 From: Yann Roberto <1922498827@qq.com> Date: Tue, 15 Sep 2026 15:11:52 +0800 Subject: [PATCH 2/2] test: require preserved Codex ledger entries --- .../install-codex-config-preservation.test.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/lib/install-codex-config-preservation.test.js b/tests/lib/install-codex-config-preservation.test.js index f8b9d12fe..368cc5cbf 100644 --- a/tests/lib/install-codex-config-preservation.test.js +++ b/tests/lib/install-codex-config-preservation.test.js @@ -154,10 +154,10 @@ for (const name of SHARED_FILES) { const refreshedOperation = readInstallState(fixture.statePath).operations.find(operation => ( operation.destinationPath === fixture.destination(name) && operation.ownership === 'managed' )); - if (refreshedOperation) { - assert.equal(refreshedOperation.contentSha256, previousOperation.contentSha256, - 'Repair must retain the previous digest for configuration it did not write'); - } + 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); }); @@ -222,10 +222,10 @@ for (const name of SHARED_FILES) { const checkpointOperation = readInstallState(fixture.statePath).operations.find(operation => ( operation.destinationPath === destination && operation.ownership === 'managed' )); - if (checkpointOperation) { - assert.equal(checkpointOperation.contentSha256, previousOperation.contentSha256, - 'A failure checkpoint must retain the old digest, never adopt the user edit'); - } + 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); });