@workos/quickstudy 0.0.1

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.
Files changed (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +270 -0
  3. package/examples/harbor-notes/README.md +40 -0
  4. package/examples/harbor-notes/evals/create-note/EVAL.ts +14 -0
  5. package/examples/harbor-notes/evals/create-note/PROMPT.md +9 -0
  6. package/examples/harbor-notes/evals/create-note/local/README.txt +1 -0
  7. package/examples/harbor-notes/experiments/scripted.ts +6 -0
  8. package/examples/harbor-notes/package.json +6 -0
  9. package/examples/harbor-notes/quickstudy.identity.json +1 -0
  10. package/examples/harbor-notes/runtime.ts +48 -0
  11. package/examples/harbor-notes/semantic-example.ts +21 -0
  12. package/images/agent-runtime/Dockerfile +58 -0
  13. package/images/egress-proxy/Dockerfile +28 -0
  14. package/images/mcp-proxy/Dockerfile +30 -0
  15. package/package.json +53 -0
  16. package/src/adapters/claude.ts +107 -0
  17. package/src/adapters/codex.ts +107 -0
  18. package/src/adapters/echo.ts +57 -0
  19. package/src/adapters/parse.ts +117 -0
  20. package/src/adapters/types.ts +152 -0
  21. package/src/build-info.generated.ts +12 -0
  22. package/src/cli.ts +787 -0
  23. package/src/completeness.ts +104 -0
  24. package/src/diagnose/excerpt.ts +106 -0
  25. package/src/diagnose/prompt.ts +175 -0
  26. package/src/diagnose/render.ts +55 -0
  27. package/src/diagnose/run.ts +290 -0
  28. package/src/diagnose/select.ts +110 -0
  29. package/src/diagnose/types.ts +88 -0
  30. package/src/evals/discovery.ts +173 -0
  31. package/src/evals/prompt.ts +190 -0
  32. package/src/evals/result.ts +10 -0
  33. package/src/evals/types.ts +115 -0
  34. package/src/execution-policy.ts +71 -0
  35. package/src/experiments/discovery.ts +76 -0
  36. package/src/experiments/groups.ts +119 -0
  37. package/src/experiments/types.ts +116 -0
  38. package/src/export-types.ts +127 -0
  39. package/src/export.ts +381 -0
  40. package/src/hash.ts +74 -0
  41. package/src/identity-diff.ts +30 -0
  42. package/src/ids.ts +30 -0
  43. package/src/index.ts +58 -0
  44. package/src/isolation/docker.ts +639 -0
  45. package/src/isolation/image-contexts.generated.ts +927 -0
  46. package/src/isolation/images.ts +138 -0
  47. package/src/isolation/mcp-proxy/server.ts +260 -0
  48. package/src/isolation/mcp.ts +144 -0
  49. package/src/isolation/proxy/allowlist.ts +148 -0
  50. package/src/isolation/proxy/server.ts +382 -0
  51. package/src/llm.ts +132 -0
  52. package/src/manifest.ts +228 -0
  53. package/src/model-identity.ts +12 -0
  54. package/src/plan.ts +55 -0
  55. package/src/probe.ts +426 -0
  56. package/src/report/pass-at-k.ts +76 -0
  57. package/src/report/report.ts +731 -0
  58. package/src/runner/context.ts +96 -0
  59. package/src/runner/deadline.ts +37 -0
  60. package/src/runner/execute.ts +992 -0
  61. package/src/runner/run-lock.ts +32 -0
  62. package/src/runner/scheduler.ts +62 -0
  63. package/src/runner/score-worker.ts +107 -0
  64. package/src/runner/scorer-worker.ts +61 -0
  65. package/src/runtime/types.ts +89 -0
  66. package/src/secrets.ts +151 -0
  67. package/src/semantic.ts +185 -0
  68. package/src/serve.ts +52 -0
  69. package/src/source-identity.ts +76 -0
  70. package/src/store/artifacts.ts +146 -0
  71. package/src/store/db.ts +318 -0
  72. package/src/store/schema.ts +39 -0
  73. package/src/surface-usage.ts +297 -0
  74. package/src/ui-bundle.generated.ts +12 -0
  75. package/ui/dist/index.html +32 -0
@@ -0,0 +1,992 @@
1
+ /**
2
+ * The v3 attempt loop: workspace prep from the eval's `local/`, agent
3
+ * execution (host adapters, scripted container commands, or real container
4
+ * agent adapters — claude/codex — driven headless inside the attempt
5
+ * container), workspace export BEFORE scoring, scorer invocation with the
6
+ * sandbox still alive, and persistence with explicit
7
+ * `completed | incomplete | error` states.
8
+ *
9
+ * Treatments are runtime-owned: the experiment's `Runtime` supplies
10
+ * provisioning, the container image, egress hosts, MCP servers, and the
11
+ * PATH treatment (runtime/types.ts). The runner wires them — egress network
12
+ * + proxy sidecar are per attempt; the MCP auth sidecar is run-scoped. Provisioning is
13
+ * per-attempt, and every treatment reaches the container through the same
14
+ * 0600 env-file channel secrets use (never argv).
15
+ *
16
+ * Ordering invariant: for container runtimes the workspace is exported and
17
+ * the scorer runs while the container is still up — `ctx.exec` reaches into
18
+ * the live sandbox — and teardown happens last. Scoring against a dead
19
+ * sandbox is the failure mode this ordering exists to prevent. Teardown
20
+ * failures are recorded explicitly on the attempt, never silently swallowed
21
+ * and never allowed to mask the attempt's result.
22
+ */
23
+
24
+ import { cp, mkdtemp, rm } from "node:fs/promises";
25
+ import { tmpdir } from "node:os";
26
+ import { join } from "node:path";
27
+ import type {
28
+ AgentAdapter,
29
+ AttemptOutcome,
30
+ ContainerAgentAdapter,
31
+ ContainerAttemptContext,
32
+ } from "../adapters/types.ts";
33
+ import { ClaudeCodeAdapter } from "../adapters/claude.ts";
34
+ import { CodexAdapter } from "../adapters/codex.ts";
35
+ import { EchoAdapter } from "../adapters/echo.ts";
36
+ import { validEvalResult } from "../evals/result.ts";
37
+ import { importEvalScorer } from "../evals/discovery.ts";
38
+ import { deadline, StageTimeoutError } from "./deadline.ts";
39
+ import { scoreInWorker } from "./score-worker.ts";
40
+ import type { EvalResult, LoadedEval } from "../evals/types.ts";
41
+ import type { AgentSpec, Experiment, LoadedExperiment, Runtime } from "../experiments/types.ts";
42
+ import { canonicalJson } from "../identity-diff.ts";
43
+ import {
44
+ createAttemptContainer,
45
+ connectContainerNetwork,
46
+ disconnectContainerNetwork,
47
+ createRunNetwork,
48
+ inspectImageCommand,
49
+ inspectImageIdentity,
50
+ removeRunNetwork,
51
+ startEgressSidecar,
52
+ startMcpSidecar,
53
+ type EgressSidecar,
54
+ type McpSidecar,
55
+ } from "../isolation/docker.ts";
56
+ import { EGRESS_PROXY_IMAGE, MCP_PROXY_IMAGE } from "../isolation/images.ts";
57
+ import {
58
+ authedMcpServers,
59
+ persistRotatedRefreshToken,
60
+ readMcpAuthMaterialFromConfig,
61
+ rewriteMcpServerConfigs,
62
+ type McpServerConfig,
63
+ } from "../isolation/mcp.ts";
64
+ import { hashString } from "../hash.ts";
65
+ import { DEFAULT_BUDGETS, pairKey, resolvePairEnvironments, type LifecycleBudgets, type PairEnvironment } from "../execution-policy.ts";
66
+ import { ulid } from "../ids.ts";
67
+ import { attemptIdentityHash, buildRunManifest, type RunManifest, type RuntimeImageIdentity } from "../manifest.ts";
68
+ import { isMovingModelAlias } from "../model-identity.ts";
69
+ import type { AttemptPlan } from "../plan.ts";
70
+ import type { ProvisionedEnvironment, RuntimeProvisionContext } from "../runtime/types.ts";
71
+ import { collectProviderKeys, hostSecretEnv, redactSecrets, validateProviderKeys, type ProviderKeyProbe } from "../secrets.ts";
72
+ import { ArtifactsStore } from "../store/artifacts.ts";
73
+ import { activeAttempts, type NewAttempt, type ResultsStore, type RunConfig } from "../store/db.ts";
74
+ import { acquireRunLock } from "./run-lock.ts";
75
+ import { orderedPlan, schedule } from "./scheduler.ts";
76
+ import { compareGroups } from "../experiments/groups.ts";
77
+ import {
78
+ extractSurfaceUsage,
79
+ observationConfig,
80
+ surfaceUsageConfigFromRuntime,
81
+ surfaceUsageVendor,
82
+ type SurfaceUsageConfig,
83
+ } from "../surface-usage.ts";
84
+ import { buildEvalContext } from "./context.ts";
85
+
86
+ const DEFAULT_ATTEMPT_TIMEOUT_MS = 10 * 60_000;
87
+
88
+ /** The image-default PATH a `pathTreatment` directory is prepended to. */
89
+ const NORMAL_CONTAINER_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
90
+
91
+ export interface ExecuteRunV3Options {
92
+ evals: LoadedEval[];
93
+ experiments: LoadedExperiment[];
94
+ plan: AttemptPlan[];
95
+ store: ResultsStore;
96
+ artifacts: ArtifactsStore;
97
+ /** Prebuilt manifest (tests inject one); built from evals/experiments when omitted. */
98
+ manifest?: RunManifest;
99
+ /** Extra run-config fields recorded in `runs.config_json`. */
100
+ config?: Partial<RunConfig>;
101
+ /** Host adapters by name. Defaults to the built-in echo adapter. */
102
+ hostAdapters?: Record<string, AgentAdapter>;
103
+ /**
104
+ * Container agent adapter factories by name, constructed per experiment
105
+ * from its `agent` spec. Defaults to the built-in claude/codex adapters.
106
+ * A container experiment without a scripted `runtime.command` resolves its
107
+ * agent here; unknown names fail fast before any container work.
108
+ */
109
+ containerAdapters?: Record<string, (agent: AgentSpec) => ContainerAgentAdapter>;
110
+ /**
111
+ * Route every container through its own allowlisting proxy and internal
112
+ * network. Unconfigured runtimes allow adapter endpoints only. Off by default.
113
+ */
114
+ egressProxy?: boolean;
115
+ concurrency?: number;
116
+ seed?: string;
117
+ resumeRunId?: string;
118
+ retryUnfinished?: boolean;
119
+ signal?: AbortSignal;
120
+ attemptTimeoutMs?: number;
121
+ budgets?: Partial<LifecycleBudgets>;
122
+ log?: (line: string) => void;
123
+ }
124
+
125
+ export interface RunSummaryV3 {
126
+ runId: string;
127
+ total: number;
128
+ passed: number;
129
+ failed: number;
130
+ incomplete: number;
131
+ errors: number;
132
+ }
133
+
134
+ interface AttemptDisposition {
135
+ judgeRecords?: import("../evals/types.ts").JudgeRecord[];
136
+ status: "completed" | "incomplete" | "error";
137
+ result?: EvalResult;
138
+ error?: string;
139
+ }
140
+
141
+ function defaultHostAdapters(): Record<string, AgentAdapter> {
142
+ const echo = new EchoAdapter();
143
+ return { [echo.name]: echo };
144
+ }
145
+
146
+ function defaultContainerAdapters(): Record<string, (agent: AgentSpec) => ContainerAgentAdapter> {
147
+ return {
148
+ claude: (agent) => new ClaudeCodeAdapter("claude", { model: agent.model, reasoning: agent.reasoning }),
149
+ codex: (agent) => new CodexAdapter("codex", { model: agent.model, reasoning: agent.reasoning }),
150
+ };
151
+ }
152
+
153
+ /**
154
+ * Resolve one container experiment's agent adapter, fail-fast on everything
155
+ * that would otherwise burn a container: unknown adapter names, unpinned
156
+ * model/reasoning (a moving default is not a valid measurement), and missing
157
+ * provider keys (checked by the caller via `requiredHostEnv`).
158
+ */
159
+ function resolveContainerAdapter(
160
+ experiment: Experiment,
161
+ factories: Record<string, (agent: AgentSpec) => ContainerAgentAdapter>,
162
+ ): ContainerAgentAdapter {
163
+ const make = Object.hasOwn(factories, experiment.agent.adapter) ? factories[experiment.agent.adapter] : undefined;
164
+ if (!make) {
165
+ throw new Error(
166
+ `experiment "${experiment.id}" has a container runtime with no runtime.command and no container agent ` +
167
+ `adapter named "${experiment.agent.adapter}" (available: ${Object.keys(factories).sort().join(", ")}) — ` +
168
+ `declare a scripted command or select a known adapter`,
169
+ );
170
+ }
171
+ const adapter = make(experiment.agent);
172
+ if (adapter.identity?.model === "UNPINNED") {
173
+ throw new Error(
174
+ `experiment "${experiment.id}" must pin agent.model to an exact versioned id — ` +
175
+ `moving agent defaults are not valid measurements`,
176
+ );
177
+ }
178
+ if (adapter.identity?.model !== undefined && isMovingModelAlias(adapter.identity.model)) {
179
+ throw new Error(
180
+ `experiment "${experiment.id}" pins model "${adapter.identity.model}", which is a moving alias — ` +
181
+ `use an immutable versioned model id`,
182
+ );
183
+ }
184
+ if (adapter.identity?.reasoning === "UNPINNED") {
185
+ throw new Error(`experiment "${experiment.id}" must pin agent.reasoning so reasoning effort cannot drift between runs`);
186
+ }
187
+ return adapter;
188
+ }
189
+
190
+ /** Shared MCP authentication. Egress networks and allowlists belong to each attempt. */
191
+ interface TreatmentRuntimes {
192
+ mcp?: {
193
+ sidecar?: McpSidecar;
194
+ servers: Record<string, McpServerConfig>;
195
+ originalByExperiment: Map<string, Record<string, McpServerConfig>>;
196
+ serversByExperiment: Map<string, Record<string, McpServerConfig>>;
197
+ };
198
+ }
199
+
200
+ async function setupTreatmentRuntimes(args: {
201
+ declaredByExperiment: Map<string, Record<string, McpServerConfig>>;
202
+ runId: string;
203
+ }): Promise<TreatmentRuntimes> {
204
+ const merged: Record<string, McpServerConfig> = {};
205
+ for (const servers of args.declaredByExperiment.values()) for (const [name, spec] of Object.entries(servers)) {
206
+ if (merged[name] && canonicalJson(merged[name]) !== canonicalJson(spec)) throw new Error(`mcp server "${name}" has conflicting configs`);
207
+ merged[name] = spec;
208
+ }
209
+ if (args.declaredByExperiment.size === 0) return {};
210
+ let sidecar: McpSidecar | undefined;
211
+ if (Object.keys(authedMcpServers(merged)).length > 0) {
212
+ const material = readMcpAuthMaterialFromConfig(merged);
213
+ sidecar = await startMcpSidecar({ runId: args.runId, image: MCP_PROXY_IMAGE, upstreams: material.upstreams, refreshTokens: material.refreshTokens });
214
+ }
215
+ const base = sidecar ? `http://${sidecar.ip}:${sidecar.port}` : "http://unused";
216
+ return { mcp: { ...(sidecar ? { sidecar } : {}), servers: merged,
217
+ originalByExperiment: args.declaredByExperiment,
218
+ serversByExperiment: new Map([...args.declaredByExperiment].map(([id, servers]) => [id, rewriteMcpServerConfigs(servers, base)])),
219
+ } };
220
+ }
221
+
222
+ /**
223
+ * Stop the run's treatment infrastructure. Rotated MCP refresh tokens are
224
+ * read back BEFORE the sidecar dies — rotation makes the on-disk token stale
225
+ * the moment the sidecar refreshes once, and losing the live token silently
226
+ * would cost the operator a re-login. Teardown failures warn and continue
227
+ * (label-swept by `clean`); they never crash the run.
228
+ */
229
+ async function teardownTreatmentRuntimes(state: TreatmentRuntimes, log: (line: string) => void): Promise<void> {
230
+ const mcpSidecar = state.mcp?.sidecar;
231
+ if (state.mcp && mcpSidecar) {
232
+ try {
233
+ const rotated = await mcpSidecar.readRotatedRefreshTokens();
234
+ for (const [name, token] of Object.entries(rotated)) {
235
+ const spec = state.mcp.servers[name];
236
+ if (token !== undefined && spec) persistRotatedRefreshToken(spec, token);
237
+ else {
238
+ log(
239
+ `warning: could not read back the rotated refresh token for mcp server "${name}" — ` +
240
+ `the next run may need a fresh login`,
241
+ );
242
+ }
243
+ }
244
+ await mcpSidecar.teardown();
245
+ } catch (err) {
246
+ log(
247
+ `warning: mcp auth proxy teardown failed (${err instanceof Error ? err.message : String(err)}) — ` +
248
+ `inspect resources labeled for this run before removing them`,
249
+ );
250
+ }
251
+ }
252
+
253
+ }
254
+
255
+ /**
256
+ * Derive the OFFERED surface configuration per planned container experiment,
257
+ * for the run record (the egress-allowlist precedent: what a run offered is
258
+ * part of its record). MCP names come from the runner's already-resolved
259
+ * server maps — never from calling `runtime.mcpServers()` again, which could
260
+ * re-trigger credential checks. Experiments whose config yields nothing are
261
+ * omitted, and an all-empty result is `undefined` so the run record omits
262
+ * the field entirely (absent ≠ empty-offered).
263
+ */
264
+ export function collectOfferedSurfaces(options: {
265
+ experimentIds: readonly string[];
266
+ experimentsById: Map<string, Experiment>;
267
+ mcpServersByExperiment?: Map<string, Record<string, McpServerConfig>>;
268
+ }): Record<string, SurfaceUsageConfig> | undefined {
269
+ const surfaces: Record<string, SurfaceUsageConfig> = {};
270
+ const ids = [...new Set(options.experimentIds)].sort((a, b) => a.localeCompare(b));
271
+ for (const experimentId of ids) {
272
+ const experiment = options.experimentsById.get(experimentId);
273
+ if (experiment === undefined || experiment.runtime.kind !== "container") continue;
274
+ const offered = surfaceUsageConfigFromRuntime(
275
+ experiment.runtime.config,
276
+ Object.keys(options.mcpServersByExperiment?.get(experimentId) ?? {}),
277
+ );
278
+ const declared =
279
+ (offered.docsHosts?.length ?? 0) + (offered.cliCommands?.length ?? 0) + (offered.mcpServers?.length ?? 0);
280
+ if (declared > 0 || experiment.runtime.observationTargets !== undefined) surfaces[experimentId] = offered;
281
+ }
282
+ return Object.keys(surfaces).length > 0 ? surfaces : undefined;
283
+ }
284
+
285
+ export async function executeRunV3(options: ExecuteRunV3Options): Promise<RunSummaryV3> {
286
+ const { store, artifacts, plan } = options;
287
+ const log = options.log ?? ((line: string) => console.log(line));
288
+ const attemptTimeoutMs = options.attemptTimeoutMs ?? options.budgets?.agentMs ?? DEFAULT_ATTEMPT_TIMEOUT_MS;
289
+ const hostAdapters = options.hostAdapters ?? defaultHostAdapters();
290
+
291
+ const evalsById = new Map(options.evals.map((entry) => [entry.metadata.id, entry]));
292
+ const experimentsById = new Map(options.experiments.map((entry) => [entry.experiment.id, entry.experiment]));
293
+ const containerAdapterFactories = options.containerAdapters ?? defaultContainerAdapters();
294
+
295
+ // Fail fast BEFORE the first attempt: every plan entry must resolve, every
296
+ // experiment must be executable (a scripted command or a known container
297
+ // agent adapter), and every provider key must be present on the host.
298
+ const containerAgents = new Map<string, ContainerAgentAdapter>();
299
+ const imageTagsByPair: Record<string, string> = {};
300
+ const declaredByExperiment = new Map<string, Record<string, McpServerConfig>>();
301
+ const imageTagsByExperiment = new Map<string, Set<string>>();
302
+ for (const entry of plan) {
303
+ if (!evalsById.has(entry.evalId)) throw new Error(`plan names unknown eval "${entry.evalId}"`);
304
+ const experiment = experimentsById.get(entry.experimentId);
305
+ if (!experiment) throw new Error(`plan names unknown experiment "${entry.experimentId}"`);
306
+ if (experiment.runtime.kind === "host" && !Object.hasOwn(hostAdapters, experiment.agent.adapter)) {
307
+ throw new Error(
308
+ `experiment "${experiment.id}" needs host adapter "${experiment.agent.adapter}" ` +
309
+ `(available: ${Object.keys(hostAdapters).join(", ")})`,
310
+ );
311
+ }
312
+ if (experiment.runtime.kind === "container") {
313
+ if ((experiment.runtime.command ?? []).length === 0 && !containerAgents.has(experiment.id)) {
314
+ const adapter = resolveContainerAdapter(experiment, containerAdapterFactories);
315
+ // Provider keys are validated here — before any run row, sidecar, or
316
+ // container exists — and injected into the attempt container at create.
317
+ collectProviderKeys([adapter]);
318
+ containerAgents.set(experiment.id, adapter);
319
+ }
320
+ if (experiment.runtime.mcpServers && !declaredByExperiment.has(experiment.id)) declaredByExperiment.set(experiment.id, experiment.runtime.mcpServers());
321
+ const loadedEval = evalsById.get(entry.evalId) as LoadedEval;
322
+ const key = pairKey(entry.evalId, entry.experimentId);
323
+ const image = imageTagsByPair[key] ?? experiment.runtime.image ?? experiment.runtime.containerImage?.(loadedEval.metadata);
324
+ if (image === undefined || image === "") {
325
+ throw new Error(
326
+ `experiment "${experiment.id}" resolves no container image for eval "${entry.evalId}" — ` +
327
+ `set runtime.image or a containerImage(metadata) resolver`,
328
+ );
329
+ }
330
+ let tags = imageTagsByExperiment.get(experiment.id);
331
+ if (tags === undefined) imageTagsByExperiment.set(experiment.id, (tags = new Set()));
332
+ tags.add(image);
333
+ imageTagsByPair[key] = image;
334
+ }
335
+ }
336
+
337
+ // Live key preflight: a key that is present but rejected by the provider
338
+ // would burn a container per attempt just to 401 inside it. Probe each
339
+ // unique agent's endpoint once — before any docker, sidecar, or run-row
340
+ // work — and say plainly whether the run starts. Adapters without a probe
341
+ // (tests, custom adapters) skip it.
342
+ const probesByAdapter = new Map<string, ProviderKeyProbe>();
343
+ for (const adapter of containerAgents.values()) {
344
+ if (adapter.keyProbe === undefined || probesByAdapter.has(adapter.name)) continue;
345
+ probesByAdapter.set(adapter.name, {
346
+ name: adapter.name,
347
+ envVars: adapter.requiredHostEnv,
348
+ ...adapter.keyProbe(collectProviderKeys([adapter])),
349
+ });
350
+ }
351
+ await validateProviderKeys([...probesByAdapter.values()], { log });
352
+ log(`run starting: ${plan.length} attempt(s)`);
353
+
354
+ // Resolve image digests and agent CLI versions BEFORE building the manifest:
355
+ // a mutable tag names the image, its digest is the image, and the executable
356
+ // in it is the CLI version the run actually measured. Probing happens only
357
+ // when this call builds the manifest itself (injected manifests are the
358
+ // tests' deterministic path); host-only plans collect no tags and never
359
+ // touch Docker.
360
+ let manifest = options.manifest;
361
+ if (manifest === undefined) {
362
+ const allTags = [...new Set([...imageTagsByExperiment.values()].flatMap((tags) => [...tags]))].sort((a, b) =>
363
+ a.localeCompare(b),
364
+ );
365
+ const images: Record<string, RuntimeImageIdentity> = {};
366
+ for (const tag of allTags) {
367
+ // A missing image fails here — before the run row, sidecars, or any
368
+ // agent spend exist — with docker's own "No such image" message.
369
+ images[tag] = await inspectImageIdentity(tag);
370
+ }
371
+ const cliVersions: Record<string, string> = {};
372
+ const probeCache = new Map<string, string>();
373
+ for (const [experimentId, adapter] of containerAgents) {
374
+ const command = adapter.versionCommand;
375
+ const tags = imageTagsByExperiment.get(experimentId);
376
+ if (command === undefined || tags === undefined) continue;
377
+ // A resolver can map one experiment to several images; they share the
378
+ // agent CLI layer, so probe the first (sorted) tag for determinism.
379
+ const tag = [...tags].sort((a, b) => a.localeCompare(b))[0] as string;
380
+ const cacheKey = `${tag}\0${command.join(" ")}`;
381
+ let version = probeCache.get(cacheKey);
382
+ if (version === undefined) {
383
+ try {
384
+ version = await inspectImageCommand(tag, command);
385
+ } catch (err) {
386
+ // A failed probe degrades to the experiment's declared cliVersion
387
+ // rather than aborting: the headless invocation may still work
388
+ // where a bare --entrypoint probe does not.
389
+ log(
390
+ `warning: version probe "${command.join(" ")}" failed in image "${tag}" ` +
391
+ `(${err instanceof Error ? err.message : String(err)}) — recording the declared cliVersion`,
392
+ );
393
+ continue;
394
+ }
395
+ probeCache.set(cacheKey, version);
396
+ }
397
+ cliVersions[experimentId] = version;
398
+ }
399
+ const scorerIdentities: Record<string, Record<string, unknown>> = {};
400
+ for (const loaded of options.evals) {
401
+ const scorer = await importEvalScorer(loaded.scorerPath);
402
+ if (scorer.identity) scorerIdentities[loaded.metadata.id] = scorer.identity;
403
+ }
404
+ const pairs = resolvePairEnvironments({ ...options, images, imageTags: imageTagsByPair,
405
+ adapterHosts: Object.fromEntries([...containerAgents].map(([id, adapter]) => [id, adapter.egressHosts ?? []])),
406
+ budgets: { ...options.budgets, agentMs: options.attemptTimeoutMs ?? options.budgets?.agentMs ?? attemptTimeoutMs },
407
+ mcpHashes: Object.fromEntries([...declaredByExperiment].map(([id, servers]) => [id, hashString(canonicalJson(servers))])),
408
+ });
409
+ manifest = buildRunManifest({ evals: options.evals, experiments: options.experiments, images, cliVersions, pairs, scorerIdentities });
410
+ }
411
+ const recorded = options.resumeRunId ? store.getRun(options.resumeRunId) : null;
412
+ if (options.resumeRunId && !recorded) throw new Error(`cannot resume unknown run ${options.resumeRunId}`);
413
+ if (recorded && (recorded.manifest.manifestVersion !== "v4" || recorded.manifestHash !== manifest.hash)) throw new Error("resume refused: code or environment identity changed (historical v3 identity cannot be resumed)");
414
+ const runId = recorded?.id ?? ulid();
415
+ const concurrency = options.concurrency ?? recorded?.config.scheduler?.concurrency ?? 1;
416
+ const seed = options.seed ?? recorded?.config.scheduler?.seed ?? "0";
417
+ const order = orderedPlan(plan, seed);
418
+ if (recorded && canonicalJson(recorded.config.scheduler?.order) !== canonicalJson(order)) throw new Error("resume refused: planned coordinates or ordering changed");
419
+ if (!Number.isInteger(concurrency) || concurrency < 1) throw new Error("concurrency must be a positive integer");
420
+ const releaseLock = acquireRunLock(artifacts.rootDir, runId);
421
+ let treatments: TreatmentRuntimes = {};
422
+ try {
423
+
424
+ // Treatment infrastructure comes up BEFORE the run row: setup is fail-fast
425
+ // (missing MCP credentials, invalid egress hosts), and what a run could
426
+ // reach is part of its recorded configuration.
427
+ treatments = await setupTreatmentRuntimes({ declaredByExperiment, runId });
428
+
429
+ try {
430
+ // Offered surfaces are recorded ON THE RUN — the per-attempt derivation
431
+ // (observed-usage extraction) is otherwise derived live and discarded,
432
+ // which would leave post-hoc diagnosis blind in exactly the
433
+ // never-invoked-the-surface case.
434
+ const offeredSurfaces = collectOfferedSurfaces({
435
+ experimentIds: plan.map((entry) => entry.experimentId),
436
+ experimentsById,
437
+ ...(treatments.mcp !== undefined ? { mcpServersByExperiment: treatments.mcp.serversByExperiment } : {}),
438
+ });
439
+ const config: RunConfig = {
440
+ scheduler: { concurrency, seed, policy: "seeded-hash-v1", order },
441
+ trials: options.config?.trials ?? plan.reduce((max, entry) => Math.max(max, entry.trialIndex + 1), 1),
442
+ evalIds: options.config?.evalIds ?? [...new Set(plan.map((entry) => entry.evalId))],
443
+ experimentIds: options.config?.experimentIds ?? [...new Set(plan.map((entry) => entry.experimentId))],
444
+ ...(options.config?.evalsRoot !== undefined ? { evalsRoot: options.config.evalsRoot } : {}),
445
+ ...(options.config?.experimentsRoot !== undefined ? { experimentsRoot: options.config.experimentsRoot } : {}),
446
+ // Summary only. Each attempt enforces its manifest.pairs policy.
447
+ egress: { proxy: options.egressProxy ?? false, allowlist: [...new Set(Object.values(manifest.pairs ?? {}).flatMap((p) => p.egress.proxy ? p.egress.allowlist : []))].sort() },
448
+ // Comparison groups (with their withhold-and-diff state) are recorded
449
+ // ON THE RUN: the manifest holds runtime config only as an opaque hash,
450
+ // so the report could never recompute this after the fact.
451
+ groups: compareGroups(options.experiments.map((entry) => entry.experiment), manifest),
452
+ ...(offeredSurfaces !== undefined ? { surfaces: offeredSurfaces } : {}),
453
+ };
454
+
455
+ const summary: RunSummaryV3 = { runId, total: plan.length, passed: 0, failed: 0, incomplete: 0, errors: 0 };
456
+
457
+ const previous = activeAttempts(store.listAttempts(runId));
458
+ const coordinate = (entry: AttemptPlan) => JSON.stringify([entry.evalId, entry.experimentId, entry.trialIndex]);
459
+ const byCoordinate = new Map<string, typeof previous>();
460
+ for (const row of previous) {
461
+ const key = coordinate(row); const rows = byCoordinate.get(key) ?? []; rows.push(row); byCoordinate.set(key, rows);
462
+ if (row.status === "completed" && !validEvalResult(row.result)) throw new Error(`resume refused: invalid completed outcome ${row.id}`);
463
+ if (rows.length > 1) throw new Error(`resume refused: duplicate trial coordinate ${key}`);
464
+ if (row.attemptIdentityHash !== attemptIdentityHash(row, manifest)) throw new Error(`resume refused: attempt identity mismatch ${row.id}`);
465
+ }
466
+ if (recorded) store.reopenRun(runId);
467
+ else store.insertRun({ id: runId, startedAt: Date.now(), config, manifest });
468
+ const pending = order.filter((entry) => {
469
+ const existing = byCoordinate.get(coordinate(entry))?.[0];
470
+ return !existing || (existing.status !== "completed" && options.retryUnfinished);
471
+ });
472
+ const ids = new Map(plan.map((entry) => [coordinate(entry), ulid()]));
473
+ await schedule(pending, concurrency, async (entry) => {
474
+ const loadedEval = evalsById.get(entry.evalId) as LoadedEval;
475
+ const experiment = experimentsById.get(entry.experimentId) as Experiment;
476
+ const attemptId = ids.get(coordinate(entry))!;
477
+ const startedAt = Date.now();
478
+
479
+ const persisted: NewAttempt = {
480
+ id: attemptId,
481
+ retryOf: byCoordinate.get(coordinate(entry))?.[0]?.id ?? null,
482
+ runId,
483
+ evalId: entry.evalId,
484
+ experimentId: entry.experimentId,
485
+ suite: loadedEval.metadata.suite,
486
+ trialIndex: entry.trialIndex,
487
+ attemptIdentityHash: attemptIdentityHash(entry, manifest),
488
+ status: "error",
489
+ startedAt,
490
+ };
491
+
492
+ let disposition: AttemptDisposition;
493
+ try {
494
+ disposition =
495
+ experiment.runtime.kind === "host"
496
+ ? await runHostAttemptV3(options, hostAdapters, loadedEval, experiment, entry, { runId, attemptId, attemptTimeoutMs, persisted })
497
+ : await runContainerAttemptV3(
498
+ options,
499
+ loadedEval,
500
+ experiment,
501
+ entry,
502
+ { runId, attemptId, attemptTimeoutMs, persisted, environment: manifest.pairs?.[pairKey(entry.evalId, entry.experimentId)] },
503
+ treatments,
504
+ containerAgents.get(experiment.id),
505
+ );
506
+ } catch (err) {
507
+ // An attempt failure never aborts the run: persist the error and move on.
508
+ const message = err instanceof Error ? err.message : String(err);
509
+ await artifacts.writeError(runId, attemptId, message);
510
+ disposition = { status: "error", error: message };
511
+ }
512
+
513
+ persisted.status = disposition.status;
514
+ if (disposition.judgeRecords) persisted.judgeRecords = disposition.judgeRecords;
515
+ if (disposition.result !== undefined) persisted.result = disposition.result;
516
+ if (disposition.error !== undefined) persisted.error = disposition.error;
517
+ persisted.finishedAt = Date.now();
518
+ store.insertAttempt(persisted);
519
+
520
+ if (disposition.status === "completed") {
521
+ if (disposition.result?.passed) summary.passed += 1;
522
+ else summary.failed += 1;
523
+ } else if (disposition.status === "incomplete") {
524
+ summary.incomplete += 1;
525
+ } else {
526
+ summary.errors += 1;
527
+ }
528
+ log(
529
+ `attempt ${attemptId} (${entry.evalId} × ${entry.experimentId} trial ${entry.trialIndex + 1}): ` +
530
+ `${disposition.status}${disposition.result ? ` — ${disposition.result.passed ? "passed" : "failed"}` : ""}`,
531
+ );
532
+ }, { ...(options.signal ? { signal: options.signal } : {}), capacity: (entry) => experimentsById.get(entry.experimentId)?.runtime.capacity });
533
+
534
+ const current = activeAttempts(store.listAttempts(runId));
535
+ summary.passed = current.filter((a) => a.status === "completed" && a.result?.passed).length;
536
+ summary.failed = current.filter((a) => a.status === "completed" && !a.result?.passed).length;
537
+ summary.errors = current.filter((a) => a.status === "error").length;
538
+ summary.incomplete = current.filter((a) => a.status === "incomplete").length + plan.length - current.length;
539
+ if (!options.signal?.aborted) store.finishRun(runId, Date.now());
540
+ return summary;
541
+ } finally {
542
+ await teardownTreatmentRuntimes(treatments, log);
543
+ }
544
+ } finally { releaseLock(); }
545
+ }
546
+
547
+ /** Exec inside the container and throw (with stderr) on non-zero exit. */
548
+ async function mustExec(
549
+ container: { execInWorkspace(cmd: string[], opts?: { timeoutMs?: number }): Promise<{ exitCode: number; stdout: string; stderr: string }> },
550
+ cmd: string[],
551
+ ): Promise<{ exitCode: number; stdout: string; stderr: string }> {
552
+ const result = await container.execInWorkspace(cmd);
553
+ if (result.exitCode !== 0) {
554
+ throw new Error(`container command "${cmd.join(" ")}" exited ${result.exitCode}: ${result.stderr.trim()}`);
555
+ }
556
+ return result;
557
+ }
558
+
559
+ /** Prepare the attempt's starting state: a copy of `local/`, or empty (tools-only evals). */
560
+ async function prepWorkspaceSource(loadedEval: LoadedEval): Promise<string> {
561
+ const sourceDir = await mkdtemp(join(tmpdir(), "quickstudy-v3-source-"));
562
+ if (loadedEval.localDir !== null) {
563
+ await cp(loadedEval.localDir, sourceDir, { recursive: true });
564
+ }
565
+ return sourceDir;
566
+ }
567
+
568
+ interface AttemptArgs {
569
+ runId: string;
570
+ attemptId: string;
571
+ attemptTimeoutMs: number;
572
+ persisted: NewAttempt;
573
+ environment?: PairEnvironment;
574
+ }
575
+
576
+ async function runHostAttemptV3(
577
+ options: ExecuteRunV3Options,
578
+ hostAdapters: Record<string, AgentAdapter>,
579
+ loadedEval: LoadedEval,
580
+ experiment: Experiment,
581
+ entry: AttemptPlan,
582
+ args: AttemptArgs,
583
+ ): Promise<AttemptDisposition> {
584
+ const { artifacts } = options;
585
+ const { runId, attemptId, persisted } = args;
586
+ const adapter = hostAdapters[experiment.agent.adapter] as AgentAdapter;
587
+
588
+ const sourceDir = await prepWorkspaceSource(loadedEval);
589
+ const scratchDir = await mkdtemp(join(tmpdir(), "quickstudy-v3-attempt-"));
590
+ try {
591
+ let outcome: AttemptOutcome;
592
+ try {
593
+ outcome = await deadline("agent", args.attemptTimeoutMs, (signal) => adapter.runAttempt({
594
+ signal,
595
+ runId,
596
+ attemptId,
597
+ fixtureDir: sourceDir,
598
+ workspaceDir: scratchDir,
599
+ prompt: loadedEval.promptBody,
600
+ trialIndex: entry.trialIndex,
601
+ }));
602
+ } catch (err) {
603
+ if (!(err instanceof StageTimeoutError)) throw err;
604
+ await artifacts.writeError(runId, attemptId, `attempt timed out after ${args.attemptTimeoutMs}ms`);
605
+ return { status: "incomplete", error: `attempt timed out after ${args.attemptTimeoutMs}ms` };
606
+ }
607
+
608
+ persisted.transcriptRef = await artifacts.writeTranscript(runId, attemptId, outcome.transcript);
609
+ persisted.diffRef = await artifacts.writeDiff(runId, attemptId, outcome.diff);
610
+ persisted.tokensIn = outcome.metrics.tokensIn;
611
+ persisted.tokensOut = outcome.metrics.tokensOut;
612
+ persisted.turns = outcome.metrics.turns;
613
+ if (outcome.metrics.costUsd !== undefined) persisted.costUsd = outcome.metrics.costUsd;
614
+
615
+ // Export FIRST, then score against the export (no live sandbox exists on
616
+ // the host path — exec runs in the exported workspace directory).
617
+ const workspaceDir = await artifacts.exportWorkspace(runId, attemptId, scratchDir);
618
+ return await scoreAttempt(options, loadedEval, experiment, entry, { runId, attemptId, workspaceDir, agentOutput: { finalReport: outcome.transcript.filter((e) => e.role === "assistant").at(-1)?.content ?? null, transcript: JSON.stringify(outcome.transcript).slice(0, 131072), truncated: JSON.stringify(outcome.transcript).length > 131072 } });
619
+ } finally {
620
+ await rm(scratchDir, { recursive: true, force: true });
621
+ if (sourceDir) await rm(sourceDir, { recursive: true, force: true });
622
+ }
623
+ }
624
+
625
+ /**
626
+ * Run the provisioned (or runtime-level) teardown, returning a failure note
627
+ * instead of throwing: a broken teardown must never mask the attempt's
628
+ * result — the note is recorded explicitly on the attempt instead.
629
+ */
630
+ async function attemptRuntimeTeardown(
631
+ runtime: Runtime,
632
+ provisioned: ProvisionedEnvironment | undefined,
633
+ ctx: RuntimeProvisionContext,
634
+ timeoutMs = DEFAULT_BUDGETS.cleanupMs,
635
+ ): Promise<string | undefined> {
636
+ try {
637
+ await deadline("cleanup", timeoutMs, async (signal) => {
638
+ if (provisioned?.teardown) await provisioned.teardown(signal);
639
+ else if (runtime.teardown) await runtime.teardown({ ...ctx, signal });
640
+ });
641
+ return undefined;
642
+ } catch (err) {
643
+ return `runtime teardown failed: ${err instanceof Error ? err.message : String(err)}`;
644
+ }
645
+ }
646
+
647
+ async function runContainerAttemptV3(
648
+ options: ExecuteRunV3Options,
649
+ loadedEval: LoadedEval,
650
+ experiment: Experiment,
651
+ entry: AttemptPlan,
652
+ args: AttemptArgs,
653
+ treatments: TreatmentRuntimes,
654
+ agentAdapter: ContainerAgentAdapter | undefined,
655
+ ): Promise<AttemptDisposition> {
656
+ const { artifacts } = options;
657
+ const { runId, attemptId, persisted } = args;
658
+ const runtime = experiment.runtime;
659
+ const environment = args.environment;
660
+ let egress: { networkName: string; sidecar: EgressSidecar } | undefined;
661
+ let mcpIp: string | undefined;
662
+ let mcpNetwork: string | undefined;
663
+
664
+ const provisionCtx: RuntimeProvisionContext = {
665
+ attemptId,
666
+ evalId: entry.evalId,
667
+ metadata: loadedEval.metadata,
668
+ };
669
+
670
+ // Provision BEFORE any container work: a failed provision is an explicit
671
+ // attempt error with the provisioner's message and costs no agent spend.
672
+ let provisioned: ProvisionedEnvironment | undefined;
673
+ if (runtime.provision) {
674
+ try {
675
+ provisioned = await deadline("provision", environment?.budgets.provisionMs ?? options.budgets?.provisionMs ?? DEFAULT_BUDGETS.provisionMs,
676
+ (signal) => runtime.provision!({ ...provisionCtx, signal }),
677
+ async (late) => { const note = await attemptRuntimeTeardown(runtime, late, provisionCtx, options.budgets?.cleanupMs); if (note) await artifacts.writeError(runId, attemptId, note); });
678
+ } catch (err) {
679
+ const message = `provision failed: ${err instanceof Error ? err.message : String(err)}`;
680
+ // Teardown is still attempted — a provisioner may fail after creating
681
+ // some of what its runtime-level teardown knows how to scrub.
682
+ const teardownFailure = await attemptRuntimeTeardown(runtime, undefined, provisionCtx, environment?.budgets.cleanupMs ?? options.budgets?.cleanupMs);
683
+ const error = teardownFailure !== undefined ? `${message}; ${teardownFailure}` : message;
684
+ await artifacts.writeError(runId, attemptId, error);
685
+ return { status: "error", error };
686
+ }
687
+ }
688
+
689
+ let sourceDir: Awaited<ReturnType<typeof prepWorkspaceSource>> | undefined;
690
+ let containerTeardownFailure: string | undefined;
691
+ let disposition: AttemptDisposition;
692
+ try {
693
+ disposition = await (async (): Promise<AttemptDisposition> => {
694
+ sourceDir = await prepWorkspaceSource(loadedEval);
695
+ const resolvedImage = environment?.image;
696
+ const image = resolvedImage && resolvedImage.id !== "unresolved" ? resolvedImage.id : resolvedImage?.tag ?? runtime.image ?? (runtime.containerImage?.(loadedEval.metadata) as string);
697
+ if (environment?.egress.proxy) {
698
+ const networkName = await createRunNetwork(`${runId}-${attemptId}`);
699
+ try {
700
+ const sidecar = await startEgressSidecar({ runId, networkName, allowlist: environment.egress.allowlist, image: EGRESS_PROXY_IMAGE });
701
+ egress = { networkName, sidecar };
702
+ } catch (err) { await removeRunNetwork(networkName); throw err; }
703
+ if (treatments.mcp?.sidecar) {
704
+ mcpNetwork = networkName;
705
+ mcpIp = await connectContainerNetwork(treatments.mcp.sidecar.id, networkName);
706
+ }
707
+ }
708
+
709
+ // --- treatment env: everything rides the 0600 env-file, never argv ---
710
+ const env: Record<string, string> = { ...provisioned?.env };
711
+ // Only the SECRET-bearing subset feeds redaction: provisioned
712
+ // credentials and provider keys. Treatment plumbing (PATH, proxy env,
713
+ // the credential-free MCP server map) is diagnostic, not secret —
714
+ // redacting it would blind the transcript reader for no hygiene gain.
715
+ const secretEnv: Record<string, string> = { ...provisioned?.env };
716
+ const pathDir = environment?.pathTreatment ?? undefined;
717
+ if (pathDir !== undefined && pathDir !== "") {
718
+ // Only this treatment exposes the directory; other treatments keep
719
+ // the image-default PATH and cannot accidentally discover its tools.
720
+ env["PATH"] = `${pathDir}:${NORMAL_CONTAINER_PATH}`;
721
+ }
722
+ const originalServers = treatments.mcp?.originalByExperiment.get(experiment.id);
723
+ const rewrittenServers = mcpIp && originalServers && treatments.mcp?.sidecar
724
+ ? rewriteMcpServerConfigs(originalServers, `http://${mcpIp}:${treatments.mcp.sidecar.port}`)
725
+ : treatments.mcp?.serversByExperiment.get(experiment.id);
726
+ if (rewrittenServers !== undefined) {
727
+ // Credential-free by construction: rewriteMcpServerConfigs stripped
728
+ // every auth block and pointed authed servers at the proxy sidecar.
729
+ env["QUICKSTUDY_MCP_SERVERS"] = JSON.stringify(rewrittenServers);
730
+ }
731
+ if (agentAdapter !== undefined) {
732
+ // The agent's provider keys, validated at run preflight — injected
733
+ // at create via the 0600 env-file channel, never argv.
734
+ const providerKeys = collectProviderKeys([agentAdapter]);
735
+ Object.assign(env, providerKeys);
736
+ Object.assign(secretEnv, providerKeys);
737
+ }
738
+ if (egress) {
739
+ const proxyEnv = egress.sidecar.proxyEnv();
740
+ const mcpSidecar = treatments.mcp?.sidecar;
741
+ if (mcpSidecar) {
742
+ // The mcp sidecar shares the internal network — its address must
743
+ // bypass the egress proxy, which refuses raw-IP targets by design.
744
+ const noProxy = `${proxyEnv["NO_PROXY"]},${mcpIp ?? mcpSidecar.ip}`;
745
+ proxyEnv["NO_PROXY"] = noProxy;
746
+ proxyEnv["no_proxy"] = noProxy;
747
+ }
748
+ Object.assign(env, proxyEnv);
749
+ }
750
+
751
+ const paths = await artifacts.prepareAttempt(runId, attemptId);
752
+ const container = await createAttemptContainer({
753
+ image,
754
+ env,
755
+ resultsDir: paths.attemptDir,
756
+ attemptId,
757
+ fixtureDir: sourceDir,
758
+ // Under the egress treatment the internal network replaces the
759
+ // default bridge: the proxy sidecar is the only path to the world.
760
+ ...(egress ? { network: egress.networkName } : {}),
761
+ });
762
+ try {
763
+ let command: string[];
764
+ let baselineSha: string | undefined;
765
+ if (agentAdapter !== undefined) {
766
+ // --- real agent path: baseline commit -> treatment files -> CLI ---
767
+ // Baseline commit: the diff artifact is git's comparison of the
768
+ // final workspace against this exact commit. The SHA is recorded
769
+ // (not just HEAD) so an agent that makes its own commits cannot
770
+ // move the baseline out from under the diff.
771
+ const gitAvailable = (await container.execInWorkspace(["git", "--version"])).exitCode === 0;
772
+ if (gitAvailable) {
773
+ await mustExec(container, ["git", "init", "-q"]);
774
+ await mustExec(container, ["git", "add", "-A"]);
775
+ await mustExec(container, [
776
+ "git", "-c", "user.name=quickstudy", "-c", "user.email=quickstudy@localhost",
777
+ "commit", "-q", "--allow-empty", "-m", "quickstudy fixture baseline",
778
+ ]);
779
+ baselineSha = (await mustExec(container, ["git", "rev-parse", "HEAD"])).stdout.trim();
780
+ } else {
781
+ (options.log ?? console.log)(
782
+ `attempt ${attemptId}: git is unavailable in image "${image}" — diff.patch will be empty ` +
783
+ `(the agent-runtime image ships git; only bare test images lack it)`,
784
+ );
785
+ }
786
+
787
+ const promptCtx: ContainerAttemptContext = {
788
+ runId,
789
+ attemptId,
790
+ prompt: loadedEval.promptBody,
791
+ trialIndex: entry.trialIndex,
792
+ ...(rewrittenServers !== undefined ? { mcpServers: rewrittenServers } : {}),
793
+ webPolicy: environment?.webPolicy ?? "native-web-blocked",
794
+ };
795
+ // Treatment config files (e.g. the MCP treatment's vendor config).
796
+ // The prompt itself is byte-identical across experiments.
797
+ for (const [path, content] of Object.entries(agentAdapter.setupFiles(promptCtx))) {
798
+ await container.writeFile(path, content);
799
+ }
800
+ command = agentAdapter.command(promptCtx);
801
+ } else {
802
+ command = runtime.command as string[];
803
+ }
804
+
805
+ const agentResult = await container.execInWorkspace(command, { timeoutMs: args.attemptTimeoutMs });
806
+ // The raw stream is the transcript artifact (redacted) — persisted
807
+ // even when the agent timed out or failed, so there is always evidence.
808
+ const redactedStream = redactSecrets(agentResult.stdout, secretEnv);
809
+ persisted.transcriptRef = await artifacts.writeTranscriptRaw(runId, attemptId, redactedStream);
810
+ if (agentAdapter !== undefined) {
811
+ const metrics = agentAdapter.parseStream(agentResult.stdout);
812
+ persisted.tokensIn = metrics.tokensIn;
813
+ persisted.tokensOut = metrics.tokensOut;
814
+ persisted.turns = metrics.turns;
815
+ if (metrics.costUsd !== undefined) persisted.costUsd = metrics.costUsd;
816
+ // Surface-usage telemetry: extracted from the already-redacted
817
+ // stream (only sanitized identifiers are stored). Offered surfaces
818
+ // derive from the runtime's config + resolved MCP server names. An
819
+ // unsupported vendor stream stays NULL — never a zero-filled object.
820
+ const vendor = surfaceUsageVendor(experiment.agent.adapter);
821
+ if (vendor !== null) {
822
+ persisted.surfaceUsage = extractSurfaceUsage(
823
+ vendor,
824
+ redactedStream,
825
+ observationConfig(surfaceUsageConfigFromRuntime(runtime.config, Object.keys(rewrittenServers ?? {})), runtime.observationTargets),
826
+ );
827
+ }
828
+ }
829
+ if (agentResult.timedOut) {
830
+ await artifacts.writeError(runId, attemptId, `attempt timed out after ${args.attemptTimeoutMs}ms — container removed`);
831
+ return { status: "incomplete", error: `attempt timed out after ${args.attemptTimeoutMs}ms` };
832
+ }
833
+ if (agentResult.exitCode !== 0) {
834
+ const message = `agent command exited ${agentResult.exitCode}\n--- stderr ---\n${agentResult.stderr}`;
835
+ await artifacts.writeError(runId, attemptId, redactSecrets(message, secretEnv));
836
+ return { status: "error", error: `agent command exited ${agentResult.exitCode}` };
837
+ }
838
+
839
+ if (baselineSha !== undefined) {
840
+ // `git add -A` stages new files so the diff captures them; diffing
841
+ // the recorded baseline SHA (not HEAD) also captures any commits
842
+ // the agent made itself.
843
+ await mustExec(container, ["git", "add", "-A"]);
844
+ const diff = (await mustExec(container, ["git", "diff", baselineSha])).stdout;
845
+ persisted.diffRef = await artifacts.writeDiff(runId, attemptId, redactSecrets(diff, secretEnv));
846
+ }
847
+
848
+ // Export the workspace BEFORE scoring, with the container still alive:
849
+ // file checks read the export, ctx.exec reaches into the live sandbox.
850
+ await deadline("export", environment?.budgets.exportMs ?? DEFAULT_BUDGETS.exportMs, async (signal) => {
851
+ const kill = () => { void container.teardown(); };
852
+ signal.addEventListener("abort", kill, { once: true });
853
+ try { await container.exportWorkspace(paths.workspace); } finally { signal.removeEventListener("abort", kill); }
854
+ });
855
+ return await scoreAttempt(options, loadedEval, experiment, entry, {
856
+ runId,
857
+ attemptId,
858
+ workspaceDir: paths.workspace,
859
+ agentOutput: extractAgentOutput(redactedStream),
860
+ exec: (cmd, opts) => container.execInWorkspace(cmd, opts),
861
+ cancel: () => container.teardown(),
862
+ ...(provisioned?.env ? { provisionedEnv: provisioned.env } : {}),
863
+ });
864
+ } finally {
865
+ // Teardown LAST — after export and scoring both resolved. A teardown
866
+ // failure is captured, never thrown: it must not mask the result.
867
+ if (egress) {
868
+ try {
869
+ const lines = (await egress.sidecar.logs()).split("\n").flatMap((line) => {
870
+ try {
871
+ const event = JSON.parse(line);
872
+ if (event.type !== "egress" || event.decision !== "denied") return [];
873
+ // Only hostname-level identifiers survive: never raw requests or query strings.
874
+ const host = typeof event.host === "string" && /^[a-zA-Z0-9.*:[\]_-]+$/.test(event.host) ? event.host.slice(0, 253) : "invalid-host";
875
+ return [JSON.stringify({ type: "egress", attempt_id: attemptId, host, decision: "denied", reason: event.reason })];
876
+ } catch { return []; }
877
+ });
878
+ await artifacts.writeEgressDenials(runId, attemptId, lines);
879
+ } catch (err) { containerTeardownFailure = `egress evidence unavailable: ${err instanceof Error ? err.message : String(err)}`; }
880
+ }
881
+ try {
882
+ await container.teardown();
883
+ } catch (err) {
884
+ containerTeardownFailure = `container teardown failed: ${err instanceof Error ? err.message : String(err)}`;
885
+ }
886
+ }
887
+ })();
888
+ } catch (err) {
889
+ const message = err instanceof Error ? err.message : String(err);
890
+ await artifacts.writeError(runId, attemptId, message);
891
+ disposition = { status: "error", error: message };
892
+ } finally {
893
+ if (mcpNetwork && treatments.mcp?.sidecar) {
894
+ try { await disconnectContainerNetwork(treatments.mcp.sidecar.id, mcpNetwork); } catch { containerTeardownFailure = "MCP network disconnect failed"; }
895
+ }
896
+ if (egress) {
897
+ try { await egress.sidecar.teardown(); } catch { containerTeardownFailure = "egress sidecar teardown failed"; }
898
+ try { await removeRunNetwork(egress.networkName); } catch { containerTeardownFailure = "egress network teardown failed"; }
899
+ }
900
+ if (sourceDir) await rm(sourceDir, { recursive: true, force: true });
901
+ }
902
+
903
+ // Explicit teardown accounting: the attempt keeps its status and result;
904
+ // any teardown failure is appended to its recorded error, never thrown.
905
+ const failures: string[] = [];
906
+ if (containerTeardownFailure !== undefined) failures.push(containerTeardownFailure);
907
+ const provisionTeardownFailure = await attemptRuntimeTeardown(runtime, provisioned, provisionCtx, environment?.budgets.cleanupMs ?? options.budgets?.cleanupMs);
908
+ if (provisionTeardownFailure !== undefined) failures.push(provisionTeardownFailure);
909
+ if (failures.length > 0) {
910
+ const note = failures.join("; ");
911
+ const error = disposition.error !== undefined && disposition.error !== "" ? `${disposition.error}; ${note}` : note;
912
+ await artifacts.writeError(runId, attemptId, error);
913
+ disposition = { ...disposition, error };
914
+ }
915
+ return disposition;
916
+ }
917
+
918
+ async function scoreAttempt(
919
+ options: ExecuteRunV3Options,
920
+ loadedEval: LoadedEval,
921
+ experiment: Experiment,
922
+ entry: AttemptPlan,
923
+ args: {
924
+ runId: string;
925
+ attemptId: string;
926
+ workspaceDir: string;
927
+ exec?: (cmd: string[], opts?: { timeoutMs?: number }) => Promise<{ exitCode: number; stdout: string; stderr: string; timedOut: boolean }>;
928
+ /** The provisioned env, when the runtime provisioned this attempt. */
929
+ provisionedEnv?: Record<string, string>;
930
+ cancel?: () => Promise<void>;
931
+ agentOutput?: import("../evals/types.ts").AgentOutput;
932
+ },
933
+ ): Promise<AttemptDisposition> {
934
+ try {
935
+ return await deadline("scoring", options.budgets?.scoreMs ?? DEFAULT_BUDGETS.scoreMs, async (signal) => {
936
+ const cancel = () => { void args.cancel?.(); };
937
+ signal.addEventListener("abort", cancel, { once: true });
938
+ try {
939
+ // Runtime-provided capabilities join the scorer's context here; a helper
940
+ // the runtime does not provide throws at call time, naming the
941
+ // capability and the experiment (context.ts).
942
+ const capabilities =
943
+ experiment.runtime.scorerCapabilities?.({
944
+ signal,
945
+ attemptId: args.attemptId,
946
+ evalId: entry.evalId,
947
+ experimentId: entry.experimentId,
948
+ metadata: loadedEval.metadata,
949
+ env: args.provisionedEnv ?? {},
950
+ }) ?? {};
951
+ const result = await scoreInWorker(loadedEval.scorerPath,
952
+ buildEvalContext({
953
+ signal,
954
+ ...(args.agentOutput ? { agentOutput: args.agentOutput } : {}),
955
+ redact: async (text) => redactSecrets(redactSecrets(text, hostSecretEnv()), args.provisionedEnv ?? {}),
956
+ evalId: entry.evalId,
957
+ experimentId: entry.experimentId,
958
+ trialIndex: entry.trialIndex,
959
+ ...(loadedEval.metadata.framework !== undefined ? { framework: loadedEval.metadata.framework } : {}),
960
+ workspaceDir: args.workspaceDir,
961
+ ...(args.exec ? { exec: args.exec } : {}),
962
+ ...(capabilities.query ? { query: capabilities.query } : {}),
963
+ ...(capabilities.getClient ? { getClient: capabilities.getClient } : {}),
964
+ }), signal,
965
+ );
966
+ if (!validEvalResult(result)) throw new Error("scorer returned an invalid EvalResult");
967
+ return { status: "completed", result };
968
+ } finally { signal.removeEventListener("abort", cancel); }
969
+ });
970
+ } catch (err) {
971
+ const message = redactSecrets(redactSecrets(`scorer threw: ${err instanceof Error ? err.message : String(err)}`, hostSecretEnv()), args.provisionedEnv ?? {});
972
+ await options.artifacts.writeError(args.runId, args.attemptId, message);
973
+ const judgments = (err as { judgments?: import("../evals/types.ts").JudgeRecord[] })?.judgments;
974
+ const judgeRecords = judgments && validEvalResult({passed:false,checks:[],judgments}) ? judgments : undefined;
975
+ return { status: "error", error: message, ...(judgeRecords ? {judgeRecords} : {}) };
976
+ }
977
+ }
978
+
979
+ /** Supported stream shapes only; unavailable final reports stay null. */
980
+ function extractAgentOutput(raw: string): import("../evals/types.ts").AgentOutput {
981
+ let finalReport: string | null = null;
982
+ for (const line of raw.split("\n")) {
983
+ try {
984
+ const event = JSON.parse(line);
985
+ if (event.type === "result" && typeof event.result === "string") finalReport = event.result;
986
+ if (event.type === "item.completed" && event.item?.type === "agent_message" && typeof event.item.text === "string") finalReport = event.item.text;
987
+ const text = event.type === "assistant" ? event.message?.content?.filter((p: any) => p.type === "text").map((p: any) => p.text).join("\n") : undefined;
988
+ if (text) finalReport = text;
989
+ } catch { /* unsupported event */ }
990
+ }
991
+ return { finalReport: finalReport?.slice(0, 131072) ?? null, transcript: raw.slice(0, 131072), truncated: raw.length > 131072 || (finalReport?.length ?? 0) > 131072 };
992
+ }