@vibe-agent-toolkit/agent-skills 0.1.39-rc.9 → 0.1.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/files-config.d.ts +61 -0
- package/dist/files-config.d.ts.map +1 -1
- package/dist/files-config.js +262 -6
- package/dist/files-config.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/plugin-distribution-layout.d.ts +70 -0
- package/dist/plugin-distribution-layout.d.ts.map +1 -0
- package/dist/plugin-distribution-layout.js +97 -0
- package/dist/plugin-distribution-layout.js.map +1 -0
- package/dist/skill-packager.d.ts +43 -1
- package/dist/skill-packager.d.ts.map +1 -1
- package/dist/skill-packager.js +131 -53
- package/dist/skill-packager.js.map +1 -1
- package/dist/skill-source/git-clone.d.ts +2 -2
- package/dist/skill-source/git-clone.d.ts.map +1 -1
- package/dist/skill-source/git-clone.js +33 -10
- package/dist/skill-source/git-clone.js.map +1 -1
- package/dist/skill-source/sources/url-source.d.ts.map +1 -1
- package/dist/skill-source/sources/url-source.js +23 -8
- package/dist/skill-source/sources/url-source.js.map +1 -1
- package/dist/skill-source/sources/workspace-source.d.ts.map +1 -1
- package/dist/skill-source/sources/workspace-source.js +16 -8
- package/dist/skill-source/sources/workspace-source.js.map +1 -1
- package/dist/skill-source/stage.js +6 -2
- package/dist/skill-source/stage.js.map +1 -1
- package/dist/skill-test/configure-writer.d.ts +19 -11
- package/dist/skill-test/configure-writer.d.ts.map +1 -1
- package/dist/skill-test/configure-writer.js +23 -15
- package/dist/skill-test/configure-writer.js.map +1 -1
- package/dist/skill-test/declared-env.d.ts +72 -0
- package/dist/skill-test/declared-env.d.ts.map +1 -0
- package/dist/skill-test/declared-env.js +85 -0
- package/dist/skill-test/declared-env.js.map +1 -0
- package/dist/skill-test/eval-inputs.d.ts +159 -0
- package/dist/skill-test/eval-inputs.d.ts.map +1 -0
- package/dist/skill-test/eval-inputs.js +158 -0
- package/dist/skill-test/eval-inputs.js.map +1 -0
- package/dist/skill-test/evals-template.d.ts.map +1 -1
- package/dist/skill-test/evals-template.js +27 -10
- package/dist/skill-test/evals-template.js.map +1 -1
- package/dist/skill-test/exit-codes.d.ts +43 -5
- package/dist/skill-test/exit-codes.d.ts.map +1 -1
- package/dist/skill-test/exit-codes.js +60 -9
- package/dist/skill-test/exit-codes.js.map +1 -1
- package/dist/skill-test/experimenter-prompt.d.ts +22 -0
- package/dist/skill-test/experimenter-prompt.d.ts.map +1 -1
- package/dist/skill-test/experimenter-prompt.js +38 -2
- package/dist/skill-test/experimenter-prompt.js.map +1 -1
- package/dist/skill-test/grading-adapter.d.ts +42 -0
- package/dist/skill-test/grading-adapter.d.ts.map +1 -1
- package/dist/skill-test/grading-adapter.js +63 -1
- package/dist/skill-test/grading-adapter.js.map +1 -1
- package/dist/skill-test/grading-schema.d.ts +73 -5
- package/dist/skill-test/grading-schema.d.ts.map +1 -1
- package/dist/skill-test/grading-schema.js +20 -5
- package/dist/skill-test/grading-schema.js.map +1 -1
- package/dist/skill-test/harness-location.d.ts +22 -4
- package/dist/skill-test/harness-location.d.ts.map +1 -1
- package/dist/skill-test/harness-location.js +83 -18
- package/dist/skill-test/harness-location.js.map +1 -1
- package/dist/skill-test/index.d.ts +7 -4
- package/dist/skill-test/index.d.ts.map +1 -1
- package/dist/skill-test/index.js +7 -4
- package/dist/skill-test/index.js.map +1 -1
- package/dist/skill-test/lock.d.ts +23 -0
- package/dist/skill-test/lock.d.ts.map +1 -1
- package/dist/skill-test/lock.js +34 -0
- package/dist/skill-test/lock.js.map +1 -1
- package/dist/skill-test/preflight.d.ts.map +1 -1
- package/dist/skill-test/preflight.js +1 -2
- package/dist/skill-test/preflight.js.map +1 -1
- package/dist/skill-test/run-harness.d.ts +193 -19
- package/dist/skill-test/run-harness.d.ts.map +1 -1
- package/dist/skill-test/run-harness.js +366 -76
- package/dist/skill-test/run-harness.js.map +1 -1
- package/dist/skill-test/staging.d.ts.map +1 -1
- package/dist/skill-test/staging.js +31 -15
- package/dist/skill-test/staging.js.map +1 -1
- package/dist/skill-test/vendor-manifest.d.ts +3 -3
- package/dist/skill-test/vendor-manifest.d.ts.map +1 -1
- package/dist/skill-test/vendor-manifest.js +13 -3
- package/dist/skill-test/vendor-manifest.js.map +1 -1
- package/dist/validators/packaging-validator.d.ts.map +1 -1
- package/dist/validators/packaging-validator.js +134 -1
- package/dist/validators/packaging-validator.js.map +1 -1
- package/dist/validators/skill-validator.d.ts.map +1 -1
- package/dist/validators/skill-validator.js +18 -14
- package/dist/validators/skill-validator.js.map +1 -1
- package/dist/walk-link-graph.d.ts.map +1 -1
- package/dist/walk-link-graph.js +26 -1
- package/dist/walk-link-graph.js.map +1 -1
- package/package.json +6 -5
|
@@ -8,19 +8,20 @@
|
|
|
8
8
|
* → build effective prompt + assertPromptInvariants → dry-run short-circuit
|
|
9
9
|
* → spawnHeadlessClaude → parseGradingJson → release lock → return result
|
|
10
10
|
*/
|
|
11
|
-
import {
|
|
11
|
+
import { randomBytes } from 'node:crypto';
|
|
12
|
+
import { cpSync, existsSync, lstatSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
12
13
|
import { basename, dirname } from 'node:path';
|
|
13
14
|
import { fileURLToPath } from 'node:url';
|
|
14
15
|
import { getToolVersion, mkdirSyncReal, normalizedTmpdir, probeAuthStatus, resolveAssetReference, safeExecResult, safePath, spawnHeadlessClaude, toForwardSlash, } from '@vibe-agent-toolkit/utils';
|
|
15
16
|
import { resolveSkillSource } from '../skill-source/resolve-skill-source.js';
|
|
16
|
-
import {
|
|
17
|
+
import { assembleChildEnv, computeEnvTokens, resolveInjectEnv } from './declared-env.js';
|
|
18
|
+
import { EvalInputError, parseEvalSuite, stageEvalWorkspaces } from './eval-inputs.js';
|
|
17
19
|
import { writeEvalsTemplate } from './evals-template.js';
|
|
18
|
-
import { BootstrapNeededError, InternalHarnessError, SkillTestExitCode } from './exit-codes.js';
|
|
19
|
-
import { assertPromptInvariants, buildExperimenterPrompt, } from './experimenter-prompt.js';
|
|
20
|
-
import { parseGradingJson } from './grading-adapter.js';
|
|
20
|
+
import { BootstrapNeededError, InternalHarnessError, SkillTestExitCode, } from './exit-codes.js';
|
|
21
|
+
import { appendIntegrityNonceDirective, assertPromptInvariants, buildExperimenterPrompt, redactNonce, } from './experimenter-prompt.js';
|
|
22
|
+
import { assertGradingNonce, parseGradingJson, reconcileGrading } from './grading-adapter.js';
|
|
21
23
|
import { assertSafeHarnessRoot, assertSafeWorkdir, prepareHarnessRoot, resolveHarnessRoot } from './harness-location.js';
|
|
22
|
-
import { acquireHarnessLock } from './lock.js';
|
|
23
|
-
import { withPluginRootEnv } from './plugin-env.js';
|
|
24
|
+
import { acquireHarnessLock, installSignalCleanup } from './lock.js';
|
|
24
25
|
import { detectPluginLayout } from './plugin-layout.js';
|
|
25
26
|
import { runPreflight } from './preflight.js';
|
|
26
27
|
import { descriptorToSource, stageHarness } from './staging.js';
|
|
@@ -41,13 +42,42 @@ const VENDORED_SKILL_CREATOR_DIR = safePath.resolve(dirname(fileURLToPath(import
|
|
|
41
42
|
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
|
42
43
|
const DEFAULT_MAX_TURNS = 50;
|
|
43
44
|
const DEFAULT_MAX_BUDGET_USD = 5;
|
|
44
|
-
|
|
45
|
+
/**
|
|
46
|
+
* Built-in cost/runtime safety ceilings for the experimenter spawn. These are the
|
|
47
|
+
* SAME values the harness applies as defaults, but exported as an explicit cap so
|
|
48
|
+
* the CLI precedence layer (run.ts) can enforce a critical asymmetry:
|
|
49
|
+
*
|
|
50
|
+
* - a CLI flag (explicit operator intent, typed at the terminal for THIS run)
|
|
51
|
+
* may RAISE a knob above the built-in ceiling;
|
|
52
|
+
* - a value sourced from a committed `test.*` config (which rides along in an
|
|
53
|
+
* untrusted subject repo you may only be testing) may only LOWER a ceiling,
|
|
54
|
+
* never raise it — so cloning + testing a hostile skill can't silently
|
|
55
|
+
* escalate the $5 / 50-turn / 5-minute budget the run bills against.
|
|
56
|
+
*
|
|
57
|
+
* `timeoutSeconds` is expressed in seconds to match the `--timeout`/`test.timeout`
|
|
58
|
+
* unit (the harness multiplies by 1000 internally; see resolveTimeoutMs).
|
|
59
|
+
*/
|
|
60
|
+
export const SKILL_TEST_BUILTIN_CAPS = {
|
|
61
|
+
maxTurns: DEFAULT_MAX_TURNS,
|
|
62
|
+
maxBudgetUsd: DEFAULT_MAX_BUDGET_USD,
|
|
63
|
+
timeoutSeconds: DEFAULT_TIMEOUT_MS / 1000,
|
|
64
|
+
};
|
|
65
|
+
export function resolveTimeoutMs(opts) {
|
|
45
66
|
return opts.timeout === undefined ? DEFAULT_TIMEOUT_MS : opts.timeout * 1000;
|
|
46
67
|
}
|
|
47
|
-
|
|
68
|
+
/**
|
|
69
|
+
* Map an eval verdict to a process exit code. Default behavior: a completed run
|
|
70
|
+
* exits Ok regardless of pass/fail (the count lives in the summary). When
|
|
71
|
+
* `failOnEvalFailure` is set, a failing verdict escalates to EvalFailure (4) so
|
|
72
|
+
* CI can gate on eval outcomes without conflating them with harness breakage.
|
|
73
|
+
*/
|
|
74
|
+
export function verdictExitCode(allPassed, failOnEvalFailure) {
|
|
75
|
+
return failOnEvalFailure && !allPassed ? SkillTestExitCode.EvalFailure : SkillTestExitCode.Ok;
|
|
76
|
+
}
|
|
77
|
+
export function resolveStallMs(opts) {
|
|
48
78
|
return opts.stall === undefined ? undefined : opts.stall * 1000;
|
|
49
79
|
}
|
|
50
|
-
function resolveKnobs(opts) {
|
|
80
|
+
export function resolveKnobs(opts) {
|
|
51
81
|
const result = {
|
|
52
82
|
maxTurns: opts.maxTurns ?? DEFAULT_MAX_TURNS,
|
|
53
83
|
maxBudgetUsd: opts.maxBudgetUsd ?? DEFAULT_MAX_BUDGET_USD,
|
|
@@ -67,13 +97,13 @@ function resolveKnobs(opts) {
|
|
|
67
97
|
* sources (npm/url/vendored/workspace) have no local source tree to walk, so they
|
|
68
98
|
* are always staged flat (returns undefined). undefined → flat staging.
|
|
69
99
|
*/
|
|
70
|
-
function detectItemPluginLayout(source, repoRoot) {
|
|
100
|
+
export function detectItemPluginLayout(source, repoRoot) {
|
|
71
101
|
if (!('path' in source))
|
|
72
102
|
return undefined;
|
|
73
103
|
const sourceDir = resolveAssetReference(source.path, repoRoot);
|
|
74
104
|
return detectPluginLayout(sourceDir, existsSync) ?? undefined;
|
|
75
105
|
}
|
|
76
|
-
function makeStageItem(name, source, repoRoot, role) {
|
|
106
|
+
export function makeStageItem(name, source, repoRoot, role) {
|
|
77
107
|
const pluginLayout = detectItemPluginLayout(source, repoRoot);
|
|
78
108
|
return {
|
|
79
109
|
name,
|
|
@@ -82,16 +112,23 @@ function makeStageItem(name, source, repoRoot, role) {
|
|
|
82
112
|
...(pluginLayout === undefined ? {} : { pluginLayout }),
|
|
83
113
|
};
|
|
84
114
|
}
|
|
85
|
-
function buildStageItems(opts, repoRoot) {
|
|
115
|
+
export function buildStageItems(opts, repoRoot) {
|
|
86
116
|
const items = [];
|
|
87
117
|
// The FIRST positional skill is the subject under test; the rest of the
|
|
88
118
|
// primary set and any `--with`/`--with-optional` deps are supporting context.
|
|
89
119
|
const subjectName = opts.skills[0];
|
|
90
120
|
for (const name of opts.skills) {
|
|
91
121
|
const override = opts.withSources?.[name];
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
122
|
+
let source;
|
|
123
|
+
if (name === subjectName && opts.subjectSource !== undefined) {
|
|
124
|
+
source = opts.subjectSource;
|
|
125
|
+
}
|
|
126
|
+
else if (override) {
|
|
127
|
+
source = descriptorToSource(override);
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
source = { path: name };
|
|
131
|
+
}
|
|
95
132
|
items.push(makeStageItem(name, source, repoRoot, name === subjectName ? 'subject' : undefined));
|
|
96
133
|
}
|
|
97
134
|
if (opts.withOptional !== undefined) {
|
|
@@ -102,7 +139,7 @@ function buildStageItems(opts, repoRoot) {
|
|
|
102
139
|
}
|
|
103
140
|
return items;
|
|
104
141
|
}
|
|
105
|
-
function buildResolveCtx(harnessRoot, repoRoot) {
|
|
142
|
+
export function buildResolveCtx(harnessRoot, repoRoot) {
|
|
106
143
|
return {
|
|
107
144
|
repoRoot,
|
|
108
145
|
stagingRoot: safePath.joinUnderRoot(harnessRoot, 'staged'),
|
|
@@ -128,7 +165,7 @@ const FLAG_DUMMY_VALUES = {
|
|
|
128
165
|
* Return a sensible dummy value for a given CLI flag so that value-validation
|
|
129
166
|
* doesn't reject the argument before `--help` can short-circuit the session.
|
|
130
167
|
*/
|
|
131
|
-
function flagDummyValueFor(flag) {
|
|
168
|
+
export function flagDummyValueFor(flag) {
|
|
132
169
|
return FLAG_DUMMY_VALUES[flag] ?? '1';
|
|
133
170
|
}
|
|
134
171
|
/**
|
|
@@ -156,7 +193,7 @@ function buildFlagParseProbe() {
|
|
|
156
193
|
return result.success;
|
|
157
194
|
};
|
|
158
195
|
}
|
|
159
|
-
function buildPreflightInput(evalsPath, pluginDirs, opts, knobs) {
|
|
196
|
+
export function buildPreflightInput(evalsPath, pluginDirs, opts, knobs) {
|
|
160
197
|
const costEstimate = {
|
|
161
198
|
evalCount: 1,
|
|
162
199
|
configurations: 1,
|
|
@@ -185,13 +222,20 @@ function buildPreflightInput(evalsPath, pluginDirs, opts, knobs) {
|
|
|
185
222
|
preflightOpts.requireAuth = opts.requireAuth;
|
|
186
223
|
return preflightOpts;
|
|
187
224
|
}
|
|
188
|
-
function renderPreflightSummary(checks) {
|
|
225
|
+
export function renderPreflightSummary(checks) {
|
|
189
226
|
const failed = checks.filter(c => !c.passed);
|
|
190
227
|
return failed.length > 0
|
|
191
228
|
? failed.map(c => ` [FAIL] ${c.name}: ${c.message}`).join('\n')
|
|
192
229
|
: ' All preflight checks passed.';
|
|
193
230
|
}
|
|
194
|
-
|
|
231
|
+
/**
|
|
232
|
+
* The single source of truth for "is this run acknowledged?": a dry-run never
|
|
233
|
+
* executes skill code (so it is implicitly acknowledged), otherwise the caller
|
|
234
|
+
* must pass --i-understand-this-runs-skill-code. Narrowed to the two fields it
|
|
235
|
+
* reads so run.ts can reuse the SAME predicate to gate the pre-build ack check
|
|
236
|
+
* (the harness Step-6 check and the run.ts pre-build check cannot diverge).
|
|
237
|
+
*/
|
|
238
|
+
export function isAcknowledged(opts) {
|
|
195
239
|
return opts.dryRun === true || opts.acknowledgedRunsSkillCode === true;
|
|
196
240
|
}
|
|
197
241
|
/**
|
|
@@ -199,7 +243,7 @@ function isAcknowledged(opts) {
|
|
|
199
243
|
* A stall, a timeout, OR a non-zero exit are each authoritative — a non-zero exit
|
|
200
244
|
* is never laundered into a PASS even if a grading.json happens to be on disk.
|
|
201
245
|
*/
|
|
202
|
-
function assertExperimenterSucceeded(spawnResult, stallMs, timeoutMs) {
|
|
246
|
+
export function assertExperimenterSucceeded(spawnResult, stallMs, timeoutMs) {
|
|
203
247
|
if (spawnResult.stalled) {
|
|
204
248
|
throw new InternalHarnessError(`Experimenter stalled (no output for ${stallMs ?? 0}ms).`);
|
|
205
249
|
}
|
|
@@ -217,20 +261,197 @@ function assertExperimenterSucceeded(spawnResult, stallMs, timeoutMs) {
|
|
|
217
261
|
* that real source dir; otherwise we anchor under the repo root by skill name.
|
|
218
262
|
*/
|
|
219
263
|
/** The subject skill's display name (trailing segment of the positional skill arg). */
|
|
220
|
-
function subjectSkillName(opts) {
|
|
264
|
+
export function subjectSkillName(opts) {
|
|
221
265
|
const subject = opts.skills[0] ?? 'skill';
|
|
222
266
|
return basename(toForwardSlash(subject)) || subject;
|
|
223
267
|
}
|
|
224
|
-
function resolveScaffoldEvalsPath(opts, repoRoot, evalsSubpath) {
|
|
268
|
+
export function resolveScaffoldEvalsPath(opts, repoRoot, evalsSubpath) {
|
|
269
|
+
// Prefer the explicit authored source dir resolved by run.ts (the staged/built
|
|
270
|
+
// tree is ephemeral; this is where the user can edit the scaffolded template).
|
|
271
|
+
if (opts.subjectScaffoldDir !== undefined) {
|
|
272
|
+
return safePath.join(opts.subjectScaffoldDir, evalsSubpath);
|
|
273
|
+
}
|
|
225
274
|
const subjectName = opts.skills[0] ?? '';
|
|
226
275
|
const override = opts.withSources?.[subjectName];
|
|
227
|
-
const overridePath = override &&
|
|
276
|
+
const overridePath = override && 'path' in override ? override.path : undefined;
|
|
228
277
|
// Default (no override) resolution treats the positional name as a path.
|
|
229
278
|
const sourcePath = overridePath ?? subjectName;
|
|
230
279
|
// eslint-disable-next-line local/no-unsafe-root-join -- the positional skill source may be an absolute path; resolving it against repoRoot (which returns an absolute sourcePath unchanged) is intentional, documented behavior, not a containment bug.
|
|
231
280
|
const sourceDir = safePath.resolve(repoRoot, sourcePath);
|
|
232
281
|
return safePath.join(sourceDir, evalsSubpath);
|
|
233
282
|
}
|
|
283
|
+
/**
|
|
284
|
+
* Bootstrap (exit 3) when the eval suite is absent everywhere. A real run writes
|
|
285
|
+
* a persistent template at the source scaffold location and reports it; a dry run
|
|
286
|
+
* must never touch the filesystem, so it reports where a real run *would* scaffold
|
|
287
|
+
* and writes nothing. Both surface the same exit-3 BootstrapNeededError. No-op when
|
|
288
|
+
* `evalsPath` already exists.
|
|
289
|
+
*/
|
|
290
|
+
function bootstrapEvalSuiteIfMissing(opts, repoRoot, evalsSubpath, evalsPath) {
|
|
291
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename -- our own staged-subject path
|
|
292
|
+
if (existsSync(evalsPath)) {
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const scaffoldPath = resolveScaffoldEvalsPath(opts, repoRoot, evalsSubpath);
|
|
296
|
+
if (opts.dryRun === true) {
|
|
297
|
+
throw new BootstrapNeededError(scaffoldPath, { dryRun: true });
|
|
298
|
+
}
|
|
299
|
+
throw new BootstrapNeededError(writeEvalsTemplate(scaffoldPath, subjectSkillName(opts)));
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* The eval suite (`evals/`, incl. `fixtures/`) is authored TEST INPUT, not a
|
|
303
|
+
* shipped artifact — a built/dist subject won't carry it (packageSkill bundles
|
|
304
|
+
* only link-reachable resources + `files:`). Since the harness reads evals and
|
|
305
|
+
* fixtures relative to the staged subject, overlay the authored suite from the
|
|
306
|
+
* scaffold (source) dir onto the staged subject when it lacks it. No-op when there
|
|
307
|
+
* is no scaffold dir, the suite subpath has no directory, or the staged subject
|
|
308
|
+
* already carries the suite — so authored evals are never overwritten.
|
|
309
|
+
*/
|
|
310
|
+
function overlayAuthoredEvalSuite(opts, subjectStagedDir, evalsSubpath) {
|
|
311
|
+
const evalsDir = dirname(evalsSubpath);
|
|
312
|
+
if (evalsDir === '.' || opts.subjectScaffoldDir === undefined)
|
|
313
|
+
return;
|
|
314
|
+
const scaffoldEvalsDir = safePath.join(opts.subjectScaffoldDir, evalsDir);
|
|
315
|
+
const stagedEvalsDir = safePath.join(subjectStagedDir, evalsDir);
|
|
316
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename -- our own staged/scaffold paths
|
|
317
|
+
if (!existsSync(stagedEvalsDir) && existsSync(scaffoldEvalsDir)) {
|
|
318
|
+
cpSync(scaffoldEvalsDir, stagedEvalsDir, { recursive: true });
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
/** Parse the staged eval suite and materialize each eval's input `files` into
|
|
322
|
+
* `<harnessRoot>/workspaces/<id>/`. Returns the workspaces root. The dir is wiped
|
|
323
|
+
* first so a reused harness root cannot leak a prior run's inputs. Throws
|
|
324
|
+
* {@link EvalInputError} (mapped by the caller to exit 2) on a bad suite or a
|
|
325
|
+
* missing input file. */
|
|
326
|
+
export function stageWorkspacesForRun(evalsPath, harnessRoot) {
|
|
327
|
+
const workspacesRoot = safePath.joinUnderRoot(harnessRoot, 'workspaces');
|
|
328
|
+
rmSync(workspacesRoot, { recursive: true, force: true });
|
|
329
|
+
mkdirSyncReal(workspacesRoot, { recursive: true });
|
|
330
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename -- evalsPath is our staged-subject path
|
|
331
|
+
const suite = parseEvalSuite(readFileSync(evalsPath, 'utf-8'));
|
|
332
|
+
return stageEvalWorkspaces({ suite, evalsDir: dirname(evalsPath), workspacesRoot });
|
|
333
|
+
}
|
|
334
|
+
/** Format the model flag string for logging and dry-run summary output. */
|
|
335
|
+
function buildModelFlag(model) {
|
|
336
|
+
return model === undefined ? '(no --model; claude default)' : `--model ${model}`;
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Wrap {@link stageWorkspacesForRun} for the orchestrator: returns the
|
|
340
|
+
* `workspacesRoot` string on success, or a {@link RunHarnessResult} early-return
|
|
341
|
+
* value when the suite is invalid or a declared input file is missing. Any other
|
|
342
|
+
* error is re-thrown so it propagates as an InternalHarnessError upstream.
|
|
343
|
+
* Keeping the try/catch in a private helper avoids inflating the orchestrator's
|
|
344
|
+
* cognitive complexity.
|
|
345
|
+
*/
|
|
346
|
+
function attemptStageWorkspaces(evalsPath, harnessRoot) {
|
|
347
|
+
try {
|
|
348
|
+
return { workspacesRoot: stageWorkspacesForRun(evalsPath, harnessRoot) };
|
|
349
|
+
}
|
|
350
|
+
catch (e) {
|
|
351
|
+
if (e instanceof EvalInputError) {
|
|
352
|
+
return {
|
|
353
|
+
harnessPath: harnessRoot,
|
|
354
|
+
exitCode: SkillTestExitCode.Preflight,
|
|
355
|
+
summary: `Eval input error:\n ${e.message}`,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
throw e;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Assemble the experimenter's child env: the scrubbed forwarded env unioned with
|
|
363
|
+
* the declared test env (Features A + B) and CLAUDE_PLUGIN_ROOT, then emit the
|
|
364
|
+
* transparency line and any protected-key collision warnings to stderr.
|
|
365
|
+
*
|
|
366
|
+
* Refuses to assemble without a resolved auth — a null here is an internal
|
|
367
|
+
* invariant violation (preflight passed), never a reason to fall back to
|
|
368
|
+
* process.env, which would hand untrusted skill code every secret it contains.
|
|
369
|
+
*/
|
|
370
|
+
function resolveDeclaredChildEnv(input) {
|
|
371
|
+
if (input.resolvedAuth === null) {
|
|
372
|
+
throw new InternalHarnessError('Internal: preflight passed but resolvedAuth is null — refusing to spawn with an unscrubbed environment.');
|
|
373
|
+
}
|
|
374
|
+
const envTokens = computeEnvTokens({
|
|
375
|
+
subjectStagedDir: input.subjectStagedDir,
|
|
376
|
+
harnessRoot: input.harnessRoot,
|
|
377
|
+
resultsDir: input.resultsDir,
|
|
378
|
+
evalsSubpath: input.evalsSubpath,
|
|
379
|
+
});
|
|
380
|
+
const injectEnv = resolveInjectEnv(input.opts.env, envTokens);
|
|
381
|
+
const assembled = assembleChildEnv({
|
|
382
|
+
base: input.resolvedAuth.forwardedEnv,
|
|
383
|
+
source: process.env,
|
|
384
|
+
...(input.opts.passEnv ? { passEnv: input.opts.passEnv } : {}),
|
|
385
|
+
...(injectEnv ? { injectEnv } : {}),
|
|
386
|
+
subjectPluginRoot: input.subjectPluginRoot,
|
|
387
|
+
});
|
|
388
|
+
for (const warning of assembled.warnings)
|
|
389
|
+
process.stderr.write(`warning: ${warning}\n`);
|
|
390
|
+
process.stderr.write(assembled.line + '\n');
|
|
391
|
+
return assembled;
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Build the dry-run summary string. Pure function so it can be unit-tested
|
|
395
|
+
* without running the full harness.
|
|
396
|
+
*
|
|
397
|
+
* The summary covers three scenarios:
|
|
398
|
+
* 1. Declared (buildable) subject — no dist existed, fell back to source dir.
|
|
399
|
+
* 2. Declared (buildable) subject — existing dist was staged WITHOUT rebuilding
|
|
400
|
+
* (potentially stale).
|
|
401
|
+
* 3. Plain source subject — staged as-is, no build step.
|
|
402
|
+
*
|
|
403
|
+
* Always includes the assembled spawn command, the staged-manifest entry count +
|
|
404
|
+
* fingerprint, and the provenance.json path so a stale tree is visible at a glance.
|
|
405
|
+
*/
|
|
406
|
+
export function buildDryRunSummary(input) {
|
|
407
|
+
const lines = [];
|
|
408
|
+
if (input.wouldBuild) {
|
|
409
|
+
lines.push('[dry-run] A real run would: build + stage the declared skill, then spawn claude.');
|
|
410
|
+
if (input.dryRunStagedExistingDist === true) {
|
|
411
|
+
lines.push('[dry-run] WARNING: This preview used the EXISTING built dist WITHOUT rebuilding — it may be STALE.', '[dry-run] Run `vat build` before testing to ensure the preview reflects current source.');
|
|
412
|
+
}
|
|
413
|
+
else if (input.dryRunStagedExistingDist === false) {
|
|
414
|
+
lines.push('[dry-run] No built dist exists yet; this preview fell back to the source dir.');
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
else {
|
|
418
|
+
lines.push('[dry-run] A real run would: stage the source dir as-is, then spawn claude.');
|
|
419
|
+
}
|
|
420
|
+
const count = input.provenanceEntryCount;
|
|
421
|
+
lines.push(`[dry-run] Would spawn: claude -p ${input.modelFlag} (prompt via stdin)`, `[dry-run] Staged manifest: ${count} entr${count === 1 ? 'y' : 'ies'} | fingerprint: ${input.provenanceFingerprint}`, `[dry-run] Provenance: ${input.provenancePath}`);
|
|
422
|
+
return lines.join('\n');
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Remove the harness directory after a run so staged untrusted skill bytes and
|
|
426
|
+
* prompts do not accumulate in OS tmp. No-op when the user asked to keep it, when
|
|
427
|
+
* the dir is a user-supplied location (`--out`/`--workdir`), or when it is already
|
|
428
|
+
* gone. Idempotent and never throws — it runs from a `finally`, so it must not
|
|
429
|
+
* mask the run's real outcome.
|
|
430
|
+
*
|
|
431
|
+
* SAFETY: re-asserts the root is not a symlink immediately before removal (via
|
|
432
|
+
* `lstat`, which does NOT follow the link). A root swapped to a symlink between
|
|
433
|
+
* the run and cleanup is left in place rather than followed — `rmSync(recursive)`
|
|
434
|
+
* could otherwise delete the symlink's target outside tmp.
|
|
435
|
+
*/
|
|
436
|
+
export function cleanupHarness(harnessRoot, opts) {
|
|
437
|
+
if (opts.keep || !opts.created)
|
|
438
|
+
return;
|
|
439
|
+
// Best-effort: cleanup runs from a `finally`, so a TOCTOU race (the dir is
|
|
440
|
+
// reaped between checks) or a permission error must never throw out and mask
|
|
441
|
+
// the run's real outcome. Worst case is a leftover 0700 tmp dir, not a failure.
|
|
442
|
+
try {
|
|
443
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename -- our own derived harness root
|
|
444
|
+
if (!existsSync(harnessRoot))
|
|
445
|
+
return;
|
|
446
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename -- our own derived harness root
|
|
447
|
+
if (lstatSync(harnessRoot).isSymbolicLink())
|
|
448
|
+
return;
|
|
449
|
+
rmSync(harnessRoot, { recursive: true, force: true });
|
|
450
|
+
}
|
|
451
|
+
catch {
|
|
452
|
+
// Swallow: a failed cleanup is not a run failure.
|
|
453
|
+
}
|
|
454
|
+
}
|
|
234
455
|
// ---------------------------------------------------------------------------
|
|
235
456
|
// Main orchestrator
|
|
236
457
|
// ---------------------------------------------------------------------------
|
|
@@ -245,6 +466,10 @@ export async function runSkillTestHarness(opts) {
|
|
|
245
466
|
assertSafeWorkdir(opts.workdir);
|
|
246
467
|
}
|
|
247
468
|
const harnessRoot = opts.out ?? resolveHarnessRoot(opts.skills, opts.workdir);
|
|
469
|
+
// Only auto-remove the dir the harness itself created under OS tmp. An explicit
|
|
470
|
+
// --out (exact dir) or --workdir (user-chosen base) is a location the user owns;
|
|
471
|
+
// treat it like --keep and never delete it.
|
|
472
|
+
const harnessCreated = opts.out === undefined && opts.workdir === undefined;
|
|
248
473
|
const repoRoot = opts.repoRoot ?? harnessRoot;
|
|
249
474
|
const evalsSubpath = opts.evalsSubpath ?? DEFAULT_EVALS_SUBPATH;
|
|
250
475
|
const currentUid = typeof process.getuid === 'function' ? process.getuid() : 0;
|
|
@@ -256,49 +481,50 @@ export async function runSkillTestHarness(opts) {
|
|
|
256
481
|
mkdirSyncReal(harnessRoot, { recursive: true, mode: 0o700 });
|
|
257
482
|
// Step 1: Assert safe harness root (symlink/ownership/mode checks) BEFORE
|
|
258
483
|
// acquiring the lock — never write a lockfile into a directory we have not yet
|
|
259
|
-
// confirmed is non-symlinked, owned by the current uid, and 0700.
|
|
260
|
-
|
|
484
|
+
// confirmed is non-symlinked, owned by the current uid, and 0700. The trusted
|
|
485
|
+
// boundary is the tmp/workdir base the harness root was derived from; every
|
|
486
|
+
// component beneath it (incl. the recursively-created `vat-skill-test` parent)
|
|
487
|
+
// is validated, closing the shared-/tmp TOCTOU on intermediate components.
|
|
488
|
+
const trustedTmpRoot = opts.workdir ?? normalizedTmpdir();
|
|
489
|
+
assertSafeHarnessRoot(harnessRoot, currentUid, trustedTmpRoot);
|
|
261
490
|
// Step 2: Acquire exclusive harness lock.
|
|
262
491
|
const lock = acquireHarnessLock(harnessRoot);
|
|
492
|
+
// Release the lock and remove the harness dir if the run is interrupted by
|
|
493
|
+
// SIGINT/SIGTERM — a try/finally alone does not run on a signal, so without
|
|
494
|
+
// this the lockfile (and staged bytes) would leak and break the next run.
|
|
495
|
+
const cleanup = () => {
|
|
496
|
+
lock.release();
|
|
497
|
+
cleanupHarness(harnessRoot, { keep: opts.keep === true, created: harnessCreated });
|
|
498
|
+
};
|
|
499
|
+
const removeSignalCleanup = installSignalCleanup({ onSignal: cleanup });
|
|
263
500
|
try {
|
|
264
|
-
// Step 2.5: Run pre-stage build hook (if configured).
|
|
265
|
-
// Runs ONCE before staging so generated artifacts are present for the copy step.
|
|
266
|
-
// cwd = configRoot (directory containing vibe-agent-toolkit.config.yaml).
|
|
267
|
-
// A non-zero exit throws BuildHookError → mapErrorToExitCode → exit 2 (preflight).
|
|
268
|
-
// Ordering note: build necessarily precedes the bootstrap check (Step 4), because
|
|
269
|
-
// that check reads the *staged* subject's evals (Step 3), and staging must copy the
|
|
270
|
-
// build's output. So a run on a not-yet-scaffolded skill pays the build before
|
|
271
|
-
// exiting 3 — acceptable: build emits code artifacts, not the authored evals.json,
|
|
272
|
-
// so it can't change the bootstrap outcome. (Token spend is the Step-10 spawn, far
|
|
273
|
-
// downstream; the build never reaches it.)
|
|
274
|
-
const configRoot = opts.configRoot ?? repoRoot;
|
|
275
|
-
if (opts.build !== undefined) {
|
|
276
|
-
runPreStageBuild({ buildCommand: opts.build, configRoot });
|
|
277
|
-
}
|
|
278
501
|
// Step 3: Stage the harness FIRST — the subject's own evals/evals.json lands
|
|
279
|
-
// inside its staged dir, so we must stage before we can locate it.
|
|
502
|
+
// inside its staged dir, so we must stage before we can locate it. The subject's
|
|
503
|
+
// source is pre-resolved (and, for declared skills, pre-built) by run.ts and
|
|
504
|
+
// arrives via opts.subjectSource; build no longer happens in-domain here.
|
|
280
505
|
const resolveCtx = buildResolveCtx(harnessRoot, repoRoot);
|
|
281
506
|
const items = buildStageItems(opts, repoRoot);
|
|
282
|
-
const
|
|
507
|
+
const stageResult = await stageHarness({
|
|
283
508
|
harnessRoot,
|
|
284
509
|
items,
|
|
285
510
|
resolve: (source, ctx) => resolveSkillSource(source, ctx),
|
|
286
511
|
ctx: resolveCtx,
|
|
287
512
|
currentUid,
|
|
288
513
|
});
|
|
514
|
+
const { pluginDirs, subjectStagedDir, subjectPluginRoot } = stageResult;
|
|
289
515
|
if (subjectStagedDir === null) {
|
|
290
516
|
throw new InternalHarnessError('Staging did not yield a subject directory (no item tagged role:subject).');
|
|
291
517
|
}
|
|
292
|
-
// Step 4: Resolve the subject's eval path inside its staged dir
|
|
293
|
-
//
|
|
294
|
-
//
|
|
518
|
+
// Step 4: Resolve the subject's eval path inside its staged dir, overlaying the
|
|
519
|
+
// authored suite onto a built/dist subject that doesn't carry it (see
|
|
520
|
+
// overlayAuthoredEvalSuite). The bootstrap below then fires — and writes a
|
|
521
|
+
// template to the source scaffold — ONLY when the suite genuinely doesn't exist
|
|
522
|
+
// anywhere, so authored evals are never overwritten.
|
|
523
|
+
overlayAuthoredEvalSuite(opts, subjectStagedDir, evalsSubpath);
|
|
524
|
+
// Bootstrap (exit 3) fires when the suite is absent everywhere — scaffold a
|
|
525
|
+
// persistent template at the source location the user can edit, then throw.
|
|
295
526
|
const evalsPath = safePath.join(subjectStagedDir, evalsSubpath);
|
|
296
|
-
|
|
297
|
-
if (!existsSync(evalsPath)) {
|
|
298
|
-
const scaffoldPath = resolveScaffoldEvalsPath(opts, repoRoot, evalsSubpath);
|
|
299
|
-
const written = writeEvalsTemplate(scaffoldPath, subjectSkillName(opts));
|
|
300
|
-
throw new BootstrapNeededError(written);
|
|
301
|
-
}
|
|
527
|
+
bootstrapEvalSuiteIfMissing(opts, repoRoot, evalsSubpath, evalsPath);
|
|
302
528
|
// Step 5: Preflight checks.
|
|
303
529
|
const knobs = resolveKnobs(opts);
|
|
304
530
|
const preflightInput = buildPreflightInput(evalsPath, pluginDirs, opts, knobs);
|
|
@@ -311,6 +537,11 @@ export async function runSkillTestHarness(opts) {
|
|
|
311
537
|
summary: `Preflight failed:\n${summary}`,
|
|
312
538
|
};
|
|
313
539
|
}
|
|
540
|
+
// Step 5.5: Parse the eval suite and stage per-eval input workspaces.
|
|
541
|
+
const workspaceStageResult = attemptStageWorkspaces(evalsPath, harnessRoot);
|
|
542
|
+
if ('exitCode' in workspaceStageResult)
|
|
543
|
+
return workspaceStageResult;
|
|
544
|
+
const { workspacesRoot } = workspaceStageResult;
|
|
314
545
|
// Step 6: Enforce the §12 security ack (must pass --i-understand-this-runs-skill-code).
|
|
315
546
|
// Only enforced when not a dry-run and not already acknowledged.
|
|
316
547
|
if (!isAcknowledged(opts)) {
|
|
@@ -323,6 +554,19 @@ export async function runSkillTestHarness(opts) {
|
|
|
323
554
|
// Step 7: Build effective experimenter prompt and validate invariants.
|
|
324
555
|
const resultsDir = safePath.joinUnderRoot(harnessRoot, 'results');
|
|
325
556
|
mkdirSyncReal(resultsDir, { recursive: true });
|
|
557
|
+
// Record what was actually staged & tested (the subject identity + the
|
|
558
|
+
// staged manifest fingerprint + per-entry content hashes) so a run is
|
|
559
|
+
// auditable: which source was resolved, and whether it was rebuilt by run.ts.
|
|
560
|
+
const provenance = {
|
|
561
|
+
subject: subjectSkillName(opts),
|
|
562
|
+
fingerprint: stageResult.manifest.fingerprint,
|
|
563
|
+
entries: stageResult.manifest.entries,
|
|
564
|
+
rebuilt: opts.rebuilt === true,
|
|
565
|
+
};
|
|
566
|
+
const provenancePath = safePath.join(resultsDir, 'provenance.json');
|
|
567
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename -- our own derived results path
|
|
568
|
+
writeFileSync(provenancePath, JSON.stringify(provenance, null, 2) + '\n', 'utf-8');
|
|
569
|
+
process.stderr.write(`Provenance: ${provenance.fingerprint}\n`);
|
|
326
570
|
const gradingOut = safePath.join(resultsDir, 'grading.json');
|
|
327
571
|
const frictionOut = safePath.join(resultsDir, 'friction.json');
|
|
328
572
|
const effectivePrompt = opts.promptOverride ??
|
|
@@ -331,42 +575,77 @@ export async function runSkillTestHarness(opts) {
|
|
|
331
575
|
evalsPath,
|
|
332
576
|
gradingOut,
|
|
333
577
|
frictionOut,
|
|
578
|
+
workspacesRoot,
|
|
334
579
|
baseline: opts.baseline ?? false,
|
|
335
580
|
});
|
|
336
581
|
assertPromptInvariants(effectivePrompt);
|
|
337
|
-
//
|
|
582
|
+
// Integrity nonce (Harness B): stamp a secret per-run nonce the experimenter
|
|
583
|
+
// must echo into grading.json, and verify it after the run. This is what
|
|
584
|
+
// distinguishes a grading produced by the experimenter WE prompted from one
|
|
585
|
+
// forged/left behind by untrusted skill code in the shared sandbox. The nonce
|
|
586
|
+
// is appended AFTER any user prompt override so a committed config can't opt
|
|
587
|
+
// out. The full prompt (with the nonce) reaches claude only via stdin (see
|
|
588
|
+
// spawnHeadlessClaude) and is NEVER written to disk; the persisted audit copy
|
|
589
|
+
// below is redacted so skill code can't read the nonce back and forge a match.
|
|
590
|
+
const runNonce = randomBytes(16).toString('hex');
|
|
591
|
+
const noncedPrompt = appendIntegrityNonceDirective(effectivePrompt, runNonce);
|
|
592
|
+
// Step 8: Write the REDACTED experimenter prompt as the audit artifact. The
|
|
593
|
+
// real (nonce-bearing) prompt is passed to the spawn in memory, not from here.
|
|
338
594
|
const promptFile = safePath.join(resultsDir, 'experimenter-prompt.txt');
|
|
339
595
|
// eslint-disable-next-line security/detect-non-literal-fs-filename -- our own derived path
|
|
340
|
-
writeFileSync(promptFile,
|
|
596
|
+
writeFileSync(promptFile, redactNonce(noncedPrompt, runNonce) + '\n', 'utf-8');
|
|
597
|
+
// Step 7.5: Resolve the declared test env (Features A + B). Token resolution
|
|
598
|
+
// can hard-fail (exit 2) on an unknown ${token}; do it before the dry-run
|
|
599
|
+
// short-circuit so a dry run validates interpolation too.
|
|
600
|
+
const assembledEnv = resolveDeclaredChildEnv({
|
|
601
|
+
opts,
|
|
602
|
+
resolvedAuth: preflightResult.resolvedAuth,
|
|
603
|
+
subjectStagedDir,
|
|
604
|
+
harnessRoot,
|
|
605
|
+
resultsDir,
|
|
606
|
+
evalsSubpath,
|
|
607
|
+
subjectPluginRoot,
|
|
608
|
+
});
|
|
609
|
+
// Echo the selected model on EVERY run (regular + dry-run) so it is
|
|
610
|
+
// unambiguous which model the experimenter spawn uses. The id is forwarded
|
|
611
|
+
// verbatim to `claude --model`; with none set, no flag is passed and claude
|
|
612
|
+
// uses its own default.
|
|
613
|
+
const modelFlag = buildModelFlag(knobs.model);
|
|
614
|
+
process.stderr.write(`Model: ${knobs.model ?? '(claude default)'}\n`);
|
|
341
615
|
// Step 9: Dry-run short-circuit — return assembled info without spawning.
|
|
342
616
|
if (opts.dryRun === true) {
|
|
343
617
|
return {
|
|
344
618
|
harnessPath: harnessRoot,
|
|
345
619
|
exitCode: SkillTestExitCode.Ok,
|
|
346
|
-
summary:
|
|
620
|
+
summary: buildDryRunSummary({
|
|
621
|
+
wouldBuild: opts.wouldBuild === true,
|
|
622
|
+
...(opts.dryRunStagedExistingDist === undefined
|
|
623
|
+
? {}
|
|
624
|
+
: { dryRunStagedExistingDist: opts.dryRunStagedExistingDist }),
|
|
625
|
+
provenancePath,
|
|
626
|
+
provenanceFingerprint: provenance.fingerprint,
|
|
627
|
+
provenanceEntryCount: provenance.entries.length,
|
|
628
|
+
modelFlag,
|
|
629
|
+
}),
|
|
347
630
|
};
|
|
348
631
|
}
|
|
349
632
|
// Step 10: Spawn headless Claude.
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
// which would hand the untrusted skill code every secret it contains.
|
|
354
|
-
if (preflightResult.resolvedAuth === null) {
|
|
355
|
-
throw new InternalHarnessError('Internal: preflight passed but resolvedAuth is null — refusing to spawn with an unscrubbed environment.');
|
|
356
|
-
}
|
|
357
|
-
// A reused harness root (--out / --keep) may carry a grading.json from an
|
|
358
|
-
// earlier run. Remove it so a post-spawn read can only reflect THIS run.
|
|
633
|
+
// A reused harness root (--out / --keep) may carry a grading.json OR a
|
|
634
|
+
// friction.json from an earlier run. Remove both so a post-spawn read can
|
|
635
|
+
// only reflect THIS run (a stale friction.json must not leak across runs).
|
|
359
636
|
rmSync(gradingOut, { force: true });
|
|
637
|
+
rmSync(frictionOut, { force: true });
|
|
360
638
|
const timeoutMs = resolveTimeoutMs(opts);
|
|
361
639
|
const spawnOpts = {
|
|
362
|
-
|
|
640
|
+
// In-memory, nonce-bearing prompt — streamed to stdin, never written to a
|
|
641
|
+
// file the skill could read (see the integrity-nonce note above).
|
|
642
|
+
prompt: noncedPrompt + '\n',
|
|
363
643
|
pluginDirs,
|
|
364
644
|
sandboxDir: harnessRoot,
|
|
365
645
|
cwd: harnessRoot,
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
|
|
369
|
-
env: withPluginRootEnv(preflightResult.resolvedAuth.forwardedEnv, subjectPluginRoot),
|
|
646
|
+
// The scrubbed forwarded env, unioned with the declared test env (Features
|
|
647
|
+
// A + B) and CLAUDE_PLUGIN_ROOT when the subject is plugin-distributed.
|
|
648
|
+
env: assembledEnv.env,
|
|
370
649
|
timeoutMs,
|
|
371
650
|
onStdout: (chunk) => { process.stderr.write(chunk); },
|
|
372
651
|
onStderr: (chunk) => { process.stderr.write(chunk); },
|
|
@@ -391,20 +670,31 @@ export async function runSkillTestHarness(opts) {
|
|
|
391
670
|
throw new InternalHarnessError(`grading.json is not valid JSON: ${String(e)}`);
|
|
392
671
|
}
|
|
393
672
|
const grading = parseGradingJson(gradingRaw);
|
|
394
|
-
|
|
395
|
-
|
|
673
|
+
// Integrity gate (Harness B): reject a grading.json that doesn't echo THIS
|
|
674
|
+
// run's secret nonce BEFORE trusting any verdict — a missing/wrong nonce means
|
|
675
|
+
// the grading was not produced by the experimenter we prompted (e.g. forged by
|
|
676
|
+
// skill code in the sandbox). Thrown GradingNonceError maps to exit 1.
|
|
677
|
+
assertGradingNonce(grading.runNonce, runNonce);
|
|
678
|
+
// Verdict comes from the authoritative per-expectation `passed` flags, NOT
|
|
679
|
+
// the grader's self-reported summary. reconcileGrading throws GradingSkewError
|
|
680
|
+
// if the grader graded nothing or its summary disagrees with the expectations.
|
|
681
|
+
const { passed, total, allPassed } = reconcileGrading(grading);
|
|
396
682
|
const summary = `${allPassed ? 'PASS' : 'FAIL'} ${passed}/${total}`;
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
// callers should not
|
|
683
|
+
// Default: exit Ok whenever the harness ran to completion and produced a
|
|
684
|
+
// valid grading.json — pass/fail lives in the summary string and grading.json,
|
|
685
|
+
// and callers should not read the exit code to distinguish eval pass from fail.
|
|
686
|
+
// Opt-in: with failOnEvalFailure, a failing verdict escalates to EvalFailure (4).
|
|
400
687
|
return {
|
|
401
688
|
harnessPath: harnessRoot,
|
|
402
|
-
exitCode:
|
|
689
|
+
exitCode: verdictExitCode(allPassed, opts.failOnEvalFailure === true),
|
|
403
690
|
summary,
|
|
404
691
|
};
|
|
405
692
|
}
|
|
406
693
|
finally {
|
|
407
|
-
|
|
694
|
+
// Remove the signal handlers first (no listener leak across runs), then run
|
|
695
|
+
// the same cleanup: release the lock, then remove the harness dir.
|
|
696
|
+
removeSignalCleanup();
|
|
697
|
+
cleanup();
|
|
408
698
|
}
|
|
409
699
|
}
|
|
410
700
|
//# sourceMappingURL=run-harness.js.map
|