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.
This commit is contained in:
@@ -35,15 +35,6 @@ const {
|
||||
FIXTURE_HOOK_TIMEOUT_SECONDS,
|
||||
} = require('./helpers/timeouts.cjs');
|
||||
|
||||
/**
|
||||
* Bounds executing a single already-staged hook script directly (not the
|
||||
* installer itself, just the emitted script under a real node process).
|
||||
* 30000ms digits coincide with the shared BUILD_TIMEOUT_MS, but this is a
|
||||
* different operation class (running staged output vs. bundling it), so it
|
||||
* is kept as its own local constant rather than aliased onto that norm.
|
||||
*/
|
||||
const INSTALLED_HOOK_EXEC_TIMEOUT_MS = 30000;
|
||||
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const {
|
||||
@@ -394,24 +385,6 @@ describe('install: --minimal honoured for every runtime, on-disk matches manifes
|
||||
}
|
||||
});
|
||||
|
||||
describe('install: Cline --minimal (rules-based, no skills/ dir)', () => {
|
||||
for (const scope of ['global', 'local']) {
|
||||
test(`cline --${scope} --minimal: mode=minimal, zero agents, .clinerules present`, () => {
|
||||
const { manifest, configDir, root } = runMinimalInstall({
|
||||
runtime: 'cline', scope, extraArgs: ['--minimal'],
|
||||
});
|
||||
try {
|
||||
assert.ok(manifest, 'cline must produce manifest');
|
||||
assert.strictEqual(manifest.mode, 'minimal');
|
||||
assert.strictEqual(manifestAgentCount(manifest), 0);
|
||||
assert.ok(fs.existsSync(path.join(configDir, '.clinerules')));
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Section 11: --minimal manifest mode + downgrade ─────────────────────────
|
||||
|
||||
// Shared across "manifest records mode" and "install-minimal-backcompat": both
|
||||
@@ -691,7 +664,7 @@ describe('#1755: .sh hooks are copied and executable after install', () => {
|
||||
// hook Kilo's plugin spawns.) These tests assert the split: ZCode gets no
|
||||
// hooks; Kilo/OpenCode/pi (and Claude) do.
|
||||
|
||||
describe('#1821/#2305: ZCode receives no dead hook files; Kilo/OpenCode/Claude keep their hooks', () => {
|
||||
describe('#1821/#2305: ZCode receives no dead hook files; OpenCode/Claude keep their hooks', () => {
|
||||
function msdHookFilesUnder(configDir, hooksDirName) {
|
||||
const hooksDir = path.join(configDir, hooksDirName);
|
||||
if (!fs.existsSync(hooksDir)) return [];
|
||||
@@ -730,8 +703,7 @@ describe('#1821/#2305: ZCode receives no dead hook files; Kilo/OpenCode/Claude k
|
||||
|
||||
// ZCode declares hooksSurface:'none' with no plugin surface, so its staged
|
||||
// hooks are genuinely dead weight (#1821) — this is the case the fix
|
||||
// removes. (Kilo was originally in this loop; #2305 moved it to the
|
||||
// OpenCode group below — its native plugin spawns the staged guard hooks.)
|
||||
// removes.
|
||||
for (const runtime of ['zcode']) {
|
||||
test(`${runtime} --global install creates no msd-*.js/.sh hook files or hooks/lib`, () => {
|
||||
const { hookFiles, hooksLibExists } = installAndCollect(runtime);
|
||||
@@ -740,26 +712,6 @@ describe('#1821/#2305: ZCode receives no dead hook files; Kilo/OpenCode/Claude k
|
||||
});
|
||||
}
|
||||
|
||||
// #2305: Kilo's native plugin (plugins/msd-core.js, byte-identical to
|
||||
// OpenCode's) spawns the staged PreToolUse guard hooks as subprocesses, so
|
||||
// Kilo must receive the shared hooks bundle as a sibling of msd-core/ —
|
||||
// the shape the plugin's resolveRepoRoot walk requires. #1821 excluded
|
||||
// Kilo on the false premise that it had no plugin surface; with the skip
|
||||
// flag set, every guard silently no-opped on every Kilo install.
|
||||
test('kilo --global install stages the guard hooks its plugin spawns, hooks/lib, and the plugin', () => {
|
||||
const { hookFiles, hooksLibExists, gitCmdExists, pluginExists } = installAndCollect('kilo');
|
||||
const basenames = hookFiles.map((f) => path.basename(f));
|
||||
for (const expected of ['msd-prompt-guard.js', 'msd-read-guard.js', 'msd-worktree-path-guard.js']) {
|
||||
assert.ok(
|
||||
basenames.includes(expected),
|
||||
`kilo install must copy ${expected} (spawned by the plugin's runHook), found: ${basenames.join(', ')}`,
|
||||
);
|
||||
}
|
||||
assert.ok(hooksLibExists, 'kilo install must create hooks/lib/');
|
||||
assert.ok(gitCmdExists, 'kilo install must copy hooks/lib/git-cmd.js (required by the shared hooks)');
|
||||
assert.ok(pluginExists, 'kilo install must install plugins/msd-core.js (the hook-spawning plugin)');
|
||||
});
|
||||
|
||||
// Regression guard for #1914: OpenCode's plugin adapter spawns the staged
|
||||
// hooks, so excluding OpenCode from the hook copy would break it. OpenCode
|
||||
// must KEEP its hooks and receive the plugin.
|
||||
@@ -773,48 +725,6 @@ describe('#1821/#2305: ZCode receives no dead hook files; Kilo/OpenCode/Claude k
|
||||
assert.ok(pluginExists, 'opencode install must install plugins/msd-core.js (#1914 hook bridge)');
|
||||
});
|
||||
|
||||
// pi ALSO declares hooksSurface:'none', but — like OpenCode — it is NOT a
|
||||
// dead-weight case: pi's native extension (pi/msd.cjs → extensions/msd.js)
|
||||
// spawns the staged msd-hooks/*.js scripts as bounded subprocesses (session_start
|
||||
// → msd-ensure-canonical-path.js, before_agent_start → msd-workflow-guard.js,
|
||||
// session_before_compact → msd-context-monitor.js — #2102 Stage 2), and its
|
||||
// /msd command handler tokenizes raw args via the shared msd-hooks/lib/git-cmd.js
|
||||
// tokenizer. hostBehaviors.skipSharedHooksInstall is therefore NOT set for
|
||||
// pi (unlike Kilo/ZCode/Cursor/Cline/Trae/Copilot/Windsurf/Kimi) — pi is in
|
||||
// the OpenCode group, not the Kilo/ZCode group. #3023: pi's bundle is staged
|
||||
// under `msd-hooks/` (hostBehaviors.sharedHooksDirName), not the default
|
||||
// `hooks/` every other runtime in this describe block uses.
|
||||
test('pi --global install still copies msd-hooks/ (spawned by the native extension) + msd-hooks/lib/git-cmd.js + the extension itself', () => {
|
||||
// #2470: derive the extension filename from pi's own descriptor rather than
|
||||
// hardcoding it, and assert it satisfies pi's isExtensionFile() discovery
|
||||
// filter (.ts/.js only) — a dest pi cannot discover installs "successfully"
|
||||
// while /msd never registers, which is exactly how this shipped broken.
|
||||
const piNativePlugin = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'pi', 'capability.json'), 'utf8'),
|
||||
).runtime.hostBehaviors.nativePlugin;
|
||||
assert.ok(
|
||||
piNativePlugin.file.endsWith('.ts') || piNativePlugin.file.endsWith('.js'),
|
||||
`pi's installed extension "${piNativePlugin.file}" must end in .ts or .js — pi silently ` +
|
||||
'skips any other suffix during extensions/ auto-discovery (#2470)',
|
||||
);
|
||||
const { hookFiles, hooksLibExists, gitCmdExists, pluginExists } = installAndCollect('pi', {
|
||||
pluginRelPath: path.join(piNativePlugin.dir, piNativePlugin.file),
|
||||
});
|
||||
const basenames = hookFiles.map((f) => path.basename(f));
|
||||
for (const expected of ['msd-ensure-canonical-path.js', 'msd-workflow-guard.js', 'msd-context-monitor.js']) {
|
||||
assert.ok(
|
||||
basenames.includes(expected),
|
||||
`pi install must copy ${expected} (spawned by pi/msd.cjs's event bridges), found: ${basenames.join(', ')}`,
|
||||
);
|
||||
}
|
||||
assert.ok(hooksLibExists, 'pi install must create msd-hooks/lib/');
|
||||
assert.ok(gitCmdExists, 'pi install must copy msd-hooks/lib/git-cmd.js (the /msd command tokenizer)');
|
||||
assert.ok(
|
||||
pluginExists,
|
||||
`pi install must install ${piNativePlugin.dir}/${piNativePlugin.file} (the native-extension hook bridge)`,
|
||||
);
|
||||
});
|
||||
|
||||
// Positive control: guards against over-exclusion breaking runtimes that
|
||||
// legitimately need hooks (hooksSurface !== 'none').
|
||||
test('claude --global install still copies msd-*.js hooks', () => {
|
||||
@@ -1909,68 +1819,6 @@ describe('enh-1077 phase 5f-2: claude hookEvents dialect → PostToolUse/PreTool
|
||||
});
|
||||
});
|
||||
|
||||
// ── augment ──
|
||||
|
||||
describe('augment install uses PostToolUse/PreToolUse (claude dialect)', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
let settings;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('msd-1077-augment-');
|
||||
previousCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
|
||||
const augDir = path.join(tmpDir, '.augment');
|
||||
fs.mkdirSync(augDir, { recursive: true });
|
||||
const result = install(false, 'augment');
|
||||
settings = result && result.settings;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('registry confirms augment hookEvents is "claude"', () => {
|
||||
const he = registryHookEvents('augment');
|
||||
if (he !== undefined) {
|
||||
assert.strictEqual(he, 'claude',
|
||||
'Registry descriptor for augment must declare hookEvents="claude"');
|
||||
}
|
||||
});
|
||||
|
||||
test('augment install returns a settings object', () => {
|
||||
assert.ok(settings !== null && typeof settings === 'object',
|
||||
'augment install must return a non-null settings object');
|
||||
});
|
||||
|
||||
test('augment install registers at least one hook under PostToolUse', () => {
|
||||
assert.ok(hasHooksFor(settings, 'PostToolUse'),
|
||||
`Expected PostToolUse hooks on augment; got hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`);
|
||||
});
|
||||
|
||||
test('augment install does NOT register context-monitor under AfterTool', () => {
|
||||
const cmds = hooksForEvent(settings, 'AfterTool');
|
||||
const hasMonitor = cmds.some(c => c && c.includes('msd-context-monitor'));
|
||||
assert.strictEqual(hasMonitor, false,
|
||||
`augment must NOT use AfterTool for context-monitor; got: ${JSON.stringify(cmds)}`);
|
||||
});
|
||||
|
||||
test('augment install registers at least one pre-tool hook (prompt-guard) under PreToolUse', () => {
|
||||
const cmds = hooksForEvent(settings, 'PreToolUse');
|
||||
const hasPromptGuard = cmds.some(c => c && c.includes('msd-prompt-guard'));
|
||||
assert.ok(hasPromptGuard,
|
||||
`Expected prompt-guard hook under PreToolUse on augment; PreToolUse commands: ${JSON.stringify(cmds)}; hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`);
|
||||
});
|
||||
|
||||
test('augment install does NOT register prompt-guard under BeforeTool (wrong pre-tool dialect)', () => {
|
||||
const cmds = hooksForEvent(settings, 'BeforeTool');
|
||||
const hasPromptGuard = cmds.some(c => c && c.includes('msd-prompt-guard'));
|
||||
assert.strictEqual(hasPromptGuard, false,
|
||||
`augment must NOT use BeforeTool for prompt-guard; got BeforeTool commands: ${JSON.stringify(cmds)}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 3: Registry-parity invariant ──────────────────────────────────────
|
||||
@@ -2087,7 +1935,7 @@ const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { install, uninstall, validateHookFields } = require('../bin/install.js');
|
||||
const { install } = require('../bin/install.js');
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
@@ -2128,94 +1976,6 @@ function stubHooksIntoTarget(targetDir) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist in-memory settings to disk, simulating what finishInstall() does
|
||||
* (finishInstall is not exported). Required for tests that call install()
|
||||
* twice and need the second call to read the first call's hook registrations.
|
||||
*/
|
||||
function persistSettings(settingsPath, settings) {
|
||||
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(validateHookFields(settings), null, 2) + '\n', 'utf8');
|
||||
}
|
||||
|
||||
// ─── Suite 1: Qwen — new events are registered ───────────────────────────────
|
||||
|
||||
describe('enh-788: Qwen install registers 3 new hook events', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
let settings;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('msd-788-qwen-');
|
||||
previousCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
|
||||
const targetDir = path.join(tmpDir, '.qwen');
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
// Pre-populate hook files so installer registration guards (fs.existsSync)
|
||||
// pass and hooks are actually registered in settings.json.
|
||||
stubHooksIntoTarget(targetDir);
|
||||
|
||||
const result = install(false, 'qwen');
|
||||
settings = result.settings;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('install returns a settings object (not null)', () => {
|
||||
assert.ok(settings !== null && typeof settings === 'object',
|
||||
'Qwen install must return a non-null settings object');
|
||||
});
|
||||
|
||||
test('SubagentStop event is registered with at least one hook', () => {
|
||||
const cmds = hooksForEvent(settings, 'SubagentStop');
|
||||
assert.ok(cmds.length > 0,
|
||||
`Expected SubagentStop hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`);
|
||||
});
|
||||
|
||||
test('Stop event is registered with at least one hook', () => {
|
||||
const cmds = hooksForEvent(settings, 'Stop');
|
||||
assert.ok(cmds.length > 0,
|
||||
`Expected Stop hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`);
|
||||
});
|
||||
|
||||
test('PreCompact event is registered with at least one hook', () => {
|
||||
const cmds = hooksForEvent(settings, 'PreCompact');
|
||||
assert.ok(cmds.length > 0,
|
||||
`Expected PreCompact hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`);
|
||||
});
|
||||
|
||||
test('UserPromptSubmit is NOT registered (handler not yet implemented for that payload shape)', () => {
|
||||
// msd-prompt-guard exits unless tool_name is Write|Edit — it is a no-op
|
||||
// for UserPromptSubmit payloads. Registration is deferred until a
|
||||
// dedicated hook can process the user-prompt payload shape.
|
||||
const cmds = hooksForEvent(settings, 'UserPromptSubmit');
|
||||
assert.strictEqual(cmds.length, 0,
|
||||
`UserPromptSubmit should NOT be registered yet; got: ${JSON.stringify(cmds)}`);
|
||||
});
|
||||
|
||||
test('SubagentStop / Stop / PreCompact all use msd-context-monitor', () => {
|
||||
for (const event of ['SubagentStop', 'Stop', 'PreCompact']) {
|
||||
const cmds = hooksForEvent(settings, event);
|
||||
assert.ok(
|
||||
cmds.some(c => c.includes('msd-context-monitor')),
|
||||
`Event ${event} should use msd-context-monitor; got commands: ${JSON.stringify(cmds)}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('FileChanged is NOT registered for Qwen (Claude-only event)', () => {
|
||||
// msd-config-reload / FileChanged is a Claude Code-only registration.
|
||||
// Qwen does not support the FileChanged hook event at all.
|
||||
const cmds = hooksForEvent(settings, 'FileChanged');
|
||||
assert.strictEqual(cmds.length, 0,
|
||||
`FileChanged should NOT be registered for Qwen; got: ${JSON.stringify(cmds)}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 2: Claude install DOES get the context events (since #770) ───────
|
||||
// Note: Prior to #770, these were Qwen-only events. #770 extended them to
|
||||
// Claude Code. This suite is updated to match the new expected behavior.
|
||||
@@ -2258,83 +2018,6 @@ describe('enh-788 (updated by #770): Claude install registers context lifecycle
|
||||
`Claude should have PreCompact since #770; got: ${JSON.stringify(cmds)}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 3: Idempotency — persisted reinstall does not duplicate hooks ──────
|
||||
|
||||
describe('enh-788: Qwen install is idempotent across persisted reinstalls', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('msd-788-idem-');
|
||||
previousCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
|
||||
const targetDir = path.join(tmpDir, '.qwen');
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
stubHooksIntoTarget(targetDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('re-running after persisted first install does not duplicate hook entries', () => {
|
||||
// First install: get settings and persist to disk (simulating finishInstall)
|
||||
const result1 = install(false, 'qwen');
|
||||
persistSettings(result1.settingsPath, result1.settings);
|
||||
|
||||
// Second install: reads the persisted settings.json — dedup guards apply
|
||||
process.chdir(tmpDir);
|
||||
const result2 = install(false, 'qwen');
|
||||
const s2 = result2.settings;
|
||||
|
||||
for (const event of ['SubagentStop', 'Stop', 'PreCompact']) {
|
||||
const cmds = hooksForEvent(s2, event);
|
||||
assert.strictEqual(cmds.length, 1,
|
||||
`Event ${event} should have exactly 1 hook command after idempotent reinstall; got ${cmds.length}: ${JSON.stringify(cmds)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 4: Uninstall removes the new event registrations ──────────────────
|
||||
|
||||
describe('enh-788: Qwen uninstall removes new hook event entries', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('msd-788-uninstall-');
|
||||
previousCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
|
||||
const targetDir = path.join(tmpDir, '.qwen');
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
stubHooksIntoTarget(targetDir);
|
||||
|
||||
// Install and persist to disk so uninstall has a settings.json to clean
|
||||
const result = install(false, 'qwen');
|
||||
persistSettings(result.settingsPath, result.settings);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('settings.json hook entries are removed on uninstall', () => {
|
||||
uninstall(false, 'qwen');
|
||||
const settingsPath = path.join(tmpDir, '.qwen', 'settings.json');
|
||||
if (!fs.existsSync(settingsPath)) return; // file removed entirely is fine
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
for (const event of ['SubagentStop', 'Stop', 'PreCompact']) {
|
||||
const cmds = hooksForEvent(settings, event);
|
||||
assert.strictEqual(cmds.length, 0,
|
||||
`After uninstall, ${event} should have 0 hooks; got: ${JSON.stringify(cmds)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3202,139 +2885,6 @@ describe('#4087 regression: Codex install stages the hook helpers its hooks requ
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #3023: pi must not stage its shared-hooks bundle in pi's reserved hooks/ ──
|
||||
//
|
||||
// pi (pi.dev) renamed its `hooks/` directory to `extensions/` and now prints a
|
||||
// deprecation warning on every startup when a `hooks/` directory exists in its
|
||||
// agent dir. MSD's installer stages the shared hook bundle at
|
||||
// `<destRootDir>/hooks` for every runtime that does not set
|
||||
// hostBehaviors.skipSharedHooksInstall — which since #2102 Stage 2 includes pi.
|
||||
// The bundle must live under a name pi does not reserve.
|
||||
//
|
||||
// The expected directory name is asserted as a LITERAL on purpose: importing the
|
||||
// production constant would make the assertion re-derive the very value under
|
||||
// test, and it could then never catch that value changing.
|
||||
describe('#4087 review: Windsurf install stages the hook helpers its hooks require', () => {
|
||||
// Same defect class as the Codex rows above, one runtime over. Windsurf sets
|
||||
// skipSharedHooksInstall, so it never reaches installSharedHooksBundle, and
|
||||
// writeWindsurfHooksJson staged the two Cascade guards without the hooks/lib
|
||||
// helpers both require at module load. Measured before the fix against a real
|
||||
// `--windsurf --global` install: installer exit 0, hooks/ holding only the two
|
||||
// scripts, and each one exiting 1 with "Cannot find module './lib/hook-exit.js'".
|
||||
//
|
||||
// tests/windsurf-hooks-bridge.test.cjs runs these guards from the SOURCE tree,
|
||||
// where hooks/lib/ is a sibling and require() trivially resolves — the same
|
||||
// "assert existence, never execute the installed copy" blind spot that let
|
||||
// #4087 ship. These rows execute the INSTALLED copy.
|
||||
const { before: __gtmBefore, after: __gtmAfter } = require('node:test');
|
||||
let __savedMsdTestMode;
|
||||
__gtmBefore(() => { __savedMsdTestMode = process.env.MSD_TEST_MODE; delete process.env.MSD_TEST_MODE; });
|
||||
__gtmAfter(() => { if (__savedMsdTestMode === undefined) delete process.env.MSD_TEST_MODE; else process.env.MSD_TEST_MODE = __savedMsdTestMode; });
|
||||
|
||||
let tmpDir;
|
||||
beforeEach(() => { tmpDir = createTempDir('msd-install-4087-windsurf-'); });
|
||||
afterEach(() => { cleanup(tmpDir); });
|
||||
|
||||
function installWindsurf(configDir) {
|
||||
// HOME/USERPROFILE sandboxed for the CHILD, for the same reason as installCodex.
|
||||
throwIfFailed(
|
||||
runNode([INSTALL_SCRIPT, '--windsurf', '--global', '--yes', '--config-dir', configDir], {
|
||||
timeoutMs: INSTALL_TIMEOUT_MS,
|
||||
env: { ...process.env, HOME: configDir, USERPROFILE: configDir },
|
||||
}),
|
||||
`node ${INSTALL_SCRIPT} --windsurf --global --config-dir ${configDir}`,
|
||||
);
|
||||
return path.join(configDir, 'hooks');
|
||||
}
|
||||
|
||||
test('both installed Windsurf guards LOAD AND RUN, and their transitive helpers are staged', () => {
|
||||
const hooksDir = installWindsurf(tmpDir);
|
||||
|
||||
for (const script of ['msd-windsurf-pre-write.js', 'msd-windsurf-pre-command.js']) {
|
||||
const hook = path.join(hooksDir, script);
|
||||
assert.ok(fs.existsSync(hook), `precondition: ${script} must be staged`);
|
||||
const result = runNode([hook], { timeoutMs: INSTALLED_HOOK_EXEC_TIMEOUT_MS, input: '{}', env: { ...process.env } });
|
||||
assert.strictEqual(result.outcome, 'exited',
|
||||
`${script} must run to completion, not time out or be killed. outcome=${result.outcome}`);
|
||||
assert.strictEqual(result.exitCode, 0,
|
||||
`the installed ${script} must load and exit 0 — a MODULE_NOT_FOUND at load fires on every `
|
||||
+ `pre_write_code / pre_run_command event and is invisible to the installer's exit code. stderr: ${result.stderr}`);
|
||||
assert.doesNotMatch(String(result.stderr || ''), /MODULE_NOT_FOUND|Cannot find module/,
|
||||
`no missing-module error may reach stderr for ${script}`);
|
||||
}
|
||||
|
||||
const libDir = path.join(hooksDir, 'lib');
|
||||
assert.ok(fs.existsSync(libDir), 'hooks/lib/ must be staged for Windsurf');
|
||||
// Direct requires of the two guards, plus what hook-exit.js itself requires
|
||||
// (cli-exit.js → exit-code-registry.js). The exact set is also pinned by
|
||||
// tests/fixtures/install-tree/windsurf.json via the golden-install-tree test;
|
||||
// this row states the reason each file must be present.
|
||||
for (const helper of ['hook-exit.js', 'git-probe.js', 'cli-exit.js', 'exit-code-registry.js']) {
|
||||
assert.ok(fs.existsSync(path.join(libDir, helper)),
|
||||
`${helper} is on the require path of a staged Windsurf guard and must be staged`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('#3023 pi shared-hooks bundle avoids the host-reserved hooks/ directory', () => {
|
||||
const PI_RESERVED_DIR = 'hooks';
|
||||
const PI_BUNDLE_DIR = 'msd-hooks';
|
||||
|
||||
for (const scope of ['local', 'global']) {
|
||||
test(`pi ${scope} install: no host-reserved hooks/ dir, bundle staged under ${PI_BUNDLE_DIR}/, manifested`, (t) => {
|
||||
const { manifest, configDir, root } = runMinimalInstall({ runtime: 'pi', scope });
|
||||
t.after(() => cleanup(root));
|
||||
|
||||
const reserved = path.join(configDir, PI_RESERVED_DIR);
|
||||
assert.equal(
|
||||
fs.existsSync(reserved),
|
||||
false,
|
||||
`pi reserves <configDir>/${PI_RESERVED_DIR} as its deprecated extension location; ` +
|
||||
`MSD must not create it (found ${reserved})`
|
||||
);
|
||||
|
||||
const bundle = path.join(configDir, PI_BUNDLE_DIR);
|
||||
assert.equal(
|
||||
fs.existsSync(bundle) && fs.statSync(bundle).isDirectory(),
|
||||
true,
|
||||
`pi's shared hook bundle must be staged at ${bundle}`
|
||||
);
|
||||
|
||||
// The adapter's live require target (pi/msd.cjs parseMsdCommandArgs).
|
||||
const gitCmd = path.join(bundle, 'lib', 'git-cmd.js');
|
||||
assert.equal(
|
||||
fs.existsSync(gitCmd) && fs.statSync(gitCmd).isFile(),
|
||||
true,
|
||||
`pi adapter requires ${gitCmd}; the hooks/lib helpers must move with the bundle`
|
||||
);
|
||||
|
||||
// #2544 CommonJS marker follows the bundle, and is NOT dropped at the
|
||||
// shared config root (user-owned territory).
|
||||
assert.equal(
|
||||
fs.existsSync(path.join(bundle, 'package.json')),
|
||||
true,
|
||||
'the CommonJS marker must live inside the bundle directory'
|
||||
);
|
||||
|
||||
assert.ok(manifest && manifest.files, 'pi install must write a file manifest');
|
||||
const keys = Object.keys(manifest.files);
|
||||
|
||||
const stale = keys.filter((k) => k.startsWith(`${PI_RESERVED_DIR}/`));
|
||||
assert.deepEqual(
|
||||
stale,
|
||||
[],
|
||||
'no manifest key may reference the host-reserved hooks/ directory'
|
||||
);
|
||||
|
||||
const staged = keys.filter((k) => k.startsWith(`${PI_BUNDLE_DIR}/`));
|
||||
assert.ok(
|
||||
staged.length > 0,
|
||||
`manifest must track the staged bundle under ${PI_BUNDLE_DIR}/ so uninstall can remove it`
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── #3981: blocking PreToolUse guards must not fail open on a host stall ────
|
||||
|
||||
describe('bug #3981: blocking-guard timeout budget + migration', () => {
|
||||
|
||||
Reference in New Issue
Block a user