@workos/quickstudy 0.9.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/README.md CHANGED
@@ -130,10 +130,11 @@ preflight key probe at it:
130
130
  | Agent | Overrides | How the CLI gets them |
131
131
  | ------ | ------------------------------------------------ | -------------------------------------------------------------------------------------- |
132
132
  | claude | `ANTHROPIC_BASE_URL`, `ANTHROPIC_CUSTOM_HEADERS` | env, read natively by Claude Code (and by the `diagnose` analyst) |
133
- | codex | `OPENAI_BASE_URL` | rendered into `config.toml` as a custom model provider (codex ignores the env var) |
133
+ | codex | `OPENAI_BASE_URL`, `OPENAI_CUSTOM_HEADERS` | rendered into `config.toml` as a custom model provider (`base_url`, `http_headers`); codex has no env channel for either |
134
134
 
135
- `ANTHROPIC_CUSTOM_HEADERS` is `Name: Value` lines and is redacted from
136
- artifacts like a key. With Cloudflare AI Gateway holding the provider keys,
135
+ The `*_CUSTOM_HEADERS` bundles are `Name: Value` lines and are redacted from
136
+ artifacts like a key; a `cf-aig-metadata` line is how a gateway attributes
137
+ the traffic per app. With Cloudflare AI Gateway holding the provider keys,
137
138
  the gateway token *is* the API key:
138
139
 
139
140
  ```bash
@@ -142,8 +143,12 @@ export ANTHROPIC_API_KEY=<gateway token>
142
143
  export ANTHROPIC_CUSTOM_HEADERS="cf-aig-authorization: Bearer <gateway token>"
143
144
  export OPENAI_BASE_URL=https://gateway.ai.cloudflare.com/v1/<account>/<gateway>/openai
144
145
  export OPENAI_API_KEY=<gateway token>
146
+ export OPENAI_CUSTOM_HEADERS='cf-aig-metadata: {"app":"my-evals"}'
145
147
  ```
146
148
 
149
+ Leave every override unset and both agents run vendor-direct against
150
+ api.anthropic.com and api.openai.com — nothing is rendered, nothing changes.
151
+
147
152
  ## How it works
148
153
 
149
154
  Every attempt is one point in a 2-D plan:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workos/quickstudy",
3
- "version": "0.9.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",
@@ -10,38 +10,55 @@
10
10
  * command wrapper pipes it into `codex login --with-api-key` (stdin, never
11
11
  * argv) to write the auth.json the CLI requires.
12
12
  *
13
- * Endpoint override: an OPENAI_BASE_URL set on the host (an LLM gateway) is
13
+ * Endpoint overrides: an OPENAI_BASE_URL set on the host (an LLM gateway) is
14
14
  * rendered into the same config.toml as a custom model provider — codex
15
15
  * 0.149 ignores the env var for its built-in provider (verified live: it
16
16
  * still dialed api.openai.com), while a `[model_providers.*]` entry with
17
17
  * `env_key = "OPENAI_API_KEY"` routes every request through the override.
18
+ * OPENAI_CUSTOM_HEADERS (`Name: Value` lines, the Codex counterpart of Claude
19
+ * Code's ANTHROPIC_CUSTOM_HEADERS) becomes that provider's `http_headers` —
20
+ * e.g. `cf-aig-metadata` so a gateway can attribute the traffic. Codex has
21
+ * no env-var channel for extra headers, so the values land in config.toml
22
+ * inside the attempt container: the same trust boundary as the env that
23
+ * already carries them, written over stdin, destroyed with the container.
24
+ * With neither override set nothing is rendered and the built-in provider
25
+ * talks to api.openai.com — vendor-direct stays the default.
18
26
  */
19
27
 
20
28
  import type { McpServerConfig } from "../isolation/mcp.ts";
21
- import { endpointUrl } from "../secrets.ts";
29
+ import { endpointUrl, parseHeaderLines } from "../secrets.ts";
22
30
  import { parseCodexStream } from "./parse.ts";
23
31
  import type { AttemptMetrics, ContainerAgentAdapter, ContainerAttemptContext } from "./types.ts";
24
32
 
25
33
  /** Container path of the Codex config (containers run as root). */
26
34
  export const CODEX_CONFIG_PATH = "/root/.codex/config.toml";
27
35
 
28
- /** The config.toml model provider id an OPENAI_BASE_URL override is rendered as. */
36
+ /** The config.toml model provider id the OPENAI_* overrides are rendered as. */
29
37
  export const CODEX_GATEWAY_PROVIDER = "quickstudy-gateway";
30
38
 
39
+ /** Codex's own default for the built-in provider; the probe and a headers-only override use it. */
40
+ const OPENAI_DEFAULT_BASE_URL = "https://api.openai.com/v1";
41
+
31
42
  /**
32
43
  * A custom model provider pointing at `baseUrl` (the Responses API, which
33
- * codex requires), selected as the default. Top-level keys must precede any
34
- * table, so this block always renders first in the file.
44
+ * codex requires), carrying `headers` on every request, selected as the
45
+ * default. Top-level keys must precede any table, so this block always
46
+ * renders first in the file. JSON string escapes are valid TOML basic-string
47
+ * escapes, which keeps header values such as JSON metadata intact.
35
48
  */
36
- function renderCodexProviderToml(baseUrl: string): string {
49
+ function renderCodexProviderToml(baseUrl: string, headers: Readonly<Record<string, string>>): string {
50
+ const entries = Object.entries(headers);
37
51
  return [
38
52
  `model_provider = ${JSON.stringify(CODEX_GATEWAY_PROVIDER)}`,
39
53
  "",
40
54
  `[model_providers.${CODEX_GATEWAY_PROVIDER}]`,
41
- 'name = "OpenAI (via OPENAI_BASE_URL)"',
55
+ 'name = "OpenAI (quickstudy endpoint overrides)"',
42
56
  `base_url = ${JSON.stringify(baseUrl)}`,
43
57
  'env_key = "OPENAI_API_KEY"',
44
58
  'wire_api = "responses"',
59
+ ...(entries.length > 0
60
+ ? [`http_headers = { ${entries.map(([name, value]) => `${JSON.stringify(name)} = ${JSON.stringify(value)}`).join(", ")} }`]
61
+ : []),
45
62
  "",
46
63
  ].join("\n");
47
64
  }
@@ -66,13 +83,15 @@ export class CodexAdapter implements ContainerAgentAdapter {
66
83
  readonly versionCommand = ["codex", "--version"] as const;
67
84
  readonly requiredHostEnv = ["OPENAI_API_KEY"] as const;
68
85
  /**
69
- * Endpoint override, forwarded only when set: OPENAI_BASE_URL (vendor
86
+ * Endpoint overrides, forwarded only when set: OPENAI_BASE_URL (vendor
70
87
  * default https://api.openai.com/v1) — e.g. Cloudflare AI Gateway's
71
- * `/openai` endpoint, where the gateway token doubles as OPENAI_API_KEY.
72
- * Rendered into config.toml by setupFiles (see the module comment); the
73
- * key probe follows it.
88
+ * `/openai` endpoint, where the gateway token doubles as OPENAI_API_KEY —
89
+ * and OPENAI_CUSTOM_HEADERS (`Name: Value` lines) for whatever else the
90
+ * endpoint wants on each request, such as `cf-aig-metadata`. Both are
91
+ * rendered into config.toml by setupFiles (see the module comment); the
92
+ * key probe follows them.
74
93
  */
75
- readonly optionalHostEnv = ["OPENAI_BASE_URL"] as const;
94
+ readonly optionalHostEnv = ["OPENAI_BASE_URL", "OPENAI_CUSTOM_HEADERS"] as const;
76
95
  /**
77
96
  * Egress-proxy allowlist (harness-default layer). Codex is reqwest-based;
78
97
  * reqwest honors HTTP_PROXY/HTTPS_PROXY env by default (system proxies) —
@@ -103,15 +122,19 @@ export class CodexAdapter implements ContainerAgentAdapter {
103
122
 
104
123
  keyProbe(env: Record<string, string>): { url: string; headers: Record<string, string> } {
105
124
  return {
106
- url: endpointUrl(env["OPENAI_BASE_URL"], "https://api.openai.com/v1", "models"),
107
- headers: { Authorization: `Bearer ${env["OPENAI_API_KEY"] ?? ""}` },
125
+ url: endpointUrl(env["OPENAI_BASE_URL"], OPENAI_DEFAULT_BASE_URL, "models"),
126
+ headers: { ...parseHeaderLines(env["OPENAI_CUSTOM_HEADERS"]), Authorization: `Bearer ${env["OPENAI_API_KEY"] ?? ""}` },
108
127
  };
109
128
  }
110
129
 
111
130
  setupFiles(ctx: ContainerAttemptContext): Record<string, string> {
112
131
  const sections: string[] = [];
113
132
  const baseUrl = ctx.overrides?.["OPENAI_BASE_URL"];
114
- if (baseUrl !== undefined) sections.push(renderCodexProviderToml(baseUrl));
133
+ const headers = parseHeaderLines(ctx.overrides?.["OPENAI_CUSTOM_HEADERS"]);
134
+ // Either override needs the custom provider; headers alone keep the vendor endpoint.
135
+ if (baseUrl !== undefined || Object.keys(headers).length > 0) {
136
+ sections.push(renderCodexProviderToml(baseUrl ?? OPENAI_DEFAULT_BASE_URL, headers));
137
+ }
115
138
  if (ctx.mcpServers !== undefined) sections.push(renderCodexMcpToml(ctx.mcpServers));
116
139
  if (sections.length === 0) return {};
117
140
  return { [CODEX_CONFIG_PATH]: sections.join("\n") };
@@ -133,10 +133,12 @@ export interface ContainerAttemptContext {
133
133
  /** The runtime's native web-tool policy (default: blocked). */
134
134
  webPolicy: WebPolicy;
135
135
  /**
136
- * The adapter's endpoint overrides that are set on the host (its
137
- * `optionalHostEnv` minus secret-named entries — never provider keys or
138
- * header bundles), for a CLI that takes a config-file entry rather than an
139
- * env var. Absent when none are set.
136
+ * The adapter's own endpoint overrides that are set on the host (every
137
+ * `optionalHostEnv` value, header bundles included — never the provider
138
+ * keys), for a CLI that takes a config-file entry rather than an env var.
139
+ * Anything rendered from these is written inside the attempt container
140
+ * only, the same boundary the env already crosses. Absent when none are
141
+ * set.
140
142
  */
141
143
  overrides?: Readonly<Record<string, string>>;
142
144
  }
@@ -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.9.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;
@@ -823,7 +849,6 @@ async function runContainerAttemptV3(
823
849
  // Endpoint overrides set on the host for this agent (gateway base URLs
824
850
  // and the headers they need) — see ContainerAgentAdapter.optionalHostEnv.
825
851
  const overrides = agentAdapter !== undefined ? collectOptionalHostEnv([agentAdapter]) : {};
826
- const publicOverrides = Object.fromEntries(Object.entries(overrides).filter(([name]) => !isSecretEnvName(name)));
827
852
  if (agentAdapter !== undefined) {
828
853
  // The agent's provider keys, validated at run preflight — injected
829
854
  // at create via the 0600 env-file channel, never argv.
@@ -856,7 +881,7 @@ async function runContainerAttemptV3(
856
881
 
857
882
  const paths = await inStage("environment", () => artifacts.prepareAttempt(runId, attemptId));
858
883
  const fixtureDir = sourceDir;
859
- const container = await inStage("environment", () => createAttemptContainer({
884
+ const container = await inStage("environment", () => (options.createContainer ?? createAttemptContainer)({
860
885
  image,
861
886
  env,
862
887
  resultsDir: paths.attemptDir,
@@ -887,6 +912,7 @@ async function runContainerAttemptV3(
887
912
  return (await mustExec(container, ["git", "rev-parse", "HEAD"])).stdout.trim();
888
913
  });
889
914
  } else {
915
+ if (workspaceFiles) throw new StageFailure("environment", new Error("workspaceFiles: git baseline is required"));
890
916
  (options.log ?? console.log)(
891
917
  `attempt ${attemptId}: git is unavailable in image "${image}" — diff.patch will be empty ` +
892
918
  `(the agent-runtime image ships git; only bare test images lack it)`,
@@ -902,14 +928,20 @@ async function runContainerAttemptV3(
902
928
  trialIndex: entry.trialIndex,
903
929
  ...(rewrittenServers !== undefined ? { mcpServers: rewrittenServers } : {}),
904
930
  webPolicy: environment?.webPolicy ?? "native-web-blocked",
905
- // Never the keys or header bundles: adapters whose CLI wants a
906
- // config-file entry (codex's model provider) render from here.
907
- ...(Object.keys(publicOverrides).length > 0 ? { overrides: publicOverrides } : {}),
931
+ // The adapter's own overrides (never the provider keys): a CLI
932
+ // that wants a config-file entry (codex's model provider and its
933
+ // http_headers) renders them from here, inside the container.
934
+ ...(Object.keys(overrides).length > 0 ? { overrides } : {}),
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");