@workos/quickstudy 0.10.0 → 0.11.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.11.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.11.0", // x-release-please-version
13
13
  revision: null,
14
14
  dirty: null,
15
15
  sourceHash: null,
@@ -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;
@@ -55,6 +58,8 @@ export function resolvePairEnvironments(options: {
55
58
  budgets?: Partial<LifecycleBudgets>;
56
59
  mcpHashes?: Record<string, string>;
57
60
  imageTags?: Record<string, string>;
61
+ /** Runner-captured declarations; entries with undefined explicitly mean no overlay. */
62
+ workspaceFilesByExperiment?: ReadonlyMap<string, WorkspaceFiles | undefined>;
58
63
  }): Record<string, PairEnvironment> {
59
64
  const budgets = { ...DEFAULT_BUDGETS, ...options.budgets };
60
65
  for (const [name, value] of Object.entries(budgets))
@@ -63,11 +68,13 @@ export function resolvePairEnvironments(options: {
63
68
  for (const loaded of options.evals)
64
69
  for (const { experiment } of options.experiments) {
65
70
  const runtime = experiment.runtime;
71
+ const files = options.workspaceFilesByExperiment ? options.workspaceFilesByExperiment.get(experiment.id) : runtime.workspaceFiles;
66
72
  const tag =
67
73
  options.imageTags?.[pairKey(loaded.metadata.id, experiment.id)] ??
68
74
  runtime.image ??
69
75
  runtime.containerImage?.(loaded.metadata);
70
76
  pairs[pairKey(loaded.metadata.id, experiment.id)] = {
77
+ ...(files ? { workspaceFiles: workspaceFilesIdentity(files) } : {}),
71
78
  image: tag ? (options.images?.[tag] ?? { tag, id: "unresolved", repoDigests: [] }) : null,
72
79
  // With the flag, every container is proxied. No runtime hosts means
73
80
  // only the adapter's provider endpoints are permitted.
@@ -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,20 @@ 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
+ /** Attempt-local non-secret files, staged without replacing the normal adapter. */
59
+ workspaceFiles?: WorkspaceFiles;
45
60
  /** Container image tag (container runtimes; alternative to `containerImage`). */
46
61
  image?: string;
47
62
  /** Scripted command run in /workspace (container runtimes, this phase). */
@@ -112,5 +127,6 @@ export function defineExperiment(experiment: Experiment): Experiment {
112
127
  if (experiment.comparisonGroup !== undefined && experiment.comparisonGroup.trim() === "") {
113
128
  throw new Error(`defineExperiment: experiment "${experiment.id}" comparisonGroup must not be empty`);
114
129
  }
130
+ validateWorkspaceFilesRuntime(experiment.runtime);
115
131
  return deepFreeze(experiment);
116
132
  }
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,
@@ -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;
@@ -468,7 +480,7 @@ export async function executeRunV3(options: ExecuteRunV3Options): Promise<RunSum
468
480
  const endpoints = Object.fromEntries(Object.entries(overrides).filter(([name]) => !isSecretEnvName(name)));
469
481
  return Object.keys(endpoints).length > 0 ? [[id, endpoints] as const] : [];
470
482
  }));
471
- const pairs = resolvePairEnvironments({ ...options, images, imageTags: imageTagsByPair,
483
+ const pairs = resolvePairEnvironments({ ...options, workspaceFilesByExperiment, images, imageTags: imageTagsByPair,
472
484
  adapterHosts: Object.fromEntries([...containerAgents].map(([id, adapter]) => [id, [
473
485
  ...(adapter.egressHosts ?? []),
474
486
  ...(options.egressProxy ? endpointHosts(overridesByExperiment.get(id) ?? {}) : []),
@@ -477,7 +489,16 @@ export async function executeRunV3(options: ExecuteRunV3Options): Promise<RunSum
477
489
  budgets: { ...options.budgets, agentMs: options.attemptTimeoutMs ?? options.budgets?.agentMs ?? attemptTimeoutMs },
478
490
  mcpHashes: Object.fromEntries([...declaredByExperiment].map(([id, servers]) => [id, hashString(canonicalJson(servers))])),
479
491
  });
480
- manifest = buildRunManifest({ evals: options.evals, experiments: options.experiments, images, cliVersions, pairs, scorerIdentities });
492
+ manifest = buildRunManifest({ evals: options.evals, experiments: options.experiments, workspaceFilesByExperiment, images, cliVersions, pairs, scorerIdentities });
493
+ }
494
+ // Injected/stale manifests must not authorize a different payload (or silently
495
+ // omit one). Historical no-overlay manifests retain their original shape.
496
+ for (const entry of plan) {
497
+ const files = workspaceFilesByExperiment.get(entry.experimentId);
498
+ const intended = files ? workspaceFilesIdentity(files) : undefined;
499
+ if (canonicalJson(intended ?? null) !== canonicalJson(manifest.pairs?.[pairKey(entry.evalId, entry.experimentId)]?.workspaceFiles ?? null)) {
500
+ throw new Error("workspaceFiles: manifest payload identity mismatch");
501
+ }
481
502
  }
482
503
  const recorded = options.resumeRunId ? store.getRun(options.resumeRunId) : null;
483
504
  if (options.resumeRunId && !recorded) throw new Error(`cannot resume unknown run ${options.resumeRunId}`);
@@ -582,7 +603,7 @@ export async function executeRunV3(options: ExecuteRunV3Options): Promise<RunSum
582
603
  loadedEval,
583
604
  experiment,
584
605
  entry,
585
- { runId, attemptId, attemptTimeoutMs, persisted, environment: manifest.pairs?.[pairKey(entry.evalId, entry.experimentId)] },
606
+ { runId, attemptId, attemptTimeoutMs, persisted, environment: manifest.pairs?.[pairKey(entry.evalId, entry.experimentId)], workspaceFiles: workspaceFilesByExperiment.get(entry.experimentId) },
586
607
  treatments,
587
608
  containerAgents.get(experiment.id),
588
609
  );
@@ -616,7 +637,10 @@ export async function executeRunV3(options: ExecuteRunV3Options): Promise<RunSum
616
637
  `attempt ${attemptId} (${entry.evalId} × ${entry.experimentId} trial ${entry.trialIndex + 1}): ` +
617
638
  `${disposition.status}${disposition.result ? ` — ${disposition.result.passed ? "passed" : "failed"}` : ""}`,
618
639
  );
619
- }, { ...(options.signal ? { signal: options.signal } : {}), capacity: (entry) => experimentsById.get(entry.experimentId)?.runtime.capacity });
640
+ }, {
641
+ ...(options.signal ? { signal: options.signal } : {}),
642
+ capacity: (entry) => resolveRuntimeCapacity(experimentsById.get(entry.experimentId)?.runtime, evalsById.get(entry.evalId)?.metadata),
643
+ });
620
644
 
621
645
  const current = activeAttempts(store.listAttempts(runId));
622
646
  summary.passed = current.filter((a) => a.status === "completed" && a.result?.passed).length;
@@ -649,6 +673,7 @@ interface AttemptArgs {
649
673
  attemptTimeoutMs: number;
650
674
  persisted: NewAttempt;
651
675
  environment?: PairEnvironment;
676
+ workspaceFiles?: WorkspaceFiles;
652
677
  }
653
678
 
654
679
  async function runHostAttemptV3(
@@ -745,6 +770,7 @@ async function runContainerAttemptV3(
745
770
  const { artifacts } = options;
746
771
  const { runId, attemptId, persisted } = args;
747
772
  const runtime = experiment.runtime;
773
+ const workspaceFiles = args.workspaceFiles;
748
774
  const environment = args.environment;
749
775
  let egress: { networkName: string; sidecar: EgressSidecar } | undefined;
750
776
  let mcpIp: string | undefined;
@@ -855,7 +881,7 @@ async function runContainerAttemptV3(
855
881
 
856
882
  const paths = await inStage("environment", () => artifacts.prepareAttempt(runId, attemptId));
857
883
  const fixtureDir = sourceDir;
858
- const container = await inStage("environment", () => createAttemptContainer({
884
+ const container = await inStage("environment", () => (options.createContainer ?? createAttemptContainer)({
859
885
  image,
860
886
  env,
861
887
  resultsDir: paths.attemptDir,
@@ -886,6 +912,7 @@ async function runContainerAttemptV3(
886
912
  return (await mustExec(container, ["git", "rev-parse", "HEAD"])).stdout.trim();
887
913
  });
888
914
  } else {
915
+ if (workspaceFiles) throw new StageFailure("environment", new Error("workspaceFiles: git baseline is required"));
889
916
  (options.log ?? console.log)(
890
917
  `attempt ${attemptId}: git is unavailable in image "${image}" — diff.patch will be empty ` +
891
918
  `(the agent-runtime image ships git; only bare test images lack it)`,
@@ -908,8 +935,13 @@ async function runContainerAttemptV3(
908
935
  };
909
936
  // Treatment config files (e.g. the MCP treatment's vendor config).
910
937
  await inStage("environment", async () => {
911
- for (const [path, content] of Object.entries(agentAdapter.setupFiles(promptCtx))) {
912
- await container.writeFile(path, content);
938
+ const setup = agentAdapter.setupFiles(promptCtx);
939
+ if (workspaceFiles) {
940
+ const evidence = await stageWorkspaceFiles(container, workspaceFiles, environment!.workspaceFiles!, setup, options.signal);
941
+ await artifacts.writeWorkspaceFilesEvidence(runId, attemptId, evidence);
942
+ if (options.signal?.aborted) throw new Error("workspaceFiles: staging cancelled");
943
+ } else {
944
+ for (const [path, content] of Object.entries(setup)) await container.writeFile(path, content);
913
945
  }
914
946
  });
915
947
  command = agentAdapter.command(promptCtx);
@@ -917,6 +949,7 @@ async function runContainerAttemptV3(
917
949
  command = runtime.command as string[];
918
950
  }
919
951
 
952
+ if (workspaceFiles && options.signal?.aborted) throw new StageFailure("environment", new Error("workspaceFiles: staging cancelled"));
920
953
  persisted.agentStartedAt = Date.now();
921
954
  let agentResult: Awaited<ReturnType<typeof container.execInWorkspace>>;
922
955
  try {
@@ -1039,7 +1072,10 @@ async function runContainerAttemptV3(
1039
1072
  try { await egress.sidecar.teardown(); } catch { containerTeardownFailure = "egress sidecar teardown failed"; }
1040
1073
  try { await removeRunNetwork(egress.networkName); } catch { containerTeardownFailure = "egress network teardown failed"; }
1041
1074
  }
1042
- if (sourceDir) await rm(sourceDir, { recursive: true, force: true });
1075
+ if (sourceDir) {
1076
+ try { await rm(sourceDir, { recursive: true, force: true }); }
1077
+ catch { containerTeardownFailure = [containerTeardownFailure, "workspace source cleanup failed"].filter(Boolean).join("; "); }
1078
+ }
1043
1079
  }
1044
1080
 
1045
1081
  // 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
+ }
@@ -0,0 +1,91 @@
1
+ import { hashString, SECRET_FILE } from "../hash.ts";
2
+ import { EXCLUDED_WORKSPACE_DIRS } from "../store/artifacts.ts";
3
+
4
+ /** Non-secret, regular, non-executable files. Source labels are declarations, not attestations. */
5
+ export interface WorkspaceFile {
6
+ path: string;
7
+ content: string;
8
+ encoding: "utf8" | "base64";
9
+ source: { id: string; version: string };
10
+ }
11
+
12
+ /** v1: create-only, no symlinks/hardlinks, 0644 files, final read-back, no deletion. */
13
+ export interface WorkspaceFiles {
14
+ version: 1;
15
+ files: readonly WorkspaceFile[];
16
+ absent: readonly string[];
17
+ }
18
+
19
+ export interface WorkspaceFilesIdentity {
20
+ version: 1;
21
+ files: readonly { path: string; size: number; sha256: string; source: { id: string; version: string } }[];
22
+ absent: readonly string[];
23
+ }
24
+
25
+ export const WORKSPACE_FILES_LIMITS = Object.freeze({ files: 128, absent: 128, fileBytes: 262144, totalBytes: 1048576, pathBytes: 240, depth: 16, timeoutMs: 30000 });
26
+
27
+ function invalid(reason: string): never {
28
+ // Never interpolate consumer data: even malformed metadata may contain secrets.
29
+ throw new Error(`workspaceFiles: ${reason}`);
30
+ }
31
+
32
+ function pathCheck(path: unknown): asserts path is string {
33
+ if (typeof path !== "string" || path.length > WORKSPACE_FILES_LIMITS.pathBytes) invalid("invalid relative path");
34
+ const parts = path.split("/");
35
+ if (parts.length > WORKSPACE_FILES_LIMITS.depth || parts.some((part) =>
36
+ !/^[A-Za-z0-9_.-]+$/.test(part) || part === "." || part === ".." || part.endsWith(".") ||
37
+ /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(?:\.|$)/i.test(part) ||
38
+ EXCLUDED_WORKSPACE_DIRS.includes(part.toLowerCase()) || SECRET_FILE.test(part) ||
39
+ /^\.(?:git|hg|svn)/i.test(part) || /^\.quickstudy/i.test(part)
40
+ )) invalid("unsafe or reserved relative path");
41
+ }
42
+
43
+ export function pathsOverlap(a: string, b: string): boolean {
44
+ a = a.toLowerCase(); b = b.toLowerCase();
45
+ return a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
46
+ }
47
+
48
+ /** Validate, copy, canonicalize to base64, and freeze without retaining mutable caller state. */
49
+ export function defineWorkspaceFiles(input: WorkspaceFiles): WorkspaceFiles {
50
+ if (!input || input.version !== 1) invalid("unsupported staging version");
51
+ if (!Array.isArray(input.files) || !Array.isArray(input.absent) ||
52
+ input.files.length > WORKSPACE_FILES_LIMITS.files || input.absent.length > WORKSPACE_FILES_LIMITS.absent) invalid("invalid inventory or count limit exceeded");
53
+ let total = 0;
54
+ const files = input.files.map((file) => {
55
+ if (!file || typeof file.content !== "string" || file.content.length > WORKSPACE_FILES_LIMITS.fileBytes * 4) invalid("invalid or oversized payload");
56
+ pathCheck(file.path);
57
+ if (!file.source || ![file.source.id, file.source.version].every((v) => typeof v === "string" && /^[A-Za-z0-9][A-Za-z0-9_.:@+-]{0,159}$/.test(v))) invalid("invalid source id/version (use opaque labels, not paths or URLs)");
58
+ if (file.encoding !== "utf8" && file.encoding !== "base64") invalid("unsupported encoding");
59
+ const bytes = Buffer.from(file.content, file.encoding === "utf8" ? "utf8" : "base64");
60
+ if (file.encoding === "base64" && bytes.toString("base64") !== file.content) invalid("base64 must be canonical padded RFC 4648");
61
+ if (file.encoding === "utf8" && bytes.toString("utf8") !== file.content) invalid("utf8 must not contain unpaired surrogates");
62
+ total += bytes.length;
63
+ if (bytes.length > WORKSPACE_FILES_LIMITS.fileBytes || total > WORKSPACE_FILES_LIMITS.totalBytes) invalid("payload size limit exceeded");
64
+ return Object.freeze({ path: file.path, content: bytes.toString("base64"), encoding: "base64" as const, source: Object.freeze({ id: file.source.id, version: file.source.version }) });
65
+ }).sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
66
+ const absent = [...input.absent];
67
+ for (const path of absent) pathCheck(path);
68
+ const paths = [...files.map((file) => file.path), ...absent];
69
+ for (let i = 0; i < paths.length; i++) for (let j = i + 1; j < paths.length; j++) {
70
+ if (pathsOverlap(paths[i]!, paths[j]!)) invalid("duplicate or overlapping destinations/absence assertions");
71
+ }
72
+ return Object.freeze({ version: 1, files: Object.freeze(files), absent: Object.freeze(absent.sort()) });
73
+ }
74
+
75
+ export function workspaceFilesIdentity(input: WorkspaceFiles): WorkspaceFilesIdentity {
76
+ const declaration = defineWorkspaceFiles(input);
77
+ return {
78
+ version: declaration.version,
79
+ files: declaration.files.map((file) => {
80
+ const bytes = Buffer.from(file.content, "base64");
81
+ return { path: file.path, size: bytes.length, sha256: hashString(bytes), source: file.source };
82
+ }),
83
+ absent: declaration.absent,
84
+ };
85
+ }
86
+
87
+ export function validateWorkspaceFilesRuntime(runtime: { kind: string; command?: string[]; workspaceFiles?: WorkspaceFiles }): void {
88
+ if (runtime.workspaceFiles === undefined) return;
89
+ defineWorkspaceFiles(runtime.workspaceFiles);
90
+ if (runtime.kind !== "container" || (runtime.command?.length ?? 0) > 0) invalid("requires a container adapter; host and scripted commands are unsupported");
91
+ }
@@ -22,6 +22,7 @@
22
22
 
23
23
  import { cp, mkdir, writeFile, lstat, realpath, readlink } from "node:fs/promises";
24
24
  import { basename, join, resolve } from "node:path";
25
+ import type { WorkspaceFilesEvidence } from "../isolation/workspace-files.ts";
25
26
  import type { TranscriptEvent } from "../adapters/types.ts";
26
27
 
27
28
  /** Dependency directories never copied into an exported workspace. */
@@ -73,6 +74,12 @@ export class ArtifactsStore {
73
74
  return paths;
74
75
  }
75
76
 
77
+ /** Placement evidence, distinct from intended identity; proves no model use. */
78
+ async writeWorkspaceFilesEvidence(runId: string, attemptId: string, evidence: WorkspaceFilesEvidence): Promise<void> {
79
+ const paths = await this.prepareAttempt(runId, attemptId);
80
+ await writeFile(join(paths.attemptDir, "workspace-files.json"), `${JSON.stringify(evidence, null, 2)}\n`, "utf8");
81
+ }
82
+
76
83
  async writeTranscript(runId: string, attemptId: string, events: TranscriptEvent[]): Promise<string> {
77
84
  const paths = await this.prepareAttempt(runId, attemptId);
78
85
  const jsonl = events.map((event) => JSON.stringify(event)).join("\n");