const { test, describe } = require('node:test'); const assert = require('node:assert/strict'); const path = require('node:path'); const { spawnSync } = require('node:child_process'); const { withIsolatedProcessState, TEST_ENV_BASE, CONFIG_LOCATION_ENV_KEYS, scrubConfigLocationEnv, } = require('./helpers.cjs'); const { INSTALL_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); /** * A cold `node -e` require-probe in this file (not the shared * `PROBE_TIMEOUT_MS` class — that constant is 15000ms and consolidating * onto it would silently halve this file's pre-existing bound, a real * behavior change a rename-only migration must not make). The comment at * each use site already documents the observed duration as sub-second, so * 30000ms leaves ~30x headroom. Pre-existing value, unchanged by this * migration. */ const COLD_REQUIRE_PROBE_TIMEOUT_MS = 30000; describe('#2665: the built-lib require is deferred', () => { // The scrub set derives from msd-core/bin/lib, which is BUILT. Requiring it at // module scope made an unbuilt tree throw inside `require('./helpers.cjs')` — // before any test() registered — so one missing `npm run build:lib` became a // whole-suite crash in the file ~370 test files import. A cold child is the only // honest probe: this process has already loaded everything. const probe = (touch) => { const src = [ "const path = require('node:path');", `require(${JSON.stringify(path.join(__dirname, 'helpers.cjs'))});`, touch, "const needle = path.join('msd-core', 'bin', 'lib', 'capability-registry.cjs');", 'process.stdout.write(String(Object.keys(require.cache).some((m) => m.endsWith(needle))));', ].join('\n'); // Bounded per local/no-unbounded-spawn (#3143): a cold require is sub-second, so // COLD_REQUIRE_PROBE_TIMEOUT_MS leaves ~30x headroom and fails loudly instead of hanging a lane. const r = spawnSync(process.execPath, ['-e', src], { encoding: 'utf8', timeout: COLD_REQUIRE_PROBE_TIMEOUT_MS }); assert.strictEqual(r.status, 0, `probe failed: ${r.stderr}`); return r.stdout === 'true'; }; test('requiring helpers.cjs alone does NOT load the built runtime lib', () => { assert.strictEqual(probe(''), false, 'the built lib was loaded at import time'); }); test('reading TEST_ENV_BASE is what loads it', () => { const touch = `require(${JSON.stringify(path.join(__dirname, 'helpers.cjs'))}).TEST_ENV_BASE;`; assert.strictEqual(probe(touch), true, 'reading the scrub set must resolve the built lib'); }); }); describe('withIsolatedProcessState', () => { test('restores env, cwd, and exitCode after callback', () => { const originalCwd = process.cwd(); const originalExitCode = process.exitCode; const originalMarker = process.env.MSD_TEST_ISOLATION_MARKER; const tempCwd = path.dirname(originalCwd); withIsolatedProcessState(() => { process.env.MSD_TEST_ISOLATION_MARKER = 'changed'; process.exitCode = 73; process.chdir(tempCwd); }); assert.strictEqual(process.cwd(), originalCwd); assert.strictEqual(process.exitCode, originalExitCode); assert.strictEqual(process.env.MSD_TEST_ISOLATION_MARKER, originalMarker); }); test('restores state even when callback throws', () => { const originalCwd = process.cwd(); const originalPath = process.env.PATH; assert.throws(() => { withIsolatedProcessState(() => { process.env.PATH = ''; process.chdir(path.dirname(originalCwd)); throw new Error('boom'); }); }, /boom/); assert.strictEqual(process.cwd(), originalCwd); assert.strictEqual(process.env.PATH, originalPath); }); }); // ─── #2665: the config-location scrub is DERIVED, and stays that way ────────── // // The recurrence guard. #2665 documents two prior authors independently // diagnosing this class and each fixing only the instance in front of them; // this is the third pass. A hand-maintained scrub list cannot be defended by // review alone, so the invariant is asserted instead of trusted. // // SCOPE BOUNDARY — read this before trusting a green run here. // // Every test below asserts that TEST_ENV_BASE covers some ENUMERATION (the // capability registry, the non-registry descriptor set, MSD's own location // keys). Each therefore proves only that the scrub set is not narrower than the // enumeration it derives from. NONE of them can prove the enumeration is itself // complete: a config-location var that no enumeration carries is invisible to // all of them, and they stay green. // // That is not hypothetical — it is how round 2 found MSD_HOME and // a runtime-specific config-location var while this block was fully green. // MSD_HOME belonged to no enumeration at all (it is MSD's own store root, not a // runtime configHome); the other var sat inside a function body where nothing could enumerate it. // Round 3's fix was to make both enumerable rather than to add two assertions, // precisely because an assertion added per reviewer-named var is the // hand-maintained list wearing a test's clothes. // // The completeness question — "is every env-first first-party location var in // SOME enumeration?" — is answered by a source census re-derived each round // (see the PR discussion), and by scripts/live-config-guard.cjs at runtime, // which observes actual writes rather than reasoning about names. Neither lives // here, and this block should not be read as standing in for them. describe('#2665: TEST_ENV_BASE config-location coverage', () => { test('every runtime configHome env var in the registry is scrubbed', () => { const { runtimes } = require('../msd-core/bin/lib/capability-registry.cjs'); const declared = [ ...new Set( Object.values(runtimes).flatMap((r) => r?.runtime?.configHome?.env ?? []), ), ].sort(); // Guards the guard: an empty/renamed registry shape would make the // assertion below vacuously true and silently retire this test. assert.ok( declared.length >= 5, `expected the registry to declare many configHome env vars, got ${declared.length} — ` + 'if the registry shape changed, this derivation needs updating, not deleting', ); const missing = declared.filter((k) => !(k in TEST_ENV_BASE)); assert.deepStrictEqual( missing, [], `config-location env vars reachable by the resolver but not scrubbed: ${missing.join(', ')}. ` + 'TEST_ENV_BASE derives this set from the capability registry — a gap here means the ' + 'derivation broke, not that the list needs a manual entry.', ); }); test('every scrubbed config-location var is blanked, not merely present', () => { for (const key of CONFIG_LOCATION_ENV_KEYS) { assert.strictEqual( TEST_ENV_BASE[key], '', `${key} must be blanked ('') so the child sees a falsy value on the env-first branch`, ); } }); test('the non-registry config-location vars are covered too', () => { // These have no capability descriptor, so the registry derivation alone // cannot reach them: GROK_AGENTS_HOME is a hardcoded branch of // getGlobalConfigDir, MSD_RUNTIME selects which runtime home resolves, and // MSD_PROJECT / MSD_WORKSTREAM move a child's .planning root // (src/planning-workspace.cts). Named explicitly so deleting one from the // helper is a test failure rather than a silent narrowing. for (const key of ['GROK_AGENTS_HOME', 'MSD_RUNTIME', 'MSD_PROJECT', 'MSD_WORKSTREAM']) { assert.strictEqual(TEST_ENV_BASE[key], '', `${key} must be scrubbed`); } }); test('descriptor-shaped config homes OUTSIDE the registry are derived, not listed', () => { const { NON_REGISTRY_CONFIG_HOME_DESCRIPTORS, } = require('../msd-core/bin/lib/runtime-homes.cjs'); // Round 3. A runtime can own a config home that is NOT registry-visible (a // hardcoded descriptor inside a resolver). The registry-only derivation // would miss it, so the non-registry set is walked too. The set may be // empty (no such descriptor is shipped today) — then this is trivially // satisfied, and the reversion-sensitive sentinel test below proves the // walk itself still reaches a descriptor when one exists. const declared = [ ...new Set(NON_REGISTRY_CONFIG_HOME_DESCRIPTORS.flatMap((d) => d?.env ?? [])), ]; const missing = declared.filter((k) => !(k in TEST_ENV_BASE)); assert.deepStrictEqual( missing, [], `descriptor-declared config-location vars not scrubbed: ${missing.join(', ')}`, ); }); test('skillsHome env vars are walked on BOTH descriptor rungs', () => { // Round 4. A configHome descriptor can nest a second, independently-resolved // descriptor (skillsHome → resolveSkillsBaseFromDescriptor), which carries // its own env array. Walking configHome.env alone is the identical // walk-one-field gap-shape rounds 2-3 closed for the registry and the // non-registry set. Inert today — no shipped runtime declares skillsHome // env vars — so this asserts the DERIVATION reaches the field, not that any // var currently flows from it: every skillsHome-declared var (registry and // non-registry alike) must land in TEST_ENV_BASE the moment one exists. const { runtimes } = require('../msd-core/bin/lib/capability-registry.cjs'); const { NON_REGISTRY_CONFIG_HOME_DESCRIPTORS, } = require('../msd-core/bin/lib/runtime-homes.cjs'); const declared = [ ...new Set([ ...Object.values(runtimes).flatMap( (r) => r?.runtime?.configHome?.skillsHome?.env ?? [], ), ...NON_REGISTRY_CONFIG_HOME_DESCRIPTORS.flatMap( (d) => d?.skillsHome?.env ?? [], ), ]), ]; const missing = declared.filter((k) => !(k in TEST_ENV_BASE)); assert.deepStrictEqual( missing, [], `skillsHome-declared config-location vars not scrubbed: ${missing.join(', ')}`, ); }); test("MSD's OWN location vars are scrubbed (a second family, not a registry gap)", () => { const { MSD_LOCATION_ENV_KEYS } = require('../msd-core/bin/lib/runtime-homes.cjs'); // MSD_HOME decides where MSD keeps user-owned state ($MSD_HOME/.msd/ — // consent.json, defaults.json, capability overlays) and is read env-FIRST, // ahead of os.homedir(), across capability-loader / capability-consent / // capability-state / capability-writer / config-loader / install-profiles / // bin/install.js. MSD_AGENTS_DIR is priority 1 in getAgentsDir. Neither is a // runtime configHome, so no amount of registry derivation reaches them. assert.ok(MSD_LOCATION_ENV_KEYS.includes('MSD_HOME')); for (const key of MSD_LOCATION_ENV_KEYS) { assert.strictEqual(TEST_ENV_BASE[key], '', `${key} must be scrubbed`); } }); test('write-escape PERMISSIONS are scrubbed (a fifth family — not a location var)', () => { // #2665 round 5. MSD_ALLOW_SYMLINKED_DEST names no path, so every rung of the // derivation above is structurally incapable of reaching it — it is not a // registry configHome, not descriptor-shaped, not one of MSD's own location // vars. It is still a #2665 leak vector: install-engine.cts reads it env-first // and threads it into the symlink-escape guard that stops a write leaving the // install root, so an ambient `=1` disarms that guard for the whole suite. // // Named literally rather than derived from the family constant on purpose: a // test that reads WRITE_ESCAPE_PERMISSION_ENV_KEYS and asserts over it shrinks // its own expectation when the family is emptied — the enumeration-relative // failure this suite already documents, and the one that let a // descriptor go unwatched. Naming it is what makes removal fail loudly. assert.strictEqual( TEST_ENV_BASE.MSD_ALLOW_SYMLINKED_DEST, '', 'MSD_ALLOW_SYMLINKED_DEST must be blanked: ambient =1 disarms the symlink-escape guard', ); // Blanking must be fail-SAFE — '' is neither '1' nor 'true', so the guard gets // stricter, never looser. This is what licenses scrubbing it wholesale. assert.ok(!['1', 'true'].includes(TEST_ENV_BASE.MSD_ALLOW_SYMLINKED_DEST)); }); test('scrubConfigLocationEnv clears and restores the parent process env', () => { // The in-process half of the fix (Blocker 1): TEST_ENV_BASE only reaches // children, so a test calling install() in-process needs the PARENT's env // cleared. Round-trip both states — set and unset — because restoring an // originally-unset var as '' rather than deleting it is itself a leak. withIsolatedProcessState(() => { process.env.CLAUDE_CONFIG_DIR = '/tmp/ambient-claude'; delete process.env.CODEX_HOME; const restore = scrubConfigLocationEnv(); assert.strictEqual(process.env.CLAUDE_CONFIG_DIR, undefined, 'a set config-location var must be deleted, not blanked, on the parent'); assert.strictEqual(process.env.CODEX_HOME, undefined); restore(); assert.strictEqual(process.env.CLAUDE_CONFIG_DIR, '/tmp/ambient-claude', 'restore must put back the original value'); assert.ok(!('CODEX_HOME' in process.env), 'restore must leave an originally-unset var unset, not set it to empty string'); }); }); }); describe('#2665 round 4: the skillsHome walk is reversion-sensitive', () => { // The coverage tests above are enumeration-relative, and skillsHome.env is // empty everywhere today — so reverting the skillsHome rungs from the // derivation leaves every one of them green (measured by this round's // pre-push adversarial review). This test closes that: it cold-requires // helpers.cjs in a child process after injecting sentinel skillsHome env // vars into BOTH enumerations (registry and non-registry), so the walk // itself is what is under test, not today's empty declarations. test('sentinel skillsHome vars flow into TEST_ENV_BASE on both rungs', () => { const { execFileSync } = require('node:child_process'); const regPath = require.resolve('../msd-core/bin/lib/capability-registry.cjs'); const rhPath = require.resolve('../msd-core/bin/lib/runtime-homes.cjs'); const helpersPath = require.resolve('./helpers.cjs'); const script = ` 'use strict'; const reg = require(${JSON.stringify(regPath)}); const rh = require(${JSON.stringify(rhPath)}); // Rung 1 (registry): give one runtime a skillsHome env var. Push a // sentinel into whichever runtime declares skillsHome, or graft one onto // the first runtime if none does. const declaring = Object.values(reg.runtimes).find( (r) => r?.runtime?.configHome?.skillsHome, ) ?? Object.values(reg.runtimes)[0]; if (!declaring.runtime.configHome.skillsHome) { declaring.runtime.configHome.skillsHome = { kind: 'dot-home', name: '.x', env: [] }; } declaring.runtime.configHome.skillsHome.env = ['MSD_TEST_SENTINEL_REGISTRY_SKILLS']; // Rung 2 (non-registry): graft a skillsHome onto the first descriptor // (the shipped set is empty, so seed one if needed). if (rh.NON_REGISTRY_CONFIG_HOME_DESCRIPTORS.length === 0) { rh.NON_REGISTRY_CONFIG_HOME_DESCRIPTORS.push({ kind: 'dot-home', name: '.x', env: [] }); } rh.NON_REGISTRY_CONFIG_HOME_DESCRIPTORS[0].skillsHome = { kind: 'dot-home', name: '.x', env: ['MSD_TEST_SENTINEL_NONREG_SKILLS'], }; const { TEST_ENV_BASE } = require(${JSON.stringify(helpersPath)}); const missing = [ 'MSD_TEST_SENTINEL_REGISTRY_SKILLS', 'MSD_TEST_SENTINEL_NONREG_SKILLS', ].filter((k) => TEST_ENV_BASE[k] !== ''); if (missing.length > 0) { console.error('skillsHome walk missed: ' + missing.join(', ')); process.exit(1); } process.exit(0); `; const out = execFileSync(process.execPath, ['-e', script], { cwd: __dirname, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], // Same class as the probe above (#2665) — see COLD_REQUIRE_PROBE_TIMEOUT_MS's own comment. timeout: COLD_REQUIRE_PROBE_TIMEOUT_MS, }); void out; // exit 0 is the assertion; execFileSync throws on nonzero }); }); describe('#3156: a raw installer spawn cannot write into the ambient HOME', () => { const fs = require('node:fs'); const os = require('node:os'); const { execFileSync } = require('node:child_process'); const { installSpawnEnv, cleanup } = require('./helpers.cjs'); const { installerEnv } = require('./helpers/install-shared.cjs'); const INSTALL_PATH = path.join(__dirname, '..', 'bin', 'install.js'); // Contract half — cheap, and it names the precedence the callers depend on. test('the sandbox HOME replaces the ambient one, but an explicit override still wins', () => { for (const build of [installSpawnEnv, installerEnv]) { const env = build(); assert.notStrictEqual(env.HOME, process.env.HOME, 'a raw installer spawn must not inherit the ambient HOME'); assert.strictEqual(env.USERPROFILE, env.HOME, 'USERPROFILE must track HOME — os.homedir() reads it on Windows'); assert.strictEqual(build({ HOME: '/explicit', USERPROFILE: '/explicit' }).HOME, '/explicit', 'an explicit HOME override must still win (overrides spread last)'); // #3712 / Codex review of #3725: the marker attests to the home ACTUALLY in // effect. Spreading `overrides` last used to leave it naming this helper's // default home while HOME was the caller's, and on a passwd-less host the // test-home guard compares the two and refuses a legitimately sandboxed // spawn. Asserting HOME alone did not catch it — the marker has to move too. assert.strictEqual(build().MSD_TEST_HOME_SANDBOX, build().HOME, 'the marker must name the default sandbox home'); const overridden = build({ HOME: '/explicit', USERPROFILE: '/explicit' }); assert.strictEqual(overridden.MSD_TEST_HOME_SANDBOX, '/explicit', 'the marker must follow an overridden HOME, not keep naming the default one'); assert.strictEqual( build({ HOME: '/explicit', MSD_TEST_HOME_SANDBOX: '/caller-chosen' }).MSD_TEST_HOME_SANDBOX, '/caller-chosen', 'an explicitly supplied marker still wins over the derived one'); } }); // Behavioural half — the one that actually fails pre-fix. // // bin/install.js writeNonClaudeDefaults() (#2834) writes // /.msd/defaults.json for every NON-Claude runtime, reading no // MSD variable at all. So this is deliberately driven through the real // installer against a real ambient HOME: no assertion about the scrub set can // stand in for it, because no scrub set can reach os.homedir(). // // Negative control: revert installerEnv() to `{ ...process.env, ...overrides }` // and the canary gains .msd/defaults.json. test('installing a non-Claude runtime leaves the ambient HOME untouched', () => { const canaryHome = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-3156-canary-home-')); const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-3156-project-')); const realHome = process.env.HOME; const realUserProfile = process.env.USERPROFILE; try { // Make the AMBIENT home the canary — the vector is the parent process's // own HOME, exactly as on a developer machine or a CI runner. process.env.HOME = canaryHome; process.env.USERPROFILE = canaryHome; execFileSync(process.execPath, [INSTALL_PATH, '--cursor', '--local', '--no-sdk'], { cwd: projectDir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], env: installerEnv(), timeout: INSTALL_TIMEOUT_MS, }); assert.ok(!fs.existsSync(path.join(canaryHome, '.msd')), `the installer wrote MSD's user store into the ambient HOME: ${ fs.existsSync(path.join(canaryHome, '.msd')) ? fs.readdirSync(path.join(canaryHome, '.msd')).join(', ') : '' }`); } finally { if (realHome === undefined) delete process.env.HOME; else process.env.HOME = realHome; if (realUserProfile === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = realUserProfile; cleanup(canaryHome); cleanup(projectDir); } }); }); describe('#3712: a raw installer spawn cannot reach the ambient HOME\'s shared skills root', () => { const fs = require('node:fs'); const os = require('node:os'); const { execFileSync } = require('node:child_process'); const { cleanup } = require('./helpers.cjs'); const { installerEnv } = require('./helpers/install-shared.cjs'); const INSTALL_PATH = path.join(__dirname, '..', 'bin', 'install.js'); /** * A GLOBAL codex install additionally walks and copies the shared skills * tree across the ambient-HOME boundary (#3712) on top of a normal * install run, so it keeps a larger cap than INSTALL_TIMEOUT_MS. No fresh * bench measurement justifies a new number here; the existing multiple is * preserved rather than re-guessed on migration. */ const INSTALL_GLOBAL_TIMEOUT_MS = INSTALL_TIMEOUT_MS * 2.5; // #3156's canary asserts on /.msd. That is not the only ambient-HOME // surface: a kind may declare a global `home` override resolved from os.homedir() // — codex's skills kind (`.agents`) — and the installer PRUNES msd-* entries under // it. A 71-skill deletion there passed the .msd-only canary unnoticed (#3712). // // The runtime choice is load-bearing, not incidental. The .msd row spawns // `--cursor --local`, and cursor declares no home override, so an `.agents` // assertion on THAT spawn passes even with all confinement removed. Only a // runtime that actually carries the override can discriminate — today that is // codex at global scope, asserted below so a descriptor change fails here loudly. test('installing codex globally leaves the ambient HOME\'s .agents/skills sampled inventory unchanged', () => { const { runtimes } = require('../msd-core/bin/lib/capability-registry.cjs'); const codexGlobalSkills = (runtimes?.codex?.runtime?.artifactLayout?.global ?? []) .find((e) => e.kind === 'skills' && e.home); assert.ok(codexGlobalSkills, 'codex global skills must still declare a `home` override — without it this row ' + 'is non-discriminating and must be re-pointed at whichever runtime carries one'); const canaryHome = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-3712-canary-home-')); const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-3712-project-')); const realHome = process.env.HOME; const realUserProfile = process.env.USERPROFILE; const skillsRoot = path.join(canaryHome, '.agents', 'skills'); fs.mkdirSync(skillsRoot, { recursive: true }); for (const name of ['msd-plan-phase', 'msd-dev-preferences', 'cloudflare']) { fs.mkdirSync(path.join(skillsRoot, name), { recursive: true }); fs.writeFileSync(path.join(skillsRoot, name, 'SKILL.md'), `# ${name}\n`); } // A SAMPLE, not a byte-for-byte tree compare: immediate dir names plus each // dir's SKILL.md. It catches the demonstrated regression (whole msd-* dirs // pruned) and would miss nested-file, mode, or metadata mutations. const inventory = () => fs.readdirSync(skillsRoot).sort() .map((d) => `${d}:${fs.readFileSync(path.join(skillsRoot, d, 'SKILL.md'), 'utf-8')}`) .join('|'); const before = inventory(); try { // Make the AMBIENT home the canary, exactly as on a developer machine. process.env.HOME = canaryHome; process.env.USERPROFILE = canaryHome; execFileSync(process.execPath, [INSTALL_PATH, '--codex', '--global', '--no-sdk'], { cwd: projectDir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], env: installerEnv(), timeout: INSTALL_GLOBAL_TIMEOUT_MS, }); assert.strictEqual(inventory(), before, 'the installer pruned or wrote MSD skills in the ambient HOME instead of its own sandbox'); } finally { if (realHome === undefined) delete process.env.HOME; else process.env.HOME = realHome; if (realUserProfile === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = realUserProfile; cleanup(canaryHome); cleanup(projectDir); } }); });