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.
559 lines
25 KiB
JavaScript
559 lines
25 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, 'cline', false, root, {
|
|
configuredEntrypoints: [{ runtime: 'cline', 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 — Cline has no
|
|
// revert path, so its entry must be flagged "NOT reverted".
|
|
assert.throws(() => finishInstall(null, null, null, false, 'cline', false, root, {
|
|
configuredEntrypoints: [{ runtime: 'cline', 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 windsurfRoot = path.join(root, 'windsurf');
|
|
const windsurf = hooksSurface.writeWindsurfHooksJson(windsurfRoot, sourceRoot);
|
|
assert.deepEqual(
|
|
windsurf.configuredEntrypoints.map(entry => path.basename(entry.scriptPath)).sort(),
|
|
['msd-windsurf-pre-command.js', 'msd-windsurf-pre-write.js'],
|
|
);
|
|
|
|
const kimiRoot = path.join(root, 'kimi');
|
|
fs.cpSync(path.join(sourceRoot, 'hooks'), path.join(kimiRoot, 'hooks'), { recursive: true });
|
|
const kimiConfig = path.join(root, 'kimi-config.toml');
|
|
const kimi = hooksSurface.writeKimiHooksToml(kimiConfig, kimiRoot, {
|
|
hookOpts: { runtime: 'kimi' },
|
|
});
|
|
assert.equal(kimi.configuredEntrypoints.length, kimi.entryCount);
|
|
assert.ok(kimi.configuredEntrypoints.every(entry => entry.configPath === kimiConfig));
|
|
|
|
const clineRoot = path.join(root, 'cline');
|
|
const cline = hooksSurface.writeClineArtifacts(clineRoot, false);
|
|
assert.deepEqual(
|
|
cline.configuredEntrypoints.map(entry => path.basename(entry.scriptPath)),
|
|
['PreToolUse'],
|
|
);
|
|
// #4249 (CodeRabbit): Cline's `#!/usr/bin/env node` hook is a hybrid —
|
|
// self-executable (needs its own execute bit checked) AND PATH-dependent
|
|
// on `node` (needs an interpreter candidate resolved), unlike MSD's other
|
|
// JS hooks which bake an absolute node path specifically to avoid this.
|
|
assert.equal(cline.configuredEntrypoints[0].selfExecutable, true);
|
|
assert.deepEqual(cline.configuredEntrypoints[0].interpreterCandidates, ['node']);
|
|
|
|
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',
|
|
);
|
|
});
|
|
});
|
|
|
|
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);
|
|
|
|
// Cline's `#!/usr/bin/env node` hook records interpreterCandidates: ['node'],
|
|
// resolved off PATH. Emptying PATH makes exactly that entry fail, which is
|
|
// the only way to drive a REAL aggregate failure through installAllRuntimes:
|
|
// every other entrypoint is a path the installer just wrote. Codex installs
|
|
// first, so the reversed rollback loop reaches its restoreCodexSnapshot.
|
|
const savedPath = process.env.PATH;
|
|
process.env.PATH = '';
|
|
try {
|
|
assert.throws(
|
|
() => installAllRuntimes(['codex', 'cline'], true, false),
|
|
/Configured entrypoint validation failed/,
|
|
'an unresolvable interpreter must fail the aggregate gate',
|
|
);
|
|
} finally {
|
|
process.env.PATH = savedPath;
|
|
}
|
|
|
|
assert.equal(
|
|
fs.readFileSync(configPath, 'utf8'),
|
|
priorBytes,
|
|
'the aggregate failure must reach restoreCodexSnapshot, not just throw',
|
|
);
|
|
});
|
|
});
|
|
|
|
test('an aggregate entrypoint validation failure leaves Cline\'s own config file on disk, unreverted (#4249 review, Major)', (t) => {
|
|
withSandboxedHome(t, 'configured-entrypoint-unreverted-', () => {
|
|
// Cline is one of the four runtimes (Cursor/Windsurf/Kimi/Cline) that
|
|
// write their 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. Unlike the Codex companion test above,
|
|
// this asserts the POSITIVE case that same paragraph discloses in
|
|
// prose but no prior test proved: the file the failing runtime itself
|
|
// just wrote is still there afterward, broken and unreverted.
|
|
const clineFirst = install(true, 'cline');
|
|
const clineHookPath = path.join(clineFirst.configDir, '.clinerules', 'hooks', 'PreToolUse');
|
|
assert.equal(fs.existsSync(clineHookPath), true, 'precondition: Cline hook must exist before the failing install');
|
|
|
|
// Same technique as the Codex rollback test: an emptied PATH makes
|
|
// Cline's own `#!/usr/bin/env node` hook fail interpreter resolution,
|
|
// driving a REAL aggregate failure through installAllRuntimes.
|
|
const savedPath = process.env.PATH;
|
|
process.env.PATH = '';
|
|
try {
|
|
assert.throws(
|
|
() => installAllRuntimes(['codex', 'cline'], true, false),
|
|
/Configured entrypoint validation failed/,
|
|
'an unresolvable interpreter must fail the aggregate gate',
|
|
);
|
|
} finally {
|
|
process.env.PATH = savedPath;
|
|
}
|
|
|
|
assert.equal(
|
|
fs.existsSync(clineHookPath),
|
|
true,
|
|
'Cline has no snapshot/restore path — its already-written hook file 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);
|
|
|
|
// Kilo is the smallest real non-Codex runtime whose finishInstall still
|
|
// writes: plan.finishPermissionWriter === 'kilo' makes it call
|
|
// configureKiloPermissions unconditionally (that writer, unlike OpenCode's,
|
|
// is NOT MSD_TEST_MODE-gated), ending in an unguarded
|
|
// fs.writeFileSync(<configDir>/kilo.json). Cline can't stand in here — its
|
|
// plan is writesSharedSettings:false + finishPermissionWriter:null, so its
|
|
// finishInstall performs no write at all and has no non-entrypoint failure
|
|
// path to force. EACCES on the Kilo 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 (/[\\/]kilo\.jsonc?$/.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', 'kilo'], true, false),
|
|
/EACCES: permission denied/,
|
|
'a failed Kilo permission write must abort the aggregate install',
|
|
);
|
|
} finally {
|
|
fs.writeFileSync = realWriteFileSync;
|
|
}
|
|
|
|
// Codex installs first and finishInstall prints its "Done!" summary before
|
|
// Kilo'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',
|
|
);
|
|
});
|
|
});
|