Files
msd-core/tests/configured-entrypoint-validation.test.cjs
Jakub Zych 6cfa0c55d2 refactor: drop 12 runtimes, keep Claude, Codex, OpenCode, Cursor, ZCode, Antigravity
Removes kilo, kimi, kimi-code, copilot, windsurf, augment, trae, qwen, hermes,
cline, codebuddy and pi end to end: capability descriptors, installer branches
and converters (bin/install.js 14.9k -> 11.2k lines), TypeScript converters,
hook surfaces and runtime homes, review lanes qwen/kimi-code, the two pi
migrations, Kimi payload normalization in the hook guards, dead hostBehaviors
vocabulary, launcher home probes, fixtures, runtime-specific tests and the
prose that presented them as supported.

Installer output for the six kept runtimes is byte-identical to before the
prune. The Kimi tool-vocabulary tests in workflow-guard, read-guard and
read-injection-scanner are left in place pending a decision.
2026-10-06 20:02:40 +02:00

534 lines
23 KiB
JavaScript

'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const test = require('node:test');
const helpers = require('./helpers.cjs');
const hooksSurface = require('../msd-core/bin/lib/runtime-hooks-surface.cjs');
const { install, installAllRuntimes, finishInstall } = require('../bin/install.js');
/**
* Run `fn` with HOME/USERPROFILE pointed at a fresh temp dir and every
* config-location env var scrubbed, so a real install() never touches the
* developer's own config. Restores all of it, and cleans the dir, afterwards.
*/
function withSandboxedHome(t, prefix, fn) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
t.after(() => helpers.cleanup(root));
const savedHome = process.env.HOME;
const savedUserProfile = process.env.USERPROFILE;
process.env.HOME = root;
process.env.USERPROFILE = root;
const restoreConfigLocationEnv = helpers.scrubConfigLocationEnv();
try {
return fn(root);
} finally {
if (savedHome === undefined) delete process.env.HOME;
else process.env.HOME = savedHome;
if (savedUserProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = savedUserProfile;
restoreConfigLocationEnv();
}
}
test('configured entrypoint validation exposes an aggregate typed boundary', () => {
assert.equal(
typeof hooksSurface.validateConfiguredEntrypoints,
'function',
'the Runtime Hooks Surface must export configured-entrypoint validation',
);
});
test('finishInstall rejects an invalid configured entrypoint before Done output', (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'configured-entrypoint-finish-'));
t.after(() => helpers.cleanup(root));
const logs = [];
const originalLog = console.log;
console.log = (...args) => logs.push(args.join(' '));
// #2665/#4249: finishInstall asserts configured entrypoints before any of its
// own writes now, but still sandbox HOME (+ USERPROFILE for os.homedir() on
// Windows) and config-location env defensively, so a future reordering that
// reintroduces a pre-assertion write can never redirect it to a live config dir.
const savedHome = process.env.HOME;
const savedUserProfile = process.env.USERPROFILE;
process.env.HOME = root;
process.env.USERPROFILE = root;
const restoreConfigLocationEnv = helpers.scrubConfigLocationEnv();
try {
assert.throws(() => finishInstall(null, null, null, false, 'cursor', false, root, {
configuredEntrypoints: [{ runtime: 'cursor', configPath: path.join(root, 'config'), scriptPath: path.join(root, 'missing.js') }],
}), /Configured entrypoint validation failed/);
// #4249 review, Major: the message must name the actual consequence for
// the failing runtime, not just that something failed — Cursor has no
// revert path, so its entry must be flagged "NOT reverted".
assert.throws(() => finishInstall(null, null, null, false, 'cursor', false, root, {
configuredEntrypoints: [{ runtime: 'cursor', configPath: path.join(root, 'config'), scriptPath: path.join(root, 'missing.js') }],
}), /NOT reverted/);
} finally {
console.log = originalLog;
restoreConfigLocationEnv();
if (savedHome === undefined) delete process.env.HOME;
else process.env.HOME = savedHome;
if (savedUserProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = savedUserProfile;
}
assert.equal(logs.some(line => line.includes('Done!')), false);
});
test('a hook already registered under a stale command is still tracked for validation on re-install (#4154 Blocker)', (t) => {
withSandboxedHome(t, 'configured-entrypoint-stale-', () => {
const first = install(true, 'claude');
assert.ok(first.settingsPath, 'a fresh global install must produce a settings path');
finishInstall(first.settingsPath, first.settings, first.statuslineCommand, false, 'claude', true, first.configDir, {
configuredEntrypoints: first.configuredEntrypoints,
});
// Simulate an entry registered by an older installer under a DIFFERENT
// node install (e.g. an nvm switch, #4087/#4098/#4137): same real
// scriptPath under <configDir>/hooks/ (that never changes across
// installer versions) and still shaped as the modern runtime-resolving
// chain (rewriteLegacyManagedNodeHookCommands deliberately never touches
// an already-current-format entry — #3662), but baked with a node path
// this install would never produce. `hasMsdUpdateHook` still finds it and
// applySettingsJsonHooks takes its register-only-if-absent branch on the
// next install (never rewriting it).
const onDisk = JSON.parse(fs.readFileSync(first.settingsPath, 'utf8'));
const staleEntry = (onDisk.hooks.SessionStart || []).find(entry =>
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('msd-check-update.js'))
);
assert.ok(staleEntry, 'a fresh install must register the check-update hook');
const staleCommand = hooksSurface.buildHookCommand(first.configDir, 'msd-check-update.js', {
execPath: '/old/nvm/pinned/node',
platform: process.platform,
runtime: 'claude',
});
for (const h of staleEntry.hooks) {
if (h.command && h.command.includes('msd-check-update.js')) {
h.command = staleCommand;
}
}
fs.writeFileSync(first.settingsPath, JSON.stringify(onDisk, null, 2));
const second = install(true, 'claude');
const trackedNames = (second.configuredEntrypoints || []).map(entry => path.basename(entry.scriptPath));
assert.ok(
trackedNames.includes('msd-check-update.js'),
`a hook already registered under a stale command must still be tracked for validation, got: ${trackedNames.join(', ')}`,
);
});
});
test('configured entrypoint validation aggregates file and interpreter failures without execution', (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'configured-entrypoint-'));
t.after(() => helpers.cleanup(root));
const directory = path.join(root, 'directory');
fs.mkdirSync(directory);
const unreadablePath = path.join(root, 'unreadable.js');
const notExecutablePath = path.join(root, 'not-executable.js');
const result = hooksSurface.validateConfiguredEntrypoints([
{ runtime: 'claude', configPath: path.join(root, 'settings.json'), scriptPath: path.join(root, 'missing.js') },
{ runtime: 'claude', configPath: path.join(root, 'settings.json'), scriptPath: directory },
{ runtime: 'claude', configPath: path.join(root, 'settings.json'), scriptPath: __filename, interpreterCandidates: ['missing-node'] },
{ runtime: 'claude', configPath: path.join(root, 'settings.json'), scriptPath: unreadablePath },
// #4249: selfExecutable means this entry is invoked directly via its own
// shebang (e.g. a Windows-Claude .sh hook) — must itself be +x.
// platform pinned to non-win32: the X_OK check itself is a POSIX-only
// concept (skipped entirely on win32, matching production) — this case
// must exercise it deterministically regardless of which OS runs the test.
{ runtime: 'claude', configPath: path.join(root, 'settings.json'), scriptPath: notExecutablePath, selfExecutable: true, platform: 'linux' },
], {
resolveExecutableBinary: () => null,
statSync: (p) => {
if (p === unreadablePath) {
const err = new Error('EACCES: permission denied');
err.code = 'EACCES';
throw err;
}
return fs.statSync(p === notExecutablePath ? __filename : p);
},
accessSync: (p, mode) => {
if (p === notExecutablePath && mode === fs.constants.X_OK) {
const err = new Error('EACCES: permission denied');
err.code = 'EACCES';
throw err;
}
// notExecutablePath is never written to disk (its statSync mock above
// redirects to a real file instead) — its R_OK call must redirect too,
// or this falls through to a real accessSync on a nonexistent path.
return fs.accessSync(p === notExecutablePath ? __filename : p, mode);
},
});
assert.equal(result.ok, false);
assert.deepEqual(result.invalid.map(({ role, reason }) => [role, reason]), [
['script', 'missing'],
['script', 'wrong-file-type'],
['interpreter', 'unresolved-interpreter'],
['script', 'unreadable'],
['script', 'not-executable'],
]);
});
test('an interpreter-invoked script that exists but has no read permission is reported unreadable, not ok (#4249 agy review)', (t) => {
// statSync only needs search (+x) permission on the parent directories, so
// it succeeds on a chmod-000 file even though `node <script>` would fail
// with EACCES at hook-fire time.
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'configured-entrypoint-unreadable-interp-'));
t.after(() => helpers.cleanup(root));
const scriptPath = path.join(root, 'hook.js');
fs.writeFileSync(scriptPath, '// unreadable to the invoking user at hook-fire time\n');
const result = hooksSurface.validateConfiguredEntrypoints([
{
runtime: 'claude',
configPath: path.join(root, 'settings.json'),
scriptPath,
interpreterCandidates: ['/usr/bin/node'],
},
], {
resolveExecutableBinary: () => '/usr/bin/node',
accessSync: (p, mode) => {
if (p === scriptPath && mode === fs.constants.R_OK) {
const err = new Error('EACCES: permission denied');
err.code = 'EACCES';
throw err;
}
return fs.accessSync(p, mode);
},
});
assert.equal(result.ok, false);
assert.deepEqual(result.invalid.map(({ role, reason }) => [role, reason]), [
['script', 'unreadable'],
]);
});
test('an EPERM from statSync (Windows equivalent of EACCES on a parent directory) is also reported unreadable, not missing (#4249 agy review)', (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'configured-entrypoint-eperm-'));
t.after(() => helpers.cleanup(root));
const scriptPath = path.join(root, 'hook.js');
const result = hooksSurface.validateConfiguredEntrypoints([
{ runtime: 'claude', configPath: path.join(root, 'settings.json'), scriptPath },
], {
statSync: () => {
const err = new Error('EPERM: operation not permitted');
err.code = 'EPERM';
throw err;
},
});
assert.equal(result.ok, false);
assert.deepEqual(result.invalid.map(({ role, reason }) => [role, reason]), [
['script', 'unreadable'],
]);
});
test('a selfExecutable + interpreterCandidates entry checks both the execute bit and the interpreter (#4249 CodeRabbit review — Cline\'s hybrid `env node` shebang)', (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'configured-entrypoint-self-exec-'));
t.after(() => helpers.cleanup(root));
const okScript = path.join(root, 'ok.js');
const notExecScript = path.join(root, 'not-exec.js');
fs.writeFileSync(okScript, '#!/usr/bin/env node\n');
fs.writeFileSync(notExecScript, '#!/usr/bin/env node\n');
const makeEntry = (scriptPath) => ({
runtime: 'cline',
configPath: path.join(root, '.clinerules', 'hooks', 'PreToolUse'),
scriptPath,
interpreterCandidates: ['node'],
selfExecutable: true,
// X_OK is a POSIX-only concept (skipped entirely on win32, matching
// production) — pinned here so the execute-bit case below is exercised
// deterministically regardless of which OS runs the test.
platform: 'linux',
});
// plain writeFileSync never sets the execute bit (and the repo bans chmod
// in tests), so every case below stubs accessSync to simulate the exact
// permission state under test rather than relying on the real filesystem.
const alwaysOk = () => {};
// node missing from PATH entirely: caught even though the script itself is fine.
const missingInterpreter = hooksSurface.validateConfiguredEntrypoints([makeEntry(okScript)], {
resolveExecutableBinary: () => null,
accessSync: alwaysOk,
});
assert.equal(missingInterpreter.ok, false);
assert.deepEqual(missingInterpreter.invalid.map(({ role, reason }) => [role, reason]), [
['interpreter', 'unresolved-interpreter'],
]);
// node resolves fine, but the script itself lost its execute bit: caught too —
// interpreterCandidates being present must not skip the X_OK check here.
const notExecutable = hooksSurface.validateConfiguredEntrypoints([makeEntry(notExecScript)], {
resolveExecutableBinary: () => '/usr/bin/node',
accessSync: (p, mode) => {
if (p === notExecScript && mode === fs.constants.X_OK) {
const err = new Error('EACCES: permission denied');
err.code = 'EACCES';
throw err;
}
},
});
assert.equal(notExecutable.ok, false);
assert.deepEqual(notExecutable.invalid.map(({ role, reason }) => [role, reason]), [
['script', 'not-executable'],
]);
// both hold: clean pass.
const clean = hooksSurface.validateConfiguredEntrypoints([makeEntry(okScript)], {
resolveExecutableBinary: () => '/usr/bin/node',
accessSync: alwaysOk,
});
assert.equal(clean.ok, true);
});
test('a win32 selfExecutable entry (Codex .cmd shim) skips the execute-bit check (#4249 agy review)', (t) => {
// POSIX mode bits don't mean executable on win32 — this must not depend on
// Node's own accessSync(X_OK)-as-F_OK no-op (which only protects a real
// Windows machine), or a test simulating win32 on a POSIX runner would see
// a spurious 'not-executable' for a .cmd shim that was never chmod'd +x.
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'configured-entrypoint-win32-cmd-'));
t.after(() => helpers.cleanup(root));
const cmdPath = path.join(root, 'msd-check-update.cmd');
fs.writeFileSync(cmdPath, '@echo off\r\n');
const result = hooksSurface.validateConfiguredEntrypoints([
{
runtime: 'codex',
configPath: path.join(root, 'hooks.json'),
scriptPath: cmdPath,
platform: 'win32',
selfExecutable: true,
},
], {
accessSync: (p, mode) => {
if (mode === fs.constants.X_OK) {
const err = new Error('EACCES: permission denied');
err.code = 'EACCES';
throw err;
}
},
});
assert.equal(result.ok, true, JSON.stringify(result));
});
test('runtime config writers expose the exact configured entrypoints they emit', (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'configured-entrypoint-writers-'));
t.after(() => helpers.cleanup(root));
const sourceRoot = path.join(__dirname, '..');
const codexRoot = path.join(root, 'codex');
// On win32, ensureCodexHooksJsonSessionStart writes a .cmd shim under
// <codexRoot>/hooks/; the real installer only calls this once that dir
// (and msd-check-update.js) already exist, so create it here too.
fs.mkdirSync(path.join(codexRoot, 'hooks'), { recursive: true });
const codex = hooksSurface.ensureCodexHooksJsonSessionStart(codexRoot, {
absoluteRunner: JSON.stringify(process.execPath),
});
// win32 emits two entries (the .cmd shim plus the underlying script);
// every other platform emits the script alone — assert the shape common
// to both rather than a platform-fixed array.
assert.ok(codex.configuredEntrypoints.length >= 1);
assert.ok(codex.configuredEntrypoints.every(entry => entry.runtime === 'codex'
&& entry.configPath === path.join(codexRoot, 'hooks.json')
&& entry.platform === process.platform));
assert.ok(codex.configuredEntrypoints.some(
entry => path.basename(entry.scriptPath) === 'msd-check-update.js'
&& Array.isArray(entry.interpreterCandidates)
&& entry.interpreterCandidates.length === 1,
));
const cursorRoot = path.join(root, 'cursor');
const cursor = hooksSurface.writeCursorHooksJson(cursorRoot, sourceRoot, {
managedHookEvents: ['sessionStart'],
});
assert.deepEqual(
cursor.configuredEntrypoints.map(entry => path.basename(entry.scriptPath)),
['msd-cursor-session-start.js'],
);
assert.ok(cursor.configuredEntrypoints.every(entry => entry.configPath === cursor.hooksJsonPath));
const portable = [];
assert.ok(hooksSurface.buildHookCommand(root, 'msd-context-monitor.js', {
runtime: 'claude',
portableHooks: true,
configPath: path.join(root, 'settings.json'),
configuredEntrypoints: portable,
}));
assert.deepEqual(
portable.map(entry => path.basename(entry.scriptPath)),
['msd-node-runner.sh', 'msd-context-monitor.js'],
);
});
test('a minimal Codex rollback restores skills from the alternate skills home (#4249 CodeRabbit)', (t) => {
withSandboxedHome(t, 'configured-entrypoint-codex-skills-', (root) => {
const first = install(true, 'codex');
// Codex resolves skills to $HOME/.agents/skills, NOT configDir (ADR-1239
// skills-kind `home` override) — the surface #3245's own snapshot owns and
// the manifest-driven pass deliberately leaves to it.
const skillsRoot = path.join(root, '.agents', 'skills');
const skillDir = fs.readdirSync(skillsRoot).find(name => name.startsWith('msd-'));
assert.ok(skillDir, `a Codex install must write msd-* skills under ${skillsRoot}`);
const skillFile = path.join(skillsRoot, skillDir, 'SKILL.md');
const priorBytes = '# bytes only this test wrote\n';
fs.writeFileSync(skillFile, priorBytes);
// Marker-driven core profile — the `msd update` path into minimal mode.
fs.writeFileSync(path.join(first.configDir, '.msd-profile'), 'core\n');
const second = install(true, 'codex');
second.rollbackPreInstallSnapshot();
assert.equal(
fs.existsSync(skillFile),
true,
'a minimal-mode rollback must not delete a skill dir it never snapshotted',
);
assert.equal(
fs.readFileSync(skillFile, 'utf8'),
priorBytes,
'a minimal-mode rollback must restore alternate-home skills byte-for-byte',
);
});
});
// Drive a REAL aggregate failure through installAllRuntimes: delegate to the real
// validator, then append an invalid entry for the named runtime. Every real
// entrypoint is a path the installer just wrote, so there is no PATH trick left
// that fails one runtime's entry on its own.
function forceEntrypointFailureFor(runtime) {
const real = hooksSurface.validateConfiguredEntrypoints;
hooksSurface.validateConfiguredEntrypoints = (entries, deps) => {
const result = real(entries, deps);
const invalid = [...(result.invalid || []), {
runtime, configPath: '/nonexistent/config', role: 'script', path: '/nonexistent/hook.js', reason: 'missing',
}];
return { ok: false, invalid };
};
return () => { hooksSurface.validateConfiguredEntrypoints = real; };
}
test('an aggregate entrypoint validation failure rolls the Codex install back (#4249)', (t) => {
withSandboxedHome(t, 'configured-entrypoint-aggregate-', () => {
const first = install(true, 'codex');
// config.toml is the surface #3245's snapshot restores and the one this
// runtime's writer rewrites on every install, so a sentinel surviving here
// can only be the rollback's doing.
const configPath = path.join(first.configDir, 'config.toml');
const priorBytes = '# bytes only this test wrote\n';
fs.writeFileSync(configPath, priorBytes);
// Codex installs first, so the reversed rollback loop reaches its
// restoreCodexSnapshot.
const restore = forceEntrypointFailureFor('cursor');
try {
assert.throws(
() => installAllRuntimes(['codex', 'cursor'], true, false),
/Configured entrypoint validation failed/,
'an invalid entrypoint must fail the aggregate gate',
);
} finally {
restore();
}
assert.equal(
fs.readFileSync(configPath, 'utf8'),
priorBytes,
'the aggregate failure must reach restoreCodexSnapshot, not just throw',
);
});
});
test('an aggregate entrypoint validation failure leaves Cursor\'s own config file on disk, unreverted (#4249 review, Major)', (t) => {
withSandboxedHome(t, 'configured-entrypoint-unreverted-', () => {
// Cursor writes its config file inside install(), ahead of the validation
// gate, with no snapshot/restore path — see the rollback-matrix paragraph
// in the update-msd how-to. This asserts the POSITIVE case that paragraph
// discloses in prose: the file the failing runtime itself just wrote is
// still there afterward, broken and unreverted.
const cursorFirst = install(true, 'cursor');
const cursorHooksJson = path.join(cursorFirst.configDir, 'hooks.json');
assert.equal(fs.existsSync(cursorHooksJson), true, 'precondition: Cursor hooks.json must exist before the failing install');
const restore = forceEntrypointFailureFor('cursor');
try {
assert.throws(
() => installAllRuntimes(['codex', 'cursor'], true, false),
/Configured entrypoint validation failed[\s\S]*cursor[\s\S]*NOT reverted/,
'an invalid entrypoint must fail the aggregate gate and flag Cursor as NOT reverted',
);
} finally {
restore();
}
assert.equal(
fs.existsSync(cursorHooksJson),
true,
'Cursor has no snapshot/restore path — its already-written hooks.json must remain on disk, not be deleted',
);
});
});
test('a NON-entrypoint finalize failure leaves an already-successful Codex install in place (#4249)', (t) => {
withSandboxedHome(t, 'configured-entrypoint-nonentry-', () => {
const first = install(true, 'codex');
// config.toml sits inside surface #3245's pre-install snapshot, so these
// bytes come back if — and only if — the full restoreCodexSnapshot() runs.
const configPath = path.join(first.configDir, 'config.toml');
const priorBytes = '# bytes only this test wrote\n';
fs.writeFileSync(configPath, priorBytes);
// Antigravity is the smallest real non-Codex runtime whose finishInstall
// still writes: plan.finishPermissionWriter === 'antigravity' makes it call
// configureAntigravityMcpConfig unconditionally (that writer, unlike
// OpenCode's, is NOT MSD_TEST_MODE-gated), ending in an unguarded
// fs.writeFileSync(<configDir>/mcp_config.json). EACCES on that write is
// injected by monkeypatching
// node:fs and restoring it in a finally — never chmod 0o000, which root
// bypasses under Docker/CI and would give this test zero real coverage.
const realWriteFileSync = fs.writeFileSync;
fs.writeFileSync = function poisonedWriteFileSync(target, ...args) {
const targetStr = typeof target === 'string' ? target : String(target);
if (/[\\/]mcp_config\.json$/.test(targetStr)) {
const injected = new Error(`EACCES: permission denied, open '${targetStr}'`);
injected.code = 'EACCES';
throw injected;
}
return realWriteFileSync.apply(fs, [target, ...args]);
};
try {
assert.throws(
() => installAllRuntimes(['codex', 'antigravity'], true, false),
/EACCES: permission denied/,
'a failed Antigravity MCP config write must abort the aggregate install',
);
} finally {
fs.writeFileSync = realWriteFileSync;
}
// Codex installs first and finishInstall prints its "Done!" summary before
// Antigravity's throws, so the reversed rollback loop does reach Codex's entry —
// but with a non-entrypoint error it must take the installer-migrations-only
// closure, not restoreCodexSnapshot. A configured-entrypoint validation
// failure is the only trigger docs/how-to/update-msd.md documents for
// reverting a runtime install that otherwise succeeded; un-installing (on
// update, downgrading) Codex because an unrelated runtime hit EACCES would
// contradict the "Done!" the user has already been shown.
assert.notEqual(
fs.readFileSync(configPath, 'utf8'),
priorBytes,
'a non-entrypoint finalize failure must not restore the Codex pre-install snapshot',
);
});
});