fix(research-loop-cap): resolve the data root in code so the loop can run
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
This commit is contained in:
parent
2e352a7dbb
commit
6dafdf2a2a
8 changed files with 260 additions and 61 deletions
|
|
@ -1165,6 +1165,26 @@ test('STORM: VOYAGE_STORM_ENABLED is documented as gating BOTH phases, not the l
|
|||
}
|
||||
});
|
||||
|
||||
// The variable is empty in the Bash tool env, so "missing CLAUDE_PLUGIN_DATA
|
||||
// denies" described a loop that could never spend turn 1. The root is resolved
|
||||
// in code now; a doc that still promises the deny describes a mechanism the
|
||||
// code does not have.
|
||||
test('STORM: no surface claims a missing CLAUDE_PLUGIN_DATA denies — the root falls back', () => {
|
||||
for (const f of ['docs/architecture.md', 'CLAUDE.md', 'README.md', 'docs/command-modes.md']) {
|
||||
const t = read(f);
|
||||
assert.doesNotMatch(
|
||||
t,
|
||||
/(missing|no|absent) `?CLAUDE_PLUGIN_DATA`?[^.\n]*(denies|fails closed)/i,
|
||||
`${f}: CLAUDE_PLUGIN_DATA absence no longer denies — it resolves to ~/.claude/voyage`,
|
||||
);
|
||||
}
|
||||
assert.match(
|
||||
read('docs/architecture.md'),
|
||||
/~\/\.claude\/voyage/,
|
||||
'docs/architecture.md must name the fallback data root the cap and the hook share',
|
||||
);
|
||||
});
|
||||
|
||||
test('STORM: README research-dimension prose stays at the existing 3–8 ceiling', () => {
|
||||
// Phase 4.5 discovers dimensions UNDER settings.json:16's maxDimensions: 8.
|
||||
// Rewriting this prose upward would raise a ceiling the brief asked us to hold.
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { mkdtempSync, rmSync, existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
|
@ -16,6 +16,7 @@ import {
|
|||
isStormEnabled,
|
||||
resolveMaxConvTurns,
|
||||
resolveLedgerPath,
|
||||
resolveDataRoot,
|
||||
MAX_CONV_TURNS,
|
||||
MAX_TOTAL_DIMENSIONS,
|
||||
} from '../../lib/util/research-loop-cap.mjs';
|
||||
|
|
@ -45,6 +46,25 @@ function runShim(args, env) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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', () => {
|
||||
|
|
@ -76,14 +96,33 @@ test('allowTurn — enabled but effort !== high denies with budget 0', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ---- (f) CLAUDE_PLUGIN_DATA unset => fail-closed deny -----------------------
|
||||
// ---- (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 denies even when enabled + high effort', () => {
|
||||
const env = { VOYAGE_STORM_ENABLED: '1' }; // no CLAUDE_PLUGIN_DATA
|
||||
const r = allowTurn({ runId: 'r1', dimension: 'd1', effort: 'high' }, { env });
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.reason, 'no_plugin_data_dir');
|
||||
assert.equal(r.budget, MAX_CONV_TURNS * MAX_TOTAL_DIMENSIONS);
|
||||
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 ----------------------------------------------
|
||||
|
|
@ -180,9 +219,10 @@ test('isStormEnabled — only the literal string "1" enables', () => {
|
|||
assert.equal(isStormEnabled({}), false);
|
||||
});
|
||||
|
||||
test('resolveLedgerPath — null when CLAUDE_PLUGIN_DATA unset or empty', () => {
|
||||
assert.equal(resolveLedgerPath({}), null);
|
||||
assert.equal(resolveLedgerPath({ CLAUDE_PLUGIN_DATA: '' }), null);
|
||||
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', () => {
|
||||
|
|
@ -224,6 +264,19 @@ test('CLI shim — denies and exits 1 when disabled', () => {
|
|||
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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue