An unreadable ledger returned 0 from countTurns in BOTH the primitive and the hook, so a run whose ledger existed but could not be read (EISDIR, EACCES, EIO) was handed the full budget again on every call - unbounded. research-loop-cap.mjs argues against exactly that three lines above the code that did it, and its missing-DIRECTORY case already failed closed. The unreadable-FILE case now agrees with it. Only ENOENT still counts as zero turns spent: that is the legitimate first-turn state, and the reason this cannot just throw on any read failure. The hook no longer carries its own countTurns. It imports the primitive's exported readLedger(), the same way it already resolves the data root through resolveDataRoot() - a reader and a writer with private copies of the counting rule is how a hook ends up enforcing a different bound than the gate it backs. In scope + cannot count now exits 2 with a message that says counting failed, not that the budget is spent. Fail-closed stays scoped to the loop: a test pins that an unreadable ledger in an OUT-of-scope session still exits 0, because a PreToolUse hook that over-blocks bricks every session on the box. Also dropped the existsSync pre-check before the read - readFileSync's own ENOENT carries the same information without a second syscall that can disagree with the read that follows it. Review finding 5e1c6230f48ead38fa77cd8f4b06bfdc2b5b7bbf (MINOR). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LuGhWAbWyRFBFeemfhxoVv
368 lines
15 KiB
JavaScript
368 lines
15 KiB
JavaScript
// tests/lib/research-loop-cap.test.mjs
|
||
// Cover lib/util/research-loop-cap.mjs: default-off, worst-case arithmetic,
|
||
// anti-dead-data (different caps → different denial points), statefulness
|
||
// (identical args → different answers once the budget is hit), env
|
||
// coercion, fail-closed on missing CLAUDE_PLUGIN_DATA, and the CLI shim.
|
||
|
||
import { test } from 'node:test';
|
||
import { strict as assert } from 'node:assert';
|
||
import { execFileSync } from 'node:child_process';
|
||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs';
|
||
import { tmpdir } from 'node:os';
|
||
import { dirname, join } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import {
|
||
allowTurn,
|
||
isStormEnabled,
|
||
resolveMaxConvTurns,
|
||
resolveLedgerPath,
|
||
resolveDataRoot,
|
||
readLedger,
|
||
MAX_CONV_TURNS,
|
||
MAX_TOTAL_DIMENSIONS,
|
||
} from '../../lib/util/research-loop-cap.mjs';
|
||
|
||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||
const SHIM = join(HERE, '..', '..', 'lib', 'util', 'research-loop-cap.mjs');
|
||
|
||
function withTmpDataDir(fn) {
|
||
const dir = mkdtempSync(join(tmpdir(), 'research-loop-cap-'));
|
||
try {
|
||
return fn(dir);
|
||
} finally {
|
||
rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
}
|
||
|
||
function runShim(args, env) {
|
||
try {
|
||
const out = execFileSync(process.execPath, [SHIM, ...args], {
|
||
encoding: 'utf-8',
|
||
stdio: ['ignore', 'pipe', 'pipe'],
|
||
env: { ...process.env, ...env },
|
||
});
|
||
return { code: 0, out };
|
||
} catch (e) {
|
||
return { code: e.status ?? 1, out: e.stdout?.toString() ?? '' };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Run the shim in a BUILT env rather than an inherited one. Spreading
|
||
* process.env means no test can express "CLAUDE_PLUGIN_DATA is absent" — the
|
||
* exact condition that holds in every real run — so the shim's behaviour there
|
||
* went uncovered while the module was denying turn 1.
|
||
*/
|
||
function runShimStripped(args, env = {}) {
|
||
try {
|
||
const out = execFileSync(process.execPath, [SHIM, ...args], {
|
||
encoding: 'utf-8',
|
||
stdio: ['ignore', 'pipe', 'pipe'],
|
||
env: { PATH: process.env.PATH, ...env },
|
||
});
|
||
return { code: 0, out };
|
||
} catch (e) {
|
||
return { code: e.status ?? 1, out: e.stdout?.toString() ?? '' };
|
||
}
|
||
}
|
||
|
||
// ---- (a) default-off --------------------------------------------------------
|
||
|
||
test('allowTurn — VOYAGE_STORM_ENABLED unset denies with budget 0, regardless of effort', () => {
|
||
withTmpDataDir((dir) => {
|
||
const env = { CLAUDE_PLUGIN_DATA: dir };
|
||
const r = allowTurn({ runId: 'r1', dimension: 'd1', effort: 'high' }, { env });
|
||
assert.equal(r.ok, false);
|
||
assert.equal(r.reason, 'storm_disabled');
|
||
assert.equal(r.budget, 0);
|
||
});
|
||
});
|
||
|
||
test('allowTurn — VOYAGE_STORM_ENABLED=0 denies same as unset', () => {
|
||
withTmpDataDir((dir) => {
|
||
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '0' };
|
||
const r = allowTurn({ runId: 'r1', dimension: 'd1', effort: 'high' }, { env });
|
||
assert.equal(r.ok, false);
|
||
assert.equal(r.reason, 'storm_disabled');
|
||
});
|
||
});
|
||
|
||
test('allowTurn — enabled but effort !== high denies with budget 0', () => {
|
||
withTmpDataDir((dir) => {
|
||
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1' };
|
||
const r = allowTurn({ runId: 'r1', dimension: 'd1', effort: 'standard' }, { env });
|
||
assert.equal(r.ok, false);
|
||
assert.equal(r.reason, 'effort_not_high');
|
||
assert.equal(r.budget, 0);
|
||
});
|
||
});
|
||
|
||
// ---- (f) CLAUDE_PLUGIN_DATA unset => documented fallback root ----------------
|
||
//
|
||
// CLAUDE_PLUGIN_DATA is EMPTY in the Bash tool's process env (measured in a
|
||
// live plugin-enabled session), and the Bash snippet in commands/trekresearch.md
|
||
// is the module's only caller. Denying on its absence therefore denied turn 1
|
||
// of every real run: the loop could never spend a turn, and the pre-registered
|
||
// measurement could not be run at all. The root is resolved in code, not
|
||
// demanded of the environment.
|
||
|
||
test('allowTurn — CLAUDE_PLUGIN_DATA unset falls back to the documented root and grants', () => {
|
||
withTmpDataDir((home) => {
|
||
const env = { VOYAGE_STORM_ENABLED: '1', HOME: home }; // no CLAUDE_PLUGIN_DATA
|
||
const r = allowTurn({ runId: 'r1', dimension: 'd1', effort: 'high' }, { env });
|
||
assert.equal(r.ok, true, 'the loop must be able to spend turn 1 without CLAUDE_PLUGIN_DATA');
|
||
assert.equal(r.used, 1);
|
||
assert.equal(r.budget, MAX_CONV_TURNS * MAX_TOTAL_DIMENSIONS);
|
||
assert.ok(
|
||
existsSync(join(home, '.claude', 'voyage', 'trekresearch-loop-ledger.jsonl')),
|
||
'the ledger must be written under the fallback root',
|
||
);
|
||
});
|
||
});
|
||
|
||
test('resolveDataRoot — CLAUDE_PLUGIN_DATA wins; empty or unset falls back to ~/.claude/voyage', () => {
|
||
assert.equal(resolveDataRoot({ CLAUDE_PLUGIN_DATA: '/tmp/plugin-data' }), '/tmp/plugin-data');
|
||
assert.equal(resolveDataRoot({ CLAUDE_PLUGIN_DATA: '', HOME: '/home/x' }), join('/home/x', '.claude', 'voyage'));
|
||
assert.equal(resolveDataRoot({ HOME: '/home/x' }), join('/home/x', '.claude', 'voyage'));
|
||
});
|
||
|
||
// ---- (c) worst-case arithmetic ----------------------------------------------
|
||
|
||
test('allowTurn — budget is max_conv_turns × max_total_dimensions (default 3×8=24)', () => {
|
||
withTmpDataDir((dir) => {
|
||
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1' };
|
||
const r = allowTurn({ runId: 'r1', dimension: 'd1', effort: 'high' }, { env });
|
||
assert.equal(r.ok, true);
|
||
assert.equal(r.budget, 24);
|
||
assert.equal(r.used, 1);
|
||
});
|
||
});
|
||
|
||
test('allowTurn — grants exactly `budget` turns then denies the next one (default 24)', () => {
|
||
withTmpDataDir((dir) => {
|
||
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1' };
|
||
let last;
|
||
for (let i = 0; i < 24; i++) {
|
||
last = allowTurn({ runId: 'r-exhaust', dimension: `d${i % 8}`, effort: 'high' }, { env });
|
||
assert.equal(last.ok, true, `turn ${i + 1} should be granted`);
|
||
}
|
||
const denied = allowTurn({ runId: 'r-exhaust', dimension: 'd0', effort: 'high' }, { env });
|
||
assert.equal(denied.ok, false);
|
||
assert.equal(denied.reason, 'budget_exhausted');
|
||
assert.equal(denied.used, 24);
|
||
assert.equal(denied.budget, 24);
|
||
});
|
||
});
|
||
|
||
// ---- (c)/(anti-dead-data) — different caps → observably different denial points
|
||
|
||
test('allowTurn — TREKRESEARCH_MAX_CONV_TURNS=1 denies after 8 turns (1×8), not 24', () => {
|
||
withTmpDataDir((dir) => {
|
||
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1', TREKRESEARCH_MAX_CONV_TURNS: '1' };
|
||
let last;
|
||
for (let i = 0; i < 8; i++) {
|
||
last = allowTurn({ runId: 'r-narrow', dimension: `d${i}`, effort: 'high' }, { env });
|
||
assert.equal(last.ok, true, `turn ${i + 1} should be granted`);
|
||
}
|
||
const denied = allowTurn({ runId: 'r-narrow', dimension: 'd8', effort: 'high' }, { env });
|
||
assert.equal(denied.ok, false);
|
||
assert.equal(denied.budget, 8);
|
||
assert.notEqual(denied.budget, 24, 'a narrower cap must produce a different denial point than the default');
|
||
});
|
||
});
|
||
|
||
// ---- (d) stateful — identical args give different answers once exhausted ---
|
||
|
||
test('allowTurn — identical {runId, dimension, effort} args diverge once the budget is hit (proves statefulness)', () => {
|
||
withTmpDataDir((dir) => {
|
||
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1', TREKRESEARCH_MAX_CONV_TURNS: '1' };
|
||
const args = { runId: 'r-identical', dimension: 'same-dim', effort: 'high' };
|
||
const results = [];
|
||
for (let i = 0; i < 9; i++) results.push(allowTurn(args, { env }));
|
||
// First 8 (budget = 1*8) granted, 9th denied — same exact input object each time.
|
||
assert.deepEqual(results.slice(0, 8).map(r => r.ok), Array(8).fill(true));
|
||
assert.equal(results[8].ok, false);
|
||
assert.equal(results[8].reason, 'budget_exhausted');
|
||
});
|
||
});
|
||
|
||
// ---- (e) env coercion --------------------------------------------------------
|
||
|
||
test('resolveMaxConvTurns — NaN string falls back to default', () => {
|
||
assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: 'abc' }), MAX_CONV_TURNS);
|
||
});
|
||
|
||
test('resolveMaxConvTurns — empty string falls back to default', () => {
|
||
assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: '' }), MAX_CONV_TURNS);
|
||
});
|
||
|
||
test('resolveMaxConvTurns — negative value falls back to default', () => {
|
||
assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: '-5' }), MAX_CONV_TURNS);
|
||
});
|
||
|
||
test('resolveMaxConvTurns — zero falls back to default (never unbounded)', () => {
|
||
assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: '0' }), MAX_CONV_TURNS);
|
||
});
|
||
|
||
test('resolveMaxConvTurns — unset falls back to default', () => {
|
||
assert.equal(resolveMaxConvTurns({}), MAX_CONV_TURNS);
|
||
});
|
||
|
||
test('resolveMaxConvTurns — valid positive integer string is honored', () => {
|
||
assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: '2' }), 2);
|
||
});
|
||
|
||
// A fractional value passed the `n <= 0` guard and only THEN floored, so 0.5 and
|
||
// 0.9 became 0 and the budget became 0 × 8 = 0 — every turn denied, the loop
|
||
// silently dead, while README.md and docs/architecture.md both promise a
|
||
// fallback of 3. The guard has to see the floored value, not the raw one.
|
||
test('resolveMaxConvTurns — a fractional value below 1 falls back to the default, never 0', () => {
|
||
assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: '0.5' }), MAX_CONV_TURNS);
|
||
assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: '0.9' }), MAX_CONV_TURNS);
|
||
});
|
||
|
||
test('resolveMaxConvTurns — a fractional value above 1 still floors (2.7 → 2)', () => {
|
||
assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: '2.7' }), 2);
|
||
});
|
||
|
||
test('resolveMaxConvTurns — Infinity is not a cap and falls back to the default', () => {
|
||
assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: 'Infinity' }), MAX_CONV_TURNS);
|
||
});
|
||
|
||
test('allowTurn — a fractional cap below 1 cannot produce a budget of 0', () => {
|
||
withTmpDataDir((dir) => {
|
||
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1', TREKRESEARCH_MAX_CONV_TURNS: '0.5' };
|
||
const r = allowTurn({ runId: 'r-frac', dimension: 'd1', effort: 'high' }, { env });
|
||
assert.equal(r.ok, true, 'a budget of 0 would make the loop silently dead, not bounded');
|
||
assert.equal(r.budget, MAX_CONV_TURNS * MAX_TOTAL_DIMENSIONS);
|
||
});
|
||
});
|
||
|
||
// ---- pure-core unit coverage --------------------------------------------------
|
||
|
||
test('isStormEnabled — only the literal string "1" enables', () => {
|
||
assert.equal(isStormEnabled({ VOYAGE_STORM_ENABLED: '1' }), true);
|
||
assert.equal(isStormEnabled({ VOYAGE_STORM_ENABLED: 'true' }), false);
|
||
assert.equal(isStormEnabled({}), false);
|
||
});
|
||
|
||
test('resolveLedgerPath — falls back under ~/.claude/voyage when CLAUDE_PLUGIN_DATA is unset or empty', () => {
|
||
const expected = join('/home/x', '.claude', 'voyage', 'trekresearch-loop-ledger.jsonl');
|
||
assert.equal(resolveLedgerPath({ HOME: '/home/x' }), expected);
|
||
assert.equal(resolveLedgerPath({ CLAUDE_PLUGIN_DATA: '', HOME: '/home/x' }), expected);
|
||
});
|
||
|
||
test('resolveLedgerPath — joins CLAUDE_PLUGIN_DATA with the ledger filename', () => {
|
||
const p = resolveLedgerPath({ CLAUDE_PLUGIN_DATA: '/tmp/plugin-data' });
|
||
assert.equal(p, join('/tmp/plugin-data', 'trekresearch-loop-ledger.jsonl'));
|
||
});
|
||
|
||
test('allowTurn — missing runId or dimension denies with missing_args', () => {
|
||
withTmpDataDir((dir) => {
|
||
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1' };
|
||
const r1 = allowTurn({ dimension: 'd1', effort: 'high' }, { env });
|
||
assert.equal(r1.ok, false);
|
||
assert.equal(r1.reason, 'missing_args');
|
||
const r2 = allowTurn({ runId: 'r1', effort: 'high' }, { env });
|
||
assert.equal(r2.ok, false);
|
||
assert.equal(r2.reason, 'missing_args');
|
||
});
|
||
});
|
||
|
||
// ---- fail-closed on an unreadable ledger ------------------------------------
|
||
//
|
||
// The module's own header states that a budget control "must never silently
|
||
// grant unlimited turns just because the data dir is missing". The
|
||
// missing-DIRECTORY case already failed closed; the unreadable-FILE case
|
||
// returned 0 from countTurns and therefore re-granted the full budget on every
|
||
// call, unbounded — a fail-open in the same module that argues against one.
|
||
// A directory standing where the ledger file belongs reproduces it portably
|
||
// (EISDIR), with no chmod that a root test runner would ignore.
|
||
|
||
function withUnreadableLedger(fn) {
|
||
return withTmpDataDir((dir) => {
|
||
mkdirSync(join(dir, 'trekresearch-loop-ledger.jsonl'), { recursive: true });
|
||
return fn(dir);
|
||
});
|
||
}
|
||
|
||
test('readLedger — a missing ledger is 0 turns, not an error (turn 1 must be grantable)', () => {
|
||
withTmpDataDir((dir) => {
|
||
assert.equal(readLedger(join(dir, 'nope.jsonl'), 'r1').granted, 0);
|
||
});
|
||
});
|
||
|
||
test('readLedger — an unreadable ledger throws rather than reporting 0 turns spent', () => {
|
||
withUnreadableLedger((dir) => {
|
||
assert.throws(
|
||
() => readLedger(join(dir, 'trekresearch-loop-ledger.jsonl'), 'r1'),
|
||
/unreadable/i,
|
||
);
|
||
});
|
||
});
|
||
|
||
test('readLedger — malformed lines are skipped, well-formed ones for the run still count', () => {
|
||
withTmpDataDir((dir) => {
|
||
const p = join(dir, 'trekresearch-loop-ledger.jsonl');
|
||
writeFileSync(p, [
|
||
JSON.stringify({ runId: 'r1', dimension: 'd1' }),
|
||
'{ not json',
|
||
'',
|
||
JSON.stringify({ runId: 'other', dimension: 'd1' }),
|
||
JSON.stringify({ runId: 'r1', dimension: 'd2' }),
|
||
].join('\n') + '\n');
|
||
assert.equal(readLedger(p, 'r1').granted, 2);
|
||
});
|
||
});
|
||
|
||
test('allowTurn — an unreadable ledger DENIES the turn instead of granting a fresh budget', () => {
|
||
withUnreadableLedger((dir) => {
|
||
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1' };
|
||
const r = allowTurn({ runId: 'r-unreadable', dimension: 'd1', effort: 'high' }, { env });
|
||
assert.equal(r.ok, false, 'a budget control that cannot count must not grant');
|
||
assert.match(r.reason, /ledger-read-failed/);
|
||
});
|
||
});
|
||
|
||
// ---- (g) shim contract --------------------------------------------------------
|
||
|
||
test('CLI shim — grants and exits 0 when enabled + high effort + budget available', () => {
|
||
withTmpDataDir((dir) => {
|
||
const r = runShim(
|
||
['--run-id', 'shim-1', '--dimension', 'd1', '--effort', 'high'],
|
||
{ CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1' },
|
||
);
|
||
assert.equal(r.code, 0);
|
||
const parsed = JSON.parse(r.out.trim());
|
||
assert.equal(parsed.ok, true);
|
||
});
|
||
});
|
||
|
||
test('CLI shim — denies and exits 1 when disabled', () => {
|
||
const r = runShim(['--run-id', 'shim-2', '--dimension', 'd1', '--effort', 'high'], { VOYAGE_STORM_ENABLED: '0' });
|
||
assert.equal(r.code, 1);
|
||
const parsed = JSON.parse(r.out.trim());
|
||
assert.equal(parsed.ok, false);
|
||
assert.equal(parsed.reason, 'storm_disabled');
|
||
});
|
||
|
||
test('CLI shim — grants with CLAUDE_PLUGIN_DATA STRIPPED from the environment', () => {
|
||
withTmpDataDir((home) => {
|
||
const r = runShimStripped(
|
||
['--run-id', 'shim-stripped', '--dimension', 'd1', '--effort', 'high'],
|
||
{ VOYAGE_STORM_ENABLED: '1', HOME: home },
|
||
);
|
||
assert.equal(r.code, 0, `shim must grant without CLAUDE_PLUGIN_DATA; got: ${r.out}`);
|
||
const parsed = JSON.parse(r.out.trim());
|
||
assert.equal(parsed.ok, true);
|
||
assert.ok(existsSync(join(home, '.claude', 'voyage', 'trekresearch-loop-ledger.jsonl')));
|
||
});
|
||
});
|
||
|
||
test('CLI shim — missing required args exits 1 with usage reason', () => {
|
||
const r = runShim(['--run-id', 'shim-3']);
|
||
assert.equal(r.code, 1);
|
||
const parsed = JSON.parse(r.out.trim());
|
||
assert.equal(parsed.ok, false);
|
||
assert.match(parsed.reason, /usage:/);
|
||
});
|