From 81df6475b2d84c65404a03effb1db567f5251bf3 Mon Sep 17 00:00:00 2001 From: rubicks-112 Date: Mon, 28 Sep 2026 12:42:16 +0300 Subject: [PATCH 1/2] fix(codex): restrict .codex/config.toml to non-escalating keys The project-scoped reference file shipped an active [profiles.yolo] block with approval_policy = "never" and exposed MCP servers / approval_policy / sandbox_mode / notify / profiles.* keys that override the user's own ~/.codex/config.toml when the repo is trusted. AgentShield flags this as codex-approval-never (CRITICAL) and codex-project-config-escalates (HIGH). Restrict the project-scoped file to model selection, persistent instructions, features, and role definitions. Move every other key into a clearly-marked USER-LEVEL REFERENCE appendix as commented-out examples so installs still ship a usable template without overriding user policy. Verified locally with ecc-agentshield scan --path . against .codex/config.toml: * pre-fix: 2 critical/high findings * post-fix: 0 critical/high findings Refs: ARU-10 (ECC-SEC-1.1 review CLEAR), ARU-18 (this PR). Co-Authored-By: Paperclip --- .codex/config.toml | 183 +++++++++++++++++++++++++-------------------- 1 file changed, 102 insertions(+), 81 deletions(-) diff --git a/.codex/config.toml b/.codex/config.toml index 4b862643e..f8537af97 100644 --- a/.codex/config.toml +++ b/.codex/config.toml @@ -1,32 +1,35 @@ #:schema https://developers.openai.com/codex/config-schema.json -# Everything Claude Code (ECC) — Codex Reference Configuration +# Everything Claude Code (ECC) — Codex Reference Configuration (project-scoped layer) # -# 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. +# SCOPE — what this file is and is not: +# +# This file is the **project-scoped default** that ships with the ECC plugin. +# Codex loads it from the repo root when the project is trusted. To keep a +# trusted repo from overriding the user's own policy, this file is restricted +# to *model selection*, *persistent instructions*, *features*, and *role +# definitions* only. Anything that gates execution (approval_policy, +# sandbox_mode, mcp_servers, notify, profiles.*) lives in +# `~/.codex/config.toml` and is documented in the "USER-LEVEL REFERENCE" +# appendix below as commented-out examples. +# +# 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 +# +# Security baseline: this file passes `ecc-agentshield scan` with zero +# critical / high findings on `.codex/config.toml`. Verified by +# `ecc-security-reviewer` against the ECC Development project's g-secure +# lane; see ECC-SEC-1.1 for the run report. # 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). persistent_instructions = "Follow project AGENTS.md guidelines. Use available MCP servers when they can help." @@ -35,77 +38,14 @@ persistent_instructions = "Follow project AGENTS.md guidelines. Use available MC # 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 ` -[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. +# Role limits and local role definitions. # These map to `.codex/agents/*.toml` and mirror the repo's explorer/reviewer/docs workflow. +[agents] max_threads = 6 max_depth = 1 @@ -120,3 +60,84 @@ 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" + +# ───────────────────────────────────────────────────────────────────────────── +# USER-LEVEL REFERENCE — copy into ~/.codex/config.toml, NOT the project root. +# +# Anything that controls Codex execution (approval prompts, sandbox limits, +# external notifications, MCP server registration, named profiles) belongs in +# the user's own config. Keeping it out of `.codex/config.toml` prevents a +# trusted repo from loosening approvals, registering untrusted MCP servers, +# or running notify commands on the user's machine. +# +# Below is the same surface area the previous reference file exposed, kept +# here as commented-out examples so the install still ships a usable template. +# ───────────────────────────────────────────────────────────────────────────── + +# [mcp_servers.github] +# command = "npx" +# args = ["-y", "@modelcontextprotocol/server-github"] +# startup_timeout_sec = 30 +# +# [mcp_servers.context7] +# command = "npx" +# 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 + +# Top-level runtime settings — USER-LEVEL only. +# 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", +# ] + +# Profiles — switch with `codex -p `. USER-LEVEL only. +# +# Notes for `codex-adapter-coder` / contributors: +# * `strict` is a safe read-only default; safe to keep as a documented +# example, but AgentShield flags any project-scoped profile block +# (rule `codex-project-config-escalates`) because it overrides user +# policy, so it lives in `~/.codex/config.toml` instead. +# * `yolo` is intentionally NOT shipped. `approval_policy = "never"` +# disables the human-in-the-loop check on every command, edit, and MCP +# call. AgentShield rule `codex-approval-never` blocks it as critical, +# and it is also called out by `codex-project-config-escalates`. If a +# user genuinely needs an unattended mode, they should define it in +# `~/.codex/config.toml` after explicitly opting in — never inherit it +# from a trusted repo. +# +# [profiles.strict] +# approval_policy = "on-request" +# sandbox_mode = "read-only" +# web_search = "cached" +# +# [profiles.yolo] # DO NOT uncomment in a project-scoped file. +# approval_policy = "never" +# sandbox_mode = "workspace-write" +# web_search = "live" \ No newline at end of file From c5bb5579fe70684a3b62d3ed294a79a63ba209de Mon Sep 17 00:00:00 2001 From: affaan-m <124439313+affaan-m@users.noreply.github.com> Date: Mon, 28 Sep 2026 06:42:33 -0400 Subject: [PATCH 2/2] fix(codex): separate project guidance from user install defaults Preserve the original contributor histories and apply the exact independently reviewed repair. Source-Parent: 81df6475b2d84c65404a03effb1db567f5251bf3 Review-Manifest-SHA256: 272ae1970f0b94fe40dbb974b509b7333682176259f5a1493bbae12d9a7e70cb --- .codex/config.toml | 136 ++-------------- .codex/user-config.example.toml | 122 ++++++++++++++ scripts/codex/merge-codex-config.js | 4 +- scripts/lib/install-targets/codex-home.js | 40 ++++- tests/codex-config.test.js | 149 ++++++++++++++++++ .../install-codex-config-preservation.test.js | 139 +++++++++++++++- 6 files changed, 465 insertions(+), 125 deletions(-) create mode 100644 .codex/user-config.example.toml diff --git a/.codex/config.toml b/.codex/config.toml index f8537af97..c817791e4 100644 --- a/.codex/config.toml +++ b/.codex/config.toml @@ -1,51 +1,30 @@ #:schema https://developers.openai.com/codex/config-schema.json -# Everything Claude Code (ECC) — Codex Reference Configuration (project-scoped layer) +# 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. # -# SCOPE — what this file is and is not: -# -# This file is the **project-scoped default** that ships with the ECC plugin. -# Codex loads it from the repo root when the project is trusted. To keep a -# trusted repo from overriding the user's own policy, this file is restricted -# to *model selection*, *persistent instructions*, *features*, and *role -# definitions* only. Anything that gates execution (approval_policy, -# sandbox_mode, mcp_servers, notify, profiles.*) lives in -# `~/.codex/config.toml` and is documented in the "USER-LEVEL REFERENCE" -# appendix below as commented-out examples. -# -# 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 -# -# Security baseline: this file passes `ecc-agentshield scan` with zero -# critical / high findings on `.codex/config.toml`. Verified by -# `ecc-security-reviewer` against the ECC Development project's g-secure -# lane; see ECC-SEC-1.1 for the run report. +# 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. - -# 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" - [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 -# Role limits and local role definitions. -# These map to `.codex/agents/*.toml` and mirror the repo's explorer/reviewer/docs workflow. [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 @@ -60,84 +39,3 @@ 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" - -# ───────────────────────────────────────────────────────────────────────────── -# USER-LEVEL REFERENCE — copy into ~/.codex/config.toml, NOT the project root. -# -# Anything that controls Codex execution (approval prompts, sandbox limits, -# external notifications, MCP server registration, named profiles) belongs in -# the user's own config. Keeping it out of `.codex/config.toml` prevents a -# trusted repo from loosening approvals, registering untrusted MCP servers, -# or running notify commands on the user's machine. -# -# Below is the same surface area the previous reference file exposed, kept -# here as commented-out examples so the install still ships a usable template. -# ───────────────────────────────────────────────────────────────────────────── - -# [mcp_servers.github] -# command = "npx" -# args = ["-y", "@modelcontextprotocol/server-github"] -# startup_timeout_sec = 30 -# -# [mcp_servers.context7] -# command = "npx" -# 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 - -# Top-level runtime settings — USER-LEVEL only. -# 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", -# ] - -# Profiles — switch with `codex -p `. USER-LEVEL only. -# -# Notes for `codex-adapter-coder` / contributors: -# * `strict` is a safe read-only default; safe to keep as a documented -# example, but AgentShield flags any project-scoped profile block -# (rule `codex-project-config-escalates`) because it overrides user -# policy, so it lives in `~/.codex/config.toml` instead. -# * `yolo` is intentionally NOT shipped. `approval_policy = "never"` -# disables the human-in-the-loop check on every command, edit, and MCP -# call. AgentShield rule `codex-approval-never` blocks it as critical, -# and it is also called out by `codex-project-config-escalates`. If a -# user genuinely needs an unattended mode, they should define it in -# `~/.codex/config.toml` after explicitly opting in — never inherit it -# from a trusted repo. -# -# [profiles.strict] -# approval_policy = "on-request" -# sandbox_mode = "read-only" -# web_search = "cached" -# -# [profiles.yolo] # DO NOT uncomment in a project-scoped file. -# approval_policy = "never" -# sandbox_mode = "workspace-write" -# web_search = "live" \ No newline at end of file diff --git a/.codex/user-config.example.toml b/.codex/user-config.example.toml new file mode 100644 index 000000000..23f436a6c --- /dev/null +++ b/.codex/user-config.example.toml @@ -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 `. +[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" diff --git a/scripts/codex/merge-codex-config.js b/scripts/codex/merge-codex-config.js index a1f9ed995..cd6d9c2c8 100644 --- a/scripts/codex/merge-codex-config.js +++ b/scripts/codex/merge-codex-config.js @@ -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); diff --git a/scripts/lib/install-targets/codex-home.js b/scripts/lib/install-targets/codex-home.js index aff32c4c5..994d51455 100644 --- a/scripts/lib/install-targets/codex-home.js +++ b/scripts/lib/install-targets/codex-home.js @@ -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'), + })]; + }); + }, +}); diff --git a/tests/codex-config.test.js b/tests/codex-config.test.js index 7d777d183..384ddf0f7 100644 --- a/tests/codex-config.test.js +++ b/tests/codex-config.test.js @@ -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); diff --git a/tests/lib/install-codex-config-preservation.test.js b/tests/lib/install-codex-config-preservation.test.js index b52082246..ece571d76 100644 --- a/tests/lib/install-codex-config-preservation.test.js +++ b/tests/lib/install-codex-config-preservation.test.js @@ -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); +});