Files
msd-core/tests/helpers.cjs
Jakub Zych 6b0b92674a refactor: remove dead descriptor-driven mechanisms (no remaining consumer)
Drop code whose only consumer was a retired runtime: hostBehaviors readers for
agentFileExtension, localTargetIsProjectRoot, sharedHooksDirName,
skillsManifestPrefix and skipCodexSkillsManifest; the empty
NON_REGISTRY_CONFIG_HOME_DESCRIPTORS array and live-config-guard plumbing; the
empty RUNTIME_NOTE_AUDIENCE_BY_HEADING filter; the unused resolveVersionFrom
export; and the WINDSURF_SESSION_ID workstream session key. Delete tests that
only exercised those mechanisms.
2026-10-06 20:49:35 +02:00

1296 lines
60 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* MSD Tools Test Helpers
*/
const { execFileSync } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { createFixture } = require('./fixtures/index.cjs');
const processSeam = require('./helpers/process-seam.cjs');
const { SEAM_DEFAULT_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const TOOLS_PATH = path.join(__dirname, '..', 'msd-core', 'bin', 'msd-tools.cjs');
// Session-IDENTITY vars. Blanked so a child cannot inherit the developer's
// terminal/agent session and key shared state off it.
const SESSION_IDENTITY_ENV_KEYS = [
'MSD_SESSION_KEY',
'CODEX_THREAD_ID',
'CLAUDE_SESSION_ID',
'CLAUDE_CODE_SESSION_ID',
'CLAUDE_CODE_SSE_PORT',
'OPENCODE_SESSION_ID',
'GEMINI_SESSION_ID',
'CURSOR_SESSION_ID',
'TERM_SESSION_ID',
'WT_SESSION',
'TMUX_PANE',
'ZELLIJ_SESSION_NAME',
'TTY',
'SSH_TTY',
];
// LAZY, and memoized. These live in the BUILT runtime lib, so requiring them at
// module scope made an unbuilt tree throw during `require('./helpers.cjs')` —
// before a single test() had registered — which turns one missing
// `npm run build:lib` into a whole-suite crash with no actionable message, in the
// file ~370 test files import. `npm test` builds via its pretest hook, so the
// shape that hits this is a direct `node --test` invocation.
//
// Deferring the require means only the tests that actually need the derived scrub
// set pay for the build, and they fail with a message that names the remedy.
let _builtLib = null;
function builtLib() {
if (_builtLib) return _builtLib;
try {
const { runtimes } = require('../msd-core/bin/lib/capability-registry.cjs');
const { MSD_LOCATION_ENV_KEYS } = require('../msd-core/bin/lib/runtime-homes.cjs');
_builtLib = { runtimes, MSD_LOCATION_ENV_KEYS };
} catch (cause) {
throw new Error(
'tests/helpers.cjs derives the config-location scrub set from the built runtime '
+ 'lib (msd-core/bin/lib), which is not present. Run `npm run build:lib` first — '
+ '`npm test` does this for you via its pretest script.',
{ cause },
);
}
return _builtLib;
}
// Config-location vars that are neither in the registry nor descriptor-shaped,
// each with its reader:
// GROK_AGENTS_HOME — hardcoded `grok` branch in getGlobalConfigDir (src/runtime-homes.cts)
// MSD_RUNTIME — selects WHICH runtime home resolves (src/model-resolver.cts)
// MSD_PROJECT — planningDir() project segment (src/planning-workspace.cts)
// MSD_WORKSTREAM — planningDir() workstream segment (src/planning-workspace.cts)
//
// #2665 round 3: this list shrinks as sources become enumerable, and that direction
// is the point. Descriptor-resolved vars were NOT added here — they derive from the
// capability registry, because hand-adding each var a reviewer names is precisely
// what reopened this bug three times.
const NON_REGISTRY_CONFIG_LOCATION_ENV_KEYS = [
'GROK_AGENTS_HOME',
'MSD_RUNTIME',
'MSD_PROJECT',
'MSD_WORKSTREAM',
// #3245: host-session signals MSD now reads (host-runtime-detection.cts's
// detectHostRuntime / resolveReportedRuntime). Scrubbed for the same reason
// MSD_RUNTIME is — an ambiently-set CODEX_SANDBOX / (this repo's test suite
// running from inside a Codex session, or any host that happens to export
// these) would non-deterministically flip the detected runtime for every
// test that does not explicitly pass them. Tests that WANT them set still
// can, via the per-call env override, which is applied after this base and
// so continues to win.
'CODEX_SANDBOX',
'CODEX_SANDBOX_NETWORK_DISABLED',
];
// Write-escape PERMISSIONS — deliberately its own family, and deliberately NOT
// folded into any of the four rungs below.
//
// #2665 round 5: MSD_ALLOW_SYMLINKED_DEST is boolean and names no path, so it is
// not a config-location var by any honest reading. But install-engine.cts reads it
// env-first (`:214`) and threads it as `allowOptInFollow` into the symlink-escape
// guard at four call sites, each gating a write (`:361/:367`, `:416/:424`,
// `:785/:790`, `:927/:932`). That guard is what stops a write leaving the install
// root, so an ambient `=1` disarms it for the whole suite — the #2665 hazard
// exactly, arriving through a permission rather than a path.
//
// Blanking is fail-safe in the only direction that matters: '' is neither '1' nor
// 'true', so a blanked value makes the guard STRICTER, never looser. That asymmetry
// is why this can be scrubbed wholesale without reasoning about each call site.
const WRITE_ESCAPE_PERMISSION_ENV_KEYS = ['MSD_ALLOW_SYMLINKED_DEST'];
// Config-LOCATION vars — distinct in kind from the session-identity vars above:
// these decide WHERE a child writes, so leaving one ambient lets a test that
// sandboxes HOME still escape into the developer's real config dir.
//
// #2665: this list is DERIVED, not hand-maintained. A hand-written list is
// exactly what reopened this bug twice — it can only ever be as complete as the
// author's recall, and every resolver in `runtime-homes.cts` is env-FIRST, so a
// key missing here is a live escape hatch rather than a cosmetic gap. Sourcing
// it from the same registry the resolver reads makes the scrub list structurally
// incapable of being narrower than the surface it guards: adding a capability
// that declares a new configHome env var extends this set in the same commit.
let _configLocationEnvKeys = null;
function configLocationEnvKeys() {
if (_configLocationEnvKeys) return _configLocationEnvKeys;
const { runtimes, MSD_LOCATION_ENV_KEYS } = builtLib();
_configLocationEnvKeys = [
...new Set([
// 1. Every runtime descriptor the capability registry carries — including
// the nested skillsHome descriptor, which resolves independently of
// configHome (resolveSkillsBaseFromDescriptor) and can carry its own
// env array. Inert today (no runtime declares skillsHome env),
// but walking configHome.env alone is the identical gap-shape this PR
// closed twice already, one field over. (#2665 round 4)
...Object.values(runtimes).flatMap((r) => r?.runtime?.configHome?.env ?? []),
...Object.values(runtimes).flatMap(
(r) => r?.runtime?.configHome?.skillsHome?.env ?? [],
),
// 2. MSD's OWN location vars — a different family: they decide where MSD keeps
// user-owned state ($MSD_HOME/.msd/), not where a runtime keeps its config.
...MSD_LOCATION_ENV_KEYS,
// 3. The residue that is not registry-carried.
...NON_REGISTRY_CONFIG_LOCATION_ENV_KEYS,
// 4. Write-escape permissions — NOT locations. Same mechanism because the
// hazard is identical (ambient env lets a suite write outside the sandbox);
// named separately above so the list does not misdescribe what they are.
...WRITE_ESCAPE_PERMISSION_ENV_KEYS,
]),
].sort();
return _configLocationEnvKeys;
}
let _testEnvBase = null;
function testEnvBase() {
if (_testEnvBase) return _testEnvBase;
_testEnvBase = Object.fromEntries(
[...SESSION_IDENTITY_ENV_KEYS, ...configLocationEnvKeys()].map((k) => [k, '']),
);
return _testEnvBase;
}
/**
* Save + clear every config-LOCATION env var on THIS process; returns a restorer.
*
* #2665: TEST_ENV_BASE only reaches CHILD processes. A test that calls the real
* installer IN-PROCESS — `install(true, 'claude')` — resolves through the same
* env-first `getGlobalConfigDir`, so an ambient CLAUDE_CONFIG_DIR beats a
* sandboxed `process.env.HOME` and a complete global install (agents/, commands/,
* skills/, msd-core/, manifest, settings) lands in the developer's live config
* dir. No child-env scrub can reach that call; only clearing the parent's env can.
*
* Pair with a HOME sandbox, not instead of one: HOME covers the home-derived
* fallback, this covers the env-first branch that overrides it.
*
* @returns {() => void} restorer — call in afterEach to put the env back exactly
* as it was (deleting keys that were previously unset, rather than setting '').
*/
function scrubConfigLocationEnv() {
const saved = {};
const keys = configLocationEnvKeys();
for (const key of keys) {
saved[key] = process.env[key];
delete process.env[key];
}
return function restoreConfigLocationEnv() {
for (const key of keys) {
if (saved[key] === undefined) delete process.env[key];
else process.env[key] = saved[key];
}
};
}
/**
* Run msd-tools command.
*
* @param {string|string[]} args - Command string (shell-interpreted) or array
* of arguments (shell-bypassed via execFileSync, safe for JSON and dollar signs).
* @param {string} cwd - Working directory.
* @param {object} [env] - Optional env overrides merged on top of process.env.
* Pass { HOME: cwd } to sandbox ~/.msd/ lookups in tests that assert concrete
* config values that could be overridden by a developer's defaults.json.
*/
function runMsdTools(args, cwd = process.cwd(), env = {}) {
// Resolve argv once so both the first attempt and the retry use the same vector.
const childEnv = { ...process.env, ...testEnvBase(), ...env };
const argv = Array.isArray(args)
? args
: (args.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [])
.map(t => t.replace(/"([^"]*)"/g, '$1').replace(/'([^']*)'/g, '$1'));
// Adapter over tests/helpers/process-seam.cjs (#3055). The seam returns a
// typed { outcome, exitCode, stdout, stderr, timedOut, signal, killed, code }
// result — never throws for a kill/timeout/buffer-overflow/spawn-failure.
// This adapter is the ONLY place that retries and the ONLY place that
// reconstructs runMsdTools's legacy { success, output, error, exitCode }
// shape, so all 136 callers keep their existing contract byte-identically.
//
// `processSeam.runNode` is looked up on the module object (not destructured
// at require time) so tests can `mock.method(processSeam, 'runNode', fn)`
// to inject TIMED_OUT / BUFFER_OVERFLOW / SPAWN_FAILED without waiting on
// real subprocess timers.
function attempt() {
return processSeam.runNode([TOOLS_PATH, ...argv], {
cwd,
env: childEnv,
timeoutMs: SEAM_DEFAULT_TIMEOUT_MS,
});
}
function throwResourceStarvation(result) {
throw new Error(
`[runMsdTools: resource-starvation / subprocess-kill after retry] ` +
`msd-tools was killed before completion ` +
`(signal=${result.signal}, code=${result.code}, killed=${result.killed}). ` +
`This indicates host OOM or scheduler contention, not a product bug. ` +
`stdout=${(result.stdout || '').trim()} ` +
`stderr=${(result.stderr || '').trim()}`
);
}
function toLegacyShape(result) {
if (result.outcome === processSeam.OUTCOME.EXITED) {
if (result.exitCode === 0) {
return { success: true, output: (result.stdout || '').trim(), exitCode: 0 };
}
// Clean non-zero exit (real command error, no kill signal, no spawn
// failure): return normally. No retry, no throw — preserves existing
// test behavior that asserts on error shape.
const stderrRaw = (result.stderr || '').trim();
// Prefer actual stderr content; fall back to the same "Command failed:
// <argv0> <args...>" message Node's execFileSync used to synthesize
// for a clean non-zero exit with no stderr (verified against this
// runtime's child_process internals: checkExecSyncError() only builds
// that message when `ret.error` is absent and `ret.status !== 0`, and
// never appends stderr when it is empty). If stderr is empty, append a
// note so CI logs show "stderr: (empty)" rather than silently losing
// the fact that the child process produced no error output — empty
// stderr with a non-zero exit code is a signal of OS-level crash (OOM
// kill, worker thread fatal error) rather than a msd-tools application
// error.
const commandLine = [process.execPath, TOOLS_PATH, ...argv].join(' ');
const error = stderrRaw
|| `Command failed: ${commandLine} [stderr: (empty) exit:${result.exitCode ?? 1}]`;
return {
success: false,
output: (result.stdout || '').trim(),
error,
exitCode: result.exitCode ?? 1,
};
}
if (result.outcome === processSeam.OUTCOME.BUFFER_OVERFLOW) {
// Never retried. This is a DELIBERATE divergence from the old
// execFileSync-based helper, not an oversight: the old code saw a
// maxBuffer overflow as `err.signal === 'SIGTERM'`, which made the old
// `isKilled(err)` true and triggered a retry. The new seam classifies
// overflow as its own BUFFER_OVERFLOW outcome specifically so it stops
// being conflated with a kill — the child ran fine and produced too
// much output, so retrying wastes 60s and fails identically every
// time.
//
// exitCode is coerced to 1 here — RETRACTED claim from an earlier
// revision of this comment that it was "never coerced to exitCode:1,
// unlike the pre-seam helper": that was wrong. A real caller
// (tests/context-predicates-query.test.cjs) asserts
// `typeof r.exitCode === 'number'`, matching the old code's
// `err.status ?? 1` on every non-retried failure path. The SEAM layer
// still reports `exitCode: null` (see toSeamResult) — that typed
// result is where the "no numeric exit code exists" information
// lives, discriminated via `outcome`. This LEGACY adapter's job is to
// preserve the old numeric contract for existing callers, so it
// coerces null to 1 here rather than propagating the seam's null.
return {
success: false,
output: (result.stdout || '').trim(),
error: `msd-tools output exceeded the subprocess buffer limit (code=${result.code})`,
exitCode: 1,
};
}
if (result.outcome === processSeam.OUTCOME.KILLED) {
// Defensive only: the retry loop below always retries KILLED once and
// throws throwResourceStarvation() if it is still KILLED afterward, so
// this function is never actually invoked with a KILLED result that
// has not already survived a retry. It is handled explicitly (instead
// of falling into the SPAWN_FAILED catch-all below, whose message
// would be misleading) so a KILLED result can never silently render as
// a generic {success:false, exitCode:1}-shaped spawn failure.
return {
success: false,
output: (result.stdout || '').trim(),
error: `msd-tools was killed by signal (signal=${result.signal}, code=${result.code})`,
exitCode: null,
};
}
// SPAWN_FAILED: the process never started (matches old behavior — ENOENT
// and friends carry no signal, so the old `isKilled(err)` was false).
// Never retried — retrying is pointless.
//
// exitCode is coerced to 1 here — same retraction as the BUFFER_OVERFLOW
// branch above: this was previously described as "never coerced to
// exitCode:1," which was wrong for the ADAPTER path. The old
// execFileSync-based helper returned `err.status ?? 1` on every
// non-retried failure, i.e. always `1` for a spawn failure, and a real
// caller depends on `typeof exitCode === 'number'`. The SEAM's own
// `toSeamResult` still reports `exitCode: null` for SPAWN_FAILED — that
// typed layer is where "no numeric exit code exists" is expressed via
// `outcome`; this legacy adapter re-applies the old numeric contract on
// top of it.
return {
success: false,
output: (result.stdout || '').trim(),
error: `msd-tools failed to spawn (code=${result.code})`,
exitCode: 1,
};
}
// Kill-signal discrimination (#969): transient OOM/contention usually
// succeeds on retry; retry ONCE before surfacing the labeled error.
// TIMED_OUT and KILLED are retried — together they reproduce the OLD
// execFileSync-based `isKilled(err)` semantics exactly:
// old = err.killed || err.signal != null || err.code === 'ETIMEDOUT'
// TIMED_OUT covers the timeout case; KILLED covers a child terminated by a
// signal nobody in the seam sent (e.g. an external OOM kill) — the exact
// #969 case this retry exists for. BUFFER_OVERFLOW and SPAWN_FAILED are
// not kills and are never retried (see their branches in toLegacyShape).
const first = attempt();
if (first.outcome === processSeam.OUTCOME.TIMED_OUT || first.outcome === processSeam.OUTCOME.KILLED) {
const retry = attempt();
if (retry.outcome === processSeam.OUTCOME.TIMED_OUT || retry.outcome === processSeam.OUTCOME.KILLED) {
// Still killed after retry — persistent resource starvation, throw.
throwResourceStarvation(retry);
}
return toLegacyShape(retry);
}
return toLegacyShape(first);
}
// Create a bare temp directory (no .planning/ structure)
function createTempDir(prefix = 'msd-test-') {
return fs.mkdtempSync(path.join(require('os').tmpdir(), prefix));
}
// Create temp directory structure
function createTempProject(prefix = 'msd-test-') {
return createFixture({ prefix, planning: true, git: false });
}
// Create temp directory with initialized git repo and at least one commit
function createTempGitProject(prefix = 'msd-test-') {
return createFixture({ prefix, planning: true, git: true, projectDoc: true });
}
// The OS temp root has several canonical spellings, and a path a caller
// legitimately passes to cleanup() may arrive in any of them: macOS resolves
// os.tmpdir() under /var/folders/... but /var is a symlink to /private/var;
// Windows CI runners report os.tmpdir() in the 8.3 SHORT form
// (C:\Users\RUNNER~1\AppData\Local\Temp) while the caller's path is the
// expanded LONG form, and fs.realpathSync() does not reliably expand 8.3
// short names there — only fs.realpathSync.native() does; drive-letter and
// path casing can also differ (C:\ vs c:\). This function collects the full
// set of accepted temp roots — os.tmpdir()'s several spellings are one part
// of that set, not the whole of it (see below). Each probe is wrapped in its
// own try/catch — none of them may throw and crash cleanup(), they just
// contribute nothing if unavailable.
// Memoization cache for tmpRootCandidates(), keyed on the LIVE os.tmpdir()
// value (not hoisted to a plain module-level constant) — two test files in
// this suite mutate TMPDIR/TEMP/TMP mid-run and restore them afterward, so
// caching on the current os.tmpdir() read is what keeps a stale cache from
// leaking across that override instead of a one-time computation baked in
// at module load.
let _tmpRootCandidatesCacheKey;
let _tmpRootCandidatesCache;
function tmpRootCandidates() {
const cacheKey = os.tmpdir();
if (cacheKey === _tmpRootCandidatesCacheKey && _tmpRootCandidatesCache) {
return _tmpRootCandidatesCache;
}
const deduped = _computeTmpRootCandidates();
_tmpRootCandidatesCacheKey = cacheKey;
_tmpRootCandidatesCache = Object.freeze(deduped);
return _tmpRootCandidatesCache;
}
function _computeTmpRootCandidates() {
const roots = [];
try {
roots.push(path.resolve(os.tmpdir()));
} catch (_) { /* os.tmpdir() unavailable — skip this variant */ }
try {
roots.push(fs.realpathSync(os.tmpdir()));
} catch (_) { /* temp root unreadable — skip this variant */ }
try {
roots.push(fs.realpathSync.native(os.tmpdir()));
} catch (_) { /* native realpath unavailable/unreadable — skip this variant */ }
const isWindows = process.platform === 'win32';
// os.tmpdir() alone is too narrow: it honors $TMPDIR, but some tests
// (e.g. tests/config-schema.property.test.cjs's getWritableTmp()) create
// fixtures directly under the conventional system temp roots instead of
// through $TMPDIR — on macOS that is /private/tmp, which can differ from
// os.tmpdir()'s /var/folders/.../T. Probe the well-known non-Windows temp
// roots too, each independently and only if it actually exists on this
// host, so the accepted set stays a bounded, explicit list rather than an
// open-ended patch list. macOS additionally exposes /tmp as a symlink to
// /private/tmp, so both the unprefixed and /private-prefixed spellings —
// and each one's realpath — are collected.
if (!isWindows) {
for (const candidate of ['/tmp', '/private/tmp']) {
try {
if (fs.existsSync(candidate)) {
roots.push(path.resolve(candidate));
try {
roots.push(fs.realpathSync(candidate));
} catch (_) { /* exists but unreadable via realpath — skip this variant */ }
}
} catch (_) { /* existsSync itself should not throw, but fail closed if it does */ }
}
}
const seen = new Set();
const deduped = [];
for (const root of roots) {
const key = isWindows ? root.toLowerCase() : root;
if (seen.has(key)) continue;
seen.add(key);
deduped.push(root);
}
return deduped;
}
function cleanup(tmpDir) {
if (typeof tmpDir !== 'string' || tmpDir.length === 0) return;
const target = path.resolve(tmpDir);
const cwd = path.resolve(process.cwd());
// The temp-root check below was previously done only inside the catch block,
// so it classified a transient Windows error but was never consulted by the
// destructive rmSync call itself — a wrong `target` would still chdir out of
// its own tree and get force-deleted. Hoisted above both the chdir and the
// rmSync so an out-of-temp-root path is refused before either can run.
// Comparison is case-insensitive on Windows (drive-letter and path casing
// vary there) and case-sensitive everywhere else; the error message below
// always prints the original-case target.
const isWindows = process.platform === 'win32';
const tmpRoots = tmpRootCandidates();
function isUnderRoots(p) {
const pForCompare = isWindows ? p.toLowerCase() : p;
return tmpRoots.some((root) => {
const rootForCompare = isWindows ? root.toLowerCase() : root;
if (pForCompare === rootForCompare) return true;
// A root that is itself a filesystem root (`/`, or `C:\` reachable via
// TMPDIR=/) already ends with path.sep — appending a second one would
// build `//`, which only the literal string `/` satisfies, refusing
// every real descendant. Only append the separator when it is not
// already there.
const prefix = rootForCompare.endsWith(path.sep)
? rootForCompare
: `${rootForCompare}${path.sep}`;
return pForCompare.startsWith(prefix);
});
}
if (!isUnderRoots(target)) {
throw new Error(
`cleanup() refused to remove a path outside the known temp roots ` +
`(${tmpRoots.join(', ')}): ${target}`
);
}
// No symlink-escape check here: fs.rmSync does not follow a top-level
// symlink — it unlinks the link itself and leaves the target intact — so
// there is no live hazard for the root-membership check above to guard
// against. That check is the one closing an actual defect.
if (cwd === target || cwd.startsWith(`${target}${path.sep}`)) {
// Windows cannot remove a directory that is the current working directory.
process.chdir(path.dirname(target));
}
// maxRetries/retryDelay absorbs transient Windows EBUSY where AV scanners,
// file-indexers, or just-exited child processes still hold handles when
// teardown runs. On POSIX the retry loop is a no-op (rmSync succeeds first try).
// Budget: 20 × 250ms = 5s total — Windows Defender's deferred scan can hold
// newly-written files for several seconds on cold runners.
try {
fs.rmSync(target, { recursive: true, force: true, maxRetries: 20, retryDelay: 250 });
} catch (error) {
// After retries, Windows can still briefly hold temp dirs open after a timed-out
// child exits. Ignore that teardown-only flake for temp roots, but rethrow everything else.
// By this point target is guaranteed under a temp root: the guard clauses above throw
// for any other path, so this swallow doesn't need to re-test that.
const isTransientWinErr = process.platform === 'win32'
&& ['EBUSY', 'ENOTEMPTY', 'EPERM'].includes(error && error.code);
if (!isTransientWinErr) throw error;
}
}
/**
* Read a text file with CRLF normalized to LF.
*
* DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE (CONTEXT.md; recurring since #1700):
* a test that reads a workflow/agent/reference `.md` file, slices or
* regex-matches a fenced code block out of it, and hands that block to
* `spawnSync('bash', ...)` breaks on a Windows checkout — `.gitattributes`
* `eol=lf` is not always honored by `actions/checkout` on `windows-latest`,
* so `readFileSync` can return `\r\n` line endings. Bash then treats the
* trailing `\r` on every line as part of the token; an opening quote never
* finds its match and the parser dies mid-script with "unexpected EOF while
* looking for matching `"'" or a bare syntax error at the next `{`/`)`.
*
* `.split(/\r?\n/)` on the FENCE DELIMITER alone does not fix this — it only
* protects the boundary match, not the captured body between the fences,
* which still carries embedded `\r` characters (the exact bug #2650's
* verification round found in tests/fix-2650-plan-phase-stall-detection.test.cjs,
* despite that file's fence regex already using `\r?\n`).
*
* Normalizing ONCE at the read boundary, before any slicing/regex/fence
* parsing runs, is cheaper and safer than normalizing at each extraction
* call site: every downstream `indexOf`/`slice`/regex/`spawnSync` then
* operates on LF-only content by construction, and a new `.md`-extraction
* test is correct by default just by reading through this helper.
*
* @param {string} filePath - Absolute or relative path to a text file.
* @returns {string} File content with every `\r\n` replaced by `\n`.
*/
function readFileNormalized(filePath) {
return fs.readFileSync(filePath, 'utf-8').replace(/\r\n/g, '\n');
}
/**
* Fault-injection helper for durable-write tests (#1874): monkeypatches
* `fsModule.writeFileSync` so any call matching `matches(target)` writes
* only the first `bytesBeforeThrow` bytes of `data` (a faithful crash
* window — the partial bytes DO land on disk, mirroring a real ENOSPC/EIO
* mid-write) and then throws. Calls not matching `matches` pass through to
* the real implementation unchanged.
*
* fs-method override rather than chmod: root bypasses mode bits, so a
* permission-based fault injection silently passes with zero coverage in
* root CI (CLAUDE.md §4 / CONTRIBUTING.md).
*
* Returns a restore function — call it via `t.after(...)`, never a manual
* try/finally in the test body.
*
* @param {typeof import('fs')} fsModule
* @param {(target: unknown) => boolean} matches - defaults to matching every write
* @param {number} bytesBeforeThrow - byte count of `data` that lands before the throw
* @param {{code?: string, message?: string}} [options]
* @returns {() => void} restore function
*/
function mockPartialWriteThenThrow(fsModule, matches, bytesBeforeThrow, options = {}) {
const { code = 'ENOSPC', message = `${code}: simulated partial write failure` } = options;
const shouldMatch = typeof matches === 'function' ? matches : () => true;
const origWriteFileSync = fsModule.writeFileSync;
fsModule.writeFileSync = (target, data, writeOptions) => {
if (!shouldMatch(target)) {
return origWriteFileSync.call(fsModule, target, data, writeOptions);
}
origWriteFileSync.call(fsModule, target, String(data).slice(0, bytesBeforeThrow), writeOptions);
throw Object.assign(new Error(message), { code });
};
return () => { fsModule.writeFileSync = origWriteFileSync; };
}
/**
* Capture the bytes written to `captureFd` while `fn()` runs, WITHOUT ever
* fabricating a byte count for any fd (#4306).
*
* Every previous hand-rolled version of this idiom across the test suite
* mocked `fs.writeSync`, and on its "success" arm returned a fabricated byte
* count while pushing the bytes into a local array instead of ever calling
* the real `fs.writeSync` — the data reached nowhere but that array. That is
* unsafe: Node's `node:test` runner defaults to `--test-isolation=process`
* (Node >= 22), so each test file's own real stdout is what the PARENT
* runner reads to parse its child-to-parent result/TAP protocol. If the
* runner's own reporter write for an adjacent test lands on the mocked fd
* during this window, a mock that fabricates success without delivering the
* bytes silently swallows that write instead of letting it reach the real
* pipe — the parent then tries to parse a truncated stream, observed in CI
* as "Unable to deserialize cloned data" (Node's generic corrupted/truncated
* v8.deserialize error), not as a thrown exception.
*
* This helper always forwards every write, on every fd, to the real
* `fs.writeSync` first — so nothing is ever swallowed, regardless of what
* else shares the fd during the mocked window — and returns the REAL
* result. Only `captureFd`'s traffic is additionally recorded and returned
* to the caller as a joined UTF-8 string; every other fd's bytes still
* reach their real destination (e.g. a test's own stderr diagnostics still
* physically write to stderr, just outside the returned capture), they are
* simply not included in the returned string.
*
* Standalone — no node:test context required; save/restore in a `finally`
* so a thrown assertion still restores the real `fs.writeSync`.
*
* @param {number} captureFd - the fd to capture and return (1 for stdout, 2 for stderr).
* @param {() => void} fn - synchronous function to run while capturing.
* @returns {string} every byte actually written to `captureFd` during `fn()`.
*/
function captureFdSync(captureFd, fn) {
const chunks = [];
const orig = fs.writeSync;
fs.writeSync = (fd, data, ...rest) => {
const n = orig.call(fs, fd, data, ...rest);
if (fd === captureFd) {
// rest[0] is `offset` only for the buffer-form overload; the
// string-form overload's 2nd arg is `position`, which is irrelevant
// here since a string write has no byte offset into `data` itself.
const offset = Buffer.isBuffer(data) && typeof rest[0] === 'number' ? rest[0] : 0;
const buf = Buffer.isBuffer(data)
? data.subarray(offset, offset + n)
: Buffer.from(String(data), 'utf8').subarray(0, n);
// Buffered, not decoded per-call: a real short write can split a
// multi-byte UTF-8 codepoint across two writeSync calls, and decoding
// each half separately would corrupt it. Decode once, after joining.
chunks.push(buf);
}
return n;
};
try {
fn();
} finally {
fs.writeSync = orig;
}
return Buffer.concat(chunks).toString('utf8');
}
/**
* NARROW, deliberate exception to `captureFdSync`'s
* "never swallow" philosophy (#4306) — do NOT reach for this casually.
*
* Async twin of `captureFdSync` that awaits `fn()` before restoring the
* patch, so a deferred write that happens after a microtask/macrotask
* boundary is still safely observed. Its patched `fs.writeSync`
* does NOT forward `captureFd`'s writes to the real `fs.writeSync` at all.
* It only records the bytes into `chunks` and returns the byte length of
* `data` as if the real syscall had succeeded, so a caller that inspects the
* return value sees a normal success and not an error. Every OTHER fd's
* writes still forward to the real `fs.writeSync` exactly as
* `captureFdSync` does — only the `captureFd`-matching branch
* differs.
*
* This exists for #4448: `runMain()`/`io.output()`'s deferred `fs.writeSync(1,
* ...)` races Node's own `node:test` child-to-parent IPC, which also uses fd
* 1 under the default `--test-isolation=process` — corrupting the parent's
* message parsing ("Unable to deserialize cloned data"). An always-forward
* capture (the first fix attempted for this issue) does not
* fix that: the corrupting write still physically reaches fd 1. Use this
* ONLY for a window the caller has verified is narrow and fully controlled —
* i.e. nothing else legitimately needs to write to `captureFd` during `fn()`
* — such as a single `runMain(...)` call plus its promise-chain settling.
* Reaching for this in a window where something else might legitimately
* write to `captureFd` will silently swallow that other write.
*
* @param {number} captureFd - the fd whose writes are suppressed and recorded
* (never delivered to the real fd) while `fn()` runs.
* @param {() => (Promise<void> | void)} fn - function to run (and await) while suppressing.
* @returns {Promise<string>} every byte that WOULD have been written to
* `captureFd` during `fn()`, joined as UTF-8 — none of it actually reached
* the real fd.
*/
async function suppressFdAsync(captureFd, fn) {
const chunks = [];
const orig = fs.writeSync;
fs.writeSync = (fd, data, ...rest) => {
if (fd === captureFd) {
const offset = Buffer.isBuffer(data) && typeof rest[0] === 'number' ? rest[0] : 0;
// No real syscall happens here (unlike captureFdSync, which slices to
// the real return value `n`), so the caller-requested `length` IS the
// count that must be recorded and returned — suppression always
// "succeeds" in full, so anything else silently drops or over-reports
// bytes.
const length = Buffer.isBuffer(data) && typeof rest[1] === 'number' ? rest[1] : undefined;
const buf = Buffer.isBuffer(data)
? data.subarray(offset, length === undefined ? undefined : offset + length)
: Buffer.from(String(data), 'utf8');
// Buffered, not decoded per-call: see captureFdSync's identical note on
// why joined-then-decoded avoids splitting a multi-byte UTF-8 codepoint.
chunks.push(buf);
// No real fs.writeSync call for this fd — that is the entire point of
// this helper. Return the byte length as if the write succeeded, so a
// caller inspecting the return value (Node's own writeSync contract)
// sees ordinary success rather than an error.
return buf.length;
}
return orig.call(fs, fd, data, ...rest);
};
try {
await fn();
} finally {
fs.writeSync = orig;
}
return Buffer.concat(chunks).toString('utf8');
}
/**
* Read a workflow .md file plus every .md file under its sibling
* `<workflow-basename>/steps/` directory, concatenated in document order
* (host file first, then step files sorted by filename).
*
* ADR-1671's workflow fragmentization (#2930/#2932/#2993 et al.) moves whole
* sections out of a host workflow (e.g. `plan-phase.md`) into lazily-loaded
* step files under `msd-core/workflows/<name>/steps/*.md`. A structural or
* drift guard that reads the host file alone goes blind the moment a
* section it cares about moves out — this is exactly the shape #2650's own
* regression tests hit when #2993 relocated plan-phase.md's chunked-planning
* spawn sites into `plan-phase/steps/chunked-planning-mode.md`. Any test
* that needs to see the FULL picture (counting markers, asserting a marker
* exists somewhere in the workflow) should read through this helper instead
* of `fs.readFileSync(workflowPath)` alone, so the next relocation doesn't
* silently blind it again. Originally local to
* tests/plan-phase-drift-guard.test.cjs (readPlanPhaseCombined) — promoted
* here so a second, divergent copy is never written (Generative Fix
* Divergence class).
*
* @param {string} workflowPath - absolute path to the host workflow .md file.
* @returns {string} host content, then '\n' + each step file's content in
* sorted-filename order. An absent steps directory degrades to the host
* content alone (not an error — most workflows have no steps/ dir).
*/
function readWorkflowCombined(workflowPath) {
let combined = readFileNormalized(workflowPath);
const stepsDir = path.join(path.dirname(workflowPath), path.basename(workflowPath, '.md'), 'steps');
if (fs.existsSync(stepsDir)) {
for (const entry of fs.readdirSync(stepsDir).sort()) {
if (entry.endsWith('.md')) {
combined += '\n' + readFileNormalized(path.join(stepsDir, entry));
}
}
}
return combined;
}
/**
* Parse a Markdown frontmatter block into a flat key→value map.
*
* Handles the YAML scalar forms emitted by the install converters:
* key: "json-encoded value" → JSON.parse
* key: 'value with ''escape'' → strip quotes, unescape ''
* key: bare value → trimmed string
*
* Multi-line and block scalars are out of scope — every converter in
* `bin/install.js` emits single-line scalars only. Throws if the content
* has no closed `---` block so a regression in the emitter shape fails
* loudly rather than silently returning {}.
*
* Tests use this helper instead of `result.includes('key: value')` to
* follow the project's "tests parse, never grep" convention.
*
* @param {string} content - Full file content beginning with `---`.
* @returns {Record<string, string>} Map of frontmatter keys to decoded values.
*/
function parseFrontmatter(content) {
if (!content.startsWith('---')) {
throw new Error(`parseFrontmatter: content must start with '---', got: ${content.slice(0, 40)}`);
}
// CRLF tolerance: a Windows-authored file split on `\n` would leave a
// trailing `\r` on every line, making `lines[i] === '---'` fail to
// recognize delimiters. Same goes for whitespace-padded delimiter lines.
// Normalize via a CRLF-aware split + trimmed comparison.
const lines = content.split(/\r?\n/);
let openIdx = -1;
let closeIdx = -1;
for (let i = 0; i < lines.length; i += 1) {
if (lines[i].trim() === '---') {
if (openIdx === -1) openIdx = i;
else { closeIdx = i; break; }
}
}
if (openIdx === -1 || closeIdx === -1) {
throw new Error('parseFrontmatter: no closed --- block');
}
const fields = {};
for (const line of lines.slice(openIdx + 1, closeIdx)) {
const match = line.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/);
if (!match) continue; // skip block-list items, blank lines, comments
const [, key, rawValue] = match;
const value = rawValue.trim();
if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
fields[key] = JSON.parse(value);
} else if (value.startsWith("'") && value.endsWith("'") && value.length >= 2) {
fields[key] = value.slice(1, -1).replace(/''/g, "'");
} else {
fields[key] = value;
}
}
return fields;
}
// #3026 CR: shared `--help` output check used by bug-1818 + bug-3019 tests.
// Render-on-help shape is `Usage: msd-tools …\nCommands: …` — both lines
// must be present; structural test, not prose substring matching.
function isUsageOutput(text) {
return /Usage:\s*msd-tools/.test(text) && /Commands:/.test(text);
}
/**
* Isolated HOME directory used by runNpm() for the lifetime of this process.
*
* npm reads $HOME/.npmrc (user config) and writes to $HOME/.npm (default cache)
* when these paths are not overridden. On Docker hosts the running user's HOME
* may be uninitialized, unwritable, or contain stale state from a prior run —
* any of which causes `npm pack` / `npm install -g` to fail. Fix: create a
* fresh temp directory once per process, redirect HOME + cache + userconfig into
* it, and clean up on process exit. This makes runNpm() independent of the
* caller's environment. (#131)
*/
const _npmIsolatedHome = fs.mkdtempSync(path.join(require('os').tmpdir(), 'npm-home-'));
process.on('exit', () => {
try { fs.rmSync(_npmIsolatedHome, { recursive: true, force: true }); } catch (_) { /* best-effort */ }
});
/**
* Run `fn` with console.log/warn/error captured, returning {stdout, stderr}
* with ANSI colors stripped. Re-throws any exception fn threw AFTER restoring
* the real console so the caller's assertion path sees the failure (without
* this, a fn that crashes before printing would falsely pass !hasReady-style
* assertions). #2775 CR follow-up established this exact contract.
*
* Previously duplicated in bug-2775, bug-2829, bug-3033, bug-3211, bug-3231,
* bug-3359, and installer-migration-install-integration.
*/
function captureConsole(fn) {
const stdout = [];
const stderr = [];
const origLog = console.log;
const origWarn = console.warn;
const origError = console.error;
console.log = (...a) => stdout.push(a.join(' '));
console.warn = (...a) => stderr.push(a.join(' '));
console.error = (...a) => stderr.push(a.join(' '));
let threw = null;
try {
fn();
} catch (e) {
threw = e;
} finally {
console.log = origLog;
console.warn = origWarn;
console.error = origError;
}
if (threw) throw threw;
// Built via String.fromCharCode (not a literal control character in a
// regex, which `no-control-regex` rejects) so the ESC byte itself is
// matched at runtime — this strips real ANSI color codes, not a decoy.
const ansiPattern = new RegExp(`${String.fromCharCode(0x1b)}\\[[0-9;]*m`, 'g');
const strip = (s) => s.replace(ansiPattern, '');
return {
stdout: stdout.map(strip).join('\n'),
stderr: stderr.map(strip).join('\n'),
};
}
/**
* Normalize platform path separators to POSIX forward slashes. Use for
* cross-platform path comparisons in test assertions where the runtime
* emits the platform-native separator (\ on Windows) but the test
* fixture or expected literal is POSIX. Returns the input unchanged if
* null/undefined so it composes safely with optional chaining.
*/
function toPosixPath(p) {
return p == null ? p : p.split(path.sep).join('/');
}
/**
* Build the expected absolute, POSIX-normalized `.planning/...` path for a
* given fixture root — the shape #2376's init/state path-field output now
* emits (anchored on process.cwd() / --cwd) instead of the historical
* relative literal.
*
* Centralizes the identical inline `absPlanningPath` helper previously
* duplicated across tests/quick-research.test.cjs, tests/init.test.cjs,
* tests/onboard-command.test.cjs, and tests/roadmap-parser.test.cjs.
*
* Callers MUST pass a realpath'd fixture root (e.g.
* `fs.realpathSync(createTempProject())`) so the expected value matches
* what a spawned child process actually resolves via `process.cwd()` — on
* macOS `os.tmpdir()` is a symlink (`/var/...` -> `/private/var/...`) that
* the child's cwd resolves through but a bare `mkdtempSync()` does not.
*
* @param {string} base - fixture root (should be realpath'd by the caller).
* @param {...string} segments - path segments under `.planning/`.
* @returns {string} POSIX-normalized absolute path.
*/
function absPlanningPath(base, ...segments) {
return toPosixPath(path.join(base, '.planning', ...segments));
}
/**
* Run an npm command via execFileSync with cross-platform portability.
*
* Handles the Windows `npm.cmd` vs POSIX `npm` distinction and the
* `shell: true` requirement on Windows so tests do not need to
* re-implement platform detection inline.
*
* @param {string[]} args - npm subcommand and flags (e.g. ['pack', '--pack-destination', dir]).
* @param {object} [options] - execFileSync options merged with platform defaults.
* `cwd`, `encoding`, `timeout`, and `env` are the commonly overridden keys.
* @returns {string} trimmed stdout string (encoding: 'utf-8').
* @throws {Error} re-throws the execFileSync error on non-zero exit so callers
* get the full stderr in the error message.
*/
function runNpm(args, options = {}) {
const isWindows = process.platform === 'win32';
const npmCmd = isWindows ? 'npm.cmd' : 'npm';
// Inject an isolated HOME so npm never reads from or writes to the caller's
// $HOME. This prevents failures on Docker hosts where HOME is unwritable or
// uninitialized. The caller may still pass { env: {...} } in options to
// further override specific variables — those overrides win because they are
// applied after the isolated env below (via the spread in the merge). (#131)
const isolatedEnv = {
...process.env,
HOME: _npmIsolatedHome,
npm_config_cache: path.join(_npmIsolatedHome, '.npm'),
npm_config_userconfig: path.join(_npmIsolatedHome, '.npmrc'),
npm_config_loglevel: 'error',
npm_config_update_notifier: 'false',
NO_UPDATE_NOTIFIER: '1',
};
const defaults = {
encoding: 'utf-8',
shell: isWindows,
env: isolatedEnv,
};
// Merge options; if caller passes their own env, merge it on top of isolatedEnv
// so the isolation is preserved unless the caller explicitly overrides HOME.
// `timeout` is destructured with a default (not left inside `defaults`) so an
// explicit `timeout: undefined` in `options` — an own key, not an omission —
// cannot silently erase the bound via the spread below; a destructure default
// only applies on `undefined`, whereas `{ ...defaults, ...otherOptions }`
// would let that own key win and fall through to no bound at all. 180000ms:
// npm install/pack against an isolated HOME; this is the pre-existing value,
// preserved.
const NPM_TIMEOUT_MS = 180000;
const { env: callerEnv, timeout = NPM_TIMEOUT_MS, ...otherOptions } = options;
const mergedEnv = callerEnv ? { ...isolatedEnv, ...callerEnv } : isolatedEnv;
return execFileSync(npmCmd, args, { ...defaults, ...otherOptions, timeout, env: mergedEnv }).trim();
}
/**
* Returns the isolated npm environment dict used by runNpm().
*
* Callers (e.g. runSmoke()) can spread this into a spawnSync env so that npm
* never reads from or writes to the caller's $HOME — the same guarantee
* runNpm() already provides. (#131)
*
* @returns {object} env dict with HOME, npm_config_cache, npm_config_userconfig
* pointing into a process-scoped temp directory.
*/
function isolatedNpmEnv() {
return {
...process.env,
HOME: _npmIsolatedHome,
npm_config_cache: path.join(_npmIsolatedHome, '.npm'),
npm_config_userconfig: path.join(_npmIsolatedHome, '.npmrc'),
npm_config_loglevel: 'error',
npm_config_update_notifier: 'false',
NO_UPDATE_NOTIFIER: '1',
};
}
/**
* Run a callback with process-level state isolation.
* Restores cwd, exitCode, and process.env after callback returns or throws.
*
* @template T
* @param {() => T} fn
* @returns {T}
*/
function withIsolatedProcessState(fn) {
const originalCwd = process.cwd();
const originalExitCode = process.exitCode;
const originalEnv = { ...process.env };
try {
return fn();
} finally {
if (process.cwd() !== originalCwd) {
process.chdir(originalCwd);
}
process.exitCode = originalExitCode;
for (const key of Object.keys(process.env)) {
if (!(key in originalEnv)) delete process.env[key];
}
for (const [key, value] of Object.entries(originalEnv)) {
process.env[key] = value;
}
}
}
/**
* Async delay — yields the event loop for `ms` ms without a synchronous block.
* Replaces raw setTimeout / Atomics.wait sleeps in tests. `ms` is an identifier
* and the Promise is not awaited inline, so it does not trip the no-magic-sleep
* / no-restricted-syntax test rules (which only scan *.test.cjs anyway).
*/
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Poll `predicate` until it returns truthy or the deadline elapses — the approved
* poll-for-condition pattern for cross-process test synchronization. Returns the
* predicate's truthy value; throws Error(message) on timeout.
*
* `predicate` must return a boolean or truthy value when ready; any falsy result
* (including `0` or `''`) is treated as "not ready yet". Do not use predicates
* whose meaningful result can be falsy.
*
* `predicate` should not throw — exceptions propagate out of `waitFor` uncaught
* and are NOT retried. If the readiness check can throw on a transient state
* (e.g. parsing a partially-written file), guard inside the predicate and return
* `false` instead.
*/
async function waitFor(predicate, { timeoutMs = 10000, stepMs = 25, message = 'waitFor timed out' } = {}) {
const deadline = Date.now() + timeoutMs;
for (;;) {
const value = predicate();
if (value) return value;
if (Date.now() >= deadline) throw new Error(message);
await delay(stepMs);
}
}
/**
* Reset all runtime-warning caches in config-loader.cjs and model-resolver.cjs.
*
* Use this in beforeEach/afterEach hooks in tests that exercise warning-emission
* paths so that each test starts with a clean slate. Replaces the duplicated local
* `_resetRuntimeWarningCacheForTests` wrappers in individual test files.
*/
function resetRuntimeWarningCaches() {
const configLoader = require('../msd-core/bin/lib/config-loader.cjs');
const modelResolver = require('../msd-core/bin/lib/model-resolver.cjs');
configLoader._resetRuntimeWarningCacheForTests();
modelResolver._resetModelPolicyWarningCacheForTests();
modelResolver._resetModelOverrideWarningCacheForTests();
}
/**
* Env vars that influence workstream-session identity (getWorkstreamSessionKey
* in active-workstream-store.cjs) or workstream/project resolution (planningDir).
* Single source of truth for tests that need a deterministic, session-key-free
* and workstream/project-free process.env — save/clear before, restore after.
* Union of the sets previously hand-duplicated in
* tests/active-workstream-store.unit.test.cjs and tests/msd-statusline.test.cjs
* (#2850 code review finding: the two copies had already silently diverged).
*/
const SESSION_ENV_KEYS = [
'MSD_SESSION_KEY', 'CODEX_THREAD_ID', 'CLAUDE_SESSION_ID', 'CLAUDE_CODE_SESSION_ID',
'CLAUDE_CODE_SSE_PORT',
'OPENCODE_SESSION_ID', 'GEMINI_SESSION_ID', 'CURSOR_SESSION_ID',
'TERM_SESSION_ID', 'WT_SESSION', 'TMUX_PANE', 'ZELLIJ_SESSION_NAME',
'TTY', 'SSH_TTY', 'CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS',
'MSD_WORKSTREAM', 'MSD_PROJECT',
];
function saveSessionEnv() {
const saved = {};
for (const k of SESSION_ENV_KEYS) saved[k] = process.env[k];
return saved;
}
function restoreSessionEnv(saved) {
for (const k of SESSION_ENV_KEYS) {
if (saved[k] === undefined) delete process.env[k];
else process.env[k] = saved[k];
}
}
function clearSessionEnv() {
for (const k of SESSION_ENV_KEYS) delete process.env[k];
}
/**
* Save + clear MSD_WORKSTREAM and MSD_PROJECT on process.env, paired with
* restoreWorkstreamEnv(). planningDir() reads both directly from
* process.env when its params are omitted, so a test asserting
* workstream/project-scoped behavior must isolate them from ambient shell
* state (and from whatever an earlier test in the same process left behind).
*
* Previously duplicated as a local isolateWorkstreamEnv()/restoreWorkstreamEnv()
* pair in tests/phase-locator.test.cjs, and as the MSD_WORKSTREAM/MSD_PROJECT
* slice of tests/model-resolver.test.cjs's broader isolateHome()/restoreHome()
* (which still isolates HOME/USERPROFILE/MSD_HOME/MSD_RUNTIME locally — that
* part is genuinely specific to model-resolver's tests and stays there).
*
* Module-level save slot (not a returned snapshot) to match the exact
* no-arg isolate()/restore() call shape both prior local copies used.
*/
let _origMsdWorkstream;
let _origMsdProject;
function isolateWorkstreamEnv() {
_origMsdWorkstream = process.env.MSD_WORKSTREAM;
_origMsdProject = process.env.MSD_PROJECT;
delete process.env.MSD_WORKSTREAM;
delete process.env.MSD_PROJECT;
}
function restoreWorkstreamEnv() {
if (_origMsdWorkstream === undefined) delete process.env.MSD_WORKSTREAM;
else process.env.MSD_WORKSTREAM = _origMsdWorkstream;
if (_origMsdProject === undefined) delete process.env.MSD_PROJECT;
else process.env.MSD_PROJECT = _origMsdProject;
}
/**
* #3156: env for a RAW installer spawn — one that bypasses runMsdTools and so
* never receives TEST_ENV_BASE on its own.
*
* Blanking config-LOCATION vars is necessary but NOT sufficient here.
* bin/install.js writes MSD's own user-owned store through os.homedir()
* DIRECTLY (writeNonClaudeDefaults -> <home>/.msd/defaults.json, #2834), and
* os.homedir() consults no MSD variable at all — so nothing in
* CONFIG_LOCATION_ENV_KEYS can reach it, and blanking MSD_HOME does not reach
* it either, because a blank MSD_HOME falls back to exactly that homedir().
* Only a sandboxed HOME/USERPROFILE contains it.
*
* HOME stays deliberately OUT of TEST_ENV_BASE — blanking it would break far
* more than it fixed — so it is sandboxed per spawn instead, which is the
* discipline the suite already applies by hand elsewhere. USERPROFILE is set
* with it because os.homedir() reads that one on Windows.
*
* The sandbox home is per-process and removed on exit, so a caller gets
* containment without having to own a lifecycle.
*
* SCOPE, stated because it is a real residual rather than an oversight: this is
* one home per test-FILE process, not one per spawn. Two installer spawns in the
* same file therefore share `.msd` state, so a prior non-Claude install can be
* observed by a later spawn. That is strictly better than the status quo it
* replaces -- which shared the developer's REAL home, and all of its state --
* and it closes the leak this helper exists for; it does not claim isolation
* BETWEEN spawns. A test needing that passes its own { HOME, USERPROFILE }.
*/
let installSpawnHomeDir = null;
function installSpawnHome() {
if (installSpawnHomeDir === null) {
installSpawnHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-install-home-'));
process.on('exit', () => {
try { fs.rmSync(installSpawnHomeDir, { recursive: true, force: true }); } catch { /* best effort */ }
});
}
return installSpawnHomeDir;
}
function installSpawnEnv(overrides = {}) {
const home = installSpawnHome();
// #3712 — carry the sandbox marker too, not just HOME. The guard falls back to
// the marker on hosts with no readable passwd entry (some CI images) and
// otherwise REFUSES. A spawned installer inherits NODE_TEST_CONTEXT and has a
// legitimately redirected HOME, so without this it is refused on exactly the
// environment the fallback exists to serve. The name is the same constant
// sandboxHome() writes; see the note there on why it is a bare string.
const env = {
...process.env,
...testEnvBase(),
HOME: home,
USERPROFILE: home,
[TEST_HOME_SANDBOX_MARKER]: home,
...overrides,
};
// The marker attests to the home ACTUALLY in effect, so it has to follow an
// overridden HOME rather than keep naming this helper's default one. Spreading
// `overrides` last is deliberate (an explicit HOME must win — see the docblock's
// "A test needing that passes its own { HOME, USERPROFILE }"), but it left the
// marker stale: a caller supplying its own HOME got HOME=<theirs> and
// marker=<helper default>. On a passwd-less host the guard compares the two and
// REFUSES a legitimately sandboxed spawn — tests/install.test.cjs:7143 and
// install-shared.cjs's own runInstaller both take that path. An explicitly
// supplied marker still wins over both. Reported in Codex review of #3725.
if (!(TEST_HOME_SANDBOX_MARKER in overrides)) env[TEST_HOME_SANDBOX_MARKER] = env.HOME;
return env;
}
/**
* #3712 — sandbox HOME/USERPROFILE for the duration of ONE test.
*
* The spawn-side helpers above (#3156) cover CHILD processes only. A test that
* calls the installer IN-PROCESS gets no protection from them, and a runtime kind
* may declare a global `home` override that resolves from `os.homedir()` rather
* than from the sandboxed configDir — codex's skills kind (`.agents`, ADR-1239 /
* #2088) is the live case. Without this, such a call writes to, and prunes
* `msd-*` entries from, the developer's REAL ~/.agents/skills.
*
* Promoted here from the identical private copies in executed-plan.test.cjs and
* install-runtime-artifacts.test.cjs so new in-process callers have one obvious
* helper to reach for instead of re-deriving it (or forgetting it).
*
* Pass the test's own temp configDir as `dir` where possible: codex's skills dir
* then resolves to `<configDir>/.agents/skills`, keeping every artifact the call
* writes inside the directory the test already cleans up.
*
* @param {{ after: (fn: () => void) => void }} t - node:test context.
* @param {string} dir - directory to use as HOME for the duration of the test.
*/
// #3712: the marker NAME is a constant, duplicated here deliberately rather than
// required from the compiled guard. helpers.cjs is imported by ~370 test files and
// documents (see builtLib above) that it must NOT load msd-core/bin/lib at module
// scope — an unbuilt tree would then fail on import alone, turning a missing
// `npm run build:lib` into a whole-suite crash. A lazy require inside sandboxHome
// would satisfy that too, but a bare string needs no build at all. The pairing is
// pinned by a test so the two cannot drift.
const TEST_HOME_SANDBOX_MARKER = 'MSD_TEST_HOME_SANDBOX';
function sandboxHome(t, dir) {
const savedHome = process.env.HOME;
const savedUserProfile = process.env.USERPROFILE;
const savedMarker = process.env[TEST_HOME_SANDBOX_MARKER];
process.env.HOME = dir;
process.env.USERPROFILE = dir;
// Records WHICH directory this call sandboxed to. src/real-home-guard.cts fails
// CLOSED when it cannot read a passwd entry to compare HOME against (some CI
// images), and consults this only in that branch, accepting it only when it
// names the home actually in effect — so a stale marker cannot vouch for a
// later, un-sandboxed call.
process.env[TEST_HOME_SANDBOX_MARKER] = dir;
t.after(() => {
if (savedHome === undefined) delete process.env.HOME;
else process.env.HOME = savedHome;
if (savedUserProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = savedUserProfile;
if (savedMarker === undefined) delete process.env[TEST_HOME_SANDBOX_MARKER];
else process.env[TEST_HOME_SANDBOX_MARKER] = savedMarker;
});
}
function writePackageSourceMarkerFixture(configDir) {
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(
path.join(configDir, '.msd-source'),
path.join(__dirname, '..', 'commands', 'msd') + '\n',
);
return configDir;
}
/** Write one valid third-party gate into a synthetic user capability home. */
function writeAmbientCapabilityGate(home, id, point) {
const capDir = path.join(home, '.msd', 'capabilities', id);
fs.mkdirSync(capDir, { recursive: true });
fs.writeFileSync(path.join(capDir, 'capability.json'), JSON.stringify({
id,
title: 'Ambient test capability',
version: '1.0.0',
role: 'feature',
tier: 'full',
description: 'Capability outside the test fixture that must remain invisible.',
engines: { msd: '>=1.7.0' },
requires: [],
runtimeCompat: { supported: ['claude'], unsupported: [] },
skills: [],
agents: [],
config: {},
steps: [],
contributions: [],
gates: [{ point, check: { query: 'ambient.check' }, blocking: false, onError: 'skip' }],
}), 'utf8');
}
/**
* Put a capability in the parent process's ambient home for one serial test.
* The child must still receive installSpawnEnv()'s different sandbox home.
*/
function withAmbientCapabilityHome(t, prefix, id, point) {
const home = createTempDir(prefix);
writeAmbientCapabilityGate(home, id, point);
const previous = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE };
process.env.HOME = home;
process.env.USERPROFILE = home;
t.after(() => {
for (const [key, value] of Object.entries(previous)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
cleanup(home);
});
return home;
}
module.exports = { runMsdTools, createTempDir, createTempProject, createTempGitProject, cleanup, tmpRootCandidates, readFileNormalized, readWorkflowCombined, parseFrontmatter, isUsageOutput, captureConsole, toPosixPath, absPlanningPath, runNpm, isolatedNpmEnv, withIsolatedProcessState, delay, waitFor, resetRuntimeWarningCaches, SESSION_ENV_KEYS, saveSessionEnv, restoreSessionEnv, clearSessionEnv, isolateWorkstreamEnv, restoreWorkstreamEnv, TOOLS_PATH, SESSION_IDENTITY_ENV_KEYS, scrubConfigLocationEnv, installSpawnEnv, installSpawnHome, sandboxHome, writePackageSourceMarkerFixture, writeAmbientCapabilityGate, withAmbientCapabilityHome, TEST_HOME_SANDBOX_MARKER, mockPartialWriteThenThrow, captureFdSync, suppressFdAsync };
// Lazy, for the reason builtLib() is lazy: reading either of these is what
// forces the built-lib require, so a test file that needs neither can still
// import this helper on an unbuilt tree. Enumerable, so destructuring and
// Object.keys() behave exactly as they did when these were plain properties.
Object.defineProperties(module.exports, {
TEST_ENV_BASE: { enumerable: true, get: testEnvBase },
CONFIG_LOCATION_ENV_KEYS: { enumerable: true, get: configLocationEnvKeys },
});