/** * 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: _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: _getCodexSkillAdapterHeader, convertClaudeAgentToCodexAgent: _convertClaudeAgentToCodexAgent, convertClaudeCommandToCodexSkill: _convertClaudeCommandToCodexSkill, generateCodexAgentToml: _generateCodexAgentToml, _resetCodexWarningDedupeForTests: __resetCodexWarningDedupeForTests, cleanupCodexSkillMetadataSidecars, generateCodexConfigBlock: _generateCodexConfigBlock, stripMsdFromCodexConfig, migrateCodexHooksMapFormat: _migrateCodexHooksMapFormat, mergeCodexConfig: _mergeCodexConfig, install, MSD_CODEX_MARKER, deriveCodexSandboxMode: _deriveCodexSandboxMode, // #3897 rung 3 (ADR-3473 §8.3, option 2 — HALT.md): anticipated new export // which held the 17 explicit read-only pins until #4770 lifted them (now an empty frozen map) 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: _CODEX_SANDBOX_HOLDS, parseTomlToObject, validateCodexConfigSchema: _validateCodexConfigSchema, uninstall: _uninstall, CODEX_EXTENDED_HOOK_EVENTS: _CODEX_EXTENDED_HOOK_EVENTS, } = require('../bin/install.js'); const { resolveNodeRunner } = require('../msd-core/bin/lib/runtime-hooks-surface.cjs'); const { resolveInstallPlan: _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: _extractFrontmatterAndBody, extractFrontmatterField: _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: _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 ───────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────── // Folded from tests/issue-2940-codex-config-merge-trailing.test.cjs — consolidation epic #1969 (H3 W4 #3336) // ──────────────────────────────────────────────────────────────────────── { const { describe: __foldDescribe } = require('node:test'); __foldDescribe("folded:bug-2940-codex-config-merge-trailing (consolidation epic #1969 H3 W4 #3336)", () => { 'use strict'; process.env.MSD_TEST_MODE = '1'; /** * Regression test for #2940 — `msd-update` overwrites `~/.codex/config.toml`, * removing any user/Codex-CLI settings added after the MSD-managed marker block. * * Root cause: `mergeCodexConfig`'s Case 2 (marker present) preserved content * BEFORE the marker but unconditionally discarded everything from the marker to * EOF, replacing it with a freshly generated MSD block. Since a fresh install * writes the MSD block as the file's entire content, any settings the user or * Codex CLI later adds (`[model]`, `[mcp_servers.*]`, `[profiles.*]`) land AFTER * the block, and every subsequent update wiped them. * * The fix preserves genuine trailing TOML by routing the post-marker region * through the existing `stripLeakedMsdCodexSections` (which removes MSD's own * managed/leaked sections while keeping user tables), then re-appending it after * the regenerated MSD block — without regressing #2406's de-dup. * * Matrix: .msd/bug/fix/2940-codex-config-merge-preserves-trailing-content/50-test-matrix.md * * NOTE: this describe block covers trailing-content-after-the-marker preservation * ([model]/[mcp_servers.*]/[profiles.*] appended AFTER the MSD block) — a case the * pre-existing 'mergeCodexConfig' suite above does not exercise (that suite's cases * write user content BEFORE the marker/block, not after). Verified non-duplicate * against both the pre-existing target and the other three folded sources. */ const { describe, test, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); const { cleanup } = require('./helpers.cjs'); const { generateCodexConfigBlock, mergeCodexConfig, MSD_CODEX_MARKER, } = require('../bin/install.js'); describe('mergeCodexConfig trailing-content preservation (#2940)', () => { let tmpDir; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-2940-merge-')); }); afterEach(() => { cleanup(tmpDir); }); /** A MSD block with one agent (the shape installCodexConfig passes). */ const block = () => generateCodexConfigBlock([{ name: 'msd-executor', description: 'Executes plans' }]); test('trailingUserModelSectionPreserved', () => { // Row 1 (failing-first regression): a config with the MSD block FIRST, then a user // [model] section after it (the real-world layout — fresh install fills the file, // user settings land after). Re-merge must preserve [model] byte-for-byte. const configPath = path.join(tmpDir, 'config.toml'); const trailing = '[model]\nname = "gpt-5.4"\n'; // First write: MSD block + user content after it (no content before the marker). fs.writeFileSync(configPath, block() + '\n' + trailing); mergeCodexConfig(configPath, block()); const content = fs.readFileSync(configPath, 'utf8'); assert.ok(content.includes('[model]'), 'user [model] section preserved after re-merge'); assert.ok(content.includes('name = "gpt-5.4"'), 'user model value preserved verbatim'); assert.ok(content.includes(MSD_CODEX_MARKER), 'MSD marker still present'); const markerCount = (content.match(new RegExp(escapeRegex(MSD_CODEX_MARKER), 'g')) || []).length; assert.strictEqual(markerCount, 1, 'exactly one marker (no duplication)'); assert.ok(content.includes('max_depth ='), 'MSD-managed [agents] block regenerated'); }); test('multipleTrailingTablesPreserved', () => { // Row 2: multiple trailing user tables ([mcp_servers.*], [profiles.*]). const configPath = path.join(tmpDir, 'config.toml'); const trailing = [ '[mcp_servers.figma]', 'command = "npx"', 'args = ["-y", "figma-mcp"]', '', '[profiles.dev]', 'model = "o3"', 'sandbox_mode = "workspace-write"', ].join('\n'); fs.writeFileSync(configPath, block() + '\n' + trailing + '\n'); mergeCodexConfig(configPath, block()); const content = fs.readFileSync(configPath, 'utf8'); assert.ok(content.includes('[mcp_servers.figma]'), 'mcp_servers table preserved'); assert.ok(content.includes('[profiles.dev]'), 'profiles table preserved'); assert.ok(content.includes('sandbox_mode = "workspace-write"'), 'profile value preserved'); assert.ok(content.includes(MSD_CODEX_MARKER), 'MSD block regenerated'); }); test('reMergeIsIdempotent', () => { // Row 3 (acceptance #2): merging the result of a merge again yields identical content. const configPath = path.join(tmpDir, 'config.toml'); fs.writeFileSync(configPath, block() + '\n[model]\nname = "o3"\n'); mergeCodexConfig(configPath, block()); const afterFirst = fs.readFileSync(configPath, 'utf8'); mergeCodexConfig(configPath, block()); const afterSecond = fs.readFileSync(configPath, 'utf8'); assert.strictEqual(afterSecond, afterFirst, 'second merge is idempotent (no further change)'); }); test('leakedMsdSectionAfterMarkerStillStripped', () => { // Row 4 (#2406 non-regression): a leaked MSD-managed [agents.msd-*] section AFTER the // marker is still REMOVED (not regrown), while genuine user content after it is preserved. const configPath = path.join(tmpDir, 'config.toml'); const leakedAndUser = [ '[agents.msd-executor]', 'description = "stale leaked"', 'config_file = "agents/msd-executor.toml"', '', '[model]', 'name = "o3"', ].join('\n'); fs.writeFileSync(configPath, block() + '\n' + leakedAndUser + '\n'); mergeCodexConfig(configPath, block()); const content = fs.readFileSync(configPath, 'utf8'); const msdStructCount = (content.match(/^\[agents\.msd-executor\]\s*$/gm) || []).length; assert.strictEqual(msdStructCount, 0, 'leaked [agents.msd-executor] after marker is stripped (not regrown)'); assert.ok(content.includes('[model]'), 'genuine user [model] after the leaked section still preserved'); }); test('bareAgentsAfterMarkerHandled', () => { // Row 5: a user AgentsToml scalar (max_threads) the user folded INTO the managed [agents] // block (the valid, realistic shape — two [agents] tables would be invalid TOML), PLUS a // separate trailing [model] section. The fix must preserve the user scalar via the existing // spliceCodexAgentsScalars path AND preserve the trailing [model] via the new trailing-region // logic, while regenerating exactly one managed [agents] table. const configPath = path.join(tmpDir, 'config.toml'); // Simulate: fresh install wrote the MSD block; the user then added max_threads into the // [agents] table and added a [model] section after it. const existing = [ MSD_CODEX_MARKER, '', '[agents]', 'max_depth = 1', 'max_threads = 4', '', '[model]', 'name = "o3"', ].join('\n'); fs.writeFileSync(configPath, existing + '\n'); mergeCodexConfig(configPath, block()); const content = fs.readFileSync(configPath, 'utf8'); // The user's max_threads scalar is preserved (spliced into the regenerated managed [agents]); // there is exactly one [agents] table (the managed one). assert.ok(content.includes('max_threads = 4'), 'user AgentsToml scalar (max_threads) preserved in managed block'); const agentsHeaders = (content.match(/^\[agents\]\s*$/gm) || []).length; assert.strictEqual(agentsHeaders, 1, 'exactly one [agents] table (the managed one)'); assert.ok(content.includes('max_depth = 1'), 'MSD-managed max_depth still present'); assert.ok(content.includes('[model]'), 'trailing [model] still preserved'); }); test('beforeAndAfterMarkerBothPreserved', () => { // Row 6: content both BEFORE and AFTER the marker is preserved; MSD block regenerated once. const configPath = path.join(tmpDir, 'config.toml'); const before = '[profiles.work]\nmodel = "gpt-5.4"\n'; const after = '[mcp_servers.github]\ncommand = "gh-mcp"\n'; fs.writeFileSync(configPath, before + '\n' + block() + '\n' + after + '\n'); mergeCodexConfig(configPath, block()); const content = fs.readFileSync(configPath, 'utf8'); assert.ok(content.includes('[profiles.work]'), 'content before marker preserved'); assert.ok(content.includes('[mcp_servers.github]'), 'content after marker preserved'); const markerCount = (content.match(new RegExp(escapeRegex(MSD_CODEX_MARKER), 'g')) || []).length; assert.strictEqual(markerCount, 1, 'exactly one marker'); }); test('noTrailingContentUnchanged', () => { // Row 7 (zero-trailing boundary): a config with ONLY the MSD block (fresh-install case) // re-merges to just the regenerated block — no spurious blank-line artifacts introduced // by the trailing-preservation logic. const configPath = path.join(tmpDir, 'config.toml'); fs.writeFileSync(configPath, block() + '\n'); mergeCodexConfig(configPath, block()); const content = fs.readFileSync(configPath, 'utf8'); // No spurious trailing blank lines beyond the single trailing newline. Use a CRLF-safe // pattern (\r?\n) so the assertion holds under Windows git-autocrlf line endings. assert.ok(!/(?:\r?\n){3,}$/.test(content), 'no spurious run of blank lines at end of file'); assert.strictEqual(content.trim(), block().trim(), 'content is exactly the regenerated block (whitespace-trimmed)'); }); }); }); } // ─── Integration: installCodexConfig ──────────────────────────────────────────── describe('installCodexConfig (integration)', () => { let tmpTarget; const agentsSrc = path.join(__dirname, '..', 'agents'); beforeEach(() => { tmpTarget = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-codex-install-')); }); afterEach(() => { cleanup(tmpTarget); }); // Only run if agents/ directory exists (not in CI without full checkout) const hasAgents = fs.existsSync(agentsSrc); (hasAgents ? test : test.skip)('generates config.toml and agent .toml files', () => { const { installCodexConfig } = require('../bin/install.js'); const count = installCodexConfig(tmpTarget, agentsSrc); assert.ok(count >= 11, `installed ${count} agents (expected >= 11)`); // Verify config.toml const configPath = path.join(tmpTarget, 'config.toml'); assert.ok(fs.existsSync(configPath), 'config.toml exists'); const config = fs.readFileSync(configPath, 'utf8'); assert.ok(config.includes(MSD_CODEX_MARKER), 'has MSD marker'); // #2406: config.toml must NOT register agent roles — the standalone // agents/.toml (verified below) is the sole canonical source // Codex auto-discovers. A role table here would be a second, // duplicate registration of the same role. assert.ok(!config.includes('[agents.msd-executor]'), 'no executor role table in config.toml'); assert.strictEqual((config.match(/^\[agents\.msd-/gm) || []).length, 0, 'zero [agents.msd-*] role tables of any kind'); assert.strictEqual((config.match(/^config_file = /gm) || []).length, 0, 'zero config_file lines'); assert.ok(!config.includes('multi_agent'), 'no feature flags'); // Verify per-agent .toml files const agentsDir = path.join(tmpTarget, 'agents'); assert.ok(fs.existsSync(path.join(agentsDir, 'msd-executor.toml')), 'executor .toml exists'); assert.ok(fs.existsSync(path.join(agentsDir, 'msd-plan-checker.toml')), 'plan-checker .toml exists'); const executorToml = fs.readFileSync(path.join(agentsDir, 'msd-executor.toml'), 'utf8'); assert.ok(executorToml.includes('name = "msd-executor"'), 'executor has name'); assert.ok(executorToml.includes('description = "Executes MSD plans with atomic commits, deviation handling, checkpoint protocols, and state management. Spawned by execute-phase orchestrator or execute-plan command."'), 'executor has description'); assert.ok(executorToml.includes('sandbox_mode = "workspace-write"'), 'executor is workspace-write'); assert.ok(executorToml.includes('developer_instructions'), 'has developer_instructions'); const checkerToml = fs.readFileSync(path.join(agentsDir, 'msd-plan-checker.toml'), 'utf8'); assert.ok(checkerToml.includes('name = "msd-plan-checker"'), 'plan-checker has name'); assert.ok(checkerToml.includes('sandbox_mode = "read-only"'), 'plan-checker is read-only'); }); // PATHS-01: no ~/.claude references should leak into generated .toml files (#2320) // Covers both trailing-slash and bare end-of-string forms, and scans all .toml // files (agents/ subdirectory + top-level config.toml if present). (hasAgents ? test : test.skip)('generated .toml files contain no leaked ~/.claude paths (PATHS-01)', () => { const { installCodexConfig } = require('../bin/install.js'); installCodexConfig(tmpTarget, agentsSrc); // Collect all .toml files: per-agent files in agents/ plus top-level config.toml. // Not the shared listAgentFiles() helper: reads the INSTALLED target dir and // collects generated .toml (absolute paths), not the source .md roster. const agentsDir = path.join(tmpTarget, 'agents'); const tomlFiles = fs.readdirSync(agentsDir) .filter(f => f.endsWith('.toml')) .map(f => path.join(agentsDir, f)); const topLevel = path.join(tmpTarget, 'config.toml'); if (fs.existsSync(topLevel)) tomlFiles.push(topLevel); assert.ok(tomlFiles.length > 0, 'at least one .toml file generated'); // Match ~/.claude, $HOME/.claude, or ./.claude with or without trailing slash const leakPattern = /(?:~|\$HOME|\.)\/\.claude(?:\/|$)/; const leaks = []; for (const filePath of tomlFiles) { const content = fs.readFileSync(filePath, 'utf8'); if (leakPattern.test(content)) { leaks.push(path.relative(tmpTarget, filePath)); } } assert.deepStrictEqual(leaks, [], `No .toml files should contain .claude paths; found leaks in: ${leaks.join(', ')}`); }); (hasAgents ? test : test.skip)('generated Codex agent .toml files do not call bare msd-tools', () => { const { installCodexConfig } = require('../bin/install.js'); installCodexConfig(tmpTarget, agentsSrc); // Not the shared listAgentFiles() helper: reads the INSTALLED target dir and // filters generated msd-*.toml output, not the source .md roster. const agentsDir = path.join(tmpTarget, 'agents'); const tomlFiles = fs.readdirSync(agentsDir) .filter((file) => file.startsWith('msd-') && file.endsWith('.toml')); assert.ok(tomlFiles.length > 0, 'expected generated Codex agent toml files'); for (const file of tomlFiles) { const content = fs.readFileSync(path.join(agentsDir, file), 'utf8'); assertNoCodexBareMsdToolsInvocation(content, `agents/${file}`); } }); }); // ──────────────────────────────────────────────────────────────────────── // Folded from tests/issue-2834-codex-install-model-ordering.test.cjs — consolidation epic #1969 (H3 W4 #3336) // ──────────────────────────────────────────────────────────────────────── { const { describe: __foldDescribe } = require('node:test'); __foldDescribe("folded:bug-2834-codex-install-model-ordering (consolidation epic #1969 H3 W4 #3336)", () => { // allow-test-rule: structural-implementation-guard (#2834) 'use strict'; // Regression guard for #2834: on a clean Codex install, agent TOMLs contained no // model-routing fields because defaults.json (resolve_model_ids + runtime) was written // AFTER installCodexConfig generated the TOMLs. The fix extracts writeNonClaudeDefaults // and calls it BEFORE installCodexConfig. This test asserts the ordering invariant in // the install source so a future edit can't silently re-introduce the gap. // // Verified non-duplicate: no existing coverage in this file asserts on // writeNonClaudeDefaults / the install-flow call ordering (source-text guard), and // none of the other three folded sources touch this. const { test } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const INSTALL_JS = path.join(__dirname, '..', 'bin', 'install.js'); test('writeNonClaudeDefaults is called before installCodexConfig in the Codex install flow (#2834)', () => { const src = fs.readFileSync(INSTALL_JS, 'utf8'); // Find the call to writeNonClaudeDefaults that precedes installCodexConfig. const writeIdx = src.indexOf('writeNonClaudeDefaults(runtime);'); // allow-test-rule: structural-implementation-guard (#2834) assert.ok(writeIdx !== -1, 'writeNonClaudeDefaults(runtime) must be called in the install flow'); // Find the FIRST installCodexConfig call AFTER the writeNonClaudeDefaults call. const codexGenIdx = src.indexOf('installCodexConfig(targetDir', writeIdx); // allow-test-rule: structural-implementation-guard (#2834) assert.ok(codexGenIdx !== -1 && codexGenIdx > writeIdx, 'installCodexConfig must be called AFTER writeNonClaudeDefaults so defaults.json ' + '(resolve_model_ids + runtime) exists before agent TOML generation reads it (#2834)'); // The #2834 comment must be present at the call site. const callSite = src.slice(writeIdx - 300, writeIdx + 100); assert.ok(/#2834/.test(callSite), 'the writeNonClaudeDefaults call must carry the #2834 rationale comment'); // allow-test-rule: structural-implementation-guard (#2834) }); test('writeNonClaudeDefaults function exists and is a no-op for Claude (#2834)', () => { const src = fs.readFileSync(INSTALL_JS, 'utf8'); const fnIdx = src.indexOf('function writeNonClaudeDefaults('); // allow-test-rule: structural-implementation-guard (#2834) assert.ok(fnIdx !== -1, 'writeNonClaudeDefaults must be defined as a function'); // Bound the slice by the next top-level declaration rather than a fixed // character count, so adding a comment or a guard inside the function cannot // push the asserted tokens out of the window and red this test spuriously. const nextFnIdx = src.indexOf('\nfunction ', fnIdx + 1); // allow-test-rule: structural-implementation-guard (#2834) const fnBody = src.slice(fnIdx, nextFnIdx === -1 ? undefined : nextFnIdx); // Source-text guard, not a behavioral call: writeNonClaudeDefaults() early-returns // as a no-op whenever process.env.MSD_TEST_MODE is set (see its own body), and this // suite sets MSD_TEST_MODE='1' file-wide (line 14), so invoking it here could never // observe the resolve_model_ids/runtime writes it is supposed to make (#2834). assert.ok(/nativeModelAliases/.test(fnBody), 'writeNonClaudeDefaults must early-return for Claude (nativeModelAliases check)'); // allow-test-rule: structural-implementation-guard (#2834) assert.ok(/resolve_model_ids/.test(fnBody), 'writeNonClaudeDefaults must write resolve_model_ids'); // allow-test-rule: structural-implementation-guard (#2834) assert.ok(/defaults\.runtime/.test(fnBody), 'writeNonClaudeDefaults must write runtime'); // allow-test-rule: structural-implementation-guard (#2834) }); }); } // ──────────────────────────────────────────────────────────────────────── // Folded from tests/issue-2639-codex-toml-neutralization.test.cjs — consolidation epic #1969 (H3 W4 #3336) // ──────────────────────────────────────────────────────────────────────── { const { describe: __foldDescribe } = require('node:test'); __foldDescribe("folded:bug-2639-codex-toml-neutralization (consolidation epic #1969 H3 W4 #3336)", () => { /** * Regression: issue #2639 — Codex install generated agent TOMLs with stale * Claude-specific references (CLAUDE.md, .claude/skills/, .claudeignore). * * RCA: `installCodexConfig()` applied a narrow path-only regex pass before * calling `generateCodexAgentToml()`, bypassing the full * `convertClaudeToCodexMarkdown()` + `neutralizeAgentReferences(..., 'AGENTS.md')` * pipeline used on the .md emit path. Fix routes the TOML path through the * same pipeline and extends the pipeline to cover bare `.claude/skills/`, * `.claude/commands/`, `.claude/agents/`, and `.claudeignore`. * * Verified non-duplicate: the pre-existing 'generateCodexAgentToml' suite covers * model_overrides/sandbox_mode/reasoning-effort, not CLAUDE.md/.claudeignore/skills-path * neutralization in the emitted TOML; the '#570 — Codex leak scanner sub-bugs' suite * covers ~/.claude path leaks via convertClaudeToCodexMarkdown but not the * installCodexConfig()-level TOML-emit pipeline this regression targets. */ process.env.MSD_TEST_MODE = '1'; const { test, describe, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const os = require('os'); const { installCodexConfig } = require('../bin/install.js'); const { cleanup } = require('./helpers.cjs'); function makeTempDir() { return fs.mkdtempSync(path.join(os.tmpdir(), 'msd-2639-')); } function writeAgentFixture(agentsSrc, name, body) { const content = `--- name: ${name} description: Test agent for #2639 --- ${body} `; fs.writeFileSync(path.join(agentsSrc, `${name}.md`), content); } describe('#2639 — Codex TOML emit routes through full neutralization pipeline', () => { let tmpDir; let agentsSrc; let targetDir; beforeEach(() => { tmpDir = makeTempDir(); agentsSrc = path.join(tmpDir, 'agents'); targetDir = path.join(tmpDir, 'codex'); fs.mkdirSync(agentsSrc, { recursive: true }); fs.mkdirSync(targetDir, { recursive: true }); }); afterEach(() => { cleanup(tmpDir); }); test('strips CLAUDE.md, .claude/skills/, .claude/commands/, .claude/agents/, and .claudeignore from emitted TOML', () => { writeAgentFixture(agentsSrc, 'msd-code-reviewer', [ '**Project instructions:** Read `./CLAUDE.md` if it exists.', '', '**CLAUDE.md enforcement:** If `./CLAUDE.md` exists, treat it as hard constraints.', '', '**Project skills:** Check `.claude/skills/` or `.agents/skills/` directory.', '', 'Also check `.claude/commands/` and `.claude/agents/` for definitions.', '', 'DO respect .gitignore and .claudeignore. Do not review ignored files.', '', 'Claude will refuse the task if policy violated.', ].join('\n')); installCodexConfig(targetDir, agentsSrc); const tomlPath = path.join(targetDir, 'agents', 'msd-code-reviewer.toml'); assert.ok(fs.existsSync(tomlPath), 'per-agent TOML written'); const toml = fs.readFileSync(tomlPath, 'utf8'); assert.ok(!toml.includes('CLAUDE.md'), 'no CLAUDE.md references remain in TOML'); assert.ok(!toml.includes('.claude/skills/'), 'no .claude/skills/ references remain'); assert.ok(!toml.includes('.claude/commands/'), 'no .claude/commands/ references remain'); assert.ok(!toml.includes('.claude/agents/'), 'no .claude/agents/ references remain'); assert.ok(!toml.includes('.claudeignore'), 'no .claudeignore references remain'); assert.ok(toml.includes('AGENTS.md'), 'AGENTS.md substituted for CLAUDE.md'); assert.ok( toml.includes('.codex/skills/') || toml.includes('.agents/skills/'), 'skills path neutralized' ); // Standalone "Claude" agent-name references replaced assert.ok(!/\bClaude\b(?! Code| Opus| Sonnet| Haiku| native| based)/.test(toml), 'standalone Claude agent-name references replaced'); }); test('preserves Claude product/model names (Claude Code, Claude Opus) in TOML', () => { writeAgentFixture(agentsSrc, 'msd-executor', [ 'This agent runs under Claude Code with the Claude Opus 4 model.', 'Do not confuse with Claude Sonnet or Claude Haiku.', ].join('\n')); installCodexConfig(targetDir, agentsSrc); const toml = fs.readFileSync(path.join(targetDir, 'agents', 'msd-executor.toml'), 'utf8'); assert.ok(toml.includes('Claude Code'), 'Claude Code product name preserved'); assert.ok(toml.includes('Claude Opus'), 'Claude Opus model name preserved'); }); }); }); } // ─── Codex config.toml [features] safety (#1202) ───────────────────────────── describe('codex features section safety', () => { test('non-boolean keys under [features] are moved to top level', () => { // Simulate the bug from #1202: model = "gpt-5.4" under [features] // causes "invalid type: string, expected a boolean in features" const configContent = `[features]\ncodex_hooks = true\n\nmodel = "gpt-5.4"\nmodel_reasoning_effort = "medium"\n\n[agents.msd-executor]\ndescription = "test"\n`; const featuresMatch = configContent.match(/\[features\]\r?\n([\s\S]*?)(?=\n\[|$)/); assert.ok(featuresMatch, 'features section found'); const featuresBody = featuresMatch[1]; const nonBooleanKeys = featuresBody.split(/\r?\n/) .filter(line => line.match(/^\s*\w+\s*=/) && !line.match(/=\s*(true|false)\s*(#.*)?$/)) .map(line => line.trim()); assert.strictEqual(nonBooleanKeys.length, 2, 'should detect 2 non-boolean keys'); assert.ok(nonBooleanKeys.includes('model = "gpt-5.4"'), 'detects model key'); assert.ok(nonBooleanKeys.includes('model_reasoning_effort = "medium"'), 'detects model_reasoning_effort key'); }); test('boolean keys under [features] are NOT flagged', () => { const configContent = `[features]\ncodex_hooks = true\nmulti_agent = false\n`; const featuresMatch = configContent.match(/\[features\]\r?\n([\s\S]*?)(?=\n\[|$)/); const featuresBody = featuresMatch[1]; const nonBooleanKeys = featuresBody.split(/\r?\n/) .filter(line => line.match(/^\s*\w+\s*=/) && !line.match(/=\s*(true|false)\s*(#.*)?$/)) .map(line => line.trim()); assert.strictEqual(nonBooleanKeys.length, 0, 'no non-boolean keys in a clean config'); }); }); describe('Codex install hook configuration (e2e)', () => { let tmpDir; let codexHome; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-codex-e2e-')); codexHome = path.join(tmpDir, 'codex-home'); }); afterEach(() => { cleanup(tmpDir); }); test('Codex install copies hook file that is referenced in hooks.json (#2153)', () => { // Regression test: Codex install writes msd-check-update hook reference into // hooks.json and must also copy the hook file to ~/$CODEX_HOME/hooks/ runCodexInstall(codexHome); const configContent = readCodexConfig(codexHome); const parsedConfig = parseTomlToObject(configContent); assert.ok( !parsedConfig.hooks || !Array.isArray(parsedConfig.hooks.SessionStart), 'config.toml does not carry managed SessionStart hooks' ); const hooksJsonCommands = readHooksSessionStartCommands(codexHome); assert.equal( hooksJsonCommands.some((cmd) => cmd.includes('msd-check-update')), true, 'hooks.json references msd-check-update (.js on POSIX, .cmd on Windows)' ); // The hook file must physically exist at the referenced path const hookFile = path.join(codexHome, 'hooks', 'msd-check-update.js'); assert.ok( fs.existsSync(hookFile), `msd-check-update.js must exist at ${hookFile} — hooks.json references it (directly on POSIX, via .cmd shim on Windows) but file was not installed` ); }); test('fresh CODEX_HOME enables codex_hooks without draft root defaults', () => { runCodexInstall(codexHome); const content = readCodexConfig(codexHome); assert.ok(content.includes('[features]\nhooks = true\n'), 'writes codex_hooks feature'); const parsed = parseTomlToObject(content); assert.ok(!parsed.hooks || !Array.isArray(parsed.hooks.SessionStart), 'config.toml does not carry managed SessionStart hooks'); // #3017 / #3426: on POSIX the handler command uses the absolute Node binary path // "" "" // On Windows (#3426) a .cmd shim is written instead; the command in hooks.json // is the quoted .cmd path (no node runner prefix — cmd.exe executes .cmd natively). const hooksJsonCommands = readHooksSessionStartCommands(codexHome); const msdCommands = hooksJsonCommands.filter((cmd) => cmd.includes('msd-check-update')); assert.strictEqual(msdCommands.length, 1, 'writes one MSD update hook in hooks.json'); if (process.platform === 'win32') { // On Windows, the command is the .cmd shim path (quoted). const expectedCmdPath = path.join(codexHome, 'hooks', 'msd-check-update.cmd').replace(/\\/g, '/'); assert.strictEqual(msdCommands[0], JSON.stringify(expectedCmdPath), 'win32: handler command must be the .cmd shim path (#3426)'); } else { // On POSIX, the command is the node runner + .js hook path. const expectedRunner = JSON.parse(resolveNodeRunner()); const expectedHookPath = path.join(codexHome, 'hooks', 'msd-check-update.js').replace(/\\/g, '/'); const expectedCommand = `"${expectedRunner}" "${expectedHookPath}"`; assert.strictEqual(msdCommands[0], expectedCommand, 'handler command must use absolute node runner pointing at msd-check-update.js (#3017)'); } assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'writes one codex_hooks key'); assertNoDraftRootKeys(content); assertUsesOnlyEol(content, '\n'); }); test('#2406: config.toml carries no config_file entries — standalone agents/*.toml under CODEX_HOME are the sole canonical source', () => { runCodexInstall(codexHome); const content = readCodexConfig(codexHome); // config.toml previously carried a `config_file = "/.toml"` // line per role, pointing back at the standalone TOML Codex already // auto-discovers under $CODEX_HOME/agents/ — a second, duplicate // registration of the same role that produced one // "Ignoring malformed agent role definition: duplicate agent role name" // warning per agent. That line is gone entirely now. const configFileLines = content.split(/\r?\n/).filter(l => l.startsWith('config_file = ')); assert.deepStrictEqual(configFileLines, [], 'config.toml has zero config_file entries'); // The standalone per-agent TOMLs are still written under CODEX_HOME/agents/ // and are what Codex auto-discovers. const agentsDir = path.join(codexHome, 'agents'); const tomlFiles = fs.existsSync(agentsDir) ? fs.readdirSync(agentsDir).filter((f) => f.startsWith('msd-') && f.endsWith('.toml')) : []; assert.ok(tomlFiles.length > 0, 'standalone msd-*.toml files exist under CODEX_HOME/agents/'); }); test('re-install repairs non-boolean keys trapped under [features] by previous install (#1379)', () => { // Bug: a pre-#1346 install prepended [features] before bare top-level keys, // trapping model= under [features]. Re-installing with the fix must detect // and relocate those keys back to the top level so Codex can parse them. writeCodexConfig(codexHome, [ '[features]', 'codex_hooks = true', '', 'model = "gpt-5.3-codex"', 'model_reasoning_effort = "high"', '', '[projects."/Users/oltmannk/myproject"]', 'trust_level = "trusted"', '', ].join('\n')); runCodexInstall(codexHome); const content = readCodexConfig(codexHome); // model= and model_reasoning_effort= must NOT be under [features] const featuresIndex = content.indexOf('[features]'); const modelIndex = content.indexOf('model = "gpt-5.3-codex"'); const reasoningIndex = content.indexOf('model_reasoning_effort = "high"'); assert.ok(modelIndex !== -1, 'model key is present'); assert.ok(reasoningIndex !== -1, 'model_reasoning_effort key is present'); assert.ok(modelIndex < featuresIndex, 'model= relocated before [features]'); assert.ok(reasoningIndex < featuresIndex, 'model_reasoning_effort= relocated before [features]'); // [features] should only contain boolean keys const featuresMatch = content.match(/\[features\]\r?\n([\s\S]*?)(?=\n\[|$)/); assert.ok(featuresMatch, 'features section found'); const featuresBody = featuresMatch[1]; const nonBooleanKeys = featuresBody.split(/\r?\n/) .filter(line => line.match(/^\s*\w+\s*=/) && !line.match(/=\s*(true|false)\s*(#.*)?$/)); assert.strictEqual(nonBooleanKeys.length, 0, 'no non-boolean keys under [features]'); // User content preserved assert.ok(content.includes('[projects."/Users/oltmannk/myproject"]'), 'preserves project section'); assert.ok(content.includes('trust_level = "trusted"'), 'preserves project trust level'); assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'one codex_hooks key'); }); test('existing LF config without [features] gets one features block and preserves user content', () => { writeCodexConfig(codexHome, [ '# user comment', '[model]', 'name = "o3"', '', '[[hooks]]', 'event = "SessionStart"', 'command = "echo custom"', '', ].join('\n')); runCodexInstall(codexHome); const content = readCodexConfig(codexHome); assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'creates one [features] section'); assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'creates one codex_hooks key'); assert.ok(content.includes('# user comment'), 'preserves user comment'); assert.ok(content.includes('[model]\nname = "o3"'), 'preserves model section'); assert.ok(content.includes('command = "echo custom"'), 'preserves custom hook'); const hooksJsonCommands = readHooksSessionStartCommands(codexHome); const msdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('msd-check-update')); assert.strictEqual(msdEntries.length, 1, 'adds one MSD update hook in hooks.json'); assertNoDraftRootKeys(content); }); test('bare top-level keys are NOT trapped under [features] (#1202)', () => { // Real-world config: model= and model_reasoning_effort= at root level, // followed by [projects] section. MSD must not prepend [features] before // these keys, which would make Codex reject them as "expected a boolean". writeCodexConfig(codexHome, [ 'model = "gpt-5.4"', 'model_reasoning_effort = "high"', '', '[projects."/home/user/myproject"]', 'trust_level = "trusted"', '', ].join('\n')); runCodexInstall(codexHome); const content = readCodexConfig(codexHome); // [features] must come AFTER bare top-level keys const featuresIndex = content.indexOf('[features]'); const modelIndex = content.indexOf('model = "gpt-5.4"'); const reasoningIndex = content.indexOf('model_reasoning_effort = "high"'); assert.ok(modelIndex < featuresIndex, 'model= stays before [features]'); assert.ok(reasoningIndex < featuresIndex, 'model_reasoning_effort= stays before [features]'); // [features] should only contain boolean keys const featuresMatch = content.match(/\[features\]\r?\n([\s\S]*?)(?=\n\[|$)/); assert.ok(featuresMatch, 'features section found'); const featuresBody = featuresMatch[1]; const nonBooleanKeys = featuresBody.split(/\r?\n/) .filter(line => line.match(/^\s*\w+\s*=/) && !line.match(/=\s*(true|false)\s*(#.*)?$/)); assert.strictEqual(nonBooleanKeys.length, 0, 'no non-boolean keys under [features]'); // User content preserved assert.ok(content.includes('[projects."/home/user/myproject"]'), 'preserves project section'); assert.ok(content.includes('trust_level = "trusted"'), 'preserves project trust level'); }); test('existing CRLF config without [features] preserves CRLF and adds codex_hooks', () => { writeCodexConfig(codexHome, '# user comment\r\n[model]\r\nname = "o3"\r\n'); runCodexInstall(codexHome); const content = readCodexConfig(codexHome); assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'creates one [features] section'); assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'creates one codex_hooks key'); assert.ok(content.includes('# user comment'), 'preserves user comment'); assert.ok(content.includes('[model]\r\nname = "o3"'), 'preserves model section'); // [features] should be inserted between top-level lines and [model], not prepended const featuresIndex = content.indexOf('[features]'); const modelIndex = content.indexOf('[model]'); assert.ok(featuresIndex < modelIndex, '[features] comes before [model]'); assertUsesOnlyEol(content, '\r\n'); assertNoDraftRootKeys(content); }); test('existing CRLF [features] comment-only table gets codex_hooks without losing adjacent text', () => { writeCodexConfig(codexHome, [ '# user comment', '[features]', '# keep me', '', '[model]', 'name = "o3"', '', ].join('\r\n')); runCodexInstall(codexHome); const content = readCodexConfig(codexHome); assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section'); assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'adds one codex_hooks key'); assert.ok(content.includes('[features]\r\n# keep me\r\n\r\nhooks = true\r\n'), 'adds codex_hooks within comment-only table'); assert.ok(content.includes('[model]\r\nname = "o3"\r\n'), 'preserves following table'); assertUsesOnlyEol(content, '\r\n'); assertNoDraftRootKeys(content); }); test('existing [features] with trailing comment gets one codex_hooks without a second table', () => { writeCodexConfig(codexHome, [ '[features] # keep comment', 'other_feature = true', '', '[model]', 'name = "o3"', '', ].join('\n')); runCodexInstall(codexHome); const content = readCodexConfig(codexHome); assert.strictEqual(countMatches(content, /^\s*\[features\](?:\s*#.*)?$/gm), 1, 'keeps one commented [features] header'); assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'adds one codex_hooks key'); assert.ok(content.includes('[features] # keep comment\nother_feature = true'), 'preserves commented features table'); assert.ok(content.indexOf('hooks = true') > content.indexOf('[features] # keep comment'), 'adds codex_hooks within existing features table'); assert.ok(content.indexOf('hooks = true') < content.indexOf('[model]'), 'does not create a second features table before model'); assertNoDraftRootKeys(content); }); test('existing [features] at EOF without trailing newline is updated in place', () => { writeCodexConfig(codexHome, '[model]\nname = "o3"\n\n[features]'); runCodexInstall(codexHome); const content = readCodexConfig(codexHome); assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section'); assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'adds one codex_hooks key'); assert.ok(content.indexOf('hooks = true') > content.indexOf('[features]'), 'adds codex_hooks after the existing EOF features header'); // In this EOF-without-trailing-newline edge case, the pre-existing // [features] header has no blank-line boundary to close it, so the // appended MSD marker/ownership comment textually falls *inside* what // reads as the [features] section body, and `hooks = true` is inserted // at the end of that body — after the marker, not before it. That // ordering is unrelated to #2406 (verified unchanged against // origin/next's install.js) and #2406 removed the [agents.] role // tables that used to anchor this assertion, so anchor on the bare // [agents] dispatch-tuning table instead — codex_hooks always lands // before it. assert.ok(content.indexOf('hooks = true') < content.indexOf('[agents]'), 'keeps codex_hooks before the [agents] dispatch-tuning table'); assertNoDraftRootKeys(content); }); test('existing empty [features] and codex_hooks = false are normalized and remain idempotent', () => { writeCodexConfig(codexHome, [ '[features]', 'codex_hooks = false', 'other_feature = true', '', '[[hooks]]', 'event = "SessionStart"', 'command = "echo custom"', '', ].join('\n')); runCodexInstall(codexHome); runCodexInstall(codexHome); runCodexInstall(codexHome); const content = readCodexConfig(codexHome); assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section'); assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'normalizes to one codex_hooks = true'); assert.ok(!content.includes('codex_hooks = false'), 'removes false codex_hooks value'); assert.ok(content.includes('other_feature = true'), 'preserves other feature keys'); assert.ok(content.includes('command = "echo custom"'), 'preserves custom hook'); const hooksJsonCommands = readHooksSessionStartCommands(codexHome); const msdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('msd-check-update')); assert.strictEqual(msdEntries.length, 1, 'does not duplicate MSD update hook in hooks.json'); assertNoDraftRootKeys(content); }); test('quoted codex_hooks keys inside [features] are normalized without adding a bare duplicate', () => { writeCodexConfig(codexHome, [ '[features]', '"codex_hooks" = false', 'other_feature = true', '', ].join('\n')); runCodexInstall(codexHome); runCodexInstall(codexHome); const content = readCodexConfig(codexHome); assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section'); assert.strictEqual(countMatches(content, /^"codex_hooks" = true$/gm), 1, 'normalizes the quoted key to true'); assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 0, 'does not append a bare duplicate codex_hooks key'); assert.ok(content.includes('other_feature = true'), 'preserves other feature keys'); assertNoDraftRootKeys(content); }); test('quoted [features] headers are recognized as the existing features table', () => { writeCodexConfig(codexHome, [ '["features"]', '"codex_hooks" = false', 'other_feature = true', '', '[model]', 'name = "o3"', '', ].join('\n')); runCodexInstall(codexHome); runCodexInstall(codexHome); const content = readCodexConfig(codexHome); assert.strictEqual(countMatches(content, /^\[(?:"features"|'features'|features)\]\s*$/gm), 1, 'keeps one features table'); assert.strictEqual(countMatches(content, /^"codex_hooks" = true$/gm), 1, 'normalizes the quoted codex_hooks key to true'); assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 0, 'does not prepend a second bare features table'); assert.ok(content.includes('other_feature = true'), 'preserves existing feature keys'); const hooksJsonCommands = readHooksSessionStartCommands(codexHome); const msdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('msd-check-update')); assert.strictEqual(msdEntries.length, 1, 'keeps one MSD update hook in hooks.json'); assertNoDraftRootKeys(content); }); test('quoted table headers containing # are parsed without treating # as a comment start', () => { writeCodexConfig(codexHome, [ '[features."a#b"]', 'enabled = true', '', '[model]', 'name = "o3"', '', ].join('\n')); runCodexInstall(codexHome); runCodexInstall(codexHome); const content = readCodexConfig(codexHome); assert.ok(content.includes('[features."a#b"]\nenabled = true'), 'preserves the quoted nested features table'); assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'adds one real top-level features table'); assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'adds one codex_hooks key'); const hooksJsonCommands = readHooksSessionStartCommands(codexHome); const msdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('msd-check-update')); assert.strictEqual(msdEntries.length, 1, 'remains idempotent for the MSD hook block in hooks.json'); assertNoDraftRootKeys(content); }); test('existing dotted features config stays dotted and does not grow a [features] table', () => { writeCodexConfig(codexHome, [ 'features.other_feature = true', '', '[model]', 'name = "o3"', '', ].join('\n')); runCodexInstall(codexHome); runCodexInstall(codexHome); const content = readCodexConfig(codexHome); assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 0, 'does not add a [features] table'); assert.strictEqual(countMatches(content, /^features\.hooks = true$/gm), 1, 'adds one dotted codex_hooks key'); assert.ok(content.includes('features.other_feature = true'), 'preserves existing dotted features key'); const hooksJsonCommands = readHooksSessionStartCommands(codexHome); const msdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('msd-check-update')); assert.strictEqual(msdEntries.length, 1, 'adds one MSD update hook for dotted codex_hooks and remains idempotent'); assertNoDraftRootKeys(content); }); test('root inline-table features assignments are left untouched without appending invalid dotted keys or hooks', () => { writeCodexConfig(codexHome, [ 'features = { other_feature = true }', '', '[model]', 'name = "o3"', '', ].join('\n')); runCodexInstall(codexHome); runCodexInstall(codexHome); const content = readCodexConfig(codexHome); assert.ok(content.includes('features = { other_feature = true }'), 'preserves the root inline-table assignment'); assert.strictEqual(countMatches(content, /^features\.codex_hooks = true$/gm), 0, 'does not append an invalid dotted codex_hooks key'); assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 0, 'does not prepend a features table'); assert.strictEqual(countMatches(content, /msd-check-update\.js/g), 0, 'does not add the MSD hook block when codex_hooks cannot be enabled safely'); // #2406: config.toml no longer carries an [agents.] role table — // it still installs the managed [agents] dispatch-tuning block. assert.ok(content.includes(MSD_CODEX_MARKER), 'still installs the managed MSD block'); assert.ok(!content.includes('[agents.msd-executor]'), 'no agent role table (canonical source is the standalone TOML)'); assertNoDraftRootKeys(content); }); test('root scalar features assignments are left untouched without appending invalid dotted keys or hooks', () => { writeCodexConfig(codexHome, [ 'features = "disabled"', '', '[model]', 'name = "o3"', '', ].join('\n')); runCodexInstall(codexHome); runCodexInstall(codexHome); const content = readCodexConfig(codexHome); assert.ok(content.includes('features = "disabled"'), 'preserves the root scalar assignment'); assert.strictEqual(countMatches(content, /^features\.codex_hooks = true$/gm), 0, 'does not append an invalid dotted codex_hooks key'); assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 0, 'does not prepend a features table'); assert.strictEqual(countMatches(content, /msd-check-update\.js/g), 0, 'does not add the MSD hook block when codex_hooks cannot be enabled safely'); // #2406: config.toml no longer carries an [agents.] role table — // it still installs the managed [agents] dispatch-tuning block. assert.ok(content.includes(MSD_CODEX_MARKER), 'still installs the managed MSD block'); assert.ok(!content.includes('[agents.msd-executor]'), 'no agent role table (canonical source is the standalone TOML)'); assertNoDraftRootKeys(content); }); test('quoted dotted codex_hooks keys stay dotted and are normalized without duplication', () => { writeCodexConfig(codexHome, [ 'features."codex_hooks" = false', 'features.other_feature = true', '', '[model]', 'name = "o3"', '', ].join('\n')); runCodexInstall(codexHome); runCodexInstall(codexHome); const content = readCodexConfig(codexHome); assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 0, 'does not add a [features] table'); assert.strictEqual(countMatches(content, /^features\."codex_hooks" = true$/gm), 1, 'normalizes the quoted dotted key to true'); assert.strictEqual(countMatches(content, /^features\.codex_hooks = true$/gm), 0, 'does not append a bare dotted duplicate'); assert.ok(content.includes('features.other_feature = true'), 'preserves other dotted features keys'); const hooksJsonCommands = readHooksSessionStartCommands(codexHome); const msdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('msd-check-update')); assert.strictEqual(msdEntries.length, 1, 'adds one MSD update hook for quoted dotted codex_hooks and remains idempotent'); assertNoDraftRootKeys(content); }); test('multiline dotted features assignments insert codex_hooks after the full assignment block', () => { writeCodexConfig(codexHome, [ 'features.notes = """', 'keep-me', '"""', '', '[model]', 'name = "o3"', '', ].join('\n')); runCodexInstall(codexHome); runCodexInstall(codexHome); const content = readCodexConfig(codexHome); assert.ok(content.includes('features.notes = """\nkeep-me\n"""'), 'preserves the multiline dotted assignment'); assert.strictEqual(countMatches(content, /^features\.hooks = true$/gm), 1, 'adds one dotted codex_hooks key'); assert.ok(content.indexOf('features.hooks = true') > content.indexOf('"""'), 'inserts codex_hooks after the multiline assignment closes'); assert.ok(content.indexOf('features.hooks = true') < content.indexOf('[model]'), 'inserts codex_hooks before the next table'); assertNoDraftRootKeys(content); }); test('existing empty [features] table is populated with one codex_hooks key', () => { writeCodexConfig(codexHome, '[features]\r\n\r\n[model]\r\nname = "o3"\r\n'); runCodexInstall(codexHome); const content = readCodexConfig(codexHome); assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section'); assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'adds one codex_hooks key'); assert.ok(content.includes('[features]\r\n\r\nhooks = true\r\n'), 'adds codex_hooks to empty table'); assertUsesOnlyEol(content, '\r\n'); assertNoDraftRootKeys(content); }); test('multiline strings inside [features] do not create fake tables or fake codex_hooks matches', () => { writeCodexConfig(codexHome, [ '[features]', 'notes = \'\'\'', '[model]', 'codex_hooks = false', '\'\'\'', 'other_feature = true', '', '[[hooks]]', 'event = "AfterCommand"', 'command = "echo custom-after-command"', '', ].join('\n')); runCodexInstall(codexHome); const content = readCodexConfig(codexHome); assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section'); assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'adds a real codex_hooks key once'); assert.ok(content.includes('notes = \'\'\'\n[model]\ncodex_hooks = false\n\'\'\''), 'preserves multiline string content'); assert.strictEqual(countMatches(content, /^codex_hooks = false$/gm), 1, 'does not rewrite codex_hooks text inside multiline string'); assert.ok(content.indexOf('hooks = true') > content.indexOf('other_feature = true'), 'does not stop the features section at multiline string content'); // Parse structurally — verify codex_hooks and migrated AfterCommand hook via parsed object const parsed = parseTomlToObject(content); assert.equal(parsed.features?.hooks, true, 'writes a real hooks boolean key (#3566)'); assert.ok(Array.isArray(parsed.hooks?.AfterCommand), 'AfterCommand flat [[hooks]] migrated to namespaced AoT'); const afterCmds = parsed.hooks.AfterCommand.flatMap((entry) => Array.isArray(entry.hooks) ? entry.hooks.map((h) => h.command).filter(Boolean) : [] ); assert.ok(afterCmds.includes('echo custom-after-command'), 'preserves AfterCommand user hook command'); assertNoDraftRootKeys(content); }); test('non-boolean codex_hooks assignments are normalized to true without duplication', () => { writeCodexConfig(codexHome, [ '[features]', 'codex_hooks = "sometimes"', 'other_feature = true', '', '[model]', 'name = "o3"', '', ].join('\n')); runCodexInstall(codexHome); const content = readCodexConfig(codexHome); assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section'); assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'normalizes to one true value'); assert.ok(!content.includes('codex_hooks = "sometimes"'), 'removes non-boolean value'); assert.ok(content.includes('other_feature = true'), 'preserves other feature keys'); assertNoDraftRootKeys(content); }); test('multiline basic-string codex_hooks assignments are fully normalized without leaving trailing lines behind', () => { writeCodexConfig(codexHome, [ '[features]', 'codex_hooks = """', 'multiline-basic-sentinel', 'still-in-string', '"""', 'other_feature = true', '', '[model]', 'name = "o3"', '', ].join('\n')); runCodexInstall(codexHome); runCodexInstall(codexHome); const content = readCodexConfig(codexHome); assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'replaces the multiline basic-string assignment with one true value'); assert.ok(!content.includes('multiline-basic-sentinel'), 'removes multiline basic-string continuation lines'); assert.ok(content.includes('other_feature = true'), 'preserves following feature keys'); const hooksJsonCommands = readHooksSessionStartCommands(codexHome); const msdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('msd-check-update')); assert.strictEqual(msdEntries.length, 1, 'remains idempotent for the MSD hook block in hooks.json'); assertNoDraftRootKeys(content); }); test('multiline literal-string codex_hooks assignments are fully normalized without leaving trailing lines behind', () => { writeCodexConfig(codexHome, [ '[features]', 'codex_hooks = \'\'\'', 'multiline-literal-sentinel', 'still-in-literal', '\'\'\'', 'other_feature = true', '', '[model]', 'name = "o3"', '', ].join('\n')); runCodexInstall(codexHome); runCodexInstall(codexHome); const content = readCodexConfig(codexHome); assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'replaces the multiline literal-string assignment with one true value'); assert.ok(!content.includes('multiline-literal-sentinel'), 'removes multiline literal-string continuation lines'); assert.ok(content.includes('other_feature = true'), 'preserves following feature keys'); const hooksJsonCommands = readHooksSessionStartCommands(codexHome); const msdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('msd-check-update')); assert.strictEqual(msdEntries.length, 1, 'remains idempotent for the MSD hook block in hooks.json'); assertNoDraftRootKeys(content); }); test('multiline array codex_hooks assignments are fully normalized without leaving trailing lines behind', () => { writeCodexConfig(codexHome, [ '[features]', 'codex_hooks = [', ' "array-sentinel-1",', ' "array-sentinel-2",', ']', 'other_feature = true', '', '[model]', 'name = "o3"', '', ].join('\n')); runCodexInstall(codexHome); runCodexInstall(codexHome); const content = readCodexConfig(codexHome); assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'replaces the multiline array assignment with one true value'); assert.ok(!content.includes('array-sentinel-1'), 'removes multiline array continuation lines'); assert.ok(!content.includes('array-sentinel-2'), 'removes multiline array continuation lines'); assert.ok(content.includes('other_feature = true'), 'preserves following feature keys'); const hooksJsonCommands = readHooksSessionStartCommands(codexHome); const msdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('msd-check-update')); assert.strictEqual(msdEntries.length, 1, 'remains idempotent for the MSD hook block in hooks.json'); assertNoDraftRootKeys(content); }); test('triple-quoted codex_hooks values keep inline comments when normalized', () => { writeCodexConfig(codexHome, [ '[features]', 'codex_hooks = """sometimes""" # keep me', 'other_feature = true', '', '[model]', 'name = "o3"', '', ].join('\n')); runCodexInstall(codexHome); const content = readCodexConfig(codexHome); assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section'); assert.strictEqual(countMatches(content, /^codex_hooks = true # keep me$/gm), 1, 'normalizes to true and preserves inline comment'); assert.ok(!content.includes('"""sometimes"""'), 'removes the old triple-quoted value'); assert.ok(content.includes('other_feature = true'), 'preserves other feature keys'); assertNoDraftRootKeys(content); }); test('existing CRLF codex_hooks = true stays single and preserves non-MSD hooks', () => { writeCodexConfig(codexHome, [ '[features]', 'codex_hooks = true', 'other_feature = true', '', '[[hooks]]', 'event = "AfterCommand"', 'command = "echo custom-after-command"', '', ].join('\r\n')); runCodexInstall(codexHome); runCodexInstall(codexHome); const content = readCodexConfig(codexHome); assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section'); assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'keeps one codex_hooks = true'); assert.ok(content.includes('other_feature = true'), 'preserves other feature keys'); assert.strictEqual(countMatches(content, /echo custom-after-command/g), 1, 'preserves non-MSD hook exactly once'); const hooksJsonCommands = readHooksSessionStartCommands(codexHome); const msdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('msd-check-update')); assert.strictEqual(msdEntries.length, 1, 'keeps one MSD update hook in hooks.json'); assertUsesOnlyEol(content, '\r\n'); assertNoDraftRootKeys(content); }); test('codex_hooks = true with an inline comment is treated as enabled for hook installation', () => { writeCodexConfig(codexHome, [ '[features]', 'codex_hooks = true # keep me', 'other_feature = true', '', '[model]', 'name = "o3"', '', ].join('\n')); runCodexInstall(codexHome); runCodexInstall(codexHome); const content = readCodexConfig(codexHome); assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section'); assert.strictEqual(countMatches(content, /^codex_hooks = true # keep me$/gm), 1, 'preserves the commented true value'); assert.ok(content.includes('other_feature = true'), 'preserves other feature keys'); const hooksJsonCommands = readHooksSessionStartCommands(codexHome); const msdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('msd-check-update')); assert.strictEqual(msdEntries.length, 1, 'adds the MSD update hook once in hooks.json'); assertNoDraftRootKeys(content); }); test('mixed-EOL configs use the first newline style for inserted Codex content', () => { writeCodexConfig(codexHome, '# first line wins\n[model]\r\nname = "o3"\r\n'); runCodexInstall(codexHome); runCodexInstall(codexHome); const content = readCodexConfig(codexHome); // [features] is inserted after top-level lines, before [model] — not prepended assert.ok(content.includes('# first line wins\n\n[features]\nhooks = true\n'), 'inserts features after top-level lines using first newline style'); assert.ok(content.includes(`# MSD Agent Configuration — managed by msd-core installer\n`), 'writes the managed agent block using the first newline style'); // Structural check: managed SessionStart hooks live in hooks.json. const parsedMixed = parseTomlToObject(content); assert.ok(!parsedMixed.hooks || !Array.isArray(parsedMixed.hooks.SessionStart), 'does not write managed SessionStart hooks to config.toml'); const hooksJsonCommands = readHooksSessionStartCommands(codexHome); const msdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('msd-check-update')); assert.strictEqual(msdEntries.length, 1, 'writes one managed SessionStart hook to hooks.json'); assert.ok(content.includes('[model]\r\nname = "o3"'), 'preserves the existing CRLF model lines'); assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'remains idempotent on repeated installs'); assertNoDraftRootKeys(content); }); }); // ──────────────────────────────────────────────────────────────────────── // Folded from tests/issue-2695-codex-hook-set.test.cjs — consolidation epic #1969 (H3 W4 #3336) // ──────────────────────────────────────────────────────────────────────── { const { describe: __foldDescribe } = require('node:test'); __foldDescribe("folded:bug-2695-codex-hook-set (consolidation epic #1969 H3 W4 #3336)", () => { // Regression tests for #2695 — Codex native updates omit the update-hook worker // and the managed-hooks registry. // // The Codex install branch in bin/install.js used to allowlist only two of the // four hook files the shipped build emitted at the time (msd-check-update.js + // msd-context-monitor.js — the latter permanently removed by #2586, see below), // and gated the entire branch on !isMinimalMode so the // `core` profile installed none of them. The parent SessionStart hook spawn()s // the worker, which require()s the registry — so Codex was wired to a dependency // chain the same installer never delivered. // // These tests drive the real installer (bin/install.js) behaviorally into an // isolated temp config dir and assert the complete three-file set is delivered // for both profiles, the registry is byte-for-byte, the version stamps resolve // to the installed package version, and unrelated user files are preserved. // // #2586 reduced the set back to three: msd-context-monitor.js read a Claude-only // statusline bridge file Codex never writes, so it was a guaranteed silent no-op // on every Codex hook event and was dropped from CODEX_HOOKS_TO_COPY for good. // // Verified non-duplicate: the pre-existing 'Codex install hook configuration // (e2e)' suite above only asserts msd-check-update.js delivery/wiring — it never // asserts on msd-check-update-worker.js, managed-hooks-registry.cjs, the // core/full profile matrix, upgrade-refresh, byte-for-byte registry copy, // idempotency of the three-file set, user-file preservation, or the // core-profile negative-space (no agent files) — all genuinely distinct // assertions this fold adds. 'use strict'; const { test, describe, before } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); const { runNode } = require('./helpers/process-seam.cjs'); const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const { cleanup } = require('./helpers.cjs'); const { INSTALL_SCRIPT, BUILD_SCRIPT, HOOKS_DIST, installerEnv, } = require('./helpers/install-shared.cjs'); const PKG_VERSION = require('../package.json').version; // #3145: class-norm timeouts, not per-suite values — see helpers/timeouts.cjs. const { BUILD_TIMEOUT_MS: BUILD_HOOKS_TIMEOUT_MS, INSTALL_TIMEOUT_MS, } = require('./helpers/timeouts.cjs'); // The three-file hook set the Codex surface must deliver together (#2695). // msd-context-monitor.js was removed from this set by #2586: it read a // Claude-only statusline bridge file Codex never writes, so it was a // guaranteed silent no-op on every Codex hook event. const CODEX_HOOK_FILES = [ 'msd-check-update.js', 'msd-check-update-worker.js', 'managed-hooks-registry.cjs', ]; // Build hooks/dist before any install runs (the installer copies from there). before(() => { const r = runNode([BUILD_SCRIPT], { timeoutMs: BUILD_HOOKS_TIMEOUT_MS }); throwIfFailed(r, `node ${BUILD_SCRIPT}`); }); function hooksDirOf(configDir) { return path.join(configDir, 'hooks'); } /** Run the Codex installer into an isolated temp config dir. */ function runCodexInstall({ profile, preseed }) { const configDir = fs.mkdtempSync(path.join(os.tmpdir(), `msd-2695-${profile}-`)); if (preseed) { const hooksDest = hooksDirOf(configDir); fs.mkdirSync(hooksDest, { recursive: true }); for (const [name, body] of Object.entries(preseed)) { fs.writeFileSync(path.join(hooksDest, name), body); } } // Sandbox HOME/USERPROFILE to configDir: Codex's skills-kind `home: ".agents"` // override resolves via os.homedir(); sandboxing keeps the spawn self-contained // (mirrors tests/install-minimal-hooks.test.cjs Codex downgrade test). const result = runNode( [INSTALL_SCRIPT, '--codex', '--global', '--config-dir', configDir, `--profile=${profile}`], { env: installerEnv({ HOME: configDir, USERPROFILE: configDir }), timeoutMs: INSTALL_TIMEOUT_MS }, ); return { configDir, result }; } // Older-version stamp used to pre-seed an "upgrade" scenario. const OLDER_VERSION = '1.7.0'; describe('#2695: fresh Codex installs deliver the complete three-file hook set', () => { for (const profile of ['core', 'full']) { test(`fresh --profile=${profile} installs all three hook files`, (t) => { const { configDir, result } = runCodexInstall({ profile }); t.after(() => cleanup(configDir)); const hooksDir = hooksDirOf(configDir); for (const file of CODEX_HOOK_FILES) { assert.ok( fs.existsSync(path.join(hooksDir, file)), `expected ${file} under /hooks for --profile=${profile}\n` + `installer stdout: ${result.stdout}\ninstaller stderr: ${result.stderr}`, ); } }); } }); describe('#2695: Codex upgrades refresh all three hook files to the current version', () => { // Pre-seed all three files stamped at OLDER_VERSION so an upgrade must overwrite them. function olderSeed() { const seed = {}; for (const name of CODEX_HOOK_FILES) { // Registry carries no version token; seed it with a stale sentinel body. if (name.endsWith('.cjs')) { seed[name] = `// stale registry ${OLDER_VERSION}\nmodule.exports = {};\n`; } else { seed[name] = `// msd-hook-version: ${OLDER_VERSION}\n// stale\n`; } } return seed; } for (const profile of ['core', 'full']) { test(`--profile=${profile} upgrade refreshes all three hook files`, (t) => { const { configDir, result } = runCodexInstall({ profile, preseed: olderSeed() }); t.after(() => cleanup(configDir)); const hooksDir = hooksDirOf(configDir); // All three must now carry the current version stamp where one exists, and // the registry must no longer be the stale sentinel. for (const name of CODEX_HOOK_FILES) { const dest = path.join(hooksDir, name); assert.ok( fs.existsSync(dest), `expected refreshed ${name} for --profile=${profile}\n` + `installer stdout: ${result.stdout}\ninstaller stderr: ${result.stderr}`, ); } // The registry must be REFRESHED on upgrade, not merely present: assert it no // longer carries the stale sentinel and now matches the shipped dist byte-for-byte // (the raw-copy fallback must overwrite an existing dest, not skip it). const registryDest = path.join(hooksDir, 'managed-hooks-registry.cjs'); const registryBytes = fs.readFileSync(registryDest, 'utf8'); assert.ok( !registryBytes.includes(`stale registry ${OLDER_VERSION}`), `registry must be refreshed on upgrade for --profile=${profile} (still carries the stale sentinel)`, ); assert.deepStrictEqual( fs.readFileSync(registryDest), fs.readFileSync(path.join(HOOKS_DIST, 'managed-hooks-registry.cjs')), `refreshed registry must match hooks/dist byte-for-byte for --profile=${profile}`, ); // Version stamps resolved (acceptance #2/#3). const workerStamp = readHookVersionLine(path.join(hooksDir, 'msd-check-update-worker.js')); assert.strictEqual( workerStamp, PKG_VERSION, `worker msd-hook-version stamp must be the installed package version (${PKG_VERSION}), ` + `got "${workerStamp}" for --profile=${profile}`, ); const parentStamp = readHookVersionLine(path.join(hooksDir, 'msd-check-update.js')); assert.strictEqual( parentStamp, PKG_VERSION, `parent msd-check-update stamp must be the installed package version (${PKG_VERSION}), ` + `got "${parentStamp}" for --profile=${profile}`, ); }); } }); describe('#2695: managed-hooks-registry.cjs is copied byte-for-byte', () => { for (const profile of ['core', 'full']) { test(`--profile=${profile} registry matches hooks/dist byte-for-byte`, (t) => { const { configDir, result } = runCodexInstall({ profile }); t.after(() => cleanup(configDir)); const dest = path.join(hooksDirOf(configDir), 'managed-hooks-registry.cjs'); assert.ok(fs.existsSync(dest), `registry missing for --profile=${profile}\nstdout: ${result.stdout}`); const distBytes = fs.readFileSync(path.join(HOOKS_DIST, 'managed-hooks-registry.cjs')); const destBytes = fs.readFileSync(dest); assert.deepStrictEqual( destBytes, distBytes, `managed-hooks-registry.cjs must be copied byte-for-byte (no version/path transform) for --profile=${profile}`, ); }); } }); describe('#2695: worker hook-version stamp is a literal install-time value', () => { test('the stamp is the literal package version, never a placeholder or a runtime lookup', (t) => { const { configDir } = runCodexInstall({ profile: 'full' }); t.after(() => cleanup(configDir)); const workerPath = path.join(hooksDirOf(configDir), 'msd-check-update-worker.js'); const content = fs.readFileSync(workerPath, 'utf8'); // The placeholder must have been replaced — a leftover {{MSD_VERSION}} is the bug shape. assert.ok( !content.includes('{{MSD_VERSION}}'), 'worker still carries an unresolved {{MSD_VERSION}} placeholder — stamping did not run', ); // And the resolved value must be the literal version, present on the version-comment line. const stamp = readHookVersionLine(workerPath); assert.strictEqual(stamp, PKG_VERSION, `worker stamp must equal package.json version, got "${stamp}"`); }); }); describe('#2695: unrelated user-owned hook files are preserved', () => { for (const profile of ['core', 'full']) { test(`--profile=${profile} leaves a pre-existing user hook untouched`, (t) => { const userOwned = 'my-custom-hook.js'; const userBody = '// user-owned hook — do not touch\nconsole.log("mine");\n'; const { configDir, result } = runCodexInstall({ profile, preseed: { [userOwned]: userBody } }); t.after(() => cleanup(configDir)); const dest = path.join(hooksDirOf(configDir), userOwned); assert.ok(fs.existsSync(dest), `user-owned ${userOwned} must be preserved for --profile=${profile}\nstdout: ${result.stdout}`); assert.strictEqual( fs.readFileSync(dest, 'utf8'), userBody, `user-owned ${userOwned} bytes must be unchanged for --profile=${profile}`, ); }); } }); describe('#2695: re-running the installer is idempotent for the three-file set', () => { test('a second full install leaves all three files present and correctly stamped', (t) => { const first = runCodexInstall({ profile: 'full' }); t.after(() => cleanup(first.configDir)); // Second run into the SAME config dir. const result2 = runNode( [INSTALL_SCRIPT, '--codex', '--global', '--config-dir', first.configDir, '--profile=full'], { env: installerEnv({ HOME: first.configDir, USERPROFILE: first.configDir }), timeoutMs: INSTALL_TIMEOUT_MS }, ); assert.ok(result2.stdout || result2.stderr); const hooksDir = hooksDirOf(first.configDir); for (const name of CODEX_HOOK_FILES) { assert.ok(fs.existsSync(path.join(hooksDir, name)), `${name} must survive a second install`); } assert.strictEqual( readHookVersionLine(path.join(hooksDir, 'msd-check-update-worker.js')), PKG_VERSION, 'worker stamp must remain correct after a second install', ); }); }); describe('#2695: the core profile enables the hook feature and wires SessionStart (intended)', () => { // For the update-check/context-monitor hooks to actually fire, Codex needs both // the feature flag in config.toml AND the hooks.json routing — copying inert // files alone would leave `core` with scripts Codex never invokes. Entering the // codex-toml branch for `core` (the #2695 gate change) synthesizes `[features] // hooks = true` via ensureCodexHooksFeature, writes config.toml, and registers // the hooks. This is the intended behavior of the fix, not a side effect — these // assertions pin it so a future re-gating cannot silently regress it. test('--profile=core writes config.toml enabling the hooks feature', (t) => { const { configDir } = runCodexInstall({ profile: 'core' }); t.after(() => cleanup(configDir)); const configPath = path.join(configDir, 'config.toml'); assert.ok(fs.existsSync(configPath), 'core must write config.toml so the hooks feature is enabled'); const config = fs.readFileSync(configPath, 'utf8'); assert.ok(/^\s*hooks\s*=\s*true\s*$/m.test(config), 'config.toml must enable hooks = true for core'); }); test('--profile=core wires the SessionStart update-check hook in hooks.json', (t) => { const { configDir } = runCodexInstall({ profile: 'core' }); t.after(() => cleanup(configDir)); const hooksJsonPath = path.join(configDir, 'hooks.json'); assert.ok(fs.existsSync(hooksJsonPath), 'core must write hooks.json'); const hooksJson = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); const sessionStartCmds = collectHookCommands(hooksJson, 'SessionStart'); // The command points at the msd-check-update hook script. Its extension is // platform-specific — Windows routes through a .cmd shim, POSIX through .js — // so assert on the basename prefix, not a hardcoded extension (Windows parity). const routedToUpdateHook = sessionStartCmds.some((c) => { const token = c.replace(/"/g, '').replace(/\\/g, '/'); const segs = token.split('/'); const last = segs[segs.length - 1]; return last.startsWith('msd-check-update.'); }); assert.ok( routedToUpdateHook, `core must route SessionStart to the msd-check-update hook in hooks.json; got: ${JSON.stringify(sessionStartCmds)}`, ); }); }); describe('#2695: the core profile still installs no agent files (negative space)', () => { test('--profile=core delivers hooks but no msd-* agent files', (t) => { const { configDir } = runCodexInstall({ profile: 'core' }); t.after(() => cleanup(configDir)); // Hooks delivered (the fix)… for (const name of CODEX_HOOK_FILES) { assert.ok(fs.existsSync(path.join(hooksDirOf(configDir), name)), `${name} delivered for core`); } // …but the full agent surface is still absent (core stays minimal). Codex agents // are .toml ([agents.msd-*] in config.toml + agents/msd-*.toml), so check both // extensions — a .md-only filter would miss a Codex agent-surface regression. const agentsDir = path.join(configDir, 'agents'); if (fs.existsSync(agentsDir)) { const msdAgents = fs.readdirSync(agentsDir).filter( (f) => f.startsWith('msd-') && (f.endsWith('.md') || f.endsWith('.toml')), ); assert.deepStrictEqual(msdAgents, [], 'core must not install the full agent surface'); } // And config.toml must carry no agent role sections. const configPath = path.join(configDir, 'config.toml'); if (fs.existsSync(configPath)) { const config = fs.readFileSync(configPath, 'utf8'); assert.ok( !/^\[agents\.msd-/m.test(config), 'core config.toml must not declare [agents.msd-*] roles (full agent surface stays a full-profile concern)', ); } }); }); /** * Read the `// msd-hook-version: ` comment value from a hook file. * Returns the trimmed literal. Used so tests assert on the structured stamp, * not on raw `.includes()` prose (CONTRIBUTING raw-text-matching rule). */ function readHookVersionLine(hookPath) { const content = fs.readFileSync(hookPath, 'utf8'); const m = content.match(/^\/\/ msd-hook-version:\s*(.+?)\s*$/m); return m ? m[1] : null; } /** * Collect every hook command string registered under a given Codex hooks.json * event key. Used so the SessionStart-wiring test asserts on the structured * hook entries (commands), not on raw text matching against the whole file. */ function collectHookCommands(hooksJson, eventName) { const entries = (hooksJson && hooksJson.hooks && Array.isArray(hooksJson.hooks[eventName])) ? hooksJson.hooks[eventName] : []; return entries.flatMap((entry) => (entry && Array.isArray(entry.hooks) ? entry.hooks : []) .map((h) => (h && typeof h.command === 'string' ? h.command : null)) .filter(Boolean), ); } }); } describe('Codex uninstall symmetry for hook-enabled configs', () => { let tmpDir; let codexHome; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-codex-uninstall-')); codexHome = path.join(tmpDir, 'codex-home'); }); afterEach(() => { cleanup(tmpDir); }); test('fresh install removes the MSD-added codex_hooks feature on uninstall', () => { runCodexInstall(codexHome); const cleaned = stripMsdFromCodexConfig(readCodexConfig(codexHome)); assert.strictEqual(cleaned, null, 'fresh MSD-only config strips back to nothing'); }); test('install then uninstall removes [features].codex_hooks while preserving other feature keys, comments, hooks, and CRLF', () => { writeCodexConfig(codexHome, [ '[features]', '# keep me', 'other_feature = true', '', '[[hooks]]', 'event = "AfterCommand"', 'command = "echo custom-after-command"', '', '[model]', 'name = "o3"', '', ].join('\r\n')); runCodexInstall(codexHome); const cleaned = stripMsdFromCodexConfig(readCodexConfig(codexHome)); assert.ok(cleaned, 'preserves user config after uninstall cleanup'); assert.strictEqual(countMatches(cleaned, /^\[features\](?:\s*#.*)?$/gm), 1, 'keeps the existing features table'); assert.strictEqual(countMatches(cleaned, /^codex_hooks = true$/gm), 0, 'removes the MSD-added codex_hooks key'); assert.ok(cleaned.includes('# keep me'), 'preserves user comments in [features]'); assert.ok(cleaned.includes('other_feature = true'), 'preserves other feature keys'); assert.strictEqual(countMatches(cleaned, /echo custom-after-command/g), 1, 'preserves non-MSD hooks'); assert.strictEqual(countMatches(cleaned, /msd-check-update\.js/g), 0, 'removes only the MSD update hook'); assert.strictEqual(countMatches(cleaned, /\[agents\.msd-/g), 0, 'removes managed MSD agent sections'); assertUsesOnlyEol(cleaned, '\r\n'); }); test('install then uninstall removes dotted features.codex_hooks without creating a [features] table', () => { writeCodexConfig(codexHome, [ 'features.other_feature = true', '', '[[hooks]]', 'event = "AfterCommand"', 'command = "echo custom-after-command"', '', '[model]', 'name = "o3"', '', ].join('\n')); runCodexInstall(codexHome); const cleaned = stripMsdFromCodexConfig(readCodexConfig(codexHome)); assert.ok(cleaned.includes('features.other_feature = true'), 'preserves other dotted feature keys'); assert.strictEqual(countMatches(cleaned, /^features\.codex_hooks = true$/gm), 0, 'removes the dotted MSD codex_hooks key'); assert.strictEqual(countMatches(cleaned, /^\[features\]\s*$/gm), 0, 'does not leave behind a [features] table'); assert.strictEqual(countMatches(cleaned, /echo custom-after-command/g), 1, 'preserves non-MSD hooks'); assert.strictEqual(countMatches(cleaned, /msd-check-update\.js/g), 0, 'removes the MSD update hook'); }); test('install then uninstall preserves a pre-existing [features].codex_hooks = true', () => { writeCodexConfig(codexHome, [ '[features]', 'codex_hooks = true', 'other_feature = true', '', '[model]', 'name = "o3"', '', ].join('\n')); runCodexInstall(codexHome); const cleaned = stripMsdFromCodexConfig(readCodexConfig(codexHome)); assert.ok(cleaned.includes('[features]\ncodex_hooks = true\nother_feature = true'), 'preserves the user-authored codex_hooks assignment'); assert.strictEqual(countMatches(cleaned, /^codex_hooks = true$/gm), 1, 'keeps the pre-existing codex_hooks key'); assert.strictEqual(countMatches(cleaned, /msd-check-update\.js/g), 0, 'removes the MSD update hook'); assert.strictEqual(countMatches(cleaned, /\[agents\.msd-/g), 0, 'removes managed MSD agent sections'); }); test('install then uninstall preserves a pre-existing quoted [features]."codex_hooks" = true', () => { writeCodexConfig(codexHome, [ '[features]', '"codex_hooks" = true', 'other_feature = true', '', '[model]', 'name = "o3"', '', ].join('\n')); runCodexInstall(codexHome); const cleaned = stripMsdFromCodexConfig(readCodexConfig(codexHome)); assert.ok(cleaned.includes('[features]\n"codex_hooks" = true\nother_feature = true'), 'preserves the user-authored quoted codex_hooks assignment'); assert.strictEqual(countMatches(cleaned, /^"codex_hooks" = true$/gm), 1, 'keeps the pre-existing quoted codex_hooks key'); assert.strictEqual(countMatches(cleaned, /msd-check-update\.js/g), 0, 'removes the MSD update hook'); assert.strictEqual(countMatches(cleaned, /\[agents\.msd-/g), 0, 'removes managed MSD agent sections'); }); test('install then uninstall preserves a pre-existing root dotted features.codex_hooks = true', () => { writeCodexConfig(codexHome, [ 'features.codex_hooks = true', 'features.other_feature = true', '', '[model]', 'name = "o3"', '', ].join('\n')); runCodexInstall(codexHome); const cleaned = stripMsdFromCodexConfig(readCodexConfig(codexHome)); assert.ok(cleaned.includes('features.codex_hooks = true\nfeatures.other_feature = true'), 'preserves the user-authored dotted codex_hooks assignment'); assert.strictEqual(countMatches(cleaned, /^features\.codex_hooks = true$/gm), 1, 'keeps the pre-existing dotted codex_hooks key'); assert.strictEqual(countMatches(cleaned, /msd-check-update\.js/g), 0, 'removes the MSD update hook'); assert.strictEqual(countMatches(cleaned, /\[agents\.msd-/g), 0, 'removes managed MSD agent sections'); }); test('install then uninstall leaves short-circuited root features assignments untouched', () => { const cases = [ 'features = { other_feature = true }\n\n[model]\nname = "o3"\n', 'features = "disabled"\n\n[model]\nname = "o3"\n', ]; for (const initialContent of cases) { writeCodexConfig(codexHome, initialContent); runCodexInstall(codexHome); const cleaned = stripMsdFromCodexConfig(readCodexConfig(codexHome)); assert.strictEqual(cleaned, initialContent, `preserves short-circuited root features assignment: ${initialContent.split(/\r?\n/)[0]}`); cleanup(codexHome); fs.mkdirSync(codexHome, { recursive: true }); } }); test('install then uninstall keeps mixed-EOL user content stable while removing MSD hook state', () => { const initialContent = [ '# first line wins', '[features]', 'other_feature = true', '', '[model]', 'name = "o3"', '', ].join('\r\n').replace(/^# first line wins\r\r?\n/, '# first line wins\n'); writeCodexConfig(codexHome, initialContent); runCodexInstall(codexHome); const cleaned = stripMsdFromCodexConfig(readCodexConfig(codexHome)); assert.ok(cleaned.includes('# first line wins\n[features]\r\nother_feature = true\r\n\r\n[model]\r\nname = "o3"'), 'preserves the original mixed-EOL user content'); assert.strictEqual(countMatches(cleaned, /^codex_hooks = true$/gm), 0, 'removes the injected codex_hooks key'); assert.strictEqual(countMatches(cleaned, /msd-check-update\.js/g), 0, 'removes the MSD update hook'); assert.strictEqual(countMatches(cleaned, /\[agents\.msd-/g), 0, 'removes managed MSD agent sections'); }); }); // ─── #1326: cleanupCodexSkillMetadataSidecars (replaces #774 writeCodexSkillMetadataFiles) ── describe('cleanupCodexSkillMetadataSidecars (#1326)', () => { let tmpDir; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-test-sidecar-cleanup-')); }); afterEach(() => { cleanup(tmpDir); }); test('Codex install does not emit managed agents/openai.yaml sidecars and removes stale ones (#1326)', () => { // msd-foo: managed skill with stale sidecar → sidecar removed, empty agents/ pruned const fooAgents = path.join(tmpDir, 'msd-foo', 'agents'); fs.mkdirSync(fooAgents, { recursive: true }); fs.writeFileSync(path.join(tmpDir, 'msd-foo', 'SKILL.md'), '---\nname: msd-foo\n---\nBody.\n'); fs.writeFileSync(path.join(fooAgents, 'openai.yaml'), 'interface:\n display_name: "foo"\n'); // msd-dev-preferences: user-owned → sidecar PRESERVED const prefAgents = path.join(tmpDir, 'msd-dev-preferences', 'agents'); fs.mkdirSync(prefAgents, { recursive: true }); fs.writeFileSync(path.join(tmpDir, 'msd-dev-preferences', 'SKILL.md'), '---\nname: msd-dev-preferences\n---\nBody.\n'); const userYaml = 'interface:\n display_name: "my prefs"\n short_description: "User-authored"\n'; fs.writeFileSync(path.join(prefAgents, 'openai.yaml'), userYaml); // msd-bar: managed skill with sidecar + another file in agents/ → sidecar removed, agents/ kept (has other.txt) const barAgents = path.join(tmpDir, 'msd-bar', 'agents'); fs.mkdirSync(barAgents, { recursive: true }); fs.writeFileSync(path.join(tmpDir, 'msd-bar', 'SKILL.md'), '---\nname: msd-bar\n---\nBody.\n'); fs.writeFileSync(path.join(barAgents, 'openai.yaml'), 'interface:\n display_name: "bar"\n'); fs.writeFileSync(path.join(barAgents, 'other.txt'), 'some other content\n'); // helper: non-msd dir with openai.yaml → UNTOUCHED const helperAgents = path.join(tmpDir, 'helper', 'agents'); fs.mkdirSync(helperAgents, { recursive: true }); fs.writeFileSync(path.join(helperAgents, 'openai.yaml'), 'interface:\n display_name: "helper"\n'); cleanupCodexSkillMetadataSidecars(tmpDir); // msd-foo: sidecar removed and empty agents/ pruned assert.ok(!fs.existsSync(path.join(fooAgents, 'openai.yaml')), 'msd-foo/agents/openai.yaml must be removed (managed stale sidecar)'); assert.ok(!fs.existsSync(fooAgents), 'msd-foo/agents/ must be pruned when empty after sidecar removal'); // msd-dev-preferences: user-owned, sidecar preserved assert.ok(fs.existsSync(path.join(prefAgents, 'openai.yaml')), 'msd-dev-preferences/agents/openai.yaml must be preserved (user-owned)'); assert.strictEqual(fs.readFileSync(path.join(prefAgents, 'openai.yaml'), 'utf8'), userYaml, 'msd-dev-preferences/agents/openai.yaml content must be unchanged'); // msd-bar: sidecar removed but agents/ kept (still has other.txt) assert.ok(!fs.existsSync(path.join(barAgents, 'openai.yaml')), 'msd-bar/agents/openai.yaml must be removed'); assert.ok(fs.existsSync(barAgents), 'msd-bar/agents/ must NOT be pruned (still contains other.txt)'); assert.ok(fs.existsSync(path.join(barAgents, 'other.txt')), 'msd-bar/agents/other.txt must be preserved'); // helper: non-msd dir untouched assert.ok(fs.existsSync(path.join(helperAgents, 'openai.yaml')), 'helper/agents/openai.yaml must be untouched (non-msd dir)'); }); test('is a no-op when skillsDir does not exist (#1326)', () => { assert.doesNotThrow(() => { cleanupCodexSkillMetadataSidecars(path.join(tmpDir, 'nonexistent')); }, 'must not throw when skillsDir does not exist'); }); test('is a no-op for managed msd-* dirs with no agents/openai.yaml (#1326)', () => { // No sidecar present — should not throw, should not create anything const skillDir = path.join(tmpDir, 'msd-baz'); fs.mkdirSync(skillDir, { recursive: true }); fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '---\nname: msd-baz\n---\nBody.\n'); assert.doesNotThrow(() => { cleanupCodexSkillMetadataSidecars(tmpDir); }, 'must not throw when no sidecar exists'); assert.ok(!fs.existsSync(path.join(skillDir, 'agents')), 'must not create agents/ dir when no sidecar was present'); }); test('does not delete through a symlinked agents/ directory (#1326)', { skip: process.platform === 'win32' }, () => { // Setup: a skills dir with msd-foo/ whose agents/ is a SYMLINK to an external dir. // The cleanup must not delete files through the symlink. const externalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-test-symlink-ext-')); try { // Place openai.yaml and a sentinel in the external dir. fs.writeFileSync(path.join(externalDir, 'openai.yaml'), 'interface:\n display_name: "external"\n'); fs.writeFileSync(path.join(externalDir, 'keep.txt'), 'sentinel\n'); // Create msd-foo/ in the skills dir and make agents/ a symlink to externalDir. const skillDir = path.join(tmpDir, 'msd-foo'); fs.mkdirSync(skillDir, { recursive: true }); const agentsLink = path.join(skillDir, 'agents'); fs.symlinkSync(externalDir, agentsLink, 'dir'); cleanupCodexSkillMetadataSidecars(tmpDir); // Nothing in the external dir must have been deleted. assert.ok(fs.existsSync(path.join(externalDir, 'openai.yaml')), 'external/openai.yaml must still exist — cleanup must not delete through a symlinked agents/ dir'); assert.ok(fs.existsSync(path.join(externalDir, 'keep.txt')), 'external/keep.txt must still exist — cleanup must not delete through a symlinked agents/ dir'); // The symlink itself must still be present. assert.ok(fs.existsSync(agentsLink), 'msd-foo/agents symlink must still exist'); } finally { cleanup(externalDir); } }); test('Codex install does not create agents/openai.yaml sidecars for any managed skill (#1326)', () => { // Integration test: full Codex install must NOT produce any managed msd-*/agents/openai.yaml const codexHome = path.join(tmpDir, 'codex-home'); fs.mkdirSync(codexHome, { recursive: true }); runCodexInstall(codexHome); const skillsDir = codexSkillsRoot(codexHome); assert.ok(fs.existsSync(skillsDir), 'Codex install must create a skills/ directory'); const msdSkillDirs = fs.readdirSync(skillsDir, { withFileTypes: true }) .filter(e => e.isDirectory() && e.name.startsWith('msd-') && e.name !== 'msd-dev-preferences'); assert.ok(msdSkillDirs.length > 0, 'install must create at least one managed msd-* skill directory'); for (const skillEntry of msdSkillDirs) { const yamlPath = path.join(skillsDir, skillEntry.name, 'agents', 'openai.yaml'); assert.ok(!fs.existsSync(yamlPath), `${skillEntry.name}/agents/openai.yaml must NOT exist after install (#1326 sidecar dedup)`); } }); }); // ──────────────────────────────────────────────────────────────────────── // Folded from tests/bug-2698-crlf-install.test.cjs — consolidation epic #1969 (B1 #1970) // ──────────────────────────────────────────────────────────────────────── { const { describe: __foldDescribe } = require('node:test'); __foldDescribe("folded:bug-2698-crlf-install (consolidation epic #1969 B1 #1970)", () => { // allow-test-rule: source-text-is-the-product (see #2698) // Workflow .md / agent .md / command .md / reference .md files — their text // IS what the runtime loads. Testing text content tests the deployed contract. // Per CONTRIBUTING.md exception matrix. /** * Regression test for #2698: CRLF line endings break agent-block strip regexes * * The legacy `msd-update-check` hook migration in bin/install.js uses two * separate .replace() calls: * 1. LF-only regex: /\n# MSD Hooks\n\[\[hooks\]\]\nevent = ...\n/ * 2. CRLF-only regex: /\r\n# MSD Hooks\r\n\[\[hooks\]\]\r\nevent = ...\r\n/ * * These patterns fail when config.toml has mixed line endings — e.g. the * "# MSD Hooks" header uses LF but the body uses CRLF, or vice versa. This * can happen when the file is created cross-platform (Windows/Linux), when * editors convert only part of the file, or when a previous MSD version wrote * the block with different EOL than the file's dominant EOL. * * Fix: consolidate to a single \r?\n-aware regex that handles LF, CRLF, and * any mix in a single pass, making the migration robust regardless of the * platform the file was last written on. * * Test approach: write a `.codex/config.toml` with a stale msd-update-check * block that uses mixed line endings (header in LF, body in CRLF), then run * install() and assert the stale block is gone. * * Note: The local Codex install writes to `.codex/` in the current directory. * Tests `process.chdir(tmpDir)` and write fixtures to `tmpDir/.codex/`. */ 'use strict'; process.env.MSD_TEST_MODE = '1'; const { describe, test, 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 INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js'); const BUILD_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 } = require('./helpers/timeouts.cjs'); const { install, MSD_CODEX_MARKER } = require(INSTALL_SRC); const { cleanup } = require('./helpers.cjs'); // Ensure hooks/dist/ is populated before install tests before(() => { throwIfFailed( runNode([BUILD_SCRIPT], { timeoutMs: BUILD_TIMEOUT_MS }), `node ${BUILD_SCRIPT}`, ); }); describe('#2698: CRLF stale msd-update-check block is removed on Codex reinstall', () => { let tmpDir; let _previousHome; let _previousUserProfile; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-crlf-install-2698-')); // #2088 (ADR-1239 upgrade 3): Codex's skills-kind `home: ".agents"` override // applies to BOTH global and local scope and resolves via os.homedir(). This // describe block calls install(false, 'codex') (local scope) directly — // without sandboxing HOME/USERPROFILE to tmpDir, that in-process install // would materialize a full msd-* skill set into the developer/CI machine's // REAL $HOME/.agents/skills instead of the temp dir. _previousHome = process.env.HOME; _previousUserProfile = process.env.USERPROFILE; process.env.HOME = tmpDir; process.env.USERPROFILE = tmpDir; }); afterEach(() => { if (_previousHome === undefined) delete process.env.HOME; else process.env.HOME = _previousHome; if (_previousUserProfile === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = _previousUserProfile; // Use the shared 5s Windows-EBUSY retry budget instead of inline 1s. cleanup(tmpDir); }); // Helper: pre-populate .codex/config.toml with a MSD marker + stale hooks block // using the given line ending for the stale hooks block header, and a potentially // different EOL for the hooks body. This exercises the cross-platform mixed scenario. function writeCodexConfigWithStaleHooks(dir, headerEol, bodyEol) { // Build the stale block with header EOL for the "# MSD Hooks" line, but body EOL // for the content lines (simulates a file edited by two different platforms). const staleBlock = [ '# MSD Hooks', // line that starts the stale section '[[hooks]]', 'event = "SessionStart"', 'command = "node /old/path/msd-update-check.js"', ].join(bodyEol); // Put the stale block in user content BEFORE the MSD marker. The MSD marker area // will be regenerated by mergeCodexConfig during install(); the stale block in // the user area is what the hooks migration must remove. const content = [ '[features]', 'codex_hooks = true', '', ].join(headerEol) + headerEol + staleBlock + headerEol + headerEol + MSD_CODEX_MARKER + headerEol; const codexDir = path.join(dir, '.codex'); fs.mkdirSync(codexDir, { recursive: true }); const configPath = path.join(codexDir, 'config.toml'); fs.writeFileSync(configPath, content, 'utf-8'); return configPath; } 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') : []), ]); } test('LF config.toml: stale msd-update-check block removed on reinstall', (t) => { const origCwd = process.cwd(); t.after(() => { process.chdir(origCwd); }); process.chdir(tmpDir); writeCodexConfigWithStaleHooks(tmpDir, '\n', '\n'); install(false, 'codex'); const configPath = path.join(tmpDir, '.codex', 'config.toml'); const content = fs.readFileSync(configPath, 'utf-8'); assert.ok( !content.includes('msd-update-check'), 'Stale msd-update-check entry must be removed from LF config.toml (#2698)' ); const hooksJsonCommands = readHooksSessionStartCommands(path.join(tmpDir, '.codex')); assert.equal( hooksJsonCommands.some((cmd) => cmd.includes('msd-check-update')), true, 'New msd-check-update hook must appear in hooks.json after reinstall' ); }); test('CRLF config.toml: stale msd-update-check block removed on reinstall', (t) => { const origCwd = process.cwd(); t.after(() => { process.chdir(origCwd); }); process.chdir(tmpDir); writeCodexConfigWithStaleHooks(tmpDir, '\r\n', '\r\n'); install(false, 'codex'); const configPath = path.join(tmpDir, '.codex', 'config.toml'); const content = fs.readFileSync(configPath, 'utf-8'); assert.ok( !content.includes('msd-update-check'), 'Stale msd-update-check entry must be removed from CRLF config.toml (#2698)' ); const hooksJsonCommands = readHooksSessionStartCommands(path.join(tmpDir, '.codex')); assert.equal( hooksJsonCommands.some((cmd) => cmd.includes('msd-check-update')), true, 'New msd-check-update hook must appear in hooks.json after reinstall' ); }); test('mixed-EOL config.toml: stale block with LF header but CRLF body removed on reinstall', (t) => { // This is the primary failure case: header line uses LF but the body uses CRLF. // The old LF-only regex requires all-\n separators; the old CRLF-only regex requires // all-\r\n separators. Neither matches a block with mixed endings, so the stale // block survives reinstall with the old code (#2698). const origCwd = process.cwd(); t.after(() => { process.chdir(origCwd); }); process.chdir(tmpDir); // headerEol='\n' (file dominant), bodyEol='\r\n' (hook block from another platform) writeCodexConfigWithStaleHooks(tmpDir, '\n', '\r\n'); install(false, 'codex'); const configPath = path.join(tmpDir, '.codex', 'config.toml'); const content = fs.readFileSync(configPath, 'utf-8'); assert.ok( !content.includes('msd-update-check'), [ 'Stale msd-update-check block with mixed LF/CRLF endings must be removed (#2698).', 'Old code used two separate LF-only and CRLF-only regexes; neither matched mixed content.', 'Fix consolidates to a single \\r?\\n-aware regex.', ].join(' ') ); }); }); }); }