merge: preserve project and user Codex configuration source 3253

This commit is contained in:
haelyra
2026-10-09 17:02:47 -04:00
6 changed files with 463 additions and 102 deletions
+15 -96
View File
@@ -1,108 +1,27 @@
#:schema https://developers.openai.com/codex/config-schema.json
# ECC — Codex Reference Configuration
# ECC project-scoped reference: guidance, features and local role definitions.
# Trusted-project loading, precedence and supported keys depend on the Codex
# version and higher-level policy. Omitted settings inherit other config layers.
# This file does not certify effective permissions or prevent configured tools
# from running. The referenced role files retain their read-only sandbox requests.
#
# Copy this file to ~/.codex/config.toml for global defaults, or keep it in
# the project root as .codex/config.toml for project-local settings.
#
# Official docs:
# - https://developers.openai.com/codex/config-reference
# - https://developers.openai.com/codex/multi-agent
# Explicit user installs use the separate user-config.example.toml reference.
# Do not copy this project file as an equivalent user-level baseline. The user
# reference preserves ECC's existing runtime defaults, profiles and integrations;
# review them before installation. Current Codex docs ignore notify/profiles in
# project config; that is not a universal claim about older versions.
# https://developers.openai.com/codex/config-reference
# https://developers.openai.com/codex/multi-agent
# Model selection
# Leave `model` and `model_provider` unset so Codex CLI uses its current
# built-in defaults. Uncomment and pin them only if you intentionally want
# repo-local or global model overrides.
# Top-level runtime settings (current Codex schema)
approval_policy = "on-request"
sandbox_mode = "workspace-write"
web_search = "live"
# External notifications receive a JSON payload on stdin.
notify = [
"terminal-notifier",
"-title", "Codex ECC",
"-message", "Task completed!",
"-sound", "default",
]
# Persistent instructions are appended to every prompt (additive, unlike
# model_instructions_file which replaces AGENTS.md).
# Leave top-level model and model_provider unset to inherit other/default choices.
# Existing ECC instruction key retained for sync compatibility, without claiming
# support in every Codex version. Consult the supported version's reference.
persistent_instructions = "Follow project AGENTS.md guidelines. Use available MCP servers when they can help."
# model_instructions_file replaces built-in instructions instead of AGENTS.md,
# so leave it unset unless you intentionally want a single override file.
# model_instructions_file = "/absolute/path/to/instructions.md"
# MCP servers
# Keep the default project set lean. API-backed servers inherit credentials from
# the launching environment or can be supplied by a user-level ~/.codex/config.toml.
[mcp_servers.github]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-github"]
startup_timeout_sec = 30
[mcp_servers.context7]
command = "npx"
# Canonical Codex section name is `context7`; the package itself remains
# `@upstash/context7-mcp`.
args = ["-y", "@upstash/context7-mcp@latest"]
startup_timeout_sec = 30
[mcp_servers.exa]
command = "npx"
args = ["-y", "mcp-remote", "https://mcp.exa.ai/mcp"]
startup_timeout_sec = 30
[mcp_servers.memory]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-memory"]
startup_timeout_sec = 30
[mcp_servers.playwright]
command = "npx"
args = ["-y", "@playwright/mcp@latest", "--extension"]
startup_timeout_sec = 30
[mcp_servers.sequential-thinking]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-sequential-thinking"]
startup_timeout_sec = 30
# Additional MCP servers (uncomment as needed):
# [mcp_servers.supabase]
# command = "npx"
# args = ["-y", "supabase-mcp-server@latest", "--read-only"]
#
# [mcp_servers.firecrawl]
# command = "npx"
# args = ["-y", "firecrawl-mcp"]
#
# [mcp_servers.fal-ai]
# command = "npx"
# args = ["-y", "fal-ai-mcp-server"]
#
# [mcp_servers.cloudflare]
# command = "npx"
# args = ["-y", "@cloudflare/mcp-server-cloudflare"]
[features]
# Codex multi-agent collaboration is stable and on by default in current builds.
# Keep the explicit toggle here so the repo documents its expectation clearly.
multi_agent = true
# Profiles — switch with `codex -p <name>`
[profiles.strict]
approval_policy = "on-request"
sandbox_mode = "read-only"
web_search = "cached"
[profiles.yolo]
approval_policy = "never"
sandbox_mode = "workspace-write"
web_search = "live"
[agents]
# Multi-agent role limits and local role definitions.
# These map to `.codex/agents/*.toml` and mirror the repo's explorer/reviewer/docs workflow.
+122
View File
@@ -0,0 +1,122 @@
#:schema https://developers.openai.com/codex/config-schema.json
# ECC user-install reference (a separate TOML document).
# Legacy add-only sync and the native Codex home installer use this file.
# Review these existing defaults and integrations before choosing to install them.
# This is not the project config; .codex/config.toml contains project guidance.
# Neither reference certifies effective permissions or runtime compatibility.
# CLI overrides, supported Codex versions and administrator policy still matter.
# https://developers.openai.com/codex/config-reference
# https://developers.openai.com/codex/multi-agent
# Model selection
# Leave `model` and `model_provider` unset so Codex CLI uses its current
# built-in defaults. Uncomment and pin them only if you intentionally want
# user-level model overrides.
# Existing ECC user-level runtime defaults.
approval_policy = "on-request"
sandbox_mode = "workspace-write"
web_search = "live"
# Optional external notification command, preserved from the user reference.
notify = [
"terminal-notifier",
"-title", "Codex ECC",
"-message", "Task completed!",
"-sound", "default",
]
# Existing ECC instruction key retained for sync compatibility.
# Check the configuration reference for your supported Codex version.
persistent_instructions = "Follow project AGENTS.md guidelines. Use available MCP servers when they can help."
# Optional instruction-file override; consult the supported version's semantics.
# model_instructions_file = "/absolute/path/to/instructions.md"
# MCP servers
# Existing user-install registrations are preserved here as data.
# Installing or running a server is a separate choice; no package pins are changed.
[mcp_servers.github]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-github"]
startup_timeout_sec = 30
[mcp_servers.context7]
command = "npx"
# Canonical Codex section name is `context7`; the package itself remains
# `@upstash/context7-mcp`.
args = ["-y", "@upstash/context7-mcp@latest"]
startup_timeout_sec = 30
[mcp_servers.exa]
command = "npx"
args = ["-y", "mcp-remote", "https://mcp.exa.ai/mcp"]
startup_timeout_sec = 30
[mcp_servers.memory]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-memory"]
startup_timeout_sec = 30
[mcp_servers.playwright]
command = "npx"
args = ["-y", "@playwright/mcp@latest", "--extension"]
startup_timeout_sec = 30
[mcp_servers.sequential-thinking]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-sequential-thinking"]
startup_timeout_sec = 30
# Additional MCP servers (uncomment as needed):
# [mcp_servers.supabase]
# command = "npx"
# args = ["-y", "supabase-mcp-server@latest", "--read-only"]
#
# [mcp_servers.firecrawl]
# command = "npx"
# args = ["-y", "firecrawl-mcp"]
#
# [mcp_servers.fal-ai]
# command = "npx"
# args = ["-y", "fal-ai-mcp-server"]
#
# [mcp_servers.cloudflare]
# command = "npx"
# args = ["-y", "@cloudflare/mcp-server-cloudflare"]
[features]
# Preserve ECC's explicit multi-agent setting.
multi_agent = true
# Named profiles are available for explicit selection; defining one does not select it.
# The default above remains on-request. Approval and sandbox policy are separate.
# Select a supported profile explicitly with `codex -p <name>`.
[profiles.strict]
approval_policy = "on-request"
sandbox_mode = "read-only"
web_search = "cached"
[profiles.yolo]
approval_policy = "never"
sandbox_mode = "workspace-write"
web_search = "live"
[agents]
# Multi-agent role limits and local role definitions.
# These map to `.codex/agents/*.toml` and mirror the repo's explorer/reviewer/docs workflow.
max_threads = 6
max_depth = 1
[agents.explorer]
description = "Read-only codebase explorer for gathering evidence before changes are proposed."
config_file = "agents/explorer.toml"
[agents.reviewer]
description = "PR reviewer focused on correctness, security, and missing tests."
config_file = "agents/reviewer.toml"
[agents.docs_researcher]
description = "Documentation specialist that verifies APIs, framework behavior, and release notes."
config_file = "agents/docs-researcher.toml"
+2 -2
View File
@@ -2,7 +2,7 @@
'use strict';
/**
* Merge the non-MCP Codex baseline from `.codex/config.toml` into a target
* Merge the non-MCP Codex baseline from `.codex/user-config.example.toml` into a target
* `config.toml` without overwriting existing user choices.
*
* Strategy: add-only.
@@ -219,7 +219,7 @@ function main() {
process.exit(1);
}
const referencePath = path.join(__dirname, '..', '..', '.codex', 'config.toml');
const referencePath = path.join(__dirname, '..', '..', '.codex', 'user-config.example.toml');
if (!fs.existsSync(referencePath)) {
console.error(`[ecc-codex] Reference config not found: ${referencePath}`);
process.exit(1);
+38 -2
View File
@@ -1,6 +1,10 @@
const { HOME_INSTALL_EXCLUDED_SOURCE_PATHS, createInstallTargetAdapter } = require('./helpers');
const fs = require('fs');
const path = require('path');
const {
HOME_INSTALL_EXCLUDED_SOURCE_PATHS, createInstallTargetAdapter, createManagedOperation,
} = require('./helpers');
module.exports = createInstallTargetAdapter({
const adapter = createInstallTargetAdapter({
id: 'codex-home',
target: 'codex',
kind: 'home',
@@ -9,3 +13,35 @@ module.exports = createInstallTargetAdapter({
nativeRootRelativePath: '.codex',
excludedSourcePaths: HOME_INSTALL_EXCLUDED_SOURCE_PATHS,
});
const USER_REFERENCE = '.codex/user-config.example.toml';
module.exports = Object.freeze({
...adapter,
planOperations(input = {}) {
// Delegate the existing platform filters, exclusions and scaffold metadata.
return adapter.planOperations(input).flatMap(operation => {
if (operation.sourceRelativePath !== '.codex') return [operation];
// Abstract metadata-only plans need no filesystem. A selected real source
// must not silently fall back to the narrower project config.
if (input.repoRoot && fs.existsSync(input.repoRoot)) {
let stat;
try {
stat = fs.lstatSync(path.join(input.repoRoot, USER_REFERENCE));
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
if (!stat || !stat.isFile() || stat.isSymbolicLink()) {
throw new Error('Codex user reference must exist as a regular file');
}
}
// The planner materializes these operations, then keeps the last copy per
// destination before preview/state/apply. Only the user reference survives.
return [operation, createManagedOperation({
moduleId: operation.moduleId,
sourceRelativePath: USER_REFERENCE,
destinationPath: path.join(operation.destinationPath, 'config.toml'),
})];
});
},
});
+149
View File
@@ -7,6 +7,13 @@
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { spawnSync } = require('child_process');
const TOML = require('@iarna/toml');
// Copied fixture scripts need the parent's resolved dependency, even without NODE_PATH.
const tomlDependencyRoot = path.resolve(
path.dirname(require.resolve('@iarna/toml/package.json')), '..', '..'
);
function test(name, fn) {
try {
@@ -112,6 +119,148 @@ if (
passed++;
else failed++;
function readUserReference() {
return TOML.parse(fs.readFileSync(path.join(repoRoot, '.codex', 'user-config.example.toml'), 'utf8'));
}
function withMergeFixture(fn) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-codex-reference-'));
try {
const scriptDir = path.join(root, 'scripts', 'codex');
fs.mkdirSync(scriptDir, { recursive: true });
fs.mkdirSync(path.join(root, '.codex'));
const script = path.join(scriptDir, 'merge-codex-config.js');
fs.copyFileSync(path.join(repoRoot, 'scripts', 'codex', 'merge-codex-config.js'), script);
for (const name of ['config.toml', 'user-config.example.toml']) {
const source = path.join(repoRoot, '.codex', name);
if (fs.existsSync(source)) fs.copyFileSync(source, path.join(root, '.codex', name));
}
const target = path.join(root, 'target.toml');
fs.writeFileSync(target, '');
const run = (...args) => spawnSync(process.execPath, [script, target, ...args], {
cwd: root, shell: false, encoding: 'utf8', input: '', timeout: 5000,
maxBuffer: 128 * 1024,
env: {
PATH: path.dirname(process.execPath), HOME: root, USERPROFILE: root,
TMPDIR: root, TMP: root, TEMP: root,
NODE_PATH: tomlDependencyRoot,
},
});
fn({ root, target, run });
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
}
const checks = [
['project config has only the intended guidance, feature and role keys', () => {
const project = TOML.parse(config);
assert.deepStrictEqual(Object.keys(project).sort(), ['agents', 'features', 'persistent_instructions']);
assert.deepStrictEqual(project.features, { multi_agent: true });
assert.strictEqual(project.persistent_instructions,
'Follow project AGENTS.md guidelines. Use available MCP servers when they can help.');
assert.deepStrictEqual(Object.keys(project.agents).sort(),
['docs_researcher', 'explorer', 'max_depth', 'max_threads', 'reviewer']);
assert.strictEqual(project.agents.max_threads, 6);
assert.strictEqual(project.agents.max_depth, 1);
}],
['user reference preserves root defaults and explicit profiles in their own tables', () => {
const user = readUserReference();
assert.deepStrictEqual(Object.keys(user).sort(), [
'agents', 'approval_policy', 'features', 'mcp_servers', 'notify',
'persistent_instructions', 'profiles', 'sandbox_mode', 'web_search',
]);
assert.strictEqual(user.approval_policy, 'on-request');
assert.strictEqual(user.sandbox_mode, 'workspace-write');
assert.strictEqual(user.web_search, 'live');
assert.deepStrictEqual(user.notify,
['terminal-notifier', '-title', 'Codex ECC', '-message', 'Task completed!', '-sound', 'default']);
assert.deepStrictEqual(user.profiles, {
strict: { approval_policy: 'on-request', sandbox_mode: 'read-only', web_search: 'cached' },
yolo: { approval_policy: 'never', sandbox_mode: 'workspace-write', web_search: 'live' },
});
assert.ok(!Object.hasOwn(user, 'profile'), 'Defining a profile must not select it');
}],
['user reference preserves guidance, role mappings and existing MCP data', () => {
const user = readUserReference();
const project = TOML.parse(config);
for (const key of ['persistent_instructions', 'features', 'agents']) {
assert.deepStrictEqual(user[key], project[key]);
}
const args = {
github: ['-y', '@modelcontextprotocol/server-github'],
context7: ['-y', '@upstash/context7-mcp@latest'],
exa: ['-y', 'mcp-remote', 'https://mcp.exa.ai/mcp'],
memory: ['-y', '@modelcontextprotocol/server-memory'],
playwright: ['-y', '@playwright/mcp@latest', '--extension'],
'sequential-thinking': ['-y', '@modelcontextprotocol/server-sequential-thinking'],
};
assert.deepStrictEqual(user.mcp_servers, Object.fromEntries(Object.entries(args).map(
([name, values]) => [name, { command: 'npx', args: values, startup_timeout_sec: 30 }]
)));
}],
['legacy merger adds the user baseline and is idempotent without installing MCP data', () => {
withMergeFixture(({ target, run }) => {
const first = run();
assert.strictEqual(first.status, 0, first.stderr);
const raw = fs.readFileSync(target, 'utf8');
const expected = readUserReference();
delete expected.mcp_servers;
assert.deepStrictEqual(TOML.parse(raw), expected);
assert.strictEqual(run().status, 0);
assert.strictEqual(fs.readFileSync(target, 'utf8'), raw);
});
}],
['legacy merger preserves customized values, unknown tables and dry-run bytes', () => {
withMergeFixture(({ target, run }) => {
const custom = {
approval_policy: 'never', sandbox_mode: 'read-only', web_search: 'disabled',
notify: ['custom-notifier'], persistent_instructions: 'My instructions', extra: 'keep',
features: { multi_agent: false }, profiles: { strict: { sandbox_mode: 'workspace-write' } },
mcp_servers: { personal: { command: 'private-command' } },
};
const raw = TOML.stringify(custom);
fs.writeFileSync(target, raw);
assert.strictEqual(run('--dry-run').status, 0);
assert.strictEqual(fs.readFileSync(target, 'utf8'), raw);
const result = run();
assert.strictEqual(result.status, 0, result.stderr);
const merged = TOML.parse(fs.readFileSync(target, 'utf8'));
for (const key of ['approval_policy', 'sandbox_mode', 'web_search', 'notify',
'persistent_instructions', 'extra', 'features', 'mcp_servers']) {
assert.deepStrictEqual(merged[key], custom[key]);
}
assert.strictEqual(merged.profiles.strict.sandbox_mode, 'workspace-write');
assert.strictEqual(merged.profiles.strict.approval_policy, 'on-request');
assert.deepStrictEqual(merged.profiles.yolo, readUserReference().profiles.yolo);
});
}],
['legacy merger rejects missing user reference without falling back to project config', () => {
withMergeFixture(({ root, target, run }) => {
fs.rmSync(path.join(root, '.codex', 'user-config.example.toml'), { force: true });
fs.writeFileSync(target, '# untouched\n');
const result = run();
assert.strictEqual(result.status, 1);
assert.match(result.stderr, /Reference config not found/);
assert.strictEqual(fs.readFileSync(target, 'utf8'), '# untouched\n');
});
}],
['legacy merger rejects malformed target TOML without writing it', () => {
withMergeFixture(({ target, run }) => {
const raw = 'invalid = [\n';
fs.writeFileSync(target, raw);
const result = run();
assert.strictEqual(result.status, 1);
assert.match(result.stderr, /Failed to parse TOML/);
assert.strictEqual(fs.readFileSync(target, 'utf8'), raw);
});
}],
];
for (const [name, fn] of checks) {
if (test(name, fn)) passed++; else failed++;
}
console.log(`\nPassed: ${passed}`);
console.log(`Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
@@ -13,6 +13,10 @@ const {
} = require('../../scripts/lib/install-lifecycle');
const { readInstallState, writeInstallState } = require('../../scripts/lib/install-state');
const codexAdapter = require('../../scripts/lib/install-targets/codex-home');
const USER_REFERENCE = 'user-config.example.toml';
const PROJECT_CONFIG = '# Project-only configuration\n[features]\nmulti_agent = true\n';
const SHARED_FILES = ['config.toml', 'AGENTS.md'];
const TEMPLATES = {
'config.toml': '# ECC defaults\nmodel = "example-model"\n',
@@ -50,6 +54,8 @@ function createFixture(t) {
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, '.codex', USER_REFERENCE), TEMPLATES['config.toml']);
writeFile(path.join(sourceRoot, '.codex', 'config.toml'), PROJECT_CONFIG);
writeFile(path.join(sourceRoot, 'scripts', 'independent-helper.js'), 'module.exports = "helper";\n');
fs.mkdirSync(homeDir, { recursive: true });
fs.mkdirSync(projectRoot, { recursive: true });
@@ -61,6 +67,7 @@ function createFixture(t) {
}, options);
return {
sourceRoot,
template: name => path.join(sourceRoot, '.codex', name === 'config.toml' ? USER_REFERENCE : name),
destination: name => path.join(homeDir, '.codex', name),
statePath: path.join(homeDir, '.codex', 'ecc-install-state.json'),
plan,
@@ -134,7 +141,7 @@ for (const name of SHARED_FILES) {
operation.destinationPath === fixture.destination(name)
));
if (stage === 'bridge') {
writeFile(path.join(fixture.sourceRoot, '.codex', name),
writeFile(fixture.template(name),
`${TEMPLATES[name]}\n# Updated upstream template\n`);
}
const content = `${TEMPLATES[name]}\r\n# Saved after repair inspected the file\r\n`;
@@ -345,7 +352,7 @@ for (const name of SHARED_FILES) {
const fixture = createFixture(t);
fixture.install();
const updated = `${TEMPLATES[name]}\n# New upstream default\n`;
writeFile(path.join(fixture.sourceRoot, '.codex', name), updated);
writeFile(fixture.template(name), updated);
if (action === 'reinstall') fixture.install();
else lifecycleResult(fixture.repair());
@@ -370,3 +377,131 @@ for (const name of SHARED_FILES) {
assertPreserved(fixture, name, installedContent);
});
}
function installOldConfigLedger(fixture) {
const projectPath = path.join(fixture.sourceRoot, '.codex', 'config.toml');
writeFile(projectPath, TEMPLATES['config.toml']);
try {
const plan = fixture.plan();
const oldOperation = plan.operations.find(operation => (
operation.destinationPath === fixture.destination('config.toml')
));
assert.ok(oldOperation);
const useOldSource = operation => (
operation.destinationPath === oldOperation.destinationPath ? {
...operation, sourceRelativePath: '.codex/config.toml', sourcePath: projectPath,
} : operation
);
// Both executable operations and the planned ledger describe the old source.
applyInstallPlan({
...plan,
operations: plan.operations.map(useOldSource),
statePreview: { ...plan.statePreview, operations: plan.statePreview.operations.map(useOldSource) },
});
assert.equal(readInstallState(fixture.statePath).operations.find(operation => (
operation.destinationPath === fixture.destination('config.toml')
)).sourceRelativePath, '.codex/config.toml');
} finally {
writeFile(projectPath, PROJECT_CONFIG);
}
}
test('Codex native install uses one user-reference operation before preview, apply and state', t => {
const fixture = createFixture(t);
const plan = fixture.plan();
const selected = plan.operations.filter(operation => (
operation.destinationPath === fixture.destination('config.toml')
));
assert.equal(selected.length, 1);
assert.equal(selected[0].sourceRelativePath, '.codex/user-config.example.toml');
assert.equal(previewInstallPlan(plan).operations.filter(operation => (
operation.destinationPath === fixture.destination('config.toml')
)).length, 1);
applyInstallPlan(plan);
assertPreserved(fixture, 'config.toml', TEMPLATES['config.toml']);
assertPreserved(fixture, 'AGENTS.md', TEMPLATES['AGENTS.md']);
assertPreserved(fixture, USER_REFERENCE, TEMPLATES['config.toml']);
const recorded = readInstallState(fixture.statePath).operations.filter(operation => (
operation.destinationPath === fixture.destination('config.toml')
));
assert.equal(recorded.length, 1);
assert.equal(recorded[0].sourceRelativePath, '.codex/user-config.example.toml');
});
for (const invalid of ['missing', 'directory']) {
test(`selected Codex native install rejects ${invalid} user reference before writes`, t => {
const fixture = createFixture(t);
fs.unlinkSync(fixture.template('config.toml'));
if (invalid === 'directory') fs.mkdirSync(fixture.template('config.toml'));
assert.throws(() => fixture.install(), /Codex user reference.*regular file/);
assert.ok(!fs.existsSync(fixture.statePath));
assert.ok(!fs.existsSync(fixture.destination('config.toml')));
assert.ok(!fs.existsSync(fixture.destination('AGENTS.md')));
// Unselected native configuration must not block independent modules.
fixture.install(['helper-scripts']);
assert.ok(!fs.existsSync(fixture.destination('config.toml')));
});
}
test('Codex adapter preserves metadata-only planning and foreign/excluded filters', () => {
const operations = codexAdapter.planOperations({
homeDir: '/example',
modules: [{ id: 'platform-configs', paths: ['.codex', '.cursor', '.agents', 'scripts'] }],
});
assert.deepEqual(operations.map(operation => operation.sourceRelativePath),
['.codex', '.codex/user-config.example.toml', 'scripts']);
assert.equal(operations[1].destinationPath, path.join('/example', '.codex', 'config.toml'));
});
for (const action of ['reinstall', 'repair']) {
for (const edited of [false, true]) {
test(`${action} preserves ownership through old config source migration (edited=${edited})`, t => {
const fixture = createFixture(t);
installOldConfigLedger(fixture);
const updated = `${TEMPLATES['config.toml']}# New explicit user reference\n`;
writeFile(fixture.template('config.toml'), updated);
const personal = `${TEMPLATES['config.toml']}# Personal change\n`;
if (edited) writeFile(fixture.destination('config.toml'), personal);
const result = action === 'reinstall' ? fixture.install() : lifecycleResult(fixture.repair());
assertPreserved(fixture, 'config.toml', edited ? personal : updated);
if (edited) {
assertWarning(result, 'config.toml');
assertUnmanaged(fixture, 'config.toml');
} else {
const operations = readInstallState(fixture.statePath).operations.filter(operation => (
operation.destinationPath === fixture.destination('config.toml')
));
assert.equal(operations.length, 1);
assert.equal(operations[0].sourceRelativePath, '.codex/user-config.example.toml');
assert.equal(operations[0].ownership, 'managed');
}
lifecycleResult(fixture.doctor());
lifecycleResult(fixture.uninstall());
if (edited) assertPreserved(fixture, 'config.toml', personal);
else assert.ok(!fs.existsSync(fixture.destination('config.toml')));
});
}
}
test('old config source migration retains its digest after a raced user edit', t => {
const fixture = createFixture(t);
installOldConfigLedger(fixture);
const previous = readInstallState(fixture.statePath).operations.find(operation => (
operation.destinationPath === fixture.destination('config.toml')
));
writeFile(fixture.template('config.toml'), `${TEMPLATES['config.toml']}# Upstream update\n`);
const personal = '# User saved during migration\n';
const { result, injected } = editAfterRepairInspection(
fixture, 'config.toml', personal, () => fixture.repair()
);
assert.ok(injected);
assert.equal(result.results[0].status, 'error');
assert.match(result.results[0].error, /Refusing.*user configuration.*changed after planning/);
assertPreserved(fixture, 'config.toml', personal);
const current = readInstallState(fixture.statePath).operations.find(operation => (
operation.destinationPath === fixture.destination('config.toml')
));
assert.equal(current.contentSha256, previous.contentSha256);
lifecycleResult(fixture.uninstall());
assertPreserved(fixture, 'config.toml', personal);
});