'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 /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