/** * 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: _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: _cleanupCodexSkillMetadataSidecars, generateCodexConfigBlock: _generateCodexConfigBlock, stripMsdFromCodexConfig: _stripMsdFromCodexConfig, migrateCodexHooksMapFormat: _migrateCodexHooksMapFormat, mergeCodexConfig: _mergeCodexConfig, install, MSD_CODEX_MARKER: _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: _parseTomlToObject, validateCodexConfigSchema: _validateCodexConfigSchema, uninstall, 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/bug-3562-codex-install-skill-surface.test.cjs — consolidation epic #1969 (B1 #1970) // ──────────────────────────────────────────────────────────────────────── { const { describe: __foldDescribe } = require('node:test'); __foldDescribe("folded:bug-3562-codex-install-skill-surface (consolidation epic #1969 B1 #1970)", () => { 'use strict'; process.env.MSD_TEST_MODE = '1'; /** * Regression test for bug #3562 — Codex global install must create a * discoverable $msd-* skill surface. * * Codex CLI 0.130.0 (the version in the issue report) does NOT auto-discover * commands from msd-core/workflows/*.md or agents/*.md. It only registers * commands from skills//SKILL.md. Prior installer logic ("Codex now * discovers official skills from .agents/skills") was based on an assumption * that does not match the shipping Codex CLI behavior, leaving users with * workflows on disk and no $msd-* entrypoints after `npx @golem15/msd-core * --codex --global`. * * Fix: re-wire copyCommandsAsCodexSkills() back into the install dispatch path * so the same skill-shape that Claude / Copilot / Antigravity / Cursor / * Windsurf / Augment / Trae installs produce is also produced for Codex. */ 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 { runNode } = require('./helpers/process-seam.cjs'); const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const { install } = require('../bin/install.js'); const { createTempDir, cleanup, parseFrontmatter } = require('./helpers.cjs'); 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 } = require('./helpers/timeouts.cjs'); function withCodexHome(codexHome, fn) { const prev = process.env.CODEX_HOME; // #2088 (ADR-1239 upgrade 3): Codex skills now resolve an alternate install // home rooted at the REAL os.homedir() ($HOME/.agents), independent of // CODEX_HOME. Fake $HOME (and $USERPROFILE) too — using the sandbox root // (codexHome's parent, since codexHome is conventionally `/.codex` // in this file) — so this in-process install never touches the developer/CI // machine's real home directory. tmpRoot is reclaimed by the caller's afterEach. const prevHome = process.env.HOME; const prevUserProfile = process.env.USERPROFILE; const fakeHome = path.dirname(codexHome); process.env.CODEX_HOME = codexHome; process.env.HOME = fakeHome; process.env.USERPROFILE = fakeHome; try { return fn(); } finally { if (prev == null) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = prev; if (prevHome == null) delete process.env.HOME; else process.env.HOME = prevHome; if (prevUserProfile == null) delete process.env.USERPROFILE; else process.env.USERPROFILE = prevUserProfile; } } describe('#3562 — Codex install produces discoverable $msd-* skill surface', { concurrency: false }, () => { let tmpRoot; let codexHome; beforeEach(() => { if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) { throwIfFailed( runNode([BUILD_HOOKS_SCRIPT], { timeoutMs: BUILD_TIMEOUT_MS }), `node ${BUILD_HOOKS_SCRIPT}`, ); } tmpRoot = createTempDir('msd-3562-'); codexHome = path.join(tmpRoot, '.codex'); fs.mkdirSync(codexHome, { recursive: true }); }); afterEach(() => { cleanup(tmpRoot); }); test('global install creates skills/msd-help/SKILL.md', () => { withCodexHome(codexHome, () => install(true, 'codex')); // #2088: skills now install to the canonical $HOME/.agents/skills root. // withCodexHome fakes $HOME to tmpRoot (codexHome's parent) above. const skillPath = path.join(codexSkillsRoot(tmpRoot), 'msd-help', 'SKILL.md'); assert.ok( fs.existsSync(skillPath), `Codex install must create ${skillPath} so $msd-help is discoverable. ` + 'Without this, Codex CLI 0.130.0 does not expose any $msd-* command.', ); }); test('SKILL.md content has frontmatter expected by Codex skill discovery', () => { withCodexHome(codexHome, () => install(true, 'codex')); const skillPath = path.join(codexSkillsRoot(tmpRoot), 'msd-help', 'SKILL.md'); assert.ok(fs.existsSync(skillPath), 'precondition: SKILL.md exists'); const content = fs.readFileSync(skillPath, 'utf8'); const frontmatter = parseFrontmatter(content); assert.equal(frontmatter.name, 'msd-help', 'SKILL.md frontmatter must declare name: msd-help so $msd-help resolves'); }); test('multiple core $msd-* skills are produced (not just msd-help)', () => { withCodexHome(codexHome, () => install(true, 'codex')); const skillsDir = codexSkillsRoot(tmpRoot); assert.ok(fs.existsSync(skillsDir), 'skills/ directory must exist after install'); const msdSkills = fs .readdirSync(skillsDir, { withFileTypes: true }) .filter((e) => e.isDirectory() && e.name.startsWith('msd-')) .map((e) => e.name); // Lower bound — exact count depends on the current command surface. The // commands/msd/ directory holds dozens of *.md files; expecting more than // 10 generated skills is a conservative floor that catches "we generated // nothing" or "we only generated one accidentally" regressions. assert.ok( msdSkills.length >= 10, `Expected >= 10 generated msd-* skill directories, found ${msdSkills.length}: ${msdSkills.join(', ')}`, ); }); test('install preserves existing user skills (does not remove unrelated dirs)', () => { fs.mkdirSync(path.join(codexHome, 'skills', 'custom-user-skill'), { recursive: true }); fs.writeFileSync( path.join(codexHome, 'skills', 'custom-user-skill', 'SKILL.md'), '---\nname: custom-user-skill\n---\n# user skill\n', ); withCodexHome(codexHome, () => install(true, 'codex')); const userSkill = path.join(codexHome, 'skills', 'custom-user-skill', 'SKILL.md'); assert.ok( fs.existsSync(userSkill), 'Codex install must preserve existing non-msd user skill directories', ); }); }); }); } // ──────────────────────────────────────────────────────────────────────── // Folded from tests/bug-3566-codex-hooks-feature-canonical-key.test.cjs — consolidation epic #1969 (B1 #1970) // ──────────────────────────────────────────────────────────────────────── { const { describe: __foldDescribe } = require('node:test'); __foldDescribe("folded:bug-3566-codex-hooks-feature-canonical-key (consolidation epic #1969 B1 #1970)", () => { 'use strict'; process.env.MSD_TEST_MODE = '1'; /** * Regression tests for bug #3566 — Codex installer must emit canonical * [features].hooks (not the legacy [features].codex_hooks). * * Codex itself marks `codex_hooks` as a `legacy_key` in * codex-rs/features/src/legacy.rs. The canonical current feature flag is * `hooks`. The MSD installer was still writing `codex_hooks` on every fresh * install / reinstall, leaving deprecated config behind. This file pins: * * 1. Fresh install writes canonical `[features].hooks = true` and never * emits `codex_hooks` (section, root-dotted, or block-fallback forms). * 2. Reinstall over a MSD-owned section-form legacy * `[features].codex_hooks = true` migrates forward to * `[features].hooks = true` (legacy line removed); user-owned legacy * entries are preserved per #2760. * 3. Reinstall over a MSD-owned root-dotted legacy * `features.codex_hooks = true` migrates forward to * `features.hooks = true`; user-owned legacy entries are preserved. * 4. Reinstall over a user-owned `[features].hooks = true` (no MSD * ownership marker) preserves the user line; no double-write, no * ownership stamp. * 5. The `hasEnabledCodexHooksFeature` recognizer treats both canonical * `hooks` AND legacy `codex_hooks` as "enabled" so existing installs * keep working across the migration window. * 6. Uninstall removes either MSD-owned `hooks` or MSD-owned legacy * `codex_hooks`; user-owned `hooks` is preserved. * * All assertions use parseTomlToObject — never substring-match on raw TOML * text (per RULESET.TESTS.no-source-grep). The product surface is the * parsed config shape, not the file's lexical layout. */ 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 { runNode } = require('./helpers/process-seam.cjs'); const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const { install, uninstall, parseTomlToObject } = require('../bin/install.js'); const { createTempDir, cleanup } = require('./helpers.cjs'); 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 } = require('./helpers/timeouts.cjs'); function withCodexHome(codexHome, fn) { const prev = process.env.CODEX_HOME; // #2088 (ADR-1239 upgrade 3): Codex skills now resolve an alternate install // home rooted at the REAL os.homedir() ($HOME/.agents), independent of // CODEX_HOME. Fake $HOME (and $USERPROFILE) too — using the sandbox root // (codexHome's parent, since codexHome is conventionally `/.codex` // in this file) — so this in-process install never touches the developer/CI // machine's real home directory. tmpRoot is reclaimed by the caller's afterEach. const prevHome = process.env.HOME; const prevUserProfile = process.env.USERPROFILE; const fakeHome = path.dirname(codexHome); process.env.CODEX_HOME = codexHome; process.env.HOME = fakeHome; process.env.USERPROFILE = fakeHome; try { return fn(); } finally { if (prev == null) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = prev; if (prevHome == null) delete process.env.HOME; else process.env.HOME = prevHome; if (prevUserProfile == null) delete process.env.USERPROFILE; else process.env.USERPROFILE = prevUserProfile; } } function readConfig(codexHome) { const text = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'); return { text, parsed: parseTomlToObject(text) }; } function featuresHooks(parsed) { return parsed?.features?.hooks; } function featuresCodexHooks(parsed) { return parsed?.features?.codex_hooks; } describe('#3566 — Codex feature flag is canonical "hooks" (not legacy "codex_hooks")', { concurrency: false }, () => { let tmpRoot; let codexHome; beforeEach(() => { if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) { throwIfFailed( runNode([BUILD_HOOKS_SCRIPT], { timeoutMs: BUILD_TIMEOUT_MS }), `node ${BUILD_HOOKS_SCRIPT}`, ); } tmpRoot = createTempDir('msd-3566-'); codexHome = path.join(tmpRoot, '.codex'); fs.mkdirSync(codexHome, { recursive: true }); }); afterEach(() => { cleanup(tmpRoot); }); test('fresh install writes [features].hooks = true and never emits codex_hooks', () => { withCodexHome(codexHome, () => install(true, 'codex')); const { parsed } = readConfig(codexHome); assert.strictEqual( featuresHooks(parsed), true, 'fresh install must write canonical [features].hooks = true', ); assert.strictEqual( featuresCodexHooks(parsed), undefined, 'fresh install must NOT write legacy [features].codex_hooks', ); }); test('install over a pre-existing legacy [features].codex_hooks line preserves it (user-owned, #2760 defensive)', () => { // A user who hand-wrote `codex_hooks = true` keeps the legacy key. // Codex itself maps it via the runtime legacy_key alias, so this is // forward-compatible without MSD rewriting user-authored content. const legacy = [ '[features]', 'codex_hooks = true', '', ].join('\n'); fs.writeFileSync(path.join(codexHome, 'config.toml'), legacy); withCodexHome(codexHome, () => install(true, 'codex')); const { parsed } = readConfig(codexHome); assert.strictEqual( featuresCodexHooks(parsed), true, 'user-owned legacy codex_hooks line must be preserved verbatim', ); }); test('install over a pre-existing legacy root-dotted features.codex_hooks line preserves it', () => { const legacy = 'features.codex_hooks = true\n'; fs.writeFileSync(path.join(codexHome, 'config.toml'), legacy); withCodexHome(codexHome, () => install(true, 'codex')); const { parsed } = readConfig(codexHome); assert.strictEqual( featuresCodexHooks(parsed), true, 'user-owned root-dotted legacy line must be preserved verbatim', ); }); test('reinstall preserves user-owned [features].hooks = true (no MSD ownership marker)', () => { const userOwned = [ '[features]', 'hooks = true', '', ].join('\n'); fs.writeFileSync(path.join(codexHome, 'config.toml'), userOwned); withCodexHome(codexHome, () => install(true, 'codex')); const { parsed } = readConfig(codexHome); assert.strictEqual( featuresHooks(parsed), true, 'user-owned hooks=true must be preserved', ); }); test('uninstall removes MSD-owned canonical hooks line but preserves user-owned hooks', () => { // Phase 1: fresh MSD install — writes MSD-owned hooks line. withCodexHome(codexHome, () => install(true, 'codex')); const { parsed: afterInstall } = readConfig(codexHome); assert.strictEqual( featuresHooks(afterInstall), true, 'precondition: install wrote canonical hooks', ); withCodexHome(codexHome, () => uninstall(true, 'codex')); const configPath = path.join(codexHome, 'config.toml'); if (!fs.existsSync(configPath)) { // Uninstall may delete config.toml entirely when nothing user-owned // remains — that is the strongest possible "feature flag removed" // signal and counts as success. return; } const { parsed: afterUninstall } = readConfig(codexHome); assert.notStrictEqual( featuresHooks(afterUninstall), true, 'uninstall must remove MSD-owned canonical hooks line', ); }); test('uninstall preserves user-owned hooks=true when MSD never owned it', () => { const userOwned = [ '[features]', 'hooks = true', '', ].join('\n'); fs.writeFileSync(path.join(codexHome, 'config.toml'), userOwned); withCodexHome(codexHome, () => uninstall(true, 'codex')); const { parsed } = readConfig(codexHome); assert.strictEqual( featuresHooks(parsed), true, 'uninstall must NOT touch a hooks line MSD never claimed ownership of (#2760 defensive principle)', ); }); }); }); } // ──────────────────────────────────────────────────────────────────────── // Folded from tests/bug-3582-codex-skills-materialized.test.cjs — consolidation epic #1969 (B1 #1970) // ──────────────────────────────────────────────────────────────────────── { const { describe: __foldDescribe } = require('node:test'); __foldDescribe("folded:bug-3582-codex-skills-materialized (consolidation epic #1969 B1 #1970)", () => { /** * Regression test for bug #3582 — Codex install must materialize the skill * surface under `~/.codex/skills//SKILL.md`. * * Background: MSD 1.42.2 reported the user-visible failure * > Skipped Codex skill-copy generation (Codex discovers official skills directly) * which left users with a "successful" install but no routable `$msd-*` * entrypoints in Codex CLI 0.130.0. Codex CLI does NOT auto-discover * commands from `~/.codex/msd-core/workflows/*.md` or `agents/*.md`; * it only registers slash commands derived from `~/.codex/skills//SKILL.md`. * The "Codex discovers official skills directly" assumption was wrong. * * The current installer (#3562 / current main) calls * `copyCommandsAsCodexSkills()` to materialize one SKILL.md per * commands/msd/*.md, with Claude-flavored command frontmatter rewritten * into Codex skill frontmatter and the `` body * produced by `getCodexSkillAdapterHeader()`. * * This test locks the install contract so the 1.42.2 regression cannot * silently come back. It asserts the full expected skill-name set * (deepStrictEqual, not just count), the full adapter block (using * the exported `getCodexSkillAdapterHeader` IR as the expected value, * not raw substring search), and the success/skip log invariant. */ // allow-test-rule: source-text-is-the-product (see #3582) // This assertion validates the generated adapter block that is shipped to // users in SKILL.md; matching exact emitted text is the contract under test. 'use strict'; // MSD_TEST_MODE neutralizes side-effecting branches (auto-detection, etc.). // Must be set BEFORE requiring bin/install.js; scoped to module load only // so downstream tests don't see it. Mirrors the bug-2760 codex harness. const previousMsdTestMode = process.env.MSD_TEST_MODE; 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 { install, getCodexSkillAdapterHeader } = require('../bin/install.js'); const { parseFrontmatter, createTempDir, cleanup } = require('./helpers.cjs'); if (previousMsdTestMode === undefined) { delete process.env.MSD_TEST_MODE; } else { process.env.MSD_TEST_MODE = previousMsdTestMode; } const ROOT = path.join(__dirname, '..'); const COMMANDS_DIR = path.join(ROOT, 'commands', 'msd'); // Strip ANSI color codes so log assertions don't depend on TTY detection. function stripAnsi(s) { // eslint-disable-next-line no-control-regex -- \x1b (ESC) is the required leading byte of ANSI SGR color sequences; matching it is the purpose of stripping ANSI codes from captured CLI/console output return s.replace(/\x1b\[[0-9;]*m/g, ''); } function assertNoBareMsdToolsInvocation(content, label) { const patterns = [ /(^|\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`, ); } } /** * Walk commands/msd/**\/*.md and return the set of skill names the installer * is contractually obligated to produce. Naming rule mirrors * `copyCommandsAsCodexSkills` in bin/install.js: nested dirs collapse to * `msd--` with the .md stripped. */ function expectedSkillNames() { const names = new Set(); function recurse(dir, prefix) { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { if (entry.isDirectory()) { recurse(path.join(dir, entry.name), `${prefix}-${entry.name}`); } else if (entry.name.endsWith('.md')) { const base = entry.name.slice(0, -3); names.add(`${prefix}-${base}`); } } } recurse(COMMANDS_DIR, 'msd'); return names; } /** * Run a Codex global install into a temp CODEX_HOME and capture stdout/stderr. * Cleans up codexHome on throw so a partial-install failure never leaks * temp directories. */ function runCodexInstallCaptured() { const codexHome = createTempDir('msd-3582-codex-'); const logs = []; const warnings = []; const origLog = console.log; const origWarn = console.warn; console.log = (...a) => { logs.push(a.join(' ')); }; console.warn = (...a) => { warnings.push(a.join(' ')); }; const previousCodexHome = process.env.CODEX_HOME; const previousCwd = process.cwd(); // #2088 (ADR-1239 upgrade 3): Codex skills now resolve an alternate install // home rooted at os.homedir() ($HOME/.agents), independent of CODEX_HOME. // Sandbox $HOME (and $USERPROFILE) to codexHome too — otherwise this // in-process install would materialize skills under the developer/CI // machine's REAL home directory instead of the temp dir. const previousHome = process.env.HOME; const previousUserProfile = process.env.USERPROFILE; process.env.CODEX_HOME = codexHome; process.env.HOME = codexHome; process.env.USERPROFILE = codexHome; process.env.MSD_TEST_MODE = '1'; try { process.chdir(ROOT); install(true, 'codex'); return { codexHome, logs, warnings }; } catch (err) { // Always reclaim the temp dir if install throws — otherwise the // describe-level afterEach can't see codexHome and it leaks. try { cleanup(codexHome); } catch { /* best-effort */ } throw err; } finally { process.chdir(previousCwd); console.log = origLog; console.warn = origWarn; if (previousCodexHome === undefined) { delete process.env.CODEX_HOME; } else { process.env.CODEX_HOME = previousCodexHome; } if (previousHome === undefined) { delete process.env.HOME; } else { process.env.HOME = previousHome; } if (previousUserProfile === undefined) { delete process.env.USERPROFILE; } else { process.env.USERPROFILE = previousUserProfile; } if (previousMsdTestMode === undefined) { delete process.env.MSD_TEST_MODE; } else { process.env.MSD_TEST_MODE = previousMsdTestMode; } } } // concurrency:false — harness mutates console.* / process.env / process.cwd(). // Matches the convention used by tests/bug-3562-codex-install-skill-surface.test.cjs. describe('bug-3582: Codex global install materializes the skill surface', { concurrency: false }, () => { let installRun; beforeEach(() => { installRun = runCodexInstallCaptured(); }); afterEach(() => { if (installRun && installRun.codexHome) { cleanup(installRun.codexHome); } }); test('writes the exact expected set of msd-*/SKILL.md skills (deepEqual on name set)', () => { const skillsDir = codexSkillsRoot(installRun.codexHome); assert.ok( fs.existsSync(skillsDir), `Codex install must create ${skillsDir} (the 1.42.2 regression skipped this entirely)`, ); const actualNames = fs.readdirSync(skillsDir, { withFileTypes: true }) .filter(e => e.isDirectory() && e.name.startsWith('msd-')) .map(e => e.name); // deepStrictEqual on the sorted full set — not just count — so a // partial install that drops a real command and substitutes a bogus // same-count `msd-*` directory cannot pass. const expected = [...expectedSkillNames()].sort(); assert.deepStrictEqual( [...actualNames].sort(), expected, `installed Codex skills must exactly match commands/msd/**/*.md (one skill per command)`, ); // Every skill dir contains a non-empty SKILL.md file. Empty dirs or // empty SKILL.md bodies would defeat Codex's slash-command // registration as silently as the 1.42.2 "skipped" branch did. for (const name of actualNames) { const skillMd = path.join(skillsDir, name, 'SKILL.md'); const stat = fs.statSync(skillMd); assert.ok(stat.isFile(), `${skillMd} must be a regular file`); assert.ok(stat.size > 0, `${skillMd} must not be empty`); } }); test('SKILL.md frontmatter declares hyphen-form name matching the directory', () => { const skillsDir = codexSkillsRoot(installRun.codexHome); const skillDirs = fs.readdirSync(skillsDir, { withFileTypes: true }) .filter(e => e.isDirectory() && e.name.startsWith('msd-')) .map(e => e.name); for (const name of skillDirs) { const content = fs.readFileSync( path.join(skillsDir, name, 'SKILL.md'), 'utf-8', ); // Uses the shared `parseFrontmatter` from tests/helpers.cjs per the // CONTRIBUTING.md "tests parse, never grep" convention. const fm = parseFrontmatter(content); assert.strictEqual( fm.name, name, `SKILL.md name field must match directory name for ${name} (got ${JSON.stringify(fm.name)})`, ); assert.ok( typeof fm.description === 'string' && fm.description.length > 0, `SKILL.md description must be a non-empty string for ${name}`, ); } }); test('SKILL.md body contains the full block produced by the exported builder', () => { // Structural check against the production builder's output — NOT a // raw substring grep on the rendered file. `getCodexSkillAdapterHeader` // is the typed IR exported by bin/install.js (#3582 PR #3609 codex // review); the file on disk must contain its full output verbatim // (open tag, body, closing ``). A truncated, // empty, or missing-closing-tag adapter cannot satisfy this assertion. const skillsDir = codexSkillsRoot(installRun.codexHome); const skillDirs = fs.readdirSync(skillsDir, { withFileTypes: true }) .filter(e => e.isDirectory() && e.name.startsWith('msd-')) .map(e => e.name); for (const name of skillDirs) { const expectedAdapter = getCodexSkillAdapterHeader(name); // Sanity: the builder itself must produce a closed block for the // assertion below to be meaningful. assert.ok( expectedAdapter.startsWith(''), `getCodexSkillAdapterHeader(${name}) must start with the opening tag`, ); assert.ok( expectedAdapter.trimEnd().endsWith(''), `getCodexSkillAdapterHeader(${name}) must end with the closing tag`, ); const content = fs.readFileSync( path.join(skillsDir, name, 'SKILL.md'), 'utf-8', ); assert.ok( content.includes(expectedAdapter), `${name}/SKILL.md must contain the full adapter block produced by getCodexSkillAdapterHeader(${name}); Codex routes $${name} via this exact body`, ); } }); test('representative skills named in the issue report are present', () => { // The bug report and triage explicitly named these. Locking them as a // representative set so a future dispatch / filter / profile change // cannot drop just the commands the original user was trying to run. const representative = [ 'msd-map-codebase', // the literal command from the bug report 'msd-execute-phase', 'msd-plan-phase', 'msd-new-project', 'msd-health', ]; const skillsDir = codexSkillsRoot(installRun.codexHome); for (const name of representative) { const skillMd = path.join(skillsDir, name, 'SKILL.md'); assert.ok( fs.existsSync(skillMd), `${name}/SKILL.md must exist after Codex install (was unrouteable in 1.42.2)`, ); } }); test('installed Codex skills do not ask agents to run bare msd-tools commands', () => { const skillsDir = codexSkillsRoot(installRun.codexHome); const skillDirs = fs.readdirSync(skillsDir, { withFileTypes: true }) .filter(e => e.isDirectory() && e.name.startsWith('msd-')) .map(e => e.name); for (const name of skillDirs) { const content = fs.readFileSync( path.join(skillsDir, name, 'SKILL.md'), 'utf-8', ); assertNoBareMsdToolsInvocation(content, `${name}/SKILL.md`); } }); test('installer success log mentions skills/ — never claims success while skipping', () => { // The 1.42.2 user-visible failure mode was a successful install that // printed "Skipped Codex skill-copy generation (Codex discovers // official skills directly)" while leaving the user with no // entrypoints. Lock that the broken strings can NEVER coexist with a // success indicator. Current main prints "✓ Installed N skills". const cleanLogs = installRun.logs.map(stripAnsi); const cleanWarnings = installRun.warnings.map(stripAnsi); const allOutput = [...cleanLogs, ...cleanWarnings].join('\n'); assert.ok( !/Skipped Codex skill-copy generation/i.test(allOutput), `installer must never print "Skipped Codex skill-copy generation" (1.42.2 failure). Output:\n${allOutput}`, ); assert.ok( !/Codex discovers official skills directly/i.test(allOutput), `installer must never claim "Codex discovers official skills directly" (1.42.2 incorrect assumption). Output:\n${allOutput}`, ); // Positive proof — at least one log line acknowledges the skills install. const hasSkillsInstalledLog = cleanLogs.some(line => /Installed\s+\d+\s+skills\s+to\s+skills\//.test(line)); assert.ok( hasSkillsInstalledLog, `installer must print a success line of the form "Installed N skills to skills/". Logs:\n${cleanLogs.join('\n')}`, ); }); }); }); } // ──────────────────────────────────────────────────────────────────────── // Folded from tests/bug-3808-codex-adapter-text-mode-fallback.test.cjs — consolidation epic #1969 (B1 #1970) // ──────────────────────────────────────────────────────────────────────── { const { describe: __foldDescribe } = require('node:test'); __foldDescribe("folded:bug-3808-codex-adapter-text-mode-fallback (consolidation epic #1969 B1 #1970)", () => { /** * Regression test for bug #3808. * * When Codex runs in Default mode, `request_user_input` is reported as * unavailable. The Codex skill adapter must tell the agent to activate the * workflow's built-in TEXT_MODE mechanism (`--text` flag) rather than either: * (a) silently picking a default value — the #3018 failure mode, or * (b) ad-hoc plain-text fallback that bypasses the workflow's own branching. * * Workflows (e.g. plan-phase.md) already have TEXT_MODE logic: * "Set TEXT_MODE=true if `--text` is present in $ARGUMENTS OR text_mode * from init JSON is true." * The adapter must tell the agent to USE that mechanism when * `request_user_input` is unavailable instead of inventing its own fallback * or silently continuing with defaults. * * Test design: mirrors the typed-semantic-flag pattern from bug #3018 so that * prose rewording doesn't break tests as long as the semantics stay correct. */ 'use strict'; process.env.MSD_TEST_MODE = '1'; const { test, describe } = require('node:test'); const assert = require('node:assert/strict'); const path = require('node:path'); const INSTALL = require(path.join(__dirname, '..', 'bin', 'install.js')); const { getCodexSkillAdapterHeader } = INSTALL; const { tokenizeHeadings } = require('../msd-core/bin/lib/markdown-sectionizer.cjs'); /** * Extract the "Execute mode fallback" section text from the adapter header. * Returns null if the section is missing. Section runs from the * "Execute mode fallback:" label up to the next heading or tag. */ function extractExecuteModeFallback(header) { const label = 'Execute mode fallback:'; const labelIdx = header.indexOf(label); if (labelIdx === -1) return null; const bodyStart = header.indexOf('\n', labelIdx + label.length); if (bodyStart === -1) return null; // End at whichever comes first: the next "## " heading (via the canonical // heading tokenizer, not an ad-hoc regex) or the closing adapter tag. const headings = tokenizeHeadings(header).filter((h) => h.level === 2 && h.offset > bodyStart); const nextHeadingOffset = headings.length > 0 ? headings[0].offset - 1 : Infinity; // -1 for the leading \n const closeTagIdx = header.indexOf('', bodyStart); const closeTagOffset = closeTagIdx === -1 ? Infinity : closeTagIdx - 1; // -1 for the leading \n const bodyEnd = Math.min(nextHeadingOffset, closeTagOffset); if (bodyEnd === Infinity) return null; return header.slice(bodyStart + 1, bodyEnd).trim(); } /** * Parse the Execute-mode-fallback section into a typed semantic-flag record. * * Flags for bug #3808 (TEXT_MODE activation): * activatesTextMode — does the prose tell the agent to activate TEXT_MODE / use --text? * instructsStop — does the prose tell the agent to stop/halt/wait? * presentsPlainText — does the prose mention plain-text / numbered-list presentation? * silentlyPicksDefaults — (anti-pattern) does the prose instruct silent-default picking? */ function parseExecuteModeFallbackFor3808(section) { if (!section || typeof section !== 'string') { return { ok: false, sectionLength: 0, activatesTextMode: false, instructsStop: false, presentsPlainText: false, silentlyPicksDefaults: false, }; } const lower = section.toLowerCase(); // (a) TEXT_MODE activation — adapter must tell the agent to use the workflow's // built-in text mode mechanism when request_user_input is unavailable. // Accept either: explicit "--text" flag mention OR "text_mode" / "text mode" // paired with context showing it is being SET/ACTIVATED (not just referenced). const mentionsTextFlag = section.includes('--text'); const mentionsTextModeOn = /text_mode\s*=\s*true|set\s+text_mode|activate\s+text.?mode|enable\s+text.?mode|text.?mode.*active|text.?mode.*on\b/i.test(section); const activatesTextMode = mentionsTextFlag || mentionsTextModeOn; // (b) STOP/WAIT directive — the agent must halt instead of proceeding silently. const instructsStop = /\b(stop|halt|wait)\b/.test(lower); // (c) Plain-text fallback presentation. const presentsPlainText = /plain.?text|numbered list/.test(lower); // Anti-pattern guard — the prose that caused #3018 and resurfaces in #3808. const silentlyPicksDefaults = /pick (a |the )?(reasonable|sensible|sane) default/i.test(section); return { ok: true, sectionLength: section.length, activatesTextMode, instructsStop, presentsPlainText, silentlyPicksDefaults, }; } describe('bug #3808: codex skill adapter activates TEXT_MODE when request_user_input is unavailable', () => { const SKILL_NAMES = ['msd-plan-phase', 'msd-discuss-phase', 'msd-execute-phase', 'msd-verify-work']; test('getCodexSkillAdapterHeader is exported', () => { assert.equal(typeof getCodexSkillAdapterHeader, 'function'); }); test('Execute mode fallback section exists for all key skills', () => { for (const skillName of SKILL_NAMES) { const header = getCodexSkillAdapterHeader(skillName); const section = extractExecuteModeFallback(header); assert.ok(section !== null && section.length > 0, `${skillName}: Execute mode fallback section must exist and have content`); } }); for (const skillName of SKILL_NAMES) { test(`${skillName}: fallback activates TEXT_MODE (--text flag or text_mode=true) when request_user_input is unavailable`, () => { const header = getCodexSkillAdapterHeader(skillName); const section = extractExecuteModeFallback(header); const parsed = parseExecuteModeFallbackFor3808(section); assert.equal(parsed.activatesTextMode, true, `${skillName}: fallback must instruct the agent to activate TEXT_MODE (mention --text flag or text_mode=true/active) when request_user_input is unavailable (#3808). Section was:\n${section}`); }); test(`${skillName}: fallback instructs STOP/WAIT (not silent continuation)`, () => { const header = getCodexSkillAdapterHeader(skillName); const section = extractExecuteModeFallback(header); const parsed = parseExecuteModeFallbackFor3808(section); assert.equal(parsed.instructsStop, true, `${skillName}: fallback must include stop/halt/wait instruction. Section was:\n${section}`); }); test(`${skillName}: fallback does NOT contain silent-default anti-pattern`, () => { const header = getCodexSkillAdapterHeader(skillName); const section = extractExecuteModeFallback(header); const parsed = parseExecuteModeFallbackFor3808(section); assert.equal(parsed.silentlyPicksDefaults, false, `${skillName}: regression — fallback must NOT instruct the agent to pick defaults autonomously (#3018 / #3808). Section was:\n${section}`); }); } test('typed semantic-record snapshot for msd-plan-phase — full contract', () => { const section = extractExecuteModeFallback(getCodexSkillAdapterHeader('msd-plan-phase')); const parsed = parseExecuteModeFallbackFor3808(section); assert.deepStrictEqual( { ok: parsed.ok, activatesTextMode: parsed.activatesTextMode, instructsStop: parsed.instructsStop, presentsPlainText: parsed.presentsPlainText, silentlyPicksDefaults: parsed.silentlyPicksDefaults, }, { ok: true, activatesTextMode: true, instructsStop: true, presentsPlainText: true, silentlyPicksDefaults: false, }, `msd-plan-phase: full TEXT_MODE fallback contract violated (#3808). Section was:\n${section}`, ); }); }); }); } // ──────────────────────────────────────────────────────────────────────── // Folded from tests/bug-570-codex-leak-scanner.test.cjs — consolidation epic #1969 (B1 #1970) // ──────────────────────────────────────────────────────────────────────── { const { describe: __foldDescribe } = require('node:test'); __foldDescribe("folded:bug-570-codex-leak-scanner (consolidation epic #1969 B1 #1970)", () => { // allow-test-rule: source-text-is-the-product (see #570) 'use strict'; process.env.MSD_TEST_MODE = '1'; /** * Regression tests for issue #570 — three related sub-bugs in the Codex leak * scanner and supporting infrastructure. * * SUB-BUG A: scanForLeakedPaths recursively scans the entire targetDir, * including pre-existing unrelated files that contain ~/.claude references. * Fix: scan only files listed in msd-file-manifest.json. * * SUB-BUG B: convertClaudeToCodexMarkdown replaces "~/.claude/" (with trailing * slash) but NOT bare "~/.claude" (no slash). The scanner regex * /(?:~|\$HOME)\/\.claude\b/ matches without trailing slash. * Fix: add bare word-boundary replacement. * * SUB-BUG C: writeManifest checks file.endsWith('.md') for the agents/ * directory. Codex installs .toml agent files, so they are invisible to the * manifest and thus to any manifest-based scan fix. * Fix: also check .toml. */ 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 { runNode } = require('./helpers/process-seam.cjs'); const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const { install, convertClaudeCommandToCodexSkill, } = require('../bin/install.js'); const { createTempDir, cleanup, captureConsole } = require('./helpers.cjs'); 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 } = require('./helpers/timeouts.cjs'); function withCodexHome(codexHome, fn) { const prev = process.env.CODEX_HOME; // #2088 (ADR-1239 upgrade 3): Codex skills now resolve an alternate install // home rooted at the REAL os.homedir() ($HOME/.agents), independent of // CODEX_HOME. Fake $HOME (and $USERPROFILE) too — using the sandbox root // (codexHome's parent, since codexHome is conventionally `/.codex` // in this file) — so this in-process install never touches the developer/CI // machine's real home directory. tmpRoot is reclaimed by the caller's afterEach. const prevHome = process.env.HOME; const prevUserProfile = process.env.USERPROFILE; const fakeHome = path.dirname(codexHome); process.env.CODEX_HOME = codexHome; process.env.HOME = fakeHome; process.env.USERPROFILE = fakeHome; try { return fn(); } finally { if (prev == null) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = prev; if (prevHome == null) delete process.env.HOME; else process.env.HOME = prevHome; if (prevUserProfile == null) delete process.env.USERPROFILE; else process.env.USERPROFILE = prevUserProfile; } } describe('#570 — Codex leak scanner sub-bugs', { concurrency: false }, () => { let tmpRoot; let codexHome; beforeEach(() => { if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) { throwIfFailed( runNode([BUILD_HOOKS_SCRIPT], { timeoutMs: BUILD_TIMEOUT_MS }), `node ${BUILD_HOOKS_SCRIPT}`, ); } tmpRoot = createTempDir('msd-570-'); codexHome = path.join(tmpRoot, '.codex'); fs.mkdirSync(codexHome, { recursive: true }); }); afterEach(() => { cleanup(tmpRoot); }); // SUB-BUG B test('convertClaudeToCodexMarkdown replaces bare ~/.claude (no trailing slash)', () => { // convertClaudeToCodexMarkdown is not exported directly; exercise it via // convertClaudeCommandToCodexSkill which calls it internally. const input = 'configDir = ~/.claude\npath = ~/.claude/hooks/\ndir = $HOME/.claude'; const out = convertClaudeCommandToCodexSkill(input, 'msd-test'); assert.ok( !/(?:~|\$HOME)\/\.claude\b/.test(out), `Expected no leaked ~/.claude reference after conversion, got:\n${out}`, ); }); // SUB-BUG C test('writeManifest includes .toml agent files for Codex', () => { withCodexHome(codexHome, () => install(true, 'codex')); const agentsDir = path.join(codexHome, 'agents'); // Not the shared listAgentFiles() helper: this reads the INSTALLED Codex // dest dir and filters .toml (not source .md), so its semantics differ. // Confirm that Codex actually wrote .toml agent files — if none exist the // test is vacuous and we should fail loudly. const tomlFiles = fs.existsSync(agentsDir) ? fs.readdirSync(agentsDir).filter((f) => f.startsWith('msd-') && f.endsWith('.toml')) : []; assert.ok( tomlFiles.length > 0, `Precondition: Codex install must write at least one msd-*.toml in agents/; found none in ${agentsDir}`, ); const manifestPath = path.join(codexHome, 'msd-file-manifest.json'); assert.ok( fs.existsSync(manifestPath), `msd-file-manifest.json must exist after install; not found at ${manifestPath}`, ); const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); const manifestKeys = Object.keys(manifest.files || {}); const tomlManifestKeys = manifestKeys.filter( (k) => k.startsWith('agents/msd-') && k.endsWith('.toml'), ); assert.ok( tomlManifestKeys.length > 0, `Expected at least one 'agents/msd-*.toml' key in manifest.files, but found none.\n` + `agents/ toml files on disk: ${tomlFiles.join(', ')}\n` + `All manifest keys (agents/): ${manifestKeys.filter((k) => k.startsWith('agents/')).join(', ')}`, ); }); // SUB-BUG A test('scanForLeakedPaths does not warn for pre-existing unrelated files in ~/.codex', () => { // Write a pre-existing file with ~/.claude references BEFORE install. const memoriesDir = path.join(codexHome, 'memories'); fs.mkdirSync(memoriesDir, { recursive: true }); const preExistingFile = path.join(memoriesDir, 'raw_memories.md'); fs.writeFileSync( preExistingFile, '# Old memories\nI used to work in ~/.claude and $HOME/.claude regularly.\n', ); let captured; withCodexHome(codexHome, () => { captured = captureConsole(() => install(true, 'codex')); }); const combinedOutput = captured.stderr; assert.ok( !combinedOutput.includes('memories/raw_memories.md'), `scanForLeakedPaths must not warn about pre-existing unrelated file memories/raw_memories.md.\n` + `Actual warnings:\n${combinedOutput}`, ); }); }); }); } // ──────────────────────────────────────────────────────────────────────── // Folded from tests/bug-704-codex-launcher-path-corruption.test.cjs — consolidation epic #1969 (B1 #1970) // ──────────────────────────────────────────────────────────────────────── { const { describe: __foldDescribe } = require('node:test'); __foldDescribe("folded:bug-704-codex-launcher-path-corruption (consolidation epic #1969 B1 #1970)", () => { // allow-test-rule: source-text-is-the-product (see #704) 'use strict'; /** * Regression test for issue #704: * "v1.3.1 global install ships literal $msd-core launcher paths in workflows" * * ROOT CAUSE: `convertSlashCommandsToCodexSkillMentions` had a regex * /(? { test('convertClaudeCommandToCodexSkill does not corrupt ${VAR}/msd-core/ or $(cmd)/msd-* paths', () => { // Minimal fixture with the launcher snippet and command-substitution patterns // that were being corrupted (#704). const input = [ '---', 'description: Test skill', '---', '', '```bash', '_MSD_SHIM_NAME="msd-tools.cjs"', '_MSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"', 'MSD_TOOLS="${_MSD_RUNTIME_ROOT}/msd-core/bin/${_MSD_SHIM_NAME}"', 'if [ -f "$MSD_TOOLS" ]; then', ' msd_run() { node "$MSD_TOOLS" "$@"; }', 'elif [ -f "${_MSD_RUNTIME_ROOT}/.claude/msd-core/bin/${_MSD_SHIM_NAME}" ]; then', ' MSD_TOOLS="${_MSD_RUNTIME_ROOT}/.claude/msd-core/bin/${_MSD_SHIM_NAME}"', ' msd_run() { node "$MSD_TOOLS" "$@"; }', 'elif [ -f "$HOME/.claude/msd-core/bin/${_MSD_SHIM_NAME}" ]; then', ' MSD_TOOLS="$HOME/.claude/msd-core/bin/${_MSD_SHIM_NAME}"', ' msd_run() { node "$MSD_TOOLS" "$@"; }', 'fi', '# Command-substitution path form (reapply-patches pattern)', 'candidate="$(expand_home "$KILO_CONFIG_DIR")/msd-local-patches"', '```', ].join('\n'); const output = convertClaudeCommandToCodexSkill(input, 'msd-test-704'); // Shell-context corruption patterns from issue #704: // - `}$msd-*` from shell variable expressions `${VAR}/msd-*` // - `)$msd-*` from command-substitution paths `$(cmd)/msd-*` const shellCorruptionPatterns = [ { pattern: '}' + BAD_TOKEN, description: 'shell-variable }$msd-core' }, { pattern: ')$msd-local', description: 'command-substitution )$msd-local-patches' }, ]; for (const { pattern, description } of shellCorruptionPatterns) { assert.ok( !output.includes(pattern), `Codex skill conversion must not produce "${pattern}" (${description}). ` + `Offending fragment: ${ output.includes(pattern) ? output.substring(output.indexOf(pattern) - 50, output.indexOf(pattern) + 80) : '(not found)' }`, ); } // The correct path forms must be preserved — the canonical launcher path // (RUNTIME_ROOT_PATH) must survive Codex conversion intact. assert.ok( output.includes(RUNTIME_ROOT_PATH), `Expected canonical launcher path "${RUNTIME_ROOT_PATH}" to appear in the converted output. ` + `Got:\n${output.substring(0, 500)}`, ); assert.ok( output.includes(')/msd-local-patches'), `Expected ")/msd-local-patches" to appear in the converted output. ` + `Got:\n${output.substring(0, 500)}`, ); }); test('convertClaudeCommandToCodexSkill preserves all shell path forms (}, ) closers)', () => { // All these paths appear after a shell-closing character (} or )) and must // NOT be converted to $msd-* by the Codex slash-command converter. const shellPaths = [ // Shell variable expression forms (} closer) { path: '"${_MSD_RUNTIME_ROOT}/msd-core/bin/${_MSD_SHIM_NAME}"', corruptedForm: '}$msd-core' }, { path: '"${_MSD_RUNTIME_ROOT}/.claude/msd-core/bin/${_MSD_SHIM_NAME}"', corruptedForm: '}$msd-core' }, { path: '"$HOME/.claude/msd-core/bin/${_MSD_SHIM_NAME}"', corruptedForm: '}$msd-core' }, // Command-substitution forms () closer) — reapply-patches pattern { path: 'candidate="$(expand_home "$KILO_CONFIG_DIR")/msd-local-patches"', corruptedForm: ')$msd-local' }, { path: 'candidate="$(dirname "$(expand_home "$OPENCODE_CONFIG")")/msd-local-patches"', corruptedForm: ')$msd-local' }, ]; for (const { path: p, corruptedForm } of shellPaths) { const input = `---\ndescription: Test\n---\n\n\`\`\`bash\n${p}\n\`\`\``; const output = convertClaudeCommandToCodexSkill(input, 'msd-test-704-paths'); assert.ok( !output.includes(corruptedForm), `Path "${p}" was corrupted to contain "${corruptedForm}" after Codex conversion.\n` + `Got:\n${output}`, ); } }); test('convertClaudeCommandToCodexSkill still converts legitimate /msd- slash mentions', () => { // Slash-command mentions (not preceded by }) should still be converted const input = [ '---', 'description: Test', '---', '', 'Use /msd-discuss-phase to start a discussion.', 'Or use /msd-plan-phase for planning.', 'Also: /msd:capture --backlog adds items.', ].join('\n'); const output = convertClaudeCommandToCodexSkill(input, 'msd-test-704-cmds'); assert.ok( output.includes('$msd-discuss-phase'), 'Expected /msd-discuss-phase to be converted to $msd-discuss-phase', ); assert.ok( output.includes('$msd-plan-phase'), 'Expected /msd-plan-phase to be converted to $msd-plan-phase', ); assert.ok( output.includes('$msd-capture'), 'Expected /msd:capture to be converted to $msd-capture', ); }); test('actual shipped workflow files: shell-variable launcher paths contain no $msd-core', () => { // Walk msd-core/workflows/ and assert that no file produces $msd-core // inside a shell variable expansion context after Codex conversion. // // NOTE: The backtick-wrapped prose-path case (`/msd-core/workflows/update.md`) // was a pre-existing gap with the #704 lookbehind fix and is now addressed by // the positive-boundary regex introduced in #712. That case is covered by the // "#712" describe block below. // // We probe for the specific shell-context pattern from the issue report: // BAD: ${_MSD_RUNTIME_ROOT}$msd-core/bin/ // GOOD: ${_MSD_RUNTIME_ROOT}/msd-core/bin/ const workflowsDir = path.join(__dirname, '..', 'msd-core', 'workflows'); if (!fs.existsSync(workflowsDir)) { // If the directory doesn't exist, skip gracefully (non-standard layout) return; } const files = fs.readdirSync(workflowsDir) .filter((f) => f.endsWith('.md')) .map((f) => path.join(workflowsDir, f)); assert.ok(files.length > 0, 'Expected at least one workflow .md file'); // Shell-context corruption patterns from issue #704: // - `}$msd-*`: closing brace from `${VAR}/msd-*` shell variable expressions // - `)$msd-*`: closing paren from `$(cmd)/msd-*` command substitutions const SHELL_CORRUPTION_RE = /[})](\$msd-[a-z])/; const offending = []; for (const file of files) { const content = fs.readFileSync(file, 'utf8'); const skillName = `msd-${path.basename(file, '.md')}`; const converted = convertClaudeCommandToCodexSkill(content, skillName); const match = converted.match(SHELL_CORRUPTION_RE); if (match) { const idx = converted.indexOf(match[0]); offending.push({ file: path.relative(workflowsDir, file), context: converted.substring(Math.max(0, idx - 40), idx + 80), }); } } assert.deepStrictEqual( offending, [], `Found shell-context path corruption ([})]$msd-*) in Codex-converted workflow files (#704):\n` + offending.map((o) => ` ${o.file}: ...${o.context}...`).join('\n'), ); }); test('commands/msd/*.md: shell-variable launcher paths contain no $msd-core', () => { // Walk commands/msd/ and assert that no command file produces the shell-context // }$msd-core corruption — since commands also go through // convertClaudeCommandToCodexSkill when installed globally for Codex. const commandsDir = path.join(__dirname, '..', 'commands', 'msd'); if (!fs.existsSync(commandsDir)) return; const files = fs.readdirSync(commandsDir) .filter((f) => f.endsWith('.md')) .map((f) => path.join(commandsDir, f)); assert.ok(files.length > 0, 'Expected at least one command .md file'); const SHELL_CORRUPTION_RE = /[})](\$msd-[a-z])/; const offending = []; for (const file of files) { const content = fs.readFileSync(file, 'utf8'); const skillName = `msd-${path.basename(file, '.md')}`; const converted = convertClaudeCommandToCodexSkill(content, skillName); const match = converted.match(SHELL_CORRUPTION_RE); if (match) { const idx = converted.indexOf(match[0]); offending.push({ file: path.relative(commandsDir, file), context: converted.substring(Math.max(0, idx - 40), idx + 80), }); } } assert.deepStrictEqual( offending, [], `Found shell-context path corruption ([})]$msd-*) in Codex-converted command files (#704):\n` + offending.map((o) => ` ${o.file}: ...${o.context}...`).join('\n'), ); }); }); describe('#712: positive-boundary slash-command conversion', () => { // Tests call convertSlashCommandsToCodexSkillMentions directly so the regex // is exercised in isolation — no frontmatter wrapping, no ADAPTER_CLOSE // stripping, no .claude→.codex rewrite masking the result. // ── MUST-NOT-CONVERT (negative) cases ───────────────────────────────────── // These inputs must be returned UNCHANGED — no $msd-* substitution. test('backtick-wrapped path: `/msd-core/workflows/update.md` is NOT converted (THE new fix)', () => { const input = 'See `/msd-core/workflows/update.md` for details.'; const result = convertSlashCommandsToCodexSkillMentions(input); assert.strictEqual( result, input, `Expected backtick-wrapped path to be unchanged. Got: ${result}`, ); }); test('backtick-wrapped path deeper: `/gsd-pi/bin/foo.cjs` is NOT converted', () => { const input = 'Run `/gsd-pi/bin/foo.cjs` directly.'; const result = convertSlashCommandsToCodexSkillMentions(input); assert.strictEqual( result, input, `Expected deep backtick-wrapped path to be unchanged. Got: ${result}`, ); }); test('shell var expansion: ${_MSD_RUNTIME_ROOT}/msd-core/bin/x is NOT converted (regression guard)', () => { const input = 'PATH="${_MSD_RUNTIME_ROOT}/msd-core/bin/x"'; const result = convertSlashCommandsToCodexSkillMentions(input); assert.ok( !result.includes('$msd-core'), `Expected no $msd-core substitution in shell var path. Got: ${result}`, ); assert.ok( result.includes('/msd-core/bin/x'), `Expected original path to be preserved. Got: ${result}`, ); }); test('command substitution: $(expand_home ~/.claude)/msd-local-patches is NOT converted (regression guard)', () => { const input = 'candidate="$(expand_home ~/.claude)/msd-local-patches"'; const result = convertSlashCommandsToCodexSkillMentions(input); assert.ok( !result.includes(')$msd-local'), `Expected no )$msd-local substitution. Got: ${result}`, ); assert.ok( result.includes(')/msd-local-patches'), `Expected original path to be preserved. Got: ${result}`, ); }); test('plain path segment: bin/msd-tools.cjs is NOT converted', () => { const input = 'node bin/msd-tools.cjs --help'; const result = convertSlashCommandsToCodexSkillMentions(input); assert.strictEqual( result, input, `Expected plain path segment to be unchanged. Got: ${result}`, ); }); test('plain path segment: .claude/msd-core/agents — /msd-core portion is NOT slash-command converted', () => { // Tests the regex in isolation: the .claude→.codex path rewrite that happens // inside convertClaudeToCodexMarkdown does NOT run here. We assert directly // that the slash-command regex leaves /msd-core after the slash intact — // i.e. the `e` in `/msd-core` is NOT treated as a command boundary. const input = 'Look in .claude/msd-core/agents for the agent files.'; const result = convertSlashCommandsToCodexSkillMentions(input); assert.ok( !result.includes('$msd-core'), `Expected no $msd-core substitution in .claude/msd-core path. Got: ${result}`, ); assert.ok( result.includes('/msd-core/agents'), `Expected /msd-core/agents to remain as a path segment. Got: ${result}`, ); }); // ── MUST-CONVERT (positive) cases ───────────────────────────────────────── // These inputs contain legitimate /msd- mentions that MUST be converted. test('space-preceded prose: Use /msd-discuss-phase to start. → $msd-discuss-phase', () => { const input = 'Use /msd-discuss-phase to start.'; const result = convertSlashCommandsToCodexSkillMentions(input); assert.ok( result.includes('$msd-discuss-phase'), `Expected /msd-discuss-phase to be converted. Got: ${result}`, ); assert.ok( !result.includes('/msd-discuss-phase'), `Expected original /msd-discuss-phase to be replaced. Got: ${result}`, ); }); test('backtick-WRAPPED MENTION (single segment): Run `/msd-execute-phase` now → `$msd-execute-phase`', () => { // A backtick-wrapped COMMAND (single segment, no path continuation) MUST // still be converted — this guards against a naive whitespace-only fix. const input = 'Run `/msd-execute-phase` now.'; const result = convertSlashCommandsToCodexSkillMentions(input); assert.ok( result.includes('`$msd-execute-phase`'), `Expected backtick-wrapped command to be converted to \`$msd-execute-phase\`. Got: ${result}`, ); assert.ok( !result.includes('`/msd-execute-phase`'), `Expected original \`/msd-execute-phase\` to be replaced. Got: ${result}`, ); }); test('parenthetical/backtick list like CONTEXT.md:59: (`/msd-plan-phase`, `/msd-progress`) → converted', () => { const input = 'Available commands: (`/msd-plan-phase`, `/msd-progress`) — pick one.'; const result = convertSlashCommandsToCodexSkillMentions(input); assert.ok( result.includes('`$msd-plan-phase`'), `Expected /msd-plan-phase to be converted. Got: ${result}`, ); assert.ok( result.includes('`$msd-progress`'), `Expected /msd-progress to be converted. Got: ${result}`, ); }); test('start-of-string: /msd-manager runs → $msd-manager runs (exercises the ^ branch of lookbehind)', () => { // This case is IMPOSSIBLE to test through the frontmatter-wrapping pipeline // (the body always has preceding chars). Direct call exercises the ^ branch. const input = '/msd-manager runs the pipeline.'; const result = convertSlashCommandsToCodexSkillMentions(input); assert.ok( result.includes('$msd-manager'), `Expected /msd-manager to be converted. Got: ${result}`, ); assert.ok( !result.includes('/msd-manager'), `Expected original /msd-manager to be replaced. Got: ${result}`, ); }); test('double-quote wrapped: "/msd-resume" → "$msd-resume"', () => { const input = 'Call "/msd-resume" to continue.'; const result = convertSlashCommandsToCodexSkillMentions(input); assert.ok( result.includes('"$msd-resume"'), `Expected "/msd-resume" to be converted to "$msd-resume". Got: ${result}`, ); assert.ok( !result.includes('"/msd-resume"'), `Expected original "/msd-resume" to be replaced. Got: ${result}`, ); }); // ── End-to-end: headline #712 bug through the real install pipeline ──────── test('end-to-end: backtick-wrapped path `/msd-core/workflows/update.md` survives full Codex install pipeline', () => { // Uses convertClaudeCommandToCodexSkill (same pattern as #704 tests above) // to prove the real install path does not corrupt prose references to repo paths. const input = [ '---', 'description: Test', '---', '', 'See `/msd-core/workflows/update.md` for the update workflow.', ].join('\n'); const output = convertClaudeCommandToCodexSkill(input, 'msd-test-712-e2e'); assert.ok( !output.includes('$msd-core'), `Expected no $msd-core in converted output. Got:\n${output}`, ); assert.ok( output.includes('/msd-core/workflows/update.md'), `Expected backtick-wrapped path to survive conversion. Got:\n${output}`, ); }); }); }); } // ──────────────────────────────────────────────────────────────────────── // Folded from tests/bug-851-codex-quick-adapter-agent-type-fallback.test.cjs — consolidation epic #1969 (B1 #1970) // ──────────────────────────────────────────────────────────────────────── { const { describe: __foldDescribe } = require('node:test'); __foldDescribe("folded:bug-851-codex-quick-adapter-agent-type-fallback (consolidation epic #1969 B1 #1970)", () => { // allow-test-rule: source-text-is-the-product (see #851) // Tests assert on text in bin/install.js (Codex adapter header prose) — // the adapter text IS the product loaded by Codex agents at runtime. 'use strict'; const { test, describe } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const INSTALL_JS = path.join(__dirname, '..', 'bin', 'install.js'); const src = fs.readFileSync(INSTALL_JS, 'utf8'); // Helper: extract Section C from the raw source text. // Anchors on the heading and ends at . function getSectionC() { // allow-test-rule: source-text-is-the-product (see #851) const headingIdx = src.indexOf('## C. Task() → spawn_agent Mapping'); // allow-test-rule: source-text-is-the-product (see #851) assert.ok(headingIdx >= 0, 'Section C heading must exist in bin/install.js'); const closeTag = src.indexOf('', headingIdx); // allow-test-rule: source-text-is-the-product (see #851) assert.ok(closeTag >= 0, 'Section C must be followed by '); return src.slice(headingIdx, closeTag); } describe('bug #851: Codex adapter documents multi_agent_v1 schema limitation and fallback', () => { // (a) Schema-detection step: the adapter must require the agent to inspect // spawn_agent's parameter schema BEFORE deciding how to dispatch. test('(a) schema-detection: adapter requires inspecting spawn_agent schema before dispatching', () => { const sectionC = getSectionC(); // Must name BOTH schema variants so the agent knows what to look for assert.ok( sectionC.includes('multi_agent_v1'), 'Section C must name the multi_agent_v1 schema to identify the limited form', ); assert.ok( sectionC.includes('multi_agent_v2') || sectionC.includes('agent_type-capable'), 'Section C must name the typed schema (multi_agent_v2 or agent_type-capable) as the capable form', ); // Must instruct schema inspection before spawning assert.ok( sectionC.includes('tool_search') || sectionC.includes('inspect') || sectionC.includes('schema'), 'Section C must instruct the agent to inspect the spawn_agent schema (via tool_search or similar)', ); // All three requirements together (AND): assert.ok( sectionC.includes('multi_agent_v1') && (sectionC.includes('multi_agent_v2') || sectionC.includes('agent_type-capable')) && (sectionC.includes('tool_search') || sectionC.includes('inspect') || sectionC.includes('schema')), 'Section C must require schema-detection: name both schema variants AND instruct inspection before spawning', ); }); // (b) Active-config-root resolution: the TOML path must describe how to // resolve the config root (honoring $CODEX_HOME / --config-dir / --local), // not imply a single fixed path. test('(b) active-config-root: fallback TOML path resolves the active Codex config root', () => { const sectionC = getSectionC(); // Must mention the agents/.toml relative path assert.ok( sectionC.includes('agents/.toml'), 'Section C must reference agents/.toml for the TOML extraction step', ); // Must describe dynamic config-root resolution (at least two of the three // override mechanisms, plus the word "config" to anchor context) const mentionsCodexHome = sectionC.includes('$CODEX_HOME') || sectionC.includes('CODEX_HOME'); const mentionsConfigDir = sectionC.includes('--config-dir') || sectionC.includes('config-dir'); const mentionsLocal = sectionC.includes('--local') || sectionC.includes('.codex') || sectionC.includes('local'); const mentionsConfigRoot = sectionC.includes('config root') || sectionC.includes('config.toml') || sectionC.includes('config directory'); assert.ok( mentionsCodexHome, 'Section C fallback must mention $CODEX_HOME for config-root resolution', ); assert.ok( mentionsConfigDir, 'Section C fallback must mention --config-dir for config-root resolution', ); assert.ok( mentionsLocal, 'Section C fallback must mention --local / .codex for config-root resolution', ); assert.ok( mentionsConfigRoot, 'Section C fallback must describe the concept of an active config root (config.toml or config root/directory)', ); // AND: all four required elements together assert.ok( mentionsCodexHome && mentionsConfigDir && mentionsLocal && mentionsConfigRoot, 'Section C fallback must describe active-config-root resolution: $CODEX_HOME + --config-dir + --local + config-root concept (AND logic)', ); // Must NOT contain the literal ~/.codex/ (would be rewritten by _applyRuntimeRewrites // and cause bug-3582 to diverge) assert.ok( !sectionC.includes('~/.codex/'), 'Section C must NOT contain the literal ~/.codex/ substring (breaks bug-3582 materialization test)', ); }); // (c) "NOT equivalent" label: the workaround must be explicitly labeled as // not equivalent to typed msd-planner/msd-executor execution. test('(c) not-equivalent label: generic-agent workaround is labeled as NOT equivalent to typed dispatch', () => { const sectionC = getSectionC(); // Must name at least one typed agent const namesTypedAgent = sectionC.includes('msd-planner') || sectionC.includes('msd-executor') || sectionC.includes('typed MSD agent') || sectionC.includes('typed msd-'); // Must contain explicit "not equivalent" / "NOT equivalent" / negation language const hasNotEquivalent = sectionC.toLowerCase().includes('not equivalent') || sectionC.includes('NOT equivalent') || sectionC.includes('is NOT possible'); // Must name the workaround as a workaround, not a first-class path const hasWorkaroundLabel = sectionC.includes('workaround') || sectionC.includes('fallback'); assert.ok( namesTypedAgent, 'Section C must name at least one typed MSD agent (msd-planner, msd-executor, or "typed MSD agent")', ); assert.ok( hasNotEquivalent, 'Section C must contain explicit "not equivalent" / "NOT equivalent" language for the generic-agent path', ); assert.ok( hasWorkaroundLabel, 'Section C must label the generic-agent path as a workaround or fallback', ); // AND: all three together assert.ok( namesTypedAgent && hasNotEquivalent && hasWorkaroundLabel, 'Section C must AND: name a typed agent + label it NOT equivalent + call the generic path a workaround/fallback', ); }); // (d) Fail-closed rule: when typed dispatch is mandatory, the adapter must // instruct the agent to fail closed and report the limitation, not silently degrade. test('(d) fail-closed: adapter requires failing closed when typed dispatch is mandatory', () => { const sectionC = getSectionC(); const hasFailClosed = sectionC.includes('fail closed') || sectionC.includes('fail-closed') || sectionC.includes('fail_closed'); const hasReportLimitation = sectionC.includes('schema limitation') || sectionC.includes('report') || sectionC.includes('not silently') || sectionC.includes('silently degrading') || sectionC.includes('silently'); const hasMandatoryContext = sectionC.includes('mandatory') || sectionC.includes('required') || sectionC.includes('worktree isolation') || sectionC.includes('isolation'); assert.ok( hasFailClosed, 'Section C must instruct fail-closed behavior (the phrase "fail closed" or equivalent)', ); assert.ok( hasReportLimitation, 'Section C must instruct reporting the schema limitation rather than silently degrading', ); assert.ok( hasMandatoryContext, 'Section C must identify a context where typed dispatch is mandatory (e.g. worktree isolation)', ); // AND: all three together assert.ok( hasFailClosed && hasReportLimitation && hasMandatoryContext, 'Section C must AND: instruct fail-closed + report limitation + identify mandatory-typed-dispatch contexts', ); }); // Regression guard: typed mapping for capable schema must still be present. test('adapter still documents typed agent_type spawn for sessions that support it', () => { const sectionC = getSectionC(); assert.ok( sectionC.includes('agent_type-capable') || sectionC.includes('multi_agent_v2'), 'Section C must still document the typed schema (agent_type-capable / multi_agent_v2)', ); assert.ok( sectionC.includes('spawn_agent(agent_type=') || sectionC.includes('agent_type="X"'), 'Section C must still show a typed spawn_agent(agent_type=...) example for capable sessions', ); }); // Regression guard: deferred tool discovery must remain (bug-279 contract). test('adapter deferred tool discovery instruction is preserved', () => { // The pre-existing bug-279 contract must remain intact // allow-test-rule: source-text-is-the-product (see #851) assert.ok( src.includes('deferred') && src.includes('tool_search') && src.includes('spawn_agent'), // allow-test-rule: source-text-is-the-product (see #851) 'Adapter must still instruct deferred tool discovery via tool_search before deciding to run inline', ); }); }); }); } // ──────────────────────────────────────────────────────────────────────── // Folded from tests/enh-772-codex-hook-events.test.cjs — consolidation epic #1969 (B1 #1970) // ──────────────────────────────────────────────────────────────────────── { const { describe: __foldDescribe } = require('node:test'); __foldDescribe("folded:enh-772-codex-hook-events (consolidation epic #1969 B1 #1970)", () => { 'use strict'; process.env.MSD_TEST_MODE = '1'; /** * Enhancement #772: Adopt new stable Codex hook events + commandWindows for * Windows parity. * * Codex CLI (rust-v0.137.0) stabilised the full hook-event set. This suite * asserts that a Codex install: * * (a) Registers the 3 new high-value hook events in hooks.json: * - SubagentStart — inject context / MSD_AGENT_NAME awareness at subagent open * - Stop — post-session context headroom tracking * - PostToolUse — mirror the Claude Code PostToolUse context monitor * * (b) Emits `commandWindows` in the SessionStart hooks.json entry so that * Windows users get the .cmd shim path and non-Windows users get the POSIX * node runner command. Both fields are present in the same entry; Codex picks * the right one per its HookHandlerConfig schema * (codex-rs/config/src/hook_config.rs: commandWindows / command_windows alias). * * Note: UserPromptSubmit is NOT wired (same rationale as Qwen #788 — the * msd-prompt-guard handler exits unless tool_name is Write|Edit, so it would be * a silent no-op for the UserPromptSubmit payload shape). * * Test strategy: * - Test new event registration via ensureCodexHooksJsonEvent() directly * (mirrors the #3426 pattern of testing ensureCodexHooksJsonSessionStart * directly with a stub hook file — avoids full install() migration dance). * - Test commandWindows via ensureCodexHooksJsonSessionStart() directly. * - IR-first discipline: assert on the structured result, not rendered text. * * Verified hook event schema: * https://github.com/openai/codex/blob/main/codex-rs/protocol/src/protocol.rs * https://github.com/openai/codex/blob/main/codex/codex-rs/config/src/hook_config.rs */ const { test, describe, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const { ensureCodexHooksJsonSessionStart, ensureCodexHooksJsonEvent, removeCodexHooksJsonEvent, reconcileCodexHooksJsonEvent, } = require('../msd-core/bin/lib/runtime-hooks-surface.cjs'); const { createTempDir, cleanup } = require('./helpers.cjs'); // ─── Helpers ───────────────────────────────────────────────────────────────── /** * Extract all hook handler entries (full objects with type/command/etc.) for * `eventName` from a hooks.json object (flat or nested-hooks shape). */ function hooksJsonHandlersForEvent(hooksJson, eventName) { if (!hooksJson || typeof hooksJson !== 'object') return []; const table = hooksJson.hooks && typeof hooksJson.hooks === 'object' && !Array.isArray(hooksJson.hooks) ? hooksJson.hooks : hooksJson; if (!Array.isArray(table[eventName])) return []; return table[eventName].flatMap(entry => Array.isArray(entry && entry.hooks) ? entry.hooks : [] ); } function readHooksJson(targetDir) { const p = path.join(targetDir, 'hooks.json'); if (!fs.existsSync(p)) return null; return JSON.parse(fs.readFileSync(p, 'utf8')); } function stubHookFile(targetDir, hookName) { const hooksDest = path.join(targetDir, 'hooks'); fs.mkdirSync(hooksDest, { recursive: true }); const dest = path.join(hooksDest, hookName); if (!fs.existsSync(dest)) { fs.writeFileSync(dest, '#!/usr/bin/env node\n// stub\n'); try { fs.chmodSync(dest, 0o755); } catch { /* Windows */ } } } // ─── Suite 1: ensureCodexHooksJsonEvent export surface ─────────────────────── describe('enh-772: export surface — new functions are exported', () => { test('ensureCodexHooksJsonEvent is a function', () => { assert.strictEqual(typeof ensureCodexHooksJsonEvent, 'function', 'ensureCodexHooksJsonEvent must be exported from runtime-hooks-surface.cjs'); }); test('removeCodexHooksJsonEvent is a function', () => { assert.strictEqual(typeof removeCodexHooksJsonEvent, 'function', 'removeCodexHooksJsonEvent must be exported from runtime-hooks-surface.cjs'); }); test('reconcileCodexHooksJsonEvent is a function', () => { assert.strictEqual(typeof reconcileCodexHooksJsonEvent, 'function', 'reconcileCodexHooksJsonEvent must be exported from runtime-hooks-surface.cjs'); }); }); // ─── Suite 2: ensureCodexHooksJsonEvent registers new events ───────────────── describe('enh-772: ensureCodexHooksJsonEvent registers SubagentStart, Stop, PostToolUse', () => { let tmpDir; beforeEach(() => { tmpDir = createTempDir('msd-772-events-'); stubHookFile(tmpDir, 'msd-context-monitor.js'); }); afterEach(() => { cleanup(tmpDir); }); for (const eventName of ['SubagentStart', 'Stop', 'PostToolUse']) { test(`${eventName}: ensureCodexHooksJsonEvent writes hooks.json`, () => { const fakeRunner = '"/usr/local/bin/node"'; const result = ensureCodexHooksJsonEvent(tmpDir, eventName, { absoluteRunner: fakeRunner, platform: 'linux', }); assert.ok(result && result.path, `result must have path for ${eventName}`); assert.ok(result.wrote || result.changed, `ensureCodexHooksJsonEvent must write or change hooks.json for ${eventName}`); assert.ok(fs.existsSync(path.join(tmpDir, 'hooks.json')), `hooks.json must exist after registering ${eventName}`); }); test(`${eventName}: hooks.json contains the event entry`, () => { const fakeRunner = '"/usr/local/bin/node"'; ensureCodexHooksJsonEvent(tmpDir, eventName, { absoluteRunner: fakeRunner, platform: 'linux', }); const hooksJson = readHooksJson(tmpDir); const handlers = hooksJsonHandlersForEvent(hooksJson, eventName); assert.ok(handlers.length > 0, `Expected ${eventName} entry in hooks.json; got: ${JSON.stringify(hooksJson)}`); }); test(`${eventName}: hook entry uses msd-context-monitor`, () => { const fakeRunner = '"/usr/local/bin/node"'; ensureCodexHooksJsonEvent(tmpDir, eventName, { absoluteRunner: fakeRunner, platform: 'linux', }); const hooksJson = readHooksJson(tmpDir); const handlers = hooksJsonHandlersForEvent(hooksJson, eventName); assert.ok( handlers.some(h => h.command && h.command.includes('msd-context-monitor')), `${eventName} hook must use msd-context-monitor; got: ${JSON.stringify(handlers)}` ); }); test(`${eventName}: hook entry has type: 'command'`, () => { const fakeRunner = '"/usr/local/bin/node"'; ensureCodexHooksJsonEvent(tmpDir, eventName, { absoluteRunner: fakeRunner, platform: 'linux', }); const hooksJson = readHooksJson(tmpDir); const handlers = hooksJsonHandlersForEvent(hooksJson, eventName); const entry = handlers.find(h => h.command && h.command.includes('msd-context-monitor')); assert.strictEqual(entry && entry.type, 'command', `${eventName} hook entry must have type 'command'`); }); test(`${eventName}: hook entry has timeout: 10`, () => { const fakeRunner = '"/usr/local/bin/node"'; ensureCodexHooksJsonEvent(tmpDir, eventName, { absoluteRunner: fakeRunner, platform: 'linux', }); const hooksJson = readHooksJson(tmpDir); const handlers = hooksJsonHandlersForEvent(hooksJson, eventName); const entry = handlers.find(h => h.command && h.command.includes('msd-context-monitor')); assert.strictEqual(entry && entry.timeout, 10, `${eventName} hook entry must have timeout 10`); }); } test('null absoluteRunner returns unchanged result without writing', () => { const result = ensureCodexHooksJsonEvent(tmpDir, 'SubagentStart', { absoluteRunner: null, platform: 'linux', }); assert.strictEqual(result.changed, false, 'null runner must return changed: false'); assert.ok(!fs.existsSync(path.join(tmpDir, 'hooks.json')), 'hooks.json must NOT be written when runner is null'); }); }); // ─── Suite 3: commandWindows parity in SessionStart ────────────────────────── describe('enh-772: commandWindows parity — ensureCodexHooksJsonSessionStart emits commandWindows', () => { let tmpDir; beforeEach(() => { tmpDir = createTempDir('msd-772-cmdwin-'); stubHookFile(tmpDir, 'msd-check-update.js'); }); afterEach(() => { cleanup(tmpDir); }); // commandWindows is ONLY emitted on win32 platform (where the .cmd shim is also // written). On POSIX platforms, commandWindows is omitted to avoid pointing Windows // Codex at a non-existent .cmd file (the shim is only present after a native Windows // install that runs buildCodexHookWindowsShimIR and atomicWriteFileSync). test('POSIX platform: commandWindows is NOT emitted (shim not written on POSIX)', () => { const fakeRunner = '"/usr/local/bin/node"'; const result = ensureCodexHooksJsonSessionStart(tmpDir, { absoluteRunner: fakeRunner, platform: 'linux', }); assert.ok(result && result.wrote, 'must write hooks.json on linux'); const hooksJson = readHooksJson(tmpDir); const handlers = hooksJsonHandlersForEvent(hooksJson, 'SessionStart'); assert.ok(handlers.length > 0, `Expected SessionStart handlers; got: ${JSON.stringify(hooksJson)}`); const entry = handlers[0]; assert.ok( entry.commandWindows === undefined, `commandWindows must NOT be emitted on POSIX (shim not written); got: ${JSON.stringify(entry)}` ); }); test('POSIX platform: command references msd-check-update.js (not .cmd)', () => { const fakeRunner = '"/usr/local/bin/node"'; ensureCodexHooksJsonSessionStart(tmpDir, { absoluteRunner: fakeRunner, platform: 'linux', }); const hooksJson = readHooksJson(tmpDir); const handlers = hooksJsonHandlersForEvent(hooksJson, 'SessionStart'); const entry = handlers[0]; assert.ok( entry.command && entry.command.includes('msd-check-update'), `POSIX command must reference msd-check-update; got: ${entry.command}` ); assert.ok( !entry.command.endsWith('.cmd') && !entry.command.endsWith('.cmd"'), `POSIX command must not end with .cmd; got: ${entry.command}` ); }); test('null absoluteRunner: no commandWindows emitted, no write', () => { const result = ensureCodexHooksJsonSessionStart(tmpDir, { absoluteRunner: null, platform: 'linux', }); assert.strictEqual(result.changed, false, 'null runner must return changed: false'); const hooksJson = readHooksJson(tmpDir); if (hooksJson) { const handlers = hooksJsonHandlersForEvent(hooksJson, 'SessionStart'); for (const h of handlers) { assert.ok(!h.commandWindows, `commandWindows must not be present when runner is null; got: ${JSON.stringify(h)}`); } } }); test('Windows platform: SessionStart hook is written with commandWindows pointing to .cmd shim', () => { // On win32, both `command` and `commandWindows` use the .cmd shim path // (because managedCommand = shimIR.hookCommand = .cmd path, and // commandWindows = same .cmd path). This ensures Codex picks the .cmd // on Windows regardless of which field it reads. const fakeRunner = '"C:/Program Files/nodejs/node.exe"'; const result = ensureCodexHooksJsonSessionStart(tmpDir, { absoluteRunner: fakeRunner, platform: 'win32', }); // The shim write and hooks.json write should succeed in the tmp dir. if (result.wrote) { const hooksJson = readHooksJson(tmpDir); const handlers = hooksJsonHandlersForEvent(hooksJson, 'SessionStart'); assert.ok(handlers.length > 0, `SessionStart must be registered on Windows path; got: ${JSON.stringify(hooksJson)}`); const entry = handlers[0]; assert.ok(typeof entry.commandWindows === 'string', `commandWindows must be present on Windows path; got: ${JSON.stringify(entry)}`); // commandWindows should reference the .cmd shim assert.ok( entry.commandWindows.includes('msd-check-update') && entry.commandWindows.includes('.cmd'), `commandWindows must reference msd-check-update.cmd on win32; got: ${entry.commandWindows}` ); } }); }); // ─── Suite 4: idempotency ──────────────────────────────────────────────────── describe('enh-772: ensureCodexHooksJsonEvent is idempotent', () => { let tmpDir; beforeEach(() => { tmpDir = createTempDir('msd-772-idem-'); stubHookFile(tmpDir, 'msd-context-monitor.js'); }); afterEach(() => { cleanup(tmpDir); }); for (const eventName of ['SubagentStart', 'Stop', 'PostToolUse']) { test(`${eventName}: calling twice does not duplicate hook entries`, () => { const fakeRunner = '"/usr/local/bin/node"'; const opts = { absoluteRunner: fakeRunner, platform: 'linux' }; ensureCodexHooksJsonEvent(tmpDir, eventName, opts); ensureCodexHooksJsonEvent(tmpDir, eventName, opts); const hooksJson = readHooksJson(tmpDir); const handlers = hooksJsonHandlersForEvent(hooksJson, eventName); assert.strictEqual(handlers.length, 1, `${eventName} should have exactly 1 hook handler after idempotent re-register; got ${handlers.length}: ${JSON.stringify(handlers)}`); }); } }); // ─── Suite 5: removeCodexHooksJsonEvent ────────────────────────────────────── describe('enh-772: removeCodexHooksJsonEvent removes managed entries', () => { let tmpDir; beforeEach(() => { tmpDir = createTempDir('msd-772-remove-'); stubHookFile(tmpDir, 'msd-context-monitor.js'); }); afterEach(() => { cleanup(tmpDir); }); for (const eventName of ['SubagentStart', 'Stop', 'PostToolUse']) { test(`${eventName}: removeCodexHooksJsonEvent removes the managed entry`, () => { const fakeRunner = '"/usr/local/bin/node"'; ensureCodexHooksJsonEvent(tmpDir, eventName, { absoluteRunner: fakeRunner, platform: 'linux', }); // Verify it was registered let hooksJson = readHooksJson(tmpDir); let handlers = hooksJsonHandlersForEvent(hooksJson, eventName); assert.ok(handlers.length > 0, `${eventName} must be registered before removal`); // Remove const result = removeCodexHooksJsonEvent(tmpDir, eventName); assert.ok(result.changed || result.wrote, `removeCodexHooksJsonEvent must change hooks.json for ${eventName}`); hooksJson = readHooksJson(tmpDir); if (hooksJson) { handlers = hooksJsonHandlersForEvent(hooksJson, eventName); assert.strictEqual(handlers.length, 0, `After removal, ${eventName} should have 0 handlers; got: ${JSON.stringify(handlers)}`); } }); } }); // ─── Suite 6: reconcileCodexHooksJsonEvent preserves user entries ───────────── describe('enh-772: reconcileCodexHooksJsonEvent preserves user-owned entries', () => { let tmpDir; beforeEach(() => { tmpDir = createTempDir('msd-772-preserve-'); }); afterEach(() => { cleanup(tmpDir); }); test('user-owned SubagentStart entry is preserved when MSD entry is registered', () => { const hooksJsonPath = path.join(tmpDir, 'hooks.json'); const userEntry = { hooks: [{ type: 'command', command: 'my-custom-hook.sh' }] }; fs.writeFileSync(hooksJsonPath, JSON.stringify({ SubagentStart: [userEntry] }, null, 2) + '\n'); reconcileCodexHooksJsonEvent(tmpDir, 'SubagentStart', { managedCommand: '"/usr/local/bin/node" "/home/me/.codex/hooks/msd-context-monitor.js"', }); const hooksJson = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); const table = hooksJson.hooks || hooksJson; const entries = Array.isArray(table.SubagentStart) ? table.SubagentStart : []; // Should have 2 entries: user entry + MSD entry assert.ok(entries.length >= 2, `User entry must be preserved; got entries: ${JSON.stringify(entries)}`); // User entry must still be present const userEntryStillPresent = entries.some(e => Array.isArray(e.hooks) && e.hooks.some(h => h.command === 'my-custom-hook.sh') ); assert.ok(userEntryStillPresent, `User entry must survive MSD registration; entries: ${JSON.stringify(entries)}`); }); }); }); } // ──────────────────────────────────────────────────────────────────────── // Folded from tests/bug-2866-codex-strip-no-trailing-newline.test.cjs — consolidation epic #1969 (B8 #1977) // ──────────────────────────────────────────────────────────────────────── { const { describe: __foldDescribe } = require('node:test'); __foldDescribe("folded:bug-2866-codex-strip-no-trailing-newline (consolidation epic #1969 B8 #1977)", () => { /** * Bug #2866: Codex Installer (RC.7) fails to strip legacy flat hooks if * trailing newline is missing. * * The cleanup regexes in `bin/install.js` matched stale MSD hook blocks * via `\r?\n` at the end. When a stale block sat at end-of-file without * a trailing newline (very common — many editors strip them, and the * legacy installer never wrote one), no shape stripped, the installer * saw `msd-check-update` already present, skipped writing the new * Nested-AoT block, and Codex 0.125+ refused to load with * "invalid type: map, expected a sequence in `hooks`" * * Fix: every shape's terminator is now `(?:\r?\n|$)` so end-of-file * counts as a valid terminator. The strip logic was lifted into a pure * helper, `stripStaleMsdHookBlocks(configContent)`, exported from * `bin/install.js` for direct test coverage. * * This test parses `package.json` to require `bin/install.js` * structurally (not by hardcoded path), then drives each historical * shape through the helper twice — once with a trailing newline, once * without — and asserts both are stripped. */ 'use strict'; process.env.MSD_TEST_MODE = '1'; const { describe, test } = require('node:test'); const assert = require('node:assert/strict'); const path = require('node:path'); const fs = require('node:fs'); const REPO_ROOT = path.join(__dirname, '..'); const pkg = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf-8')); const installPath = path.resolve(REPO_ROOT, pkg.bin['msd-core']); const { stripStaleMsdHookBlocks } = require(installPath); /** * Parse the TOML output line-structurally so assertions check shape, not * substring presence in raw text. Comments are dropped, table headers are * recorded, and string-valued keys are captured. Sufficient for the small, * well-formed TOML produced by these tests. */ function parseTomlShape(text) { const tableHeaders = []; const keys = new Map(); // dotted path → string value (last-write-wins, fine for these inputs) let currentTable = ''; for (const rawLine of text.split('\n')) { const line = rawLine.replace(/(?:^|\s)#.*$/, '').trim(); if (!line) continue; const tableMatch = line.match(/^\[(\[)?([^\]]+)\]?\]$/); if (tableMatch) { currentTable = tableMatch[2]; tableHeaders.push((tableMatch[1] ? '[[' : '[') + currentTable + (tableMatch[1] ? ']]' : ']')); continue; } const kvMatch = line.match(/^([A-Za-z_][\w-]*)\s*=\s*(.*)$/); if (kvMatch) { const key = currentTable ? `${currentTable}.${kvMatch[1]}` : kvMatch[1]; const value = kvMatch[2].replace(/^"(.*)"$/, '$1'); keys.set(key, value); } } return { tableHeaders, keys }; } const SHAPES = { 'Shape 1 (legacy msd-update-check)': [ '# MSD Hooks', '[[hooks]]', 'event = "SessionStart"', 'command = "node /Users/USER/.codex/hooks/msd-update-check.js"', ].join('\n'), 'Shape 2 (flat [[hooks]] + msd-check-update)': [ '# MSD Hooks', '[[hooks]]', 'event = "SessionStart"', 'command = "node /Users/USER/.codex/hooks/msd-check-update.js"', ].join('\n'), 'Shape 3 ([[hooks.SessionStart]] without nested .hooks)': [ '# MSD Hooks', '[[hooks.SessionStart]]', 'command = "node /Users/USER/.codex/hooks/msd-check-update.js"', ].join('\n'), 'Shape 4 (nested [[hooks.SessionStart]] + [[hooks.SessionStart.hooks]])': [ '# MSD Hooks', '[[hooks.SessionStart]]', '', '[[hooks.SessionStart.hooks]]', 'type = "command"', 'command = "node /Users/USER/.codex/hooks/msd-check-update.js"', ].join('\n'), }; describe('bug-2866: stripStaleMsdHookBlocks handles end-of-file without trailing newline', () => { test('stripStaleMsdHookBlocks is exported from bin/install.js', () => { assert.strictEqual(typeof stripStaleMsdHookBlocks, 'function', 'bin/install.js must export stripStaleMsdHookBlocks'); }); function assertStripped(out, shape, scenario) { const shape_ = parseTomlShape(out); const hooksTable = shape_.tableHeaders.find((h) => /^\[\[?hooks(\.|]\])/.test(h)); assert.strictEqual(hooksTable, undefined, `(${shape}, ${scenario}) no hooks table header may remain after strip, got tables: ${shape_.tableHeaders.join(', ')}`); const staleCmd = [...shape_.keys.entries()].find(([_, v]) => /msd-(update-check|check-update)/.test(v)); assert.strictEqual(staleCmd, undefined, `(${shape}, ${scenario}) no key may carry a stale msd-*-update command, got: ${staleCmd && staleCmd.join('=')}`); assert.strictEqual(shape_.keys.get('history.persistence'), 'save-all', `(${shape}, ${scenario}) history.persistence must be preserved as "save-all"`); } for (const [shape, block] of Object.entries(SHAPES)) { test(`${shape}: stripped when terminated by trailing newline`, () => { const input = `[history]\npersistence = "save-all"\n${block}\n`; assertStripped(stripStaleMsdHookBlocks(input), shape, 'with trailing newline'); }); test(`${shape}: stripped when at end-of-file without trailing newline`, () => { // The reporter's repro: stale block sits at the very end with no \n. const input = `[history]\npersistence = "save-all"\n${block}`; assertStripped(stripStaleMsdHookBlocks(input), shape, 'no trailing newline'); }); } test('returns input unchanged when no MSD hook block is present', () => { const benign = '[history]\npersistence = "save-all"\n'; const out = stripStaleMsdHookBlocks(benign); assert.strictEqual(out, benign, 'helper must be a no-op when no MSD reference exists'); const benignShape = parseTomlShape(out); assert.strictEqual(benignShape.keys.get('history.persistence'), 'save-all', 'parsed shape must preserve history.persistence'); assert.deepStrictEqual(benignShape.tableHeaders, ['[history]'], 'parsed shape must contain only the [history] table'); }); // The structural rewrite (TOML-AST-driven, not regex-driven) must handle // whitespace and key-ordering variations that the previous regex missed. // These cases were silently leaked by the old implementation; one // (V3) actually corrupted the file by leaving an orphaned key=value line // outside any table. const VARIATIONS = { 'extra blank line in Shape 4': [ '# MSD Hooks', '[[hooks.SessionStart]]', '', '', '[[hooks.SessionStart.hooks]]', 'type = "command"', 'command = "node /Users/USER/.codex/hooks/msd-check-update.js"', ].join('\n'), 'keys reordered (command before event in Shape 2)': [ '# MSD Hooks', '[[hooks]]', 'command = "node /Users/USER/.codex/hooks/msd-check-update.js"', 'event = "SessionStart"', ].join('\n'), 'extra key alongside command (Shape 3 + timeout)': [ '# MSD Hooks', '[[hooks.SessionStart]]', 'command = "node /Users/USER/.codex/hooks/msd-check-update.js"', 'timeout = 5000', ].join('\n'), 'tight whitespace (no spaces around `=`)': [ '# MSD Hooks', '[[hooks]]', 'event="SessionStart"', 'command="node /Users/USER/.codex/hooks/msd-check-update.js"', ].join('\n'), }; for (const [variation, block] of Object.entries(VARIATIONS)) { test(`variation stripped: ${variation}`, () => { const input = `[history]\npersistence = "save-all"\n${block}\n`; assertStripped(stripStaleMsdHookBlocks(input), variation, 'with trailing newline'); }); test(`variation stripped at EOF without trailing newline: ${variation}`, () => { const input = `[history]\npersistence = "save-all"\n${block}`; assertStripped(stripStaleMsdHookBlocks(input), variation, 'no trailing newline'); }); } test('user-authored [[hooks.UserPromptSubmit]] is preserved', () => { // The structural strip must not touch hook tables that don't carry a // MSD-managed `msd-(check-update|update-check).js` command. const input = [ '[history]', 'persistence = "save-all"', '[[hooks.UserPromptSubmit]]', 'command = "node /Users/USER/my-hook.js"', '', ].join('\n'); const out = stripStaleMsdHookBlocks(input); const shape = parseTomlShape(out); assert.ok( shape.tableHeaders.includes('[[hooks.UserPromptSubmit]]'), `user-authored [[hooks.UserPromptSubmit]] must survive, got: ${shape.tableHeaders.join(', ')}`, ); assert.strictEqual( shape.keys.get('hooks.UserPromptSubmit.command'), 'node /Users/USER/my-hook.js', 'user-authored command value must be preserved verbatim', ); }); test('Shape 4 strip does not leave an orphaned [[hooks.SessionStart]] header', () => { // Shape 4 is stripped before Shape 3 specifically to avoid this. const block = SHAPES['Shape 4 (nested [[hooks.SessionStart]] + [[hooks.SessionStart.hooks]])']; const out = stripStaleMsdHookBlocks(`[history]\npersistence = "save-all"\n${block}`); const outShape = parseTomlShape(out); const orphan = outShape.tableHeaders.find((h) => /hooks\.SessionStart/.test(h)); assert.strictEqual(orphan, undefined, `Shape 4 strip must remove the parent [[hooks.SessionStart]] header too, got tables: ${outShape.tableHeaders.join(', ')}`); }); }); }); } // ─── #2586: stop installing Codex context-monitor hooks without metrics ──── // msd-context-monitor.js reads a statusline bridge file Codex never writes, // so every registered event was a guaranteed silent no-op. These tests drive // the REAL install()/uninstall() entry points against a real temp CODEX_HOME // — never a hand-fabricated manifest (see PR #2709's Blocker 1: its cleanup // path was unreachable in production because its tests only exercised a // fixture, not real installer state). describe('#2586 Codex context-monitor: stop installing, clean up on reinstall', () => { const { cleanupOrphanedCodexContextMonitorScript, isMsdOwnedCodexContextMonitorScript, hooksJsonReferencesCodexContextMonitor, } = require('../msd-core/bin/lib/runtime-hooks-surface.cjs'); let codexHome; beforeEach(() => { codexHome = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-codex-2586-')); }); afterEach(() => { cleanup(codexHome); delete process.env.MSD_ALLOW_SYMLINKED_DEST; }); function hooksJsonPath(home) { return path.join(home, 'hooks.json'); } function readHooksJson(home) { const raw = fs.readFileSync(hooksJsonPath(home), 'utf8'); return JSON.parse(raw); } function monitorScriptPath(home) { return path.join(home, 'hooks', 'msd-context-monitor.js'); } function monitorCmdShimPath(home) { return path.join(home, 'hooks', 'msd-context-monitor.cmd'); } // uninstall(), unlike install(), does not sandbox CODEX_HOME/HOME itself — // runCodexInstall's own env-restore in its `finally` means those are back // to the real environment by the time a bare `uninstall(true, 'codex')` // would run. Mirrors runCodexInstall's own sandboxing exactly so uninstall // operates on the temp fixture, never the real ~/.codex. function runCodexUninstall(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; process.env.HOME = codexHome; process.env.USERPROFILE = codexHome; try { process.chdir(cwd); return uninstall(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; } } // The exact shape a real pre-#2586 install would have written: the shipped // msd-context-monitor.js content (with its ownership markers intact) plus // hooks.json registrations for every CODEX_EXTENDED_HOOK_EVENTS member, // using the same command-projection shape ensureCodexHooksJsonEvent used // to write. Built from the real resolveNodeRunner() output, not a literal // guess at the command string, so a drift in projectManagedHookCommand's // output shape cannot make this fixture silently stop matching reality. function seedPreExisting2586Install(home) { fs.mkdirSync(path.join(home, 'hooks'), { recursive: true }); // A literal fixture carrying the same ownership markers // isMsdOwnedCodexContextMonitorScript looks for, built as a string // (never read+string-matched from the real shipped source file — see // this repo's "no source grep" test rule). const fixtureContent = [ '#!/usr/bin/env node', '// msd-hook-version: 1.12.0', '// Context Monitor - PostToolUse/AfterTool hook', 'process.exit(0);', '', ].join('\n'); fs.writeFileSync(monitorScriptPath(home), fixtureContent, 'utf8'); const runner = resolveNodeRunner(); const hooksSurface = require('../msd-core/bin/lib/runtime-hooks-surface.cjs'); for (const eventName of CODEX_EXTENDED_HOOK_EVENTS) { hooksSurface.ensureCodexHooksJsonEvent(home, eventName, { absoluteRunner: runner, platform: process.platform, }); } } test('fresh install does not copy msd-context-monitor.js or register any extended event', () => { runCodexInstall(codexHome); assert.strictEqual(fs.existsSync(monitorScriptPath(codexHome)), false, 'msd-context-monitor.js must not be copied on a fresh Codex install'); assert.strictEqual(fs.existsSync(monitorCmdShimPath(codexHome)), false); assert.strictEqual(fs.existsSync(path.join(codexHome, 'hooks', 'lib', 'hook-exit.js')), false, 'hook-exit.js is only required by msd-context-monitor.js — nothing else staged should pull it in'); assert.ok(fs.existsSync(path.join(codexHome, 'hooks', 'msd-check-update.js')), 'msd-check-update.js must still be staged'); assert.ok(fs.existsSync(path.join(codexHome, 'hooks', 'msd-check-update-worker.js'))); assert.ok(fs.existsSync(path.join(codexHome, 'hooks', 'managed-hooks-registry.cjs'))); if (fs.existsSync(hooksJsonPath(codexHome))) { assert.strictEqual(hooksJsonReferencesCodexContextMonitor(codexHome), false); } }); test('reinstall removes exact pre-#2586 registrations for every extended event and deletes the orphaned script', () => { runCodexInstall(codexHome); seedPreExisting2586Install(codexHome); assert.ok(fs.existsSync(monitorScriptPath(codexHome)), 'fixture sanity: script seeded'); assert.strictEqual(hooksJsonReferencesCodexContextMonitor(codexHome), true, 'fixture sanity: registered'); runCodexInstall(codexHome); assert.strictEqual(fs.existsSync(monitorScriptPath(codexHome)), false, 'orphaned msd-context-monitor.js must be removed once unreferenced and MSD-owned'); assert.strictEqual(hooksJsonReferencesCodexContextMonitor(codexHome), false, 'no hooks.json entry may still reference msd-context-monitor after reinstall'); // msd-check-update's own SessionStart registration must survive untouched. const hooks = readHooksJson(codexHome); const sessionStart = (hooks.hooks && hooks.hooks.SessionStart) || []; const hasCheckUpdate = sessionStart.some((entry) => (entry.hooks || []).some((h) => typeof h.command === 'string' && /msd-check-update/.test(h.command))); assert.ok(hasCheckUpdate, 'msd-check-update SessionStart registration must remain after cleanup'); }); test('reinstall preserves a hand-customized registration and does not delete a still-referenced script', () => { runCodexInstall(codexHome); seedPreExisting2586Install(codexHome); // Hand-edit ONE event's entry to a shape isManagedHookCommand will not // recognize (wraps the invocation in a shell script it does not know). const before = readHooksJson(codexHome); before.hooks.Stop = [{ hooks: [{ type: 'command', command: 'bash -c "/opt/custom/my-wrapper.sh"' }] }]; fs.writeFileSync(hooksJsonPath(codexHome), JSON.stringify(before, null, 2) + '\n', 'utf8'); runCodexInstall(codexHome); const after = readHooksJson(codexHome); assert.deepStrictEqual(after.hooks.Stop, before.hooks.Stop, 'a hand-customized registration must survive verbatim'); }); test('reinstall leaves unrelated hooks.json events and config.toml keys untouched', () => { runCodexInstall(codexHome); let hooks = fs.existsSync(hooksJsonPath(codexHome)) ? readHooksJson(codexHome) : { hooks: {} }; if (!hooks.hooks) hooks.hooks = {}; hooks.hooks.UnrelatedEvent = [{ hooks: [{ type: 'command', command: 'echo unrelated' }] }]; fs.writeFileSync(hooksJsonPath(codexHome), JSON.stringify(hooks, null, 2) + '\n', 'utf8'); const configPath = path.join(codexHome, 'config.toml'); const configBefore = fs.readFileSync(configPath, 'utf8') + '\n[my_unrelated_section]\nfoo = "bar"\n'; fs.writeFileSync(configPath, configBefore, 'utf8'); runCodexInstall(codexHome); const after = readHooksJson(codexHome); assert.deepStrictEqual(after.hooks.UnrelatedEvent, hooks.hooks.UnrelatedEvent, 'an unrelated event array must be untouched'); const configAfter = fs.readFileSync(configPath, 'utf8'); assert.ok(configAfter.includes('[my_unrelated_section]\nfoo = "bar"'), 'unrelated config.toml section must survive a Codex reinstall'); }); test('a user-owned pre-existing EMPTY event array is preserved, not deleted', () => { runCodexInstall(codexHome); fs.writeFileSync(hooksJsonPath(codexHome), JSON.stringify({ hooks: { Stop: [] } }, null, 2) + '\n', 'utf8'); runCodexInstall(codexHome); const after = readHooksJson(codexHome); assert.ok(Array.isArray(after.hooks.Stop) && after.hooks.Stop.length === 0, 'an empty array the user already had must not be dropped by cleanup that found nothing MSD-owned to remove'); }); test('symlinked hooks.json aborts before any Codex change, without MSD_ALLOW_SYMLINKED_DEST', () => { runCodexInstall(codexHome); const configPath = path.join(codexHome, 'config.toml'); const configBefore = fs.readFileSync(configPath, 'utf8'); const realHooksJson = path.join(codexHome, 'real-hooks.json'); fs.writeFileSync(realHooksJson, JSON.stringify({ hooks: {} }, null, 2) + '\n', 'utf8'); fs.unlinkSync(hooksJsonPath(codexHome)); fs.symlinkSync(realHooksJson, hooksJsonPath(codexHome)); assert.throws(() => runCodexInstall(codexHome), /symlink/i); assert.ok(fs.lstatSync(hooksJsonPath(codexHome)).isSymbolicLink(), 'the symlink itself must survive an aborted install — never replaced by a plain file'); assert.strictEqual(fs.readFileSync(configPath, 'utf8'), configBefore, 'config.toml must be restored to its pre-attempt snapshot on abort'); }); test('symlinked hooks.json is followed when MSD_ALLOW_SYMLINKED_DEST=1', () => { runCodexInstall(codexHome); const realHooksJson = path.join(codexHome, 'real-hooks.json'); fs.writeFileSync(realHooksJson, JSON.stringify({ hooks: {} }, null, 2) + '\n', 'utf8'); fs.unlinkSync(hooksJsonPath(codexHome)); fs.symlinkSync(realHooksJson, hooksJsonPath(codexHome)); process.env.MSD_ALLOW_SYMLINKED_DEST = '1'; assert.doesNotThrow(() => runCodexInstall(codexHome)); assert.ok(fs.lstatSync(hooksJsonPath(codexHome)).isSymbolicLink(), 'still a symlink afterward'); assert.ok(fs.existsSync(realHooksJson), 'the symlink target must have been written through'); }); test('cleanupOrphanedCodexContextMonitorScript keeps the hooks.json deregistration when script deletion fails', () => { runCodexInstall(codexHome); seedPreExisting2586Install(codexHome); for (const eventName of CODEX_EXTENDED_HOOK_EVENTS) { require('../msd-core/bin/lib/runtime-hooks-surface.cjs').removeCodexHooksJsonEvent(codexHome, eventName); } assert.strictEqual(hooksJsonReferencesCodexContextMonitor(codexHome), false, 'deregistration committed first'); const originalUnlinkSync = fs.unlinkSync; fs.unlinkSync = (target, ...rest) => { if (typeof target === 'string' && target.includes('msd-context-monitor')) { throw Object.assign(new Error('EPERM: simulated'), { code: 'EPERM' }); } return originalUnlinkSync.call(fs, target, ...rest); }; // Determine which MSD-owned candidates actually exist BEFORE the mocked // deletion attempt — on Windows, ensureCodexHooksJsonEvent also staged a // .cmd shim alongside the .js file (see buildCodexHookWindowsShimIR), so // both deletions fail under the mock above; on POSIX only the .js file // exists. Asserting against this rather than a hardcoded 1 keeps the row // meaningful on both platforms instead of just loosening it to "at least // one" (see CI failure: Windows reported 2 warnings, not 1). const cmdShimPath = monitorCmdShimPath(codexHome); const cmdShimExisted = fs.existsSync(cmdShimPath); let result; try { result = cleanupOrphanedCodexContextMonitorScript(codexHome); } finally { fs.unlinkSync = originalUnlinkSync; } const expectedWarningCount = cmdShimExisted ? 2 : 1; assert.strictEqual(result.warnings.length, expectedWarningCount); assert.ok(result.warnings.some((w) => /msd-context-monitor\.js$/.test(w.path)), 'a warning must name the .js script'); if (cmdShimExisted) { assert.ok(result.warnings.some((w) => /msd-context-monitor\.cmd$/.test(w.path)), 'a warning must name the .cmd shim when Windows staged one'); assert.ok(fs.existsSync(cmdShimPath), 'the .cmd shim remains on disk since its deletion failed too'); } assert.strictEqual(hooksJsonReferencesCodexContextMonitor(codexHome), false, 'the already-safe hooks.json deregistration must not be reverted by a script-deletion failure'); assert.ok(fs.existsSync(monitorScriptPath(codexHome)), 'the file remains on disk since deletion failed'); }); test('isMsdOwnedCodexContextMonitorScript rejects a user file at the same path', () => { runCodexInstall(codexHome); fs.mkdirSync(path.join(codexHome, 'hooks'), { recursive: true }); fs.writeFileSync(monitorScriptPath(codexHome), '#!/usr/bin/env node\nconsole.log("my own script");\n', 'utf8'); assert.strictEqual(isMsdOwnedCodexContextMonitorScript(monitorScriptPath(codexHome)), false); }); test('uninstall removes recognized registrations and the orphaned script symmetrically with install', () => { runCodexInstall(codexHome); seedPreExisting2586Install(codexHome); runCodexUninstall(codexHome); assert.strictEqual(fs.existsSync(monitorScriptPath(codexHome)), false); if (fs.existsSync(hooksJsonPath(codexHome))) { assert.strictEqual(hooksJsonReferencesCodexContextMonitor(codexHome), false); } }); test('uninstall does not throw on an unmodeled hooks.json event-value shape', () => { runCodexInstall(codexHome); fs.writeFileSync(hooksJsonPath(codexHome), JSON.stringify({ hooks: { Stop: 'not-an-array' } }, null, 2) + '\n', 'utf8'); assert.doesNotThrow(() => runCodexUninstall(codexHome)); }); test('property: reconcileCodexHooksJsonEvent never removes a non-managed-shape command', () => { const { reconcileCodexHooksJsonEvent } = require('../msd-core/bin/lib/runtime-hooks-surface.cjs'); fc.assert( fc.property( fc.array(fc.string({ minLength: 1, maxLength: 40 }).filter((s) => !/msd-context-monitor|msd-check-update/.test(s)), { minLength: 1, maxLength: 5 }), (customCommands) => { const home = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-codex-2586-prop-')); try { const seeded = { hooks: { Stop: [{ hooks: customCommands.map((c) => ({ type: 'command', command: c })) }] } }; fs.writeFileSync(path.join(home, 'hooks.json'), JSON.stringify(seeded, null, 2) + '\n', 'utf8'); reconcileCodexHooksJsonEvent(home, 'Stop', { managedCommand: null }); const after = JSON.parse(fs.readFileSync(path.join(home, 'hooks.json'), 'utf8')); const survivingCommands = ((after.hooks && after.hooks.Stop) || []) .flatMap((entry) => (entry.hooks || []).map((h) => h.command)); for (const c of customCommands) { assert.ok(survivingCommands.includes(c), `non-managed command "${c}" must survive removal`); } } finally { cleanup(home); } }, ), { numRuns: 25 }, ); }); });