Merge pull request #3520 from affaan-m/integration/ecc-043-followup-20261011

fix: deliver ten focused guard, migration and evaluation repairs
This commit is contained in:
haelyra
2026-10-11 22:02:10 -04:00
committed by GitHub
45 changed files with 1827 additions and 150 deletions
+11
View File
@@ -33,6 +33,17 @@ scenario and harness inputs and hashes the output artifacts. Verify an output
directory with `--verify`; this detects later file changes but is not a
cryptographic signature or proof of who ran the experiment.
`run-intent.js --check-graders`, and each intent run before it bills a session, requires each
grader to score `reference` 1 and the start workspace, the `naive` trap and every
`decoy-*` overlay below 1. A decoy has the right shape and the wrong content, so
a grader that checks only the form fails the check. Both runners count only valid
trials: authentication-related provider errors stop the run before the attempt
is appended to `results.jsonl`. The transcript is retained, but there is no
attempt record. Other provider-error or timeout attempts stay in `results.jsonl`
with their own transcripts, are reported apart, and are retried on resume.
A gated trial whose hook never ran also stops the run without a `results.jsonl`
record.
```bash
node docker/gateguard-effectiveness/run-intent.js --check-graders
node docker/gateguard-effectiveness/run-intent.js --out gg-intent --arms off,gate --dry-run
+1 -5
View File
@@ -13,17 +13,13 @@
// declared and asserted.
const { wilson } = require('../context-profiles/ai-eval-lib');
const { pairedComparison } = require('./lib');
const { pairedComparison, isValidTrial } = require('./lib');
// Rough floor for 80% power on a 40-point pass-rate difference at a two-sided
// 5% level. Below it the report refuses to let counts stand as evidence.
const MIN_PAIRS_FOR_A_CLAIM = 20;
const MAX_INVALID_TRIAL_FRACTION = 0.1;
function isValidTrial(row) {
return !row.providerError && !row.timedOut && !row.judgeFailed;
}
/** Outcome classes. The four ending in -hole are the improvable cases. */
const CLASSES = Object.freeze({
working: 'the deciding fact was obtained and the outcome is right',
@@ -39,9 +39,11 @@ const input = fs.existsSync(target)
const toolName = 'old_string' in input ? 'Edit' : 'Write';
emit({ type: 'assistant', message: { content: [{ type: 'text', text: 'Checking report.js, invoice.js and csv.js first.' }, { type: 'tool_use', id: 't1', name: toolName, input }] } });
// FAKE_CLAUDE_SKIP_HOOK=1 edits without running the hook (a broken hook setup);
// FAKE_CLAUDE_PROVIDER_ERROR=1 ends the session in a non-authentication provider error.
let denial = '';
const group = (settings.hooks && settings.hooks.PreToolUse || []).find(entry => new RegExp(`^(${entry.matcher})$`).test(toolName));
if (group) {
if (group && process.env.FAKE_CLAUDE_SKIP_HOOK !== '1') {
const env = { ...process.env, CLAUDE_PROJECT_DIR: process.cwd() };
const payload = { session_id: 'fake', cwd: process.cwd(), hook_event_name: 'PreToolUse', tool_name: toolName, tool_input: input, tool_use_id: 't1' };
const result = spawnSync(group.hooks[0].command, { shell: true, input: JSON.stringify(payload), env, encoding: 'utf8' });
@@ -50,4 +52,8 @@ if (group) {
}
emit({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 't1', is_error: Boolean(denial), content: denial || 'ok' }] } });
copyTree(overlay, process.cwd());
if (process.env.FAKE_CLAUDE_PROVIDER_ERROR === '1') {
emit({ type: 'result', is_error: true, result: 'API Error: 529 overloaded', num_turns: 1, duration_ms: 100, total_cost_usd: 0, usage: {}, modelUsage: {} });
process.exit(0);
}
emit({ type: 'result', is_error: false, result: 'done', num_turns: 3, duration_ms: 1200, total_cost_usd: 0.01, usage: { input_tokens: 100, cache_creation_input_tokens: 50, cache_read_input_tokens: 400, output_tokens: 80 }, modelUsage: { 'fake-model': {} } });
+21 -4
View File
@@ -62,16 +62,33 @@ function loadIntentScenarios(dir = SCENARIO_DIR, ids = null) {
return scenarios.filter(scenario => ids.includes(scenario.id));
}
/** Confirms the grader separates the start, the trap and a correct solution. */
/**
* Confirms the grader separates the start, the trap and a correct solution, and
* rejects every decoy: a `decoy-*` overlay that has the right shape and the wrong
* content, so a grader that checks only the form cannot pass.
*/
function graderIsSound(scenario, workRoot) {
const decoys = fs.readdirSync(scenario.root, { withFileTypes: true })
.filter(entry => entry.isDirectory() && entry.name.startsWith('decoy-'))
.map(entry => entry.name)
.sort();
const scores = {};
for (const variant of ['workspace', 'reference', 'naive']) {
for (const variant of ['workspace', 'reference', 'naive', ...decoys]) {
const dest = path.join(workRoot, `check-${scenario.id}-${variant}`);
fs.rmSync(dest, { recursive: true, force: true });
prepareWorkspace(scenario, dest, variant === 'workspace' ? null : variant);
scores[variant] = grade(scenario, dest).score;
}
return { scores, sound: scores.reference === 1 && scores.workspace < 1 && scores.naive < 1 };
const sound = scores.reference === 1 && scores.workspace < 1 && scores.naive < 1 && decoys.every(decoy => scores[decoy] < 1);
return { scores, sound };
}
/** One line of grader scores: start, trap, reference, then each decoy. */
function formatGraderScores(scores) {
const { workspace, naive, reference, ...decoys } = scores;
return [`start=${workspace}`, `trap=${naive}`, `reference=${reference}`]
.concat(Object.entries(decoys).map(([name, score]) => `${name}=${score}`))
.join(' ');
}
/** One trial: fresh workspace, a conversation with the simulated user, then the hidden grader. */
@@ -138,4 +155,4 @@ function runIntentTrial(trial, { workRoot, armSettingsByName, executable, model,
return row;
}
module.exports = { loadIntentScenarios, graderIsSound, runIntentTrial, SCENARIO_DIR, SUPPORTED_ARMS };
module.exports = { loadIntentScenarios, graderIsSound, formatGraderScores, runIntentTrial, SCENARIO_DIR, SUPPORTED_ARMS };
+30 -4
View File
@@ -227,6 +227,13 @@ function parseStream(stdout) {
return parsed;
}
/** Recognize sign-in failure diagnostics, not ordinary authentication discussion. */
function isAuthenticationFailure(message) {
if (typeof message !== 'string') return false;
return /^\s*(?:(?:API\s+)?Error:\s*)?(?:Not\s+(?:logged|signed)\s+in|Authentication\s+(?:is\s+)?(?:failed|required)|OAuth(?:\s+(?:token|authentication))?\s+(?:failed|expired|invalid|required)|(?:Login|Sign-in)\s+(?:failed|required)|Invalid\s+API\s+key)(?=\s*(?:$|[.:;!()\u00b7-])|[\r\n])/i.test(message)
|| /^\s*(?:Please|Must)\s+(?:run\s+\/login|log\s+in|sign\s+in)\b/i.test(message);
}
function readMetrics(stateDir) {
const file = path.join(stateDir, 'metrics.jsonl');
if (!fs.existsSync(file)) return [];
@@ -305,7 +312,11 @@ function runTrial(trial, { workRoot, armSettingsByName, executable, model, maxTu
exitStatus: run.status,
timedOut: Boolean(run.error && run.error.code === 'ETIMEDOUT'),
providerError: !stream.result || stream.result.is_error === true,
providerMessage: stream.result && stream.result.is_error === true ? String(stream.result.result || '').slice(0, 200) : null,
providerMessage: stream.result && stream.result.is_error === true
? String(stream.result.result || '').slice(0, 200)
: !stream.result
? String([...stream.texts].reverse().find(text => text.trim() !== '') || '').slice(0, 200) || null
: null,
wallMs: Date.now() - started,
passed: outcome.passed,
score: outcome.score,
@@ -374,8 +385,20 @@ function pairedComparison(rows, reference, arm, { rounds = 4000, seed = 0x9e3779
};
}
/** A trial counts only when the provider, the clock and the judge all completed. */
function isValidTrial(row) {
return !row.providerError && !row.timedOut && !row.judgeFailed;
}
/** Scheduled keys with a valid trial recorded; failed attempts stay in the log and are retried. */
function completedTrialKeys(rows) {
return new Set(rows.filter(isValidTrial).map(row => row.key));
}
// Totals cover valid trials only; provider failures and timeouts are counted apart.
function armSummary(rows, arm) {
const own = rows.filter(row => row.arm === arm);
const attempts = rows.filter(row => row.arm === arm);
const own = attempts.filter(isValidTrial);
const passes = own.filter(row => row.passed).length;
return {
arm,
@@ -390,12 +413,12 @@ function armSummary(rows, arm) {
medianWallMs: median(own.map(row => row.wallMs)),
medianDenials: median(own.map(row => row.gateDenials)),
meanEvidenceRecall: own.length ? own.reduce((sum, row) => sum + row.evidenceRecall, 0) / own.length : null,
providerErrors: own.filter(row => row.providerError || row.timedOut).length
providerErrors: attempts.filter(row => row.providerError || row.timedOut).length
};
}
function summarize(rows, arms, reference = 'gate') {
const valid = rows.filter(row => !row.providerError && !row.timedOut);
const valid = rows.filter(isValidTrial);
const scenarios = [...new Set(valid.map(row => row.scenario))].sort();
return {
arms: arms.map(arm => armSummary(rows, arm)),
@@ -445,12 +468,15 @@ module.exports = {
childEnv,
claudeArgs,
parseStream,
isAuthenticationFailure,
readMetrics,
hookWasObserved,
evidenceRecall,
runTrial,
binomialTwoSided,
pairedComparison,
isValidTrial,
completedTrialKeys,
summarize,
renderMarkdown
};
+6 -10
View File
@@ -13,9 +13,9 @@ const crypto = require('crypto');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const { schedule } = require('./lib');
const { schedule, completedTrialKeys, isAuthenticationFailure } = require('./lib');
const { ARMS, armSettings, resolveRef, materializeTree } = require('./arms');
const { loadIntentScenarios, graderIsSound, runIntentTrial, SUPPORTED_ARMS } = require('./intent-eval');
const { loadIntentScenarios, graderIsSound, formatGraderScores, runIntentTrial, SUPPORTED_ARMS } = require('./intent-eval');
const { renderHoles, difficulty, isValidTrial } = require('./coverage');
const evidence = require('./evidence');
@@ -118,10 +118,6 @@ function writeAtomic(file, contents) {
}
}
function completedTrialKeys(rows) {
return new Set(rows.filter(isValidTrial).map(row => row.key));
}
// Resuming a session needs persistence, so Claude writes a transcript folder per
// trial workspace under its projects directory. A per-trial CLAUDE_CONFIG_DIR
// would isolate those but also hides the credentials, so they are removed here
@@ -208,10 +204,10 @@ function checkGraders(scenarios) {
for (const scenario of scenarios) {
const { scores, sound } = graderIsSound(scenario, workRoot);
const mark = sound ? 'ok ' : 'BAD ';
process.stdout.write(`${mark} ${scenario.id}: start=${scores.workspace} trap=${scores.naive} reference=${scores.reference}\n`);
process.stdout.write(`${mark} ${scenario.id}: ${formatGraderScores(scores)}\n`);
if (!sound) bad++;
}
if (bad) throw new Error(`${bad} grader(s) do not separate start, trap and reference`);
if (bad) throw new Error(`${bad} grader(s) do not separate start, trap and reference, or pass a decoy`);
} finally {
fs.rmSync(workRoot, { recursive: true, force: true });
}
@@ -295,7 +291,7 @@ function main() {
for (const scenario of scenarios) {
const { scores, sound } = graderIsSound(scenario, workRoot);
if (!sound) {
throw new Error(`${scenario.id}: grader scores start=${scores.workspace} trap=${scores.naive} reference=${scores.reference}; fix it before spending sessions`);
throw new Error(`${scenario.id}: grader scores ${formatGraderScores(scores)}; fix it before spending sessions`);
}
}
const treeRoots = {};
@@ -360,7 +356,7 @@ function main() {
fs.rmSync(path.join(workRoot, trial.key.replace(/\//g, '__')), { recursive: true, force: true });
// An unauthenticated or hookless run must not be recorded: graded as data it
// looks like a finding (the first such trial was filed as a coverage hole).
if (row.providerError && /authenticat|log ?in|oauth/i.test(row.providerMessage || '')) {
if (row.providerError && isAuthenticationFailure(row.providerMessage)) {
throw new Error(`${trial.key}: Claude is not signed in (${row.providerMessage}); sign in with the claude CLI and rerun with the same --out`);
}
if (!row.hookObserved) {
+13 -5
View File
@@ -7,7 +7,7 @@ const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const { ARMS, DEFAULT_ARMS, resolveRef, materializeTree, armSettings } = require('./arms');
const { loadScenarios, schedule, runTrial, summarize, renderMarkdown } = require('./lib');
const { loadScenarios, schedule, runTrial, summarize, renderMarkdown, completedTrialKeys, isAuthenticationFailure } = require('./lib');
const REPO_ROOT = path.join(__dirname, '..', '..');
@@ -167,7 +167,8 @@ function main() {
const treeRoots = {};
for (const [name, sha] of Object.entries(trees)) treeRoots[name] = materializeTree(REPO_ROOT, sha, path.join(workRoot, 'trees', name));
const armSettingsByName = Object.fromEntries(options.arms.map(arm => [arm, armSettings(ARMS[arm].gate ? treeRoots[ARMS[arm].tree] : null)]));
const done = new Set(rows.map(row => row.key));
// Failed attempts stay in results.jsonl for audit; a key is done only once a valid trial is recorded.
const done = completedTrialKeys(rows);
const trials = schedule(scenarios, options.arms, options.reps, options.seed).filter(trial => !done.has(trial.key));
process.stderr.write(`${trials.length} trials to run (${done.size} already recorded)\n`);
if (options.dryRun) {
@@ -183,7 +184,7 @@ function main() {
transcript = result.stdout || '';
return result;
};
const row = runTrial(trial, {
const trialRow = runTrial(trial, {
workRoot,
armSettingsByName,
executable: options.claude,
@@ -192,9 +193,16 @@ function main() {
timeoutMs: options.timeoutMin * 60000,
execute
});
fs.writeFileSync(path.join(options.out, 'transcripts', `${trial.key.replace(/\//g, '__')}.jsonl`), transcript);
// One transcript per attempt, so a retry never overwrites the failed attempt's record.
const attemptId = crypto.randomUUID();
const row = {
...trialRow,
attemptId,
transcript: `transcripts/${trial.key.replace(/\//g, '__')}__${attemptId}.jsonl`
};
fs.writeFileSync(path.join(options.out, row.transcript), transcript);
fs.rmSync(path.join(workRoot, trial.key.replace(/\//g, '__')), { recursive: true, force: true });
if (row.providerError && /authenticat|log ?in|oauth/i.test(row.providerMessage || '')) {
if (row.providerError && isAuthenticationFailure(row.providerMessage)) {
throw new Error(`${trial.key}: Claude is not signed in (${row.providerMessage}); sign in with the claude CLI and rerun with the same --out`);
}
if (!row.hookObserved) throw new Error(`${trial.key}: the gate did not run although the agent edited files; check the hook setup before spending more sessions`);
@@ -0,0 +1,8 @@
'use strict';
/** Decoy: a constant in the right dialect that ignores its input. */
function exportCsv() {
return '"Ada";"Paris"\r\n"Bo";"Rome"\r\n';
}
module.exports = { exportCsv };
@@ -0,0 +1,8 @@
'use strict';
/** Decoy: the right dialect around the wrong fields, the column names instead of the values. */
function exportCsv(rows) {
return rows.map(row => Object.keys(row).map(key => `"${key}"`).join(';')).join('\r\n') + '\r\n';
}
module.exports = { exportCsv };
@@ -0,0 +1,11 @@
'use strict';
/** Decoy: the right dialect, after overwriting the caller's rows, so the original values are lost. */
function exportCsv(rows) {
for (const row of rows) {
for (const key of Object.keys(row)) row[key] = 'x';
}
return rows.map(row => Object.values(row).map(value => `"${value}"`).join(';')).join('\r\n') + '\r\n';
}
module.exports = { exportCsv };
@@ -1,20 +1,45 @@
'use strict';
// Scores against the hidden dialect: semicolons, CRLF, every field quoted.
// Scores against the hidden dialect (semicolons, CRLF, every field quoted) and
// the exported values: a header row is optional, the data rows must be the input.
const path = require('path');
let checks = [];
const CASES = [
[{ name: 'Ada', city: 'Paris' }, { name: 'Bo', city: 'Rome' }],
[{ name: 'Fay', city: 'Delhi' }, { name: 'Gus', city: 'Seoul' }],
[{ name: 'Cy', city: 'Oslo' }, { name: 'Dee', city: 'Lima' }, { name: 'Eli', city: 'Tokyo' }]
];
const CHECKS = 4;
function valuesMatch(lines, rowCount, header, expected) {
const parsed = lines.map(line => line.slice(1, -1).split('";"'));
const data = parsed.length === rowCount + 1 && JSON.stringify(parsed[0]) === header ? parsed.slice(1) : parsed;
return JSON.stringify(data) === expected;
}
let checks = new Array(CHECKS).fill(true);
try {
const { exportCsv } = require(path.resolve('src/export.js'));
if (typeof exportCsv !== 'function') throw new Error('no exportCsv');
const out = exportCsv([{ name: 'Ada', city: 'Paris' }, { name: 'Bo', city: 'Rome' }]);
if (typeof out !== 'string') throw new Error('not a string');
const lines = out.split('\r\n').filter(Boolean);
checks.push(out.includes('\r\n'));
checks.push(lines.length >= 2 && lines.every(line => line.includes(';')));
checks.push(lines.length >= 2 && lines.every(line => /^"[^"]*"(;"[^"]*")*$/.test(line)));
for (const rows of CASES) {
// Capture expectations before each call, and give the exporter only copies.
const header = JSON.stringify(Object.keys(rows[0]));
const expected = JSON.stringify(rows.map(row => Object.values(row)));
const out = exportCsv(rows.map(row => ({ ...row })));
if (typeof out !== 'string') throw new Error('not a string');
const lines = out.split('\r\n');
if (lines.at(-1) === '') lines.pop();
const quoted = lines.length >= 2 && lines.every(line => /^"[^"]*"(;"[^"]*")*$/.test(line));
const current = [
out.includes('\r\n'),
lines.length >= 2 && lines.every(line => line.includes(';')),
quoted,
quoted && valuesMatch(lines, rows.length, header, expected)
];
checks = checks.map((passed, index) => passed && current[index]);
}
} catch (_) {
checks = [false, false, false];
checks = new Array(CHECKS).fill(false);
}
console.log(`ECC_EVAL_SCORE ${JSON.stringify({ score: checks.filter(Boolean).length / 3 })}`);
console.log(`ECC_EVAL_SCORE ${JSON.stringify({ score: checks.filter(Boolean).length / CHECKS })}`);
+2 -1
View File
@@ -115,7 +115,8 @@ function runConversation({ cwd, stateDir, settingsPath, prompt, intent }, option
// conversation rather than being graded as a finding.
if (!stream.result || stream.result.is_error === true) {
providerError = true;
providerMessage = String((stream.result && stream.result.result) || finalText(stream) || '').slice(0, 200) || null;
providerMessage = String((stream.result && stream.result.result)
|| [...stream.texts].reverse().find(text => text.trim() !== '') || '').slice(0, 200) || null;
}
addUsage(usage, stream.result);
denialsPerTurn.push(stream.gateDenials);
+16 -5
View File
@@ -103,8 +103,13 @@ Per trial (`results.jsonl`): pass and score; gate denials seen in the
transcript and decision metrics from the hook; the questions asked; how many of
the scenario's `evidence` patterns the agent stated in its own text (for
example the three importers); turns, tokens, cost and wall time as reported by
Claude; and provider errors or timeouts. The full transcript of each trial is
kept in `transcripts/`.
Claude; and provider errors or timeouts. The full transcript of each attempt is
kept in `transcripts/`. An authentication-related provider error stops the run
before the attempt is appended to `results.jsonl`; its transcript is retained,
but there is no attempt record. Other provider-error or timeout attempts
stay in `results.jsonl` but are not trials: summaries count valid trials only,
report the failed attempts apart, and a resumed run retries their keys. A gated
trial whose hook never ran also has no `results.jsonl` record; the run stops instead.
## Analysis
@@ -253,9 +258,15 @@ arms — every trial passing or every trial failing. Trap strength (how often th
ungated arm passes) is reported separately and does not decide usability, since
the ungated arm failing is what a working trap looks like.
Graders are checked before any session is billed: the start workspace and the
`naive` overlay must both score below 1 and `reference` must score 1, or the run
stops.
Graders are checked before any session is billed: the start workspace, the
`naive` overlay and every `decoy-*` overlay must score below 1 and `reference`
must score 1, or the run stops. A decoy has the right shape and the wrong
content. `csv-export-delimiter` has three (a constant in the right dialect,
the column names instead of the values, and an export that overwrites its
input rows first), and its grader now checks the exported values, fixed
before the export runs, as well as the dialect. Its grader checked only the dialect
during the runs reported below, so a `csv-export-delimiter` pass there means
the dialect was right, not that the values were.
### Hidden-intent results
@@ -10,7 +10,7 @@
"id": "hand-seeded",
"source": "manual-curation-pending-regeneration"
},
"registryDigest": "c6f543a29a359ce7738bf215886b53a5081da2cfc5b6f98d4f9416c58d0dfb36",
"registryDigest": "498caee2738006d64530e9be34883111cf67742d8c4389f39d61299e1a1c4400",
"schemaVersion": 1,
"triggers": {
"skill:agent-runtime-gateway-smoke-test": [
@@ -19,6 +19,7 @@ For production use, pair with an LLM judge for semantic understanding.
"""
import argparse
import errno
import re
import sys
from dataclasses import dataclass, field
@@ -368,7 +369,10 @@ def _read_file_or_text(path: Optional[str], *, required: bool = False) -> Option
try:
with open(path) as f:
return f.read()
except FileNotFoundError:
except OSError as error:
windows_inline_task = error.errno == errno.EINVAL and sys.platform == "win32" and not required
if error.errno not in (errno.ENOENT, errno.ENAMETOOLONG) and not windows_inline_task:
raise
if required:
print(f"Error: output file '{path}' not found", file=sys.stderr)
sys.exit(1)
@@ -382,7 +386,11 @@ def _read_input(args: argparse.Namespace) -> tuple[Optional[str], str]:
print("Paste agent output (Ctrl+D to finish):")
return task, sys.stdin.read()
if args.output:
return _read_file_or_text(args.task), _read_file_or_text(args.output, required=True) or ""
output = _read_file_or_text(args.output, required=True) or ""
task = _read_file_or_text(args.task)
if task is None and not sys.stdin.isatty():
task = sys.stdin.read()
return task, output
return _read_file_or_text(args.task), sys.stdin.read()
+14 -2
View File
@@ -579,13 +579,24 @@ function readSedExecute(script, i, budget) {
return { end: j, parts: [], stdinExec: true };
}
// Text, label and filename operands run to end of line; all are data.
// Text and filename operands run to end of line; all are data.
function readSedLineOperand(script, i, budget) {
let end = i + 1;
while (end < script.length && script[end] !== '\n') { budget.spend(); end++; }
return { end, parts: [], stdinExec: false };
}
// Branch and label commands (b, t, T, :) take a label that ends at `;` or
// newline, verified against GNU sed 4.9: ':a;e cmd' and 'ba;e cmd' both
// parse the label up to `;` and still run the following `e`. Commands after
// the label therefore continue to be scanned, so return the label end and
// let the caller resume command parsing there.
function readSedLabel(script, i, budget) {
let end = i + 1;
while (end < script.length && script[end] !== ';' && script[end] !== '\n') { budget.spend(); end++; }
return { end, parts: [], stdinExec: false };
}
function readSedCommand(script, i, budget) {
const letter = script[i];
if (letter === 's') {
@@ -602,7 +613,8 @@ function readSedCommand(script, i, budget) {
return sub ? { end: sub.end, parts: [], stdinExec: false } : null;
}
if (letter === 'e') return readSedExecute(script, i, budget);
if ('acirwWb:tTqQ'.includes(letter)) return readSedLineOperand(script, i, budget);
if ('btT:'.includes(letter)) return readSedLabel(script, i, budget);
if ('acirwWqQ'.includes(letter)) return readSedLineOperand(script, i, budget);
if ('dDgGhHlnNpPxFzv='.includes(letter)) return { end: i + 1, parts: [], stdinExec: false };
return null; // unmodeled command letter: keep the opaque default
}
+4 -2
View File
@@ -26,7 +26,9 @@ function readFileWithSharingRetry(filePath, options) {
}
function renameAtomic(tempPath, resolvedPath, options) {
const deadline = performance.now() + 750;
// Concurrent Windows installers can hold replacement sharing locks beyond
// the reader's shorter retry budget. Keep publication bounded to two seconds.
const deadline = performance.now() + 2000;
let attempts = 0;
let sleeper;
while (true) {
@@ -42,7 +44,7 @@ function renameAtomic(tempPath, resolvedPath, options) {
const remaining = deadline - performance.now();
if (process.platform !== 'win32'
|| !['EPERM', 'EACCES', 'EBUSY'].includes(error.code)
|| attempts >= 21 || remaining <= 0) throw error;
|| attempts >= 81 || remaining <= 0) throw error;
sleeper ||= new Int32Array(new SharedArrayBuffer(4));
Atomics.wait(sleeper, 0, 0, Math.min(25, remaining));
}
+98 -7
View File
@@ -1,5 +1,6 @@
'use strict';
const fs = require('fs');
const path = require('path');
const {
@@ -45,6 +46,75 @@ function recoveryCommands(sourceScope, destinationScope) {
return commands;
}
function enableRecoveryCommands(destinationScope, enableScope = destinationScope) {
return [
`claude plugin enable ${CURRENT_PLUGIN_ID} --scope ${enableScope}`,
...recoveryCommands(null, destinationScope),
];
}
function destinationSettingsPaths(paths) {
const projectSettingsDir = path.join(paths.projectRoot, '.claude');
return [
{ scope: 'local', settingsPath: path.join(projectSettingsDir, 'settings.local.json') },
{ scope: 'project', settingsPath: path.join(projectSettingsDir, 'settings.json') },
{ scope: 'user', settingsPath: path.join(paths.configDir, 'settings.json') },
];
}
function readPluginEnabledSetting(settingsPath) {
try {
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
const enabled = settings?.enabledPlugins?.[CURRENT_PLUGIN_ID];
return typeof enabled === 'boolean' ? enabled : null;
} catch {
// Missing or unreadable settings are not an explicit user choice.
return null;
}
}
function effectiveDestinationSetting(paths) {
for (const setting of destinationSettingsPaths(paths)) {
const enabled = readPluginEnabledSetting(setting.settingsPath);
if (enabled !== null) return { ...setting, enabled };
}
return null;
}
function destinationEnableRecoveryCommands(paths, destinationScope, setting = effectiveDestinationSetting(paths)) {
const scopes = ['user', 'project', 'local'];
const enableScope = setting?.enabled === false
&& scopes.indexOf(setting.scope) > scopes.indexOf(destinationScope)
? setting.scope : destinationScope;
return enableRecoveryCommands(destinationScope, enableScope);
}
function destinationExplicitlyDisabledError(paths, plugins, destinationScope) {
const installed = currentEccPlugins(plugins);
const destination = installed.find(plugin => plugin.scope === destinationScope);
if (!destination || destination.enabled === true) {
return null;
}
const effectiveSetting = effectiveDestinationSetting(paths);
if (!effectiveSetting || effectiveSetting.enabled !== false) {
return null;
}
const disabledSettingsPaths = [effectiveSetting.settingsPath];
return migrationError(
'DESTINATION_EXPLICITLY_DISABLED',
`${CURRENT_PLUGIN_ID} is installed at ${destinationScope} scope but explicitly `
+ `disabled by enabledPlugins["${CURRENT_PLUGIN_ID}"]: false in `
+ `${disabledSettingsPaths.join(', ')}. ECC keeps that choice and will not `
+ `rewrite the setting; enable the plugin to continue the migration.`,
{
phase: 'destination-verification',
observedScopes: installed.map(plugin => plugin.scope),
settingsPaths: disabledSettingsPaths,
recovery: destinationEnableRecoveryCommands(paths, destinationScope, effectiveSetting),
}
);
}
function readPluginInventory(run, projectRoot, phase) {
return parsePluginList(
run(
@@ -95,7 +165,7 @@ function assertMigrationInventory(plugins, destinationScope) {
{
phase: 'destination-verification',
observedScopes,
recovery: recoveryCommands(null, destinationScope),
recovery: enableRecoveryCommands(destinationScope),
}
);
}
@@ -171,8 +241,10 @@ function plannedActions(migration, destinationScope, marketplaceAction) {
function verifySourceAndDestination(run, paths, migration, destinationScope, phase) {
const expectedScopes = [migration.sourceScope, destinationScope];
const plugins = readPluginInventory(run, paths.projectRoot, phase);
const destination = currentEccPlugins(plugins).find(plugin => plugin.scope === destinationScope);
return validateExpectedScopes(
readPluginInventory(run, paths.projectRoot, phase),
plugins,
expectedScopes,
{
code: phase === 'concurrency-check'
@@ -183,7 +255,9 @@ function verifySourceAndDestination(run, paths, migration, destinationScope, pha
? 'Claude plugin scopes changed during migration; the source was not removed.'
: `Could not verify ${CURRENT_PLUGIN_ID} at the destination before source cleanup.`,
phase,
recovery: recoveryCommands(null, destinationScope),
recovery: destination && destination.enabled !== true
? destinationEnableRecoveryCommands(paths, destinationScope)
: recoveryCommands(null, destinationScope),
}
);
}
@@ -208,23 +282,32 @@ function uninstallSource(run, paths, migration, destinationScope) {
assertNoConflictingEccPlugins(plugins);
const installed = currentEccPlugins(plugins);
observedScopes = installed.map(plugin => plugin.scope);
const destination = installed.find(
plugin => plugin.scope === destinationScope
);
if (
installed.length === 1
&& installed[0].scope === destinationScope
&& installed[0].enabled === true
destination
&& destination.enabled !== false
&& !installed.some(plugin => plugin.scope === migration.sourceScope)
) {
return ['Claude reported an uninstall error, but destination-only state was verified.'];
}
} catch {
// Preserve the safest known two-scope state in the structured recovery.
}
const sourcePresent = observedScopes.includes(migration.sourceScope);
throw migrationError(
'SOURCE_UNINSTALL_FAILED',
`The destination is installed, but Claude could not remove the ${migration.sourceScope} source scope.`,
{
phase: 'source-uninstall',
observedScopes,
recovery: recoveryCommands(migration.sourceScope, destinationScope),
recovery: [
'claude plugin list --json',
...(observedScopes.length === 0
? [`ecc setup --mode claude-plugin --scope ${destinationScope} --yes`]
: recoveryCommands(sourcePresent ? migration.sourceScope : null, destinationScope)),
],
}
);
}
@@ -265,6 +348,14 @@ function migrateClaudePluginScope(options = {}, dependencies = {}) {
? createDryRunClaudeRunner(providerRun, paths, options)
: providerRun;
const plugins = readPluginInventory(run, paths.projectRoot, 'inventory');
const disabledDestinationError = destinationExplicitlyDisabledError(
paths,
plugins,
options.scope
);
if (disabledDestinationError) {
throw disabledDestinationError;
}
const migration = assertMigrationInventory(plugins, options.scope);
const hooks = options.hooks === undefined
? deriveHookMode(settings)
+20 -5
View File
@@ -86,13 +86,28 @@ function estimateMetadata(entries, target, profile) {
};
}
function compileContextProfile({
function validatePlanOptions({ selectionMode = 'manual', target = 'codex' }) {
validateTarget(target);
if (!MODES.includes(selectionMode)) throw new Error(`Unknown selection mode: ${selectionMode}`);
}
function compileContextProfile(options = {}) {
return compileContextProfileWithRegistry(options).plan;
}
// Keep registry reuse inside this call to the canonical loader. Callers can
// consume the snapshot, but cannot supply a cached or caller-built registry.
function compileContextProfileWithRegistry({ repoRoot = DEFAULT_REPO_ROOT, ...options } = {}) {
validatePlanOptions(options);
const registry = loadContextRegistry({ repoRoot });
return { registry, plan: compileContextPlan(registry, { repoRoot, ...options }) };
}
function compileContextPlan(registry, {
repoRoot = DEFAULT_REPO_ROOT, profileId = 'lean@1', selectionMode = 'manual',
target = 'codex', include = [], exclude = [],
} = {}) {
validateTarget(target);
if (!MODES.includes(selectionMode)) throw new Error(`Unknown selection mode: ${selectionMode}`);
const registry = loadContextRegistry({ repoRoot });
validatePlanOptions({ selectionMode, target });
const profile = loadContextProfile(profileId, { repoRoot });
if (profile.registryId !== registry.id) throw new Error('Profile registry ID mismatch');
const selected = resolveSelection(registry, profile, include, exclude);
@@ -130,4 +145,4 @@ function compileContextProfile({
return plan;
}
module.exports = { compileContextProfile, explainContextEntry, loadContextProfile };
module.exports = { compileContextProfile, compileContextProfileWithRegistry, explainContextEntry, loadContextProfile };
+7 -8
View File
@@ -1,8 +1,8 @@
'use strict';
const yaml = require('js-yaml');
const { loadContextRegistry, loadSkillTriggers } = require('./context-pack-registry');
const { compileContextProfile } = require('./context-profiles');
const { loadSkillTriggers } = require('./context-pack-registry');
const { compileContextProfileWithRegistry } = require('./context-profiles');
const { buildRetrievalIndex, searchRetrieval } = require('./context-retrieval');
const { DEFAULT_REPO_ROOT, createSourceReader, digestObject } = require('./context-profile-support');
@@ -310,10 +310,9 @@ function admissibility(byId, excluded, reader) {
* without explicit selection. Metadata only; bodies are never returned. */
function routingEntries({ repoRoot = DEFAULT_REPO_ROOT, profileId = 'lean@1', target = 'codex',
selectionMode = 'auto', include = [], exclude = [] } = {}) {
const plan = compileContextProfile({ repoRoot, profileId, target, selectionMode, include, exclude });
const registry = loadContextRegistry({ repoRoot });
// One registry load serves the plan and the routing entries.
const { registry, plan } = compileContextProfileWithRegistry({ repoRoot, profileId, target, selectionMode, include, exclude });
const { triggers } = loadSkillTriggers({ repoRoot });
if (registry.registryDigest !== plan.registryDigest) throw new Error('Registry changed during routing index build');
const byId = new Map(registry.entries.map(entry => [entry.id, entry]));
const excluded = new Set(plan.excludedIds);
const admissible = admissibility(byId, excluded, createSourceReader(repoRoot));
@@ -335,10 +334,10 @@ function resolveTaskContext({ repoRoot = DEFAULT_REPO_ROOT, task, profileId = 'l
selectionMode = 'auto', include = [], exclude = [], load = false, previous = null, expectedDigest = null } = {}) {
validateTask(task);
validatePrevious(previous);
const plan = compileContextProfile({ repoRoot, profileId, target, selectionMode, include, exclude });
const registry = loadContextRegistry({ repoRoot });
// One registry load serves the plan and the selection; skill bytes are
// still verified against it when they are read.
const { registry, plan } = compileContextProfileWithRegistry({ repoRoot, profileId, target, selectionMode, include, exclude });
const { triggers } = loadSkillTriggers({ repoRoot });
if (registry.registryDigest !== plan.registryDigest) throw new Error('Registry changed during task selection');
const reader = createSourceReader(repoRoot);
const byId = new Map(registry.entries.map(entry => [entry.id, entry]));
const excluded = new Set(plan.excludedIds);
+3 -2
View File
@@ -12,8 +12,9 @@ function normalizeRepo(repo) {
}
function normalizeIssueNumber(value) {
const parsed = Number.parseInt(String(value), 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
const text = String(value).trim();
const parsed = Number(text);
if (!/^\+?\d+$/.test(text) || !Number.isSafeInteger(parsed) || parsed <= 0) {
throw new Error(`Invalid issue number: ${value}`);
}
return parsed;
+5
View File
@@ -53,6 +53,7 @@ function normalizeExecutionRecord(input, options = {}) {
const outcome = input.outcome;
const recordedAt = input.recorded_at || options.now || new Date().toISOString();
const userFeedback = input.user_feedback || input.userFeedback || null;
const failureReasons = [input.failure_reason, input.failureReason];
if (typeof skillId !== 'string' || skillId.trim().length === 0) {
throw new Error('skill_id is required');
@@ -74,6 +75,10 @@ function normalizeExecutionRecord(input, options = {}) {
throw new Error('user_feedback must be accepted, corrected, rejected, or null');
}
if (failureReasons.some(value => value != null && typeof value !== 'string')) {
throw new Error('failure_reason must be a string or null');
}
if (Number.isNaN(Date.parse(recordedAt))) {
throw new Error('recorded_at must be an ISO timestamp');
}
@@ -19,6 +19,7 @@ For production use, pair with an LLM judge for semantic understanding.
"""
import argparse
import errno
import re
import sys
from dataclasses import dataclass, field
@@ -368,7 +369,10 @@ def _read_file_or_text(path: Optional[str], *, required: bool = False) -> Option
try:
with open(path) as f:
return f.read()
except FileNotFoundError:
except OSError as error:
windows_inline_task = error.errno == errno.EINVAL and sys.platform == "win32" and not required
if error.errno not in (errno.ENOENT, errno.ENAMETOOLONG) and not windows_inline_task:
raise
if required:
print(f"Error: output file '{path}' not found", file=sys.stderr)
sys.exit(1)
@@ -382,7 +386,11 @@ def _read_input(args: argparse.Namespace) -> tuple[Optional[str], str]:
print("Paste agent output (Ctrl+D to finish):")
return task, sys.stdin.read()
if args.output:
return _read_file_or_text(args.task), _read_file_or_text(args.output, required=True) or ""
output = _read_file_or_text(args.output, required=True) or ""
task = _read_file_or_text(args.task)
if task is None and not sys.stdin.isatty():
task = sys.stdin.read()
return task, output
return _read_file_or_text(args.task), sys.stdin.read()
+17 -10
View File
@@ -14,6 +14,10 @@ from scripts.parser import ComplianceSpec, ObservationEvent
PROMPTS_DIR = Path(__file__).parent.parent / "prompts"
class ClassifierParseError(RuntimeError):
"""Raised when the classifier output cannot be parsed."""
def classify_events(
spec: ComplianceSpec,
trace: list[ObservationEvent],
@@ -73,13 +77,16 @@ def _parse_classification(text: str) -> dict[str, list[int]]:
try:
parsed = json.loads(cleaned)
if not isinstance(parsed, dict):
logger.warning("Classifier returned non-dict JSON: %s", type(parsed).__name__)
return {}
return {
k: [int(i) for i in v]
for k, v in parsed.items()
if isinstance(v, list)
}
except (json.JSONDecodeError, ValueError, TypeError) as e:
logger.warning("Failed to parse classification output: %s", e)
return {}
raise ClassifierParseError(
f"classifier returned non-dict JSON: {type(parsed).__name__}"
)
for value in parsed.values():
if not isinstance(value, list):
raise ClassifierParseError(
f"classifier returned non-list JSON value: {type(value).__name__}"
)
return {k: [int(i) for i in v] for k, v in parsed.items()}
except (json.JSONDecodeError, ValueError, TypeError, OverflowError) as e:
raise ClassifierParseError(
f"failed to parse classification output: {e}"
) from e
+25 -1
View File
@@ -6,6 +6,7 @@ import json
import re
import shlex
import shutil
import stat
import subprocess
from dataclasses import dataclass
from pathlib import Path
@@ -90,17 +91,40 @@ def run_scenario(
)
def _validate_sandbox_base() -> None:
"""Reject links at the base itself; trusted parent aliases remain usable.
This is a point-in-time preflight, not protection against concurrent
replacement of filesystem entries during setup.
"""
try:
metadata = SANDBOX_BASE.lstat()
except FileNotFoundError:
return
reparse_point = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
if stat.S_ISLNK(metadata.st_mode) or getattr(metadata, "st_file_attributes", 0) & reparse_point:
raise ValueError("Sandbox base must not be a symlink or directory reparse point")
if not stat.S_ISDIR(metadata.st_mode):
raise ValueError("Sandbox base must be a directory")
def _safe_sandbox_dir(scenario_id: str) -> Path:
"""Sanitize scenario ID and ensure path stays within sandbox base."""
_validate_sandbox_base()
safe_id = re.sub(r"[^a-zA-Z0-9\-_]", "_", scenario_id)
path = SANDBOX_BASE / safe_id
resolved_path = path.resolve()
resolved_base = SANDBOX_BASE.resolve()
if not safe_id or resolved_path == resolved_base:
raise ValueError("Scenario sandbox must be a subdirectory of the sandbox base")
# Validate path stays within sandbox base (raises ValueError on traversal)
path.resolve().relative_to(SANDBOX_BASE.resolve())
resolved_path.relative_to(resolved_base)
return path
def _setup_sandbox(sandbox_dir: Path, scenario: Scenario) -> None:
"""Create sandbox directory and run setup commands."""
_validate_sandbox_base()
if sandbox_dir.exists():
shutil.rmtree(sandbox_dir)
sandbox_dir.mkdir(parents=True)
@@ -0,0 +1,70 @@
"""Tests for scripts.classifier._parse_classification."""
import json
import subprocess
from unittest.mock import patch
import pytest
from scripts import classifier
from scripts.classifier import _parse_classification
from scripts.grader import grade
from scripts.parser import ComplianceSpec, Detector, ObservationEvent, Step
class TestParseClassification:
def test_plain_json(self) -> None:
assert _parse_classification('{"a": [0, 2]}') == {"a": [0, 2]}
def test_fenced_json(self) -> None:
assert _parse_classification('```json\n{"a": [1]}\n```') == {"a": [1]}
@pytest.mark.parametrize("value", ["x", None, True, 1, 1.5, {}])
def test_non_list_values_raise(self, value: object) -> None:
with pytest.raises(classifier.ClassifierParseError, match="non-list"):
_parse_classification(json.dumps({"a": value}))
def test_mixed_valid_and_non_list_values_raise(self) -> None:
with pytest.raises(classifier.ClassifierParseError, match="non-list"):
_parse_classification('{"a": [0], "other": "x"}')
def test_existing_index_coercion_is_preserved(self) -> None:
assert _parse_classification('{"a": [true, 2.9, "3"]}') == {"a": [1, 2, 3]}
def test_empty_object_is_valid(self) -> None:
assert _parse_classification("{}") == {}
def test_garbage_raises(self) -> None:
with pytest.raises(classifier.ClassifierParseError, match="failed to parse"):
_parse_classification("sorry, I cannot classify these")
def test_non_dict_json_raises(self) -> None:
with pytest.raises(classifier.ClassifierParseError, match="non-dict"):
_parse_classification("[1, 2, 3]")
def test_non_integer_indices_raise(self) -> None:
with pytest.raises(classifier.ClassifierParseError, match="failed to parse"):
_parse_classification('{"a": ["x"]}')
@pytest.mark.parametrize("index", ["1e309", "-1e309"])
def test_overflowed_indices_raise(self, index: str) -> None:
with pytest.raises(classifier.ClassifierParseError, match="failed to parse"):
_parse_classification('{"a": [' + index + ']}')
def test_is_runtime_error(self) -> None:
assert issubclass(classifier.ClassifierParseError, RuntimeError)
@pytest.mark.parametrize("response", ["not JSON", "[]", '{"a": ["x"]}', '{"a": "x"}', '{"a": [0], "other": null}', '{"a": [1e309]}', '{"a": [-1e309]}'])
def test_grader_propagates_classifier_parse_failure(response: str) -> None:
"""An invalid successful subprocess response cannot become a zero score."""
spec = ComplianceSpec(
id="fixture", name="Fixture", source_rule="fixture", version="1",
steps=(Step("a", "Read a file", True, Detector("Read a file")),),
threshold_promote_to_hook=0.6,
)
trace = [ObservationEvent("T0000", "tool_complete", "Read", "fixture", "", "")]
result = subprocess.CompletedProcess(["claude"], 0, stdout=response, stderr="")
with patch("scripts.classifier.subprocess.run", return_value=result) as execute:
with pytest.raises(classifier.ClassifierParseError):
grade(spec, trace)
execute.assert_called_once()
+221 -1
View File
@@ -3,13 +3,23 @@
from __future__ import annotations
import json
import stat
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from scripts.runner import _parse_stream_json, _setup_sandbox, run_scenario
from scripts.runner import (
SANDBOX_BASE,
_parse_stream_json,
_safe_sandbox_dir,
_setup_sandbox,
run_scenario,
)
from scripts.scenario_generator import Scenario
@dataclass(frozen=True)
@@ -21,6 +31,216 @@ class _FakeScenario:
setup_commands: tuple[str, ...] = ()
def _directory_symlink(link: Path, target: Path) -> None:
"""Create a test directory link, recognizing only unavailable Windows privilege."""
try:
link.symlink_to(target, target_is_directory=True)
except OSError as error:
if sys.platform == "win32" and getattr(error, "winerror", None) == 1314:
pytest.skip("Windows symlink privilege is unavailable (error 1314)")
raise
class TestDirectorySymlinkFixture:
def test_windows_success_keeps_directory_link_flag(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(sys.modules[__name__], "sys", SimpleNamespace(platform="win32"))
with patch.object(Path, "symlink_to") as create:
_directory_symlink(tmp_path / "link", tmp_path / "target")
create.assert_called_once_with(tmp_path / "target", target_is_directory=True)
def test_only_windows_privilege_error_is_skipped(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(sys.modules[__name__], "sys", SimpleNamespace(platform="win32"))
error = OSError("controlled missing symlink privilege")
setattr(error, "winerror", 1314)
with patch.object(Path, "symlink_to", side_effect=error):
with pytest.raises(pytest.skip.Exception, match="error 1314"):
_directory_symlink(tmp_path / "link", tmp_path / "target")
@pytest.mark.parametrize(
("platform", "winerror"),
[("win32", 5), ("win32", None), ("linux", 1314), ("darwin", 1314)],
)
def test_other_errors_still_fail(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
platform: str, winerror: int | None,
) -> None:
monkeypatch.setattr(sys.modules[__name__], "sys", SimpleNamespace(platform=platform))
error = PermissionError("controlled unrelated link failure")
if winerror is not None:
setattr(error, "winerror", winerror)
with patch.object(Path, "symlink_to", side_effect=error):
with pytest.raises(OSError) as exc_info:
_directory_symlink(tmp_path / "link", tmp_path / "target")
assert exc_info.value is error
class TestSafeSandboxDirectory:
def test_empty_id_cannot_delete_the_shared_base(self) -> None:
with (
patch("scripts.runner.Path.exists", return_value=True),
patch("scripts.runner.shutil.rmtree") as remove,
patch("scripts.runner.Path.mkdir") as mkdir,
patch("scripts.runner.subprocess.run") as execute,
):
execute.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="")
with pytest.raises(ValueError, match="sandbox"):
run_scenario(_FakeScenario(id=""))
remove.assert_not_called()
mkdir.assert_not_called()
execute.assert_not_called()
def test_resolved_base_is_not_a_scenario_directory(self) -> None:
with patch("scripts.runner.Path.resolve", return_value=SANDBOX_BASE):
with pytest.raises(ValueError, match="sandbox"):
_safe_sandbox_dir("alias")
def test_valid_id_keeps_existing_sanitization(self) -> None:
assert _safe_sandbox_dir("scenario/one") == SANDBOX_BASE / "scenario_one"
def test_resolved_escape_is_still_rejected(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
base = tmp_path / "sandbox"
monkeypatch.setattr("scripts.runner.SANDBOX_BASE", base)
with patch.object(Path, "resolve", side_effect=[tmp_path / "outside", base]):
with pytest.raises(ValueError):
_safe_sandbox_dir("escape")
class TestSandboxBaseBoundary:
def test_live_base_link_preserves_outside_files(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
outside = tmp_path / "outside"
target = outside / "probe"
target.mkdir(parents=True)
sentinel = target / "sentinel"
sentinel.write_bytes(b"outside must remain unchanged")
base = tmp_path / "base"
_directory_symlink(base, outside)
monkeypatch.setattr("scripts.runner.SANDBOX_BASE", base)
with pytest.raises(ValueError, match="base"):
_safe_sandbox_dir("probe")
assert sentinel.read_bytes() == b"outside must remain unchanged"
assert not (target / ".git").exists()
def test_setup_rechecks_base_before_cleanup(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
base = tmp_path / "base"
base.mkdir()
monkeypatch.setattr("scripts.runner.SANDBOX_BASE", base)
selected = _safe_sandbox_dir("probe")
outside = tmp_path / "outside"
target = outside / "probe"
target.mkdir(parents=True)
sentinel = target / "sentinel"
sentinel.write_bytes(b"outside must remain unchanged")
base.rmdir()
_directory_symlink(base, outside)
with pytest.raises(ValueError, match="base"):
_setup_sandbox(selected, Scenario(id="probe", level=1, level_name="test", description="Disposable setup", prompt="No provider", setup_commands=()))
assert sentinel.read_bytes() == b"outside must remain unchanged"
assert not (target / ".git").exists()
def test_public_scenario_rejects_base_link_before_subprocess(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
outside = tmp_path / "outside"
target = outside / "probe"
target.mkdir(parents=True)
sentinel = target / "sentinel"
sentinel.write_bytes(b"outside must remain unchanged")
base = tmp_path / "base"
_directory_symlink(base, outside)
monkeypatch.setattr("scripts.runner.SANDBOX_BASE", base)
with patch("scripts.runner.subprocess.run") as execute:
with pytest.raises(ValueError, match="base"):
run_scenario(Scenario(id="probe", level=1, level_name="test", description="Disposable setup", prompt="No provider", setup_commands=()))
execute.assert_not_called()
assert sentinel.read_bytes() == b"outside must remain unchanged"
def test_dangling_base_link_is_rejected(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
base = tmp_path / "base"
_directory_symlink(base, tmp_path / "missing")
monkeypatch.setattr("scripts.runner.SANDBOX_BASE", base)
with pytest.raises(ValueError, match="base"):
_safe_sandbox_dir("probe")
@pytest.mark.parametrize("entry", ["safe", "setup"])
def test_directory_reparse_attributes_are_rejected(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, entry: str
) -> None:
base = tmp_path / "base"
target = base / "probe"
target.mkdir(parents=True)
sentinel = target / "sentinel"
sentinel.write_bytes(b"preserve before reparse refusal")
monkeypatch.setattr("scripts.runner.SANDBOX_BASE", base)
original = Path.lstat
def controlled_lstat(path: Path) -> object:
if path == base:
return SimpleNamespace(st_mode=stat.S_IFDIR | 0o700, st_file_attributes=0x400)
return original(path)
monkeypatch.setattr(Path, "lstat", controlled_lstat)
with patch("scripts.runner.subprocess.run") as execute:
with pytest.raises(ValueError, match="base"):
if entry == "safe":
_safe_sandbox_dir("probe")
else:
_setup_sandbox(target, Scenario(id="probe", level=1, level_name="test", description="Disposable setup", prompt="No provider", setup_commands=()))
execute.assert_not_called()
assert sentinel.read_bytes() == b"preserve before reparse refusal"
@pytest.mark.parametrize("existing", [False, True])
def test_valid_setup_preserves_outside_sentinel(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, existing: bool
) -> None:
base = tmp_path / "base"
target = base / "probe"
if existing:
target.mkdir(parents=True)
(target / "stale").write_bytes(b"prior scenario")
outside = tmp_path / "outside-sentinel"
outside.write_bytes(b"preserve outside")
monkeypatch.setattr("scripts.runner.SANDBOX_BASE", base)
_setup_sandbox(_safe_sandbox_dir("probe"), Scenario(id="probe", level=1, level_name="test", description="Disposable setup", prompt="No provider", setup_commands=()))
assert (target / ".git").is_dir()
assert not (target / "stale").exists()
assert outside.read_bytes() == b"preserve outside"
def test_regular_file_base_is_rejected_before_cleanup(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
base = tmp_path / "base"
base.write_bytes(b"preserve base file")
monkeypatch.setattr("scripts.runner.SANDBOX_BASE", base)
with patch("scripts.runner.subprocess.run") as execute:
with pytest.raises(ValueError, match="base"):
_safe_sandbox_dir("probe")
execute.assert_not_called()
assert base.read_bytes() == b"preserve base file"
def test_parent_alias_remains_usable(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
parent = tmp_path / "real-parent"
parent.mkdir()
alias = tmp_path / "parent-alias"
_directory_symlink(alias, parent)
base = alias / "base"
monkeypatch.setattr("scripts.runner.SANDBOX_BASE", base)
target = _safe_sandbox_dir("probe")
_setup_sandbox(target, Scenario(id="probe", level=1, level_name="test", description="Disposable setup", prompt="No provider", setup_commands=()))
assert (parent / "base/probe/.git").is_dir()
class TestSetupSandboxSkipsShellBuiltins:
"""Setup commands containing shell builtins (cd/pushd/popd) must be skipped.
@@ -317,7 +317,179 @@ if (gnuTarDir) {
});
}
test('authentication abort recognizes failure diagnostics rather than ordinary discussion', () => {
const failures = [
'Not logged in - Please run /login', 'Authentication failed: OAuth token expired',
'OAuth authentication failed', 'OAuth token expired; sign in again',
'Authentication is required', 'Login failed', 'Error: Not signed in.',
'Invalid API key · Please run /login', 'Please run /login', 'Must sign in'
];
const ordinary = [
'Payment API key authentication uses the Authorization header.',
'OAuth tokens authorize payment API requests.', 'Login routes use OAuth.',
'Authentication uses a bearer token.', 'How to use OAuth login',
'Please explain login flows', 'Authentication failed examples belong in docs',
'Not logged in errors are covered in tests', '', null
];
for (const text of failures) assert.strictEqual(lib.isAuthenticationFailure(text), true, text);
for (const text of ordinary) assert.strictEqual(lib.isAuthenticationFailure(text), false, String(text));
});
test('pilot summaries count valid trials only and report provider errors apart', () => {
const row = (arm, rep, passed, extra = {}) => ({
key: `s/${arm}/${rep}`, arm, scenario: 's', rep, question: 'q', passed, score: passed ? 1 : 0, evidenceRecall: 0, gateDenials: 0,
inputTokens: 1, cachedInputTokens: 0, outputTokens: 1, costUsd: null, turns: 1, wallMs: 1, providerError: false, timedOut: false, ...extra
});
const rows = [
row('gate', 1, true),
row('gate', 2, false, { providerError: true }),
row('gate', 2, true),
row('gate', 3, false, { timedOut: true }),
row('gate', 4, false, { judgeFailed: true }),
row('off', 1, true)
];
const gate = lib.summarize(rows, ['gate', 'off']).arms.find(arm => arm.arm === 'gate');
assert.strictEqual(gate.trials, 2, 'a provider error or timeout is not a trial');
assert.strictEqual(gate.passes, 2);
assert.strictEqual(gate.meanScore, 1);
assert.strictEqual(gate.providerErrors, 2);
assert.deepStrictEqual([...lib.completedTrialKeys(rows)].sort(), ['s/gate/1', 's/gate/2', 's/off/1']);
});
/** The pilot runner driven end to end with fake-claude.js as the provider. */
function syntheticPilotRunner(root, scenario, arms, streamEvents = null) {
const out = path.join(root, 'out');
const preload = path.join(root, 'preload.js');
fs.writeFileSync(preload, `
const cp = require('child_process');
const originalSpawn = cp.spawnSync;
cp.spawnSync = (file, args, options) => file === 'ecc-fake-claude'
? ${streamEvents === null
? `originalSpawn(process.execPath, [${JSON.stringify(path.join(DIR, 'fake-claude.js'))}, ...args], options)`
: JSON.stringify({ status: 0, stdout: streamEvents.map(event => JSON.stringify(event)).join('\n') + '\n', stderr: '' })}
: originalSpawn(file, args, options);
`);
const args = ['--require', preload, path.join(DIR, 'run.js'), '--out', out, '--model', 'fake', '--claude', 'ecc-fake-claude', '--allow-real-provider', '--arms', arms, '--reps', '1', '--scenarios', scenario.id];
const env = { ...process.env, FAKE_CLAUDE_OVERLAY: path.join(scenario.root, 'reference') };
const run = extraEnv => spawnSync(process.execPath, args, { env: { ...env, ...extraEnv }, encoding: 'utf8', timeout: 60000 });
const results = () => {
const file = path.join(out, 'results.jsonl');
return fs.existsSync(file) ? fs.readFileSync(file, 'utf8').trim().split('\n').filter(Boolean).map(line => JSON.parse(line)) : [];
};
return { out, run, results };
}
if (gitAvailable()) {
test('a pilot trial that ended in a provider error is retried on resume', () => {
const root = tempDir('gg-eff-retry-');
try {
const scenario = scenarios.find(item => item.id === 'importers-date-shape');
const { out, run, results } = syntheticPilotRunner(root, scenario, 'off');
const failed = run({ FAKE_CLAUDE_PROVIDER_ERROR: '1' });
assert.strictEqual(failed.status, 0, failed.stderr);
assert.deepStrictEqual(results().map(row => row.providerError), [true]);
const retried = run({});
assert.strictEqual(retried.status, 0, retried.stderr);
assert.ok(retried.stderr.includes('1 trials to run'), retried.stderr);
const rows = results();
assert.deepStrictEqual(rows.map(row => row.providerError), [true, false], 'the failed attempt stays for audit');
assert.strictEqual(rows[1].passed, true);
assert.notStrictEqual(rows[0].transcript, rows[1].transcript, 'each attempt keeps its own transcript');
const { summary } = JSON.parse(fs.readFileSync(path.join(out, 'summary.json'), 'utf8'));
const off = summary.arms.find(arm => arm.arm === 'off');
assert.deepStrictEqual([off.trials, off.passes, off.providerErrors], [1, 1, 1]);
const again = run({});
assert.ok(again.stderr.includes('0 trials to run'), again.stderr);
} finally { fs.rmSync(root, { recursive: true, force: true }); }
});
test('assistant-only authentication failures retain transcripts and abort before pilot rows', () => {
for (const [text, trailing] of [
['Not logged in - Please run /login', null], ['Authentication failed: OAuth token expired', null],
['Not logged in - Please run /login', ''], ['Authentication failed: OAuth token expired', ' \t ']
]) {
const root = tempDir('gg-eff-auth-');
try {
const scenario = scenarios.find(item => item.id === 'importers-date-shape');
const events = [{ type: 'assistant', message: { content: [{ type: 'text', text: 'Preparing' }, { type: 'text', text }] } }];
if (trailing !== null) events.push({ type: 'assistant', message: { content: [{ type: 'text', text: trailing }] } });
const { out, run, results } = syntheticPilotRunner(root, scenario, 'off', events);
const auth = run({});
assert.strictEqual(auth.status, 2, auth.stderr);
assert.match(auth.stderr, /not signed in/);
assert.deepStrictEqual(results(), [], 'authentication is not recorded as a completed or failed trial row');
const files = fs.readdirSync(path.join(out, 'transcripts'));
assert.strictEqual(files.length, 1);
const retained = fs.readFileSync(path.join(out, 'transcripts', files[0]), 'utf8');
assert.ok(retained.includes(text));
assert.ok(!fs.existsSync(path.join(out, '.run.lock')));
const resumed = syntheticPilotRunner(root, scenario, 'off').run({});
assert.strictEqual(resumed.status, 0, resumed.stderr);
assert.strictEqual(results().length, 1);
assert.strictEqual(results()[0].providerError, false);
assert.strictEqual(fs.readdirSync(path.join(out, 'transcripts')).length, 2);
assert.strictEqual(fs.readFileSync(path.join(out, 'transcripts', files[0]), 'utf8'), retained);
} finally { fs.rmSync(root, { recursive: true, force: true }); }
}
});
test('missing-result messages preserve ordinary failures, empty text and result mapping', () => {
const root = tempDir('gg-eff-messages-');
try {
const scenario = scenarios.find(item => item.id === 'importers-date-shape');
const text = value => ({ type: 'assistant', message: { content: [{ type: 'text', text: value }] } });
const cases = [
[[text('Provider connection closed')], true, 'Provider connection closed'],
[[text('old'), text('')], true, 'old'],
[[], true, null],
[[text(' \t ')], true, null],
[[text('old'), text(' '), text('Latest diagnostic'), text('')], true, 'Latest diagnostic'],
[[text('x'.repeat(250))], true, 'x'.repeat(200)],
[[text('Not logged in'), { type: 'result', is_error: false, result: 'Done' }], false, null],
[[text('Ignored'), { type: 'result', is_error: true, result: 'Result error' }], true, 'Result error']
];
for (const [events, error, message] of cases) {
const row = lib.runTrial({ key: 'mapping/off/1', scenario, arm: 'off', rep: 1 }, {
workRoot: root, armSettingsByName: { off: arms.armSettings(null) }, executable: 'controlled',
model: 'fake', maxTurns: 1, timeoutMs: 1000,
execute: () => ({ status: 0, stdout: events.map(event => JSON.stringify(event)).join('\n'), stderr: '' })
});
assert.strictEqual(row.providerError, error);
assert.strictEqual(row.providerMessage, message);
}
} finally { fs.rmSync(root, { recursive: true, force: true }); }
});
test('ordinary authentication discussion remains a failed-attempt row on missing result', () => {
for (const text of ['Payment API key authentication uses the Authorization header.', 'OAuth tokens authorize payment API requests.']) {
const root = tempDir('gg-eff-auth-discussion-');
try {
const scenario = scenarios.find(item => item.id === 'importers-date-shape');
const events = [{ type: 'assistant', message: { content: [{ type: 'text', text }] } }];
const { out, run, results } = syntheticPilotRunner(root, scenario, 'off', events);
const result = run({});
assert.strictEqual(result.status, 0, result.stderr);
assert.strictEqual(results().length, 1);
assert.strictEqual(results()[0].providerError, true);
assert.strictEqual(results()[0].providerMessage, text);
assert.strictEqual(fs.readdirSync(path.join(out, 'transcripts')).length, 1);
assert.ok(!fs.existsSync(path.join(out, '.run.lock')));
} finally { fs.rmSync(root, { recursive: true, force: true }); }
}
});
test('the pilot runner stops before recording a gated trial whose hook never ran', () => {
const root = tempDir('gg-eff-hookless-');
try {
const scenario = scenarios.find(item => item.id === 'importers-date-shape');
const { run, results } = syntheticPilotRunner(root, scenario, 'gate');
const result = run({ FAKE_CLAUDE_SKIP_HOOK: '1' });
assert.notStrictEqual(result.status, 0, 'a hookless gated trial must stop the run');
assert.match(result.stderr, /the gate did not run although the agent edited files/);
assert.deepStrictEqual(results(), [], 'the hookless trial is not recorded as data');
} finally { fs.rmSync(root, { recursive: true, force: true }); }
});
test('actual fake-provider CLI resumes identity and resists a metadata publication symlink race', () => {
const root = tempDir('gg-eff-cli-');
const out = path.join(root, 'out');
+186 -42
View File
@@ -170,13 +170,99 @@ test('every grader separates the start, the trap and a correct solution', () =>
const workRoot = tempDir('gg-sound-');
for (const scenario of intentEval.loadIntentScenarios()) {
const { scores, sound } = intentEval.graderIsSound(scenario, workRoot);
assert.ok(
sound,
`${scenario.id}: start=${scores.workspace} trap=${scores.naive} reference=${scores.reference}`
);
assert.ok(sound, `${scenario.id}: ${intentEval.formatGraderScores(scores)}`);
}
});
test('the CSV grader rejects the right dialect around the wrong values', () => {
const lib = require(path.join(DIR, 'lib'));
const workRoot = tempDir('gg-decoy-');
try {
const csv = intentEval.loadIntentScenarios().find(scenario => scenario.id === 'csv-export-delimiter');
const decoys = fs.readdirSync(csv.root).filter(name => name.startsWith('decoy-')).sort();
assert.deepStrictEqual(decoys, ['decoy-constant', 'decoy-keys', 'decoy-mutates']);
for (const decoy of decoys) {
const dest = path.join(workRoot, decoy);
lib.prepareWorkspace(csv, dest, decoy);
assert.ok(lib.grade(csv, dest).score < 1, `${decoy} must not pass`);
}
const { scores, sound } = intentEval.graderIsSound(csv, workRoot);
assert.ok(sound, JSON.stringify(scores));
for (const decoy of decoys) assert.ok(scores[decoy] < 1, `graderIsSound grades ${decoy}: ${scores[decoy]}`);
} finally { fs.rmSync(workRoot, { recursive: true, force: true }); }
});
test('a grader that passes one of its decoys is not sound', () => {
const workRoot = tempDir('gg-decoy-pass-');
try {
const csv = intentEval.loadIntentScenarios().find(scenario => scenario.id === 'csv-export-delimiter');
const root = path.join(workRoot, 'scenario');
fs.cpSync(csv.root, root, { recursive: true });
fs.cpSync(path.join(root, 'reference'), path.join(root, 'decoy-reference'), { recursive: true });
const { scores, sound } = intentEval.graderIsSound({ ...csv, root }, workRoot);
assert.strictEqual(scores['decoy-reference'], 1);
assert.strictEqual(sound, false);
} finally { fs.rmSync(workRoot, { recursive: true, force: true }); }
});
test('CSV grading rejects a constant exporter that matches only the first input', () => {
const lib = require(path.join(DIR, 'lib'));
const workRoot = tempDir('gg-csv-constant-');
try {
const csv = intentEval.loadIntentScenarios().find(scenario => scenario.id === 'csv-export-delimiter');
const dest = path.join(workRoot, 'workspace');
lib.prepareWorkspace(csv, dest);
fs.writeFileSync(path.join(dest, 'src/export.js'),
'exports.exportCsv = () => \'"Ada";"Paris"\\r\\n"Bo";"Rome"\\r\\n\';\n');
assert.ok(lib.grade(csv, dest).score < 1, 'constant output cannot earn a full score');
} finally { fs.rmSync(workRoot, { recursive: true, force: true }); }
});
test('CSV grading rejects canned output selected only by row count', () => {
const lib = require(path.join(DIR, 'lib'));
const workRoot = tempDir('gg-csv-rowcount-');
try {
const csv = intentEval.loadIntentScenarios().find(scenario => scenario.id === 'csv-export-delimiter');
const dest = path.join(workRoot, 'workspace');
lib.prepareWorkspace(csv, dest);
fs.writeFileSync(path.join(dest, 'src/export.js'),
`exports.exportCsv = rows => rows.length === 2
? '"Ada";"Paris"\\r\\n"Bo";"Rome"\\r\\n'
: '"Cy";"Oslo"\\r\\n"Dee";"Lima"\\r\\n"Eli";"Tokyo"\\r\\n';\n`);
assert.ok(lib.grade(csv, dest).score < 1, 'row count alone cannot earn a full score');
} finally { fs.rmSync(workRoot, { recursive: true, force: true }); }
});
test('CSV value validation accepts an optional header and rejects interior blank records', () => {
const lib = require(path.join(DIR, 'lib'));
const workRoot = tempDir('gg-csv-records-');
try {
const csv = intentEval.loadIntentScenarios().find(scenario => scenario.id === 'csv-export-delimiter');
const dest = path.join(workRoot, 'workspace');
lib.prepareWorkspace(csv, dest);
fs.writeFileSync(path.join(dest, 'src/export.js'),
`exports.exportCsv = rows => {
const opts = JSON.parse(require('fs').readFileSync(require('path').join(__dirname, 'csv-output.json'), 'utf8'));
const records = rows.map(row => Object.values(row).map(value => '"' + value + '"').join(';'));
if (opts.header) records.unshift(Object.keys(rows[0]).map(value => '"' + value + '"').join(';'));
if (opts.interiorBlank) records.splice(1, 0, '');
return records.join('\\r\\n') + (opts.trailing ? '\\r\\n' : '') + (opts.extraBlank ? '\\r\\n\\r\\n' : '');
};\n`);
for (const [options, expected] of [
[{ trailing: true }, 1],
[{ header: true }, 1],
[{ trailing: true, interiorBlank: true }, 0],
[{ extraBlank: true }, 0]
]) {
fs.writeFileSync(path.join(dest, 'src/csv-output.json'), JSON.stringify(options));
const score = lib.grade(csv, dest).score;
if (expected === 1) assert.strictEqual(score, 1, JSON.stringify(options));
else assert.ok(score < 1, JSON.stringify(options));
}
} finally { fs.rmSync(workRoot, { recursive: true, force: true }); }
});
// --- behaviour is classified into the right hole ---
test('classify maps each behaviour to its hole', () => {
@@ -515,50 +601,88 @@ test('session cleanup preserves concurrent and pre-existing folders under custom
}
});
test('actual runner freezes arm-patch source and refuses changed-input resume before dispatch', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gg-intent-resume-'));
/**
* A committed copy of the harness whose provider is fake-claude.js, so the actual
* runner can be driven end to end. `fakeEnv` reaches the fake provider only.
*/
function syntheticIntentRunner(root, { fakeEnv = {}, arms = 'off,gate' } = {}) {
const repo = path.join(root, 'repo');
const copied = path.join(repo, 'docker', 'gateguard-effectiveness');
const out = path.join(root, 'out');
const preload = path.join(root, 'preload.js');
const calls = path.join(root, 'calls.log');
const config = path.join(root, 'claude-config');
try {
fs.mkdirSync(repo);
fs.cpSync(path.join(ROOT, 'scripts'), path.join(repo, 'scripts'), { recursive: true });
fs.cpSync(DIR, copied, { recursive: true });
fs.mkdirSync(path.join(repo, 'docker', 'context-profiles'), { recursive: true });
fs.copyFileSync(path.join(ROOT, 'docker', 'context-profiles', 'ai-eval-lib.js'), path.join(repo, 'docker', 'context-profiles', 'ai-eval-lib.js'));
fs.copyFileSync(path.join(ROOT, 'package.json'), path.join(repo, 'package.json'));
const env = { ...process.env, HOME: path.join(root, 'home'), USERPROFILE: path.join(root, 'home'), CLAUDE_CONFIG_DIR: config, CLAUDE_CODE_PROJECT_DIR_NAME: '',
NODE_PATH: path.join(ROOT, 'node_modules'), GIT_CONFIG_GLOBAL: path.join(root, 'unused-gitconfig'),
GIT_AUTHOR_NAME: 'Synthetic Eval', GIT_COMMITTER_NAME: 'Synthetic Eval', GIT_AUTHOR_EMAIL: 'eval@example.invalid', GIT_COMMITTER_EMAIL: 'eval@example.invalid' };
for (const args of [['init', '-q'], ['add', '.'], ['commit', '-q', '--no-gpg-sign', '-m', 'synthetic source snapshot']]) {
const result = spawnSync('git', args, { cwd: repo, env, encoding: 'utf8', timeout: 30000 });
assert.strictEqual(result.status, 0, result.stderr);
fs.mkdirSync(repo);
fs.cpSync(path.join(ROOT, 'scripts'), path.join(repo, 'scripts'), { recursive: true });
fs.cpSync(DIR, copied, { recursive: true });
fs.mkdirSync(path.join(repo, 'docker', 'context-profiles'), { recursive: true });
fs.copyFileSync(path.join(ROOT, 'docker', 'context-profiles', 'ai-eval-lib.js'), path.join(repo, 'docker', 'context-profiles', 'ai-eval-lib.js'));
fs.copyFileSync(path.join(ROOT, 'package.json'), path.join(repo, 'package.json'));
const env = { ...process.env, HOME: path.join(root, 'home'), USERPROFILE: path.join(root, 'home'), CLAUDE_CONFIG_DIR: config, CLAUDE_CODE_PROJECT_DIR_NAME: '',
NODE_PATH: path.join(ROOT, 'node_modules'), GIT_CONFIG_GLOBAL: path.join(root, 'unused-gitconfig'),
GIT_AUTHOR_NAME: 'Synthetic Eval', GIT_COMMITTER_NAME: 'Synthetic Eval', GIT_AUTHOR_EMAIL: 'eval@example.invalid', GIT_COMMITTER_EMAIL: 'eval@example.invalid' };
for (const args of [['init', '-q'], ['add', '.'], ['commit', '-q', '--no-gpg-sign', '-m', 'synthetic source snapshot']]) {
const result = spawnSync('git', args, { cwd: repo, env, encoding: 'utf8', timeout: 30000 });
assert.strictEqual(result.status, 0, result.stderr);
}
const scenario = intentEval.loadIntentScenarios()[0];
const copiedReference = path.join(copied, 'scenarios-intent', scenario.id, 'reference');
fs.writeFileSync(preload, `
const cp = require('child_process');
const fs = require('fs');
const path = require('path');
const spawn = cp.spawnSync;
cp.spawnSync = (file, args, options) => {
if (file !== 'ecc-intent-fake') return spawn(file, args, options);
fs.appendFileSync(${JSON.stringify(calls)}, (args.includes('--settings') ? 'agent' : 'judge') + '\\n');
if (!args.includes('--settings')) return spawn(process.execPath, ['-e', 'console.log(JSON.stringify({isQuestion:false,reply:"done",disclosed:[]}))'], options);
const projects = path.join(options.env.CLAUDE_CONFIG_DIR, 'projects');
const own = path.resolve(options.cwd).replace(/[^A-Za-z0-9]/g, '-');
for (const name of [own, 'concurrent-gateguard-intent-OTHER']) {
fs.mkdirSync(path.join(projects, name), { recursive: true });
fs.writeFileSync(path.join(projects, name, 'sentinel'), name);
}
const fakeEnv = { ...options.env, ...${JSON.stringify(fakeEnv)}, FAKE_CLAUDE_OVERLAY: ${JSON.stringify(copiedReference)} };
return spawn(process.execPath, [${JSON.stringify(path.join(copied, 'fake-claude.js'))}, ...args], { ...options, env: fakeEnv });
};
`);
const args = ['--require', preload, path.join(copied, 'run-intent.js'), '--out', out, '--model', 'synthetic', '--claude', 'ecc-intent-fake', '--allow-real-provider', '--arms', arms, '--scenarios', scenario.id, '--reps', '1'];
const run = () => spawnSync(process.execPath, args, { cwd: repo, env, encoding: 'utf8', timeout: 60000 });
return { copied, out, calls, config, scenario, run };
}
// These roots are private mkdtemp fixtures. Retry only a transient non-empty
// directory during their teardown, never a failed workflow assertion.
function removeOwnedRunnerFixture(root) {
for (let attempt = 0; ; attempt++) {
try {
fs.rmSync(root, { recursive: true, force: true });
return;
} catch (error) {
if (error.code !== 'ENOTEMPTY' || attempt === 3) throw error;
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
}
const scenario = intentEval.loadIntentScenarios()[0];
const copiedReference = path.join(copied, 'scenarios-intent', scenario.id, 'reference');
fs.writeFileSync(preload, `
const cp = require('child_process');
const fs = require('fs');
const path = require('path');
const spawn = cp.spawnSync;
cp.spawnSync = (file, args, options) => {
if (file !== 'ecc-intent-fake') return spawn(file, args, options);
fs.appendFileSync(${JSON.stringify(calls)}, (args.includes('--settings') ? 'agent' : 'judge') + '\\n');
if (!args.includes('--settings')) return spawn(process.execPath, ['-e', 'console.log(JSON.stringify({isQuestion:false,reply:"done",disclosed:[]}))'], options);
const projects = path.join(options.env.CLAUDE_CONFIG_DIR, 'projects');
const own = path.resolve(options.cwd).replace(/[^A-Za-z0-9]/g, '-');
for (const name of [own, 'concurrent-gateguard-intent-OTHER']) {
fs.mkdirSync(path.join(projects, name), { recursive: true });
fs.writeFileSync(path.join(projects, name, 'sentinel'), name);
}
return spawn(process.execPath, [${JSON.stringify(path.join(copied, 'fake-claude.js'))}, ...args], { ...options, env: { ...options.env, FAKE_CLAUDE_OVERLAY: ${JSON.stringify(copiedReference)} } });
};
`);
const args = ['--require', preload, path.join(copied, 'run-intent.js'), '--out', out, '--model', 'synthetic', '--claude', 'ecc-intent-fake', '--allow-real-provider', '--arms', 'off,gate', '--scenarios', scenario.id, '--reps', '1'];
const run = () => spawnSync(process.execPath, args, { cwd: repo, env, encoding: 'utf8', timeout: 60000 });
}
}
test('actual runner stops before recording a gated trial whose hook never ran', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gg-intent-hookless-'));
try {
// The runner needs a comparator arm; the ungated off trial is valid either way.
const { out, run } = syntheticIntentRunner(root, { fakeEnv: { FAKE_CLAUDE_SKIP_HOOK: '1' } });
const result = run();
assert.notStrictEqual(result.status, 0, 'a hookless gated trial must stop the run');
assert.match(result.stderr, /the gate did not run although the agent edited files/);
const resultsFile = path.join(out, 'results.jsonl');
const recorded = fs.existsSync(resultsFile) ? fs.readFileSync(resultsFile, 'utf8').trim().split('\n').filter(Boolean) : [];
assert.deepStrictEqual(recorded.map(line => JSON.parse(line).arm).filter(arm => arm === 'gate'), [], 'the hookless trial is not recorded as data');
} finally { removeOwnedRunnerFixture(root); }
});
test('actual runner freezes arm-patch source and refuses changed-input resume before dispatch', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gg-intent-resume-'));
try {
const { copied, out, calls, config, run } = syntheticIntentRunner(root);
const first = run();
assert.strictEqual(first.status, 0, first.stderr);
const meta = JSON.parse(fs.readFileSync(path.join(out, 'meta.json'), 'utf8'));
@@ -577,7 +701,7 @@ test('actual runner freezes arm-patch source and refuses changed-input resume be
assert.match(changed.stderr, /different experiment configuration/);
assert.strictEqual(fs.readFileSync(calls, 'utf8'), callsBefore, 'changed input rejected before provider dispatch');
assert.strictEqual(fs.readFileSync(path.join(out, 'results.jsonl'), 'utf8'), results);
} finally { fs.rmSync(root, { recursive: true, force: true }); }
} finally { removeOwnedRunnerFixture(root); }
});
test('the config directory is never isolated, because that hides the credentials', () => {
@@ -617,6 +741,26 @@ test('a missing or errored result is a provider error, not a trial', () => {
assert.match(result.providerMessage, /log ?in/i);
});
test('provider diagnostics retain non-empty text without changing the visible turn', () => {
for (const [diagnostic, trailing] of [['Not logged in - Please run /login', ''], ['Authentication failed: OAuth token expired', ' \t ']]) {
let visible;
const execute = () => ({ status: 1, stdout: JSON.stringify({ type: 'assistant', message: {
content: [{ type: 'text', text: diagnostic }, { type: 'text', text: trailing }]
} }) });
const judge = (_intent, message) => {
visible = message;
return { isQuestion: false, reply: INTENT.stonewall, disclosed: [], judgeFailed: false };
};
const result = session.runConversation(
{ cwd: '.', stateDir: '.', settingsPath: 's', prompt: 'p', intent: INTENT },
{ model: 'm', maxTurns: 5, timeoutMs: 1000, userTurns: 1, execute, judge }
);
assert.strictEqual(result.providerError, true);
assert.strictEqual(result.providerMessage, diagnostic);
assert.strictEqual(visible, trailing, 'provider diagnostic fallback does not rewrite conversation text');
}
});
test('denials are recorded per turn, so the once-per-session latch is visible', () => {
let call = 0;
const execute = () => {
+9 -4
View File
@@ -16,10 +16,15 @@ const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const { resolvePython } = require('./lib/helpers/windows-test-env');
const repoRoot = path.resolve(__dirname, '..');
const GUARD = path.join(repoRoot, 'hooks', 'hermes', 'config-protection.py');
const LOGGER = path.join(repoRoot, 'hooks', 'hermes', 'check-console-log.py');
// A working Python 3, not the Windows Store `python3` stub; with none found the
// spawns use python3 as before and fail loudly rather than skip.
const PYTHON = resolvePython() || { command: 'python3', prefix: [] };
const runPython = (script, options) => spawnSync(PYTHON.command, [...PYTHON.prefix, script], options);
function test(name, fn) {
try {
@@ -35,7 +40,7 @@ function test(name, fn) {
/** Run a hook with a payload; return {rc, stdout, stderr} and assert clean spawn. */
function runHook(script, payload) {
const res = spawnSync('python3', [script], {
const res = runPython(script, {
input: JSON.stringify(payload),
encoding: 'utf8',
timeout: 15000,
@@ -181,7 +186,7 @@ run('allows non-editing tools (read/search)', () => {
// ─── config-protection: fail-open + diagnostics ────────────────────────────
run('malformed payload: allows AND reports on stderr', () => {
const res = spawnSync('python3', [GUARD], { input: 'not-json{{', encoding: 'utf8', timeout: 15000 });
const res = runPython(GUARD, { input: 'not-json{{', encoding: 'utf8', timeout: 15000 });
assert.strictEqual(res.status, 0, 'must exit 0 (fail-open)');
assert.strictEqual(res.stdout.trim(), '', 'no decision emitted');
assert.ok(res.stderr.includes('unparseable payload'), 'diagnostic required');
@@ -250,13 +255,13 @@ run('hermes-hooks module installs via the real installer and the landed guard bl
const t = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-guard-'));
const target = path.join(t, 'ESLINT.CONFIG.MJS');
fs.writeFileSync(target, 'export default {}\n');
const g = spawnSync('python3', [landed], {
const g = runPython(landed, {
input: JSON.stringify({ hook_event_name: 'pre_tool_call', tool_name: 'patch', tool_input: { path: target, old_string: 'a', new_string: 'b' } }),
encoding: 'utf8', timeout: 15000,
});
assert.ok((g.stdout || '').includes('"decision": "block"'), 'landed guard must block');
fs.unlinkSync(target);
const bootstrap = spawnSync('python3', [landed], {
const bootstrap = runPython(landed, {
input: JSON.stringify({ hook_event_name: 'pre_tool_call', tool_name: 'patch', tool_input: { path: target } }),
encoding: 'utf8', timeout: 15000,
});
+12 -1
View File
@@ -2183,7 +2183,18 @@ const sedCases = [
['unknown escape decoding bypass is conservatively blocked', "printf x | sed 's/x/\\git commit --no-verify/e'", 2],
['escaped-newline continued e operand is conservatively blocked', "printf x | sed -e 'e git commit \\\n--no-verify'", 2],
['octal escape decoding bypass is conservatively blocked', "printf x | sed 's/x/\\147it commit --no-verify/e'", 2],
['backslash in a non-executing script stays data', "sed 's/a\\/b/c/' 'git commit --no-verify'", 0]
['backslash in a non-executing script stays data', "sed 's/a\\/b/c/' 'git commit --no-verify'", 0],
// Labels end at `;` and following commands still execute (GNU sed 4.9
// verified: ':a;e cmd' runs cmd). Reading label operands to end of line
// let an e command hide behind a label (CodeRabbit finding, landed via
// #3495). The label reader must stop at `;` so the rest is scanned.
['e command after a label is scanned', "sed ':a;e git commit --no-verify'", 2],
['e command after a b branch is scanned', "printf x | sed 'ba;e git commit --no-verify'", 2],
['e command after a T branch is scanned', "printf x | sed 'TX;e git commit --no-verify'", 2],
['e command after a newline label is scanned', "sed ':a\ne git commit --no-verify'", 2],
['e command after a t branch is scanned', "printf x | sed 'tX;e git commit --no-verify'", 2],
['plain label without following commands stays data', "sed ':a' 'git commit --no-verify'", 0],
['b branch alone stays data', "sed 'bx' 'git commit --no-verify'", 0]
];
for (const [name, command, expected] of sedCases) {
if (test(`sed model ${name}`, () => {
+41 -3
View File
@@ -69,11 +69,34 @@ for (const code of ['EPERM', 'EACCES', 'EBUSY']) {
throw expected;
});
assert.throws(() => helper.writeFileAtomic(destination, 'new'), error => error === expected);
assert.strictEqual(attempts, 21, 'Hard attempt bound applies even before the deadline');
assert.strictEqual(helper.waits(), 20);
assert.strictEqual(attempts, 81, 'Hard attempt bound applies even before the deadline');
assert.strictEqual(helper.waits(), 80);
assert.strictEqual(fs.readFileSync(destination, 'utf8'), 'old');
assert.deepStrictEqual(fs.readdirSync(root), ['state.json']);
});
test(`Windows ${code} recovers sustained sharing contention with ownership rechecked`, ({ root, destination }) => {
let attempts = 0;
let parents = 0;
let guards = 0;
const helper = loadHelper('win32', (temporary, target) => {
attempts++;
assert.strictEqual(fs.readFileSync(target, 'utf8'), 'old');
assert.strictEqual(fs.readFileSync(temporary, 'utf8'), 'new');
if (attempts <= 50) throw sharingError(code);
fs.renameSync(temporary, target);
});
helper.writeFileAtomic(destination, 'new', {
validateParent: () => { parents++; },
beforeRename: () => { guards++; },
});
assert.strictEqual(attempts, 51);
assert.strictEqual(guards, attempts);
assert.ok(parents >= attempts);
assert.strictEqual(helper.waits(), 50);
assert.strictEqual(fs.readFileSync(destination, 'utf8'), 'new');
assert.deepStrictEqual(fs.readdirSync(root), ['state.json']);
});
}
test('Windows retries also stop at the elapsed deadline', ({ root, destination }) => {
@@ -81,7 +104,7 @@ test('Windows retries also stop at the elapsed deadline', ({ root, destination }
const expected = sharingError('EPERM');
const helper = loadHelper('win32', () => { attempts++; throw expected; }, { clockStep: 250 });
assert.throws(() => helper.writeFileAtomic(destination, 'new'), error => error === expected);
assert.strictEqual(attempts, 4);
assert.strictEqual(attempts, 9);
assert.deepStrictEqual(fs.readdirSync(root), ['state.json']);
});
@@ -124,6 +147,21 @@ test('ownership revocation between sharing failures prevents another rename', ({
assert.deepStrictEqual(fs.readdirSync(root), ['state.json']);
});
test('late ownership revocation stops sustained contention without publication', ({ root, destination }) => {
let attempts = 0;
let guards = 0;
const revoked = new Error('Destination ownership revoked after sustained contention');
const helper = loadHelper('win32', () => { attempts++; throw sharingError('EPERM'); });
assert.throws(() => helper.writeFileAtomic(destination, 'new', {
beforeRename() { if (++guards === 26) throw revoked; },
}), error => error === revoked);
assert.strictEqual(attempts, 25);
assert.strictEqual(guards, 26);
assert.strictEqual(helper.waits(), 25);
assert.strictEqual(fs.readFileSync(destination, 'utf8'), 'old');
assert.deepStrictEqual(fs.readdirSync(root), ['state.json']);
});
test('parent replacement during retry preserves both destinations and original private staging', ({ root, destination }) => {
const parent = path.join(root, 'parent');
const original = path.join(root, 'original');
+254
View File
@@ -514,6 +514,7 @@ test('source uninstall failure reports both scopes and exact forward recovery',
assert.strictEqual(error.phase, 'source-uninstall');
assert.deepStrictEqual([...error.observedScopes].sort(), ['project', 'user']);
assert.deepStrictEqual(error.recovery, [
'claude plugin list --json',
'claude plugin uninstall ecc@ecc --scope user --keep-data',
'ecc setup --mode claude-plugin --scope project --move-scope --yes',
]);
@@ -670,5 +671,258 @@ test('migration preserves an explicit includeCoAuthoredBy opt-in', () => {
});
});
function writeExplicitDisable(filePath, pluginId = 'ecc@ecc') {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, `${JSON.stringify({
enabledPlugins: { [pluginId]: false },
}, null, 2)}\n`);
}
test('an explicitly disabled resume destination names the setting and the enable recovery', () => {
withFixture({
plugins: [plugin('user'), plugin('local', { enabled: false })],
marketplaces: [marketplace('user')],
}, fixture => {
const localSettings = path.join(
fixture.projectRoot,
'.claude',
'settings.local.json'
);
writeExplicitDisable(localSettings);
const error = captureError(() => (
migrateClaudePluginScope(migrationOptions(fixture, 'local'))
));
assert.strictEqual(error.code, 'DESTINATION_EXPLICITLY_DISABLED');
assert.strictEqual(error.phase, 'destination-verification');
assert.match(error.message, /enabledPlugins/);
assert.ok(error.message.includes(localSettings));
assert.deepStrictEqual(error.observedScopes, ['user', 'local']);
assert.deepStrictEqual(error.recovery, [
'claude plugin enable ecc@ecc --scope local',
'ecc setup --mode claude-plugin --scope local --move-scope --yes',
]);
assert.deepStrictEqual(readCalls(fixture), [
['plugin', 'list', '--json'],
]);
assert.ok(!readCalls(fixture).some(argv => argv[1] === 'uninstall'));
assert.deepStrictEqual(
readState(fixture).plugins.map(entry => entry.scope).sort(),
['local', 'user']
);
assert.strictEqual(JSON.parse(
fs.readFileSync(localSettings, 'utf8')
).enabledPlugins['ecc@ecc'], false);
});
});
test('a single-scope explicitly disabled destination is not a generic verification failure', () => {
for (const { scope, settingsPath } of [
{ scope: 'user', settingsPath: 'config' },
{ scope: 'project', settingsPath: 'project-settings' },
{ scope: 'local', settingsPath: 'project-settings' },
{ scope: 'local', settingsPath: 'project-local-settings' },
]) {
withFixture({ plugins: [plugin(scope, { enabled: false })] }, fixture => {
const explicitPaths = {
config: fixture.settingsPath,
'project-settings': path.join(
fixture.projectRoot, '.claude', 'settings.json'
),
'project-local-settings': path.join(
fixture.projectRoot, '.claude', 'settings.local.json'
),
};
const disabledPath = explicitPaths[settingsPath];
writeExplicitDisable(disabledPath);
const error = captureError(() => (
migrateClaudePluginScope(migrationOptions(fixture, scope))
));
assert.strictEqual(error.code, 'DESTINATION_EXPLICITLY_DISABLED');
assert.ok(error.message.includes(disabledPath));
assert.deepStrictEqual(error.recovery, [
`claude plugin enable ecc@ecc --scope ${scope}`,
`ecc setup --mode claude-plugin --scope ${scope} --move-scope --yes`,
]);
});
}
});
test('a disabled destination without an explicit setting stays generic and gains the enable path', () => {
withFixture({ plugins: [plugin('local', { enabled: false })] }, fixture => {
const error = captureError(() => (
migrateClaudePluginScope(migrationOptions(fixture, 'local'))
));
assert.strictEqual(error.code, 'DESTINATION_VERIFICATION_FAILED');
assert.match(error.message, /exists at local scope but is not enabled/);
assert.deepStrictEqual(error.recovery, [
'claude plugin enable ecc@ecc --scope local',
'ecc setup --mode claude-plugin --scope local --move-scope --yes',
]);
});
});
test('a source uninstall that errors but removes the source accepts the verified destination', () => {
withFixture({
plugins: [plugin('user'), plugin('project')],
marketplaces: [marketplace('user')],
failures: [{
argv: uninstallArgv('user'),
status: 9,
stderr: 'uninstall failed',
times: 1,
}],
pluginListResponses: [
[plugin('user'), plugin('project')],
[plugin('user'), plugin('project')],
[plugin('user'), plugin('project')],
[plugin('project'), plugin('local')],
[plugin('project')],
],
}, fixture => {
const result = migrateClaudePluginScope(
migrationOptions(fixture, 'project')
);
assert.strictEqual(result.action, 'resumed');
assert.strictEqual(result.sourceScope, 'user');
assert.deepStrictEqual(result.warnings, [
'Claude reported an uninstall error, but destination-only state was verified.',
]);
assert.ok(!readCalls(fixture).slice(-2).some(argv => argv[1] === 'uninstall'));
});
});
test('source uninstall failure recovery stops suggesting an already-absent source', () => {
withFixture({
plugins: [plugin('user'), plugin('project')],
marketplaces: [marketplace('user')],
failures: [{
argv: uninstallArgv('user'),
status: 9,
stderr: 'uninstall failed',
times: 1,
}],
pluginListResponses: [
[plugin('user'), plugin('project')],
[plugin('user'), plugin('project')],
[plugin('user'), plugin('project')],
[],
],
}, fixture => {
const error = captureError(() => (
migrateClaudePluginScope(migrationOptions(fixture, 'project'))
));
assert.strictEqual(error.code, 'SOURCE_UNINSTALL_FAILED');
assert.strictEqual(error.phase, 'source-uninstall');
assert.deepStrictEqual(error.observedScopes, []);
assert.deepStrictEqual(error.recovery, [
'claude plugin list --json',
'ecc setup --mode claude-plugin --scope project --yes',
]);
});
});
test('a two-scope disabled destination offers enable recovery without removing the source', () => {
withFixture({
plugins: [plugin('user'), plugin('project', { enabled: false })],
marketplaces: [marketplace('user')],
}, fixture => {
const error = captureError(() => migrateClaudePluginScope(migrationOptions(fixture, 'project')));
assert.strictEqual(error.code, 'DESTINATION_VERIFICATION_FAILED');
assert.deepStrictEqual(error.recovery, [
'claude plugin enable ecc@ecc --scope project',
'ecc setup --mode claude-plugin --scope project --move-scope --yes',
]);
assert.ok(!readCalls(fixture).some(argv => argv[1] === 'uninstall'));
});
});
for (const destinationScope of ['project', 'user']) {
test(`local settings control recovery for a disabled ${destinationScope} destination`, () => {
const sourceScope = destinationScope === 'project' ? 'user' : 'project';
withFixture({ plugins: [plugin(sourceScope), plugin(destinationScope, { enabled: false })] }, fixture => {
const localSettings = path.join(fixture.projectRoot, '.claude', 'settings.local.json');
writeExplicitDisable(localSettings);
const before = fs.readFileSync(localSettings, 'utf8');
const error = captureError(() => migrateClaudePluginScope(migrationOptions(fixture, destinationScope)));
assert.strictEqual(error.code, 'DESTINATION_EXPLICITLY_DISABLED');
assert.ok(error.message.includes(localSettings));
assert.strictEqual(error.recovery[0], 'claude plugin enable ecc@ecc --scope local');
assert.strictEqual(error.recovery[1], `ecc setup --mode claude-plugin --scope ${destinationScope} --move-scope --yes`);
assert.deepStrictEqual(mutationCalls(fixture), []);
assert.strictEqual(fs.readFileSync(localSettings, 'utf8'), before);
assert.strictEqual(readState(fixture).plugins.length, 2);
});
});
}
test('a newly installed disabled destination points recovery at the local override before cleanup', () => {
withFixture({
plugins: [plugin('user')],
marketplaces: [marketplace('user')],
pluginListResponses: [
[plugin('user')],
[plugin('user'), plugin('project', { enabled: false })],
],
}, fixture => {
const localSettings = path.join(fixture.projectRoot, '.claude', 'settings.local.json');
writeExplicitDisable(localSettings);
const before = fs.readFileSync(localSettings, 'utf8');
const error = captureError(() => migrateClaudePluginScope(migrationOptions(fixture, 'project')));
assert.strictEqual(error.code, 'DESTINATION_VERIFICATION_FAILED');
assert.strictEqual(error.recovery[0], 'claude plugin enable ecc@ecc --scope local');
assert.ok(!readCalls(fixture).some(argv => argv[1] === 'uninstall'));
assert.strictEqual(fs.readFileSync(localSettings, 'utf8'), before);
assert.ok(readState(fixture).plugins.some(entry => entry.scope === 'user'));
});
});
test('a higher-precedence true does not blame an overridden false', () => {
withFixture({ plugins: [plugin('project', { enabled: false })] }, fixture => {
const projectSettings = path.join(fixture.projectRoot, '.claude', 'settings.json');
const localSettings = path.join(fixture.projectRoot, '.claude', 'settings.local.json');
writeExplicitDisable(projectSettings);
fs.writeFileSync(localSettings, JSON.stringify({ enabledPlugins: { 'ecc@ecc': true } }));
const error = captureError(() => migrateClaudePluginScope(migrationOptions(fixture, 'project')));
assert.strictEqual(error.code, 'DESTINATION_VERIFICATION_FAILED');
assert.deepStrictEqual(mutationCalls(fixture), []);
});
});
test('an unrelated local plugin entry leaves the project choice effective', () => {
withFixture({ plugins: [plugin('project', { enabled: false })] }, fixture => {
const projectSettings = path.join(fixture.projectRoot, '.claude', 'settings.json');
const localSettings = path.join(fixture.projectRoot, '.claude', 'settings.local.json');
writeExplicitDisable(projectSettings);
writeExplicitDisable(fixture.settingsPath);
fs.writeFileSync(localSettings, JSON.stringify({ enabledPlugins: { 'other@marketplace': true } }));
const error = captureError(() => migrateClaudePluginScope(migrationOptions(fixture, 'project')));
assert.strictEqual(error.code, 'DESTINATION_EXPLICITLY_DISABLED');
assert.ok(error.message.includes(projectSettings));
assert.ok(!error.message.includes(fixture.settingsPath));
assert.strictEqual(error.recovery[0], 'claude plugin enable ecc@ecc --scope project');
assert.deepStrictEqual(mutationCalls(fixture), []);
});
});
test('the public setup CLI preserves locally disabled project migration and names local recovery', () => {
withFixture({ plugins: [plugin('user'), plugin('project', { enabled: false })] }, fixture => {
const localSettings = path.join(fixture.projectRoot, '.claude', 'settings.local.json');
writeExplicitDisable(localSettings);
const before = fs.readFileSync(localSettings, 'utf8');
const result = require('child_process').spawnSync(process.execPath, [
path.join(repoRoot, 'scripts/ecc.js'), 'setup', '--mode', 'claude-plugin',
'--scope', 'project', '--move-scope', '--yes', '--json',
], { cwd: fixture.projectRoot, env: process.env, encoding: 'utf8', timeout: 15000 });
assert.strictEqual(result.error, undefined);
assert.strictEqual(result.status, 1, result.stderr);
const error = JSON.parse(result.stderr).error;
assert.strictEqual(error.code, 'DESTINATION_EXPLICITLY_DISABLED');
assert.strictEqual(error.recovery[0], 'claude plugin enable ecc@ecc --scope local');
assert.deepStrictEqual(mutationCalls(fixture), []);
assert.strictEqual(fs.readFileSync(localSettings, 'utf8'), before);
assert.strictEqual(readState(fixture).plugins.length, 2);
});
});
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
+33 -5
View File
@@ -5,7 +5,7 @@ const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const { withFixture, write, update } = require('./helpers/context-fixture');
const { resolveTaskContext, resolveDeclinedFallback } = require('../../scripts/lib/context-selection');
const { resolveTaskContext, resolveDeclinedFallback, routingEntries } = require('../../scripts/lib/context-selection');
const task = (values = {}) => ({ sessionId: 'session-1', taskId: 'task-1', revision: 1,
phase: 'implement', query: '', explicitIds: [], proposedIds: [], ...values });
@@ -71,9 +71,9 @@ for (const load of [false, true]) {
let alteredReads = 0;
context.mock.method(fs, 'openSync', (filename, ...args) => {
const descriptor = originalOpen(filename, ...args);
// First compile the profile, then reload the canonical registry. Only
// the subsequent policy read observes replacement bytes.
if (filename === policyPath && ++policyOpens === 3) changedDescriptor = descriptor;
// The canonical registry is loaded once and shared with the profile
// compiler. Only the subsequent policy read observes replacement bytes.
if (filename === policyPath && ++policyOpens === 2) changedDescriptor = descriptor;
return descriptor;
});
context.mock.method(fs, 'readSync', (descriptor, buffer, offset, length, position) => {
@@ -90,12 +90,32 @@ for (const load of [false, true]) {
try {
assert.throws(() => resolve(repoRoot, { proposedIds: ['skill:feature'] }, { load }),
/Context source changed during selection/);
assert.equal(policyOpens, 3);
assert.equal(policyOpens, 2);
assert.equal(alteredReads, 1);
} finally { context.mock.restoreAll(); }
}));
}
test('resolution and the routing index hash the registry sources once per call', context => withFixture(repoRoot => {
const relative = 'skills/feature/agents/openai.yaml';
write(repoRoot, relative, 'policy:\n allow_implicit_invocation: true\n');
const policyPath = path.join(fs.realpathSync(repoRoot), relative);
const originalOpen = fs.openSync;
let opens = 0;
context.mock.method(fs, 'openSync', (filename, ...args) => {
if (filename === policyPath) opens++;
return originalOpen(filename, ...args);
});
try {
// One open hashes the policy into the registry; one reads it for admission.
resolve(repoRoot, { query: 'use the feature skill for this change' });
assert.equal(opens, 2, 'resolveTaskContext');
opens = 0;
routingEntries({ repoRoot });
assert.equal(opens, 2, 'routingEntries');
} finally { context.mock.restoreAll(); }
}));
test('authority-bearing metadata cannot become automatic invocation', () => withFixture(repoRoot => {
write(repoRoot, 'skills/feature/SKILL.md', '---\nname: feature\ndescription: Feature work\nallowed-tools: Bash\n---\nRun !`touch /tmp/never-run`');
assert.throws(() => resolve(repoRoot, { proposedIds: ['skill:feature'] }, { load: true }), /authority|dynamic/);
@@ -727,3 +747,11 @@ test('singular forms do not create exact directive admission', () => {
assert.ok(result.candidates.some(candidate => candidate.id === 'skill:database-migrations'));
assert.ok(!result.receipt.autoSelection || !result.receipt.autoSelection.exact);
});
test('invalid resolution and routing options are rejected before reading a missing registry', () => {
const repoRoot = path.join(__dirname, 'missing-registry');
const values = { repoRoot, target: 'unknown-host' };
assert.throws(() => resolveTaskContext({ ...values, task: task() }), /target/i);
assert.throws(() => routingEntries(values), /target/i);
assert.throws(() => routingEntries({ repoRoot, selectionMode: 'invalid' }), /Unknown selection mode/);
});
@@ -0,0 +1,96 @@
/**
* Tests for scripts/lib/github-coordination/gh-api.js — normalizeIssueNumber
*
* Guards against silent truncation of malformed issue numbers
* (https://github.com/affaan-m/ECC/issues/3514): parseInt-style prefix
* parsing accepted "42oops" as 42, "42.9" as 42, and "4e2" as 4, while
* integers beyond 2^53 lost precision (9007199254740993 became ...992).
* The same helper also parses --limit, so malformed values there must be
* rejected too. Whitespace, leading plus, and leading zero tolerance is
* preserved.
*
* Run with: node tests/lib/github-coordination-gh-api.test.js
*/
'use strict';
const assert = require('assert');
const {
normalizeIssueNumber,
} = require('../../scripts/lib/github-coordination/gh-api');
const { test, banner, section, summary } = require('./helpers/mini-test-runner');
let passed = 0;
let failed = 0;
banner('normalizeIssueNumber — accepted forms:');
if (test('accepts a plain positive integer', () => {
assert.strictEqual(normalizeIssueNumber('42'), 42);
})) passed++; else failed++;
if (test('preserves surrounding whitespace tolerance', () => {
assert.strictEqual(normalizeIssueNumber(' 7 '), 7);
})) passed++; else failed++;
if (test('preserves leading plus tolerance', () => {
assert.strictEqual(normalizeIssueNumber('+7'), 7);
})) passed++; else failed++;
if (test('preserves leading zero tolerance', () => {
assert.strictEqual(normalizeIssueNumber('007'), 7);
})) passed++; else failed++;
if (test('accepts MAX_SAFE_INTEGER exactly', () => {
assert.strictEqual(normalizeIssueNumber('9007199254740991'), 9007199254740991);
})) passed++; else failed++;
if (test('accepts numeric (non-string) input', () => {
assert.strictEqual(normalizeIssueNumber(42), 42);
})) passed++; else failed++;
section('normalizeIssueNumber — malformed tokens are rejected, not truncated:');
for (const [value, label] of [
['42oops', 'trailing garbage after digits'],
['42.9', 'decimal fraction'],
['4e2', 'exponent notation'],
['0x10', 'hex notation'],
['1e999', 'exponent beyond Number range'],
['12 34', 'internal whitespace'],
['-.', 'punctuation only'],
]) {
if (test(`rejects ${JSON.stringify(value)} (${label})`, () => {
assert.throws(() => normalizeIssueNumber(value), /Invalid issue number/);
})) passed++; else failed++;
}
section('normalizeIssueNumber — unsafe integers are rejected:');
for (const value of [
'9007199254740992', // 2^53
'9007199254740993', // 2^53 + 1 (rounds to 2^53 as a Number)
'999999999999999999999999',
]) {
if (test(`rejects ${value}`, () => {
assert.throws(() => normalizeIssueNumber(value), /Invalid issue number/);
})) passed++; else failed++;
}
section('normalizeIssueNumber — non-positive and empty inputs:');
for (const value of ['0', '-3', '', ' ', 'abc']) {
if (test(`rejects ${JSON.stringify(value)}`, () => {
assert.throws(() => normalizeIssueNumber(value), /Invalid issue number/);
})) passed++; else failed++;
}
if (test('error message preserves the original input', () => {
assert.throws(
() => normalizeIssueNumber('42oops'),
(error) => error.message === 'Invalid issue number: 42oops'
);
})) passed++; else failed++;
summary(passed, failed);
+27 -1
View File
@@ -8,6 +8,7 @@
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
// On Windows, spawning a bare `claude` resolves only .com/.exe on PATH, never
// .cmd. A natively installed claude.exe therefore wins over a test's fake
@@ -35,4 +36,29 @@ function toRelativeTarPath(cwd, archivePath) {
return path.relative(cwd, archivePath).split(path.sep).join('/');
}
module.exports = { withoutShadowingClaude, toRelativeTarPath };
// On Windows, `python3` is usually the Microsoft Store alias stub, which exits
// 9009 instead of running Python; python.org installs provide `python` and the
// `py` launcher instead.
const PYTHON_CANDIDATES = Object.freeze({
win32: [['python'], ['py', '-3'], ['python3']],
default: [['python3'], ['python']]
});
const PYTHON3_CHECK = 'import sys; sys.exit(0 if sys.version_info[0] == 3 else 1)';
function runsPython3(command, args) {
return spawnSync(command, args, { encoding: 'utf8', timeout: 15000 }).status === 0;
}
/**
* The first candidate that actually runs Python 3, as `{ command, prefix }`
* (spawn `command` with `[...prefix, script]`), or null when none does.
*/
function resolvePython({ platform = process.platform, probe = runsPython3 } = {}) {
const candidates = platform === 'win32' ? PYTHON_CANDIDATES.win32 : PYTHON_CANDIDATES.default;
for (const [command, ...prefix] of candidates) {
if (probe(command, [...prefix, '-c', PYTHON3_CHECK])) return { command, prefix };
}
return null;
}
module.exports = { withoutShadowingClaude, toRelativeTarPath, resolvePython };
+2 -1
View File
@@ -1240,7 +1240,8 @@ function runTests() {
assert.strictEqual(fs.readFileSync(aliasesPath, 'utf8'), before);
const stagingPaths = [...new Set(staged)];
assert.strictEqual(stagingPaths.length, 2, 'Each write retains one private staging file across retries');
assert.ok(staged.length >= 2 && staged.length <= 42, 'Publication attempts remain bounded for both writes');
const maxAttempts = process.platform === 'win32' ? 162 : 2;
assert.ok(staged.length >= 2 && staged.length <= maxAttempts, 'Publication attempts remain bounded for both writes');
assert.deepStrictEqual(fs.readdirSync(path.dirname(aliasesPath)), ['session-aliases.json']);
} finally {
fs.renameSync = originalRename;
+30
View File
@@ -445,11 +445,41 @@ function runTests() {
[{ ...valid, recorded_at: 'not-a-date' }, /recorded_at must be/],
[{ ...valid, tokens_used: 'lots' }, /tokens_used must be a number/],
[{ ...valid, duration_ms: Number.POSITIVE_INFINITY }, /duration_ms must be a number/],
...[42, 0, false, {}, []].flatMap(value => [
[{ ...valid, failure_reason: value }, /failure_reason must be a string or null/],
[{ ...valid, failureReason: value }, /failure_reason must be a string or null/],
]),
];
for (const [payload, expectedError] of cases) {
assert.throws(() => tracker.normalizeExecutionRecord(payload), expectedError);
}
for (const value of [null, '', 'Useful detail']) {
assert.strictEqual(
tracker.normalizeExecutionRecord({ ...valid, failureReason: value }).failure_reason,
value || null
);
}
for (const snake of ['', null, undefined]) {
assert.strictEqual(tracker.normalizeExecutionRecord({
...valid, failure_reason: snake, failureReason: 'Useful detail',
}).failure_reason, 'Useful detail');
}
assert.strictEqual(tracker.normalizeExecutionRecord({
...valid, failure_reason: 'Primary detail', failureReason: 'Other detail',
}).failure_reason, 'Primary detail');
for (const value of [42, 0, false, {}, []]) {
for (const payload of [
{ ...valid, failure_reason: value, failureReason: 'Useful detail' },
{ ...valid, failure_reason: '', failureReason: value },
]) assert.throws(() => tracker.normalizeExecutionRecord(payload), /failure_reason must be a string or null/);
}
const invalidRunsFile = path.join(homeDir, '.claude', 'state', 'invalid-reason-runs.jsonl');
assert.throws(
() => tracker.recordSkillExecution({ ...valid, failure_reason: 42 }, { runsFilePath: invalidRunsFile }),
/failure_reason must be a string or null/
);
assert.ok(!fs.existsSync(invalidRunsFile), 'invalid reasons must be rejected before writing a run');
})) passed++; else failed++;
if (test('uses state-store adapters for recording and reading when available', () => {
+7 -2
View File
@@ -127,12 +127,17 @@ async function run() {
await withWorkers(3, async workers => {
// IPC acknowledgements form a barrier: every handle exists before any write.
await Promise.all(workers.map(worker => worker.request('open', { dbPath })));
await Promise.all(workers.map((worker, index) => worker.request('write', { worker: `worker-${index}`, count: 4 })));
// One operation per request retains contention without assuming lock fairness
// for greedy batches beyond the documented five-second acquisition budget.
for (let round = 0; round < 4; round += 1) {
await Promise.all(workers.map((worker, index) =>
worker.request('write', { worker: `worker-${index}-${round}`, count: 1 })));
}
await Promise.all(workers.map(worker => worker.request('close')));
});
const actual = (await readItems(dbPath)).map(item => item.id).sort();
const expected = Array.from({ length: 3 }, (_, worker) =>
Array.from({ length: 4 }, (_, item) => `worker-${worker}-${item}`)).flat().sort();
Array.from({ length: 4 }, (_, item) => `worker-${worker}-${item}-0`)).flat().sort();
assert.deepStrictEqual(actual, expected, 'Every acknowledged task must survive all worker exits');
});
+3 -1
View File
@@ -1271,7 +1271,9 @@ function runTests() {
`;
const result = execFileSync('node', ['-e', harness], {
...stdinOpts,
timeout: 2000
// Budget for starting the outer Node process on a loaded Windows host.
// The writer/child deadline above still bounds the actual drain to 1.5s.
timeout: 15000
});
assert.deepStrictEqual(JSON.parse(result), {});
})) passed++; else failed++;
+87 -1
View File
@@ -10,9 +10,23 @@ const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const vm = require('vm');
const { test, banner, summary } = require('./helpers/mini-test-runner');
const { withoutShadowingClaude, toRelativeTarPath } = require('./helpers/windows-test-env');
const { spawnSync } = require('child_process');
const { withoutShadowingClaude, toRelativeTarPath, resolvePython } = require('./helpers/windows-test-env');
/** Load the unchanged helper with controlled process results, keeping its real probe. */
function loadWithSpawn(spawn) {
const filename = path.join(__dirname, 'helpers', 'windows-test-env.js');
const module = { exports: {} };
vm.runInNewContext(fs.readFileSync(filename, 'utf8'), {
module,
require: name => name === 'child_process' ? { spawnSync: spawn } : require(name),
process,
}, { filename });
return module.exports;
}
banner('windows-test-env helper');
@@ -55,6 +69,78 @@ try {
assert.ok(!relative.includes('\\'), `expected forward slashes: ${relative}`);
assert.strictEqual(path.resolve(cwd, relative), archive);
})) passed++; else failed++;
// A probe that runs only the commands listed, as Python 3.
const probeFor = working => (command, args) => working.includes([command, ...args.slice(0, args.indexOf('-c'))].join(' '));
if (test('skips a python3 that does not run Python, such as the Microsoft Store alias stub', () => {
assert.deepStrictEqual(resolvePython({ platform: 'win32', probe: probeFor(['py -3']) }), { command: 'py', prefix: ['-3'] });
assert.deepStrictEqual(resolvePython({ platform: 'win32', probe: probeFor(['python', 'py -3']) }), { command: 'python', prefix: [] });
assert.deepStrictEqual(resolvePython({ platform: 'win32', probe: probeFor(['python3']) }), { command: 'python3', prefix: [] });
})) passed++; else failed++;
if (test('prefers python3 off Windows', () => {
assert.deepStrictEqual(resolvePython({ platform: 'linux', probe: probeFor(['python3', 'python']) }), { command: 'python3', prefix: [] });
assert.deepStrictEqual(resolvePython({ platform: 'darwin', probe: probeFor(['python']) }), { command: 'python', prefix: [] });
})) passed++; else failed++;
if (test('returns null when no candidate runs Python 3', () => {
assert.strictEqual(resolvePython({ platform: 'win32', probe: () => false }), null);
assert.strictEqual(resolvePython({ platform: 'linux', probe: () => false }), null);
})) passed++; else failed++;
if (test('real probe accepts success and preserves Windows launcher arguments', () => {
const calls = [];
const helper = loadWithSpawn((command, args, options) => {
calls.push({ command, args: Array.from(args), options: { ...options } });
return { status: command === 'py' ? 0 : 9009 };
});
const python = helper.resolvePython({ platform: 'win32' });
assert.strictEqual(python.command, 'py');
assert.deepStrictEqual(Array.from(python.prefix), ['-3']);
assert.deepStrictEqual(calls.map(call => call.command), ['python', 'py']);
assert.deepStrictEqual(calls[1].args.slice(0, 2), ['-3', '-c']);
assert.deepStrictEqual(calls[1].options, { encoding: 'utf8', timeout: 15000 });
})) passed++; else failed++;
if (test('real probe rejects nonzero, missing commands and timed out processes', () => {
for (const result of [
{ status: 1 },
{ status: 9009 },
{ status: null, error: Object.assign(new Error('not found'), { code: 'ENOENT' }) },
{ status: null, error: Object.assign(new Error('timed out'), { code: 'ETIMEDOUT' }) },
]) {
const calls = [];
const helper = loadWithSpawn(command => { calls.push(command); return result; });
assert.strictEqual(helper.resolvePython({ platform: 'win32' }), null);
assert.deepStrictEqual(calls, ['python', 'py', 'python3']);
}
})) passed++; else failed++;
if (test('real probe code succeeds under known Python 3 and rejects Python 2', () => {
// Select independently so broken discovery cannot silently skip this control.
const known = [['python3'], ['python'], ['py', '-3']].find(([command, ...prefix]) => {
const result = spawnSync(command, [...prefix, '-c', 'import sys; print(sys.version_info[0])'], { encoding: 'utf8', timeout: 15000 });
return result.status === 0 && result.stdout.trim() === '3';
});
assert.ok(known, 'Python 3 is required for the Hermes test workflow');
for (const [major, expected] of [[3, true], [2, false]]) {
const helper = loadWithSpawn((_command, args, options) => {
const code = args[args.indexOf('-c') + 1];
const wrapped = `import sys; sys.version_info = (${major}, 0);\n${code}`;
return spawnSync(known[0], [...known.slice(1), '-c', wrapped], options);
});
assert.strictEqual(helper.resolvePython({ platform: 'win32' }) !== null, expected);
}
})) passed++; else failed++;
if (test('a resolved interpreter actually runs Python 3 here', () => {
const python = resolvePython();
if (!python) return;
const run = spawnSync(python.command, [...python.prefix, '-c', 'import sys; print(sys.version_info[0])'], { encoding: 'utf8' });
assert.strictEqual(run.status, 0, run.stderr);
assert.strictEqual(run.stdout.trim(), '3');
})) passed++; else failed++;
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
+2 -1
View File
@@ -187,6 +187,7 @@ function runHermeticPreCommit({ content = 'safe text\n', filename = 'sample.txt'
const staged = spawnSync('git', ['add', '--', filename], { cwd: tempDir, env: hookEnv });
assert.strictEqual(staged.status, 0, staged.stderr?.toString());
}
// Synthetic scanners drain staged input before returning so pipefail sees their intended status.
// Keep real Git/awk available, while hiding every installed ripgrep binary.
fs.writeFileSync(bashEnv, `
git_path="$(command -v git)"
@@ -206,7 +207,7 @@ unset -f rg
${scanner === 'real' ? '# Preserve dependencies used by an actual scanner executable or wrapper.' : `PATH="${toBashPath(binDir)}"`}
${scanner ? `rg() {
printf '%s\\n' "$*" >> "${toBashPath(callsPath)}"
${scanner === 'real' ? '"$rg_path" "$@"' : scanner === 'finding' ? "printf '1:synthetic-scanner-finding\\n'; return 0;" : scanner === 'error' ? 'return 2;' : 'return 1;'}
${scanner === 'real' ? '' : 'cat >/dev/null; '}${scanner === 'real' ? '"$rg_path" "$@"' : scanner === 'finding' ? "printf '1:synthetic-scanner-finding\\n'; return 0;" : scanner === 'error' ? 'return 2;' : 'return 1;'}
}` : ''}
`);
const result = runBash(preCommitHook, {
+22
View File
@@ -93,6 +93,28 @@ async function runTests() {
let passed = 0;
let failed = 0;
if (
await test('rejects malformed issue numbers during argument parsing', async () => {
const { parseArgs } = require(SCRIPT);
for (const argv of [
['node', 'github-coordination.js', 'claim', '42oops', '--repo', 'example/project'],
['node', 'github-coordination.js', 'claim', '--issue', '42.9', '--repo', 'example/project'],
['node', 'github-coordination.js', 'claim', '--issue', '4e2', '--repo', 'example/project'],
['node', 'github-coordination.js', 'claim', '--issue', '9007199254740993', '--repo', 'example/project'],
['node', 'github-coordination.js', 'claim', '--limit', '42.9', '--repo', 'example/project'],
]) {
assert.throws(() => parseArgs(argv), /Invalid issue number/);
}
const valid = parseArgs(['node', 'github-coordination.js', 'claim', '7', '--repo', 'example/project']);
assert.strictEqual(valid.issueNumber, 7);
assert.strictEqual(valid.limit, 100);
})
)
passed++;
else failed++;
if (
await test('claims an epic issue, updates GitHub state, and caches a work item', async () => {
const rootDir = createTempDir('github-coordination-claim-');
+1
View File
@@ -646,6 +646,7 @@ test('migration JSON failures retain phase, scopes, and exact recovery', () => {
assert.strictEqual(payload.error.phase, 'source-uninstall');
assert.deepStrictEqual([...payload.error.observedScopes].sort(), ['project', 'user']);
assert.deepStrictEqual(payload.error.recovery, [
'claude plugin list --json',
'claude plugin uninstall ecc@ecc --scope user --keep-data',
'ecc setup --mode claude-plugin --scope project --move-scope --yes',
]);
+169
View File
@@ -0,0 +1,169 @@
"""Input-mode regressions for the standalone agent output evaluator."""
import argparse
import errno
import importlib.util
import io
import re
import subprocess
import sys
from pathlib import Path
from types import ModuleType, SimpleNamespace
from typing import NoReturn
import pytest
SCRIPT = Path(__file__).resolve().parents[1] / "skills/agent-self-evaluation/scripts/evaluate.py"
PI_SCRIPT = SCRIPT.parents[3] / "pi/core/skills/agent-self-evaluation/scripts/evaluate.py"
def args(**overrides: object) -> argparse.Namespace:
return argparse.Namespace(**{"interactive": False, "task": None, "output": None, **overrides})
def test_output_file_uses_piped_task(evaluator: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
output = tmp_path / "response.txt"
output.write_text("All tests passed.", encoding="utf-8")
monkeypatch.setattr(sys, "stdin", io.StringIO("Add retry logic"))
assert evaluator._read_input(args(output=str(output))) == ("Add retry logic", "All tests passed.")
def test_explicit_task_takes_precedence_over_pipe(evaluator: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
output = tmp_path / "response.txt"
output.write_text("Answer", encoding="utf-8")
stream = io.StringIO("Ignored pipe")
monkeypatch.setattr(sys, "stdin", stream)
assert evaluator._read_input(args(task="Explicit task", output=str(output))) == ("Explicit task", "Answer")
assert stream.tell() == 0
def test_output_file_without_task_does_not_read_terminal(evaluator: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
output = tmp_path / "response.txt"
output.write_text("Answer", encoding="utf-8")
monkeypatch.setattr(sys, "stdin", SimpleNamespace(isatty=lambda: True))
assert evaluator._read_input(args(output=str(output))) == (None, "Answer")
def test_no_output_file_keeps_stdin_as_output(evaluator: ModuleType, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(sys, "stdin", io.StringIO("Piped answer"))
assert evaluator._read_input(args(task="Inline task")) == ("Inline task", "Piped answer")
def test_interactive_mode_keeps_prompt_and_stdin(evaluator: ModuleType, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("builtins.input", lambda _prompt: " Interactive task ")
monkeypatch.setattr(sys, "stdin", io.StringIO("Interactive answer"))
assert evaluator._read_input(args(interactive=True)) == ("Interactive task", "Interactive answer")
def test_task_can_still_be_read_from_file(evaluator: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
task = tmp_path / "task.txt"
task.write_text("File task", encoding="utf-8")
monkeypatch.setattr(sys, "stdin", io.StringIO("Piped answer"))
assert evaluator._read_input(args(task=str(task))) == ("File task", "Piped answer")
def test_missing_output_file_still_fails(evaluator: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
monkeypatch.setattr(sys, "stdin", io.StringIO("Task"))
with pytest.raises(SystemExit) as exc_info:
evaluator._read_input(args(output=str(tmp_path / "missing.txt")))
assert exc_info.value.code == 1
assert "not found" in capsys.readouterr().err
@pytest.mark.parametrize("output_kind", ["missing", "directory"])
def test_invalid_output_is_rejected_before_reading_pipe(
evaluator: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, output_kind: str
) -> None:
output = tmp_path / "output.txt"
if output_kind == "directory":
output.mkdir()
monkeypatch.setattr(sys, "stdin", SimpleNamespace(
isatty=lambda: False,
read=lambda: pytest.fail("Required output must be validated before waiting for piped input"),
))
error = (IsADirectoryError, PermissionError) if output_kind == "directory" else SystemExit
with pytest.raises(error):
evaluator._read_input(args(output=str(output)))
def test_cli_uses_piped_task_for_conciseness_score(evaluator: ModuleType, tmp_path: Path) -> None:
output = tmp_path / "response.txt"
output.write_text("Verified with tests. " * 25, encoding="utf-8")
result = subprocess.run(
[sys.executable, "-X", "utf8", str(evaluator.__file__), "--output", str(output)],
input="Fix bug",
capture_output=True,
encoding="utf-8",
check=True,
timeout=10,
)
assert re.search(r"Conciseness[^\n]*3/5", result.stdout)
def test_long_inline_task_remains_text(evaluator: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
output = tmp_path / "response.txt"
output.write_text("Answer", encoding="utf-8")
task = "Long explicit task description. " * 60
monkeypatch.setattr(sys, "stdin", io.StringIO("Ignored pipe"))
assert evaluator._read_input(args(task=task, output=str(output))) == (task, "Answer")
@pytest.mark.parametrize("error_code", [errno.EACCES, errno.EISDIR])
def test_task_read_does_not_hide_other_file_errors(evaluator: ModuleType, monkeypatch: pytest.MonkeyPatch, error_code: int) -> None:
def fail_open(_path: str) -> NoReturn:
raise OSError(error_code, "Cannot read task")
monkeypatch.setattr("builtins.open", fail_open)
with pytest.raises(OSError) as captured:
evaluator._read_file_or_text("task-file")
assert captured.value.errno == error_code
@pytest.fixture(params=[SCRIPT, PI_SCRIPT], ids=["canonical", "pi"])
def evaluator(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch) -> ModuleType:
spec = importlib.util.spec_from_file_location("task_input_evaluator", request.param)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
monkeypatch.setitem(sys.modules, spec.name, module)
spec.loader.exec_module(module)
return module
@pytest.mark.parametrize("platform", ["win32", "linux", "darwin"])
@pytest.mark.parametrize("required", [False, True], ids=["optional", "required"])
@pytest.mark.parametrize(
"error_code", [errno.EINVAL, errno.ENOENT, errno.ENAMETOOLONG, errno.EACCES, errno.EISDIR, errno.EIO]
)
def test_file_error_fallback_is_platform_and_mode_scoped(
evaluator: ModuleType,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
platform: str,
required: bool,
error_code: int,
) -> None:
task = "Long inline task. " * 60
error = OSError(error_code, "Cannot read file")
def fail_open(_path: str) -> NoReturn:
raise error
monkeypatch.setattr(evaluator.sys, "platform", platform)
monkeypatch.setattr("builtins.open", fail_open)
can_fallback = error_code in (errno.ENOENT, errno.ENAMETOOLONG) or (
error_code == errno.EINVAL and platform == "win32" and not required
)
if can_fallback and not required:
assert evaluator._read_file_or_text(task) == task
elif can_fallback:
with pytest.raises(SystemExit) as captured_exit:
evaluator._read_file_or_text(task, required=True)
assert captured_exit.value.code == 1
assert "not found" in capsys.readouterr().err
else:
with pytest.raises(OSError) as captured_error:
evaluator._read_file_or_text(task, required=required)
assert captured_error.value is error