Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD across contents and paths, upstream package/repo coordinates -> @golem15/msd-core and golem15com/msd-core. Deep links into upstream history, sibling upstream packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is. Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line, package/plugin identity, regenerated lockfile, install-tree fixtures, derived registries and benchmark baseline; migration checksum baseline re-locked (MSD keeps its own install state, so no install had applied the old sums); sort-order and regex-escaped expectations in tests adjusted.
300 lines
17 KiB
JavaScript
300 lines
17 KiB
JavaScript
// allow-test-rule: structural-regression-guard — 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.MSD_TEST_MODE = '1';
|
|
|
|
const { createImperativeAdapter } = require('../msd-core/bin/lib/adapter-imperative.cjs');
|
|
const { createDeclarativeAdapter } = require('../msd-core/bin/lib/adapter-declarative.cjs');
|
|
const {
|
|
profileOf,
|
|
negotiateHostCapabilities,
|
|
degradationFor,
|
|
PROFILE_BASELINES,
|
|
UNDOCUMENTED,
|
|
} = require('../msd-core/bin/lib/host-integration.cjs');
|
|
|
|
const install = require('../bin/install.js');
|
|
const hooksSurface = require('../msd-core/bin/lib/runtime-hooks-surface.cjs');
|
|
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, '$msd-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/msd-* 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, 'msd-plan'), { recursive: true });
|
|
fs.mkdirSync(path.join(skillsDir, 'msd-dev-preferences'), { recursive: true }); // user-owned, preserved
|
|
fs.mkdirSync(path.join(skillsDir, 'my-own-skill'), { recursive: true }); // non-msd, preserved
|
|
try {
|
|
const removed = install.cleanupMovedSkillsOldLocation(skillsDir, 'msd-');
|
|
assert.equal(removed, 1, 'only the managed msd-plan dir is removed');
|
|
assert.ok(!fs.existsSync(path.join(skillsDir, 'msd-plan')), 'stale managed skill removed');
|
|
assert.ok(fs.existsSync(path.join(skillsDir, 'msd-dev-preferences')), 'user-owned msd-dev-preferences preserved');
|
|
assert.ok(fs.existsSync(path.join(skillsDir, 'my-own-skill')), 'non-msd 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', 'msd-context-monitor.js'), '// stub');
|
|
fs.writeFileSync(path.join(tmp, 'hooks', 'msd-check-update.js'), '// stub');
|
|
for (const ev of install.CODEX_EXTENDED_HOOK_EVENTS) {
|
|
hooksSurface.ensureCodexHooksJsonEvent(tmp, ev, { absoluteRunner: '/usr/bin/node', platform: 'linux' });
|
|
}
|
|
hooksSurface.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: 'msd-foo', description: 'Foo' }, { name: 'msd-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.msd-*] 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\.msd-foo\]/, 'no [agents.msd-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: 'msd-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: 'msd-foo', description: 'Foo' }], path.join(os.homedir(), '.codex'));
|
|
const stripped = install.stripMsdFromCodexConfig('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.), MSD-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 MSD-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 MSD-managed, not preserved from the user');
|
|
|
|
install.mergeCodexConfig(cfgPath, install.generateCodexConfigBlock([{ name: 'msd-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/, 'MSD pins max_depth = 1');
|
|
assert.doesNotMatch(merged, /max_depth = 9/, 'user max_depth is overridden by the MSD-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.msd-foo] role table in
|
|
// the first place — canonical registration lives only in the standalone
|
|
// TOML, so there is nothing msd-foo-shaped in config.toml to begin with.
|
|
assert.doesNotMatch(merged, /msd-foo/, 'no [agents.msd-foo] role table in the merged config');
|
|
|
|
// Symmetric: uninstall restores the user's scalars and drops MSD's max_depth.
|
|
const uninstalled = install.stripMsdFromCodexConfig(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 MSD-managed max_depth');
|
|
assert.doesNotMatch(uninstalled, /msd-foo/, 'no [agents.msd-foo] role table remains after uninstall either');
|
|
assert.equal(install.validateCodexConfigSchema(uninstalled).ok, true, 'the restored config validates');
|
|
} finally {
|
|
cleanup(tmp);
|
|
}
|
|
});
|