@workos/quickstudy 0.10.0 → 0.12.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workos/quickstudy",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "An open-source, company-agnostic eval harness for measuring whether coding agents can complete real integration tasks, across an agent x feature x framework x surface matrix.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -9,7 +9,7 @@ export const EMBEDDED_BUILD_INFO: {
9
9
  // (the `x-release-please-version` marker anchors the replacement). It is
10
10
  // restored as-is after a binary build (scripts/build-binary.ts), so the
11
11
  // committed source `--version` stays in lockstep with package.json.
12
- version: "0.10.0", // x-release-please-version
12
+ version: "0.12.0", // x-release-please-version
13
13
  revision: null,
14
14
  dirty: null,
15
15
  sourceHash: null,
@@ -8,10 +8,12 @@
8
8
  /**
9
9
  * Closed set of eval suites. `benchmark` and `regression` are the headline
10
10
  * suites: the leaderboard aggregates count both. `smoke` holds evals every
11
- * arm solves — run to catch breakage, never moving the headline. `other` is
12
- * anything else outside the headline.
11
+ * arm solves — run to catch breakage, never moving the headline. `discovery`
12
+ * holds vendor-neutral tasks whose outcome is categorical (which product was
13
+ * chosen) rather than pass/fail; recorded per attempt, never headline.
14
+ * `other` is anything else outside the headline.
13
15
  */
14
- export const EVAL_SUITES = ["benchmark", "regression", "smoke", "other"] as const;
16
+ export const EVAL_SUITES = ["benchmark", "regression", "smoke", "other", "discovery"] as const;
15
17
  export type EvalSuite = (typeof EVAL_SUITES)[number];
16
18
 
17
19
  /** The suites whose attempts count toward the leaderboard aggregates. */
@@ -1,3 +1,4 @@
1
+ import { workspaceFilesIdentity, type WorkspaceFiles, type WorkspaceFilesIdentity } from "./runtime/workspace-files.ts";
1
2
  import type { LoadedEval } from "./evals/types.ts";
2
3
  import type { LoadedExperiment } from "./experiments/types.ts";
3
4
  import type { RuntimeImageIdentity } from "./manifest.ts";
@@ -19,6 +20,8 @@ export const DEFAULT_BUDGETS: LifecycleBudgets = {
19
20
  cleanupMs: 30_000,
20
21
  };
21
22
  export interface PairEnvironment {
23
+ /** Omitted on historical/no-overlay pairs; payload bytes never enter manifests. */
24
+ workspaceFiles?: WorkspaceFilesIdentity;
22
25
  image: RuntimeImageIdentity | null;
23
26
  egress: { proxy: boolean; allowlist: string[] };
24
27
  webPolicy: WebPolicy;
@@ -50,11 +53,20 @@ export function resolvePairEnvironments(options: {
50
53
  images?: Record<string, RuntimeImageIdentity>;
51
54
  egressProxy?: boolean;
52
55
  adapterHosts?: Record<string, readonly string[]>;
56
+ /**
57
+ * Per experiment: the hosts of the agent's endpoint overrides. They join
58
+ * the allowlist only for pairs the proxy fronts, where the list is live and
59
+ * an IP-literal gateway is refused loudly by design; an open or unproxied
60
+ * pair never carries them, so such a gateway cannot stop the run.
61
+ */
62
+ adapterEndpointHosts?: Record<string, readonly string[]>;
53
63
  /** Per experiment: the agent's non-secret endpoint overrides (see PairEnvironment.endpoints). */
54
64
  adapterEndpoints?: Record<string, Record<string, string>>;
55
65
  budgets?: Partial<LifecycleBudgets>;
56
66
  mcpHashes?: Record<string, string>;
57
67
  imageTags?: Record<string, string>;
68
+ /** Runner-captured declarations; entries with undefined explicitly mean no overlay. */
69
+ workspaceFilesByExperiment?: ReadonlyMap<string, WorkspaceFiles | undefined>;
58
70
  }): Record<string, PairEnvironment> {
59
71
  const budgets = { ...DEFAULT_BUDGETS, ...options.budgets };
60
72
  for (const [name, value] of Object.entries(budgets))
@@ -63,19 +75,28 @@ export function resolvePairEnvironments(options: {
63
75
  for (const loaded of options.evals)
64
76
  for (const { experiment } of options.experiments) {
65
77
  const runtime = experiment.runtime;
78
+ const files = options.workspaceFilesByExperiment ? options.workspaceFilesByExperiment.get(experiment.id) : runtime.workspaceFiles;
66
79
  const tag =
67
80
  options.imageTags?.[pairKey(loaded.metadata.id, experiment.id)] ??
68
81
  runtime.image ??
69
82
  runtime.containerImage?.(loaded.metadata);
83
+ // With the flag, every container is proxied unless its runtime opts
84
+ // out with egress: "open". No runtime hosts means only the adapter's
85
+ // provider endpoints are permitted. An open pair still records the
86
+ // runtime and adapter hosts (inert) so identity holds if the run flag
87
+ // flips, but endpoint-override hosts join only a live allowlist: the
88
+ // proxy refuses IP literals, and an IP-literal gateway on an arm the
89
+ // proxy never fronts must not stop the run.
90
+ const proxied = runtime.kind === "container" && (options.egressProxy ?? false) && runtime.egress !== "open";
70
91
  pairs[pairKey(loaded.metadata.id, experiment.id)] = {
92
+ ...(files ? { workspaceFiles: workspaceFilesIdentity(files) } : {}),
71
93
  image: tag ? (options.images?.[tag] ?? { tag, id: "unresolved", repoDigests: [] }) : null,
72
- // With the flag, every container is proxied. No runtime hosts means
73
- // only the adapter's provider endpoints are permitted.
74
94
  egress: {
75
- proxy: runtime.kind === "container" && (options.egressProxy ?? false),
95
+ proxy: proxied,
76
96
  allowlist: composeAllowlistFromHosts([
77
97
  ...(runtime.egressHosts?.(loaded.metadata) ?? []),
78
98
  ...(options.adapterHosts?.[experiment.id] ?? []),
99
+ ...(proxied ? (options.adapterEndpointHosts?.[experiment.id] ?? []) : []),
79
100
  ]),
80
101
  },
81
102
  webPolicy: runtime.webPolicy?.() ?? "native-web-blocked",
@@ -6,9 +6,17 @@
6
6
  * manifest), so nothing may mutate it after definition.
7
7
  */
8
8
 
9
+ import type { EvalMetadata } from "../evals/types.ts";
10
+ import { validateWorkspaceFilesRuntime, type WorkspaceFiles } from "../runtime/workspace-files.ts";
9
11
  import type { SurfaceUsageConfig } from "../surface-usage.ts";
10
12
  import type { RuntimeCapabilities } from "../runtime/types.ts";
11
13
 
14
+ /** A shared scarce resource: the scheduler lets at most `limit` attempts hold `key` at once. */
15
+ export interface RuntimeCapacity {
16
+ key: string;
17
+ limit: number;
18
+ }
19
+
12
20
  /** Which agent executes the attempt, plus the identity fields recorded in the manifest. */
13
21
  export interface AgentSpec {
14
22
  /** Adapter name, e.g. "echo" (the built-in no-op pipeline smoke agent). */
@@ -35,13 +43,29 @@ export interface Runtime extends RuntimeCapabilities {
35
43
  kind: "host" | "container";
36
44
  /** Shared observation targets, independent of which surfaces this arm offers. */
37
45
  observationTargets?: SurfaceUsageConfig;
38
- /** Shared scarce resource pool; scheduler waits instead of starting doomed jobs. */
39
- capacity?: { key: string; limit: number };
46
+ /**
47
+ * Shared scarce resource pool; the scheduler waits instead of starting
48
+ * doomed jobs. A resolver decides per eval, so an eval that consumes no
49
+ * pooled resource can answer `undefined` and never hold a slot. The static
50
+ * form is identity; a resolver, like every capability function, is not.
51
+ */
52
+ capacity?: RuntimeCapacity | ((metadata: EvalMetadata) => RuntimeCapacity | undefined);
40
53
  /** Explicit environment fields the declared treatment may vary in a comparison.
41
54
  * Images and lifecycle budgets can never be exempted. All group members must
42
55
  * declare the same fields. Source/config identity still changes per attempt.
43
56
  */
44
- treatmentPolicyFields?: Array<"egress" | "webPolicy" | "pathTreatment" | "mcp" | "promptPreamble">;
57
+ treatmentPolicyFields?: Array<"egress" | "webPolicy" | "pathTreatment" | "mcp" | "promptPreamble" | "workspaceFiles">;
58
+ /**
59
+ * Whether this experiment's attempts go through the run's egress proxy.
60
+ * "open" skips the proxy sidecar and network for these attempts even when
61
+ * the run was started with --egress-proxy, so an arm that must reach the
62
+ * open internet can share a run with proxied arms. The choice is recorded
63
+ * in each pair environment (identity); exempt it in a comparison group with
64
+ * treatmentPolicyFields: ["egress"]. Container runtimes only.
65
+ */
66
+ egress?: "proxied" | "open";
67
+ /** Attempt-local non-secret files, staged without replacing the normal adapter. */
68
+ workspaceFiles?: WorkspaceFiles;
45
69
  /** Container image tag (container runtimes; alternative to `containerImage`). */
46
70
  image?: string;
47
71
  /** Scripted command run in /workspace (container runtimes, this phase). */
@@ -112,5 +136,11 @@ export function defineExperiment(experiment: Experiment): Experiment {
112
136
  if (experiment.comparisonGroup !== undefined && experiment.comparisonGroup.trim() === "") {
113
137
  throw new Error(`defineExperiment: experiment "${experiment.id}" comparisonGroup must not be empty`);
114
138
  }
139
+ if (kind === "host" && experiment.runtime.egress === "open") {
140
+ throw new Error(
141
+ `defineExperiment: experiment "${experiment.id}" runtime.egress "open" is only meaningful on container runtimes`,
142
+ );
143
+ }
144
+ validateWorkspaceFilesRuntime(experiment.runtime);
115
145
  return deepFreeze(experiment);
116
146
  }
package/src/index.ts CHANGED
@@ -17,8 +17,11 @@ export type {
17
17
  LoadedEval,
18
18
  } from "./evals/types.ts";
19
19
  export { EVAL_SUITES } from "./evals/types.ts";
20
+ export { defineWorkspaceFiles, WORKSPACE_FILES_LIMITS } from "./runtime/workspace-files.ts";
21
+ export type { WorkspaceFile, WorkspaceFiles, WorkspaceFilesIdentity } from "./runtime/workspace-files.ts";
22
+ export type { WorkspaceFilesEvidence } from "./isolation/workspace-files.ts";
20
23
  export { defineExperiment } from "./experiments/types.ts";
21
- export type { AgentSpec, Experiment, LoadedExperiment, Runtime } from "./experiments/types.ts";
24
+ export type { AgentSpec, Experiment, LoadedExperiment, Runtime, RuntimeCapacity } from "./experiments/types.ts";
22
25
  export type {
23
26
  ProvisionedEnvironment,
24
27
  RuntimeCapabilities,
@@ -21,6 +21,8 @@ import { mkdir, mkdtemp, rm, writeFile, cp, readdir, lstat, readlink, realpath }
21
21
  import { tmpdir } from "node:os";
22
22
  import { join, resolve } from "node:path";
23
23
  import { sanitizeServerName, type McpUpstream } from "./mcp-proxy/server.ts";
24
+ import { WORKSPACE_FILES_PROGRAM, type WorkspaceFilesRequest } from "./workspace-files.ts";
25
+ import type { WorkspaceFilesIdentity } from "../runtime/workspace-files.ts";
24
26
  import { EXCLUDED_WORKSPACE_DIRS } from "../store/artifacts.ts";
25
27
 
26
28
  /** The label key every attempt container carries. */
@@ -66,14 +68,17 @@ export interface ExecResult {
66
68
  */
67
69
  async function runDocker(
68
70
  args: string[],
69
- opts: { timeoutMs?: number; stdin?: string; env?: Record<string, string> } = {},
71
+ opts: { timeoutMs?: number; stdin?: string; env?: Record<string, string>; signal?: AbortSignal } = {},
70
72
  ): Promise<ExecResult> {
73
+ opts.signal?.throwIfAborted();
71
74
  const proc = Bun.spawn(["docker", ...args], {
72
75
  stdin: opts.stdin === undefined ? "ignore" : Buffer.from(opts.stdin),
73
76
  stdout: "pipe",
74
77
  stderr: "pipe",
75
78
  ...(opts.env !== undefined ? { env: { ...process.env, ...opts.env } } : {}),
76
79
  });
80
+ const abort = () => proc.kill("SIGKILL");
81
+ opts.signal?.addEventListener("abort", abort, { once: true });
77
82
  let timedOut = false;
78
83
  let timer: ReturnType<typeof setTimeout> | undefined;
79
84
  {
@@ -88,13 +93,14 @@ async function runDocker(
88
93
  proc.exited,
89
94
  ]);
90
95
  if (timer !== undefined) clearTimeout(timer);
96
+ opts.signal?.removeEventListener("abort", abort);
91
97
  return { exitCode, stdout, stderr, timedOut };
92
98
  }
93
99
 
94
100
  /** Run `docker <args>` and throw a DockerError on non-zero exit. */
95
101
  async function mustDocker(
96
102
  args: string[],
97
- opts: { timeoutMs?: number; stdin?: string; env?: Record<string, string> } = {},
103
+ opts: { timeoutMs?: number; stdin?: string; env?: Record<string, string>; signal?: AbortSignal } = {},
98
104
  ): Promise<ExecResult> {
99
105
  const result = await runDocker(args, opts);
100
106
  if (result.exitCode !== 0) throw new DockerError(args, result.exitCode, result.stderr);
@@ -193,7 +199,9 @@ export interface AttemptContainer {
193
199
  /** Run a command in /workspace inside the container. Never throws for non-zero exits. */
194
200
  execInWorkspace(cmd: string[], opts?: { timeoutMs?: number }): Promise<ExecResult>;
195
201
  /** Write a file inside the container (parent directories created). */
196
- writeFile(path: string, content: string): Promise<void>;
202
+ writeFile(path: string, content: string, opts?: { signal?: AbortSignal }): Promise<void>;
203
+ /** Bounded create-only overlay transport; input rides stdin, never argv. */
204
+ workspaceFiles(request: WorkspaceFilesRequest, signal: AbortSignal): Promise<WorkspaceFilesIdentity>;
197
205
  /** Copy /workspace to a host directory, excluding node_modules/vendor/.venv. */
198
206
  exportWorkspace(toDir: string): Promise<void>;
199
207
  /** `docker rm -f` — kill and remove. Idempotent. */
@@ -282,10 +290,22 @@ export async function createAttemptContainer(opts: CreateAttemptContainerOptions
282
290
  }
283
291
  return result;
284
292
  },
285
- writeFile: async (path, content) => {
293
+ workspaceFiles: async (request, signal) => {
294
+ try {
295
+ const result = await runDocker(["exec", "-i", "-w", CONTAINER_WORKSPACE, id, "/usr/local/bin/node", "-e", WORKSPACE_FILES_PROGRAM, CONTAINER_WORKSPACE], {
296
+ stdin: JSON.stringify(request), signal,
297
+ });
298
+ if (signal.aborted || result.timedOut || result.exitCode !== 0) throw new Error("staging failed");
299
+ return JSON.parse(result.stdout) as WorkspaceFilesIdentity;
300
+ } catch {
301
+ // No raw stdout/stderr, command, or payload may enter diagnostics.
302
+ throw new Error("workspaceFiles: filesystem operation failed or was cancelled/timed out");
303
+ }
304
+ },
305
+ writeFile: async (path, content, writeOpts = {}) => {
286
306
  // `$1` positional keeps the target path out of shell interpolation.
287
307
  await mustDocker(["exec", "-i", id, "sh", "-c", 'mkdir -p "$(dirname "$1")" && cat > "$1"', "sh", path], {
288
- stdin: content,
308
+ stdin: content, signal: writeOpts.signal,
289
309
  });
290
310
  },
291
311
  exportWorkspace: async (toDir) => {
@@ -0,0 +1,92 @@
1
+ import type { WorkspaceFiles, WorkspaceFilesIdentity } from "../runtime/workspace-files.ts";
2
+
3
+ /** The same program runs in the container and offline filesystem tests. No shell or payload in argv. */
4
+ export const WORKSPACE_FILES_PROGRAM = String.raw`
5
+ const fs = require('node:fs');
6
+ const crypto = require('node:crypto');
7
+ const path = require('node:path');
8
+ function apply(root, request) {
9
+ const { files, absent } = request.declaration;
10
+ const fail = () => { throw new Error('unsafe workspace state'); };
11
+ const stat = (p) => { try { return fs.lstatSync(p); } catch (e) { if (e.code === 'ENOENT') return null; throw e; } };
12
+ if (!stat(root)?.isDirectory() || stat(root).isSymbolicLink()) fail();
13
+ // No processes controlled by the agent run during preparation. On Linux,
14
+ // directory descriptors also anchor traversal against concurrent renames.
15
+ const descriptors = [];
16
+ const directory = (p) => {
17
+ const s = stat(p);
18
+ if (!s?.isDirectory() || s.isSymbolicLink()) fail();
19
+ const fd = fs.openSync(p, fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW);
20
+ descriptors.push(fd);
21
+ return process.platform === 'linux' ? '/proc/self/fd/' + fd : p;
22
+ };
23
+ const base = directory(root);
24
+ const leaf = (relative, create = false) => {
25
+ while (descriptors.length > 1) fs.closeSync(descriptors.pop());
26
+ const parts = relative.split('/');
27
+ let parent = base;
28
+ for (const part of parts.slice(0, -1)) {
29
+ const next = parent + '/' + part;
30
+ if (!stat(next)) {
31
+ if (!create) return null;
32
+ fs.mkdirSync(next, { mode: 0o755 });
33
+ }
34
+ parent = directory(next);
35
+ }
36
+ return parent + '/' + parts.at(-1);
37
+ };
38
+ const checkAbsent = (relative) => { const p = leaf(relative); if (p !== null && stat(p)) fail(); };
39
+ try {
40
+ // Setup paths may be outside the workspace, but cannot alias it through
41
+ // symlinks. Lexical overlap is separately rejected before any write.
42
+ for (const target of request.setupPaths) {
43
+ const absolute = path.resolve(root, target);
44
+ let current = '/';
45
+ for (const part of absolute.split('/').filter(Boolean)) {
46
+ current = path.join(current, part);
47
+ const s = stat(current);
48
+ if (!s) break;
49
+ if (s.isSymbolicLink()) fail();
50
+ }
51
+ }
52
+ for (const relative of absent) checkAbsent(relative);
53
+ if (request.phase === 'stage') {
54
+ // Validate the entire inventory before writing even the first file.
55
+ for (const file of files) checkAbsent(file.path);
56
+ for (const file of files) {
57
+ const p = leaf(file.path, true);
58
+ const fd = fs.openSync(p, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o644);
59
+ try { fs.writeFileSync(fd, Buffer.from(file.content, 'base64')); fs.fchmodSync(fd, 0o644); }
60
+ finally { fs.closeSync(fd); }
61
+ }
62
+ }
63
+ const inventory = files.map((file) => {
64
+ const p = leaf(file.path);
65
+ if (!p) fail();
66
+ const fd = fs.openSync(p, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
67
+ try {
68
+ const s = fs.fstatSync(fd);
69
+ if (!s.isFile() || s.nlink !== 1 || (s.mode & 0o777) !== 0o644 || s.size > 262144) fail();
70
+ const bytes = fs.readFileSync(fd);
71
+ return { path: file.path, size: bytes.length, sha256: crypto.createHash('sha256').update(bytes).digest('hex'), source: file.source };
72
+ } finally { fs.closeSync(fd); }
73
+ });
74
+ return { version: 1, files: inventory, absent };
75
+ } finally { for (const fd of descriptors.reverse()) fs.closeSync(fd); }
76
+ }
77
+ try {
78
+ const request = JSON.parse(fs.readFileSync(0, 'utf8'));
79
+ process.stdout.write(JSON.stringify(apply(process.argv[1], request)));
80
+ } catch { process.stderr.write('workspaceFiles: filesystem validation/write/read-back failed'); process.exitCode = 1; }
81
+ `;
82
+
83
+ export interface WorkspaceFilesRequest {
84
+ declaration: WorkspaceFiles;
85
+ phase: "stage" | "verify";
86
+ setupPaths: string[];
87
+ }
88
+ export interface WorkspaceFilesEvidence {
89
+ version: 1;
90
+ verified: WorkspaceFilesIdentity;
91
+ verifiedAt: number;
92
+ }
package/src/manifest.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  * adding or removing a component is a deliberate, test-visible act.
11
11
  */
12
12
 
13
+ import { validateWorkspaceFilesRuntime, workspaceFilesIdentity, type WorkspaceFiles } from "./runtime/workspace-files.ts";
13
14
  import { readFileSync } from "node:fs";
14
15
  import { join, resolve } from "node:path";
15
16
  import { EMBEDDED_BUILD_INFO } from "./build-info.generated.ts";
@@ -151,6 +152,8 @@ export interface BuildRunManifestOptions {
151
152
  harness?: HarnessIdentity;
152
153
  pairs?: Record<string, PairEnvironment>;
153
154
  scorerIdentities?: Record<string, Record<string, unknown>>;
155
+ /** Share the runner's immutable payload snapshot without copying runtime instances. */
156
+ workspaceFilesByExperiment?: ReadonlyMap<string, WorkspaceFiles | undefined>;
154
157
  }
155
158
 
156
159
  /** Build before the first paid attempt; all reads are deterministic and local. */
@@ -178,6 +181,8 @@ export function buildRunManifest(options: BuildRunManifestOptions): RunManifest
178
181
 
179
182
  const experiments: Record<string, ExperimentIdentity> = {};
180
183
  for (const { experiment, path } of options.experiments) {
184
+ const files = options.workspaceFilesByExperiment ? options.workspaceFilesByExperiment.get(experiment.id) : experiment.runtime.workspaceFiles;
185
+ validateWorkspaceFilesRuntime({ kind: experiment.runtime.kind, command: experiment.runtime.command, workspaceFiles: files });
181
186
  const imageTag = experiment.runtime.image;
182
187
  const components: ExperimentIdentityComponents = {
183
188
  source: sourceIdentity(path),
@@ -190,7 +195,9 @@ export function buildRunManifest(options: BuildRunManifestOptions): RunManifest
190
195
  },
191
196
  runtime: contentHash({
192
197
  kind: experiment.runtime.kind,
193
- capacity: experiment.runtime.capacity ?? null,
198
+ ...(files ? { workspaceFiles: workspaceFilesIdentity(files) } : {}),
199
+ // A capacity resolver is a capability function: per-eval, never identity.
200
+ capacity: typeof experiment.runtime.capacity === "function" ? null : (experiment.runtime.capacity ?? null),
194
201
  observationTargets: experiment.runtime.observationTargets ?? null,
195
202
  treatmentPolicyFields: [...(experiment.runtime.treatmentPolicyFields ?? [])].sort(),
196
203
  image: experiment.runtime.image ?? null,
@@ -3,8 +3,8 @@
3
3
  * the same attempts and (banded) pairs as the rest of the report.
4
4
  *
5
5
  * Every number is restricted to headline-suite attempts (HEADLINE_SUITES:
6
- * benchmark and regression), so smoke and other suites never move the
7
- * headline row. Telemetry follows the report's rule: sums and means cover
6
+ * benchmark and regression), so the smoke, discovery and other suites never
7
+ * move the headline row. Telemetry follows the report's rule: sums and means cover
8
8
  * only attempts that reported a value, and are null (never 0) when none did.
9
9
  */
10
10
  import { isHeadlineSuite } from "../evals/types.ts";
@@ -0,0 +1,17 @@
1
+ import type { EvalMetadata } from "../evals/types.ts";
2
+ import type { Runtime, RuntimeCapacity } from "../experiments/types.ts";
3
+
4
+ /**
5
+ * The capacity an attempt of `metadata` holds under `runtime`: the static
6
+ * pool, the resolver's answer for that eval, or none. The scheduler validates
7
+ * the shape of whatever comes back.
8
+ */
9
+ export function resolveRuntimeCapacity(
10
+ runtime: Runtime | undefined,
11
+ metadata: EvalMetadata | undefined,
12
+ ): RuntimeCapacity | undefined {
13
+ const capacity = runtime?.capacity;
14
+ if (capacity === undefined) return undefined;
15
+ if (typeof capacity === "function") return metadata === undefined ? undefined : capacity(metadata);
16
+ return capacity;
17
+ }
@@ -21,6 +21,8 @@
21
21
  * and never allowed to mask the attempt's result.
22
22
  */
23
23
 
24
+ import { stageWorkspaceFiles } from "./workspace-files.ts";
25
+ import { defineWorkspaceFiles, validateWorkspaceFilesRuntime, workspaceFilesIdentity, type WorkspaceFiles } from "../runtime/workspace-files.ts";
24
26
  import { mkdtemp, rm } from "node:fs/promises";
25
27
  import { tmpdir } from "node:os";
26
28
  import { join } from "node:path";
@@ -36,6 +38,7 @@ import { EchoAdapter } from "../adapters/echo.ts";
36
38
  import { OracleAdapter, OracleContainerAdapter } from "../adapters/oracle.ts";
37
39
  import { validEvalResult } from "../evals/result.ts";
38
40
  import { importEvalScorer } from "../evals/discovery.ts";
41
+ import { resolveRuntimeCapacity } from "./capacity.ts";
39
42
  import { deadline, StageTimeoutError } from "./deadline.ts";
40
43
  import { scoreInWorker } from "./score-worker.ts";
41
44
  import { prepWorkspaceSource } from "./workspace-source.ts";
@@ -102,6 +105,8 @@ export interface ExecuteRunV3Options {
102
105
  config?: Partial<RunConfig>;
103
106
  /** Host adapters by name. Defaults to the built-in echo adapter. */
104
107
  hostAdapters?: Record<string, AgentAdapter>;
108
+ /** Internal offline-test seam; not part of the public consumer API. */
109
+ createContainer?: typeof createAttemptContainer;
105
110
  /**
106
111
  * Container agent adapter factories by name, constructed per experiment
107
112
  * from its `agent` spec. Defaults to the built-in claude/codex adapters.
@@ -314,6 +319,13 @@ export function collectOfferedSurfaces(options: {
314
319
  }
315
320
 
316
321
  export async function executeRunV3(options: ExecuteRunV3Options): Promise<RunSummaryV3> {
322
+ // Snapshot only the payload: runtime methods must keep their original receiver,
323
+ // including prototype methods and private fields. Capture before the first await.
324
+ const workspaceFilesByExperiment = new Map(options.experiments.map(({ experiment }) => {
325
+ validateWorkspaceFilesRuntime(experiment.runtime);
326
+ const files = experiment.runtime.workspaceFiles;
327
+ return [experiment.id, files === undefined ? undefined : defineWorkspaceFiles(files)] as const;
328
+ }));
317
329
  const { store, artifacts, plan } = options;
318
330
  const log = options.log ?? ((line: string) => console.log(line));
319
331
  const attemptTimeoutMs = options.attemptTimeoutMs ?? options.budgets?.agentMs ?? DEFAULT_ATTEMPT_TIMEOUT_MS;
@@ -459,25 +471,33 @@ export async function executeRunV3(options: ExecuteRunV3Options): Promise<RunSum
459
471
  if (scorer.identity) scorerIdentities[loaded.metadata.id] = scorer.identity;
460
472
  }
461
473
  // Each agent's endpoint overrides set on the host: the non-secret base
462
- // URLs join the pair environment (identity), their hosts join the egress
463
- // allowlist — but only under the proxy, where the allowlist is live and an
464
- // IP-literal gateway is refused loudly by design; without it the list is
465
- // inert and an IP-literal override must not stop the run.
474
+ // URLs join the pair environment (identity); their hosts reach the egress
475
+ // allowlist only for pairs the proxy fronts (pair resolution decides: the
476
+ // run flag on and the runtime not opted out), where the list is live and
477
+ // an IP-literal gateway is refused loudly by design. Elsewhere the list
478
+ // is inert and an IP-literal override must not stop the run.
466
479
  const overridesByExperiment = new Map([...containerAgents].map(([id, adapter]) => [id, collectOptionalHostEnv([adapter])] as const));
467
480
  const adapterEndpoints = Object.fromEntries([...overridesByExperiment].flatMap(([id, overrides]) => {
468
481
  const endpoints = Object.fromEntries(Object.entries(overrides).filter(([name]) => !isSecretEnvName(name)));
469
482
  return Object.keys(endpoints).length > 0 ? [[id, endpoints] as const] : [];
470
483
  }));
471
- const pairs = resolvePairEnvironments({ ...options, images, imageTags: imageTagsByPair,
472
- adapterHosts: Object.fromEntries([...containerAgents].map(([id, adapter]) => [id, [
473
- ...(adapter.egressHosts ?? []),
474
- ...(options.egressProxy ? endpointHosts(overridesByExperiment.get(id) ?? {}) : []),
475
- ]])),
484
+ const pairs = resolvePairEnvironments({ ...options, workspaceFilesByExperiment, images, imageTags: imageTagsByPair,
485
+ adapterHosts: Object.fromEntries([...containerAgents].map(([id, adapter]) => [id, [...(adapter.egressHosts ?? [])]])),
486
+ adapterEndpointHosts: Object.fromEntries([...overridesByExperiment].map(([id, overrides]) => [id, endpointHosts(overrides)])),
476
487
  adapterEndpoints,
477
488
  budgets: { ...options.budgets, agentMs: options.attemptTimeoutMs ?? options.budgets?.agentMs ?? attemptTimeoutMs },
478
489
  mcpHashes: Object.fromEntries([...declaredByExperiment].map(([id, servers]) => [id, hashString(canonicalJson(servers))])),
479
490
  });
480
- manifest = buildRunManifest({ evals: options.evals, experiments: options.experiments, images, cliVersions, pairs, scorerIdentities });
491
+ manifest = buildRunManifest({ evals: options.evals, experiments: options.experiments, workspaceFilesByExperiment, images, cliVersions, pairs, scorerIdentities });
492
+ }
493
+ // Injected/stale manifests must not authorize a different payload (or silently
494
+ // omit one). Historical no-overlay manifests retain their original shape.
495
+ for (const entry of plan) {
496
+ const files = workspaceFilesByExperiment.get(entry.experimentId);
497
+ const intended = files ? workspaceFilesIdentity(files) : undefined;
498
+ if (canonicalJson(intended ?? null) !== canonicalJson(manifest.pairs?.[pairKey(entry.evalId, entry.experimentId)]?.workspaceFiles ?? null)) {
499
+ throw new Error("workspaceFiles: manifest payload identity mismatch");
500
+ }
481
501
  }
482
502
  const recorded = options.resumeRunId ? store.getRun(options.resumeRunId) : null;
483
503
  if (options.resumeRunId && !recorded) throw new Error(`cannot resume unknown run ${options.resumeRunId}`);
@@ -582,7 +602,7 @@ export async function executeRunV3(options: ExecuteRunV3Options): Promise<RunSum
582
602
  loadedEval,
583
603
  experiment,
584
604
  entry,
585
- { runId, attemptId, attemptTimeoutMs, persisted, environment: manifest.pairs?.[pairKey(entry.evalId, entry.experimentId)] },
605
+ { runId, attemptId, attemptTimeoutMs, persisted, environment: manifest.pairs?.[pairKey(entry.evalId, entry.experimentId)], workspaceFiles: workspaceFilesByExperiment.get(entry.experimentId) },
586
606
  treatments,
587
607
  containerAgents.get(experiment.id),
588
608
  );
@@ -616,7 +636,10 @@ export async function executeRunV3(options: ExecuteRunV3Options): Promise<RunSum
616
636
  `attempt ${attemptId} (${entry.evalId} × ${entry.experimentId} trial ${entry.trialIndex + 1}): ` +
617
637
  `${disposition.status}${disposition.result ? ` — ${disposition.result.passed ? "passed" : "failed"}` : ""}`,
618
638
  );
619
- }, { ...(options.signal ? { signal: options.signal } : {}), capacity: (entry) => experimentsById.get(entry.experimentId)?.runtime.capacity });
639
+ }, {
640
+ ...(options.signal ? { signal: options.signal } : {}),
641
+ capacity: (entry) => resolveRuntimeCapacity(experimentsById.get(entry.experimentId)?.runtime, evalsById.get(entry.evalId)?.metadata),
642
+ });
620
643
 
621
644
  const current = activeAttempts(store.listAttempts(runId));
622
645
  summary.passed = current.filter((a) => a.status === "completed" && a.result?.passed).length;
@@ -649,6 +672,7 @@ interface AttemptArgs {
649
672
  attemptTimeoutMs: number;
650
673
  persisted: NewAttempt;
651
674
  environment?: PairEnvironment;
675
+ workspaceFiles?: WorkspaceFiles;
652
676
  }
653
677
 
654
678
  async function runHostAttemptV3(
@@ -745,6 +769,7 @@ async function runContainerAttemptV3(
745
769
  const { artifacts } = options;
746
770
  const { runId, attemptId, persisted } = args;
747
771
  const runtime = experiment.runtime;
772
+ const workspaceFiles = args.workspaceFiles;
748
773
  const environment = args.environment;
749
774
  let egress: { networkName: string; sidecar: EgressSidecar } | undefined;
750
775
  let mcpIp: string | undefined;
@@ -855,7 +880,7 @@ async function runContainerAttemptV3(
855
880
 
856
881
  const paths = await inStage("environment", () => artifacts.prepareAttempt(runId, attemptId));
857
882
  const fixtureDir = sourceDir;
858
- const container = await inStage("environment", () => createAttemptContainer({
883
+ const container = await inStage("environment", () => (options.createContainer ?? createAttemptContainer)({
859
884
  image,
860
885
  env,
861
886
  resultsDir: paths.attemptDir,
@@ -886,6 +911,7 @@ async function runContainerAttemptV3(
886
911
  return (await mustExec(container, ["git", "rev-parse", "HEAD"])).stdout.trim();
887
912
  });
888
913
  } else {
914
+ if (workspaceFiles) throw new StageFailure("environment", new Error("workspaceFiles: git baseline is required"));
889
915
  (options.log ?? console.log)(
890
916
  `attempt ${attemptId}: git is unavailable in image "${image}" — diff.patch will be empty ` +
891
917
  `(the agent-runtime image ships git; only bare test images lack it)`,
@@ -908,8 +934,13 @@ async function runContainerAttemptV3(
908
934
  };
909
935
  // Treatment config files (e.g. the MCP treatment's vendor config).
910
936
  await inStage("environment", async () => {
911
- for (const [path, content] of Object.entries(agentAdapter.setupFiles(promptCtx))) {
912
- await container.writeFile(path, content);
937
+ const setup = agentAdapter.setupFiles(promptCtx);
938
+ if (workspaceFiles) {
939
+ const evidence = await stageWorkspaceFiles(container, workspaceFiles, environment!.workspaceFiles!, setup, options.signal);
940
+ await artifacts.writeWorkspaceFilesEvidence(runId, attemptId, evidence);
941
+ if (options.signal?.aborted) throw new Error("workspaceFiles: staging cancelled");
942
+ } else {
943
+ for (const [path, content] of Object.entries(setup)) await container.writeFile(path, content);
913
944
  }
914
945
  });
915
946
  command = agentAdapter.command(promptCtx);
@@ -917,6 +948,7 @@ async function runContainerAttemptV3(
917
948
  command = runtime.command as string[];
918
949
  }
919
950
 
951
+ if (workspaceFiles && options.signal?.aborted) throw new StageFailure("environment", new Error("workspaceFiles: staging cancelled"));
920
952
  persisted.agentStartedAt = Date.now();
921
953
  let agentResult: Awaited<ReturnType<typeof container.execInWorkspace>>;
922
954
  try {
@@ -1039,7 +1071,10 @@ async function runContainerAttemptV3(
1039
1071
  try { await egress.sidecar.teardown(); } catch { containerTeardownFailure = "egress sidecar teardown failed"; }
1040
1072
  try { await removeRunNetwork(egress.networkName); } catch { containerTeardownFailure = "egress network teardown failed"; }
1041
1073
  }
1042
- if (sourceDir) await rm(sourceDir, { recursive: true, force: true });
1074
+ if (sourceDir) {
1075
+ try { await rm(sourceDir, { recursive: true, force: true }); }
1076
+ catch { containerTeardownFailure = [containerTeardownFailure, "workspace source cleanup failed"].filter(Boolean).join("; "); }
1077
+ }
1043
1078
  }
1044
1079
 
1045
1080
  // Explicit teardown accounting: the attempt keeps its status and result;
@@ -0,0 +1,52 @@
1
+ import { posix } from "node:path";
2
+ import { canonicalJson } from "../identity-diff.ts";
3
+ import type { AttemptContainer } from "../isolation/docker.ts";
4
+ import type { WorkspaceFilesEvidence } from "../isolation/workspace-files.ts";
5
+ import { pathsOverlap, WORKSPACE_FILES_LIMITS, type WorkspaceFiles, type WorkspaceFilesIdentity } from "../runtime/workspace-files.ts";
6
+
7
+ /** No race-to-timeout: await cancelled transport, then let the attempt's finally tear down. */
8
+ export async function stageWorkspaceFiles(
9
+ container: AttemptContainer,
10
+ declaration: WorkspaceFiles,
11
+ intended: WorkspaceFilesIdentity,
12
+ setup: Record<string, string>,
13
+ parentSignal?: AbortSignal,
14
+ ): Promise<WorkspaceFilesEvidence> {
15
+ const controller = new AbortController();
16
+ const abort = () => controller.abort();
17
+ const timer = setTimeout(abort, WORKSPACE_FILES_LIMITS.timeoutMs);
18
+ parentSignal?.addEventListener("abort", abort, { once: true });
19
+ if (parentSignal?.aborted) abort();
20
+ const signal = controller.signal;
21
+ try {
22
+ const setupPaths = Object.keys(setup);
23
+ for (const path of setupPaths) {
24
+ const relative = posix.relative("/workspace", posix.resolve("/workspace", path));
25
+ if ([...intended.files.map((file) => file.path), ...intended.absent].some((target) => relative === "" || pathsOverlap(relative, target))) {
26
+ throw new Error("workspaceFiles: adapter setup collision");
27
+ }
28
+ }
29
+ const check = async (phase: "stage" | "verify") => {
30
+ signal.throwIfAborted();
31
+ const actual = await container.workspaceFiles({ declaration, phase, setupPaths }, signal);
32
+ signal.throwIfAborted();
33
+ if (canonicalJson(actual) !== canonicalJson(intended)) throw new Error("workspaceFiles: read-back/absence verification mismatch");
34
+ return actual;
35
+ };
36
+ await check("stage");
37
+ for (const [path, content] of Object.entries(setup)) {
38
+ signal.throwIfAborted();
39
+ await container.writeFile(path, content, { signal });
40
+ }
41
+ const verified = await check("verify");
42
+ return { version: 1, verified, verifiedAt: Date.now() };
43
+ } catch (err) {
44
+ if (signal.aborted) throw new Error("workspaceFiles: staging cancelled or timed out");
45
+ // Only our own fixed diagnostics survive; transport/setup errors may carry payloads.
46
+ if (err instanceof Error && ["workspaceFiles: adapter setup collision", "workspaceFiles: read-back/absence verification mismatch"].includes(err.message)) throw err;
47
+ throw new Error("workspaceFiles: staging filesystem/setup operation failed");
48
+ } finally {
49
+ clearTimeout(timer);
50
+ parentSignal?.removeEventListener("abort", abort);
51
+ }
52
+ }