self-bench 0.3.0 → 0.3.3

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.
Files changed (140) hide show
  1. package/.dockerignore +9 -0
  2. package/Dockerfile +38 -0
  3. package/Dockerfile.sandbox +24 -0
  4. package/README.md +103 -171
  5. package/biome.json +18 -0
  6. package/bun.lock +1182 -0
  7. package/compose.yaml +85 -0
  8. package/dist/agent-smoke-main.js +1 -1
  9. package/dist/api.d.ts.map +1 -1
  10. package/dist/api.js +40 -2
  11. package/dist/api.js.map +1 -1
  12. package/dist/artifacts.d.ts +0 -1
  13. package/dist/artifacts.d.ts.map +1 -1
  14. package/dist/artifacts.js +0 -3
  15. package/dist/artifacts.js.map +1 -1
  16. package/dist/build-metadata.d.ts +2 -0
  17. package/dist/build-metadata.d.ts.map +1 -0
  18. package/dist/build-metadata.js +2 -0
  19. package/dist/build-metadata.js.map +1 -0
  20. package/dist/cli.js +26 -8
  21. package/dist/cli.js.map +1 -1
  22. package/dist/codex-review.d.ts +2 -1
  23. package/dist/codex-review.d.ts.map +1 -1
  24. package/dist/codex-review.js +26 -22
  25. package/dist/codex-review.js.map +1 -1
  26. package/dist/config.d.ts +0 -1
  27. package/dist/config.d.ts.map +1 -1
  28. package/dist/config.js +0 -2
  29. package/dist/config.js.map +1 -1
  30. package/dist/contracts.d.ts +1 -1
  31. package/dist/contracts.d.ts.map +1 -1
  32. package/dist/eval-main.js +3 -3
  33. package/dist/evaluate.d.ts.map +1 -1
  34. package/dist/evaluate.js +12 -7
  35. package/dist/evaluate.js.map +1 -1
  36. package/dist/provenance.d.ts +0 -1
  37. package/dist/provenance.d.ts.map +1 -1
  38. package/dist/provenance.js.map +1 -1
  39. package/dist/reaudit-main.js +10 -7
  40. package/dist/reaudit-main.js.map +1 -1
  41. package/dist/repair-main.js +8 -5
  42. package/dist/repair-main.js.map +1 -1
  43. package/dist/review/assets/index-CZ3G759O.js +1568 -0
  44. package/dist/review/assets/index-DgSLzX8l.css +1 -0
  45. package/dist/review/assets/{mojo-Blby8dQK.js → mojo-DJz3ZmWd.js} +1 -1
  46. package/dist/review/assets/typst-BUadGCkm.js +1 -0
  47. package/dist/review/index.html +2 -2
  48. package/dist/sandbox-repair.bundle.js +8 -7
  49. package/dist/sandbox-repair.js +7 -7
  50. package/dist/sandbox-repair.js.map +1 -1
  51. package/dist/sandbox-review.bundle.js +29 -25
  52. package/dist/sandbox-review.js +5 -3
  53. package/dist/sandbox-review.js.map +1 -1
  54. package/dist/sandbox-validation-repair.bundle.js +9 -9
  55. package/dist/sandbox-validation-repair.js +11 -9
  56. package/dist/sandbox-validation-repair.js.map +1 -1
  57. package/dist/subscription-auth.d.ts +12 -1
  58. package/dist/subscription-auth.d.ts.map +1 -1
  59. package/dist/subscription-auth.js +16 -16
  60. package/dist/subscription-auth.js.map +1 -1
  61. package/dist/{activities.d.ts → temporal/activities.d.ts} +2 -3
  62. package/dist/temporal/activities.d.ts.map +1 -0
  63. package/dist/{activities.js → temporal/activities.js} +44 -33
  64. package/dist/temporal/activities.js.map +1 -0
  65. package/dist/{temporal.d.ts → temporal/connection.d.ts} +2 -2
  66. package/dist/temporal/connection.d.ts.map +1 -0
  67. package/dist/{temporal.js → temporal/connection.js} +1 -1
  68. package/dist/temporal/connection.js.map +1 -0
  69. package/dist/{worker-main.d.ts.map → temporal/worker-main.d.ts.map} +1 -1
  70. package/dist/{worker-main.js → temporal/worker-main.js} +5 -9
  71. package/dist/temporal/worker-main.js.map +1 -0
  72. package/dist/{workflow.d.ts → temporal/workflow.d.ts} +1 -1
  73. package/dist/temporal/workflow.d.ts.map +1 -0
  74. package/dist/temporal/workflow.js.map +1 -0
  75. package/dist/validate-main.js +1 -1
  76. package/docs/evaluations.md +68 -0
  77. package/docs/operations.md +169 -0
  78. package/docs/task-construction.md +94 -0
  79. package/package.json +33 -20
  80. package/scripts/verify-package.ts +57 -0
  81. package/scripts/write-build-metadata.ts +27 -0
  82. package/src/agent-smoke-main.ts +63 -0
  83. package/src/agent-smoke.ts +132 -0
  84. package/src/api-main.ts +12 -0
  85. package/src/api.ts +277 -0
  86. package/src/artifacts.ts +357 -0
  87. package/src/audit.ts +106 -0
  88. package/src/build-metadata.ts +3 -0
  89. package/src/cli.ts +359 -0
  90. package/src/codex-review.ts +229 -0
  91. package/src/config.ts +114 -0
  92. package/src/contracts.ts +204 -0
  93. package/src/coupling.ts +259 -0
  94. package/src/docker-executor.ts +115 -0
  95. package/src/eval-main.ts +92 -0
  96. package/src/evaluate.ts +298 -0
  97. package/src/github.ts +26 -0
  98. package/src/harbor-results.ts +142 -0
  99. package/src/harbor-task.ts +528 -0
  100. package/src/hash.ts +5 -0
  101. package/src/modal-auth.ts +11 -0
  102. package/src/modal-executor.ts +176 -0
  103. package/src/parallel.ts +24 -0
  104. package/src/process.ts +165 -0
  105. package/src/provenance.ts +457 -0
  106. package/src/reaudit-main.ts +195 -0
  107. package/src/repair-main.ts +206 -0
  108. package/src/repair.ts +55 -0
  109. package/src/run-wait.ts +40 -0
  110. package/src/sandbox-author.ts +19 -0
  111. package/src/sandbox-repair.ts +156 -0
  112. package/src/sandbox-review.ts +19 -0
  113. package/src/sandbox-validation-repair.ts +177 -0
  114. package/src/sandbox.ts +51 -0
  115. package/src/subscription-auth.ts +80 -0
  116. package/src/temporal/activities.ts +1241 -0
  117. package/src/temporal/connection.ts +23 -0
  118. package/src/temporal/worker-main.ts +28 -0
  119. package/src/temporal/workflow.ts +519 -0
  120. package/src/validate-main.ts +171 -0
  121. package/src/validation-repair.ts +94 -0
  122. package/tsconfig.build.json +13 -0
  123. package/tsconfig.json +21 -0
  124. package/dist/activities.d.ts.map +0 -1
  125. package/dist/activities.js.map +0 -1
  126. package/dist/extensions/review.d.ts +0 -3
  127. package/dist/extensions/review.d.ts.map +0 -1
  128. package/dist/extensions/review.js +0 -44
  129. package/dist/extensions/review.js.map +0 -1
  130. package/dist/review/assets/index-Dq-6DFzI.css +0 -1
  131. package/dist/review/assets/index-DurW_RD7.js +0 -1567
  132. package/dist/review/assets/typst-DHCkPAjA.js +0 -1
  133. package/dist/temporal.d.ts.map +0 -1
  134. package/dist/temporal.js.map +0 -1
  135. package/dist/worker-main.js.map +0 -1
  136. package/dist/workflow.d.ts.map +0 -1
  137. package/dist/workflow.js.map +0 -1
  138. package/src/extensions/review.ts +0 -54
  139. /package/dist/{worker-main.d.ts → temporal/worker-main.d.ts} +0 -0
  140. /package/dist/{workflow.js → temporal/workflow.js} +0 -0
package/src/api.ts ADDED
@@ -0,0 +1,277 @@
1
+ import { timingSafeEqual } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
4
+ import { dirname, join } from "node:path";
5
+ import { pipeline } from "node:stream/promises";
6
+ import { fileURLToPath } from "node:url";
7
+ import { Client } from "@temporalio/client";
8
+ import { z } from "zod";
9
+ import { createArtifactStore } from "./artifacts.js";
10
+ import type { SelfBenchConfig } from "./config.js";
11
+ import {
12
+ artifactRefSchema,
13
+ type RunPhase,
14
+ type RunStatus,
15
+ repositoryRefSchema,
16
+ runRequestSchema,
17
+ } from "./contracts.js";
18
+ import { connectTemporalClient } from "./temporal/connection.js";
19
+ import { selfBenchRunWorkflow, statusQuery } from "./temporal/workflow.js";
20
+
21
+ const submissionSchema = z.object({
22
+ runId: z.string().regex(/^[a-z0-9][a-z0-9-]{2,62}$/),
23
+ repository: repositoryRefSchema,
24
+ provenance: artifactRefSchema,
25
+ candidateCounts: z.object({
26
+ easy: z.number().int().min(0).max(100),
27
+ medium: z.number().int().min(0).max(100),
28
+ hard: z.number().int().min(0).max(100),
29
+ }),
30
+ authoringModel: z.string().min(1).default("gpt-5.6-sol"),
31
+ selfbenchCommit: z.string().regex(/^[0-9a-f]{40}$/i),
32
+ });
33
+
34
+ export async function startApi(config: SelfBenchConfig): Promise<() => Promise<void>> {
35
+ const connection = await connectTemporalClient(config.temporal);
36
+ const client = new Client({ connection, namespace: config.temporal.namespace });
37
+ const artifacts = createArtifactStore(config.artifact);
38
+ const server = createServer(async (request, response) => {
39
+ try {
40
+ const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
41
+ if (request.method === "GET" && url.pathname === "/healthz") {
42
+ sendJson(response, 200, { ok: true });
43
+ return;
44
+ }
45
+ if (
46
+ request.method === "GET" &&
47
+ (url.pathname === "/" || url.pathname.startsWith("/assets/"))
48
+ ) {
49
+ await sendReviewAsset(response, url.pathname);
50
+ return;
51
+ }
52
+ if (!authorized(request, config.apiToken)) {
53
+ sendJson(response, 401, { error: "unauthorized" });
54
+ return;
55
+ }
56
+ if (request.method === "POST" && url.pathname === "/v1/provenance") {
57
+ const runId = z
58
+ .string()
59
+ .regex(/^[a-z0-9][a-z0-9-]{2,62}$/)
60
+ .parse(url.searchParams.get("runId"));
61
+ const body = await readBody(request, 100 * 1024 * 1024);
62
+ const reference = await artifacts.put(
63
+ `runs/${runId}/input/provenance.jsonl`,
64
+ body,
65
+ "application/x-ndjson",
66
+ );
67
+ sendJson(response, 201, reference);
68
+ return;
69
+ }
70
+ if (request.method === "POST" && url.pathname === "/v1/runs") {
71
+ const submission = submissionSchema.parse(
72
+ JSON.parse((await readBody(request)).toString("utf8")),
73
+ );
74
+ const workflowInput = runRequestSchema.parse({
75
+ runId: submission.runId,
76
+ repository: submission.repository,
77
+ provenance: submission.provenance,
78
+ candidateCounts: submission.candidateCounts,
79
+ authoring: {
80
+ provider: "openai-codex",
81
+ model: submission.authoringModel,
82
+ reasoningEffort: "high",
83
+ },
84
+ version: {
85
+ selfbenchCommit: config.buildCommit ?? submission.selfbenchCommit,
86
+ executionBackend: config.execution.kind,
87
+ sandboxImage: config.execution.image,
88
+ schema: 1,
89
+ },
90
+ });
91
+ await client.workflow.start(selfBenchRunWorkflow, {
92
+ workflowId: workflowInput.runId,
93
+ taskQueue: config.temporal.taskQueue,
94
+ args: [workflowInput],
95
+ workflowExecutionTimeout: "14 days",
96
+ });
97
+ sendJson(response, 202, { runId: workflowInput.runId });
98
+ return;
99
+ }
100
+ const runMatch = /^\/v1\/runs\/([a-z0-9][a-z0-9-]{2,62})(?:\/(cancel|export))?$/.exec(
101
+ url.pathname,
102
+ );
103
+ if (runMatch?.[1] && request.method === "GET" && runMatch[2] === "export") {
104
+ const status = await queryStatus(client.workflow.getHandle(runMatch[1]));
105
+ if (!("export" in status) || !status.export) {
106
+ sendJson(response, 409, { error: "run export is not ready" });
107
+ return;
108
+ }
109
+ const body = await artifacts.openRead(status.export);
110
+ response.writeHead(200, {
111
+ "content-type": status.export.contentType,
112
+ "content-length": status.export.sizeBytes,
113
+ "content-disposition": `attachment; filename="selfbench-${runMatch[1]}.tar.gz"`,
114
+ "x-content-sha256": status.export.sha256,
115
+ });
116
+ await pipeline(body, response);
117
+ return;
118
+ }
119
+ if (runMatch?.[1] && request.method === "GET" && !runMatch[2]) {
120
+ const handle = client.workflow.getHandle(runMatch[1]);
121
+ const status = await queryStatus(handle);
122
+ sendJson(response, 200, status);
123
+ return;
124
+ }
125
+ if (runMatch?.[1] && request.method === "POST" && runMatch[2] === "cancel") {
126
+ await client.workflow.getHandle(runMatch[1]).cancel();
127
+ sendJson(response, 202, { runId: runMatch[1], cancellationRequested: true });
128
+ return;
129
+ }
130
+ if (request.method === "GET" && url.pathname === "/v1/runs") {
131
+ const runs: unknown[] = [];
132
+ for await (const execution of client.workflow.list({
133
+ query: "WorkflowType = 'selfBenchRunWorkflow'",
134
+ })) {
135
+ runs.push({
136
+ runId: execution.workflowId,
137
+ status: execution.status.name,
138
+ startedAt: execution.startTime.toISOString(),
139
+ closedAt: execution.closeTime?.toISOString(),
140
+ });
141
+ }
142
+ sendJson(response, 200, runs);
143
+ return;
144
+ }
145
+ sendJson(response, 404, { error: "not found" });
146
+ } catch (error) {
147
+ if (response.headersSent) {
148
+ response.destroy(error instanceof Error ? error : new Error(String(error)));
149
+ return;
150
+ }
151
+ const message = error instanceof Error ? error.message : String(error);
152
+ sendJson(response, error instanceof z.ZodError || error instanceof SyntaxError ? 400 : 500, {
153
+ error: message,
154
+ });
155
+ }
156
+ });
157
+
158
+ await new Promise<void>((resolve, reject) => {
159
+ server.once("error", reject);
160
+ server.listen(config.apiPort, config.apiHost, resolve);
161
+ });
162
+ return async () => {
163
+ await new Promise<void>((resolve, reject) =>
164
+ server.close((error) => (error ? reject(error) : resolve())),
165
+ );
166
+ await connection.close();
167
+ };
168
+ }
169
+
170
+ async function queryStatus(
171
+ handle: ReturnType<Client["workflow"]["getHandle"]>,
172
+ ): Promise<RunStatus | object> {
173
+ try {
174
+ const [status, description] = await Promise.all([handle.query(statusQuery), handle.describe()]);
175
+ if (description.status.name === "RUNNING" || terminalRunPhase(status.phase)) {
176
+ return status;
177
+ }
178
+ const phase = executionPhase(description.status.name);
179
+ return {
180
+ ...status,
181
+ phase,
182
+ ...(phase === "failed" && !status.error
183
+ ? { error: `Temporal workflow ${description.status.name.toLowerCase()}` }
184
+ : {}),
185
+ };
186
+ } catch {
187
+ const description = await handle.describe();
188
+ return {
189
+ runId: description.workflowId,
190
+ phase: executionPhase(description.status.name),
191
+ };
192
+ }
193
+ }
194
+
195
+ function terminalRunPhase(phase: RunPhase): boolean {
196
+ return ["complete", "blocked", "failed", "cancelled"].includes(phase);
197
+ }
198
+
199
+ function executionPhase(status: string): RunPhase {
200
+ switch (status) {
201
+ case "COMPLETED":
202
+ return "complete";
203
+ case "CANCELED":
204
+ return "cancelled";
205
+ case "RUNNING":
206
+ return "queued";
207
+ default:
208
+ return "failed";
209
+ }
210
+ }
211
+
212
+ async function readBody(request: IncomingMessage, limit = 10 * 1024 * 1024): Promise<Buffer> {
213
+ const chunks: Buffer[] = [];
214
+ let size = 0;
215
+ for await (const chunk of request) {
216
+ const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
217
+ size += value.byteLength;
218
+ if (size > limit) {
219
+ throw new Error(`request body exceeds ${limit} bytes`);
220
+ }
221
+ chunks.push(value);
222
+ }
223
+ return Buffer.concat(chunks);
224
+ }
225
+
226
+ function authorized(request: IncomingMessage, token: string | undefined): boolean {
227
+ if (!token) {
228
+ return true;
229
+ }
230
+ const supplied = request.headers.authorization?.replace(/^Bearer\s+/i, "");
231
+ if (!supplied) {
232
+ return false;
233
+ }
234
+ const expectedBuffer = Buffer.from(token);
235
+ const suppliedBuffer = Buffer.from(supplied);
236
+ return (
237
+ expectedBuffer.length === suppliedBuffer.length &&
238
+ timingSafeEqual(expectedBuffer, suppliedBuffer)
239
+ );
240
+ }
241
+
242
+ async function sendReviewAsset(response: ServerResponse, pathname: string): Promise<void> {
243
+ const relativePath = pathname === "/" ? "index.html" : pathname.slice(1);
244
+ const root = join(dirname(fileURLToPath(import.meta.url)), "review");
245
+ const path = join(root, relativePath);
246
+ if (!path.startsWith(`${root}/`)) {
247
+ sendJson(response, 400, { error: "invalid asset path" });
248
+ return;
249
+ }
250
+ try {
251
+ const body = await readFile(path);
252
+ response.writeHead(200, {
253
+ "content-type": contentType(path),
254
+ "content-length": body.byteLength,
255
+ "cache-control": pathname === "/" ? "no-cache" : "public, max-age=31536000, immutable",
256
+ });
257
+ response.end(body);
258
+ } catch {
259
+ sendJson(response, 404, { error: "asset not found" });
260
+ }
261
+ }
262
+
263
+ function contentType(path: string): string {
264
+ if (path.endsWith(".html")) return "text/html; charset=utf-8";
265
+ if (path.endsWith(".css")) return "text/css; charset=utf-8";
266
+ if (path.endsWith(".js")) return "text/javascript; charset=utf-8";
267
+ return "application/octet-stream";
268
+ }
269
+
270
+ function sendJson(response: ServerResponse, status: number, value: unknown): void {
271
+ const body = `${JSON.stringify(value, null, 2)}\n`;
272
+ response.writeHead(status, {
273
+ "content-type": "application/json; charset=utf-8",
274
+ "content-length": Buffer.byteLength(body),
275
+ });
276
+ response.end(body);
277
+ }
@@ -0,0 +1,357 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { createReadStream, createWriteStream } from "node:fs";
3
+ import { link, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { dirname, join, resolve, sep } from "node:path";
6
+ import { Readable, Transform } from "node:stream";
7
+ import { pipeline } from "node:stream/promises";
8
+ import { Storage } from "@google-cloud/storage";
9
+ import type { SelfBenchConfig } from "./config.js";
10
+ import type { ArtifactRef } from "./contracts.js";
11
+ import { sha256 } from "./hash.js";
12
+
13
+ export interface ArtifactStore {
14
+ put(key: string, value: Uint8Array, contentType: string): Promise<ArtifactRef>;
15
+ putFile(key: string, sourcePath: string, contentType: string): Promise<ArtifactRef>;
16
+ get(reference: ArtifactRef): Promise<Uint8Array>;
17
+ openRead(reference: ArtifactRef): Promise<Readable>;
18
+ getByKey(key: string): Promise<Uint8Array | undefined>;
19
+ }
20
+
21
+ export function createArtifactStore(config: SelfBenchConfig["artifact"]): ArtifactStore {
22
+ return config.kind === "gcs"
23
+ ? new GcsArtifactStore(config.bucket, config.prefix)
24
+ : new LocalArtifactStore(config.directory);
25
+ }
26
+
27
+ export class LocalArtifactStore implements ArtifactStore {
28
+ readonly #root: string;
29
+
30
+ constructor(root: string) {
31
+ this.#root = resolve(root);
32
+ }
33
+
34
+ async put(key: string, value: Uint8Array, contentType: string): Promise<ArtifactRef> {
35
+ const path = this.#pathFor(key);
36
+ await mkdir(dirname(path), { recursive: true });
37
+ const digest = sha256(value);
38
+ try {
39
+ await writeFile(path, value, { flag: "wx" });
40
+ } catch (error) {
41
+ const existing = await fileDigest(path).catch(() => undefined);
42
+ if (!existing || existing.sha256 !== digest || existing.sizeBytes !== value.byteLength) {
43
+ throw error;
44
+ }
45
+ }
46
+ return {
47
+ uri: `file://${path}`,
48
+ sha256: digest,
49
+ sizeBytes: value.byteLength,
50
+ contentType,
51
+ };
52
+ }
53
+
54
+ async putFile(key: string, sourcePath: string, contentType: string): Promise<ArtifactRef> {
55
+ const path = this.#pathFor(key);
56
+ await mkdir(dirname(path), { recursive: true });
57
+ const temporaryPath = `${path}.${randomUUID()}.tmp`;
58
+ try {
59
+ const digest = await copyWithDigest(sourcePath, temporaryPath);
60
+ try {
61
+ await link(temporaryPath, path);
62
+ } catch (error) {
63
+ if (!hasCode(error, "EEXIST")) {
64
+ throw error;
65
+ }
66
+ const existing = await fileDigest(path).catch(() => undefined);
67
+ if (
68
+ !existing ||
69
+ existing.sha256 !== digest.sha256 ||
70
+ existing.sizeBytes !== digest.sizeBytes
71
+ ) {
72
+ throw new Error(`artifact already exists with different contents: file://${path}`);
73
+ }
74
+ }
75
+ return {
76
+ uri: `file://${path}`,
77
+ ...digest,
78
+ contentType,
79
+ };
80
+ } finally {
81
+ await rm(temporaryPath, { force: true });
82
+ }
83
+ }
84
+
85
+ async get(reference: ArtifactRef): Promise<Uint8Array> {
86
+ const url = new URL(reference.uri);
87
+ if (url.protocol !== "file:") {
88
+ throw new Error(`local artifact store cannot read ${reference.uri}`);
89
+ }
90
+ const path = resolve(decodeURIComponent(url.pathname));
91
+ this.#assertInsideRoot(path);
92
+ const value = await readFile(path);
93
+ verifyArtifact(reference, value);
94
+ return value;
95
+ }
96
+
97
+ async openRead(reference: ArtifactRef): Promise<Readable> {
98
+ const path = this.#pathForReference(reference);
99
+ if ((await stat(path)).size !== reference.sizeBytes) {
100
+ throw new Error(`artifact integrity check failed: ${reference.uri}`);
101
+ }
102
+ return verifiedArtifactReadStream(reference, createReadStream(path));
103
+ }
104
+
105
+ async getByKey(key: string): Promise<Uint8Array | undefined> {
106
+ try {
107
+ return await readFile(this.#pathFor(key));
108
+ } catch (error) {
109
+ if (isNotFound(error)) {
110
+ return undefined;
111
+ }
112
+ throw error;
113
+ }
114
+ }
115
+
116
+ #pathForReference(reference: ArtifactRef): string {
117
+ const url = new URL(reference.uri);
118
+ if (url.protocol !== "file:") {
119
+ throw new Error(`local artifact store cannot read ${reference.uri}`);
120
+ }
121
+ const path = resolve(decodeURIComponent(url.pathname));
122
+ this.#assertInsideRoot(path);
123
+ return path;
124
+ }
125
+
126
+ #pathFor(key: string): string {
127
+ if (!key || key.startsWith("/") || key.split("/").some((part) => part === "..")) {
128
+ throw new Error(`unsafe artifact key: ${key}`);
129
+ }
130
+ const path = resolve(this.#root, key);
131
+ this.#assertInsideRoot(path);
132
+ return path;
133
+ }
134
+
135
+ #assertInsideRoot(path: string): void {
136
+ if (path !== this.#root && !path.startsWith(`${this.#root}${sep}`)) {
137
+ throw new Error(`artifact path escapes root: ${path}`);
138
+ }
139
+ }
140
+ }
141
+
142
+ export class GcsArtifactStore implements ArtifactStore {
143
+ readonly #storage = new Storage();
144
+ readonly #bucket: string;
145
+ readonly #prefix: string;
146
+
147
+ constructor(bucket: string, prefix: string) {
148
+ this.#bucket = bucket;
149
+ this.#prefix = prefix.replace(/^\/+|\/+$/g, "");
150
+ }
151
+
152
+ async put(key: string, value: Uint8Array, contentType: string): Promise<ArtifactRef> {
153
+ const object = this.#objectFor(key);
154
+ const digest = sha256(value);
155
+ const file = this.#storage.bucket(this.#bucket).file(object);
156
+ try {
157
+ await file.save(value, {
158
+ resumable: false,
159
+ preconditionOpts: { ifGenerationMatch: 0 },
160
+ metadata: {
161
+ contentType,
162
+ metadata: { sha256: digest },
163
+ },
164
+ });
165
+ } catch (error) {
166
+ const [exists] = await file.exists();
167
+ if (!exists) {
168
+ throw error;
169
+ }
170
+ const [metadata] = await file.getMetadata();
171
+ if (metadata.metadata?.sha256 !== digest) {
172
+ throw new Error(
173
+ `artifact already exists with different contents: gs://${this.#bucket}/${object}`,
174
+ );
175
+ }
176
+ }
177
+ return {
178
+ uri: `gs://${this.#bucket}/${object}`,
179
+ sha256: digest,
180
+ sizeBytes: value.byteLength,
181
+ contentType,
182
+ };
183
+ }
184
+
185
+ async putFile(key: string, sourcePath: string, contentType: string): Promise<ArtifactRef> {
186
+ const object = this.#objectFor(key);
187
+ const bucket = this.#storage.bucket(this.#bucket);
188
+ const file = bucket.file(object);
189
+ const snapshotDirectory = await mkdtemp(join(tmpdir(), "selfbench-artifact-"));
190
+ const snapshotPath = join(snapshotDirectory, "upload");
191
+ try {
192
+ const digest = await copyWithDigest(sourcePath, snapshotPath);
193
+ try {
194
+ await bucket.upload(snapshotPath, {
195
+ destination: object,
196
+ resumable: true,
197
+ preconditionOpts: { ifGenerationMatch: 0 },
198
+ metadata: {
199
+ contentType,
200
+ metadata: { sha256: digest.sha256 },
201
+ },
202
+ });
203
+ } catch (error) {
204
+ const [exists] = await file.exists();
205
+ if (!exists) {
206
+ throw error;
207
+ }
208
+ const [metadata] = await file.getMetadata();
209
+ if (
210
+ metadata.metadata?.sha256 !== digest.sha256 ||
211
+ Number(metadata.size) !== digest.sizeBytes
212
+ ) {
213
+ throw new Error(
214
+ `artifact already exists with different contents: gs://${this.#bucket}/${object}`,
215
+ );
216
+ }
217
+ }
218
+ return {
219
+ uri: `gs://${this.#bucket}/${object}`,
220
+ ...digest,
221
+ contentType,
222
+ };
223
+ } finally {
224
+ await rm(snapshotDirectory, { recursive: true, force: true });
225
+ }
226
+ }
227
+
228
+ async get(reference: ArtifactRef): Promise<Uint8Array> {
229
+ const match = /^gs:\/\/([^/]+)\/(.+)$/.exec(reference.uri);
230
+ if (!match) {
231
+ throw new Error(`GCS artifact store cannot read ${reference.uri}`);
232
+ }
233
+ const [, bucket, object] = match;
234
+ if (bucket !== this.#bucket || !object) {
235
+ throw new Error(`artifact is outside configured bucket: ${reference.uri}`);
236
+ }
237
+ if (this.#prefix && object !== this.#prefix && !object.startsWith(`${this.#prefix}/`)) {
238
+ throw new Error(`artifact is outside configured bucket: ${reference.uri}`);
239
+ }
240
+ const [value] = await this.#storage.bucket(bucket).file(object).download();
241
+ verifyArtifact(reference, value);
242
+ return value;
243
+ }
244
+
245
+ async openRead(reference: ArtifactRef): Promise<Readable> {
246
+ const file = this.#fileForReference(reference);
247
+ const [metadata] = await file.getMetadata();
248
+ if (
249
+ Number(metadata.size) !== reference.sizeBytes ||
250
+ metadata.metadata?.sha256 !== reference.sha256
251
+ ) {
252
+ throw new Error(`artifact integrity check failed: ${reference.uri}`);
253
+ }
254
+ return verifiedArtifactReadStream(reference, file.createReadStream());
255
+ }
256
+
257
+ async getByKey(key: string): Promise<Uint8Array | undefined> {
258
+ const file = this.#storage.bucket(this.#bucket).file(this.#objectFor(key));
259
+ const [exists] = await file.exists();
260
+ if (!exists) {
261
+ return undefined;
262
+ }
263
+ const [value] = await file.download();
264
+ return value;
265
+ }
266
+
267
+ #fileForReference(reference: ArtifactRef) {
268
+ const match = /^gs:\/\/([^/]+)\/(.+)$/.exec(reference.uri);
269
+ if (!match) {
270
+ throw new Error(`GCS artifact store cannot read ${reference.uri}`);
271
+ }
272
+ const [, bucket, object] = match;
273
+ if (bucket !== this.#bucket || !object) {
274
+ throw new Error(`artifact is outside configured bucket: ${reference.uri}`);
275
+ }
276
+ if (this.#prefix && object !== this.#prefix && !object.startsWith(`${this.#prefix}/`)) {
277
+ throw new Error(`artifact is outside configured bucket: ${reference.uri}`);
278
+ }
279
+ return this.#storage.bucket(bucket).file(object);
280
+ }
281
+
282
+ #objectFor(key: string): string {
283
+ if (!key || key.startsWith("/") || key.split("/").some((part) => part === "..")) {
284
+ throw new Error(`unsafe artifact key: ${key}`);
285
+ }
286
+ return this.#prefix ? `${this.#prefix}/${key}` : key;
287
+ }
288
+ }
289
+
290
+ export function verifiedArtifactReadStream(reference: ArtifactRef, input: Readable): Readable {
291
+ return Readable.from(
292
+ (async function* () {
293
+ const hash = createHash("sha256");
294
+ let sizeBytes = 0;
295
+ for await (const chunk of input) {
296
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
297
+ hash.update(bytes);
298
+ sizeBytes += bytes.byteLength;
299
+ yield bytes;
300
+ }
301
+ if (sizeBytes !== reference.sizeBytes || hash.digest("hex") !== reference.sha256) {
302
+ throw new Error(`artifact integrity check failed: ${reference.uri}`);
303
+ }
304
+ })(),
305
+ );
306
+ }
307
+
308
+ async function copyWithDigest(
309
+ sourcePath: string,
310
+ destinationPath: string,
311
+ ): Promise<{ sha256: string; sizeBytes: number }> {
312
+ const hash = createHash("sha256");
313
+ let sizeBytes = 0;
314
+ const hasher = new Transform({
315
+ transform(chunk: Buffer, _encoding, callback) {
316
+ hash.update(chunk);
317
+ sizeBytes += chunk.byteLength;
318
+ callback(undefined, chunk);
319
+ },
320
+ });
321
+ await pipeline(
322
+ createReadStream(sourcePath),
323
+ hasher,
324
+ createWriteStream(destinationPath, { flags: "wx" }),
325
+ );
326
+ return { sha256: hash.digest("hex"), sizeBytes };
327
+ }
328
+
329
+ async function fileDigest(path: string): Promise<{ sha256: string; sizeBytes: number }> {
330
+ const hash = createHash("sha256");
331
+ let sizeBytes = 0;
332
+ for await (const chunk of createReadStream(path)) {
333
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
334
+ hash.update(bytes);
335
+ sizeBytes += bytes.byteLength;
336
+ }
337
+ return { sha256: hash.digest("hex"), sizeBytes };
338
+ }
339
+
340
+ function hasCode(error: unknown, code: string): boolean {
341
+ return (
342
+ typeof error === "object" &&
343
+ error !== null &&
344
+ "code" in error &&
345
+ (error as { code?: unknown }).code === code
346
+ );
347
+ }
348
+
349
+ function isNotFound(error: unknown): boolean {
350
+ return hasCode(error, "ENOENT");
351
+ }
352
+
353
+ function verifyArtifact(reference: ArtifactRef, value: Uint8Array): void {
354
+ if (value.byteLength !== reference.sizeBytes || sha256(value) !== reference.sha256) {
355
+ throw new Error(`artifact integrity check failed: ${reference.uri}`);
356
+ }
357
+ }