Files
msd-core/tests/codex-config-agents.test.cjs
Jakub Zych a9a7a328e6 refactor: hard-fork GSD -> MSD (Make Software Done)
Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD
across contents and paths, upstream package/repo coordinates -> @golem15/msd-core
and golem15com/msd-core. Deep links into upstream history, sibling upstream
packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is.

Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line,
package/plugin identity, regenerated lockfile, install-tree fixtures, derived
registries and benchmark baseline; migration checksum baseline re-locked
(MSD keeps its own install state, so no install had applied the old sums);
sort-order and regex-escaped expectations in tests adjusted.
2026-10-06 01:47:40 +02:00

2351 lines
110 KiB
JavaScript

/**
* 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/<name>.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
// "<absolute-node-path>" "<hook-path.js>"
// 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 = "<absolute agents dir>/<name>.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.<name>] 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.<name>] 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.<name>] 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 <config>/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: <value>` 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(' ')
);
});
});
});
}