/**
* MSD Tools Tests - codex-config.cjs
*
* Tests for Codex adapter header, agent conversion, config.toml generation/merge,
* per-agent .toml generation, and uninstall cleanup.
*/
// Enable test exports from install.js (skips main CLI logic)
process.env.MSD_TEST_MODE = '1';
const { test, describe, before, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { runNode } = require('./helpers/process-seam.cjs');
const { throwIfFailed } = require('./helpers/git-fixture.cjs');
const { cleanup } = require('./helpers.cjs');
const fc = require('fast-check');
const { CLAUDE_AGENT_ALIASES } = require('../msd-core/bin/lib/model-resolver.cjs');
const { escapeRegex } = require('../msd-core/bin/lib/pattern.cjs');
// #3241 — the intended new home for CLAUDE_AGENT_ALIASES + isAnthropicFlavoredModel
// (see .msd/phase/feat-3241-codex-omit-model-by-default/40-design.md "The seam
// decision"). Neither export exists on model-catalog.cjs yet; requiring the
// module does not throw (it just has no such keys today), but calling
// isAnthropicFlavoredModel does — see the new describe block below.
const modelCatalog = require('../msd-core/bin/lib/model-catalog.cjs');
const modelResolver = require('../msd-core/bin/lib/model-resolver.cjs');
// #2153 follow-up: ensure hooks/dist/ exists before any install integration
// test runs. The Codex install path copies hook files from hooks/dist/, which
// is gitignored and only populated by `npm run build:hooks`. When one of the
// codex-config*.test.cjs files is run in isolation (`node --test
// tests/codex-config-agents.test.cjs`, for example) the build step from the
// npm-test pretest chain does not run, and the "Codex install copies hook
// file" regression silently fails because hooks/dist/ is empty.
// Build on demand so the test passes regardless of runner ordering.
const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist');
const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
// scripts/build-hooks.js copies pre-built hook files into hooks/dist and
// syntax-checks them with vm — it does not compile/bundle anything. See
// tests/helpers/timeouts.cjs for the class-norm justification.
const { BUILD_TIMEOUT_MS: BUILD_HOOKS_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
before(() => {
if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) {
throwIfFailed(
runNode([BUILD_HOOKS_SCRIPT], { timeoutMs: BUILD_HOOKS_TIMEOUT_MS }),
`node ${BUILD_HOOKS_SCRIPT}`,
);
}
});
const {
getCodexSkillAdapterHeader,
convertClaudeAgentToCodexAgent,
convertClaudeCommandToCodexSkill,
generateCodexAgentToml,
_resetCodexWarningDedupeForTests,
cleanupCodexSkillMetadataSidecars: _cleanupCodexSkillMetadataSidecars,
generateCodexConfigBlock,
stripMsdFromCodexConfig,
migrateCodexHooksMapFormat,
mergeCodexConfig,
install,
MSD_CODEX_MARKER,
deriveCodexSandboxMode,
// #3897 rung 3 (ADR-3473 §8.3, option 2 — HALT.md): anticipated new export
// holding the 17 explicit read-only pins for roles whose tool contract would
// otherwise derive workspace-write (16 measured by HALT.md + msd-nyquist-auditor,
// surfaced by the list-form parse fix). Does not exist on the current tree —
// destructuring a non-existent key is `undefined`, not a throw, so requiring
// this module still succeeds; every test below that touches it fails on its
// own `typeof` guard instead.
CODEX_SANDBOX_HOLDS,
parseTomlToObject,
validateCodexConfigSchema,
uninstall: _uninstall,
CODEX_EXTENDED_HOOK_EVENTS: _CODEX_EXTENDED_HOOK_EVENTS,
} = require('../bin/install.js');
const { resolveNodeRunner: _resolveNodeRunner } = require('../msd-core/bin/lib/runtime-hooks-surface.cjs');
const { resolveInstallPlan } = require('../msd-core/bin/lib/runtime-config-adapter-registry.cjs');
// #3897 fixup: deriveCodexSandboxMode's 2nd param is now the already-resolved
// `tools:` frontmatter VALUE, not raw agent content (codex-agent-toml.cjs no
// longer parses frontmatter at all — no third copy of that extraction).
const {
extractFrontmatterAndBody,
extractFrontmatterField,
} = require('../msd-core/bin/lib/runtime-artifact-conversion.cjs');
// #3897 list-form parse fix: the ONE shared `tools:`-value reader both
// sandbox-feeding production paths (`bin/install.js`'s `generateCodexAgentToml`
// and `agent-install-check.cts`'s `checkCodexSandboxPosture`) now route
// through — handles inline (`tools: Read, Write`) AND YAML block-list
// (`tools:` + indented `- Item` lines) form. Used below by `realAgentToolsRaw`
// so the test's own measurement of "what does this role's tool contract
// declare" cannot silently disagree with production (the exact generative-
// fix-divergence shape this fix closes).
const { extractToolsValue } = require('../msd-core/bin/lib/codex-agent-toml.cjs');
function _runCodexInstall(codexHome, cwd = path.join(__dirname, '..')) {
const previousCodeHome = process.env.CODEX_HOME;
const previousHome = process.env.HOME;
const previousUserProfile = process.env.USERPROFILE;
const previousCwd = process.cwd();
process.env.CODEX_HOME = codexHome;
// #2088: Codex skills now install to the canonical $HOME/.agents/skills root
// (os.homedir()-relative, independent of CODEX_HOME — per codex core-skills
// loader.rs). Sandbox HOME to codexHome so skills land under the temp dir
// (codexHome/.agents/skills) instead of polluting the developer's real home.
process.env.HOME = codexHome;
process.env.USERPROFILE = codexHome;
try {
process.chdir(cwd);
return install(true, 'codex');
} finally {
process.chdir(previousCwd);
if (previousCodeHome === undefined) delete process.env.CODEX_HOME;
else process.env.CODEX_HOME = previousCodeHome;
if (previousHome === undefined) delete process.env.HOME;
else process.env.HOME = previousHome;
if (previousUserProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = previousUserProfile;
}
}
// #2088: the canonical Codex skill-install root, sandboxed under codexHome.
function _codexSkillsRoot(codexHome) {
return path.join(codexHome, '.agents', 'skills');
}
function _readCodexConfig(codexHome) {
return fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8');
}
function _writeCodexConfig(codexHome, content) {
fs.mkdirSync(codexHome, { recursive: true });
fs.writeFileSync(path.join(codexHome, 'config.toml'), content, 'utf8');
}
function _readHooksSessionStartCommands(codexHome) {
const hooksPath = path.join(codexHome, 'hooks.json');
if (!fs.existsSync(hooksPath)) return [];
const raw = fs.readFileSync(hooksPath, 'utf8').trim();
if (!raw) return [];
const parsed = JSON.parse(raw);
const table = (parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks))
? parsed.hooks
: parsed;
const sessionStart = Array.isArray(table.SessionStart) ? table.SessionStart : [];
return sessionStart.flatMap((entry) => [
...(typeof entry?.command === 'string' ? [entry.command] : []),
...(Array.isArray(entry?.hooks)
? entry.hooks.map((hook) => hook && hook.command).filter((cmd) => typeof cmd === 'string')
: []),
]);
}
function countMatches(content, pattern) {
return (content.match(pattern) || []).length;
}
function _assertNoDraftRootKeys(content) {
assert.ok(!content.includes('model = "gpt-5.6-terra"'), 'does not inject draft model default');
assert.ok(!content.includes('model_reasoning_effort = "high"'), 'does not inject draft reasoning default');
assert.ok(!content.includes('disable_response_storage = true'), 'does not inject draft storage default');
}
function assertUsesOnlyEol(content, eol) {
if (eol === '\r\n') {
assert.ok(content.includes('\r\n'), 'contains CRLF line endings');
assert.ok(!content.replace(/\r\r?\n/g, '').includes('\n'), 'does not contain bare LF line endings');
return;
}
assert.ok(!content.includes('\r\n'), 'does not contain CRLF line endings');
}
function assertNoCodexBareMsdToolsInvocation(content, label) {
const patterns = [
/(^|\r?\n)[ \t]*msd-tools\s/,
/\$\(\s*msd-tools\s/,
/`\s*msd-tools\s/,
/(?:&&|\|\||[;|])\s*msd-tools\s/,
];
for (const pattern of patterns) {
assert.doesNotMatch(
content,
pattern,
`${label} must not contain a command-position bare msd-tools invocation`,
);
}
}
// ─── getCodexSkillAdapterHeader ─────────────────────────────────────────────────
describe('getCodexSkillAdapterHeader', () => {
test('contains all three sections', () => {
const result = getCodexSkillAdapterHeader('msd-execute-phase');
assert.ok(result.includes(''), 'has opening tag');
assert.ok(result.includes(''), 'has closing tag');
assert.ok(result.includes('## A. Skill Invocation'), 'has section A');
assert.ok(result.includes('## B. AskUserQuestion'), 'has section B');
assert.ok(result.includes('## C. Task() → spawn_agent'), 'has section C');
});
test('includes correct invocation syntax', () => {
const result = getCodexSkillAdapterHeader('msd-plan-phase');
assert.ok(result.includes('`$msd-plan-phase`'), 'has $skillName invocation');
assert.ok(result.includes('{{MSD_ARGS}}'), 'has MSD_ARGS variable');
});
test('section B maps AskUserQuestion parameters', () => {
const result = getCodexSkillAdapterHeader('msd-discuss-phase');
assert.ok(result.includes('request_user_input'), 'maps to request_user_input');
assert.ok(result.includes('header'), 'maps header parameter');
assert.ok(result.includes('question'), 'maps question parameter');
assert.ok(result.includes('label'), 'maps options label');
assert.ok(result.includes('description'), 'maps options description');
assert.ok(result.includes('multiSelect'), 'documents multiSelect workaround');
assert.ok(result.includes('Execute mode'), 'documents Execute mode fallback');
});
test('section C maps Task to spawn_agent', () => {
const result = getCodexSkillAdapterHeader('msd-execute-phase');
assert.ok(result.includes('spawn_agent'), 'maps to spawn_agent');
assert.ok(result.includes('agent_type'), 'maps subagent_type to agent_type');
// #4270: resolve-model exposes the portable field as `effort`; the Codex
// adapter must fetch it and translate it to spawn_agent.reasoning_effort.
assert.match(
result,
/query resolve-model --pick effort/,
'retrieves the unified effort for the dispatched role',
);
assert.match(
result,
/unified `effort` field maps to the Codex spawn argument\s+`reasoning_effort`/,
'documents reasoning_effort transport',
);
assert.ok(result.includes('do not invent one-off effort literals'), 'keeps effort policy centralized');
assert.ok(result.includes('fork_context'), 'documents fork_context default');
// #3004: collaboration tool vocabulary must match Codex's actual schema.
assert.ok(result.includes('wait_agent'), 'documents the real collaboration wait tool (wait_agent, not wait(ids))');
assert.ok(!result.includes('wait(ids)'), 'must NOT contain the obsolete wait(ids) spelling');
assert.ok(result.includes('functions.wait'), 'disambiguates from the unrelated exec-cell functions.wait tool');
assert.ok(result.includes('task_name'), 'documents the required task_name field');
assert.ok(result.includes('fork_turns'), 'documents the fork_turns parameter');
assert.ok(result.includes('close_agent'), 'documents close_agent cleanup');
assert.ok(result.includes('tool_search'), 'gates close_agent on tool visibility (schema detection)');
assert.ok(result.includes('CHECKPOINT'), 'documents result markers');
});
});
// ─── convertClaudeAgentToCodexAgent ─────────────────────────────────────────────
describe('convertClaudeAgentToCodexAgent', () => {
test('adds codex_agent_role header and cleans frontmatter', () => {
const input = `---
name: msd-executor
description: Executes MSD plans with atomic commits
tools: Read, Write, Edit, Bash, Grep, Glob
color: yellow
---
You are a MSD plan executor.
`;
const result = convertClaudeAgentToCodexAgent(input);
// Frontmatter rebuilt with only name and description
assert.ok(result.startsWith('---\n'), 'starts with frontmatter');
assert.ok(result.includes('"msd-executor"'), 'has quoted name');
assert.ok(result.includes('"Executes MSD plans with atomic commits"'), 'has quoted description');
assert.ok(!result.includes('color: yellow'), 'drops color field');
// Tools should be in but NOT in frontmatter
const fmEnd = result.indexOf('---', 4);
const frontmatterSection = result.substring(0, fmEnd);
assert.ok(!frontmatterSection.includes('tools:'), 'drops tools from frontmatter');
// Has codex_agent_role block
assert.ok(result.includes(''), 'has role header');
assert.ok(result.includes('role: msd-executor'), 'role matches agent name');
assert.ok(result.includes('tools: Read, Write, Edit, Bash, Grep, Glob'), 'tools in role block');
assert.ok(result.includes('purpose: Executes MSD plans with atomic commits'), 'purpose from description');
assert.ok(result.includes(''), 'has closing tag');
// Body preserved
assert.ok(result.includes(''), 'body content preserved');
});
test('converts slash commands in body', () => {
const input = `---
name: msd-test
description: Test agent
tools: Read
---
Run /msd:execute-phase to proceed.`;
const result = convertClaudeAgentToCodexAgent(input);
assert.ok(result.includes('$msd-execute-phase'), 'converts slash commands');
assert.ok(!result.includes('/msd:execute-phase'), 'original slash command removed');
});
test('handles content without frontmatter', () => {
const input = 'Just some content without frontmatter.';
const result = convertClaudeAgentToCodexAgent(input);
assert.strictEqual(result, input, 'returns input unchanged');
});
test('replaces .claude paths with .codex paths (#1430)', () => {
const input = `---
name: msd-debugger
description: Debugs issues
tools: Read, Bash
---
INIT=$(node "$HOME/.claude/msd-core/bin/msd-tools.cjs" state load)
node "$HOME/.claude/msd-core/bin/msd-tools.cjs" commit "docs: resolve"`;
const result = convertClaudeAgentToCodexAgent(input);
assert.ok(result.includes('$HOME/.codex/msd-core/bin/msd-tools.cjs'), 'replaces $HOME/.claude/ with $HOME/.codex/');
assert.ok(!result.includes('$HOME/.claude/'), 'no .claude paths remain');
});
test('rewrites bare msd-tools invocations to the Codex shim path', () => {
const input = `---
name: msd-planner
description: Plans phases
tools: Read, Bash
---
INIT=$(msd-tools query init.plan-phase "\${PHASE}")
msd-tools query state.load 2>/dev/null
if command -v msd-tools >/dev/null 2>&1; then echo "path fallback"; fi
Use \`msd-tools query history-digest\` for history.`;
const result = convertClaudeAgentToCodexAgent(input);
assert.ok(
result.includes('INIT=$(node "$HOME/.codex/msd-core/bin/msd-tools.cjs" query init.plan-phase'),
'rewrites command substitution',
);
assert.ok(
result.includes('node "$HOME/.codex/msd-core/bin/msd-tools.cjs" query state.load'),
'rewrites line-start command',
);
assert.ok(
result.includes('`node "$HOME/.codex/msd-core/bin/msd-tools.cjs" query history-digest`'),
'rewrites inline command example',
);
assert.ok(result.includes('command -v msd-tools'), 'keeps PATH resolver probe intact');
assertNoCodexBareMsdToolsInvocation(result, 'converted Codex agent');
});
});
// ─── Codex command prefix conversion ────────────────────────────────────────────
describe('Codex hyphen-style command prefix conversion', () => {
test('converts /msd-command in workflow output to $msd-command', () => {
const input = `---
name: msd-test
description: Test
tools: Read
---
/msd-discuss-phase 1 — gather context
/msd-plan-phase 2 — create plan
/msd-execute-phase 3 — run it`;
const result = convertClaudeCommandToCodexSkill(input, 'msd-test');
assert.ok(result.includes('$msd-discuss-phase'), 'converts /msd-discuss-phase');
assert.ok(result.includes('$msd-plan-phase'), 'converts /msd-plan-phase');
assert.ok(result.includes('$msd-execute-phase'), 'converts /msd-execute-phase');
assert.ok(!result.includes('/msd-discuss-phase'), 'no /msd-discuss-phase remains');
});
test('converts backtick-wrapped /msd- commands', () => {
const input = `---
name: msd-test
description: Test
tools: Read
---
Run \`/msd-plan-phase 1\` to plan.`;
const result = convertClaudeCommandToCodexSkill(input, 'msd-test');
assert.ok(result.includes('$msd-plan-phase'), 'converts backtick-wrapped command');
});
test('does not convert /msd- in file paths', () => {
const input = `---
name: msd-test
description: Test
tools: Read
---
node "$HOME/.claude/msd-core/bin/msd-tools.cjs" init`;
const result = convertClaudeCommandToCodexSkill(input, 'msd-test');
assert.ok(result.includes('msd-tools.cjs'), 'msd-tools.cjs preserved in path');
assert.ok(!result.includes('$msd-tools'), 'no $msd-tools in file path');
});
test('rewrites bare msd-tools commands in generated Codex skills', () => {
const input = `---
name: msd:quick
description: Quick task
---
\`\`\`bash
msd-tools query frontmatter.get .planning/quick/example/SUMMARY.md status
INIT=$(msd-tools query init.quick)
if command -v msd-tools >/dev/null 2>&1; then echo ok; fi
\`\`\`
Status fields read via \`msd-tools query frontmatter.get\`.`;
const result = convertClaudeCommandToCodexSkill(input, 'msd-quick');
assert.ok(
result.includes('node "$HOME/.codex/msd-core/bin/msd-tools.cjs" query frontmatter.get'),
'rewrites line-start command in a shell block',
);
assert.ok(
result.includes('INIT=$(node "$HOME/.codex/msd-core/bin/msd-tools.cjs" query init.quick)'),
'rewrites command substitution in a shell block',
);
assert.ok(
result.includes('`node "$HOME/.codex/msd-core/bin/msd-tools.cjs" query frontmatter.get`'),
'rewrites inline command example',
);
assert.ok(result.includes('command -v msd-tools'), 'keeps resolver probe intact');
assertNoCodexBareMsdToolsInvocation(result, 'converted Codex skill');
});
test('removes /clear then: for Codex', () => {
const input = `---
name: msd-test
description: Test
tools: Read
---
\`/clear\` then:
\`$msd-plan-phase 1\``;
const result = convertClaudeCommandToCodexSkill(input, 'msd-test');
assert.ok(!result.includes('/clear'), 'no /clear remains');
assert.ok(result.includes('$msd-plan-phase'), 'command preserved after /clear removal');
});
test('removes bare /clear then: for Codex', () => {
const input = `---
name: msd-test
description: Test
tools: Read
---
/clear then:
/msd-execute-phase 2`;
const result = convertClaudeCommandToCodexSkill(input, 'msd-test');
assert.ok(!result.includes('/clear'), 'no /clear remains');
assert.ok(result.includes('$msd-execute-phase'), 'command converted');
});
});
// ─── generateCodexAgentToml ─────────────────────────────────────────────────────
describe('generateCodexAgentToml', () => {
const sampleAgent = `---
name: msd-executor
description: Executes plans
tools: Read, Write, Edit
color: yellow
---
You are an executor.`;
test('sets workspace-write for executor', () => {
const result = generateCodexAgentToml('msd-executor', sampleAgent);
assert.ok(result.includes('sandbox_mode = "workspace-write"'), 'has workspace-write');
});
test('sets read-only for plan-checker', () => {
const checker = `---
name: msd-plan-checker
description: Checks plans
tools: Read, Grep, Glob
---
You check plans.`;
const result = generateCodexAgentToml('msd-plan-checker', checker);
assert.ok(result.includes('sandbox_mode = "read-only"'), 'has read-only');
});
test('includes developer_instructions from body', () => {
const result = generateCodexAgentToml('msd-executor', sampleAgent);
assert.ok(result.includes("developer_instructions = '''"), 'has literal triple-quoted instructions');
assert.ok(result.includes('You are an executor.'), 'body content in instructions');
assert.ok(result.includes("'''"), 'has closing literal triple quotes');
});
test('includes required name and description fields', () => {
const result = generateCodexAgentToml('msd-executor', sampleAgent);
assert.ok(result.includes('name = "msd-executor"'), 'has name');
assert.ok(result.includes('description = "Executes plans"'), 'has description');
});
test('falls back to generated description when frontmatter is missing fields', () => {
const minimalAgent = `You are an unknown agent.`;
const result = generateCodexAgentToml('msd-unknown', minimalAgent);
assert.ok(result.includes('name = "msd-unknown"'), 'falls back to agent name');
assert.ok(result.includes('description = "MSD agent msd-unknown"'), 'falls back to synthetic description');
});
// #3897 CAUSE B fix: this used to assert the deleted name-based fallback
// (an unknown agent NAME defaulted to read-only regardless of its tool
// contract). Under derivation, identity no longer determines the sandbox —
// the tool contract does (S6: "a new writing role gets the contract, not
// the pin"). Split into the two rows the old single assertion conflated:
// absence of a `tools:` grant (N8) vs. an unknown role that legitimately
// declares a writing tool (S6).
test('unknown agent with no tools: frontmatter derives read-only (N8: absence is not a grant)', () => {
const noToolsAgent = `---
name: msd-unknown
description: An unknown agent with no declared tools
---
You are an unknown agent.`;
const result = generateCodexAgentToml('msd-unknown', noToolsAgent);
assert.ok(result.includes('sandbox_mode = "read-only"'), 'no tools: frontmatter -> read-only');
});
test('unknown agent declaring Write/Edit derives workspace-write (S6: the tool contract, not the pin, decides)', () => {
const result = generateCodexAgentToml('msd-unknown', sampleAgent);
assert.ok(result.includes('sandbox_mode = "workspace-write"'), 'declares Write/Edit -> workspace-write');
});
// ─── #2256: model_overrides support ───────────────────────────────────────
test('emits model field when modelOverrides contains an entry for the agent (#2256)', () => {
const overrides = { 'msd-executor': 'gpt-5.3-codex' };
const result = generateCodexAgentToml('msd-executor', sampleAgent, overrides);
assert.ok(result.includes('model = "gpt-5.3-codex"'), 'model field must be present in TOML');
});
test('does not emit model field when modelOverrides is null (#2256)', () => {
const result = generateCodexAgentToml('msd-executor', sampleAgent, null);
assert.ok(!result.includes('model ='), 'model field must be absent when no override');
});
test('does not emit reasoning effort when Codex model is inherited (#838)', () => {
const result = generateCodexAgentToml('msd-executor', sampleAgent, null);
assert.ok(!result.includes('model ='), 'model field must be absent when Codex should inherit');
assert.ok(
!result.includes('model_reasoning_effort ='),
'reasoning effort must stay absent when the model is inherited'
);
});
test('emits reasoning effort when model override pins Codex model (#838)', () => {
const overrides = { 'msd-executor': 'gpt-5.3-codex' };
const result = generateCodexAgentToml('msd-executor', sampleAgent, overrides);
assert.ok(result.includes('model = "gpt-5.3-codex"'), 'model override must pin model');
assert.ok(
result.includes('model_reasoning_effort ='),
'reasoning effort is safe to emit when MSD also pins model'
);
});
test('omits model and reasoning effort when only the runtime resolver would have pinned one (#838, #3241)', () => {
// #3241 flips this test: the runtime-resolver auto-embed block (D1) was
// removed, so a resolver alone (no explicit model_overrides) no longer
// pins a model at install time, and #838's model/effort coupling means
// neither line survives.
const runtimeResolver = { resolve: () => ({ model: 'gpt-5.5' }) };
const result = generateCodexAgentToml('msd-executor', sampleAgent, null, runtimeResolver);
assert.ok(!result.includes('model = "gpt-5.5"'), 'runtime resolver alone must not pin model (#3241)');
assert.ok(
!result.includes('model_reasoning_effort ='),
'reasoning effort must not survive an omitted resolver model (#838 coupling)'
);
});
test('does not emit model field when modelOverrides has no entry for this agent (#2256)', () => {
const overrides = { 'msd-planner': 'gpt-5.4' };
const result = generateCodexAgentToml('msd-executor', sampleAgent, overrides);
assert.ok(!result.includes('model ='), 'model field must be absent for agents not in overrides');
});
test('model field appears before developer_instructions (#2256)', () => {
const overrides = { 'msd-executor': 'gpt-5.3-codex' };
const result = generateCodexAgentToml('msd-executor', sampleAgent, overrides);
const modelIdx = result.indexOf('model = "gpt-5.3-codex"');
const instrIdx = result.indexOf("developer_instructions = '''");
assert.ok(modelIdx !== -1, 'model field present');
assert.ok(instrIdx !== -1, 'developer_instructions present');
assert.ok(modelIdx < instrIdx, 'model field must appear before developer_instructions');
});
// ─── #2310: never leak an Anthropic-flavored model into the Codex .toml ─────
test('omits a bare MSD tier alias in model_overrides (Codex passive/session-only) (#2310)', () => {
// ADR-1239: Codex is a passive/session-only model host. A tier alias cannot be
// honored per-agent, so it is dropped and the agent inherits the session model (no 400).
for (const alias of ['opus', 'sonnet', 'haiku', 'fable']) {
const result = generateCodexAgentToml('msd-executor', sampleAgent, { 'msd-executor': alias });
assert.ok(!/^model = /m.test(result), `alias "${alias}" must be omitted (no model pinned)`);
}
});
test('never emits a bare Anthropic tier alias as the Codex model (#2310)', () => {
for (const alias of ['opus', 'sonnet', 'haiku', 'fable']) {
const result = generateCodexAgentToml('msd-executor', sampleAgent, { 'msd-executor': alias });
assert.ok(!/^model = "(opus|sonnet|haiku|fable)"$/m.test(result), `must not emit model = "${alias}"`);
}
});
test('drops a claude-* model_overrides id instead of leaking it into the Codex .toml (#2310)', () => {
const result = generateCodexAgentToml('msd-executor', sampleAgent, { 'msd-executor': 'claude-sonnet-5' });
assert.ok(!result.includes('claude-'), 'a claude-* id must never appear as the Codex model');
// No runtime resolver → nothing to fall through to → no model line at all.
assert.ok(!result.includes('model ='), 'unmappable Anthropic override falls through to Codex default (no model pinned)');
});
test('a dropped claude-* override no longer falls through to the runtime resolver (#2310, #3241)', () => {
// #3241 (D1) removed the runtime-resolver fallback embed entirely, so a
// dropped alias/claude-* override now has nothing left to fall through to
// — it is simply omitted, same as the claude id never leaking.
const runtimeResolver = { runtime: 'codex', resolve: () => ({ model: 'gpt-5.6-terra' }) };
const result = generateCodexAgentToml('msd-executor', sampleAgent, { 'msd-executor': 'claude-opus-4-8' }, runtimeResolver);
assert.ok(!result.includes('model = "gpt-5.6-terra"'), 'resolver fallback no longer fires (#3241 D1)');
assert.ok(!result.includes('claude-'), 'claude id must not leak even with a resolver present');
});
test('final gate blocks an Anthropic model from the runtime-resolver path too (#2310)', () => {
// Simulate a defaults.json runtime that does not match the codex install target:
// the resolver hands back a Claude id, which must still never reach the Codex .toml.
const runtimeResolver = { runtime: 'claude', resolve: () => ({ model: 'claude-sonnet-5' }) };
const result = generateCodexAgentToml('msd-executor', sampleAgent, null, runtimeResolver);
assert.ok(!result.includes('claude-'), 'runtime-resolver Claude id must be gated out of the Codex .toml');
assert.ok(!result.includes('model ='), 'no valid Codex model available → none pinned');
});
test('still emits a real Codex/OpenAI model_overrides id verbatim (#2310 preserves #2256)', () => {
const result = generateCodexAgentToml('msd-executor', sampleAgent, { 'msd-executor': 'gpt-5.6-sol' });
assert.ok(result.includes('model = "gpt-5.6-sol"'), 'a real gpt-* override must still pass through unchanged');
});
test('gates a provider-namespaced anthropic/claude-* model from the runtime-resolver path (#2310 review)', () => {
// Catalog assigns anthropic/claude-* to opencode/hermes/kilo. A mixed-runtime config
// (runtime: opencode) + Codex install resolves those; they must NOT reach the .toml.
const runtimeResolver = { runtime: 'opencode', resolve: () => ({ model: 'anthropic/claude-opus-4-8' }) };
const result = generateCodexAgentToml('msd-executor', sampleAgent, null, runtimeResolver);
assert.ok(!/claude/i.test(result.split('\n').find((l) => /^model = /.test(l)) || ''), 'no claude-bearing model may be emitted');
assert.ok(!/^model = /m.test(result), 'anthropic/claude-* is gated out → no model pinned');
});
test('omits a provider-namespaced anthropic/claude-* model_overrides pin (#2310 review)', () => {
const result = generateCodexAgentToml('msd-executor', sampleAgent, { 'msd-executor': 'anthropic/claude-sonnet-5' });
assert.ok(!result.includes('claude'), 'anthropic/claude-* must be omitted, never emitted');
assert.ok(!/^model = /m.test(result), 'no model pinned');
});
test('every canonical Claude tier alias is omitted (single-source guard, #2310 review)', () => {
// Iterates the CANONICAL set so a future alias is covered automatically (no divergence).
for (const alias of CLAUDE_AGENT_ALIASES) {
const result = generateCodexAgentToml('msd-executor', sampleAgent, { 'msd-executor': alias });
assert.ok(!/^model = /m.test(result), `canonical alias "${alias}" must be omitted`);
}
});
test('drops the fable alias (Claude Agent alias with no Codex mapping) (#2310)', () => {
const result = generateCodexAgentToml('msd-executor', sampleAgent, { 'msd-executor': 'fable' });
assert.ok(!result.includes('model = "fable"'), 'fable must never be emitted as the Codex model');
assert.ok(!result.includes('model ='), 'fable has no Codex mapping → dropped, no model pinned');
});
test('property: no model_overrides value ever yields an Anthropic-flavored Codex model (#2310)', () => {
const anthropicish = fc.oneof(
fc.constantFrom('opus', 'sonnet', 'haiku', 'fable'),
fc.string().map((s) => `claude-${s}`),
fc.string().map((s) => `anthropic/claude-${s}`),
fc.string().map((s) => `us.anthropic.claude-${s}`),
fc.string(),
);
fc.assert(fc.property(anthropicish, (v) => {
const result = generateCodexAgentToml('msd-executor', sampleAgent, { 'msd-executor': v });
const m = result.split('\n').find((l) => /^model = /.test(l));
if (!m) return true; // no model pinned is always safe
return !/claude/i.test(m) && !/^model = "(opus|sonnet|haiku|fable)"$/.test(m);
}), { numRuns: 400 });
});
// ─── #3241: omit the Codex per-agent model by default (resolver-only path) ────
// Phase 1 removes the runtime-resolver auto-embed. These tests drive the
// shipping default shape — runtime set + model_profile:"balanced" (mocked
// here as a resolver object, matching the existing #2517/#838 tests above,
// e.g. L514-522) — with NO model_overrides, and assert the model line (and
// its coupled model_reasoning_effort, #838) are omitted.
test('omits model and model_reasoning_effort when only the runtime resolver would have supplied one (#3241)', () => {
// RED (pre-fix): today this resolver-only path still embeds the tier
// model (see L514-522's "runtime resolver pins Codex model" test, which
// asserts the opposite of this on purpose and is left untouched per the
// Phase 1 rollout plan). Both assertions below fail against the current
// tree: `model = "gpt-5.6-sol"` and `model_reasoning_effort = "high"`
// are both present in `result` today.
const runtimeResolver = { runtime: 'codex', resolve: () => ({ model: 'gpt-5.6-sol' }) };
const result = generateCodexAgentToml('msd-executor', sampleAgent, null, runtimeResolver);
assert.ok(!/^model = /m.test(result),
'a resolver-only tier model must not be embedded by default (#3241)');
assert.ok(!result.includes('model_reasoning_effort ='),
'reasoning effort must not survive an omitted resolver model (#838 coupling)');
});
test('resolver is null (inherit profile or no runtime configured) emits no model and no warning (#3241)', (t) => {
// Regression guard — PASSES today already: readMsdRuntimeProfileResolver
// already returns null for both "no runtime" and model_profile:"inherit"
// (bin/install.js:1632, :1635), and generateCodexAgentToml already omits
// the model when runtimeResolver is null. Nothing in Phase 1 touches this
// branch; this test exists to prove it keeps holding after the fix lands.
const origWrite = process.stderr.write;
const stderrChunks = [];
process.stderr.write = (chunk) => { stderrChunks.push(String(chunk)); return true; };
t.after(() => { process.stderr.write = origWrite; });
const result = generateCodexAgentToml('msd-executor', sampleAgent, null, null);
assert.ok(!/^model = /m.test(result), 'no model when the resolver is null');
assert.strictEqual(stderrChunks.join(''), '', 'inherit/no-runtime users must never be warned — nothing was lost');
});
test('resolver present but resolve() yields nothing emits no model and no warning (#3241)', (t) => {
// Regression guard — PASSES today already: entry?.model is undefined when
// resolve() returns null, so pinnedModel stays null and no warning branch
// is reachable in current code. Nothing was lost, so nothing should warn,
// before or after the fix (negative-space row in 40-design.md).
const origWrite = process.stderr.write;
const stderrChunks = [];
process.stderr.write = (chunk) => { stderrChunks.push(String(chunk)); return true; };
t.after(() => { process.stderr.write = origWrite; });
const runtimeResolver = { runtime: 'codex', resolve: () => null };
const result = generateCodexAgentToml('msd-executor', sampleAgent, null, runtimeResolver);
assert.ok(!/^model = /m.test(result), 'no model when resolve() yields nothing');
assert.strictEqual(stderrChunks.join(''), '', 'a resolver that would not have pinned anything must never warn');
});
test('empty-string and whitespace-only model_overrides are not pins and not warnings (#3241)', (t) => {
// '' — regression guard, PASSES today: '' is falsy, so the pin branch is
// never entered at all (no pin, no warning either old or new).
//
// ' ' (whitespace-only) — RED (pre-fix, live defect, fixed in this
// phase per maintainer direction): a whitespace-only override is a
// truthy JS string, is not Anthropic-flavored per `_isAnthropicFlavoredModel`,
// and is CURRENTLY pinned verbatim (`model = " "`) with no guard —
// the same class of bug the #2310 guard exists to stop (a non-model
// value reaching the .toml and 400-ing the Codex agent). Must be
// silently dropped, matching how '' already behaves — NOT routed through
// `_warnCodexModelOverrideDropped` (that warning's "is not a valid Codex
// model (Anthropic alias/id)" text would misdescribe a blank field), so
// no warning of any kind is expected for it either.
const origWrite = process.stderr.write;
const stderrChunks = [];
process.stderr.write = (chunk) => { stderrChunks.push(String(chunk)); return true; };
t.after(() => { process.stderr.write = origWrite; });
const emptyResult = generateCodexAgentToml('msd-executor', sampleAgent, { 'msd-executor': '' });
const whitespaceResult = generateCodexAgentToml('msd-executor', sampleAgent, { 'msd-executor': ' ' });
assert.ok(!/^model = /m.test(emptyResult), 'empty-string override must not be pinned');
assert.ok(!/^model = /m.test(whitespaceResult), 'whitespace-only override must not be pinned (#3241 fix)');
assert.strictEqual(stderrChunks.join(''), '', 'empty-string/whitespace overrides must never warn');
});
test('non-string model_overrides values are ignored without throwing (#3241)', () => {
// Regression guard — PASSES today already: none of these ever reach the
// string-pin branch (`typeof rawModelOverride === 'string'` gates it), so
// no value here is ever emitted as `model =`, and none of them throw.
// Some truthy non-string values (42, {}, true, []) DO hit the existing
// `_warnCodexModelOverrideDropped` warn branch today — that is pre-2310
// behavior this phase does not touch, so no assertion is made on warning
// presence/absence here, only "no pin" and "no crash" per the matrix.
const hostileValues = [42, {}, null, true, [], 0, NaN];
for (const value of hostileValues) {
assert.doesNotThrow(() => {
const result = generateCodexAgentToml('msd-executor', sampleAgent, { 'msd-executor': value });
assert.ok(!/^model = /m.test(result), `non-string override ${JSON.stringify(value)} must not be pinned`);
}, `non-string override ${JSON.stringify(value)} must not throw`);
}
});
// 50-test-matrix.md row 15 (oversized warning value truncated at 64 chars)
// is deliberately NOT covered here. Maintainer-confirmed pinned wording
// (see the describe block below) interpolates no user-controlled value —
// no agent name, no model string — so there is nothing in the message that
// could ever exhibit truncation. A test asserting truncation against a
// message with no interpolated value would be vacuous by construction.
test('light-tier service_tier/model_verbosity survive independent of whether a model is pinned (#3241, #774 decoupling guard)', () => {
// Regression guard — PASSES today already: the light-tier emission block
// (bin/install.js ~L4198-4203) reads AGENT_DEFAULT_TIERS unconditionally
// and never inspects pinnedModel/hasPinnedModel. This test exists to
// catch a FUTURE implementation that wrongly couples these fields to
// hasPinnedModel while implementing #3241 — if that coupling is ever
// introduced, this is the test that turns red. It is not expected to be
// red before the #3241 fix lands, and per 50-test-matrix.md's own
// "Red-before-green" note this is the row most likely to be accidentally
// vacuous — flagged explicitly here rather than mis-classified.
const runtimeResolver = { runtime: 'codex', resolve: () => ({ model: 'gpt-5.6-sol' }) };
const lightAgent = `---
name: msd-plan-checker
description: Checks plans quickly
tools: Read, Grep
---
You check plans.`;
const lightResult = generateCodexAgentToml('msd-plan-checker', lightAgent, null, runtimeResolver);
assert.ok(lightResult.includes('service_tier = "flex"'), 'service_tier must not be coupled to whether a model is pinned');
assert.ok(lightResult.includes('model_verbosity = "low"'), 'model_verbosity must not be coupled to whether a model is pinned');
// Other direction: a non-light agent with no model at all must still gain
// neither field (duplicates the existing #774 coverage at L647-652
// intentionally — 50-test-matrix.md row 18 folds this into row 17 as the
// same independence guard, viewed from the opposite direction).
const standardResult = generateCodexAgentToml('msd-executor', sampleAgent, null, null);
assert.ok(!standardResult.includes('service_tier'), 'standard-tier agent must not gain service_tier just because no model is pinned');
assert.ok(!standardResult.includes('model_verbosity'), 'standard-tier agent must not gain model_verbosity just because no model is pinned');
});
// ─── #3241 review: gate the deprecation notice on "would have been EMBEDDED",
// not "would have been returned" ────────────────────────────────────────────
// The resolver-would-have-supplied-a-model check above (L652-665) doesn't
// inspect stderr, so it couldn't catch this: the notice must not fire when
// the would-be resolver model would ALSO have been rejected by the #2310
// Anthropic-flavored gate (L4192) pre-Phase-1 — that user never had the pin
// in the first place, so telling them to set model_overrides is false. The
// notice's one-time dedupe is a module-level boolean shared across every
// test in this file (an earlier test in this describe block, e.g.
// L652-665, may have already latched it), so each test here resets it via
// the documented test seam (_resetCodexWarningDedupeForTests) instead of
// busting require.cache — a cache bust would create a second module
// instance and break every other test in this file that assumes a single
// shared instance.
function captureStderr(t) {
const origWrite = process.stderr.write;
const chunks = [];
process.stderr.write = (chunk) => { chunks.push(String(chunk)); return true; };
t.after(() => { process.stderr.write = origWrite; });
return () => chunks.join('').split(/\r?\n/).filter((l) => l.length > 0);
}
test('no deprecation notice when the resolver would only have produced an Anthropic-flavored model (#3241 review — defect fix)', (t) => {
_resetCodexWarningDedupeForTests();
const getLines = captureStderr(t);
// Mixed-runtime config (runtime: opencode) resolving against a Codex
// install target — the #2310 gate (bin/install.js:4192) rejects this
// model BEFORE Phase 1 too, so this user never had the pin. Bare alias
// form covered too, since both routes hit the same gate.
for (const model of ['anthropic/claude-opus-4-8', 'sonnet']) {
const runtimeResolver = { runtime: 'opencode', resolve: () => ({ model }) };
const result = generateCodexAgentToml('msd-executor', sampleAgent, null, runtimeResolver);
assert.ok(!/^model = /m.test(result), `no model pinned for would-be resolver model "${model}"`);
}
const noticeLines = getLines().filter((l) => l.startsWith('msd: notice — '));
assert.strictEqual(noticeLines.length, 0,
'no notice: the resolver model would never have survived the #2310 gate pre-Phase-1 either, so nothing was lost');
});
test('deprecation notice still fires when the resolver would have produced a legal Codex model (#3241 review)', (t) => {
_resetCodexWarningDedupeForTests();
const getLines = captureStderr(t);
const runtimeResolver = { runtime: 'codex', resolve: () => ({ model: 'gpt-5.6-sol' }) };
const result = generateCodexAgentToml('msd-executor', sampleAgent, null, runtimeResolver);
assert.ok(!/^model = /m.test(result), 'no model pinned by default (#3241 D1)');
const noticeLines = getLines().filter((l) => l.startsWith('msd: notice — '));
assert.strictEqual(noticeLines.length, 1,
'exactly one notice: a legal gpt-5.6-sol model would have been embedded pre-Phase-1, and now is not — the fix must not over-correct into silence');
});
test('both the override-dropped warning and the resolver-omitted notice fire for an Anthropic override plus a legal resolver model (#3241 review — intentional, do NOT collapse to one message)', (t) => {
// NOT a defect. Two distinct true facts, two distinct prefixes:
// - model_overrides:"sonnet" is Anthropic-flavored → dropped pre-Phase-1
// too (#2310 gate on the override path) → `msd: warning — ` fires.
// - With the override dropped, execution falls through to the runtime
// resolver, which WOULD have supplied "gpt-5.6-sol" (a legal Codex
// model) and that pin WOULD have been embedded pre-Phase-1 → this user
// genuinely lost a pin → `msd: notice — ` fires too.
// A future reader must not "fix" this down to one message.
_resetCodexWarningDedupeForTests();
const getLines = captureStderr(t);
const runtimeResolver = { runtime: 'codex', resolve: () => ({ model: 'gpt-5.6-sol' }) };
const result = generateCodexAgentToml(
'msd-executor', sampleAgent, { 'msd-executor': 'sonnet' }, runtimeResolver,
);
assert.ok(!/^model = /m.test(result), 'no model pinned (Anthropic override dropped, resolver model not auto-embedded)');
const lines = getLines();
const warningLines = lines.filter((l) => l.startsWith('msd: warning — '));
const noticeLines = lines.filter((l) => l.startsWith('msd: notice — '));
assert.strictEqual(warningLines.length, 1, 'exactly one warning: the Anthropic override was dropped');
assert.strictEqual(noticeLines.length, 1, 'exactly one notice: the legal resolver model would have been embedded and now is not');
});
// ─── #774: service_tier / model_verbosity for light-tier agents ───────────────
test('emits service_tier="flex" and model_verbosity="low" for light-tier agents (#774)', () => {
// msd-plan-checker has routingTier:"light" in model-catalog.json
const lightAgent = `---
name: msd-plan-checker
description: Checks plans quickly
tools: Read, Grep
---
You check plans.`;
const result = generateCodexAgentToml('msd-plan-checker', lightAgent);
assert.ok(result.includes('service_tier = "flex"'), 'light-tier agent must have service_tier = "flex"');
assert.ok(result.includes('model_verbosity = "low"'), 'light-tier agent must have model_verbosity = "low"');
});
test('does not emit service_tier or model_verbosity for standard-tier agents (#774)', () => {
// msd-executor has routingTier:"standard" in model-catalog.json
const result = generateCodexAgentToml('msd-executor', sampleAgent);
assert.ok(!result.includes('service_tier'), 'standard-tier agent must not have service_tier');
assert.ok(!result.includes('model_verbosity'), 'standard-tier agent must not have model_verbosity');
});
test('does not emit service_tier or model_verbosity for heavy-tier agents (#774)', () => {
// msd-planner has routingTier:"heavy" in model-catalog.json
const heavyAgent = `---
name: msd-planner
description: Creates plans
tools: Read, Write, Edit
---
You plan.`;
const result = generateCodexAgentToml('msd-planner', heavyAgent);
assert.ok(!result.includes('service_tier'), 'heavy-tier agent must not have service_tier');
assert.ok(!result.includes('model_verbosity'), 'heavy-tier agent must not have model_verbosity');
});
test('service_tier and model_verbosity appear before developer_instructions (#774)', () => {
const lightAgent = `---
name: msd-plan-checker
description: Checks plans
---
You check plans.`;
const result = generateCodexAgentToml('msd-plan-checker', lightAgent);
const stIdx = result.indexOf('service_tier = "flex"');
const mvIdx = result.indexOf('model_verbosity = "low"');
const instrIdx = result.indexOf("developer_instructions = '''");
assert.ok(stIdx !== -1, 'service_tier present');
assert.ok(mvIdx !== -1, 'model_verbosity present');
assert.ok(instrIdx !== -1, 'developer_instructions present');
assert.ok(stIdx < instrIdx, 'service_tier must appear before developer_instructions');
assert.ok(mvIdx < instrIdx, 'model_verbosity must appear before developer_instructions');
});
test('emitted TOML is parseable and contains correct field values for light-tier agents (#774)', () => {
const lightAgent = `---
name: msd-codebase-mapper
description: Maps the codebase
---
You map the codebase.`;
const toml = generateCodexAgentToml('msd-codebase-mapper', lightAgent);
const parsed = parseTomlToObject(toml);
assert.strictEqual(parsed.service_tier, 'flex', 'service_tier must parse to "flex"');
assert.strictEqual(parsed.model_verbosity, 'low', 'model_verbosity must parse to "low"');
});
});
// ─── #3897 rung 3 (ADR-3473 §8.3, HALT.md option 2): sandbox_mode derives from
// the tool contract, with 17 widening roles held at read-only ────────────────
//
// Spec: .msd/phase/feat-3897-adr3473-83-rungs/{40-design,50-test-matrix}.md,
// rows S1-S9 / T20-T30. Measured against `next` @ ad6abc896 (HALT.md):
// deriving `workspace-write` iff an agent's frontmatter `tools:` declares
// `Write` or `Edit` reproduces all 11 CODEX_AGENT_SANDBOX map entries exactly,
// and would additionally widen 16 fallback roles that the map never covered.
// HALT.md's 16 was measured against a `tools:`-VALUE reader that only handled
// inline form; the #3897 list-form parse fix corrected `msd-nyquist-auditor`'s
// YAML block-list `tools:` (previously misread as `"- Read"`, no Write/Edit
// found), which genuinely derives `workspace-write` and adds a 17th widening
// role. `CODEX_SANDBOX_HOLDS` (destructured above; `undefined` on the current
// tree) is this rung's pin list for those 17 — every row below that depends
// on it fails on its own `typeof` guard until it lands.
//
// Deriving-from-real-content is deliberate for T20/T21/T26/T27/T30: a
// synthetic `tools:` fixture cannot prove the CURRENT tree's byte output is
// preserved, only that the derivation LOGIC agrees with a made-up example.
// #3897 rung 3 — bin/install.js's `CODEX_AGENT_SANDBOX` map is DELETED
// (ADR-3473 §8.3): HALT.md measured that deriving `workspace-write` iff an
// agent's `tools:` frontmatter declares Write/Edit reproduces every one of
// these 11 entries exactly, with zero disagreements, making the hand-
// maintained map fully redundant. This literal is the pre-#3897
// `CODEX_AGENT_SANDBOX` contents, preserved here as a regression baseline —
// the only surviving copy of these 11 role -> mode pairs. T21 below and the
// `CODEX_AGENT_SANDBOX (deleted map, derivation regression baseline)` describe
// block both reference this ONE literal rather than duplicating it.
const PRE_3897_CODEX_AGENT_SANDBOX = {
'msd-executor': 'workspace-write',
'msd-planner': 'workspace-write',
'msd-phase-researcher': 'workspace-write',
'msd-project-researcher': 'workspace-write',
'msd-research-synthesizer': 'workspace-write',
'msd-verifier': 'workspace-write',
'msd-codebase-mapper': 'workspace-write',
'msd-roadmapper': 'workspace-write',
'msd-debugger': 'workspace-write',
'msd-plan-checker': 'read-only',
'msd-integration-checker': 'read-only',
};
describe('#3897 rung 3: sandbox_mode derivation and the hold list', () => {
const AGENTS_DIR = path.join(__dirname, '..', 'agents');
// #3897 rung 4 (isolated correctness review, MINOR finding 6): T20/N6 used
// to iterate `Object.keys(EXPECTED_SANDBOX_BY_ROLE)` and then pin
// `assert.equal(checked, 35)` — that pins the FIXTURE, not the roster, so a
// 36th agent added to `agents/` would be silently unchecked by the entire
// rung-3 block instead of failing loudly. Driven from the real roster
// instead; a dedicated parity test below fails loudly, naming any file
// present in one set and not the other, the moment the two diverge.
// #4407: .compact.md variant siblings carry byte-identical `tools:`
// frontmatter to their canonical agent (verified mechanically elsewhere —
// see tests/agent-skills-compact-variant.test.cjs), so deriveCodexSandboxMode
// produces the same, correct sandbox_mode for both — confirmed directly
// against generateCodexAgentToml, not assumed. Excluded from this roster so
// EXPECTED_SANDBOX_BY_ROLE doesn't need a redundant second entry per agent
// that could only ever match its canonical sibling's value or be a bug.
const AGENT_ROSTER_ROLES = fs
.readdirSync(AGENTS_DIR)
.filter((f) => f.endsWith('.md') && !f.endsWith('.compact.md'))
.map((f) => f.slice(0, -'.md'.length))
.sort();
function realAgentToolsRaw(agentName) {
const content = fs.readFileSync(path.join(AGENTS_DIR, `${agentName}.md`), 'utf8');
// #3897 list-form parse fix: route through the SAME shared extractor
// production uses, rather than a naive single-line regex here — a
// second, test-local reimplementation of "read the tools: value" is
// exactly the generative-fix-divergence shape that let this test's own
// `measuredWideningRoles` silently miss `msd-nyquist-auditor` (YAML
// list-form `tools:`) before this fix.
return extractToolsValue(content) ?? '';
}
// #3897 rung 4 (isolated correctness review, NIT finding 7): this used to
// be a SEPARATE, simpler reimplementation of the real, private
// `_codexToolsDeclareWriteOrEdit` (codex-agent-toml.cts) — a naive
// comma-split-and-includes check that does not handle the real predicate's
// "except" negation form (F5). That is exactly the generative-fix-
// divergence shape CLAUDE.md warns about: a future change to the real
// predicate this copy does not mirror would silently disagree with it
// forever. `_codexToolsDeclareWriteOrEdit` is intentionally not exported
// (module-internal), so rather than reimplementing it a second time, this
// delegates to the REAL implementation through the public
// `deriveCodexSandboxMode`, pinned to an identity guaranteed to never be
// held or "suspicious" (F3) — with no hold in play,
// `deriveCodexSandboxMode(identity, toolsRaw) === 'workspace-write'` IS
// `_codexToolsDeclareWriteOrEdit(toolsRaw)`, byte for byte, because there is
// no second copy left to drift.
const PARITY_PROBE_IDENTITY = 'zzz-parity-probe-never-a-real-or-held-role';
function declaresWriteOrEdit(toolsRaw) {
return deriveCodexSandboxMode(PARITY_PROBE_IDENTITY, toolsRaw) === 'workspace-write';
}
test('sanity: the parity-probe identity used by declaresWriteOrEdit is never itself held or suspicious', () => {
assert.equal(
Object.prototype.hasOwnProperty.call(CODEX_SANDBOX_HOLDS, PARITY_PROBE_IDENTITY),
false,
'the probe identity must not collide with a real hold, or every T20/T21/T24 verdict derived from it would be silently wrong',
);
assert.equal(
deriveCodexSandboxMode(PARITY_PROBE_IDENTITY, 'Read, Write, Edit'),
'workspace-write',
'sanity: an unheld identity that declares Write/Edit must derive workspace-write',
);
});
// T20 (LOAD-BEARING, N6): frozen fixture of TODAY's real, per-role emitted
// sandbox_mode for all 35 roles in agents/ — captured from the CURRENT build
// by running the REAL generateCodexAgentToml against the REAL agent .md
// content (never a synthetic fixture) and committed here. This is the
// safety net for the refactor: it is true today (nothing has changed yet)
// and MUST remain true, per role, after sandbox_mode moves from the map to a
// derivation — an aggregate "35 roles emitted" count would pass even if one
// role silently widened; asserting per role (one test per role, mirroring
// codex-agent-toml.test.cjs's A14 round-trip pattern) does not let that hide.
const EXPECTED_SANDBOX_BY_ROLE = {
'msd-advisor-researcher': 'read-only',
'msd-ai-researcher': 'workspace-write',
'msd-assumptions-analyzer': 'read-only',
'msd-code-fixer': 'workspace-write',
'msd-code-reviewer': 'workspace-write',
'msd-codebase-mapper': 'workspace-write',
'msd-debug-session-manager': 'workspace-write',
'msd-debugger': 'workspace-write',
'msd-doc-classifier': 'workspace-write',
'msd-doc-synthesizer': 'workspace-write',
'msd-doc-verifier': 'workspace-write',
'msd-doc-writer': 'workspace-write',
'msd-dom-verifier': 'workspace-write',
'msd-domain-researcher': 'workspace-write',
'msd-eval-auditor': 'workspace-write',
'msd-eval-planner': 'workspace-write',
'msd-executor': 'workspace-write',
'msd-framework-selector': 'read-only',
'msd-integration-checker': 'read-only',
'msd-intel-updater': 'workspace-write',
'msd-mempalace-curator': 'read-only',
'msd-nyquist-auditor': 'workspace-write',
'msd-pattern-mapper': 'workspace-write',
'msd-phase-researcher': 'workspace-write',
'msd-plan-checker': 'read-only',
'msd-planner': 'workspace-write',
'msd-project-researcher': 'workspace-write',
'msd-research-synthesizer': 'workspace-write',
'msd-roadmapper': 'workspace-write',
'msd-security-auditor': 'read-only',
'msd-ui-auditor': 'workspace-write',
'msd-ui-checker': 'read-only',
'msd-ui-researcher': 'workspace-write',
'msd-user-profiler': 'read-only',
'msd-verifier': 'workspace-write',
};
// The 17 roles that widen: declare Write/Edit, not in the pre-#3897
// CODEX_AGENT_SANDBOX map (now deleted; PRE_3897_CODEX_AGENT_SANDBOX above
// is the surviving baseline of its contents), so today's `|| 'read-only'`
// fallback under-grants them (16 measured by HALT.md + msd-nyquist-auditor,
// whose YAML block-list `tools:` the pre-fix single-line reader misread as
// `"- Read"`, hiding its Write/Edit declaration). Computed here from the REAL
// agents/*.md tools frontmatter and that baseline — never hand-copied from
// HALT.md's prose — so this list cannot silently drift from what agents/
// actually declares (T30: "derived, not a second hardcoded copy").
const measuredWideningRoles = Object.keys(EXPECTED_SANDBOX_BY_ROLE).filter((role) => {
const declaresWrite = declaresWriteOrEdit(realAgentToolsRaw(role));
const inOldMap = Object.prototype.hasOwnProperty.call(PRE_3897_CODEX_AGENT_SANDBOX, role);
return declaresWrite && !inOldMap;
});
// #3897 rung 4 (isolated correctness review, MINOR finding 6): the roster
// and the expectation table must cover EXACTLY the same set of roles — a
// role present in one and not the other fails loudly here, by name,
// instead of being silently unchecked by the whole rung-3 block.
test('T20 roster parity: EXPECTED_SANDBOX_BY_ROLE covers exactly the real agents/ roster, no more, no fewer', () => {
const expectedRoles = Object.keys(EXPECTED_SANDBOX_BY_ROLE).sort();
const inRosterNotExpected = AGENT_ROSTER_ROLES.filter((r) => !expectedRoles.includes(r));
const inExpectedNotRoster = expectedRoles.filter((r) => !AGENT_ROSTER_ROLES.includes(r));
assert.deepEqual(
inRosterNotExpected,
[],
`agents/ contains role(s) with no EXPECTED_SANDBOX_BY_ROLE entry: ${JSON.stringify(inRosterNotExpected)} — add them or this rung's coverage silently skips them`,
);
assert.deepEqual(
inExpectedNotRoster,
[],
`EXPECTED_SANDBOX_BY_ROLE names role(s) no longer present in agents/: ${JSON.stringify(inExpectedNotRoster)} — remove the stale entry`,
);
});
for (const role of AGENT_ROSTER_ROLES) {
test(`T20 everyRoleEmitsTheSameSandboxAsBefore: ${role} emits sandbox_mode="${EXPECTED_SANDBOX_BY_ROLE[role]}" byte-identically`, () => {
assert.ok(
Object.prototype.hasOwnProperty.call(EXPECTED_SANDBOX_BY_ROLE, role),
`${role} exists in agents/ but has no EXPECTED_SANDBOX_BY_ROLE entry — see the roster-parity test above`,
);
const content = fs.readFileSync(path.join(AGENTS_DIR, `${role}.md`), 'utf8');
const toml = generateCodexAgentToml(role, content);
const match = toml.match(/^sandbox_mode = "([^"]+)"$/m);
assert.ok(match, `${role}'s emitted TOML must contain a sandbox_mode line`);
assert.equal(
match[1],
EXPECTED_SANDBOX_BY_ROLE[role],
`${role} must emit the SAME sandbox_mode after the derivation refactor lands — a per-role regression, not an aggregate count`,
);
});
}
test('T30 holdListShrunkToZero: CODEX_SANDBOX_HOLDS is empty and every measured widening role derives workspace-write (#4770)', () => {
// #4770 lift: the hold's recorded reopen condition (official OpenAI docs
// establishing sandbox_mode as enforced) is satisfied, so the list shrank
// to zero per its own ADR-3473 §8.3 shrink-only invariant. The formerly
// held roles are exactly measuredWideningRoles — this test now guards the
// LIFT: the map stays empty (no re-hold without a new recorded decision)
// and every one of those roles derives workspace-write from its own
// tools: contract.
assert.equal(
typeof CODEX_SANDBOX_HOLDS,
'object',
'install.js must export CODEX_SANDBOX_HOLDS — the (now empty) hold list shape is kept for a future re-hold',
);
assert.deepEqual(
Object.keys(CODEX_SANDBOX_HOLDS).sort(),
[],
'CODEX_SANDBOX_HOLDS must be empty after the #4770 lift — a re-hold requires a new recorded decision',
);
assert.equal(measuredWideningRoles.length, 17, 'sanity: 17 widening roles against the current agents/ tree');
for (const role of measuredWideningRoles) {
assert.equal(
EXPECTED_SANDBOX_BY_ROLE[role],
'workspace-write',
'#4770: formerly-held role ' + role + ' must derive workspace-write from its own tool contract',
);
}
});
test('T21 mappedRolesDeriveToTheirFormerValue: every former CODEX_AGENT_SANDBOX entry (11) derives to the identical value from its real tool contract', () => {
for (const [role, formerValue] of Object.entries(PRE_3897_CODEX_AGENT_SANDBOX)) {
const derivesWorkspaceWrite = declaresWriteOrEdit(realAgentToolsRaw(role));
const derived = derivesWorkspaceWrite ? 'workspace-write' : 'read-only';
assert.equal(
derived,
formerValue,
`${role}: the tool-contract derivation must reproduce the former map value exactly (HALT.md: zero disagreements across all 11)`,
);
}
assert.equal(Object.keys(PRE_3897_CODEX_AGENT_SANDBOX).length, 11);
});
test('T22 nonWritingFallbackRoleDerivesReadOnly: a fallback role declaring neither Write nor Edit derives read-only (S2)', () => {
const role = 'msd-user-profiler'; // tools: Read (no Write/Edit), never in the map
assert.equal(Object.prototype.hasOwnProperty.call(PRE_3897_CODEX_AGENT_SANDBOX, role), false);
assert.equal(declaresWriteOrEdit(realAgentToolsRaw(role)), false);
const content = fs.readFileSync(path.join(AGENTS_DIR, `${role}.md`), 'utf8');
const toml = generateCodexAgentToml(role, content);
assert.ok(toml.includes('sandbox_mode = "read-only"'));
});
test('T23 formerlyHeldRoleNowDerivesWorkspaceWrite: the #4770 lift releases msd-doc-writer to its own tool contract', () => {
assert.equal(typeof CODEX_SANDBOX_HOLDS, 'object', 'CODEX_SANDBOX_HOLDS does not exist yet');
const role = 'msd-doc-writer'; // declares Write+Edit; one of HALT.md's 16
assert.ok(declaresWriteOrEdit(realAgentToolsRaw(role)), 'sanity: this role must actually declare a writing tool');
assert.ok(!Object.prototype.hasOwnProperty.call(CODEX_SANDBOX_HOLDS, role), `${role} must no longer be a hold entry after the #4770 lift`);
const content = fs.readFileSync(path.join(AGENTS_DIR, `${role}.md`), 'utf8');
const toml = generateCodexAgentToml(role, content);
assert.ok(toml.includes('sandbox_mode = "workspace-write"'), 'the role must now derive workspace-write from its own contract (#4770)');
});
test('T24 staleHoldFailsRatherThanBeingHonored: a hold whose role no longer derives broader must FAIL, naming the role (S4) — empty after #4770', () => {
assert.equal(
typeof CODEX_SANDBOX_HOLDS,
'object',
'CODEX_SANDBOX_HOLDS does not exist yet, so there is nothing to validate for staleness',
);
assert.equal(Object.keys(CODEX_SANDBOX_HOLDS).length, 0, '#4770 lifted every hold; the sweep below stays as the re-growth guard');
// Every REAL hold entry, right now, must still derive workspace-write from
// the tool contract. A hold for a role whose tools no longer declare
// Write/Edit is exactly the staleness this row exists to catch; without
// this assertion the hold list is honored unconditionally forever, which
// is the hand-maintained-subset-map defect this rung deletes, rebuilt one
// list later (the ledger claim HALT.md makes).
for (const role of Object.keys(CODEX_SANDBOX_HOLDS)) {
assert.ok(
declaresWriteOrEdit(realAgentToolsRaw(role)),
`stale hold: ${role} is pinned to read-only but its CURRENT tool contract no longer declares Write/Edit — this hold must fail validation, not be silently honored`,
);
}
});
test('T25 holdForUnknownRoleFails: a hold naming a role that no longer exists in agents/ must FAIL (S5) — empty after #4770', () => {
assert.equal(
typeof CODEX_SANDBOX_HOLDS,
'object',
'CODEX_SANDBOX_HOLDS does not exist yet, so there is nothing to validate for an unknown role',
);
assert.equal(Object.keys(CODEX_SANDBOX_HOLDS).length, 0, '#4770 lifted every hold; the sweep below stays as the re-growth guard');
for (const role of Object.keys(CODEX_SANDBOX_HOLDS)) {
assert.ok(
fs.existsSync(path.join(AGENTS_DIR, `${role}.md`)),
`stale hold: ${role} is pinned but no longer exists in agents/ — this hold must fail validation`,
);
}
});
test('T26 newWritingRoleGetsTheContractNotThePin: a brand-new agent declaring Write, with no hold, derives workspace-write (S6) — RED today, falls back to read-only (#2540)', () => {
const newAgentContent = `---
name: msd-totally-new-agent
description: A brand-new writing agent that has never been in the map or a hold
tools: Read, Write, Bash
---
You are a brand-new agent.`;
// Today: CODEX_AGENT_SANDBOX['msd-totally-new-agent'] is undefined, so the
// `|| 'read-only'` fallback silently under-grants it — the exact defect
// §8.3 exists to fix (24 of 35 roles fell through this fallback).
const toml = generateCodexAgentToml('msd-totally-new-agent', newAgentContent);
assert.ok(
toml.includes('sandbox_mode = "workspace-write"'),
'a new agent declaring Write, absent from both the map and any hold, must derive workspace-write from its own tool contract — not silently fall back to read-only',
);
});
test('T27 absentToolContractIsNotAGrant: an agent with no tools: frontmatter at all derives read-only (N8/S7)', () => {
const noToolsContent = `---
name: msd-no-contract-agent
description: Declares no tools frontmatter key at all
---
You have no declared tools.`;
const toml = generateCodexAgentToml('msd-no-contract-agent', noToolsContent);
assert.ok(
toml.includes('sandbox_mode = "read-only"'),
'absence of a tools: contract must never be read as a grant of workspace-write',
);
});
// #3897 security review follow-up (post-merge blocker): `bin/install.js`'s
// Codex install loop used to key the CODEX_SANDBOX_HOLDS lookup off the
// agent's OWN frontmatter `name:` field (`extractFrontmatterField(frontmatter,
// 'name') || file.replace('.md', '')`) rather than its filename — so editing,
// or merely recasing, a held role's `name:` field (same file, same tool
// contract) silently derived `workspace-write` instead of the pinned
// `read-only`. Unlike the deleted CODEX_AGENT_SANDBOX map (an ALLOWLIST whose
// unmatched-key fallback was `read-only`, i.e. safe), CODEX_SANDBOX_HOLDS is a
// SUBTRACTION from a derivation that defaults to `workspace-write`, so the
// identical lookup-key mismatch now fails OPEN — a severity flip. The fix
// keys the hold off the canonical source FILENAME stem (what
// `validateCodexSandboxHolds` already verifies exists), threaded through
// `installCodexConfig`'s per-file loop independently of the frontmatter
// `name:` used for the TOML body, with a case-insensitive lookup as a second
// line of defense.
test('heldRoleCannotEscapeItsHoldByRenamingFrontmatter_3897: name edits or recasing never change the content-derived sandbox (#4770: hold lifted, property kept)', () => {
const { installCodexConfig } = require('../bin/install.js');
const heldRole = 'msd-doc-writer'; // one of the 17 formerly-held roles
assert.ok(
!Object.prototype.hasOwnProperty.call(CODEX_SANDBOX_HOLDS, heldRole),
`sanity: ${heldRole} was lifted from CODEX_SANDBOX_HOLDS by #4770`,
);
const variants = [
{ label: 'frontmatter name: edited to a different value', newName: 'msd-doc-writer-x' },
{ label: 'frontmatter name: merely recased', newName: 'MSD-Doc-Writer' },
];
for (const { label, newName } of variants) {
const tmpAgentsSrc = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-codex-tamper-src-'));
const tmpDest = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-codex-tamper-dest-'));
try {
fs.cpSync(AGENTS_DIR, tmpAgentsSrc, { recursive: true });
const rolePath = path.join(tmpAgentsSrc, `${heldRole}.md`);
const original = fs.readFileSync(rolePath, 'utf8');
const tampered = original.replace(/^name:\s*.*$/m, `name: ${newName}`);
assert.notEqual(tampered, original, `sanity: the frontmatter name: line must actually change (${label})`);
fs.writeFileSync(rolePath, tampered);
// The FILE on disk is untouched (still `msd-doc-writer.md`) — only its
// frontmatter content changed. validateCodexSandboxHolds only checks
// the file exists, so it does not (and should not) catch this by itself.
installCodexConfig(tmpDest, tmpAgentsSrc);
const emittedTomlPath = path.join(tmpDest, 'agents', `${newName}.toml`);
assert.ok(
fs.existsSync(emittedTomlPath),
`${label}: expected an emitted .toml at ${emittedTomlPath} (named after the tampered frontmatter name, per existing TOML-naming behavior — unrelated to this fix)`,
);
const toml = fs.readFileSync(emittedTomlPath, 'utf8');
const sandboxLine = toml.match(/^sandbox_mode = "([^"]{0,50})"$/m);
assert.ok(sandboxLine, `${label}: emitted .toml must contain a sandbox_mode line`);
// #4770: with the hold lifted, the sandbox is derived from the
// content's own tools contract — msd-doc-writer declares Write/Edit,
// so every name variant emits workspace-write. The F1 property that
// survives is that the derivation follows the CONTENT, never the
// self-declared name.
assert.equal(
sandboxLine[1],
'workspace-write',
`${label}: the emitted .toml must carry the content-derived workspace-write — name edits/recasing change nothing post-#4770. Got: ${sandboxLine[1]}`,
);
} finally {
cleanup(tmpAgentsSrc);
cleanup(tmpDest);
}
}
});
test('N6 (post-fix regression check): every real agents/ role remains byte-identical in emitted sandbox_mode after the filename-identity fix', () => {
// #3897 rung 4 (isolated correctness review, MINOR finding 6): driven
// from the real roster (AGENT_ROSTER_ROLES), not the hardcoded
// EXPECTED_SANDBOX_BY_ROLE key set — the roster-parity test above already
// fails loudly if the two sets ever diverge, so `checked` here is a
// genuine roster count, not a fixture-pinned literal that would silently
// stop growing when a 36th agent lands.
let checked = 0;
for (const role of AGENT_ROSTER_ROLES) {
assert.ok(
Object.prototype.hasOwnProperty.call(EXPECTED_SANDBOX_BY_ROLE, role),
`${role} exists in agents/ but has no EXPECTED_SANDBOX_BY_ROLE entry — see the roster-parity test above`,
);
const content = fs.readFileSync(path.join(AGENTS_DIR, `${role}.md`), 'utf8');
const toml = generateCodexAgentToml(role, content);
const match = toml.match(/^sandbox_mode = "([^"]+)"$/m);
assert.ok(match, `${role} must emit a sandbox_mode line`);
assert.equal(
match[1],
EXPECTED_SANDBOX_BY_ROLE[role],
`${role}: sandbox_mode must not drift as a side effect of the #3897 filename-identity fix`,
);
checked++;
}
assert.equal(
checked,
AGENT_ROSTER_ROLES.length,
'N6: every real role in agents/ must be checked and byte-identical',
);
});
});
// ─── #3897 security review F1/F3/F4/F5 regressions ─────────────────────────
//
// Isolated security review of this rung found a fail-open: `bin/install.js`'s
// Codex emit loop DECIDED sandbox_mode for the source filename stem but
// APPLIED it to the emitted `.toml`'s path, which is keyed on the
// frontmatter `name:` value instead — so a renamed file, or a sibling file
// whose `name:` collides with a held role, could land a held role's own
// artifact at `workspace-write`. F1(a)/F1(b) below assert on the EMITTED
// ARTIFACT (the written `.toml`'s `sandbox_mode`), never on
// `deriveCodexSandboxMode`'s return value directly — the whole defect is
// that the derivation and the emitted artifact could disagree.
describe('#3897 security review: F1 filename/name identity confusion, F3 confusables, F4/F5 totality', () => {
const AGENTS_DIR = path.join(__dirname, '..', 'agents');
const {
installCodexConfig,
deriveCodexSandboxMode: deriveCodexSandboxModeLocal,
} = require('../bin/install.js');
const {
normalizeSandboxIdentity,
isSandboxHeld,
extractToolsValue: extractToolsValueLocal,
} = require('../msd-core/bin/lib/codex-agent-toml.cjs');
function sandboxModeOfToml(toml) {
const match = toml.match(/^sandbox_mode = "([^"]+)"$/m);
return match ? match[1] : null;
}
test('F1(a) rename case: a source file whose FILENAME differs from a held role, but whose frontmatter name: IS the held role, emits read-only', () => {
const src = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-f1a-src-'));
const dest = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-f1a-dest-'));
try {
const original = fs.readFileSync(path.join(AGENTS_DIR, 'msd-doc-writer.md'), 'utf8');
assert.ok(/^name:\s*msd-doc-writer\s*$/m.test(original), 'sanity: fixture source declares name: msd-doc-writer');
// Renamed FILE, unchanged frontmatter name: — the fileStem no longer
// matches the held role, but the emitted TOML is still named after
// `msd-doc-writer` (the frontmatter name:).
fs.writeFileSync(path.join(src, 'msd-doc-writer-v2.md'), original);
installCodexConfig(dest, src);
const emittedPath = path.join(dest, 'agents', 'msd-doc-writer.toml');
assert.ok(fs.existsSync(emittedPath), `expected an emitted .toml at ${emittedPath}`);
const toml = fs.readFileSync(emittedPath, 'utf8');
assert.equal(
sandboxModeOfToml(toml),
'workspace-write',
'F1(a) post-#4770: the sandbox derives from the content\'s own tools contract, so a renamed source file changes nothing — msd-doc-writer declares Write/Edit and emits workspace-write',
);
} finally {
cleanup(src);
cleanup(dest);
}
});
test('F1(b) sibling-clobber case: a second, unheld source file whose frontmatter name: IS a held role must not widen the held role\'s emitted .toml', () => {
const src = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-f1b-src-'));
const dest = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-f1b-dest-'));
try {
const heldOriginal = fs.readFileSync(path.join(AGENTS_DIR, 'msd-doc-writer.md'), 'utf8');
fs.writeFileSync(path.join(src, 'msd-doc-writer.md'), heldOriginal);
// A sibling file — different filename stem, no hold entry for IT — but
// its frontmatter `name:` collides with the held role. Named to sort
// AFTER `msd-doc-writer.md` (readdirSync/emit-loop order is
// alphabetical) so this sibling's write is the LAST one to the shared
// `msd-doc-writer.toml` output path — the exact ordering the pre-fix
// defect needed to actually clobber the held role's own artifact
// (a sibling sorting BEFORE it gets silently overwritten again by the
// legitimate file's own correct read-only write, masking the bug).
const sibling = [
'---',
'name: msd-doc-writer',
'description: sibling file colliding on frontmatter name',
'tools: Read, Write, Edit',
'---',
'',
'sibling',
].join('\n');
fs.writeFileSync(path.join(src, 'msd-zzz-attacker-clone.md'), sibling);
installCodexConfig(dest, src);
const emittedPath = path.join(dest, 'agents', 'msd-doc-writer.toml');
assert.ok(fs.existsSync(emittedPath), `expected an emitted .toml at ${emittedPath}`);
const toml = fs.readFileSync(emittedPath, 'utf8');
assert.equal(
sandboxModeOfToml(toml),
'workspace-write',
'F1(b) post-#4770: the last-writer artifact carries ITS OWN content-derived sandbox (the sibling declared Write/Edit) — with no holds left, a name collision cannot escalate any role beyond what its own content derives',
);
} finally {
cleanup(src);
cleanup(dest);
}
});
// Vectors that NFKC-fold to pure ASCII (fullwidth g, whitespace, dots,
// path prefixes): post-#4770 they normalize onto a real roster role whose
// content declares Write/Edit, and the content-derived answer is
// workspace-write — the identity no longer gates.
const F3_CONTENT_DERIVED_VECTORS = [
['fullwidth leading g (g)', 'gsd-doc-writer'],
['trailing ASCII space', 'msd-doc-writer '],
['trailing NBSP', 'msd-doc-writer '],
['trailing dot', 'msd-doc-writer.'],
['trailing newline', 'msd-doc-writer\n'],
['trailing carriage return', 'msd-doc-writer\r'],
['relative-path prefix ./', './msd-doc-writer'],
['path traversal ../agents/', '../agents/msd-doc-writer'],
];
for (const [label, vector] of F3_CONTENT_DERIVED_VECTORS) {
test(`F3 ascii-folding vector — ${label} — derives from content (#4770)`, () => {
const mode = deriveCodexSandboxModeLocal(vector, 'Read, Write, Edit');
assert.equal(
mode,
'workspace-write',
`F3 post-#4770: identity ${JSON.stringify(vector)} (${label}) folds to ASCII and normalizes onto a real roster role whose content declares Write/Edit — content-derived workspace-write`,
);
});
}
// Vectors that stay non-ASCII after NFKC (Turkish İ/ı, combining acute):
// the F3 fail-closed pin is map-independent and still applies — a
// non-ASCII-after-normalization identity is never a legitimate shipped
// role and is pinned read-only regardless of its content's tools.
const F3_STILL_SUSPICIOUS_VECTORS = [
['Turkish dotted I (İ)', 'msd-doc-wrİter'],
['Turkish dotless i (ı)', 'msd-doc-wrıter'],
['NFD combining acute on r (writeŕ)', 'msd-doc-writeŕ'],
];
for (const [label, vector] of F3_STILL_SUSPICIOUS_VECTORS) {
test(`F3 non-folding vector — ${label} — stays fail-closed read-only (#4770)`, () => {
const mode = deriveCodexSandboxModeLocal(vector, 'Read, Write, Edit');
assert.equal(
mode,
'read-only',
`F3: identity ${JSON.stringify(vector)} (${label}) is still non-ASCII after NFKC — suspicious and fail-closed read-only regardless of content`,
);
});
}
// The F3 core that survives the #4770 lift: an identity still non-ASCII
// after NFKC normalization is not a legitimate shipped role and is pinned
// fail-closed read-only regardless of its content's tool contract.
test('F3 core: an identity still non-ASCII after normalization derives read-only regardless of content (#4770)', () => {
const mode = deriveCodexSandboxModeLocal('msd-dос-writer', 'Read, Write, Edit');
assert.equal(mode, 'read-only', 'a suspicious (non-ASCII after normalization) identity must stay fail-closed read-only');
});
test('F3: isSandboxHeld flags a non-ASCII-after-normalization identity as suspicious (#4770: held is vacuously false over the empty map)', () => {
const { held, suspicious } = isSandboxHeld('msd-dос-writer');
assert.equal(held, false, 'the hold map is empty post-#4770 — nothing is held');
assert.equal(suspicious, true, 'a Cyrillic-lookalike identity must still be flagged suspicious (fail-closed F3 core)');
});
test('F5: "All tools except Write, Edit" derives read-only (negation excludes Write/Edit)', () => {
const mode = deriveCodexSandboxModeLocal('msd-negation-agent', 'All tools except Write, Edit');
assert.equal(mode, 'read-only', 'excluding Write and Edit after "except" must derive read-only');
});
test('F5: "All tools except Agent" derives workspace-write (Write/Edit are not excluded)', () => {
const mode = deriveCodexSandboxModeLocal('msd-negation-agent', 'All tools except Agent');
assert.equal(mode, 'workspace-write', 'excluding only Agent leaves Write/Edit granted, so this must still derive workspace-write');
});
test('totality: deriveCodexSandboxMode never throws for undefined/null/[]/[undefined] identities', () => {
for (const identity of [undefined, null, [], [undefined]]) {
assert.doesNotThrow(
() => deriveCodexSandboxModeLocal(identity, 'Read, Write, Edit'),
`deriveCodexSandboxMode must not throw for identity ${JSON.stringify(identity)}`,
);
const result = deriveCodexSandboxModeLocal(identity, 'Read, Write, Edit');
assert.equal(typeof result, 'string', `deriveCodexSandboxMode must return a string for identity ${JSON.stringify(identity)}`);
}
});
// #3897 rung 4 (isolated correctness review, MINOR finding 4): two exotic
// identity shapes more adversarial than the undefined/null/[]/[undefined]
// set above — an object whose OWN toString throws, and a null-prototype
// object (no inherited Object.prototype methods at all, so even a
// defensive `.toString`/`.hasOwnProperty` call site would blow up). Both
// must derive a string, never throw — "TOTAL for any input" per this
// function's own docstring is not just for well-behaved falsy/array shapes.
test('totality: deriveCodexSandboxMode never throws for an identity with a throwing toString', () => {
const hostile = { toString() { throw new Error('id-boom'); } };
assert.doesNotThrow(
() => deriveCodexSandboxModeLocal(hostile, 'Read'),
'deriveCodexSandboxMode must not throw for an identity whose toString throws',
);
assert.equal(typeof deriveCodexSandboxModeLocal(hostile, 'Read'), 'string');
});
test('totality: deriveCodexSandboxMode never throws for a null-prototype identity object', () => {
const nullProto = Object.create(null);
assert.doesNotThrow(
() => deriveCodexSandboxModeLocal(nullProto, 'Read'),
'deriveCodexSandboxMode must not throw for a null-prototype identity object',
);
assert.equal(typeof deriveCodexSandboxModeLocal(nullProto, 'Read'), 'string');
});
test('totality: normalizeSandboxIdentity never throws for undefined/null/non-string input', () => {
for (const raw of [undefined, null, 42, {}, []]) {
assert.doesNotThrow(() => normalizeSandboxIdentity(raw), `must not throw for ${JSON.stringify(raw)}`);
assert.equal(normalizeSandboxIdentity(raw), null, `non-string input ${JSON.stringify(raw)} must normalize to null`);
}
});
test('F4: extractToolsValue returns undefined (never throws) for undefined/null/Buffer input', () => {
for (const value of [undefined, null, Buffer.from('tools: Write')]) {
assert.doesNotThrow(() => extractToolsValueLocal(value), `extractToolsValue must not throw for ${String(value)}`);
assert.equal(extractToolsValueLocal(value), undefined, `extractToolsValue must return undefined for non-string input ${String(value)}`);
}
});
test('F4: extractToolsValue still returns the parsed value for real string content — inline form (no regression)', () => {
const content = ['---', 'tools: Read, Write', '---', ''].join('\n');
assert.equal(extractToolsValueLocal(content), 'Read, Write');
});
// ─── #3897 list-form parse fix: YAML block-list `tools:` ─────────────────
//
// `agents/msd-nyquist-auditor.md` and `agents/msd-security-auditor.md` are
// the only two roster files using this shape. The pre-fix single-line
// regex `/^tools:\s*(.+)$/m` let `\s*` swallow the newline after a bare
// `tools:` key and matched into the FIRST list item's own line, returning
// just `"- Read"` — a real Write/Edit DECLARATION read as an absence.
test('list-form: a tools: block list declaring Write derives workspace-write (FAILS before the parse fix — pre-fix reader returned "- Read")', () => {
const content = [
'---',
'name: msd-list-form-writer',
'tools:',
' - Read',
' - Write',
' - Edit',
' - Bash',
'---',
'',
'list-form writer',
].join('\n');
const toolsRaw = extractToolsValueLocal(content);
assert.equal(toolsRaw, 'Read, Write, Edit, Bash', 'list items must be joined the same way the comma-tokenizer downstream expects');
assert.equal(
deriveCodexSandboxModeLocal('zzz-list-form-probe-never-held', toolsRaw),
'workspace-write',
'a tools: block list declaring Write must derive workspace-write, not read-only from a truncated first-item read',
);
});
test('list-form: a tools: block list with NO write tool derives read-only (no over-correction)', () => {
const content = [
'---',
'name: msd-list-form-reader',
'tools:',
' - Read',
' - Bash',
' - Glob',
'---',
'',
'list-form reader',
].join('\n');
const toolsRaw = extractToolsValueLocal(content);
assert.equal(toolsRaw, 'Read, Bash, Glob');
assert.equal(
deriveCodexSandboxModeLocal('zzz-list-form-probe-never-held', toolsRaw),
'read-only',
'a list-form tools: with no Write/Edit item must still derive read-only',
);
});
test('list-form: the list terminates at the next frontmatter key and does not swallow its value', () => {
const content = [
'---',
'name: msd-list-form-terminates',
'tools:',
' - Read',
' - Write',
'color: blue',
'---',
'',
'list-form terminates before a sibling key',
].join('\n');
assert.equal(extractToolsValueLocal(content), 'Read, Write');
// The sibling key's own value must remain independently readable — it
// must never have been consumed as a phantom third list item.
const { frontmatter } = extractFrontmatterAndBody(content);
assert.equal(extractFrontmatterField(frontmatter, 'color'), 'blue');
});
test('list-form: the list terminates at the closing --- and does not run past the frontmatter', () => {
const content = [
'---',
'name: msd-list-form-eof',
'tools:',
' - Read',
' - Write',
'---',
'- this looks like a list item but is BODY text, not frontmatter',
].join('\n');
assert.equal(extractToolsValueLocal(content), 'Read, Write');
});
test('roster truth: msd-nyquist-auditor derives workspace-write from its real tool contract, and the #4770 lift released it to that derivation', () => {
const content = fs.readFileSync(path.join(AGENTS_DIR, 'msd-nyquist-auditor.md'), 'utf8');
const toolsRaw = extractToolsValueLocal(content);
assert.ok(
/\bWrite\b/.test(toolsRaw) && /\bEdit\b/.test(toolsRaw),
`sanity: msd-nyquist-auditor's real tools: must declare both Write and Edit, got ${JSON.stringify(toolsRaw)}`,
);
// Derivation WITHOUT the hold (an identity guaranteed never held/suspicious,
// same probe idiom as the rung-3 describe block's PARITY_PROBE_IDENTITY)
// must show the role genuinely derives workspace-write from its contract.
assert.equal(
deriveCodexSandboxModeLocal('zzz-nyquist-unheld-probe-never-a-real-role', toolsRaw),
'workspace-write',
'msd-nyquist-auditor must genuinely derive workspace-write from its tool contract once list-form tools: parses correctly',
);
// #4770 lifted the hold, so the REAL identity now derives workspace-write
// too — its emitted .toml matches its own content's tool contract.
assert.ok(
!Object.prototype.hasOwnProperty.call(CODEX_SANDBOX_HOLDS, 'msd-nyquist-auditor'),
'msd-nyquist-auditor must no longer be a CODEX_SANDBOX_HOLDS entry after the #4770 lift',
);
const { generateCodexAgentToml: generateCodexAgentTomlLocal } = require('../bin/install.js');
const toml = generateCodexAgentTomlLocal('msd-nyquist-auditor', content);
assert.ok(
toml.includes('sandbox_mode = "workspace-write"'),
'msd-nyquist-auditor\'s emitted .toml now carries the content-derived workspace-write (#4770)',
);
});
});
// ─── #3241: shared isAnthropicFlavoredModel / CLAUDE_AGENT_ALIASES surface ─────
// Phase 2/3 need one predicate. 40-design.md's "seam decision" moves
// CLAUDE_AGENT_ALIASES into model-catalog.cjs and defines isAnthropicFlavoredModel
// beside it, re-exporting from model-resolver.cjs for back-compat. Neither exists
// on model-catalog.cjs yet (verified: modelCatalog.isAnthropicFlavoredModel is
// `undefined` today), so every test below is RED against the current tree.
describe('#3241 isAnthropicFlavoredModel + CLAUDE_AGENT_ALIASES (model-catalog owns it)', () => {
const parityAgent = `---
name: msd-executor
description: Executes plans
tools: Read, Write
---
You are an executor.`;
test('predicate flags every Claude tier alias and case/namespace variant (#3241)', () => {
// RED (pre-fix): modelCatalog.isAnthropicFlavoredModel is undefined today,
// so `modelCatalog.isAnthropicFlavoredModel('opus')` throws
// "isAnthropicFlavoredModel is not a function" — this test fails on the
// very first call, before any assertion runs.
for (const alias of ['opus', 'sonnet', 'haiku', 'fable']) {
assert.strictEqual(modelCatalog.isAnthropicFlavoredModel(alias), true, `alias "${alias}" must be flagged`);
}
for (const id of ['claude-opus-4-5', 'anthropic/claude-x', 'us.anthropic.claude-x', 'CLAUDE-X']) {
assert.strictEqual(modelCatalog.isAnthropicFlavoredModel(id), true, `id "${id}" must be flagged (case/namespace variant)`);
}
});
test('predicate is false for real Codex ids and non-strings, without throwing (#3241)', () => {
// RED (pre-fix): same "not a function" throw as above — fails before any
// assertion is reached.
for (const value of ['gpt-5.6-sol', 'gpt-4', '', null, undefined, {}, 0]) {
assert.doesNotThrow(() => modelCatalog.isAnthropicFlavoredModel(value), `must not throw for ${JSON.stringify(value)}`);
assert.strictEqual(modelCatalog.isAnthropicFlavoredModel(value), false, `must be false for ${JSON.stringify(value)}`);
}
});
test('the alias set has exactly one owner across both modules (#3241)', () => {
// RED (pre-fix): modelCatalog.CLAUDE_AGENT_ALIASES is undefined today
// (model-catalog.cjs exports no such key), so deepStrictEqual against
// modelResolver's real Set fails. Divergence guard per
// 50-test-matrix.md rows 22-23: without this, Phase 2/3 can silently
// fork the rule.
assert.deepStrictEqual(
modelCatalog.CLAUDE_AGENT_ALIASES,
modelResolver.CLAUDE_AGENT_ALIASES,
'model-catalog and model-resolver must share the exact same CLAUDE_AGENT_ALIASES contents'
);
});
test('installer Codex .toml emission agrees with the shared predicate (#3241)', () => {
// RED (pre-fix): modelCatalog.isAnthropicFlavoredModel is undefined, so
// the first loop iteration throws "not a function" before any
// generateCodexAgentToml call happens.
const probeValues = [...CLAUDE_AGENT_ALIASES, 'claude-sonnet-5', 'gpt-5.6-sol'];
for (const value of probeValues) {
const expectedFlavored = modelCatalog.isAnthropicFlavoredModel(value);
const result = generateCodexAgentToml('msd-executor', parityAgent, { 'msd-executor': value });
const modelLine = result.split(/\r?\n/).find((line) => /^model = /.test(line));
if (expectedFlavored) {
assert.strictEqual(modelLine, undefined, `"${value}" is Anthropic-flavored per the shared predicate — installer must omit it`);
} else {
assert.strictEqual(modelLine, `model = ${JSON.stringify(value)}`, `"${value}" is NOT Anthropic-flavored per the shared predicate — installer must emit it verbatim`);
}
}
});
});
// ─── sandboxTier gate on generateCodexAgentToml ────────────────────────────────
describe('generateCodexAgentToml sandboxTier gate', () => {
const sampleAgent = `---
name: msd-executor
description: Executes plans
tools: Read, Write, Edit
color: yellow
---
You are an executor.`;
test('sandboxTier=none: does NOT emit sandbox_mode', () => {
const result = generateCodexAgentToml('msd-executor', sampleAgent, null, null, null, 'none');
assert.ok(!result.includes('sandbox_mode'), 'sandbox_mode must be absent when sandboxTier is none');
});
test('sandboxTier=codex-agent-sandbox: emits sandbox_mode = "workspace-write"', () => {
const result = generateCodexAgentToml('msd-executor', sampleAgent, null, null, null, 'codex-agent-sandbox');
assert.ok(result.includes('sandbox_mode = "workspace-write"'), 'must emit workspace-write for codex-agent-sandbox tier');
});
test('default (no sandboxTier arg): still emits sandbox_mode = "workspace-write" (no-op for codex)', () => {
const result = generateCodexAgentToml('msd-executor', sampleAgent);
assert.ok(result.includes('sandbox_mode = "workspace-write"'), 'default preserves codex behavior');
});
test('resolveInstallPlan projection: codex.sandboxTier === "codex-agent-sandbox"', () => {
const plan = resolveInstallPlan('codex');
assert.strictEqual(plan.sandboxTier, 'codex-agent-sandbox', 'codex must project sandboxTier=codex-agent-sandbox');
});
test('resolveInstallPlan projection: claude.sandboxTier === "none"', () => {
const plan = resolveInstallPlan('claude');
assert.strictEqual(plan.sandboxTier, 'none', 'claude must project sandboxTier=none');
});
});
// ─── installCodexConfig threading-seam: sandboxTier → per-agent TOML ─────────
describe('installCodexConfig sandboxTier threading seam', () => {
const { installCodexConfig } = require('../bin/install.js');
let tmpDir;
let agentsSrc;
let targetDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-sandboxtier-seam-'));
agentsSrc = path.join(tmpDir, 'agents');
targetDir = path.join(tmpDir, 'codex');
fs.mkdirSync(agentsSrc, { recursive: true });
fs.mkdirSync(targetDir, { recursive: true });
// Write a minimal msd-executor agent fixture
fs.writeFileSync(path.join(agentsSrc, 'msd-executor.md'), [
'---',
'name: msd-executor',
'description: Executes plans',
'tools: Read, Write, Edit',
'---',
'',
'You are an executor.',
].join('\n'));
});
afterEach(() => {
cleanup(tmpDir);
});
test('sandboxTier=none: written per-agent .toml does NOT contain sandbox_mode', () => {
installCodexConfig(targetDir, agentsSrc, 'none');
const tomlPath = path.join(targetDir, 'agents', 'msd-executor.toml');
assert.ok(fs.existsSync(tomlPath), 'per-agent TOML must be written');
const toml = fs.readFileSync(tomlPath, 'utf8');
assert.ok(!toml.includes('sandbox_mode'), 'sandbox_mode must be absent when sandboxTier=none');
});
test('sandboxTier=codex-agent-sandbox: written per-agent .toml contains sandbox_mode', () => {
installCodexConfig(targetDir, agentsSrc, 'codex-agent-sandbox');
const tomlPath = path.join(targetDir, 'agents', 'msd-executor.toml');
assert.ok(fs.existsSync(tomlPath), 'per-agent TOML must be written');
const toml = fs.readFileSync(tomlPath, 'utf8');
assert.ok(toml.includes('sandbox_mode'), 'sandbox_mode must be present when sandboxTier=codex-agent-sandbox');
});
test('default 2-arg form (no sandboxTier): written per-agent .toml contains sandbox_mode (codex default)', () => {
installCodexConfig(targetDir, agentsSrc);
const tomlPath = path.join(targetDir, 'agents', 'msd-executor.toml');
assert.ok(fs.existsSync(tomlPath), 'per-agent TOML must be written');
const toml = fs.readFileSync(tomlPath, 'utf8');
assert.ok(toml.includes('sandbox_mode'), 'sandbox_mode must be present in default 2-arg form (codex-agent-sandbox default)');
});
});
// NOTE: A test for the new fail-loud throw on missing/invalid sandboxTier in
// resolveInstallPlan is omitted here. Constructing a descriptor without the
// field would require mocking the capability-registry module which is a
// singleton require(); patching it invasively would corrupt other tests in the
// same process. The throw path is verified at the type level (tsc) and by the
// build passing, and the happy-path coverage (claude.sandboxTier === 'none' and
// codex.sandboxTier === 'codex-agent-sandbox') confirms the real registry has
// valid values for all 15 runtimes.
// ─── CODEX_AGENT_SANDBOX (deleted map): derivation regression baseline ──────────
describe('CODEX_AGENT_SANDBOX (deleted map, derivation regression baseline)', () => {
// bin/install.js's CODEX_AGENT_SANDBOX map is gone (ADR-3473 §8.3, #3897
// rung 3) — deriveCodexSandboxMode is the sole owner of sandbox_mode now.
// These tests assert the SAME underlying property the deleted map's own
// suite used to assert (which 11 roles get which sandbox_mode), but against
// the real derivation and the real agents/*.md content, sourced from the
// PRE_3897_CODEX_AGENT_SANDBOX baseline literal above rather than the map.
const AGENTS_DIR = path.join(__dirname, '..', 'agents');
function realDerivedSandboxMode(role) {
const content = fs.readFileSync(path.join(AGENTS_DIR, `${role}.md`), 'utf8');
const { frontmatter } = extractFrontmatterAndBody(content);
const toolsRaw = extractFrontmatterField(frontmatter || '', 'tools') || '';
return deriveCodexSandboxMode(role, toolsRaw);
}
test('has all 11 baseline agents', () => {
const agentNames = Object.keys(PRE_3897_CODEX_AGENT_SANDBOX);
assert.strictEqual(agentNames.length, 11, 'has 11 agents');
});
test('workspace-write agents still derive workspace-write', () => {
const writeAgents = [
'msd-executor', 'msd-planner', 'msd-phase-researcher',
'msd-project-researcher', 'msd-research-synthesizer', 'msd-verifier',
'msd-codebase-mapper', 'msd-roadmapper', 'msd-debugger',
];
for (const name of writeAgents) {
assert.strictEqual(PRE_3897_CODEX_AGENT_SANDBOX[name], 'workspace-write', `${name} baseline is workspace-write`);
assert.strictEqual(realDerivedSandboxMode(name), 'workspace-write', `${name} still derives workspace-write`);
}
});
test('read-only agents still derive read-only', () => {
const readOnlyAgents = ['msd-plan-checker', 'msd-integration-checker'];
for (const name of readOnlyAgents) {
assert.strictEqual(PRE_3897_CODEX_AGENT_SANDBOX[name], 'read-only', `${name} baseline is read-only`);
assert.strictEqual(realDerivedSandboxMode(name), 'read-only', `${name} still derives read-only`);
}
});
});
// ─── generateCodexConfigBlock ───────────────────────────────────────────────────
describe('generateCodexConfigBlock', () => {
const agents = [
{ name: 'msd-executor', description: 'Executes plans' },
{ name: 'msd-planner', description: 'Creates plans' },
];
test('starts with MSD marker', () => {
const result = generateCodexConfigBlock(agents);
assert.ok(result.startsWith(MSD_CODEX_MARKER), 'starts with marker');
});
test('emits the [agents] max_depth tuning block but no feature flags (#2088)', () => {
const result = generateCodexConfigBlock(agents);
assert.ok(!result.includes('[features]'), 'no features table');
assert.ok(!result.includes('multi_agent'), 'no multi_agent');
assert.ok(!result.includes('default_mode_request_user_input'), 'no request_user_input');
// #2088: the managed block pins dispatch depth via a bare [agents]
// AgentsToml scalar table. #2406: this is now the ONLY [agents]-namespaced
// content the block emits — no [agents.] role structs.
assert.match(result, /^\[agents\]$/m, 'emits the [agents] tuning table');
assert.match(result, /^max_depth = 1$/m, 'pins max_depth = 1');
// Should not emit [[agents]] sequence format (rejected by Codex 0.124.0).
assert.ok(!result.includes('[[agents]]'), 'no [[agents]] sequence format');
// Only max_depth is managed — max_threads is intentionally left to the user.
assert.ok(!result.includes('max_threads'), 'no max_threads (only max_depth is MSD-managed)');
});
test('#2406: does not emit [agents.] role tables — the standalone agents/.toml is the sole canonical source', () => {
const result = generateCodexConfigBlock(agents);
// Codex auto-discovers standalone TOMLs under $CODEX_HOME/agents/. A
// config.toml [agents.] table pointing config_file back at that
// same file is a SECOND registration of the same role and made Codex log
// "Ignoring malformed agent role definition: duplicate agent role name"
// once per agent. Zero role headers and zero config_file lines proves
// the duplication is gone.
assert.ok(!result.includes('[agents.msd-executor]'), 'no executor role header');
assert.ok(!result.includes('[agents.msd-planner]'), 'no planner role header');
assert.ok(!result.includes('config_file'), 'no config_file line at all');
assert.ok(!result.includes('description = "Executes plans"'), 'no per-agent description leaks into config.toml');
assert.ok(!result.includes('[[agents]]'), 'no [[agents]] sequence format either');
});
test('#2406: block is a valid TOML shape with exactly one [agents] table and zero [agents.*] sub-tables', () => {
const result = generateCodexConfigBlock(agents);
assert.ok(!result.includes('[[agents]]'), 'no [[agents]] sequence format present');
const bareAgentsHeaders = (result.match(/^\[agents\]\s*$/gm) || []).length;
assert.strictEqual(bareAgentsHeaders, 1, 'exactly one bare [agents] dispatch-tuning table');
const structHeaders = (result.match(/^\[agents\.[^\]]+\]\s*$/gm) || []).length;
assert.strictEqual(structHeaders, 0, 'zero [agents.] struct headers — role tables removed (#2406)');
});
test('#2406: output is unaffected by agents/targetDir — no per-agent content is derived from either', () => {
const withAgents = generateCodexConfigBlock(agents);
const withoutAgents = generateCodexConfigBlock([]);
assert.strictEqual(withAgents, withoutAgents, 'agents list no longer influences the emitted block');
const withTargetDir = generateCodexConfigBlock(agents, '/home/user/.codex');
assert.strictEqual(withTargetDir, withAgents, 'targetDir no longer influences the emitted block');
assert.ok(!withTargetDir.includes('config_file'), 'no config_file even when targetDir is provided');
assert.ok(!withTargetDir.includes('/home/user/.codex'), 'targetDir path does not leak into the block');
});
});
// ─── stripMsdFromCodexConfig ────────────────────────────────────────────────────
describe('stripMsdFromCodexConfig', () => {
test('returns null for MSD-only config', () => {
const content = `${MSD_CODEX_MARKER}\n[features]\nmulti_agent = true\n`;
const result = stripMsdFromCodexConfig(content);
assert.strictEqual(result, null, 'returns null when MSD-only');
});
test('preserves user content before marker', () => {
const content = `[model]\nname = "o3"\n\n${MSD_CODEX_MARKER}\n[features]\nmulti_agent = true\n`;
const result = stripMsdFromCodexConfig(content);
assert.ok(result.includes('[model]'), 'preserves user section');
assert.ok(result.includes('name = "o3"'), 'preserves user values');
assert.ok(!result.includes('multi_agent'), 'removes MSD content');
assert.ok(!result.includes(MSD_CODEX_MARKER), 'removes marker');
});
test('strips injected feature keys without marker', () => {
const content = `[features]\nmulti_agent = true\ndefault_mode_request_user_input = true\nother_feature = false\n`;
const result = stripMsdFromCodexConfig(content);
assert.ok(!result.includes('multi_agent'), 'removes multi_agent');
assert.ok(!result.includes('default_mode_request_user_input'), 'removes request_user_input');
assert.ok(result.includes('other_feature = false'), 'preserves user features');
});
test('removes empty [features] section', () => {
const content = `[features]\nmulti_agent = true\n[model]\nname = "o3"\n`;
const result = stripMsdFromCodexConfig(content);
assert.ok(!result.includes('[features]'), 'removes empty features section');
assert.ok(result.includes('[model]'), 'preserves other sections');
});
test('strips injected keys above marker on uninstall', () => {
// Case 3 install injects keys into [features] AND appends marker block
const content = `[model]\nname = "o3"\n\n[features]\nmulti_agent = true\ndefault_mode_request_user_input = true\nsome_custom_flag = true\n\n${MSD_CODEX_MARKER}\n[agents]\nmax_threads = 4\n`;
const result = stripMsdFromCodexConfig(content);
assert.ok(result.includes('[model]'), 'preserves user model section');
assert.ok(result.includes('some_custom_flag = true'), 'preserves user feature');
assert.ok(!result.includes('multi_agent'), 'strips injected multi_agent');
assert.ok(!result.includes('default_mode_request_user_input'), 'strips injected request_user_input');
assert.ok(!result.includes(MSD_CODEX_MARKER), 'strips marker');
});
test('removes legacy [agents.msd-*] map sections (self-heal pre-#2645 configs)', () => {
const content = `[agents.msd-executor]\ndescription = "test"\nconfig_file = "agents/msd-executor.toml"\n\n[agents.custom-agent]\ndescription = "user agent"\n`;
const result = stripMsdFromCodexConfig(content);
assert.ok(!result.includes('[agents.msd-executor]'), 'removes legacy MSD agent map section');
assert.ok(result.includes('[agents.custom-agent]'), 'preserves user agent section');
});
test('#2645: removes [[agents]] array-of-tables entries whose name is msd-*', () => {
const content = `[[agents]]\nname = "msd-executor"\ndescription = "test"\nconfig_file = "agents/msd-executor.toml"\n\n[[agents]]\nname = "custom-agent"\ndescription = "user agent"\n`;
const result = stripMsdFromCodexConfig(content);
assert.ok(!/name = "msd-executor"/.test(result), 'removes managed MSD [[agents]] entry');
assert.ok(result.includes('name = "custom-agent"'), 'preserves user [[agents]] entry');
});
test('#2645: handles mixed legacy + new shapes and multiple user/msd entries in one file', () => {
// Multiple MSD entries (both legacy map and new array-of-tables) interleaved
// with multiple user-authored agents in both shapes — none of the user
// entries may be removed and all MSD entries must be stripped.
const content = [
'[agents.msd-executor]',
'description = "legacy msd"',
'config_file = "agents/msd-executor.toml"',
'',
'[agents.custom-legacy]',
'description = "user legacy"',
'',
'[[agents]]',
'name = "msd-planner"',
'description = "new msd"',
'',
'[[agents]]',
'name = "my-helper"',
'description = "user new"',
'',
'[[agents]]',
"name = 'msd-debugger'",
'description = "single-quoted msd"',
'',
'[[agents]]',
'name = "another-user"',
'description = "second user agent"',
'',
].join('\n');
const result = stripMsdFromCodexConfig(content);
// All MSD entries removed.
assert.ok(!result.includes('msd-executor'), 'removes legacy msd-executor');
assert.ok(!/name\s*=\s*"msd-planner"/.test(result), 'removes new msd-planner');
assert.ok(!/name\s*=\s*'msd-debugger'/.test(result), 'removes single-quoted msd-debugger');
// All user-authored entries preserved.
assert.ok(result.includes('[agents.custom-legacy]'), 'preserves user legacy [agents.custom-legacy]');
assert.ok(result.includes('user legacy'), 'preserves user legacy body');
assert.ok(result.includes('name = "my-helper"'), 'preserves user new [[agents]]');
assert.ok(result.includes('name = "another-user"'), 'preserves second user [[agents]]');
assert.ok(result.includes('second user agent'), 'preserves second user body');
});
});
// ─── migrateCodexHooksMapFormat ─────────────────────────────────────────────────
describe('migrateCodexHooksMapFormat', () => {
test('migrates flat [[hooks]] with event key to namespaced [[hooks.]] form', () => {
// Flat [[hooks]] + event = "..." is TOML-incompatible with [[hooks.SessionStart]],
// so migrateCodexHooksMapFormat now converts it to the nested namespaced form.
const content = [
'[features]',
'codex_hooks = true',
'',
'[[hooks]]',
'event = "SessionStart"',
'command = "node /home/.codex/hooks/msd-check-update.js"',
'',
].join('\n');
const result = migrateCodexHooksMapFormat(content);
const parsed = parseTomlToObject(result);
assert.ok(parsed.hooks && Array.isArray(parsed.hooks.SessionStart),
'flat [[hooks]] event=SessionStart must be promoted to [[hooks.SessionStart]] AoT');
assert.strictEqual(parsed.hooks.SessionStart.length, 1);
assert.ok(Array.isArray(parsed.hooks.SessionStart[0].hooks),
'must emit [[hooks.SessionStart.hooks]] sub-table');
assert.strictEqual(parsed.hooks.SessionStart[0].hooks[0].command,
'node /home/.codex/hooks/msd-check-update.js');
assert.strictEqual(parsed.hooks.SessionStart[0].hooks[0].type, 'command',
'migrated handler must carry type = "command" per Codex 0.124.0+ schema');
assert.equal(parsed.hooks.SessionStart[0].event, undefined,
'event key consumed as namespace — must not appear in emitted block');
assert.ok(!Array.isArray(parsed.hooks), 'hooks must be a table, not a flat array');
assert.equal(parsed.features && parsed.features.codex_hooks, true);
});
test('returns content unchanged for empty string', () => {
assert.strictEqual(migrateCodexHooksMapFormat(''), '');
});
test('converts [hooks.shell] to namespaced AoT [[hooks.shell]] (#2760 CR5 finding 3)', () => {
const content = [
'[features]',
'codex_hooks = true',
'',
'[hooks]',
'',
'[hooks.shell]',
'command = "node /home/.codex/hooks/msd-check-update.js"',
'',
].join('\n');
const result = migrateCodexHooksMapFormat(content);
// Parse structurally — no source-grep on raw bytes.
const parsed = parseTomlToObject(result);
assert.ok(parsed.hooks && Array.isArray(parsed.hooks.shell),
'hooks.shell must be an array of tables, got: ' + (parsed.hooks ? typeof parsed.hooks.shell : 'no hooks table'));
assert.strictEqual(parsed.hooks.shell.length, 1);
// #2773: command now lives in [[hooks.shell.hooks]] sub-table, not at event-entry level
assert.ok(Array.isArray(parsed.hooks.shell[0].hooks), 'must emit [[hooks.shell.hooks]] sub-table');
assert.strictEqual(parsed.hooks.shell[0].hooks[0].command, 'node /home/.codex/hooks/msd-check-update.js');
assert.strictEqual(parsed.hooks.shell[0].hooks[0].type, 'command');
// No flat top-level [[hooks]] AoT and no synthetic event field.
assert.ok(!Array.isArray(parsed.hooks),
'no top-level [[hooks]] AoT — namespace IS the event in CR5 form');
assert.equal(parsed.hooks.shell[0].event, undefined,
'no synthetic event field — namespace [[hooks.shell]] encodes the event');
// User content preserved.
assert.equal(parsed.features && parsed.features.codex_hooks, true);
});
test('converts [hooks.exec] to namespaced AoT [[hooks.exec]] (#2760 CR5 finding 3)', () => {
const content = [
'[hooks.exec]',
'command = "echo hello"',
'extra_key = "preserved"',
'',
].join('\n');
const result = migrateCodexHooksMapFormat(content);
const parsed = parseTomlToObject(result);
assert.ok(parsed.hooks && Array.isArray(parsed.hooks.exec));
assert.strictEqual(parsed.hooks.exec.length, 1);
// #2773: command and extra keys now live in [[hooks.exec.hooks]] sub-table
assert.ok(Array.isArray(parsed.hooks.exec[0].hooks), 'must emit [[hooks.exec.hooks]] sub-table');
assert.strictEqual(parsed.hooks.exec[0].hooks[0].command, 'echo hello');
assert.strictEqual(parsed.hooks.exec[0].hooks[0].type, 'command',
'migrated handler must carry type = "command" per Codex 0.124.0+ schema');
assert.strictEqual(parsed.hooks.exec[0].hooks[0].extra_key, 'preserved');
assert.equal(parsed.hooks.exec[0].event, undefined);
});
test('converts multiple [hooks.TYPE] sections to separate namespaced AoT blocks (#2760 CR5 finding 3)', () => {
const content = [
'[hooks.shell]',
'command = "node /home/.codex/hooks/msd-check-update.js"',
'',
'[hooks.exec]',
'command = "echo done"',
'',
].join('\n');
const result = migrateCodexHooksMapFormat(content);
const parsed = parseTomlToObject(result);
assert.ok(parsed.hooks && Array.isArray(parsed.hooks.shell));
assert.ok(parsed.hooks && Array.isArray(parsed.hooks.exec));
assert.strictEqual(parsed.hooks.shell.length, 1);
assert.strictEqual(parsed.hooks.exec.length, 1);
// #2773: commands now live in the [[hooks..hooks]] sub-table
assert.strictEqual(parsed.hooks.shell[0].hooks[0].command, 'node /home/.codex/hooks/msd-check-update.js');
assert.strictEqual(parsed.hooks.shell[0].hooks[0].type, 'command',
'migrated shell handler must carry type = "command"');
assert.strictEqual(parsed.hooks.exec[0].hooks[0].command, 'echo done');
assert.strictEqual(parsed.hooks.exec[0].hooks[0].type, 'command',
'migrated exec handler must carry type = "command"');
});
test('migrates flat [[hooks]] with event=AfterCommand to [[hooks.AfterCommand]] namespaced form', () => {
// Flat [[hooks]] + event = "..." is incompatible with [[hooks.]] AoT in the same
// file — TOML cannot have hooks be both an array and a table. Migration promotes it.
const content = [
'[[hooks]]',
'event = "AfterCommand"',
'command = "echo custom"',
'',
].join('\n');
const result = migrateCodexHooksMapFormat(content);
const parsed = parseTomlToObject(result);
assert.ok(parsed.hooks && Array.isArray(parsed.hooks.AfterCommand),
'flat [[hooks]] event=AfterCommand must become [[hooks.AfterCommand]] AoT');
assert.strictEqual(parsed.hooks.AfterCommand.length, 1);
assert.ok(Array.isArray(parsed.hooks.AfterCommand[0].hooks),
'must emit [[hooks.AfterCommand.hooks]] sub-table');
assert.strictEqual(parsed.hooks.AfterCommand[0].hooks[0].command, 'echo custom');
assert.strictEqual(parsed.hooks.AfterCommand[0].hooks[0].type, 'command',
'migrated AfterCommand handler must carry type = "command" per Codex 0.124.0+ schema');
assert.equal(parsed.hooks.AfterCommand[0].event, undefined,
'event key consumed as namespace — must not appear in emitted block');
assert.ok(!Array.isArray(parsed.hooks), 'hooks must be a table, not a flat array');
});
test('end-to-end: install on config with old [hooks] map format produces namespaced AoT (#2637, #2760 CR5)', () => {
// Simulates the exact old MSD config.toml format that broke on Codex 0.124.0
const oldContent = [
'[features]',
'codex_hooks = true',
'',
'[hooks]',
'',
' [hooks.shell]',
' command = "node /home/.codex/hooks/msd-check-update.js"',
'',
].join('\n');
const result = migrateCodexHooksMapFormat(oldContent);
const parsed = parseTomlToObject(result);
// Codex 0.124.0+: must produce array-of-tables form. CR5 finding 3:
// namespaced AoT [[hooks.shell]] (no flat [[hooks]] with synthetic event).
assert.ok(parsed.hooks && Array.isArray(parsed.hooks.shell),
'hooks.shell must be array-of-tables in namespaced form');
assert.strictEqual(parsed.hooks.shell.length, 1);
// #2773: command lives in [[hooks.shell.hooks]] sub-table
assert.ok(Array.isArray(parsed.hooks.shell[0].hooks), 'must emit [[hooks.shell.hooks]] sub-table');
assert.strictEqual(parsed.hooks.shell[0].hooks[0].command,
'node /home/.codex/hooks/msd-check-update.js');
assert.strictEqual(parsed.hooks.shell[0].hooks[0].type, 'command',
'migrated shell handler must carry type = "command" per Codex 0.124.0+ schema');
assert.equal(parsed.features && parsed.features.codex_hooks, true);
});
test('bare [hooks] section without sub-tables is dropped (no [[hooks]] block added)', () => {
const content = [
'[features]',
'codex_hooks = true',
'',
'[hooks]',
'# no sub-tables, just an empty container',
'',
'[model]',
'name = "o3"',
'',
].join('\n');
const result = migrateCodexHooksMapFormat(content);
assert.ok(!result.match(/^\[hooks\]$/m), 'removes bare [hooks] section');
assert.ok(!result.includes('[[hooks]]'), 'no [[hooks]] added for bare [hooks] with no sub-tables');
assert.ok(result.includes('[features]'), 'preserves [features]');
assert.ok(result.includes('[model]'), 'preserves [model]');
});
test('upgrades stale [[hooks.SessionStart]] with event-level command to nested schema (#2773 CR6)', () => {
// Pre-#2773 single-block format: handler fields live directly under
// [[hooks.SessionStart]] rather than under [[hooks.SessionStart.hooks]].
// Codex 0.124.0+ rejects this shape. Migration must promote it.
const content = [
'[features]',
'codex_hooks = true',
'',
'[[hooks.SessionStart]]',
'command = "echo stale-user-hook"',
'',
].join('\n');
const result = migrateCodexHooksMapFormat(content);
const parsed = parseTomlToObject(result);
assert.ok(parsed.hooks && Array.isArray(parsed.hooks.SessionStart),
'stale [[hooks.SessionStart]] must remain a namespaced AoT');
assert.strictEqual(parsed.hooks.SessionStart.length, 1);
assert.ok(Array.isArray(parsed.hooks.SessionStart[0].hooks),
'must emit [[hooks.SessionStart.hooks]] sub-table');
assert.strictEqual(parsed.hooks.SessionStart[0].hooks[0].command, 'echo stale-user-hook');
assert.strictEqual(parsed.hooks.SessionStart[0].hooks[0].type, 'command',
'must inject type = "command" when source body has no explicit type');
assert.equal(parsed.hooks.SessionStart[0].command, undefined,
'command must not remain at event-entry level after promotion');
assert.equal(parsed.features && parsed.features.codex_hooks, true);
});
test('leaves [[hooks.SessionStart]] + [[hooks.SessionStart.hooks]] untouched (already nested)', () => {
// Properly-nested schema: handler lives under [[hooks.SessionStart.hooks]].
// Migration must NOT create a double-wrapped [[hooks.SessionStart.hooks.hooks]] shape.
const content = [
'[[hooks.SessionStart]]',
'',
'[[hooks.SessionStart.hooks]]',
'type = "command"',
'command = "echo already-nested"',
'',
].join('\n');
const result = migrateCodexHooksMapFormat(content);
const parsed = parseTomlToObject(result);
assert.ok(Array.isArray(parsed.hooks?.SessionStart),
'SessionStart must remain a namespaced AoT after no-op migration');
assert.strictEqual(parsed.hooks.SessionStart.length, 1,
'must not duplicate the event entry');
assert.ok(Array.isArray(parsed.hooks.SessionStart[0].hooks),
'nested [[hooks.SessionStart.hooks]] sub-table must still be present');
assert.strictEqual(parsed.hooks.SessionStart[0].hooks.length, 1,
'must not create a double-wrapped [[hooks.SessionStart.hooks.hooks]]');
assert.strictEqual(parsed.hooks.SessionStart[0].hooks[0].type, 'command');
assert.strictEqual(parsed.hooks.SessionStart[0].hooks[0].command, 'echo already-nested');
assert.equal(parsed.hooks.SessionStart[0].command, undefined,
'command must not appear at event-entry level');
});
test('promotes multiple stale [[hooks.TYPE]] entries from different event types', () => {
const content = [
'[[hooks.SessionStart]]',
'command = "echo session"',
'',
'[[hooks.AfterCommand]]',
'command = "echo after-cmd"',
'',
].join('\n');
const result = migrateCodexHooksMapFormat(content);
const parsed = parseTomlToObject(result);
assert.ok(parsed.hooks && Array.isArray(parsed.hooks.SessionStart));
assert.ok(parsed.hooks && Array.isArray(parsed.hooks.AfterCommand));
assert.strictEqual(parsed.hooks.SessionStart[0].hooks[0].command, 'echo session');
assert.strictEqual(parsed.hooks.SessionStart[0].hooks[0].type, 'command');
assert.strictEqual(parsed.hooks.AfterCommand[0].hooks[0].command, 'echo after-cmd');
assert.strictEqual(parsed.hooks.AfterCommand[0].hooks[0].type, 'command');
assert.equal(parsed.hooks.SessionStart[0].command, undefined);
assert.equal(parsed.hooks.AfterCommand[0].command, undefined);
});
test('matcher-only [[hooks.SessionStart]] (no handler fields) is left untouched', () => {
// A [[hooks.SessionStart]] entry with only a `matcher` key is a valid
// event filter — no handler fields → not a stale single-block entry.
const content = [
'[[hooks.SessionStart]]',
'matcher = "some-tool"',
'',
].join('\n');
const result = migrateCodexHooksMapFormat(content);
const parsed = parseTomlToObject(result);
assert.ok(Array.isArray(parsed.hooks?.SessionStart),
'matcher-only SessionStart must remain a namespaced AoT');
assert.strictEqual(parsed.hooks.SessionStart.length, 1);
assert.strictEqual(parsed.hooks.SessionStart[0].matcher, 'some-tool',
'matcher key must be preserved');
assert.equal(parsed.hooks.SessionStart[0].hooks, undefined,
'matcher-only entry must not gain a .hooks sub-array');
assert.equal(parsed.hooks.SessionStart[0].command, undefined,
'no spurious command key must appear');
});
test('quoted event name with dot ([[hooks."before.tool"]]) is treated as single 2-segment namespace', () => {
// Regression for the split('.') bug: "before.tool" contains a dot, but the
// key is quoted so it is ONE segment — [[hooks."before.tool"]] has exactly
// two path segments and must be classified the same as [[hooks.SessionStart]].
// It should NOT be treated as a 3-level path (hooks / before / tool).
const content = [
'[[hooks."before.tool"]]',
'command = "echo hi"',
'',
].join('\n');
const result = migrateCodexHooksMapFormat(content);
const parsed = parseTomlToObject(result);
// The key in the parsed object is the unquoted event name "before.tool".
assert.ok(
parsed.hooks && Array.isArray(parsed.hooks['before.tool']),
'[[hooks."before.tool"]] must be a namespaced AoT — not split on the inner dot'
);
assert.ok(
Array.isArray(parsed.hooks['before.tool'][0].hooks),
'must emit [[hooks."before.tool".hooks]] sub-table'
);
assert.strictEqual(
parsed.hooks['before.tool'][0].hooks[0].command,
'echo hi',
'command must be preserved in the nested handler sub-table'
);
// Ensure no spurious "before" or "tool" top-level hook keys appeared.
assert.equal(parsed.hooks?.before, undefined, 'must not split quoted key on dot');
});
test('CRLF line endings are preserved through migration (#2760 CR5: namespaced AoT)', () => {
const content = [
'[features]',
'codex_hooks = true',
'',
'[hooks.shell]',
'command = "node /home/.codex/hooks/msd-check-update.js"',
'',
].join('\r\n');
const result = migrateCodexHooksMapFormat(content);
assert.ok(result.includes('[[hooks.shell]]\r\n'),
'uses CRLF in namespaced [[hooks.shell]] header');
// Round-trip parse confirms the structural shape independent of EOL.
const parsed = parseTomlToObject(result);
assert.ok(parsed.hooks && Array.isArray(parsed.hooks.shell));
// #2773: command lives in [[hooks.shell.hooks]] sub-table
assert.ok(Array.isArray(parsed.hooks.shell[0].hooks), 'must emit [[hooks.shell.hooks]] sub-table');
assert.strictEqual(parsed.hooks.shell[0].hooks[0].command,
'node /home/.codex/hooks/msd-check-update.js');
assert.strictEqual(parsed.hooks.shell[0].hooks[0].type, 'command',
'migrated shell handler must carry type = "command" per Codex 0.124.0+ schema');
});
});
// ─── shape parity between migration and managed emit (#2760 CR5 finding 3) ──
describe('Codex hooks emit: migration produces namespaced AoT so managed-emit converges', () => {
// After #2760 CR5 finding 3, the legacy migration path
// (migrateCodexHooksMapFormat) emits `[[hooks.]]` directly — the
// namespace IS the event, no synthetic `event = ...` field. The managed
// install path (writes "# MSD Hooks") detects existing namespaced AoT via
// hasUserNamespacedAotHooks and emits its block in the same shape. The two
// paths must therefore both produce a namespaced layout when a legacy
// [hooks.SessionStart] is migrated, eliminating the mixed flat+namespaced
// bug class entirely.
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-codex-fieldparity-'));
});
afterEach(() => {
cleanup(tmpDir);
});
test('migration of legacy [hooks.SessionStart] produces two-level nested AoT (#2773)', () => {
const legacyContent = [
'[features]',
'codex_hooks = true',
'',
'[hooks.SessionStart]',
'command = "node /home/.codex/hooks/msd-check-update.js"',
'',
].join('\n');
const migrated = migrateCodexHooksMapFormat(legacyContent);
const parsed = parseTomlToObject(migrated);
// Outer event entry
assert.ok(
parsed.hooks && Array.isArray(parsed.hooks.SessionStart),
'migration must emit [[hooks.SessionStart]] namespaced AoT'
);
assert.equal(parsed.hooks.SessionStart[0].event, undefined,
'migration must NOT emit a synthetic event field — namespace IS the event');
assert.equal(Array.isArray(parsed.hooks), false,
'migration must NOT emit a flat top-level [[hooks]] AoT');
// Inner handler sub-table
assert.ok(
Array.isArray(parsed.hooks.SessionStart[0].hooks),
'migration must emit [[hooks.SessionStart.hooks]] sub-table'
);
const handler = parsed.hooks.SessionStart[0].hooks[0];
assert.strictEqual(handler.type, 'command',
'migration must inject type = "command" in handler sub-table');
assert.strictEqual(
handler.command,
'node /home/.codex/hooks/msd-check-update.js',
'migration must preserve original command value in handler sub-table'
);
});
});
// ─── mergeCodexConfig ───────────────────────────────────────────────────────────
describe('mergeCodexConfig', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-codex-merge-'));
});
afterEach(() => {
cleanup(tmpDir);
});
const sampleBlock = generateCodexConfigBlock([
{ name: 'msd-executor', description: 'Executes plans' },
]);
test('case 1: creates new config.toml', () => {
const configPath = path.join(tmpDir, 'config.toml');
mergeCodexConfig(configPath, sampleBlock);
assert.ok(fs.existsSync(configPath), 'file created');
const content = fs.readFileSync(configPath, 'utf8');
assert.ok(content.includes(MSD_CODEX_MARKER), 'has marker');
// #2406: config.toml never gets an [agents.msd-*] role table — the
// standalone agents/.toml written by installCodexConfig is the
// sole canonical registration Codex auto-discovers.
assert.ok(!content.includes('[agents.msd-executor]'), 'no agent role table (canonical source is the standalone TOML)');
assert.ok(!content.includes('[features]'), 'no features section');
assert.ok(!content.includes('multi_agent'), 'no multi_agent');
});
test('case 2: replaces existing MSD block', () => {
const configPath = path.join(tmpDir, 'config.toml');
const userContent = '[model]\nname = "o3"\n';
fs.writeFileSync(configPath, userContent + '\n' + sampleBlock + '\n');
// Re-merge with updated block
const newBlock = generateCodexConfigBlock([
{ name: 'msd-executor', description: 'Updated description' },
{ name: 'msd-planner', description: 'New agent' },
]);
mergeCodexConfig(configPath, newBlock);
const content = fs.readFileSync(configPath, 'utf8');
assert.ok(content.includes('[model]'), 'preserves user content');
// #2406: description text is per-agent metadata carried only by the
// standalone TOML now — it no longer leaks into config.toml.
assert.ok(!content.includes('Updated description'), 'no per-agent description in config.toml');
assert.ok(!content.includes('[agents.msd-planner]'), 'no agent role table (canonical source is the standalone TOML)');
// Verify no duplicate markers
const markerCount = (content.match(new RegExp(escapeRegex(MSD_CODEX_MARKER), 'g')) || []).length;
assert.strictEqual(markerCount, 1, 'exactly one marker');
});
test('case 3: appends to config without MSD marker', () => {
const configPath = path.join(tmpDir, 'config.toml');
fs.writeFileSync(configPath, '[model]\nname = "o3"\n');
mergeCodexConfig(configPath, sampleBlock);
const content = fs.readFileSync(configPath, 'utf8');
assert.ok(content.includes('[model]'), 'preserves user content');
assert.ok(content.includes(MSD_CODEX_MARKER), 'adds marker');
assert.ok(!content.includes('[agents.msd-executor]'), 'no agent role table (canonical source is the standalone TOML)');
});
test('case 3 with existing [features]: preserves user features, does not inject MSD keys', () => {
const configPath = path.join(tmpDir, 'config.toml');
fs.writeFileSync(configPath, '[features]\nother_feature = true\n\n[model]\nname = "o3"\n');
mergeCodexConfig(configPath, sampleBlock);
const content = fs.readFileSync(configPath, 'utf8');
assert.ok(content.includes('other_feature = true'), 'preserves existing feature');
assert.ok(!content.includes('multi_agent'), 'does not inject multi_agent');
assert.ok(!content.includes('default_mode_request_user_input'), 'does not inject request_user_input');
assert.ok(content.includes(MSD_CODEX_MARKER), 'adds marker for agents block');
assert.ok(!content.includes('[agents.msd-executor]'), 'no agent role table (canonical source is the standalone TOML)');
});
test('case 3 strips existing [agents.msd-*] sections before appending fresh block (#2406: fresh block never re-adds them)', () => {
const configPath = path.join(tmpDir, 'config.toml');
const existing = [
'[model]',
'name = "o3"',
'',
'[agents.custom-agent]',
'description = "user agent"',
'',
'',
'[agents.msd-executor]',
'description = "old"',
'config_file = "agents/msd-executor.toml"',
'',
].join('\n');
fs.writeFileSync(configPath, existing);
mergeCodexConfig(configPath, sampleBlock);
const content = fs.readFileSync(configPath, 'utf8');
// The pre-existing legacy [agents.msd-executor] role table is a leaked
// MSD section (stripLeakedMsdCodexSections) and the fresh block never
// re-adds one (#2406) — zero role tables should remain anywhere.
const msdStructCount = (content.match(/^\[agents\.msd-executor\]\s*$/gm) || []).length;
const markerCount = (content.match(new RegExp(escapeRegex(MSD_CODEX_MARKER), 'g')) || []).length;
assert.ok(content.includes('[model]'), 'preserves user content');
assert.ok(content.includes('[agents.custom-agent]'), 'preserves non-MSD agent section');
assert.strictEqual(msdStructCount, 0, 'legacy [agents.msd-executor] struct entry is removed and not regrown');
assert.strictEqual(markerCount, 1, 'adds exactly one marker block');
assert.ok(!/\r?\n{3,}# MSD Agent Configuration/.test(content), 'does not leave extra blank lines before marker block');
});
test('idempotent: re-merge produces same result', () => {
const configPath = path.join(tmpDir, 'config.toml');
mergeCodexConfig(configPath, sampleBlock);
const first = fs.readFileSync(configPath, 'utf8');
mergeCodexConfig(configPath, sampleBlock);
const second = fs.readFileSync(configPath, 'utf8');
assert.strictEqual(first, second, 'idempotent merge');
});
test('case 2 after case 3 with existing [features]: no duplicate sections', () => {
const configPath = path.join(tmpDir, 'config.toml');
fs.writeFileSync(configPath, '[features]\nother_feature = true\n\n[model]\nname = "o3"\n');
mergeCodexConfig(configPath, sampleBlock);
mergeCodexConfig(configPath, sampleBlock);
const content = fs.readFileSync(configPath, 'utf8');
const featuresCount = (content.match(/^\[features\]\s*$/gm) || []).length;
assert.strictEqual(featuresCount, 1, 'exactly one [features] section');
assert.ok(content.includes('other_feature = true'), 'preserves user feature keys');
assert.ok(!content.includes('[agents.msd-executor]'), 'no agent role table (canonical source is the standalone TOML)');
// Verify no duplicate markers
const markerCount = (content.match(new RegExp(escapeRegex(MSD_CODEX_MARKER), 'g')) || []).length;
assert.strictEqual(markerCount, 1, 'exactly one marker');
});
test('case 2 does not inject feature keys, and drops a legacy [agents.msd-old] role table on reinstall (#2406)', () => {
const configPath = path.join(tmpDir, 'config.toml');
const manualContent = '[features]\nother_feature = true\n\n' + MSD_CODEX_MARKER + '\n[agents.msd-old]\ndescription = "old"\n';
fs.writeFileSync(configPath, manualContent);
mergeCodexConfig(configPath, sampleBlock);
const content = fs.readFileSync(configPath, 'utf8');
assert.ok(!content.includes('multi_agent'), 'does not inject multi_agent');
assert.ok(!content.includes('default_mode_request_user_input'), 'does not inject request_user_input');
assert.ok(content.includes('other_feature = true'), 'preserves user feature');
// #2406: the pre-existing managed [agents.msd-old] role table (below the
// marker, from a pre-fix install) is truncated away by Case 2's
// marker-truncate, and the fresh block never re-adds a role table.
assert.ok(!content.includes('[agents.msd-old]'), 'legacy managed role table removed');
assert.ok(!content.includes('[agents.msd-executor]'), 'no agent role table (canonical source is the standalone TOML)');
});
test('#2406: update over a legacy 1.7.0-shape install removes duplicate [agents.msd-*] role registrations, preserves unrelated user config, and is idempotent on rerun', () => {
const configPath = path.join(tmpDir, 'config.toml');
// Simulate a config.toml produced by the pre-fix installer: a managed
// block with BOTH the bare [agents] dispatch-tuning table (holding the
// user's own max_threads = 4 alongside MSD's stale max_depth = 2) AND
// [agents.msd-*] role tables duplicating what the standalone TOMLs
// already register — plus an unrelated user [model] table above the
// marker that must survive untouched.
const legacyInstall = [
'[model]',
'name = "o3"',
'',
MSD_CODEX_MARKER,
'',
'[agents]',
'max_threads = 4',
'max_depth = 2',
'',
'[agents.msd-executor]',
'description = "Executes plans"',
'config_file = "agents/msd-executor.toml"',
'',
'[agents.msd-planner]',
'description = "Creates plans"',
'config_file = "agents/msd-planner.toml"',
'',
].join('\n');
fs.writeFileSync(configPath, legacyInstall);
mergeCodexConfig(configPath, sampleBlock);
const first = fs.readFileSync(configPath, 'utf8');
// Legacy duplicate role registrations removed.
assert.strictEqual((first.match(/^\[agents\.msd-/gm) || []).length, 0, 'zero [agents.msd-*] role tables remain');
assert.ok(!first.includes('config_file'), 'no config_file line remains');
// Unrelated user config preserved.
assert.ok(first.includes('[model]') && first.includes('name = "o3"'), 'preserves unrelated user [model] table');
// User's own AgentsToml scalar tuning preserved; MSD's max_depth re-pinned to 1.
assert.ok(first.includes('max_threads = 4'), 'preserves user max_threads scalar');
assert.match(first, /max_depth = 1/, 're-pins MSD-managed max_depth to 1');
assert.doesNotMatch(first, /max_depth = 2/, 'stale legacy max_depth value is gone');
assert.strictEqual((first.match(/^\[agents\]\s*$/gm) || []).length, 1, 'exactly one [agents] table (no duplicate)');
assert.equal(validateCodexConfigSchema(first).ok, true, 'the migrated config still validates');
// Idempotent: running install/merge again produces byte-identical output — no regrowth.
mergeCodexConfig(configPath, sampleBlock);
const second = fs.readFileSync(configPath, 'utf8');
assert.strictEqual(first, second, 'second merge is byte-identical — removed registrations do not regrow');
});
test('case 2 strips leaked [agents] and [agents.msd-*] from before content, and does not regrow a role table (#2406)', () => {
const configPath = path.join(tmpDir, 'config.toml');
const brokenContent = [
'[features]',
'child_agents_md = false',
'',
'[agents]',
'max_threads = 4',
'max_depth = 2',
'',
'[agents.msd-executor]',
'description = "old"',
'config_file = "agents/msd-executor.toml"',
'',
MSD_CODEX_MARKER,
'',
'[agents.msd-executor]',
'description = "Executes plans"',
'config_file = "agents/msd-executor.toml"',
'',
].join('\n');
fs.writeFileSync(configPath, brokenContent);
mergeCodexConfig(configPath, sampleBlock);
const content = fs.readFileSync(configPath, 'utf8');
assert.ok(content.includes('child_agents_md = false'), 'preserves user feature keys');
// #2406: neither the leaked pre-marker role table nor the legacy
// post-marker managed one survives — the fresh block emits none.
assert.ok(!content.includes('[agents.msd-executor]'), 'no agent role table anywhere in the file');
// Verify the leaked [agents] table header above marker was stripped
const markerIndex = content.indexOf(MSD_CODEX_MARKER);
const beforeMarker = content.substring(0, markerIndex);
assert.ok(!beforeMarker.match(/^\[agents\]\s*$/m), 'no leaked [agents] above marker');
assert.ok(!beforeMarker.includes('[agents.msd-'), 'no leaked [agents.msd-*] above marker');
});
test('case 2 strips leaked MSD-managed sections above marker in CRLF files, and does not regrow a role table (#2406)', () => {
const configPath = path.join(tmpDir, 'config.toml');
const brokenContent = [
'[features]',
'child_agents_md = false',
'',
'[agents]',
'max_threads = 4',
'',
'[agents.msd-executor]',
'description = "stale"',
'config_file = "agents/msd-executor.toml"',
'',
MSD_CODEX_MARKER,
'',
'[agents.msd-executor]',
'description = "Executes plans"',
'config_file = "agents/msd-executor.toml"',
'',
].join('\r\n');
fs.writeFileSync(configPath, brokenContent, 'utf8');
mergeCodexConfig(configPath, sampleBlock);
mergeCodexConfig(configPath, sampleBlock);
const content = fs.readFileSync(configPath, 'utf8');
const markerIndex = content.indexOf(MSD_CODEX_MARKER);
const beforeMarker = content.slice(0, markerIndex);
assert.ok(content.includes('child_agents_md = false'), 'preserves user feature keys');
assert.strictEqual(countMatches(beforeMarker, /^\[agents\]\s*$/gm), 0, 'removes leaked [agents] above marker');
assert.strictEqual(countMatches(beforeMarker, /^\[agents\.msd-executor\]\s*$/gm), 0, 'removes leaked MSD agent section above marker');
// #2406: the fresh block never emits a role table, so zero remain
// anywhere in the file — not just above the marker.
assert.strictEqual(countMatches(content, /^\[agents\.msd-executor\]\s*$/gm), 0, 'zero role tables anywhere');
assert.strictEqual(countMatches(content, /name = "msd-executor"/g), 0, 'no name = field in struct format');
assertUsesOnlyEol(content, '\r\n');
});
test('case 2 strips bare [agents] tables (invalid in current Codex schema, #2760) and removes leaked MSD sections in CRLF files, without regrowing a role table (#2406)', () => {
const configPath = path.join(tmpDir, 'config.toml');
const brokenContent = [
'[features]',
'child_agents_md = false',
'',
'[agents]',
'default = "custom-agent"',
'',
'[agents.msd-executor]',
'description = "stale"',
'config_file = "agents/msd-executor.toml"',
'',
MSD_CODEX_MARKER,
'',
'[agents.msd-executor]',
'description = "Executes plans"',
'config_file = "agents/msd-executor.toml"',
'',
].join('\r\n');
fs.writeFileSync(configPath, brokenContent, 'utf8');
mergeCodexConfig(configPath, sampleBlock);
mergeCodexConfig(configPath, sampleBlock);
const content = fs.readFileSync(configPath, 'utf8');
const markerIndex = content.indexOf(MSD_CODEX_MARKER);
const beforeMarker = content.slice(0, markerIndex);
// Bare [agents] is invalid under Codex's current schema (rejected with
// "expected struct AgentsToml") so install-time stripping always purges
// it (#2760). User feature keys above the marker are preserved.
// Structural assertion: TOML-parse the pre-marker region and verify the
// bare [agents] block is fully gone — header AND body keys (e.g.,
// `default = "custom-agent"`). A header-only check would miss a
// partial-strip regression that leaves orphan body keys reparented to a
// sibling section.
const parsedBefore = parseTomlToObject(beforeMarker);
assert.equal(
parsedBefore.agents,
undefined,
'bare [agents] block fully purged including body keys (#2760)',
);
assert.ok(
parsedBefore.features && parsedBefore.features.child_agents_md === false,
'preserves user feature keys above marker',
);
// #2406: the fresh block never emits a role table, so zero remain
// anywhere in the file — not just above the marker.
assert.strictEqual(countMatches(content, /^\[agents\.msd-executor\]\s*$/gm), 0, 'zero role tables anywhere');
assert.strictEqual(countMatches(content, /name = "msd-executor"/g), 0, 'no name = field in struct format');
assertUsesOnlyEol(content, '\r\n');
});
test('case 2 idempotent after case 3 with existing [features]', () => {
const configPath = path.join(tmpDir, 'config.toml');
fs.writeFileSync(configPath, '[features]\nother_feature = true\n');
mergeCodexConfig(configPath, sampleBlock);
const first = fs.readFileSync(configPath, 'utf8');
mergeCodexConfig(configPath, sampleBlock);
const second = fs.readFileSync(configPath, 'utf8');
mergeCodexConfig(configPath, sampleBlock);
const third = fs.readFileSync(configPath, 'utf8');
assert.strictEqual(first, second, 'idempotent after 2nd merge');
assert.strictEqual(second, third, 'idempotent after 3rd merge');
});
test('preserves CRLF when appending MSD block to existing config', () => {
const configPath = path.join(tmpDir, 'config.toml');
fs.writeFileSync(configPath, '[model]\r\nname = "o3"\r\n', 'utf8');
mergeCodexConfig(configPath, sampleBlock);
const content = fs.readFileSync(configPath, 'utf8');
assert.ok(content.includes('[model]\r\nname = "o3"\r\n'), 'preserves existing CRLF content');
assert.ok(content.includes(`${MSD_CODEX_MARKER}\r\n`), 'writes marker with CRLF');
assertUsesOnlyEol(content, '\r\n');
});
test('uses the first newline style when appending MSD block to mixed-EOL configs', () => {
const configPath = path.join(tmpDir, 'config.toml');
fs.writeFileSync(configPath, '# first line wins\n[model]\r\nname = "o3"\r\n', 'utf8');
mergeCodexConfig(configPath, sampleBlock);
const content = fs.readFileSync(configPath, 'utf8');
assert.ok(content.includes('# first line wins\n[model]\r\nname = "o3"'), 'preserves the existing mixed-EOL model content');
assert.ok(content.includes(`\n\n${MSD_CODEX_MARKER}\n`), 'writes the managed block using the first newline style');
});
// ─── #3610: top-level keys below the marker must not be captured by [agents] ──
//
// Since #2088 the managed block opens with a bare `[agents]` table header. On
// upgrade (marker present) the block is regenerated IN PLACE, so a top-level
// key that lived below the marker (e.g. Codex Computer Use's `notify`) would
// parse as an [agents] member — validateCodexConfigSchema correctly rejected
// the merged file and the install aborted mid-flight.
test('#3610: top-level keys below the marker are hoisted above the managed block and the merged file validates', () => {
const configPath = path.join(tmpDir, 'config.toml');
fs.writeFileSync(
configPath,
`${MSD_CODEX_MARKER}\n\nnotify = ["x", "turn-ended"]\n\n[features]\nhooks = true\n`,
);
mergeCodexConfig(configPath, sampleBlock);
const content = fs.readFileSync(configPath, 'utf8');
const schema = validateCodexConfigSchema(content);
assert.ok(schema.ok, `merged config must pass Codex schema validation: ${schema.reason || ''}`);
const notifyIdx = content.indexOf('notify = ');
const agentsIdx = content.indexOf('[agents]');
assert.ok(notifyIdx !== -1 && agentsIdx !== -1, 'both the key and the agents table must be present');
assert.ok(notifyIdx < agentsIdx, 'a surviving top-level key must precede the [agents] table header, not parse as its member');
assert.ok(content.includes('[features]'), 'user tables below the marker are preserved after the block');
});
test('#3610 boundary: fresh install (no marker) with a top-level key still validates unchanged', () => {
const configPath = path.join(tmpDir, 'config.toml');
fs.writeFileSync(configPath, 'notify = ["x", "turn-ended"]\n\n[features]\nhooks = true\n');
mergeCodexConfig(configPath, sampleBlock);
const schema = validateCodexConfigSchema(fs.readFileSync(configPath, 'utf8'));
assert.ok(schema.ok, `fresh-install merge must validate: ${schema.reason || ''}`);
});
test('#3610 boundary: key above the marker is untouched by the hoist', () => {
const configPath = path.join(tmpDir, 'config.toml');
fs.writeFileSync(configPath, `notify = ["x"]\n\n${MSD_CODEX_MARKER}\n\n[features]\nhooks = true\n`);
mergeCodexConfig(configPath, sampleBlock);
const content = fs.readFileSync(configPath, 'utf8');
const schema = validateCodexConfigSchema(content);
assert.ok(schema.ok, `control merge must validate: ${schema.reason || ''}`);
assert.ok(content.indexOf('notify = ') < content.indexOf(MSD_CODEX_MARKER), 'the pre-marker key stays pre-marker');
});
test('#3610: a multiline top-level value below the marker hoists as one unit', () => {
const configPath = path.join(tmpDir, 'config.toml');
const multiline = 'notify = [\n "x",\n "turn-ended",\n]';
fs.writeFileSync(configPath, `${MSD_CODEX_MARKER}\n\n${multiline}\n\n[features]\nhooks = true\n`);
mergeCodexConfig(configPath, sampleBlock);
const content = fs.readFileSync(configPath, 'utf8');
const schema = validateCodexConfigSchema(content);
assert.ok(schema.ok, `multiline hoist must validate: ${schema.reason || ''}`);
const hoistedAt = content.indexOf(multiline);
assert.ok(hoistedAt !== -1, 'the multiline value must survive the hoist intact');
assert.ok(hoistedAt < content.indexOf('[agents]'), 'the whole multiline value lands above the table header');
});
test('#3610: hoisted keys land at FILE scope even when the pre-marker region ends inside a table', () => {
// The default real-world layout: user tables ABOVE the marker, a top-level
// key below it. Appending the key after the pre-marker tables would merely
// capture it into THOSE tables ([features].notify) — the same defect class,
// silent to validateCodexConfigSchema, which inspects only agents/hooks.
const configPath = path.join(tmpDir, 'config.toml');
fs.writeFileSync(
configPath,
`[features]\nhooks = true\n\n${MSD_CODEX_MARKER}\n\nnotify = ["x", "turn-ended"]\n\n[profiles.fast]\nmodel = "gpt-5"\n`,
);
mergeCodexConfig(configPath, sampleBlock);
const content = fs.readFileSync(configPath, 'utf8');
const schema = validateCodexConfigSchema(content);
assert.ok(schema.ok, `merge must validate: ${schema.reason || ''}`);
const parsed = parseTomlToObject(content);
assert.ok(Array.isArray(parsed.notify), 'the surviving key must parse as a top-level array');
assert.ok(!parsed.features || !('notify' in parsed.features), 'the key must NOT be captured into the pre-marker [features] table');
assert.ok(content.indexOf('notify = ') < content.indexOf('[features]'), 'file scope means before the FIRST table header, not just above the MSD block');
});
test('#3610: a top-level multiline STRING containing a table-header lookalike hoists intact', () => {
// The record parser must not treat the [looks.like.a.header] line inside
// the """ string as a table header (startsInMultilineString) — the split
// must land after the whole value.
const configPath = path.join(tmpDir, 'config.toml');
const value = 'banner = """\nnot a [table.header] line\n"""\n';
fs.writeFileSync(configPath, `${MSD_CODEX_MARKER}\n\n${value}\n[features]\nhooks = true\n`);
mergeCodexConfig(configPath, sampleBlock);
const content = fs.readFileSync(configPath, 'utf8');
const schema = validateCodexConfigSchema(content);
assert.ok(schema.ok, `multiline-string hoist must validate: ${schema.reason || ''}`);
const hoistedAt = content.indexOf(value.trim());
assert.ok(hoistedAt !== -1, 'the multiline string must survive intact');
assert.ok(hoistedAt < content.indexOf('[agents]'), 'the whole string value lands above the table header');
});
test('#3610: merging twice is idempotent (the first merge is a fixed point)', () => {
const configPath = path.join(tmpDir, 'config.toml');
fs.writeFileSync(
configPath,
`[features]\nhooks = true\n\n${MSD_CODEX_MARKER}\n\nnotify = ["x"]\n\n[profiles.fast]\nmodel = "gpt-5"\n`,
);
mergeCodexConfig(configPath, sampleBlock);
const once = fs.readFileSync(configPath, 'utf8');
mergeCodexConfig(configPath, sampleBlock);
assert.strictEqual(fs.readFileSync(configPath, 'utf8'), once, 'the second merge must not move anything');
});
test('#3610: CRLF config with a top-level key below the marker validates', () => {
const configPath = path.join(tmpDir, 'config.toml');
fs.writeFileSync(
configPath,
`${MSD_CODEX_MARKER}\r\n\r\nnotify = ["x", "turn-ended"]\r\n\r\n[features]\r\nhooks = true\r\n`,
);
mergeCodexConfig(configPath, sampleBlock);
const content = fs.readFileSync(configPath, 'utf8');
const schema = validateCodexConfigSchema(content);
assert.ok(schema.ok, `CRLF upgrade merge must validate: ${schema.reason || ''}`);
assert.ok(content.indexOf('notify = ') < content.indexOf('[agents]'), 'hoist holds under CRLF');
});
});