fix(tests): the suite no longer hands the session id to the lock CLI

npm test now preloads tests/helpers/no-session-id.mjs, which deletes CLAUDE_CODE_SESSION_ID before any test file loads, and the 18 files that spread process.env into a child use childEnv() instead (23 sites). A live session's stop guard is no longer pointed at a temp-repo lock (S133).

engines stays >=18: --import exists from Node 18.18/20.6, and the quoted test glob already needs Node 21 (criteria-runner NODE_GLOB_MAJOR), so the real floor does not move.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-12 14:09:14 +02:00
commit d8527976ef
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
22 changed files with 285 additions and 24 deletions

View file

@ -8,7 +8,7 @@
"node": ">=18"
},
"scripts": {
"test": "node --test 'tests/**/*.test.mjs'",
"test": "node --import ./tests/helpers/no-session-id.mjs --test 'tests/**/*.test.mjs'",
"annotate": "node scripts/annotate.mjs",
"verify": "bash verify.sh"
},

View file

@ -25,6 +25,7 @@ import { tmpdir } from 'node:os';
import { realpathSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { childEnv } from '../helpers/child-env.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
@ -137,7 +138,7 @@ function makeRepo(opts = {}) {
}
function runBlock(script, { cwd, env }) {
return spawnSync('bash', ['-c', script], { cwd, env: { ...process.env, ...env }, encoding: 'utf8' });
return spawnSync('bash', ['-c', script], { cwd, env: childEnv({ ...env }), encoding: 'utf8' });
}
function copyBlockEnv(repo, pathPrefixDir) {

View file

@ -0,0 +1,20 @@
// tests/helpers/child-env.mjs
// The environment a test gives a child process. The lock CLI
// (lib/util/test-lock.mjs) writes a session pointer keyed by
// CLAUDE_CODE_SESSION_ID; a child that inherits the id of the session running
// the tests writes a pointer for that live session (S133). So the id never
// reaches a child unless the test sets one itself. Same shape as
// lib/util/command-env.mjs. tests/lib/session-id-isolation.test.mjs checks that
// no other file under tests/ hands the process environment to a child.
/**
* A copy of process.env without CLAUDE_CODE_SESSION_ID, with `overrides`
* applied last (a test that sets its own id keeps it).
*
* @param {Record<string, string|undefined>} [overrides] - vars to set or replace
* @returns {Record<string, string|undefined>} the child's environment
*/
export function childEnv(overrides = {}) {
const { CLAUDE_CODE_SESSION_ID, ...rest } = process.env;
return { ...rest, ...overrides };
}

View file

@ -6,6 +6,7 @@
// Provenance: borrowed within the same marketplace (same author, MIT).
import { execFile } from 'node:child_process';
import { childEnv } from './child-env.mjs';
// How long a hook may run before the helper kills it. The tests check a
// hook's answer, not its speed (SC #14 times itself, tests/helpers/timing.mjs),
@ -37,7 +38,7 @@ export function runHook(scriptPath, input) {
*/
export function runHookWithEnv(scriptPath, input, envOverrides, opts = {}) {
return new Promise((resolve) => {
const env = { ...process.env, ...envOverrides };
const env = childEnv({ ...envOverrides });
const child = execFile(
'node',
[scriptPath],

View file

@ -0,0 +1,6 @@
// tests/helpers/no-session-id.mjs
// Preloaded by `npm test` (node --import). The lock CLI (lib/util/test-lock.mjs)
// writes a session pointer keyed by CLAUDE_CODE_SESSION_ID, and the stop guard
// reads it; a suite run inside a Claude Code session must not write one for
// that live session (S133). The id is removed before any test file starts.
delete process.env.CLAUDE_CODE_SESSION_ID;

View file

@ -10,6 +10,7 @@ import { writeFileSync, mkdirSync, realpathSync } from 'node:fs';
import { join } from 'node:path';
import { runHook, runHookWithEnv } from '../helpers/hook-helper.mjs';
import { ROOT, makeRepo, gitIn, fakeTool, tmp, pluginCopy, readStubLog, parseJsonOut } from './guard-helpers.mjs';
import { childEnv } from '../helpers/child-env.mjs';
const PRE_BASH = join(ROOT, 'hooks', 'scripts', 'pre-bash-executor.mjs');
const PRE_WRITE = join(ROOT, 'hooks', 'scripts', 'pre-write-executor.mjs');
@ -177,7 +178,7 @@ test('review 5: a guard module that cannot load makes the Bash guard ask, never
});
function lockIn(repo, rel, env = {}) {
const r = spawnSync(process.execPath, [LOCK, 'lock', '--files', rel, '--step', '1'], { cwd: repo, encoding: 'utf8', env: { ...process.env, ...env } });
const r = spawnSync(process.execPath, [LOCK, 'lock', '--files', rel, '--step', '1'], { cwd: repo, encoding: 'utf8', env: childEnv({ ...env }) });
assert.equal(r.status, 0, r.stderr);
}
@ -216,7 +217,7 @@ test('review 10: the stop guard finds the lock taken by this session from anothe
// 4, 11, 12. The step check
function runStep(repo) {
const r = spawnSync(process.execPath, [STEP, '--cwd', repo], { encoding: 'utf8', env: { ...process.env, PATH: '/usr/bin:/bin' } });
const r = spawnSync(process.execPath, [STEP, '--cwd', repo], { encoding: 'utf8', env: childEnv({ PATH: '/usr/bin:/bin' }) });
return { code: r.status, json: JSON.parse(r.stdout) };
}
function jsRepo() {

View file

@ -12,6 +12,7 @@ import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFile } from 'node:child_process';
import { childEnv } from '../helpers/child-env.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
@ -22,7 +23,7 @@ function runHookIn(cwd, input = {}) {
const child = execFile(
'node',
[HOOK],
{ timeout: 5000, cwd, env: { ...process.env } },
{ timeout: 5000, cwd, env: childEnv() },
(err, stdout, stderr) => {
resolve({
code: child.exitCode ?? 0,

View file

@ -12,6 +12,7 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'nod
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { childEnv } from '../helpers/child-env.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const HOOK = join(HERE, '..', '..', 'hooks', 'scripts', 'pre-compact-flush.mjs');
@ -43,7 +44,7 @@ function project(checkpoint, { stateStatus = 'in_progress' } = {}) {
}
function run(repo) {
return spawnSync(process.execPath, [HOOK], { input: '{}', encoding: 'utf8', env: { ...process.env, CLAUDE_PROJECT_DIR: repo } });
return spawnSync(process.execPath, [HOOK], { input: '{}', encoding: 'utf8', env: childEnv({ CLAUDE_PROJECT_DIR: repo }) });
}
const progress = (dir) => JSON.parse(readFileSync(join(dir, 'progress.json'), 'utf8'));

View file

@ -18,6 +18,7 @@ import { join } from 'node:path';
import { runHook } from '../helpers/hook-helper.mjs';
import { ROOT, makeRepo, parseJsonOut } from './guard-helpers.mjs';
import { pathWith } from '../helpers/bin-path.mjs';
import { childEnv } from '../helpers/child-env.mjs';
const CLI = join(ROOT, 'lib', 'util', 'test-lock.mjs');
const STOP = join(ROOT, 'hooks', 'scripts', 'stop-test-lock.mjs');
@ -26,7 +27,7 @@ const SESS = `sess-green-${process.pid}-${Date.now()}`;
const CMD = 'node --test tests/step.test.mjs';
function cli(repo, args, env = {}) {
const r = spawnSync(process.execPath, [CLI, ...args], { cwd: repo, encoding: 'utf8', env: { ...process.env, CLAUDE_CODE_SESSION_ID: SESS, ...env } });
const r = spawnSync(process.execPath, [CLI, ...args], { cwd: repo, encoding: 'utf8', env: childEnv({ CLAUDE_CODE_SESSION_ID: SESS, ...env }) });
return { code: r.status, stdout: r.stdout, stderr: r.stderr };
}

View file

@ -15,6 +15,7 @@ import { writeFileSync, mkdirSync, realpathSync } from 'node:fs';
import { join } from 'node:path';
import { runHook } from '../helpers/hook-helper.mjs';
import { ROOT, scriptsFor, makeRepo, parseJsonOut } from './guard-helpers.mjs';
import { childEnv } from '../helpers/child-env.mjs';
const CLI = join(ROOT, 'lib', 'util', 'test-lock.mjs');
const PRE_WRITE = join(ROOT, 'hooks', 'scripts', 'pre-write-executor.mjs');
@ -31,7 +32,7 @@ function lockedRepo({ session = SESS_A, step = 2 } = {}) {
writeFileSync(join(repo, 'tests', 'free.test.js'), 'y\n');
writeFileSync(join(repo, 'nb.ipynb'), '{}\n');
const r = spawnSync(process.execPath, [CLI, 'lock', '--files', 'tests/locked.test.js,nb.ipynb', '--step', String(step)], {
cwd: repo, encoding: 'utf8', env: { ...process.env, CLAUDE_CODE_SESSION_ID: session },
cwd: repo, encoding: 'utf8', env: childEnv({ CLAUDE_CODE_SESSION_ID: session }),
});
assert.equal(r.status, 0, `lock failed: ${r.stderr}`);
return repo;

View file

@ -18,6 +18,7 @@ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, chmodSync, existsSync, s
import { join, dirname } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { childEnv } from '../helpers/child-env.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const RUNNER = join(HERE, '..', '..', 'lib', 'verification', 'arch-rules.mjs');
@ -67,7 +68,7 @@ function jsRepo({ cycle = false, binary = true } = {}) {
}
function run(args, env = {}) {
const r = spawnSync('node', [RUNNER, ...args], { encoding: 'utf-8', env: { ...process.env, ...env } });
const r = spawnSync('node', [RUNNER, ...args], { encoding: 'utf-8', env: childEnv({ ...env }) });
let json = null;
try { json = JSON.parse(r.stdout); } catch { /* asserted by caller */ }
return { code: r.status, json, out: r.stdout, err: r.stderr };

View file

@ -15,6 +15,7 @@ import { recordVerdict, planHash, acceptOpen, gatePlan } from '../../lib/validat
import { stampBriefReview, checkBriefReview } from '../../lib/validators/brief-review-stamp.mjs';
import { stampIntentApproval } from '../../lib/validators/intent-approval.mjs';
import { buildPlan } from '../helpers/plan-v18-builder.mjs';
import { childEnv } from '../helpers/child-env.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
@ -30,11 +31,11 @@ function lockRepo(withCmd) {
writeFileSync(join(repo, 'tests', 'a.test.mjs'), "import { test } from 'node:test';\nimport assert from 'node:assert';\ntest('a', () => assert.equal(1, 2));\n");
const args = ['lock', '--files', 'tests/a.test.mjs', '--step', '1'];
if (withCmd) args.push('--cmd', 'node --test tests/a.test.mjs');
const r = spawnSync(process.execPath, [LOCK, ...args], { cwd: repo, encoding: 'utf8', env: { ...process.env, CLAUDE_CODE_SESSION_ID: SESS } });
const r = spawnSync(process.execPath, [LOCK, ...args], { cwd: repo, encoding: 'utf8', env: childEnv({ CLAUDE_CODE_SESSION_ID: SESS }) });
assert.equal(r.status, 0, r.stderr);
return repo;
}
const lockCli = (repo, args) => spawnSync(process.execPath, [LOCK, ...args], { cwd: repo, encoding: 'utf8', env: { ...process.env, CLAUDE_CODE_SESSION_ID: SESS } });
const lockCli = (repo, args) => spawnSync(process.execPath, [LOCK, ...args], { cwd: repo, encoding: 'utf8', env: childEnv({ CLAUDE_CODE_SESSION_ID: SESS }) });
test('green: another allowed command (git status) can never mark a red step green', async () => {
const repo = lockRepo(true);

View file

@ -22,6 +22,7 @@ import {
MAX_CONV_TURNS,
MAX_TOTAL_DIMENSIONS,
} from '../../lib/util/research-loop-cap.mjs';
import { childEnv } from '../helpers/child-env.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const SHIM = join(HERE, '..', '..', 'lib', 'util', 'research-loop-cap.mjs');
@ -40,7 +41,7 @@ function runShim(args, env) {
const out = execFileSync(process.execPath, [SHIM, ...args], {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, ...env },
env: childEnv({ ...env }),
});
return { code: 0, out };
} catch (e) {

View file

@ -20,6 +20,7 @@ import { stepTestsOnly } from '../../lib/parsers/plan-schema.mjs';
import { validateFindings } from '../../lib/review/findings-schema.mjs';
import { lintSkill } from '../../lib/skills/skill-lint.mjs';
import * as routing from '../../lib/skills/routing.mjs';
import { childEnv } from '../helpers/child-env.mjs';
const awaitImportSync = () => routing;
@ -980,7 +981,7 @@ test('review 630 r8 / red-proof.mjs:101: a run reports one reading of a stop, no
writeFileSync(join(repo, 'tests', 'a.test.mjs'), 'x\n');
const out = spawnSync(process.execPath, [join(ROOT, 'lib', 'verification', 'red-proof.mjs'), '--cwd', repo, '--base', base,
'--cmd', 'jest tests/a.test.mjs', '--tests', 'tests/a.test.mjs', '--red-only', '--json'],
{ encoding: 'utf8', env: { ...process.env, PATH: bin.path }, timeout: 60_000 });
{ encoding: 'utf8', env: childEnv({ PATH: bin.path }), timeout: 60_000 });
const r = JSON.parse(out.stdout);
assert.equal(r.status, 'not-run', JSON.stringify(r));
assert.match(r.reason, /could not start/, r.reason);

View file

@ -0,0 +1,217 @@
// 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 <tmpdir>/voyage-test-locks-<uid>/, 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 <file>`) 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 { 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/;
test('(d) the lock-driving test files run green and leave no pointer for a made-up id', { timeout: TIMEOUT }, () => {
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(', ')}`);
const flags = preloadFlags();
const problems = [];
for (const rel of drivers) {
for (const [mode, pre] of [['test command', flags], ['direct', []]]) {
const id = madeUpId();
const tmp = privateTmp();
try {
const r = spawnSync(process.execPath, [...pre, '--test', '--test-reporter=tap', rel],
{ cwd: ROOT, encoding: 'utf8', env: isolatedEnv(id, tmp), timeout: TIMEOUT });
const out = `${r.stdout || ''}\n${r.stderr || ''}`;
const m = out.match(/^# tests (\d+)$/m);
if (r.status !== 0) problems.push(`${rel} [${mode}]: exit ${r.status}${r.signal ? ` (${r.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`);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
}
}
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 });
}
});

View file

@ -27,6 +27,7 @@ import { fileURLToPath } from 'node:url';
import {
parseExecutionStrategy, buildWaveOrders, progressFileFor, waveStatus,
} from '../../lib/util/session-orders.mjs';
import { childEnv } from '../helpers/child-env.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
@ -100,7 +101,7 @@ function fill(script, values) {
const BASH = existsSync('/bin/bash') ? '/bin/bash' : 'bash';
function runBash(script, cwd, env = {}) {
return spawnSync(BASH, ['-c', script], { cwd, encoding: 'utf-8', env: { ...process.env, ...env } });
return spawnSync(BASH, ['-c', script], { cwd, encoding: 'utf-8', env: childEnv({ ...env }) });
}
function gitc(cwd, ...args) {
@ -204,7 +205,7 @@ test('CLI on the fixture: wave 1 writes two orders and wave.json, prints the sta
const sessionDir = join(root, '.claude', 'trekplan-sessions', 'demo');
const r = spawnSync(process.execPath, [MODULE, 'write', '--plan', PLAN_REL, '--wave', '1', '--session-dir', sessionDir], {
cwd: root, encoding: 'utf-8', env: { ...process.env, PATH: `${bin}:${process.env.PATH}` },
cwd: root, encoding: 'utf-8', env: childEnv({ PATH: `${bin}:${process.env.PATH}` }),
});
assert.equal(r.status, 0, r.stderr);
assert.equal(existsSync(marker), false, 'nothing may start Claude Code');
@ -555,7 +556,7 @@ test('Check 4 and Step 5 pass a first run (no worktrees yet) under zsh as well a
const step5 = fill(bashBlock('### Step 5 — Cleanup'), { slug: 'demo' });
for (const shell of [BASH, ZSH]) {
for (const [name, block] of [['Check 4', check4], ['Step 5', step5]]) {
const r = spawnSync(shell, ['-c', block], { cwd: root, encoding: 'utf-8', env: { ...process.env, ...env } });
const r = spawnSync(shell, ['-c', block], { cwd: root, encoding: 'utf-8', env: childEnv({ ...env }) });
assert.equal(r.status, 0, `${name} under ${shell}: ${r.stderr}`);
assert.doesNotMatch(r.stderr, /no matches found/, `${name} under ${shell}`);
}

View file

@ -21,6 +21,7 @@ import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { emit, buildRecord, resolveStatsPath, KNOWN_EVENTS } from '../../lib/stats/event-emit.mjs';
import { resolveStatsDir } from '../../lib/stats/stats-append.mjs';
import { childEnv } from '../helpers/child-env.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const SHIM = join(HERE, '..', '..', 'lib', 'stats', 'event-emit.mjs');
@ -36,7 +37,7 @@ const DATA_REL = join('.claude', 'plugins', 'data', 'voyage-ktg-plugin-marketpla
/** process.env with HOME pinned and CLAUDE_PLUGIN_DATA empty: never the real data dir. */
function isolatedEnv(home) {
const env = { ...process.env, CLAUDE_PLUGIN_DATA: '', HOME: home };
const env = childEnv({ CLAUDE_PLUGIN_DATA: '', HOME: home });
delete env.CLAUDE_COORD_DIR;
return env;
}
@ -169,7 +170,7 @@ test('CLI shim writes via emit when CLAUDE_PLUGIN_DATA is set', () => {
execFileSync(process.execPath, [
SHIM, '--event', 'brief-approved', '--payload', '{"foo":42}',
], {
env: { ...process.env, CLAUDE_PLUGIN_DATA: dir },
env: childEnv({ CLAUDE_PLUGIN_DATA: dir }),
encoding: 'utf-8',
});
const path = join(dir, 'trekexecute-stats.jsonl');

View file

@ -23,6 +23,7 @@ import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symli
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { childEnv } from '../helpers/child-env.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
@ -48,7 +49,7 @@ function appendLine(sec, kind, rel) {
/** process.env without CLAUDE_COORD_DIR: a test never reads the real coord queue. */
function baseEnv() {
const env = { ...process.env };
const env = childEnv();
delete env.CLAUDE_COORD_DIR;
return env;
}

View file

@ -12,11 +12,12 @@ import { spawnSync } from 'node:child_process';
import { writeFileSync, mkdirSync, appendFileSync } from 'node:fs';
import { join } from 'node:path';
import { ROOT, makeRepo, gitIn, fakeTool, tmp, pluginCopy } from '../hooks/guard-helpers.mjs';
import { childEnv } from '../helpers/child-env.mjs';
const CLI = join(ROOT, 'lib', 'verification', 'step-check.mjs');
function run(cli, args, env = {}) {
const r = spawnSync(process.execPath, [cli, ...args], { encoding: 'utf8', env: { ...process.env, PATH: '/usr/bin:/bin', ...env } });
const r = spawnSync(process.execPath, [cli, ...args], { encoding: 'utf8', env: childEnv({ PATH: '/usr/bin:/bin', ...env }) });
let json = null;
try { json = JSON.parse(r.stdout); } catch { json = null; }
return { code: r.status, stdout: r.stdout, stderr: r.stderr, json };

View file

@ -11,12 +11,13 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { makeRepo, gitIn, tmp } from '../hooks/guard-helpers.mjs';
import { childEnv } from '../helpers/child-env.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const CLI = join(HERE, '..', '..', 'lib', 'util', 'test-lock.mjs');
function run(cwd, args, env = {}) {
const r = spawnSync(process.execPath, [CLI, ...args], { cwd, encoding: 'utf8', env: { ...process.env, ...env } });
const r = spawnSync(process.execPath, [CLI, ...args], { cwd, encoding: 'utf8', env: childEnv({ ...env }) });
return { code: r.status, stdout: r.stdout, stderr: r.stderr };
}

View file

@ -17,6 +17,7 @@ import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync, existsSync
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { childEnv } from '../helpers/child-env.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const CHECK = join(ROOT, 'scripts', 'release-check.mjs');
@ -360,7 +361,7 @@ test('release-check — a red suite is red when the check itself runs under node
const control = run(fx);
assert.equal(control.status, 1, `the control: the suite is red: ${control.stdout}`);
const r = spawnSync(process.execPath, [CHECK, '--repo', fx.repo, '--review', fx.review],
{ encoding: 'utf8', env: { ...process.env, NODE_TEST_CONTEXT: 'child-v8' } });
{ encoding: 'utf8', env: childEnv({ NODE_TEST_CONTEXT: 'child-v8' }) });
assert.equal(r.status, 1, `under a parent test runner: ${r.stdout}`);
assert.match(r.stdout, /npm test exited/);
} finally { fx.cleanup(); }

View file

@ -20,6 +20,7 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { childEnv } from '../helpers/child-env.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
@ -78,7 +79,7 @@ function commandLine(rel, verb) {
function run(line, subs, env = {}) {
let cmd = line.split('${CLAUDE_PLUGIN_ROOT}').join(ROOT);
for (const [k, v] of Object.entries(subs)) cmd = cmd.split(k).join(v);
return spawnSync('bash', ['-c', cmd], { encoding: 'utf8', env: { ...process.env, ...env } });
return spawnSync('bash', ['-c', cmd], { encoding: 'utf8', env: childEnv({ ...env }) });
}
function gate(briefPath) {