@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 +8 -3
- package/package.json +1 -1
- package/src/adapters/codex.ts +38 -15
- package/src/adapters/types.ts +6 -4
- package/src/build-info.generated.ts +1 -1
- package/src/execution-policy.ts +7 -0
- package/src/experiments/types.ts +19 -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/runner/capacity.ts +17 -0
- package/src/runner/execute.ts +48 -12
- package/src/runner/workspace-files.ts +52 -0
- package/src/runtime/workspace-files.ts +91 -0
- package/src/store/artifacts.ts +7 -0
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`
|
|
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
|
-
`
|
|
136
|
-
artifacts like a key
|
|
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.
|
|
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",
|
package/src/adapters/codex.ts
CHANGED
|
@@ -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
|
|
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
|
|
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),
|
|
34
|
-
* table, so this block always
|
|
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 (
|
|
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
|
|
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
|
-
*
|
|
73
|
-
*
|
|
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"],
|
|
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
|
-
|
|
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") };
|
package/src/adapters/types.ts
CHANGED
|
@@ -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 (
|
|
137
|
-
* `optionalHostEnv`
|
|
138
|
-
*
|
|
139
|
-
*
|
|
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.
|
|
12
|
+
version: "0.11.0", // x-release-please-version
|
|
13
13
|
revision: null,
|
|
14
14
|
dirty: null,
|
|
15
15
|
sourceHash: null,
|
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;
|
|
@@ -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.
|
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,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
|
-
/**
|
|
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
|
+
/** 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,
|
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,
|
|
@@ -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;
|
|
@@ -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
|
-
}, {
|
|
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
|
-
//
|
|
906
|
-
// config-file entry (codex's model provider
|
|
907
|
-
|
|
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
|
-
|
|
912
|
-
|
|
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)
|
|
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
|
+
}
|
package/src/store/artifacts.ts
CHANGED
|
@@ -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");
|