@vibe-agent-toolkit/agent-skills 0.1.39-rc.8 → 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/build-hook.d.ts +58 -0
- package/dist/skill-test/build-hook.d.ts.map +1 -0
- package/dist/skill-test/build-hook.js +63 -0
- package/dist/skill-test/build-hook.js.map +1 -0
- 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 +44 -5
- package/dist/skill-test/exit-codes.d.ts.map +1 -1
- package/dist/skill-test/exit-codes.js +63 -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 +39 -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 +35 -4
- package/dist/skill-test/harness-location.d.ts.map +1 -1
- package/dist/skill-test/harness-location.js +111 -16
- 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 +35 -1
- package/dist/skill-test/lock.js.map +1 -1
- package/dist/skill-test/plugin-env.d.ts +20 -0
- package/dist/skill-test/plugin-env.d.ts.map +1 -0
- package/dist/skill-test/plugin-env.js +24 -0
- package/dist/skill-test/plugin-env.js.map +1 -0
- package/dist/skill-test/plugin-layout.d.ts +41 -0
- package/dist/skill-test/plugin-layout.d.ts.map +1 -0
- package/dist/skill-test/plugin-layout.js +49 -0
- package/dist/skill-test/plugin-layout.js.map +1 -0
- 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 +197 -11
- package/dist/skill-test/run-harness.d.ts.map +1 -1
- package/dist/skill-test/run-harness.js +401 -65
- package/dist/skill-test/run-harness.js.map +1 -1
- package/dist/skill-test/staging.d.ts +20 -1
- package/dist/skill-test/staging.d.ts.map +1 -1
- package/dist/skill-test/staging.js +71 -17
- 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,17 +8,21 @@
|
|
|
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
|
-
import { getToolVersion, mkdirSyncReal, normalizedTmpdir, probeAuthStatus, safeExecResult, safePath, spawnHeadlessClaude, toForwardSlash, } from '@vibe-agent-toolkit/utils';
|
|
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';
|
|
17
|
+
import { assembleChildEnv, computeEnvTokens, resolveInjectEnv } from './declared-env.js';
|
|
18
|
+
import { EvalInputError, parseEvalSuite, stageEvalWorkspaces } from './eval-inputs.js';
|
|
16
19
|
import { writeEvalsTemplate } from './evals-template.js';
|
|
17
|
-
import { BootstrapNeededError, InternalHarnessError, SkillTestExitCode } from './exit-codes.js';
|
|
18
|
-
import { assertPromptInvariants, buildExperimenterPrompt, } from './experimenter-prompt.js';
|
|
19
|
-
import { parseGradingJson } from './grading-adapter.js';
|
|
20
|
-
import { assertSafeHarnessRoot, assertSafeWorkdir, resolveHarnessRoot } from './harness-location.js';
|
|
21
|
-
import { acquireHarnessLock } from './lock.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';
|
|
23
|
+
import { assertSafeHarnessRoot, assertSafeWorkdir, prepareHarnessRoot, resolveHarnessRoot } from './harness-location.js';
|
|
24
|
+
import { acquireHarnessLock, installSignalCleanup } from './lock.js';
|
|
25
|
+
import { detectPluginLayout } from './plugin-layout.js';
|
|
22
26
|
import { runPreflight } from './preflight.js';
|
|
23
27
|
import { descriptorToSource, stageHarness } from './staging.js';
|
|
24
28
|
import { verifyVendoredManifest } from './vendor-manifest.js';
|
|
@@ -38,13 +42,42 @@ const VENDORED_SKILL_CREATOR_DIR = safePath.resolve(dirname(fileURLToPath(import
|
|
|
38
42
|
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
|
39
43
|
const DEFAULT_MAX_TURNS = 50;
|
|
40
44
|
const DEFAULT_MAX_BUDGET_USD = 5;
|
|
41
|
-
|
|
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) {
|
|
42
66
|
return opts.timeout === undefined ? DEFAULT_TIMEOUT_MS : opts.timeout * 1000;
|
|
43
67
|
}
|
|
44
|
-
|
|
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) {
|
|
45
78
|
return opts.stall === undefined ? undefined : opts.stall * 1000;
|
|
46
79
|
}
|
|
47
|
-
function resolveKnobs(opts) {
|
|
80
|
+
export function resolveKnobs(opts) {
|
|
48
81
|
const result = {
|
|
49
82
|
maxTurns: opts.maxTurns ?? DEFAULT_MAX_TURNS,
|
|
50
83
|
maxBudgetUsd: opts.maxBudgetUsd ?? DEFAULT_MAX_BUDGET_USD,
|
|
@@ -56,32 +89,60 @@ function resolveKnobs(opts) {
|
|
|
56
89
|
result.stallMs = stallMs;
|
|
57
90
|
return result;
|
|
58
91
|
}
|
|
59
|
-
|
|
92
|
+
/**
|
|
93
|
+
* Detect the plugin-root layout for a `{ path }` source. The resolver later COPIES
|
|
94
|
+
* the source into a temp dir (losing its plugin ancestry), so we must detect here
|
|
95
|
+
* — while the true on-disk source dir is still known — by resolving the path spec
|
|
96
|
+
* against repoRoot and walking up for `.claude-plugin/plugin.json`. Non-`{path}`
|
|
97
|
+
* sources (npm/url/vendored/workspace) have no local source tree to walk, so they
|
|
98
|
+
* are always staged flat (returns undefined). undefined → flat staging.
|
|
99
|
+
*/
|
|
100
|
+
export function detectItemPluginLayout(source, repoRoot) {
|
|
101
|
+
if (!('path' in source))
|
|
102
|
+
return undefined;
|
|
103
|
+
const sourceDir = resolveAssetReference(source.path, repoRoot);
|
|
104
|
+
return detectPluginLayout(sourceDir, existsSync) ?? undefined;
|
|
105
|
+
}
|
|
106
|
+
export function makeStageItem(name, source, repoRoot, role) {
|
|
107
|
+
const pluginLayout = detectItemPluginLayout(source, repoRoot);
|
|
108
|
+
return {
|
|
109
|
+
name,
|
|
110
|
+
source,
|
|
111
|
+
...(role === undefined ? {} : { role }),
|
|
112
|
+
...(pluginLayout === undefined ? {} : { pluginLayout }),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
export function buildStageItems(opts, repoRoot) {
|
|
60
116
|
const items = [];
|
|
61
117
|
// The FIRST positional skill is the subject under test; the rest of the
|
|
62
118
|
// primary set and any `--with`/`--with-optional` deps are supporting context.
|
|
63
119
|
const subjectName = opts.skills[0];
|
|
64
120
|
for (const name of opts.skills) {
|
|
65
121
|
const override = opts.withSources?.[name];
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
+
}
|
|
132
|
+
items.push(makeStageItem(name, source, repoRoot, name === subjectName ? 'subject' : undefined));
|
|
70
133
|
}
|
|
71
134
|
if (opts.withOptional !== undefined) {
|
|
72
135
|
for (const [name, spec] of Object.entries(opts.withOptional)) {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
source: descriptorToSource(spec),
|
|
76
|
-
});
|
|
136
|
+
const source = descriptorToSource(spec);
|
|
137
|
+
items.push(makeStageItem(name, source, repoRoot, undefined));
|
|
77
138
|
}
|
|
78
139
|
}
|
|
79
140
|
return items;
|
|
80
141
|
}
|
|
81
|
-
function buildResolveCtx(harnessRoot, repoRoot) {
|
|
142
|
+
export function buildResolveCtx(harnessRoot, repoRoot) {
|
|
82
143
|
return {
|
|
83
144
|
repoRoot,
|
|
84
|
-
stagingRoot: safePath.
|
|
145
|
+
stagingRoot: safePath.joinUnderRoot(harnessRoot, 'staged'),
|
|
85
146
|
fetchCacheDir: safePath.join(normalizedTmpdir(), 'vat-fetch-cache'),
|
|
86
147
|
};
|
|
87
148
|
}
|
|
@@ -104,7 +165,7 @@ const FLAG_DUMMY_VALUES = {
|
|
|
104
165
|
* Return a sensible dummy value for a given CLI flag so that value-validation
|
|
105
166
|
* doesn't reject the argument before `--help` can short-circuit the session.
|
|
106
167
|
*/
|
|
107
|
-
function flagDummyValueFor(flag) {
|
|
168
|
+
export function flagDummyValueFor(flag) {
|
|
108
169
|
return FLAG_DUMMY_VALUES[flag] ?? '1';
|
|
109
170
|
}
|
|
110
171
|
/**
|
|
@@ -132,7 +193,7 @@ function buildFlagParseProbe() {
|
|
|
132
193
|
return result.success;
|
|
133
194
|
};
|
|
134
195
|
}
|
|
135
|
-
function buildPreflightInput(evalsPath, pluginDirs, opts, knobs) {
|
|
196
|
+
export function buildPreflightInput(evalsPath, pluginDirs, opts, knobs) {
|
|
136
197
|
const costEstimate = {
|
|
137
198
|
evalCount: 1,
|
|
138
199
|
configurations: 1,
|
|
@@ -161,13 +222,20 @@ function buildPreflightInput(evalsPath, pluginDirs, opts, knobs) {
|
|
|
161
222
|
preflightOpts.requireAuth = opts.requireAuth;
|
|
162
223
|
return preflightOpts;
|
|
163
224
|
}
|
|
164
|
-
function renderPreflightSummary(checks) {
|
|
225
|
+
export function renderPreflightSummary(checks) {
|
|
165
226
|
const failed = checks.filter(c => !c.passed);
|
|
166
227
|
return failed.length > 0
|
|
167
228
|
? failed.map(c => ` [FAIL] ${c.name}: ${c.message}`).join('\n')
|
|
168
229
|
: ' All preflight checks passed.';
|
|
169
230
|
}
|
|
170
|
-
|
|
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) {
|
|
171
239
|
return opts.dryRun === true || opts.acknowledgedRunsSkillCode === true;
|
|
172
240
|
}
|
|
173
241
|
/**
|
|
@@ -175,7 +243,7 @@ function isAcknowledged(opts) {
|
|
|
175
243
|
* A stall, a timeout, OR a non-zero exit are each authoritative — a non-zero exit
|
|
176
244
|
* is never laundered into a PASS even if a grading.json happens to be on disk.
|
|
177
245
|
*/
|
|
178
|
-
function assertExperimenterSucceeded(spawnResult, stallMs, timeoutMs) {
|
|
246
|
+
export function assertExperimenterSucceeded(spawnResult, stallMs, timeoutMs) {
|
|
179
247
|
if (spawnResult.stalled) {
|
|
180
248
|
throw new InternalHarnessError(`Experimenter stalled (no output for ${stallMs ?? 0}ms).`);
|
|
181
249
|
}
|
|
@@ -193,19 +261,197 @@ function assertExperimenterSucceeded(spawnResult, stallMs, timeoutMs) {
|
|
|
193
261
|
* that real source dir; otherwise we anchor under the repo root by skill name.
|
|
194
262
|
*/
|
|
195
263
|
/** The subject skill's display name (trailing segment of the positional skill arg). */
|
|
196
|
-
function subjectSkillName(opts) {
|
|
264
|
+
export function subjectSkillName(opts) {
|
|
197
265
|
const subject = opts.skills[0] ?? 'skill';
|
|
198
266
|
return basename(toForwardSlash(subject)) || subject;
|
|
199
267
|
}
|
|
200
|
-
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
|
+
}
|
|
201
274
|
const subjectName = opts.skills[0] ?? '';
|
|
202
275
|
const override = opts.withSources?.[subjectName];
|
|
203
|
-
const overridePath = override &&
|
|
276
|
+
const overridePath = override && 'path' in override ? override.path : undefined;
|
|
204
277
|
// Default (no override) resolution treats the positional name as a path.
|
|
205
278
|
const sourcePath = overridePath ?? subjectName;
|
|
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.
|
|
206
280
|
const sourceDir = safePath.resolve(repoRoot, sourcePath);
|
|
207
281
|
return safePath.join(sourceDir, evalsSubpath);
|
|
208
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
|
+
}
|
|
209
455
|
// ---------------------------------------------------------------------------
|
|
210
456
|
// Main orchestrator
|
|
211
457
|
// ---------------------------------------------------------------------------
|
|
@@ -220,42 +466,65 @@ export async function runSkillTestHarness(opts) {
|
|
|
220
466
|
assertSafeWorkdir(opts.workdir);
|
|
221
467
|
}
|
|
222
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;
|
|
223
473
|
const repoRoot = opts.repoRoot ?? harnessRoot;
|
|
224
474
|
const evalsSubpath = opts.evalsSubpath ?? DEFAULT_EVALS_SUBPATH;
|
|
225
475
|
const currentUid = typeof process.getuid === 'function' ? process.getuid() : 0;
|
|
476
|
+
// Tighten an existing directory to 0700 (if present) BEFORE mkdir — adopter
|
|
477
|
+
// may have created the --out dir with default umask (0755). This is strictly
|
|
478
|
+
// safer: we only remove access, never grant it. Symlink still throws.
|
|
479
|
+
prepareHarnessRoot(harnessRoot);
|
|
226
480
|
// Ensure the harness root directory exists before validating it.
|
|
227
481
|
mkdirSyncReal(harnessRoot, { recursive: true, mode: 0o700 });
|
|
228
482
|
// Step 1: Assert safe harness root (symlink/ownership/mode checks) BEFORE
|
|
229
483
|
// acquiring the lock — never write a lockfile into a directory we have not yet
|
|
230
|
-
// confirmed is non-symlinked, owned by the current uid, and 0700.
|
|
231
|
-
|
|
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);
|
|
232
490
|
// Step 2: Acquire exclusive harness lock.
|
|
233
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 });
|
|
234
500
|
try {
|
|
235
501
|
// Step 3: Stage the harness FIRST — the subject's own evals/evals.json lands
|
|
236
|
-
// 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.
|
|
237
505
|
const resolveCtx = buildResolveCtx(harnessRoot, repoRoot);
|
|
238
|
-
const items = buildStageItems(opts);
|
|
239
|
-
const
|
|
506
|
+
const items = buildStageItems(opts, repoRoot);
|
|
507
|
+
const stageResult = await stageHarness({
|
|
240
508
|
harnessRoot,
|
|
241
509
|
items,
|
|
242
510
|
resolve: (source, ctx) => resolveSkillSource(source, ctx),
|
|
243
511
|
ctx: resolveCtx,
|
|
244
512
|
currentUid,
|
|
245
513
|
});
|
|
514
|
+
const { pluginDirs, subjectStagedDir, subjectPluginRoot } = stageResult;
|
|
246
515
|
if (subjectStagedDir === null) {
|
|
247
516
|
throw new InternalHarnessError('Staging did not yield a subject directory (no item tagged role:subject).');
|
|
248
517
|
}
|
|
249
|
-
// Step 4: Resolve the subject's eval path inside its staged dir
|
|
250
|
-
//
|
|
251
|
-
//
|
|
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.
|
|
252
526
|
const evalsPath = safePath.join(subjectStagedDir, evalsSubpath);
|
|
253
|
-
|
|
254
|
-
if (!existsSync(evalsPath)) {
|
|
255
|
-
const scaffoldPath = resolveScaffoldEvalsPath(opts, repoRoot, evalsSubpath);
|
|
256
|
-
const written = writeEvalsTemplate(scaffoldPath, subjectSkillName(opts));
|
|
257
|
-
throw new BootstrapNeededError(written);
|
|
258
|
-
}
|
|
527
|
+
bootstrapEvalSuiteIfMissing(opts, repoRoot, evalsSubpath, evalsPath);
|
|
259
528
|
// Step 5: Preflight checks.
|
|
260
529
|
const knobs = resolveKnobs(opts);
|
|
261
530
|
const preflightInput = buildPreflightInput(evalsPath, pluginDirs, opts, knobs);
|
|
@@ -268,6 +537,11 @@ export async function runSkillTestHarness(opts) {
|
|
|
268
537
|
summary: `Preflight failed:\n${summary}`,
|
|
269
538
|
};
|
|
270
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;
|
|
271
545
|
// Step 6: Enforce the §12 security ack (must pass --i-understand-this-runs-skill-code).
|
|
272
546
|
// Only enforced when not a dry-run and not already acknowledged.
|
|
273
547
|
if (!isAcknowledged(opts)) {
|
|
@@ -278,8 +552,21 @@ export async function runSkillTestHarness(opts) {
|
|
|
278
552
|
};
|
|
279
553
|
}
|
|
280
554
|
// Step 7: Build effective experimenter prompt and validate invariants.
|
|
281
|
-
const resultsDir = safePath.
|
|
555
|
+
const resultsDir = safePath.joinUnderRoot(harnessRoot, 'results');
|
|
282
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`);
|
|
283
570
|
const gradingOut = safePath.join(resultsDir, 'grading.json');
|
|
284
571
|
const frictionOut = safePath.join(resultsDir, 'friction.json');
|
|
285
572
|
const effectivePrompt = opts.promptOverride ??
|
|
@@ -288,39 +575,77 @@ export async function runSkillTestHarness(opts) {
|
|
|
288
575
|
evalsPath,
|
|
289
576
|
gradingOut,
|
|
290
577
|
frictionOut,
|
|
578
|
+
workspacesRoot,
|
|
291
579
|
baseline: opts.baseline ?? false,
|
|
292
580
|
});
|
|
293
581
|
assertPromptInvariants(effectivePrompt);
|
|
294
|
-
//
|
|
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.
|
|
295
594
|
const promptFile = safePath.join(resultsDir, 'experimenter-prompt.txt');
|
|
296
595
|
// eslint-disable-next-line security/detect-non-literal-fs-filename -- our own derived path
|
|
297
|
-
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`);
|
|
298
615
|
// Step 9: Dry-run short-circuit — return assembled info without spawning.
|
|
299
616
|
if (opts.dryRun === true) {
|
|
300
617
|
return {
|
|
301
618
|
harnessPath: harnessRoot,
|
|
302
619
|
exitCode: SkillTestExitCode.Ok,
|
|
303
|
-
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
|
+
}),
|
|
304
630
|
};
|
|
305
631
|
}
|
|
306
632
|
// Step 10: Spawn headless Claude.
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
// which would hand the untrusted skill code every secret it contains.
|
|
311
|
-
if (preflightResult.resolvedAuth === null) {
|
|
312
|
-
throw new InternalHarnessError('Internal: preflight passed but resolvedAuth is null — refusing to spawn with an unscrubbed environment.');
|
|
313
|
-
}
|
|
314
|
-
// A reused harness root (--out / --keep) may carry a grading.json from an
|
|
315
|
-
// 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).
|
|
316
636
|
rmSync(gradingOut, { force: true });
|
|
637
|
+
rmSync(frictionOut, { force: true });
|
|
317
638
|
const timeoutMs = resolveTimeoutMs(opts);
|
|
318
639
|
const spawnOpts = {
|
|
319
|
-
|
|
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',
|
|
320
643
|
pluginDirs,
|
|
321
644
|
sandboxDir: harnessRoot,
|
|
322
645
|
cwd: harnessRoot,
|
|
323
|
-
env
|
|
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,
|
|
324
649
|
timeoutMs,
|
|
325
650
|
onStdout: (chunk) => { process.stderr.write(chunk); },
|
|
326
651
|
onStderr: (chunk) => { process.stderr.write(chunk); },
|
|
@@ -345,20 +670,31 @@ export async function runSkillTestHarness(opts) {
|
|
|
345
670
|
throw new InternalHarnessError(`grading.json is not valid JSON: ${String(e)}`);
|
|
346
671
|
}
|
|
347
672
|
const grading = parseGradingJson(gradingRaw);
|
|
348
|
-
|
|
349
|
-
|
|
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);
|
|
350
682
|
const summary = `${allPassed ? 'PASS' : 'FAIL'} ${passed}/${total}`;
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
// 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).
|
|
354
687
|
return {
|
|
355
688
|
harnessPath: harnessRoot,
|
|
356
|
-
exitCode:
|
|
689
|
+
exitCode: verdictExitCode(allPassed, opts.failOnEvalFailure === true),
|
|
357
690
|
summary,
|
|
358
691
|
};
|
|
359
692
|
}
|
|
360
693
|
finally {
|
|
361
|
-
|
|
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();
|
|
362
698
|
}
|
|
363
699
|
}
|
|
364
700
|
//# sourceMappingURL=run-harness.js.map
|