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/audit.ts ADDED
@@ -0,0 +1,106 @@
1
+ import type { Difficulty, TaskDefinition } from "./contracts.js";
2
+
3
+ export interface StaticAuditReport {
4
+ readonly accepted: boolean;
5
+ readonly blockers: readonly string[];
6
+ readonly metrics: {
7
+ readonly implementationFiles: number;
8
+ readonly implementationChangedLines: number;
9
+ readonly testFiles: number;
10
+ };
11
+ }
12
+
13
+ const thresholds: Record<
14
+ Difficulty,
15
+ {
16
+ readonly implementationFiles: number;
17
+ readonly changedLines: number;
18
+ readonly passToPass: number;
19
+ }
20
+ > = {
21
+ easy: { implementationFiles: 1, changedLines: 20, passToPass: 0 },
22
+ medium: { implementationFiles: 2, changedLines: 50, passToPass: 1 },
23
+ hard: { implementationFiles: 3, changedLines: 100, passToPass: 2 },
24
+ };
25
+
26
+ export function auditTaskDefinition(
27
+ definition: TaskDefinition,
28
+ goldPatch: string,
29
+ testPatch: string,
30
+ ): StaticAuditReport {
31
+ const gold = patchMetrics(goldPatch);
32
+ const tests = patchMetrics(testPatch);
33
+ const threshold = thresholds[definition.difficulty];
34
+ const testPathSet = new Set(tests.files);
35
+ const blockers: string[] = [];
36
+ const overlap = gold.files.filter((path) => testPathSet.has(path));
37
+ if (overlap.length > 0) {
38
+ blockers.push(`gold and held-out test patches overlap: ${overlap.join(", ")}`);
39
+ }
40
+ if (gold.files.length < threshold.implementationFiles) {
41
+ blockers.push(
42
+ `${definition.difficulty} mode requires at least ${threshold.implementationFiles} implementation files; found ${gold.files.length}`,
43
+ );
44
+ }
45
+ if (gold.changedLines < threshold.changedLines) {
46
+ blockers.push(
47
+ `${definition.difficulty} mode requires at least ${threshold.changedLines} changed implementation lines; found ${gold.changedLines}`,
48
+ );
49
+ }
50
+ if (tests.files.length === 0) {
51
+ blockers.push("held-out test patch changes no files");
52
+ }
53
+ if (definition.passToPass.length < threshold.passToPass) {
54
+ blockers.push(
55
+ `${definition.difficulty} mode requires at least ${threshold.passToPass} pass-to-pass regression tests`,
56
+ );
57
+ }
58
+ if (
59
+ definition.failToPass.some((path) => testCommandHardcodesPath(definition.testCommand, path)) ||
60
+ definition.passToPass.some((path) => testCommandHardcodesPath(definition.testCommand, path))
61
+ ) {
62
+ blockers.push(
63
+ 'test command must not hard-code fail-to-pass or pass-to-pass paths outside "{tests}"',
64
+ );
65
+ }
66
+ if (definition.testCommand.split("{tests}").length !== 2) {
67
+ blockers.push('test command must contain "{tests}" exactly once');
68
+ }
69
+ if (/(["'])\{tests\}\1/.test(definition.testCommand)) {
70
+ blockers.push('test command must expand "{tests}" as an unquoted shell argument list');
71
+ }
72
+ return {
73
+ accepted: blockers.length === 0,
74
+ blockers,
75
+ metrics: {
76
+ implementationFiles: gold.files.length,
77
+ implementationChangedLines: gold.changedLines,
78
+ testFiles: tests.files.length,
79
+ },
80
+ };
81
+ }
82
+
83
+ function testCommandHardcodesPath(command: string, path: string): boolean {
84
+ return command.replace("{tests}", "").includes(path);
85
+ }
86
+
87
+ function patchMetrics(patch: string): { files: string[]; changedLines: number } {
88
+ const files: string[] = [];
89
+ let changedLines = 0;
90
+ for (const line of patch.split("\n")) {
91
+ if (line.startsWith("diff --git a/")) {
92
+ const match = /^diff --git a\/(.+) b\/(.+)$/.exec(line);
93
+ if (match?.[2]) {
94
+ files.push(match[2]);
95
+ }
96
+ continue;
97
+ }
98
+ if (
99
+ (line.startsWith("+") && !line.startsWith("+++")) ||
100
+ (line.startsWith("-") && !line.startsWith("---"))
101
+ ) {
102
+ changedLines += 1;
103
+ }
104
+ }
105
+ return { files, changedLines };
106
+ }
@@ -0,0 +1,3 @@
1
+ const unsetBuildCommit = "0".repeat(40);
2
+
3
+ export const buildCommit = process.env.SELFBENCH_BUILD_COMMIT ?? unsetBuildCommit;
package/src/cli.ts ADDED
@@ -0,0 +1,359 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createHash } from "node:crypto";
4
+ import { open, rm } from "node:fs/promises";
5
+ import { homedir } from "node:os";
6
+ import { dirname, resolve } from "node:path";
7
+ import { Readable, Transform } from "node:stream";
8
+ import { pipeline } from "node:stream/promises";
9
+ import type { ReadableStream as NodeReadableStream } from "node:stream/web";
10
+ import { fileURLToPath } from "node:url";
11
+ import { parseArgs } from "node:util";
12
+ import { buildCommit } from "./build-metadata.js";
13
+ import { runCommand } from "./process.js";
14
+ import { collectGitHubPullRequestProvenance, collectRepositoryProvenance } from "./provenance.js";
15
+ import { type PolledRunStatus, waitForRun } from "./run-wait.js";
16
+
17
+ const [command, ...rest] = process.argv.slice(2);
18
+ switch (command) {
19
+ case "up":
20
+ await up(rest);
21
+ break;
22
+ case "run":
23
+ await run(rest);
24
+ break;
25
+ case "down":
26
+ await down();
27
+ break;
28
+ case "status":
29
+ await passthrough("GET", `/v1/runs/${requiredArgument(rest, "run ID")}`);
30
+ break;
31
+ case "cancel":
32
+ await passthrough("POST", `/v1/runs/${requiredArgument(rest, "run ID")}/cancel`);
33
+ break;
34
+ case "list":
35
+ await passthrough("GET", "/v1/runs");
36
+ break;
37
+ case "download":
38
+ await download(requiredArgument(rest, "run ID"), rest[1] ?? fail("output path is required"));
39
+ break;
40
+ case "help":
41
+ case "--help":
42
+ case "-h":
43
+ case undefined:
44
+ printHelp();
45
+ break;
46
+ default:
47
+ throw new Error(`unknown command: ${command}`);
48
+ }
49
+
50
+ async function up(args: string[]): Promise<void> {
51
+ const parsed = parseArgs({
52
+ args,
53
+ options: {
54
+ backend: { type: "string", default: "docker" },
55
+ "modal-config": { type: "string" },
56
+ },
57
+ strict: true,
58
+ });
59
+ if (parsed.values.backend !== "docker" && parsed.values.backend !== "modal") {
60
+ fail('--backend must be "docker" or "modal"');
61
+ }
62
+ if (parsed.values.backend === "docker" && parsed.values["modal-config"] !== undefined) {
63
+ fail("--modal-config requires --backend modal");
64
+ }
65
+
66
+ const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
67
+ const composeFile = resolve(projectRoot, "compose.yaml");
68
+ const backend = parsed.values.backend;
69
+ const environment = {
70
+ ...process.env,
71
+ SELFBENCH_BUILD_COMMIT: await resolveSelfBenchCommit(),
72
+ SELFBENCH_EXECUTION_BACKEND: backend,
73
+ SELFBENCH_HARBOR_ENVIRONMENT: backend,
74
+ ...(backend === "modal"
75
+ ? {
76
+ SELFBENCH_MODAL_CONFIG_PATH: resolve(
77
+ parsed.values["modal-config"] ?? resolve(homedir(), ".modal.toml"),
78
+ ),
79
+ }
80
+ : {}),
81
+ };
82
+
83
+ if (backend === "docker") {
84
+ await runCommand(
85
+ "docker",
86
+ [
87
+ "build",
88
+ "-f",
89
+ resolve(projectRoot, "Dockerfile.sandbox"),
90
+ "-t",
91
+ "selfbench-sandbox:local",
92
+ projectRoot,
93
+ ],
94
+ { env: environment },
95
+ );
96
+ }
97
+ await runCommand("docker", ["compose", "--file", composeFile, "up", "-d", "--build"], {
98
+ env: environment,
99
+ });
100
+ console.log(`SelfBench is running with the ${backend} backend at http://127.0.0.1:8080`);
101
+ }
102
+
103
+ async function down(): Promise<void> {
104
+ const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
105
+ await runCommand("docker", ["compose", "--file", resolve(projectRoot, "compose.yaml"), "down"]);
106
+ }
107
+
108
+ async function run(args: string[]): Promise<void> {
109
+ const parsed = parseArgs({
110
+ args,
111
+ options: {
112
+ repo: { type: "string", short: "r" },
113
+ "easy-count": { type: "string" },
114
+ "medium-count": { type: "string" },
115
+ "hard-count": { type: "string" },
116
+ "run-id": { type: "string" },
117
+ model: { type: "string", default: "gpt-5.6-sol" },
118
+ wait: { type: "boolean", default: false },
119
+ output: { type: "string", short: "o" },
120
+ },
121
+ strict: true,
122
+ });
123
+ const repositoryPath = resolve(parsed.values.repo ?? fail("--repo is required"));
124
+ const candidateCounts = {
125
+ easy: nonnegativeInteger(parsed.values["easy-count"] ?? "0", "--easy-count"),
126
+ medium: nonnegativeInteger(parsed.values["medium-count"] ?? "0", "--medium-count"),
127
+ hard: nonnegativeInteger(parsed.values["hard-count"] ?? "0", "--hard-count"),
128
+ };
129
+ const totalCandidates = candidateCounts.easy + candidateCounts.medium + candidateCounts.hard;
130
+ if (totalCandidates < 1 || totalCandidates > 100) {
131
+ fail("the total candidate count must be between 1 and 100");
132
+ }
133
+ const runId = parsed.values["run-id"] ?? defaultRunId();
134
+ const [repository, localMessages, selfbenchCommit] = await Promise.all([
135
+ resolveRepository(repositoryPath),
136
+ collectRepositoryProvenance(repositoryPath, process.env.HOME ?? homedir()),
137
+ resolveSelfBenchCommit(),
138
+ ]);
139
+ const githubMessages = await collectGitHubPullRequestProvenance(repository.url);
140
+ const messages = [...localMessages, ...githubMessages];
141
+ if (messages.length === 0) {
142
+ throw new Error("no sanitized local-session or GitHub pull-request provenance was found");
143
+ }
144
+ const corpus = Buffer.from(`${messages.map((message) => JSON.stringify(message)).join("\n")}\n`);
145
+ const provenance = await requestJson(`/v1/provenance?runId=${encodeURIComponent(runId)}`, {
146
+ method: "POST",
147
+ body: corpus,
148
+ contentType: "application/x-ndjson",
149
+ });
150
+ const response = await requestJson("/v1/runs", {
151
+ method: "POST",
152
+ body: Buffer.from(
153
+ JSON.stringify({
154
+ runId,
155
+ repository,
156
+ provenance,
157
+ candidateCounts,
158
+ authoringModel: parsed.values.model,
159
+ selfbenchCommit,
160
+ }),
161
+ ),
162
+ contentType: "application/json",
163
+ });
164
+ console.log(
165
+ JSON.stringify(
166
+ {
167
+ ...response,
168
+ provenanceMessages: messages.length,
169
+ localProvenanceMessages: localMessages.length,
170
+ githubPullRequestMessages: githubMessages.length,
171
+ },
172
+ null,
173
+ 2,
174
+ ),
175
+ );
176
+ if (!parsed.values.wait && parsed.values.output === undefined) {
177
+ return;
178
+ }
179
+ const status = await waitForRun({
180
+ poll: async () =>
181
+ asPolledRunStatus(
182
+ await requestJson(`/v1/runs/${encodeURIComponent(runId)}`, { method: "GET" }),
183
+ ),
184
+ onPhase: (current) => {
185
+ console.error(
186
+ JSON.stringify({
187
+ runId,
188
+ phase: current.phase,
189
+ accepted: current.accepted,
190
+ rejected: current.rejected,
191
+ }),
192
+ );
193
+ },
194
+ });
195
+ if (parsed.values.output !== undefined) {
196
+ await download(runId, parsed.values.output);
197
+ } else {
198
+ console.log(JSON.stringify(status, null, 2));
199
+ }
200
+ }
201
+
202
+ async function resolveRepository(path: string): Promise<{ url: string; commit: string }> {
203
+ const [remote, commit] = await Promise.all([
204
+ runCommand("git", ["-C", path, "remote", "get-url", "origin"]),
205
+ runCommand("git", ["-C", path, "rev-parse", "HEAD"]),
206
+ ]);
207
+ return { url: normalizeGitUrl(remote.stdout.trim()), commit: commit.stdout.trim() };
208
+ }
209
+
210
+ function normalizeGitUrl(value: string): string {
211
+ const ssh = /^git@github\.com:(.+)$/.exec(value);
212
+ if (ssh?.[1]) {
213
+ return `https://github.com/${ssh[1]}`;
214
+ }
215
+ if (value.startsWith("https://")) {
216
+ return value;
217
+ }
218
+ throw new Error(`unsupported origin URL: ${value}`);
219
+ }
220
+
221
+ async function resolveSelfBenchCommit(): Promise<string> {
222
+ if (process.env.SELFBENCH_BUILD_COMMIT) {
223
+ return process.env.SELFBENCH_BUILD_COMMIT;
224
+ }
225
+ if (/^[0-9a-f]{40}$/i.test(buildCommit) && !/^0+$/.test(buildCommit)) {
226
+ return buildCommit;
227
+ }
228
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
229
+ try {
230
+ return (await runCommand("git", ["-C", root, "rev-parse", "HEAD"])).stdout.trim();
231
+ } catch {
232
+ throw new Error(
233
+ "SelfBench was built without commit metadata; set SELFBENCH_BUILD_COMMIT to a full commit SHA",
234
+ );
235
+ }
236
+ }
237
+
238
+ async function passthrough(method: "GET" | "POST", path: string): Promise<void> {
239
+ console.log(JSON.stringify(await requestJson(path, { method }), null, 2));
240
+ }
241
+
242
+ async function download(runId: string, outputPath: string): Promise<void> {
243
+ const base = process.env.SELFBENCH_API_URL ?? "http://127.0.0.1:8080";
244
+ const headers = new Headers();
245
+ if (process.env.SELFBENCH_API_TOKEN) {
246
+ headers.set("authorization", `Bearer ${process.env.SELFBENCH_API_TOKEN}`);
247
+ }
248
+ const response = await fetch(new URL(`/v1/runs/${encodeURIComponent(runId)}/export`, base), {
249
+ headers,
250
+ });
251
+ if (!response.ok) {
252
+ const value = (await response.json()) as Record<string, unknown>;
253
+ throw new Error(String(value.error ?? `SelfBench API returned ${response.status}`));
254
+ }
255
+ const expectedSha256 = response.headers.get("x-content-sha256");
256
+ if (!expectedSha256 || !response.body) {
257
+ throw new Error("SelfBench API returned an export without integrity metadata");
258
+ }
259
+ const destination = resolve(outputPath);
260
+ const file = await open(destination, "wx");
261
+ let verified = false;
262
+ try {
263
+ const hash = createHash("sha256");
264
+ const hasher = new Transform({
265
+ transform(chunk: Buffer, _encoding, callback) {
266
+ hash.update(chunk);
267
+ callback(undefined, chunk);
268
+ },
269
+ });
270
+ await pipeline(
271
+ Readable.fromWeb(response.body as unknown as NodeReadableStream),
272
+ hasher,
273
+ file.createWriteStream(),
274
+ );
275
+ if (hash.digest("hex") !== expectedSha256) {
276
+ throw new Error("downloaded export failed its SHA-256 integrity check");
277
+ }
278
+ verified = true;
279
+ } finally {
280
+ await file.close().catch(() => undefined);
281
+ if (!verified) {
282
+ await rm(destination, { force: true });
283
+ }
284
+ }
285
+ console.log(JSON.stringify({ runId, output: destination }, null, 2));
286
+ }
287
+
288
+ async function requestJson(
289
+ path: string,
290
+ options: { method: "GET" | "POST"; body?: Uint8Array; contentType?: string },
291
+ ): Promise<Record<string, unknown>> {
292
+ const base = process.env.SELFBENCH_API_URL ?? "http://127.0.0.1:8080";
293
+ const headers = new Headers();
294
+ if (options.contentType) {
295
+ headers.set("content-type", options.contentType);
296
+ }
297
+ if (process.env.SELFBENCH_API_TOKEN) {
298
+ headers.set("authorization", `Bearer ${process.env.SELFBENCH_API_TOKEN}`);
299
+ }
300
+ const response = await fetch(new URL(path, base), {
301
+ method: options.method,
302
+ headers,
303
+ ...(options.body ? { body: Buffer.from(options.body) } : {}),
304
+ });
305
+ const value = (await response.json()) as Record<string, unknown>;
306
+ if (!response.ok) {
307
+ throw new Error(String(value.error ?? `SelfBench API returned ${response.status}`));
308
+ }
309
+ return value;
310
+ }
311
+
312
+ function requiredArgument(args: string[], label: string): string {
313
+ return args[0] ?? fail(`${label} is required`);
314
+ }
315
+
316
+ function asPolledRunStatus(value: Record<string, unknown>): PolledRunStatus {
317
+ if (typeof value.phase !== "string") {
318
+ throw new Error("SelfBench status response is missing its phase");
319
+ }
320
+ return { ...value, phase: value.phase };
321
+ }
322
+
323
+ function nonnegativeInteger(value: string, label: string): number {
324
+ const parsed = Number(value);
325
+ if (!Number.isInteger(parsed) || parsed < 0) {
326
+ throw new Error(`${label} must be a nonnegative integer`);
327
+ }
328
+ return parsed;
329
+ }
330
+
331
+ function defaultRunId(): string {
332
+ return `sb-${new Date().toISOString().replace(/\D/g, "").slice(0, 14)}-${crypto.randomUUID().slice(0, 8)}`;
333
+ }
334
+
335
+ function fail(message: string): never {
336
+ throw new Error(message);
337
+ }
338
+
339
+ function printHelp(): void {
340
+ console.log(`SelfBench creates durable tiered Harbor evaluations.
341
+
342
+ Usage:
343
+ self-bench up [--backend docker|modal] [--modal-config PATH]
344
+ self-bench down
345
+ self-bench run --repo PATH [--easy-count N] [--medium-count N] [--hard-count N]
346
+ [--model MODEL] [--run-id ID] [--wait] [--output OUTPUT.tar.gz]
347
+ self-bench status RUN_ID
348
+ self-bench cancel RUN_ID
349
+ self-bench download RUN_ID OUTPUT.tar.gz
350
+ self-bench list
351
+
352
+ The up command starts the local stack and configures both sandbox execution and Harbor validation for
353
+ one backend. Modal uses ~/.modal.toml unless --modal-config overrides it.
354
+
355
+ The tier counts are candidate authoring budgets, not accepted-task targets. Rejected candidates are not
356
+ replaced, and the export contains only accepted tasks. The run command performs only repository metadata
357
+ and sanitized provenance upload locally; discovery, authoring, validation, review, and audit run remotely.
358
+ --output implies --wait, blocks until completion, and downloads the SHA-256-verified export.`);
359
+ }
@@ -0,0 +1,229 @@
1
+ import { z } from "zod";
2
+ import type { CouplingEvidence } from "./coupling.js";
3
+
4
+ export const COUPLING_REVIEW_MODEL = "gpt-5.6-sol";
5
+
6
+ export const couplingReviewSchema = z.object({
7
+ verdict: z.enum(["clean", "coupled"]),
8
+ reason: z.string().min(1),
9
+ findings: z
10
+ .array(
11
+ z.object({
12
+ artifact: z.string().min(1),
13
+ category: z.enum(["endpoint_path", "field_name", "header_name", "media_type", "other"]),
14
+ disposition: z.enum([
15
+ "base_contract",
16
+ "prompt_contract",
17
+ "external_contract",
18
+ "gold_only",
19
+ "not_contract",
20
+ ]),
21
+ evidence: z.string().min(1),
22
+ }),
23
+ )
24
+ .min(1),
25
+ counterexample: z.string().min(1),
26
+ });
27
+
28
+ export type CouplingReview = z.infer<typeof couplingReviewSchema>;
29
+
30
+ export async function reviewCouplingWithCodex(input: {
31
+ readonly apiKey?: string;
32
+ readonly authJson?: string;
33
+ readonly prompt: string;
34
+ readonly signal?: AbortSignal;
35
+ readonly fetch?: typeof fetch;
36
+ }): Promise<CouplingReview> {
37
+ const subscription =
38
+ !input.apiKey && input.authJson ? parseCredential(input.authJson) : undefined;
39
+ if (!input.apiKey && !subscription) {
40
+ throw new Error("OpenAI model authentication is required");
41
+ }
42
+ const request = input.fetch ?? fetch;
43
+ const response = await request(
44
+ subscription
45
+ ? "https://chatgpt.com/backend-api/codex/responses"
46
+ : "https://api.openai.com/v1/responses",
47
+ {
48
+ method: "POST",
49
+ headers: {
50
+ Authorization: `Bearer ${subscription?.access ?? input.apiKey}`,
51
+ ...(subscription ? { "ChatGPT-Account-Id": accountIdFromToken(subscription.access) } : {}),
52
+ Originator: "selfbench",
53
+ "OpenAI-Beta": "responses=experimental",
54
+ Accept: "text/event-stream",
55
+ "Content-Type": "application/json",
56
+ },
57
+ body: JSON.stringify({
58
+ model: COUPLING_REVIEW_MODEL,
59
+ store: false,
60
+ stream: true,
61
+ instructions:
62
+ "You are an independent benchmark-quality reviewer. Analyze the supplied evidence and call submit_review exactly once.",
63
+ input: [
64
+ {
65
+ role: "user",
66
+ content: [{ type: "input_text", text: input.prompt }],
67
+ },
68
+ ],
69
+ reasoning: { effort: "high", summary: "auto" },
70
+ text: { verbosity: "low" },
71
+ include: ["reasoning.encrypted_content"],
72
+ tools: [reviewTool()],
73
+ tool_choice: { type: "function", name: "submit_review" },
74
+ parallel_tool_calls: false,
75
+ }),
76
+ ...(input.signal ? { signal: input.signal } : {}),
77
+ },
78
+ );
79
+ const responseText = await response.text();
80
+ if (!response.ok) {
81
+ throw new Error(
82
+ `Codex coupling review failed (${response.status}): ${responseText.slice(0, 500)}`,
83
+ );
84
+ }
85
+ return parseCodexReviewEvents(responseText);
86
+ }
87
+
88
+ function parseCredential(value: string): { access: string } {
89
+ const parsed = JSON.parse(value) as unknown;
90
+ const credential = isRecord(parsed) ? parsed["openai-codex"] : undefined;
91
+ if (!isRecord(credential) || typeof credential.access !== "string") {
92
+ throw new Error("Pi auth does not contain an OpenAI Codex subscription access token");
93
+ }
94
+ return { access: credential.access };
95
+ }
96
+
97
+ function accountIdFromToken(token: string): string {
98
+ const payload = JSON.parse(
99
+ Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8"),
100
+ ) as unknown;
101
+ const auth = isRecord(payload) ? payload["https://api.openai.com/auth"] : undefined;
102
+ const accountId = isRecord(auth) ? auth.chatgpt_account_id : undefined;
103
+ if (typeof accountId !== "string" || !accountId) {
104
+ throw new Error("OpenAI Codex subscription token has no ChatGPT account ID");
105
+ }
106
+ return accountId;
107
+ }
108
+
109
+ export function parseCodexReviewEvents(value: string): CouplingReview {
110
+ for (const line of value.split("\n")) {
111
+ if (!line.startsWith("data: ")) {
112
+ continue;
113
+ }
114
+ const data = line.slice("data: ".length);
115
+ if (data === "[DONE]") {
116
+ continue;
117
+ }
118
+ const event = JSON.parse(data) as unknown;
119
+ if (!isRecord(event)) {
120
+ continue;
121
+ }
122
+ const item = event.item;
123
+ if (
124
+ event.type === "response.output_item.done" &&
125
+ isRecord(item) &&
126
+ item.type === "function_call" &&
127
+ item.name === "submit_review" &&
128
+ typeof item.arguments === "string"
129
+ ) {
130
+ return couplingReviewSchema.parse(JSON.parse(item.arguments));
131
+ }
132
+ if (event.type === "error") {
133
+ throw new Error(`Codex coupling review stream failed: ${JSON.stringify(event)}`);
134
+ }
135
+ }
136
+ throw new Error("Codex coupling review returned no submit_review tool call");
137
+ }
138
+
139
+ export function couplingReviewPrompt(): string {
140
+ return `Independently review this hard benchmark for test-to-gold coupling.
141
+
142
+ For every exact endpoint path, response/request field, header, media type, helper/module, error string, schema/index name, UI copy/order, or other implementation artifact asserted by the held-out tests, cite either the authentic prompt text that requires it or deterministic evidence that the exact base repository already establishes it. A name merely appearing in the gold patch is not evidence. Reject a test that would fail a coherent implementation of the request using different names, file boundaries, payload presentation, or internal structure.
143
+
144
+ Resolve every artifact listed in couplingEvidence.blockers with a finding whose artifact exactly matches it. Use external_contract only when the authentic request names a standard protocol that fixes the exact artifact independently of the gold patch, and cite that protocol. Use not_contract only for incidental test-language or framework syntax that is not an asserted product contract. A clean verdict with a missing blocker finding or a gold_only finding is rejected automatically.
145
+
146
+ Your findings must identify the artifact, classify it, give its disposition, and cite concrete evidence. The counterexample must describe a plausible alternative correct implementation and whether the tests would accept it. Also reject prompt leakage of PRs, commits, test names, or the solution.`;
147
+ }
148
+
149
+ export function couplingReviewInput(
150
+ prompt: string,
151
+ testPatch: string,
152
+ goldPatch: string,
153
+ evidence: CouplingEvidence,
154
+ ): string {
155
+ return `${couplingReviewPrompt()}
156
+
157
+ # Authentic request
158
+
159
+ ${prompt}
160
+
161
+ # Deterministic base-contract evidence
162
+
163
+ The exact-artifact scan compared the held-out tests with the authentic request, gold additions, and exact base snapshot. Every blocker below is asserted by tests and introduced by gold, but absent verbatim from both request and base. Resolve each blocker explicitly.
164
+
165
+ ${JSON.stringify(evidence, null, 2)}
166
+
167
+ # Held-out test patch
168
+
169
+ \`\`\`diff
170
+ ${testPatch}
171
+ \`\`\`
172
+
173
+ # Gold implementation patch
174
+
175
+ \`\`\`diff
176
+ ${goldPatch}
177
+ \`\`\`
178
+ `;
179
+ }
180
+
181
+ function reviewTool(): Record<string, unknown> {
182
+ return {
183
+ type: "function",
184
+ name: "submit_review",
185
+ description: "Submit the final independent test-coupling verdict exactly once.",
186
+ strict: true,
187
+ parameters: {
188
+ type: "object",
189
+ additionalProperties: false,
190
+ required: ["verdict", "reason", "findings", "counterexample"],
191
+ properties: {
192
+ verdict: { type: "string", enum: ["clean", "coupled"] },
193
+ reason: { type: "string", minLength: 1 },
194
+ findings: {
195
+ type: "array",
196
+ minItems: 1,
197
+ items: {
198
+ type: "object",
199
+ additionalProperties: false,
200
+ required: ["artifact", "category", "disposition", "evidence"],
201
+ properties: {
202
+ artifact: { type: "string", minLength: 1 },
203
+ category: {
204
+ type: "string",
205
+ enum: ["endpoint_path", "field_name", "header_name", "media_type", "other"],
206
+ },
207
+ disposition: {
208
+ type: "string",
209
+ enum: [
210
+ "base_contract",
211
+ "prompt_contract",
212
+ "external_contract",
213
+ "gold_only",
214
+ "not_contract",
215
+ ],
216
+ },
217
+ evidence: { type: "string", minLength: 1 },
218
+ },
219
+ },
220
+ },
221
+ counterexample: { type: "string", minLength: 1 },
222
+ },
223
+ },
224
+ };
225
+ }
226
+
227
+ function isRecord(value: unknown): value is Record<string, unknown> {
228
+ return typeof value === "object" && value !== null && !Array.isArray(value);
229
+ }