// tests/lib/session-id-isolation.test.mjs // The test suite must not run under the live session's id. The lock CLI // (lib/util/test-lock.mjs) writes a session pointer keyed by // CLAUDE_CODE_SESSION_ID into /voyage-test-locks-/, and the stop // guard reads it; a test child that inherits the id of the session running // `npm test` writes a pointer for that live session (S133). Two layers keep // the id out: // - `npm test` preloads tests/helpers/no-session-id.mjs, which deletes the // id from the runner's environment before any test file starts; // - every test that starts a child builds the child's environment with // childEnv() (tests/helpers/child-env.mjs), which drops the id, so a // single file run directly (`node --test `) does not leak it either. // // Parts: // (a) static scan: no file under tests/ spreads the process environment // into a child, except the helper itself; // (b) childEnv() drops an inherited id and keeps an id the caller sets; // (c) the test command holds the preload, and the preload clears the id; // (d) every test file that drives the lock CLI or the stop guard runs green, // through the test command's preload and directly, and leaves no // pointer for a made-up id; // (e) known-positive control: the lock CLI run with that id does write the // pointer at the computed path, so (d) cannot pass on a wrong path. // // Every child environment here is built without a spread (Object.entries + // filter), so this file does not depend on the helper and does not trip (a). import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { spawn, spawnSync } from 'node:child_process'; import { readFileSync, readdirSync, existsSync, mkdtempSync, mkdirSync, writeFileSync, rmSync, realpathSync } from 'node:fs'; import { join, dirname, relative, sep } from 'node:path'; import { tmpdir } from 'node:os'; import { createHash, randomUUID } from 'node:crypto'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { makeRepo } from '../hooks/guard-helpers.mjs'; const HERE = dirname(fileURLToPath(import.meta.url)); const ROOT = join(HERE, '..', '..'); const SELF = 'tests/lib/session-id-isolation.test.mjs'; const HELPER = 'tests/helpers/child-env.mjs'; const PRELOAD = 'tests/helpers/no-session-id.mjs'; const PRELOAD_FLAG = `--import ./${PRELOAD}`; const LOCK_CLI = join(ROOT, 'lib', 'util', 'test-lock.mjs'); const ID_VAR = 'CLAUDE_CODE_SESSION_ID'; const TIMEOUT = 300_000; // Built from parts so this file's own text never matches them. const PENV = 'process' + '\\.env(?![\\w$.])'; const SPREAD_PATTERNS = [ ['spread', new RegExp('\\.\\.\\.\\s*' + PENV)], ['env-property', new RegExp('\\benv\\s*:\\s*' + PENV)], ['object-assign', new RegExp('Object\\.assign\\([^)]*' + PENV)], ]; function walk(dir, keep) { const out = []; for (const e of readdirSync(dir, { withFileTypes: true })) { const p = join(dir, e.name); if (e.isDirectory()) out.push(...walk(p, keep)); else if (keep(e.name)) out.push(relative(ROOT, p).split(sep).join('/')); } return out.sort(); } const madeUpId = () => `isolation-${randomUUID()}`; // The pointer path, as lib/util/test-lock.mjs:76-78 computes it. function pointerPath(tmp, id) { const uid = process.getuid ? process.getuid() : 'u'; const name = `${createHash('sha256').update(String(id)).digest('hex').slice(0, 32)}.json`; return join(tmp, `voyage-test-locks-${uid}`, name); } // The child's environment: ours without NODE_TEST_CONTEXT (so a nested // `node --test` reports its own exit code) and without the live id, plus a // made-up id and a private TMPDIR. function isolatedEnv(id, tmp) { const env = Object.fromEntries(Object.entries(process.env) .filter(([k]) => k !== 'NODE_TEST_CONTEXT' && k !== ID_VAR)); env[ID_VAR] = id; env.TMPDIR = tmp; return env; } const privateTmp = () => realpathSync(mkdtempSync(join(tmpdir(), 'voyage-sid-'))); function testScript() { return JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).scripts?.test ?? ''; } // The node flags the test command puts before --test (the preload). function preloadFlags() { const words = testScript().split(/\s+/).filter(Boolean); const start = words.indexOf('node'); const end = words.indexOf('--test'); if (start < 0 || end < 0 || end < start) return []; return words.slice(start + 1, end); } test('(a) no test file spreads the process environment into a child, except the helper', () => { const files = walk(join(ROOT, 'tests'), (n) => n.endsWith('.mjs')); assert.ok(files.length >= 140, `the scan saw only ${files.length} .mjs files under tests/; an empty scan proves nothing`); const hits = []; for (const rel of files) { if (rel === HELPER) continue; const text = readFileSync(join(ROOT, rel), 'utf8'); for (const [kind, re] of SPREAD_PATTERNS) { if (re.test(text)) hits.push(`${rel} (${kind})`); } } assert.deepEqual(hits, [], `use childEnv() from ${HELPER} instead:\n${hits.join('\n')}`); }); test('(b) childEnv() drops an inherited id and keeps an id the caller sets', async () => { let mod; try { mod = await import(pathToFileURL(join(ROOT, HELPER)).href); } catch (e) { // Only the error's name: its message would read as a load crash to red-proof. assert.fail(`child-env helper missing: ${HELPER} could not be loaded (${e.name})`); } assert.equal(typeof mod.childEnv, 'function', 'child-env helper missing: no childEnv export'); const had = Object.prototype.hasOwnProperty.call(process.env, ID_VAR); const saved = process.env[ID_VAR]; const inherited = madeUpId(); process.env[ID_VAR] = inherited; try { const plain = mod.childEnv(); assert.equal(Object.prototype.hasOwnProperty.call(plain, ID_VAR), false, 'an inherited id must not reach the child'); assert.equal(plain.PATH, process.env.PATH, 'the rest of the environment is kept'); plain.VOYAGE_SID_PROBE = 'x'; assert.equal(process.env.VOYAGE_SID_PROBE, undefined, 'childEnv() returns a copy, not process.env'); const own = mod.childEnv({ [ID_VAR]: 'sess-own', VOYAGE_SID_EXTRA: 'y' }); assert.equal(own[ID_VAR], 'sess-own', 'an id the test sets itself is kept'); assert.equal(own.VOYAGE_SID_EXTRA, 'y', 'overrides are applied'); assert.equal(mod.childEnv({ PATH: '/usr/bin:/bin' }).PATH, '/usr/bin:/bin', 'overrides win over the inherited value'); } finally { if (had) process.env[ID_VAR] = saved; else delete process.env[ID_VAR]; } }); test('(c) the test command preloads the helper, and the preload clears the id', () => { assert.ok(testScript().includes(PRELOAD_FLAG), `package.json scripts.test must hold "${PRELOAD_FLAG}"; it is: ${testScript()}`); const tmp = privateTmp(); try { const r = spawnSync(process.execPath, ['--import', `./${PRELOAD}`, '-e', `process.stdout.write(JSON.stringify(process.env.${ID_VAR} ?? null))`], { cwd: ROOT, encoding: 'utf8', env: isolatedEnv(madeUpId(), tmp) }); assert.equal(r.status, 0, `the preload child did not run: ${r.stderr}`); assert.equal(r.stdout.trim(), 'null', 'the preload must delete the id'); } finally { rmSync(tmp, { recursive: true, force: true }); } }); // Files that name the lock CLI or the stop guard but do not drive either. // Each must still exist and still match, so the list stays honest. const NOT_DRIVERS = new Map([ ['tests/lib/review-630.test.mjs', 'names the lock CLI only in source-reading assertions (:502, :952-964)'], ['tests/lib/skills-workshop-prose-pins.test.mjs', 'prose pin: checks the skill names the CLI (:85)'], ]); const DRIVER_RE = /test-lock\.mjs|stop-test-lock/; // One nested `node --test` run of `rel`, with its own made-up id and private // TMPDIR. Resolves to the problems it shows. function runDriver(rel, mode, pre) { const id = madeUpId(); const tmp = privateTmp(); return new Promise((resolve) => { const child = spawn(process.execPath, [...pre, '--test', '--test-reporter=tap', rel], { cwd: ROOT, env: isolatedEnv(id, tmp), timeout: TIMEOUT }); let out = ''; child.stdout.on('data', (d) => { out += d; }); child.stderr.on('data', (d) => { out += d; }); child.on('close', (status, signal) => { const problems = []; const m = out.match(/^# tests (\d+)$/m); if (status !== 0) problems.push(`${rel} [${mode}]: exit ${status}${signal ? ` (${signal})` : ''}`); if (!m || Number(m[1]) === 0) problems.push(`${rel} [${mode}]: reported no tests`); if (existsSync(pointerPath(tmp, id))) problems.push(`${rel} [${mode}]: left a session pointer for the made-up id`); rmSync(tmp, { recursive: true, force: true }); resolve(problems); }); }); } test('(d) the lock-driving test files run green and leave no pointer for a made-up id', { timeout: TIMEOUT }, async () => { const all = walk(join(ROOT, 'tests'), (n) => n.endsWith('.test.mjs')).filter((f) => f !== SELF); const matching = all.filter((rel) => DRIVER_RE.test(readFileSync(join(ROOT, rel), 'utf8'))); for (const [rel, why] of NOT_DRIVERS) { assert.ok(existsSync(join(ROOT, rel)), `excluded file is gone, drop it from the list: ${rel} (${why})`); assert.ok(matching.includes(rel), `excluded file no longer names the lock CLI, drop it from the list: ${rel}`); } const drivers = matching.filter((rel) => !NOT_DRIVERS.has(rel)); assert.ok(drivers.length >= 5, `only ${drivers.length} lock-driving test files found: ${drivers.join(', ')}`); // The files of one mode run side by side (as `node --test` runs files); the // two modes run one after the other. const problems = []; for (const [mode, pre] of [['test command', preloadFlags()], ['direct', []]]) { const runs = await Promise.all(drivers.map((rel) => runDriver(rel, mode, pre))); problems.push(...runs.flat()); } assert.deepEqual(problems, []); }); test('(e) known-positive control: the lock CLI writes the pointer at the computed path', () => { const id = madeUpId(); const tmp = privateTmp(); const repo = makeRepo('main'); try { mkdirSync(join(repo, 'tests'), { recursive: true }); writeFileSync(join(repo, 'tests', 'a.test.js'), 'a\n'); const r = spawnSync(process.execPath, [LOCK_CLI, 'lock', '--files', 'tests/a.test.js', '--step', '1'], { cwd: repo, encoding: 'utf8', env: isolatedEnv(id, tmp) }); assert.equal(r.status, 0, r.stderr); const p = pointerPath(tmp, id); assert.ok(existsSync(p), `the lock CLI wrote no pointer at ${p}; the path in (d) would be wrong`); assert.ok(existsSync(JSON.parse(readFileSync(p, 'utf8')).lock_path), 'the pointer names the lock file'); } finally { rmSync(tmp, { recursive: true, force: true }); rmSync(repo, { recursive: true, force: true }); } });