@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 +1 -1
- package/src/build-info.generated.ts +1 -1
- package/src/evals/types.ts +5 -3
- package/src/execution-policy.ts +24 -3
- package/src/experiments/types.ts +33 -3
- package/src/index.ts +4 -1
- package/src/isolation/docker.ts +25 -5
- package/src/isolation/workspace-files.ts +92 -0
- package/src/manifest.ts +8 -1
- package/src/report/aggregate.ts +2 -2
- package/src/runner/capacity.ts +17 -0
- package/src/runner/execute.ts +51 -16
- package/src/runner/workspace-files.ts +52 -0
- package/src/runtime/workspace-files.ts +91 -0
- package/src/store/artifacts.ts +7 -0
- package/ui/dist/index.html +10 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@workos/quickstudy",
|
|
3
|
-
"version": "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.
|
|
12
|
+
version: "0.12.0", // x-release-please-version
|
|
13
13
|
revision: null,
|
|
14
14
|
dirty: null,
|
|
15
15
|
sourceHash: null,
|
package/src/evals/types.ts
CHANGED
|
@@ -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. `
|
|
12
|
-
*
|
|
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. */
|
package/src/execution-policy.ts
CHANGED
|
@@ -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:
|
|
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",
|
package/src/experiments/types.ts
CHANGED
|
@@ -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
|
-
/**
|
|
39
|
-
|
|
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,
|
package/src/isolation/docker.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
package/src/report/aggregate.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/src/runner/execute.ts
CHANGED
|
@@ -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)
|
|
463
|
-
// allowlist
|
|
464
|
-
//
|
|
465
|
-
//
|
|
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
|
-
|
|
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
|
-
}, {
|
|
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
|
-
|
|
912
|
-
|
|
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)
|
|
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
|
+
}
|