CLAUDE_PLUGIN_DATA is empty in the Bash tool's process env, and the Phase 5
bash snippet is the cap's only caller. resolveLedgerPath() returned null there
and allowTurn() failed closed, so the budget gate denied turn 1 of every real
run: the loop this delivery exists to bound could never spend a turn, and the
pre-registered measurement could not be run at all.
resolveDataRoot() is now the single root for everything the loop writes --
CLAUDE_PLUGIN_DATA when the harness sets it, ~/.claude/voyage when it does
not. Three consumers resolve through it, which is the point: the cap ledger,
the PreToolUse hook's scope-marker lookup, and the command's bash snippets.
A writer and a reader that resolved the root separately are what made the
enforcement hook allow unconditionally in every real run while CLAUDE.md and
docs/architecture.md called it enforcing.
Same root cause, same commit:
- Marker write and remove now share ONE absolute-path guard and one root; the
write requires a non-empty CLAUDE_CODE_SESSION_ID before composing the path
(unset, the marker was named `.json`, which no lookup matches and no TTL
sweep cleans up).
- The per-turn gates resolve VOYAGE_ROOT with a plugin-cache fallback and
reserve exit 2 for "gate could not run". Interpolating an empty
${CLAUDE_PLUGIN_ROOT} ran `node /lib/...` -> exit 1, which the contract read
as "privacy gate says no" -- an unsatisfiable rewrite loop no query could
clear.
Two now-unreachable deny branches are removed rather than left as dead safety
claims (allowTurn's no_plugin_data_dir; the hook's uncountable-ledger deny).
The fail-closed stance stays where it is still real: a ledger that cannot be
WRITTEN denies the turn.
Verified end-to-end through the real bash snippets and the real hook with both
variables stripped and HOME sandboxed: marker written under the fallback root,
8 turns spent, 9th denied, hook exits 2, and exits 0 again after removal.
Note: the fallback exit-2 branch fires against the installed v5.9.1 cache,
which predates lib/util/research-loop-cap.mjs -- correct behaviour, and it
clears when the plugin is reinstalled.
Review findings 2670c10a, fbd6d534, 93550dfb.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vPSXe88qp5aqWUqbDNWoF
286 lines
12 KiB
JavaScript
286 lines
12 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, 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,
|
||
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);
|
||
});
|
||
|
||
// ---- 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');
|
||
});
|
||
});
|
||
|
||
// ---- (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:/);
|
||
});
|