mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-14 12:15:29 +02:00
merge: preserve project and user Codex configuration source 3253
This commit is contained in:
+15
-96
@@ -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.
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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'),
|
||||
})];
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user