Files
msd-core/tests/codex-declarative-reference.test.cjs
Tom Boucher cd6665d73b fix(#2406): stop Codex config.toml from double-registering agent roles (#2432)
* fix(#2406): stop Codex config.toml from double-registering agent roles

generateCodexConfigBlock emitted an [agents.<name>] role table per agent
pointing config_file back at the standalone agents/<name>.toml Codex
already auto-discovers, so every install declared each role twice in
one config layer and Codex logged a duplicate-role warning per agent.
Remove the redundant role-table loop; the standalone per-agent TOML is
now the sole canonical registration source. The existing marker-truncate
and leaked-section stripping in mergeCodexConfig already clean up legacy
[agents.gsd-*] tables from prior installs, so updates converge to zero
duplicates without any new migration path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#2406): regenerate fixtures + lint gate-prep

* fix(#2406): repair failing tests after gate verification

* docs(#2406): add changeset for Codex duplicate agent-role fix

Adds the missing .changeset/*.md fragment for the Codex config.toml
double-registration fix, closing the PR-gate finding from review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#2406): backfill changeset pr (#2432)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 12:53:22 -04:00

299 lines
16 KiB
JavaScript

// allow-test-rule: AC2 requires asserting no `runtime === 'codex'` string-equality and no positive `isCodex` branch remain in bin/install.js/src — the descriptor-migration contract is a property of the source text, so a source-grep is the only faithful check (#2088)
'use strict';
/**
* codex declarative reference host — ADR-1239 Phase D / #2088 (EoS/codex).
*
* Proves Codex is driven through the PUBLIC Host-Integration Interface (the
* declarative embedding mode), that its negotiated axes classify + negotiate
* correctly (including the documented `maxDepth === 1 → flat` dispatch
* degradation), that negotiation fails CLOSED on a corrupted descriptor, that
* the three Context7-verified UPGRADES land on the user-reachable surface
* (skill-root → $HOME/.agents/skills, the 6 new hooks.json lifecycle events, and
* explicit `[agents] max_depth` dispatch tuning), and that the migration retired
* the hardcoded `runtime === 'codex'` / positive-`isCodex` projection (folded
* into descriptor-driven `runtime.hostBehaviors`).
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
process.env.GSD_TEST_MODE = '1';
const { createImperativeAdapter } = require('../gsd-core/bin/lib/adapter-imperative.cjs');
const { createDeclarativeAdapter } = require('../gsd-core/bin/lib/adapter-declarative.cjs');
const {
profileOf,
negotiateHostCapabilities,
degradationFor,
PROFILE_BASELINES,
UNDOCUMENTED,
} = require('../gsd-core/bin/lib/host-integration.cjs');
const install = require('../bin/install.js');
const { cleanup } = require('./helpers.cjs');
const CODEX_CAP = JSON.parse(
fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'codex', 'capability.json'), 'utf8'),
);
const CODEX_AXES = CODEX_CAP.runtime.hostIntegration;
// -- AC2: driven through the public interface (declarative embedding mode) ----
test('codex axes classify as the declarative-cli reference profile', () => {
assert.equal(profileOf(CODEX_AXES), 'declarative-cli');
});
test('createDeclarativeAdapter classifies codex as declarative + delegates install to the engine', () => {
const adapter = createDeclarativeAdapter({ runtime: 'codex' });
assert.equal(adapter.kind, 'declarative');
assert.equal(adapter.runtime, 'codex');
assert.equal(typeof adapter.install, 'function');
assert.equal(typeof adapter.uninstall, 'function');
});
test('the composed-registry install adapter drives codex install/uninstall', () => {
const adapter = createImperativeAdapter({ runtime: 'codex' });
assert.equal(adapter.kind, 'imperative');
assert.equal(adapter.runtime, 'codex');
assert.ok(adapter.registry && typeof adapter.registry === 'object');
assert.equal(typeof adapter.install, 'function');
assert.equal(typeof adapter.uninstall, 'function');
});
// -- AC3: every negotiated axis populated (no undocumented sentinel) ----------
test('every codex hostIntegration axis is populated (zero undocumented sentinels)', () => {
const axisVals = [
CODEX_AXES.embeddingMode, CODEX_AXES.commandSurface, CODEX_AXES.modelMode,
CODEX_AXES.hookBus, CODEX_AXES.stateIO, CODEX_AXES.transport, CODEX_AXES.runtime,
];
for (const v of axisVals) {
assert.notEqual(v, UNDOCUMENTED, `axis must be documented, got ${v}`);
assert.ok(typeof v === 'string' && v.length > 0);
}
const d = CODEX_AXES.dispatch;
for (const k of ['namedDispatch', 'nested', 'maxDepth', 'subagentToolkit', 'background', 'backgroundDispatch']) {
assert.notEqual(d[k], UNDOCUMENTED, `dispatch.${k} must be documented`);
assert.notEqual(d[k], undefined, `dispatch.${k} must be present`);
}
assert.equal(d.embeddingMode, undefined); // sanity: dispatch has no stray keys
});
// -- AC5: dispatch degrades to flat because maxDepth === 1 -------------------
test('dispatch degrades to FLAT for codex — maxDepth===1 even though nested/background are all true', () => {
assert.equal(CODEX_AXES.dispatch.nested, true);
assert.equal(CODEX_AXES.dispatch.background, true);
assert.equal(CODEX_AXES.dispatch.backgroundDispatch, true);
assert.equal(CODEX_AXES.dispatch.maxDepth, 1);
const flat = degradationFor('dispatch', CODEX_AXES);
assert.equal(flat.level, 'degraded', 'maxDepth===1 must degrade dispatch, not grant full nesting');
assert.match(flat.fallback, /flat dispatch/, 'the documented fallback is flat/inline waves');
// Prove maxDepth is the cause: at depth 2 the same axes grant full dispatch.
const deeper = { ...CODEX_AXES, dispatch: { ...CODEX_AXES.dispatch, maxDepth: 2 } };
assert.equal(degradationFor('dispatch', deeper).level, 'full');
});
// -- AC5: negotiation fails CLOSED on a corrupted descriptor ------------------
test('negotiateHostCapabilities never throws for codex, even fully corrupted', () => {
assert.doesNotThrow(() => negotiateHostCapabilities({}));
assert.doesNotThrow(() => negotiateHostCapabilities({ ...CODEX_AXES, embeddingMode: UNDOCUMENTED }));
assert.doesNotThrow(() => negotiateHostCapabilities({ ...CODEX_AXES, embeddingMode: 'future-unknown' }));
});
test('a partial/empty codex descriptor degrades to the safe floor, not the declarative-cli baseline', () => {
const result = negotiateHostCapabilities({});
assert.equal(result.effective.embeddingMode, 'declarative', 'omitted embeddingMode degrades closed');
assert.equal(result.effective.hookBus, 'none');
assert.notDeepEqual(result.effective, PROFILE_BASELINES['declarative-cli']);
assert.ok(result.warnings.length > 0);
});
// -- AC2: the hardcoded projection is retired --------------------------------
test('codex descriptor declares runtime.hostBehaviors (the folded-in behaviors)', () => {
const hb = CODEX_CAP.runtime.hostBehaviors;
assert.ok(hb && typeof hb === 'object');
assert.equal(hb.tomlConfigInstall, true, 'config.toml + agent-toml + hooks.json install runs through the descriptor gate');
assert.equal(hb.cleanupSkillSidecars, true);
assert.equal(hb.agentTomlFiles, true);
assert.equal(hb.frontmatterDialect, 'codex');
assert.equal(hb.reapplyCommand, '$gsd-update --reapply');
});
test('no `runtime === "codex"` string-equality and no positive `isCodex` gate remain in the install source (AC2)', () => {
const strip = (src) => src
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\/\/[^\r\n]*/g, '')
.replace(/`[^`]*`/g, '');
for (const rel of ['bin/install.js', 'src/install-engine.cts', 'src/runtime-artifact-conversion.cts']) {
const raw = fs.readFileSync(path.join(__dirname, '..', rel), 'utf8');
const src = strip(raw);
const stringEq = src.match(/runtime\s*[!=]==\s*'codex'/g) || [];
assert.deepEqual(stringEq, [], `AC2: no hardcoded runtime==='codex' branch may remain in ${rel}; found: ${stringEq.join(', ')}`);
// Every `isCodex` reference must be either a runtimeFlags(...) destructure
// line or a NEGATED occurrence inside a shared multi-runtime roster
// (`!isCodex && !isCopilot && ...`). A positive `isCodex` gate is forbidden.
for (const line of src.split(/\r?\n/)) {
if (!/\bisCodex\b/.test(line)) continue;
if (/runtimeFlags\s*\(/.test(line)) continue; // destructure declaration
const positive = line.replace(/!\s*isCodex\b/g, '').match(/\bisCodex\b/);
assert.equal(positive, null, `AC2: positive isCodex gate forbidden in ${rel}: ${line.trim()}`);
}
}
});
// -- AC4 upgrade 3: skill root is the canonical $HOME/.agents/skills ---------
test('upgrade 3 — codex skills resolve to $HOME/.agents/skills, not the deprecated $CODEX_HOME/skills', () => {
const codexHome = path.join(os.homedir(), '.codex');
const root = install._resolveSkillsRootDir('codex', codexHome, 'global');
assert.equal(root, path.join(os.homedir(), '.agents', 'skills'),
'skills must install to the canonical ~/.agents/skills root');
assert.ok(!root.startsWith(codexHome), 'skills must NOT land under the deprecated $CODEX_HOME/skills');
});
test('upgrade 3 — the pre-move location is migrated (stale ~/.codex/skills/gsd-* cleaned; user content preserved)', () => {
const codexHome = path.join(os.homedir(), '.codex');
const oldDir = install._resolveMovedSkillsOldDir('codex', codexHome, 'global');
assert.equal(oldDir, path.join(codexHome, 'skills'), 'the pre-move location is $CODEX_HOME/skills');
// A runtime with no home override yields null (no false migration).
assert.equal(install._resolveMovedSkillsOldDir('kimi', path.join(os.homedir(), '.kimi'), 'global'), null);
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-migrate-'));
const skillsDir = path.join(tmp, 'skills');
fs.mkdirSync(path.join(skillsDir, 'gsd-plan'), { recursive: true });
fs.mkdirSync(path.join(skillsDir, 'gsd-dev-preferences'), { recursive: true }); // user-owned, preserved
fs.mkdirSync(path.join(skillsDir, 'my-own-skill'), { recursive: true }); // non-gsd, preserved
try {
const removed = install.cleanupMovedSkillsOldLocation(skillsDir, 'gsd-');
assert.equal(removed, 1, 'only the managed gsd-plan dir is removed');
assert.ok(!fs.existsSync(path.join(skillsDir, 'gsd-plan')), 'stale managed skill removed');
assert.ok(fs.existsSync(path.join(skillsDir, 'gsd-dev-preferences')), 'user-owned gsd-dev-preferences preserved');
assert.ok(fs.existsSync(path.join(skillsDir, 'my-own-skill')), 'non-gsd dir preserved');
} finally {
cleanup(tmp);
}
});
// -- AC4 upgrade 1: the 6 new hooks.json lifecycle events are registered ------
test('upgrade 1 — codex registers all documented hooks.json lifecycle events, incl. the 6 new in #2088', () => {
const expected = [
'SubagentStart', 'Stop', 'PostToolUse', // #772
'PreToolUse', 'PermissionRequest', 'PreCompact', 'PostCompact', 'SubagentStop', 'UserPromptSubmit', // #2088
];
assert.deepEqual(install.CODEX_EXTENDED_HOOK_EVENTS, expected,
'the shared install/uninstall event list must contain the 3 original + 6 new events');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-hooks-'));
try {
fs.mkdirSync(path.join(tmp, 'hooks'), { recursive: true });
fs.writeFileSync(path.join(tmp, 'hooks', 'gsd-context-monitor.js'), '// stub');
fs.writeFileSync(path.join(tmp, 'hooks', 'gsd-check-update.js'), '// stub');
for (const ev of install.CODEX_EXTENDED_HOOK_EVENTS) {
install.ensureCodexHooksJsonEvent(tmp, ev, { absoluteRunner: '/usr/bin/node', platform: 'linux' });
}
install.ensureCodexHooksJsonSessionStart(tmp, { absoluteRunner: '/usr/bin/node', platform: 'linux' });
const hooksJson = JSON.parse(fs.readFileSync(path.join(tmp, 'hooks.json'), 'utf8'));
const registered = Object.keys(hooksJson.hooks || hooksJson || {});
for (const ev of ['PreToolUse', 'PermissionRequest', 'PreCompact', 'PostCompact', 'SubagentStop', 'UserPromptSubmit']) {
assert.ok(registered.includes(ev), `#2088 must register the ${ev} hook in hooks.json`);
}
assert.ok(registered.includes('SessionStart'), 'the SessionStart baseline stays registered');
} finally {
cleanup(tmp);
}
});
test('upgrade 1 — extendedHookEvents descriptor reconciled to the schema-valid wired subset (no longer [])', () => {
assert.deepEqual(CODEX_CAP.runtime.extendedHookEvents, ['SubagentStop', 'Stop', 'PreCompact'],
'reconciled from [] to the wired extended-lifecycle events expressible in the cross-runtime vocabulary');
});
// -- AC4 upgrade 2: explicit [agents] max_depth dispatch tuning ---------------
test('upgrade 2 — the managed config block writes [agents] max_depth = 1', () => {
const block = install.generateCodexConfigBlock(
[{ name: 'gsd-foo', description: 'Foo' }, { name: 'gsd-bar', description: 'Bar' }],
path.join(os.homedir(), '.codex'),
);
assert.match(block, /\[agents\]\nmax_depth = 1\n/, 'the block pins max_depth = 1 on a bare [agents] table');
// #2406: the bare [agents] scalar table is the ONLY [agents]-namespaced
// content the block emits — no [agents.gsd-*] role sub-tables. Codex
// auto-discovers roles from the standalone agents/<name>.toml files, so a
// config.toml role table pointing config_file back at that same file was a
// second, duplicate registration of the same role.
assert.doesNotMatch(block, /\[agents\.gsd-foo\]/, 'no [agents.gsd-foo] role table');
assert.doesNotMatch(block, /config_file/, 'no config_file line');
});
test('upgrade 2 — validateCodexConfigSchema accepts the managed [agents] block but still rejects break-forms', () => {
const block = install.generateCodexConfigBlock([{ name: 'gsd-foo', description: 'Foo' }], path.join(os.homedir(), '.codex'));
assert.equal(install.validateCodexConfigSchema(block).ok, true, 'known-scalar [agents] table must validate');
// Still rejects the actual #2760 break-forms.
assert.equal(install.validateCodexConfigSchema('[[agents]]\nname = "x"\n').ok, false, '[[agents]] sequence still rejected');
assert.equal(install.validateCodexConfigSchema('[agents]\ndefault = "x"\n').ok, false, 'bare [agents] with an unknown key still rejected');
// A user's own AgentsToml scalar table is now accepted (no longer over-rejected).
assert.equal(install.validateCodexConfigSchema('[agents]\nmax_threads = 4\n').ok, true, 'user known-scalar [agents] accepted');
assert.equal(install.codexBareAgentsHasOnlyKnownScalars('max_depth = 1\n'), true);
assert.equal(install.codexBareAgentsHasOnlyKnownScalars('default = "x"\n'), false);
});
test('upgrade 2 — uninstall removes the managed [agents] max_depth block', () => {
const block = install.generateCodexConfigBlock([{ name: 'gsd-foo', description: 'Foo' }], path.join(os.homedir(), '.codex'));
const stripped = install.stripGsdFromCodexConfig('model = "gpt-5"\n\n' + block);
assert.ok(stripped === null || !/\[agents\]/.test(stripped), `uninstall must remove the managed [agents] block; got: ${JSON.stringify(stripped)}`);
});
// Regression (#2088 review): install must NOT silently drop a user's own
// AgentsToml scalar tuning when it purges the bare [agents] table to add max_depth.
test('upgrade 2 — install preserves the user\'s own AgentsToml scalars (max_threads etc.), GSD-manages max_depth', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-agents-merge-'));
try {
const cfgPath = path.join(tmp, 'config.toml');
fs.writeFileSync(cfgPath, '[agents]\nmax_threads = 4\nmax_depth = 9\ninterrupt_message = false\n\n[model]\nname = "o3"\n');
// extract helper: user scalars except the GSD-managed max_depth.
const scalars = install.extractCodexUserAgentsScalars(fs.readFileSync(cfgPath, 'utf8'));
assert.ok(scalars.includes('max_threads = 4'), 'max_threads is a preserved user scalar');
assert.ok(scalars.includes('interrupt_message = false'), 'interrupt_message is a preserved user scalar');
assert.ok(!scalars.some((s) => s.startsWith('max_depth')), 'max_depth is GSD-managed, not preserved from the user');
install.mergeCodexConfig(cfgPath, install.generateCodexConfigBlock([{ name: 'gsd-foo', description: 'Foo' }], tmp));
const merged = fs.readFileSync(cfgPath, 'utf8');
assert.match(merged, /max_threads = 4/, 'user max_threads must survive install');
assert.match(merged, /interrupt_message = false/, 'user interrupt_message must survive install');
assert.match(merged, /max_depth = 1/, 'GSD pins max_depth = 1');
assert.doesNotMatch(merged, /max_depth = 9/, 'user max_depth is overridden by the GSD-managed value');
assert.equal((merged.match(/^\[agents\]$/mg) || []).length, 1, 'exactly one managed [agents] table (no duplicate)');
assert.equal(install.validateCodexConfigSchema(merged).ok, true, 'the merged config still validates');
// #2406: the fresh block never emitted an [agents.gsd-foo] role table in
// the first place — canonical registration lives only in the standalone
// TOML, so there is nothing gsd-foo-shaped in config.toml to begin with.
assert.doesNotMatch(merged, /gsd-foo/, 'no [agents.gsd-foo] role table in the merged config');
// Symmetric: uninstall restores the user's scalars and drops GSD's max_depth.
const uninstalled = install.stripGsdFromCodexConfig(merged);
assert.match(uninstalled, /max_threads = 4/, 'uninstall restores the user\'s max_threads');
assert.match(uninstalled, /interrupt_message = false/, 'uninstall restores interrupt_message');
assert.doesNotMatch(uninstalled, /max_depth/, 'uninstall drops the GSD-managed max_depth');
assert.doesNotMatch(uninstalled, /gsd-foo/, 'no [agents.gsd-foo] role table remains after uninstall either');
assert.equal(install.validateCodexConfigSchema(uninstalled).ok, true, 'the restored config validates');
} finally {
cleanup(tmp);
}
});