@bjoernrochel/sevro 0.1.0-rc.1

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 (71) hide show
  1. package/LICENSE +159 -0
  2. package/README.md +71 -0
  3. package/docs/advisory-fixture.md +23 -0
  4. package/docs/builtin-graders-v1.md +77 -0
  5. package/docs/cache-ownership-validation.md +49 -0
  6. package/docs/claude-continuation-validation.md +47 -0
  7. package/docs/codex-host.md +134 -0
  8. package/docs/concurrency-validation.md +86 -0
  9. package/docs/configuration-root-validation.md +70 -0
  10. package/docs/development-cli.md +327 -0
  11. package/docs/engine-slice.md +114 -0
  12. package/docs/extension-protocol-v1.md +292 -0
  13. package/docs/fixture-cleanup-validation.md +70 -0
  14. package/docs/identity-v1.md +65 -0
  15. package/docs/native-controls.md +29 -0
  16. package/docs/releases.md +88 -0
  17. package/docs/report-v1.md +32 -0
  18. package/docs/results-v1.md +118 -0
  19. package/examples/basic/graded.json +13 -0
  20. package/examples/basic/host.ts +8 -0
  21. package/examples/basic/prompt-only.json +7 -0
  22. package/package.json +45 -0
  23. package/schemas/cli-result-v1.schema.json +184 -0
  24. package/schemas/extension-v1.schema.json +659 -0
  25. package/schemas/report-v1.schema.json +164 -0
  26. package/schemas/run-evidence-v1.schema.json +510 -0
  27. package/src/advisory-fixture.ts +282 -0
  28. package/src/advisory.ts +105 -0
  29. package/src/cli.ts +837 -0
  30. package/src/engine.ts +2341 -0
  31. package/src/extension-client.ts +257 -0
  32. package/src/extension-session.ts +335 -0
  33. package/src/fixture-bin.ts +87 -0
  34. package/src/fixture-cleanup.ts +38 -0
  35. package/src/fixture-setup.ts +163 -0
  36. package/src/generated-fixture.ts +220 -0
  37. package/src/git-hooks.ts +63 -0
  38. package/src/graders/git-head.ts +176 -0
  39. package/src/graders/output.ts +359 -0
  40. package/src/graders/semantic.ts +120 -0
  41. package/src/graders/shell.ts +277 -0
  42. package/src/hosts/claude-continuation.ts +94 -0
  43. package/src/hosts/claude-credential.ts +73 -0
  44. package/src/hosts/claude-events.ts +128 -0
  45. package/src/hosts/claude-nested-skills.ts +218 -0
  46. package/src/hosts/claude-repository-invocation.ts +239 -0
  47. package/src/hosts/claude-settings.ts +50 -0
  48. package/src/hosts/claude-tool-calls.ts +159 -0
  49. package/src/hosts/claude.ts +511 -0
  50. package/src/hosts/codex-configuration.ts +85 -0
  51. package/src/hosts/codex-events.ts +102 -0
  52. package/src/hosts/codex-native-calls.ts +702 -0
  53. package/src/hosts/codex-profile.ts +78 -0
  54. package/src/hosts/codex-skill-reads.ts +345 -0
  55. package/src/hosts/codex.ts +769 -0
  56. package/src/hosts/isolation-roots.ts +60 -0
  57. package/src/hosts/mac-sandbox.ts +108 -0
  58. package/src/hosts/native-controls.ts +124 -0
  59. package/src/hosts/protected-worktrees.ts +56 -0
  60. package/src/hosts/workspace-fingerprint.ts +55 -0
  61. package/src/identity.ts +156 -0
  62. package/src/instrumentation.ts +106 -0
  63. package/src/preparation.ts +211 -0
  64. package/src/provenance.ts +244 -0
  65. package/src/report.ts +351 -0
  66. package/src/repository-fixture.ts +286 -0
  67. package/src/results.ts +160 -0
  68. package/src/run-owner.ts +276 -0
  69. package/src/schema.ts +28 -0
  70. package/src/storage.ts +23 -0
  71. package/src/trial-scheduler.ts +27 -0
@@ -0,0 +1,511 @@
1
+ import { existsSync } from "node:fs";
2
+ import {
3
+ cp,
4
+ mkdir,
5
+ mkdtemp,
6
+ readFile,
7
+ realpath,
8
+ rm,
9
+ stat,
10
+ writeFile,
11
+ } from "node:fs/promises";
12
+ import { tmpdir } from "node:os";
13
+ import { delimiter, dirname, isAbsolute, join, relative, sep } from "node:path";
14
+ import type { HostAdapter } from "../engine";
15
+ import { claudeNativeControls } from "./native-controls";
16
+ import { fixtureParts } from "../preparation";
17
+ import { stageClaudeCredential } from "./claude-credential";
18
+ import { summarizeClaudeEvents } from "./claude-events";
19
+ import { claudeNestedSkillsObservation } from "./claude-nested-skills";
20
+ import {
21
+ claudeRepositoryInvocationObservation,
22
+ verifyClaudeRepositoryInvocation,
23
+ } from "./claude-repository-invocation";
24
+ import { claudeHostSettings } from "./claude-settings";
25
+ import { claudeToolCallsObservation } from "./claude-tool-calls";
26
+ import { evaluationProtectedRoots } from "./isolation-roots";
27
+ import { runClaudeTurns, type ClaudeSession } from "./claude-continuation";
28
+
29
+ const MAX_EVENT_BYTES = 8 * 1024 * 1024;
30
+ const MAX_STDERR_BYTES = 64 * 1024;
31
+ const DEFAULT_TIMEOUT_MS = 10 * 60_000;
32
+
33
+ export interface ClaudeHostOptions {
34
+ binary: string;
35
+ model: string;
36
+ effort: string;
37
+ projectRoot: string;
38
+ resultsRoot: string;
39
+ additionalProtectedRoots: string[];
40
+ credentialFile?: string;
41
+ uvCacheDir?: string;
42
+ toolchainBinDir?: string;
43
+ projectSettings?: boolean;
44
+ timeoutMs?: number;
45
+ }
46
+
47
+ function inside(root: string, path: string): boolean {
48
+ const child = relative(root, path);
49
+ return (
50
+ child === "" ||
51
+ (child !== ".." && !child.startsWith(`..${sep}`) && !isAbsolute(child))
52
+ );
53
+ }
54
+
55
+ function minimalRoots(roots: string[]): string[] {
56
+ return roots.filter(
57
+ (root) => !roots.some((other) => other !== root && inside(other, root)),
58
+ );
59
+ }
60
+
61
+ function stop(proc: Bun.Subprocess): void {
62
+ try {
63
+ if (process.platform !== "win32") process.kill(-proc.pid, "SIGKILL");
64
+ else proc.kill("SIGKILL");
65
+ } catch {
66
+ proc.kill("SIGKILL");
67
+ }
68
+ }
69
+
70
+ async function boundedStream(
71
+ stream: ReadableStream<Uint8Array>,
72
+ limit = MAX_EVENT_BYTES,
73
+ ): Promise<string> {
74
+ const reader = stream.getReader();
75
+ const chunks: Uint8Array[] = [];
76
+ let size = 0;
77
+ while (true) {
78
+ const { value, done } = await reader.read();
79
+ if (done) break;
80
+ size += value.byteLength;
81
+ if (size > limit)
82
+ throw new Error("Claude process output exceeds its limit");
83
+ chunks.push(value);
84
+ }
85
+ return new TextDecoder("utf-8", { fatal: true }).decode(
86
+ Buffer.concat(chunks),
87
+ );
88
+ }
89
+
90
+ async function runProcess(options: {
91
+ argv: string[];
92
+ cwd: string;
93
+ env: Record<string, string>;
94
+ timeoutMs: number;
95
+ signal?: AbortSignal;
96
+ }): Promise<{ code: number; out: string; err: string }> {
97
+ if (options.signal?.aborted) throw new Error("Claude run cancelled");
98
+ const proc = Bun.spawn(options.argv, {
99
+ cwd: options.cwd,
100
+ env: options.env,
101
+ detached: process.platform !== "win32",
102
+ stdin: "ignore",
103
+ stdout: "pipe",
104
+ stderr: "pipe",
105
+ });
106
+ let timer: ReturnType<typeof setTimeout> | undefined;
107
+ let cancel: (() => void) | undefined;
108
+ try {
109
+ const completed = Promise.all([
110
+ boundedStream(proc.stdout),
111
+ boundedStream(proc.stderr, MAX_STDERR_BYTES),
112
+ proc.exited,
113
+ ]).then(([out, err, code]) => ({ out, err, code }));
114
+ const timeout = new Promise<never>((_resolve, reject) => {
115
+ timer = setTimeout(
116
+ () => reject(new Error("Claude run timed out")),
117
+ options.timeoutMs,
118
+ );
119
+ });
120
+ const aborted = new Promise<never>((_resolve, reject) => {
121
+ if (!options.signal) return;
122
+ cancel = () => reject(new Error("Claude run cancelled"));
123
+ if (options.signal.aborted) cancel();
124
+ else options.signal.addEventListener("abort", cancel, { once: true });
125
+ });
126
+ return await Promise.race([completed, timeout, aborted]);
127
+ } catch (error) {
128
+ stop(proc);
129
+ await proc.exited;
130
+ throw error;
131
+ } finally {
132
+ if (timer) clearTimeout(timer);
133
+ if (cancel) options.signal?.removeEventListener("abort", cancel);
134
+ }
135
+ }
136
+
137
+ async function pluginDirectories(
138
+ workspace: string,
139
+ declaration: NonNullable<
140
+ Parameters<HostAdapter["run"]>[0]["claudePluginDirs"]
141
+ >,
142
+ ): Promise<string[]> {
143
+ const root = await realpath(workspace);
144
+ if (
145
+ new Set(declaration.artifactRoots).size !==
146
+ declaration.artifactRoots.length ||
147
+ new Set(declaration.artifactPaths).size !== declaration.artifactPaths.length
148
+ )
149
+ throw new Error("Claude plugin declaration contains duplicates");
150
+ const paths = new Set(declaration.artifactPaths);
151
+ const directories: string[] = [];
152
+ for (const relativeRoot of declaration.artifactRoots) {
153
+ fixtureParts(relativeRoot);
154
+ const manifest = `${relativeRoot}/.claude-plugin/plugin.json`;
155
+ if (!paths.has(manifest))
156
+ throw new Error("Claude plugin manifest is undeclared");
157
+ const actual = await realpath(join(root, relativeRoot));
158
+ if (!inside(root, actual))
159
+ throw new Error("Claude plugin directory escapes workspace");
160
+ if (!(await stat(join(actual, ".claude-plugin", "plugin.json"))).isFile())
161
+ throw new Error("Claude plugin manifest is missing");
162
+ directories.push(actual);
163
+ }
164
+ if (new Set(directories).size !== directories.length)
165
+ throw new Error("Claude plugin directories overlap");
166
+ if (
167
+ directories.some((item) =>
168
+ directories.some((other) => item !== other && inside(other, item)),
169
+ )
170
+ )
171
+ throw new Error("Claude plugin directories overlap");
172
+ for (const artifactPath of paths) {
173
+ fixtureParts(artifactPath);
174
+ const index = declaration.artifactRoots.findIndex((item) =>
175
+ artifactPath.startsWith(`${item}/`),
176
+ );
177
+ if (index < 0)
178
+ throw new Error("Claude plugin artifact is outside a declared directory");
179
+ if (!inside(directories[index]!, await realpath(join(root, artifactPath))))
180
+ throw new Error("Claude plugin artifact escapes workspace");
181
+ }
182
+ return directories;
183
+ }
184
+
185
+ async function verifyInvocation(
186
+ request: Parameters<HostAdapter["run"]>[0],
187
+ directories: string[],
188
+ ): Promise<void> {
189
+ const selected = request.explicitSkillInvocation;
190
+ if (!selected) return;
191
+ if (selected.scope === "repository") return;
192
+ const { pluginName, skillName, token } = selected;
193
+ if (
194
+ token !== `/${pluginName}:${skillName}` ||
195
+ request.prompt.split(token).length - 1 !== 1 ||
196
+ !/^[a-z][a-z0-9-]*$/.test(pluginName) ||
197
+ !/^[A-Za-z0-9._-]+$/.test(skillName)
198
+ )
199
+ throw new Error("invalid Claude explicit skill invocation");
200
+ for (const [index, root] of directories.entries()) {
201
+ const relativeRoot = request.claudePluginDirs?.artifactRoots[index];
202
+ if (
203
+ !request.claudePluginDirs?.artifactPaths.includes(
204
+ `${relativeRoot}/skills/${skillName}/SKILL.md`,
205
+ )
206
+ )
207
+ continue;
208
+ let manifest: unknown;
209
+ try {
210
+ manifest = JSON.parse(
211
+ await readFile(join(root, ".claude-plugin", "plugin.json"), "utf8"),
212
+ );
213
+ } catch {
214
+ throw new Error("invalid Claude plugin manifest");
215
+ }
216
+ if (
217
+ manifest &&
218
+ typeof manifest === "object" &&
219
+ "name" in manifest &&
220
+ manifest.name === pluginName &&
221
+ (
222
+ await stat(join(root, "skills", skillName, "SKILL.md")).catch(
223
+ () => null,
224
+ )
225
+ )?.isFile()
226
+ )
227
+ return;
228
+ }
229
+ throw new Error("invoked Claude skill is absent from the package");
230
+ }
231
+
232
+ /** Run Claude with explicit plugins and its native tool sandbox. */
233
+ export function createClaudeHost(options: ClaudeHostOptions): HostAdapter {
234
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
235
+ if (
236
+ process.platform !== "darwin" ||
237
+ !existsSync("/usr/bin/sandbox-exec") ||
238
+ !isAbsolute(options.binary) ||
239
+ !isAbsolute(options.projectRoot) ||
240
+ !isAbsolute(options.resultsRoot) ||
241
+ options.additionalProtectedRoots.some((root) => !isAbsolute(root)) ||
242
+ (options.credentialFile !== undefined &&
243
+ !isAbsolute(options.credentialFile)) ||
244
+ (options.uvCacheDir !== undefined && !isAbsolute(options.uvCacheDir)) ||
245
+ (options.toolchainBinDir !== undefined &&
246
+ !isAbsolute(options.toolchainBinDir)) ||
247
+ !options.model ||
248
+ !options.effort ||
249
+ !Number.isSafeInteger(timeoutMs) ||
250
+ timeoutMs < 1 ||
251
+ timeoutMs > 30 * 60_000
252
+ )
253
+ throw new Error("invalid Claude host configuration");
254
+ return {
255
+ id: "sevro.host.claude",
256
+ model: options.model,
257
+ effort: options.effort,
258
+ hostCapabilities: [
259
+ "sevro.claude.plugin-dirs",
260
+ "sevro.claude.explicit-invocation",
261
+ ...(options.projectSettings
262
+ ? ["sevro.claude.repository-invocation"]
263
+ : []),
264
+ "sevro.claude.tool-calls",
265
+ "sevro.host.native-controls",
266
+ "sevro.claude.nested-skills",
267
+ "sevro.host.continuation",
268
+ "sevro.claude.continuation",
269
+ ],
270
+ async run(request) {
271
+ if (
272
+ request.followUpPrompt !== undefined &&
273
+ (typeof request.followUpPrompt !== "string" ||
274
+ !request.followUpPrompt.trim())
275
+ )
276
+ throw new Error("Claude follow-up prompt must be nonempty");
277
+ if (request.instrumentation?.length || request.condition !== "passive")
278
+ throw new Error("Claude enforcement instrumentation is unavailable");
279
+ if (
280
+ request.fixtureBinDir !== undefined &&
281
+ request.fixtureBinDir !== join(request.workspace, ".git", "fixture-bin")
282
+ )
283
+ throw new Error("fixture binary path is outside the workspace");
284
+ const pluginDirs = request.claudePluginDirs
285
+ ? await pluginDirectories(request.workspace, request.claudePluginDirs)
286
+ : [];
287
+ await verifyInvocation(request, pluginDirs);
288
+ if (
289
+ request.explicitSkillInvocation?.scope === "repository" &&
290
+ !options.projectSettings
291
+ )
292
+ throw new Error(
293
+ "Claude repository invocation requires project setting sources",
294
+ );
295
+ const repositoryInvocation =
296
+ request.explicitSkillInvocation?.scope === "repository"
297
+ ? await verifyClaudeRepositoryInvocation(request)
298
+ : null;
299
+ const stateRoot = await mkdtemp(join(tmpdir(), "sevro-claude-state-"));
300
+ try {
301
+ await mkdir(join(stateRoot, "private"), { mode: 0o700 });
302
+ const privateRoot = await realpath(join(stateRoot, "private"));
303
+ const home = join(stateRoot, "home");
304
+ const temp = join(stateRoot, "tmp");
305
+ await Promise.all(
306
+ [home, temp].map((path) => mkdir(path, { mode: 0o700 })),
307
+ );
308
+ const credential = await stageClaudeCredential(
309
+ join(privateRoot, "config"),
310
+ options.credentialFile,
311
+ );
312
+ const protectedRoots = minimalRoots(
313
+ await evaluationProtectedRoots({
314
+ workspace: request.workspace,
315
+ projectRoot: options.projectRoot,
316
+ resultsRoot: options.resultsRoot,
317
+ additionalRoots: options.additionalProtectedRoots,
318
+ }),
319
+ );
320
+ if (protectedRoots.some((root) => inside(root, credential)))
321
+ throw new Error("Claude private state overlaps a protected root");
322
+ const binaryRoot = await realpath(options.binary);
323
+ if (protectedRoots.some((root) => inside(root, binaryRoot)))
324
+ throw new Error("Claude executable resides inside a protected root");
325
+ const toolchainBinDir = options.toolchainBinDir
326
+ ? await realpath(options.toolchainBinDir)
327
+ : null;
328
+ if (
329
+ toolchainBinDir &&
330
+ (protectedRoots.some((root) => inside(root, toolchainBinDir)) ||
331
+ !(await stat(toolchainBinDir)).isDirectory())
332
+ )
333
+ throw new Error(
334
+ "Claude toolchain directory is unavailable or protected",
335
+ );
336
+ let runtimeRoot: string | null = null;
337
+ if (options.uvCacheDir) {
338
+ const cacheRoot = await realpath(options.uvCacheDir);
339
+ if (
340
+ protectedRoots.some((root) => inside(root, cacheRoot)) ||
341
+ !(await stat(cacheRoot)).isDirectory()
342
+ )
343
+ throw new Error("Claude UV cache is unavailable or protected");
344
+ runtimeRoot = join(request.workspace, ".git", "sevro-runtime");
345
+ await mkdir(runtimeRoot, { recursive: true, mode: 0o700 });
346
+ await cp(cacheRoot, join(runtimeRoot, "uv-cache"), {
347
+ recursive: true,
348
+ force: false,
349
+ errorOnExist: true,
350
+ });
351
+ }
352
+ const settingsPath = join(privateRoot, "settings.json");
353
+ await writeFile(
354
+ settingsPath,
355
+ JSON.stringify(
356
+ claudeHostSettings(
357
+ privateRoot,
358
+ credential,
359
+ pluginDirs,
360
+ protectedRoots,
361
+ ),
362
+ ),
363
+ { flag: "wx", mode: 0o600 },
364
+ );
365
+ const env: Record<string, string> = {
366
+ PATH: [
367
+ request.fixtureBinDir,
368
+ toolchainBinDir,
369
+ process.env.PATH ?? "/usr/bin:/bin",
370
+ ]
371
+ .filter(Boolean)
372
+ .join(delimiter),
373
+ LANG: process.env.LANG ?? "C",
374
+ HOME: runtimeRoot ? join(runtimeRoot, "host-home") : home,
375
+ TMPDIR: temp,
376
+ CLAUDE_CONFIG_DIR: dirname(credential),
377
+ NO_COLOR: "1",
378
+ ...(runtimeRoot
379
+ ? {
380
+ UV_CACHE_DIR: join(runtimeRoot, "uv-cache"),
381
+ UV_PROJECT_ENVIRONMENT: join(
382
+ runtimeRoot,
383
+ "project-environment",
384
+ ),
385
+ UV_OFFLINE: "1",
386
+ PYTHONDONTWRITEBYTECODE: "1",
387
+ }
388
+ : {}),
389
+ };
390
+ if (runtimeRoot) await mkdir(env.HOME!, { mode: 0o700 });
391
+ const runTurn = (prompt: string, session?: ClaudeSession) =>
392
+ runProcess({
393
+ argv: [
394
+ options.binary,
395
+ "-p",
396
+ prompt,
397
+ "--output-format",
398
+ "stream-json",
399
+ "--verbose",
400
+ "--model",
401
+ options.model,
402
+ "--effort",
403
+ options.effort,
404
+ "--permission-mode",
405
+ "dontAsk",
406
+ "--tools",
407
+ "Bash,Read,Edit,Skill,Agent",
408
+ "--setting-sources",
409
+ options.projectSettings ? "project" : "",
410
+ "--settings",
411
+ settingsPath,
412
+ "--strict-mcp-config",
413
+ "--mcp-config",
414
+ '{"mcpServers":{}}',
415
+ "--no-chrome",
416
+ ...(session ? [session.option, session.id] : []),
417
+ ...pluginDirs.flatMap((path) => ["--plugin-dir", path]),
418
+ ],
419
+ cwd: request.workspace,
420
+ env,
421
+ timeoutMs,
422
+ signal: request.signal,
423
+ });
424
+ const execution = await runClaudeTurns({
425
+ prompt: request.prompt,
426
+ followUpPrompt: request.followUpPrompt,
427
+ workspace: request.workspace,
428
+ run: runTurn,
429
+ });
430
+ const summary = summarizeClaudeEvents(execution.out, execution.code);
431
+ const nestedSkills = await claudeNestedSkillsObservation(
432
+ execution.out,
433
+ dirname(credential),
434
+ request.workspace,
435
+ );
436
+ const toolCalls = claudeToolCallsObservation(
437
+ execution.out,
438
+ execution.code,
439
+ );
440
+ const repositoryObservation = repositoryInvocation
441
+ ? await claudeRepositoryInvocationObservation({
442
+ invocation: repositoryInvocation,
443
+ stream: execution.initialOut ?? execution.out,
444
+ configRoot: dirname(credential),
445
+ workspace: request.workspace,
446
+ tools: execution.initialOut
447
+ ? claudeToolCallsObservation(execution.initialOut, 0)
448
+ : toolCalls,
449
+ })
450
+ : null;
451
+ return {
452
+ finalMessage: summary.finalMessage,
453
+ complete: summary.complete,
454
+ executionFailed: !summary.complete,
455
+ observations: [
456
+ toolCalls,
457
+ nestedSkills,
458
+ claudeNativeControls(execution.out, execution.code),
459
+ ...(repositoryObservation ? [repositoryObservation] : []),
460
+ ...(execution.continuation ? [execution.continuation] : []),
461
+ ],
462
+ artifacts: [
463
+ {
464
+ id: "sevro.claude.events",
465
+ bytes: Buffer.from(execution.out, "utf8"),
466
+ },
467
+ ...(execution.initialOut !== undefined
468
+ ? [
469
+ {
470
+ id: "sevro.claude.initial-events",
471
+ bytes: Buffer.from(execution.initialOut, "utf8"),
472
+ },
473
+ ]
474
+ : []),
475
+ ...(execution.followUpOut !== undefined
476
+ ? [
477
+ {
478
+ id: "sevro.claude.follow-up-events",
479
+ bytes: Buffer.from(execution.followUpOut, "utf8"),
480
+ },
481
+ ]
482
+ : []),
483
+ ...(execution.err
484
+ ? [
485
+ {
486
+ id: "sevro.claude.stderr",
487
+ bytes: Buffer.from(execution.err, "utf8"),
488
+ },
489
+ ]
490
+ : []),
491
+ ],
492
+ actualCondition: "passive" as const,
493
+ inputTokens:
494
+ execution.sessionResultsBound === false
495
+ ? null
496
+ : summary.inputTokens,
497
+ outputTokens:
498
+ execution.sessionResultsBound === false
499
+ ? null
500
+ : summary.outputTokens,
501
+ costUsd:
502
+ execution.sessionResultsBound === false ? null : summary.costUsd,
503
+ usageComplete:
504
+ execution.sessionResultsBound !== false && summary.usageComplete,
505
+ };
506
+ } finally {
507
+ await rm(stateRoot, { recursive: true, force: true });
508
+ }
509
+ },
510
+ };
511
+ }
@@ -0,0 +1,85 @@
1
+ import { lstat, readFile, realpath, stat } from "node:fs/promises";
2
+ import { dirname, isAbsolute, join, relative, sep } from "node:path";
3
+ import { parse } from "smol-toml";
4
+
5
+ export async function configurationRoot(root: string): Promise<string> {
6
+ if (!isAbsolute(root)) throw new Error("configuration root must be absolute");
7
+ try {
8
+ const canonical = await realpath(root);
9
+ if (!(await stat(canonical)).isDirectory())
10
+ throw new Error("not a directory");
11
+ return canonical;
12
+ } catch {
13
+ throw new Error(`configuration root is unreadable: ${root}`);
14
+ }
15
+ }
16
+
17
+ async function hasConfigurationDirectory(directory: string): Promise<boolean> {
18
+ let declared = false;
19
+ try {
20
+ await lstat(directory);
21
+ declared = true;
22
+ if (!(await stat(directory)).isDirectory())
23
+ throw new Error("not a directory");
24
+ return true;
25
+ } catch (error) {
26
+ if (!declared && (error as NodeJS.ErrnoException).code === "ENOENT")
27
+ return false;
28
+ throw new Error("configuration directory is unreadable", { cause: error });
29
+ }
30
+ }
31
+
32
+ /** Import one explicit setting; never inherit ambient Codex configuration. */
33
+ export async function codexAgentConcurrency(
34
+ root: string,
35
+ ): Promise<number | null> {
36
+ const configPath = join(root, ".codex", "config.toml");
37
+ let source: string;
38
+ let declared = false;
39
+ try {
40
+ if (!(await hasConfigurationDirectory(dirname(configPath)))) return null;
41
+ const info = await lstat(configPath);
42
+ declared = true;
43
+ const canonical = await realpath(configPath);
44
+ const location = relative(root, canonical);
45
+ if (
46
+ !info.isFile() ||
47
+ info.size > 64 * 1024 ||
48
+ location === ".." ||
49
+ location.startsWith(`..${sep}`) ||
50
+ isAbsolute(location)
51
+ )
52
+ throw new Error(
53
+ "configuration must be a contained regular file up to 64 KiB",
54
+ );
55
+ source = await readFile(configPath, "utf8");
56
+ } catch (error) {
57
+ if (!declared && (error as NodeJS.ErrnoException).code === "ENOENT")
58
+ return null;
59
+ throw new Error(`Cannot read Codex configuration: ${configPath}`, {
60
+ cause: error,
61
+ });
62
+ }
63
+ let config: Record<string, unknown>;
64
+ try {
65
+ config = parse(source, { integersAsBigInt: true });
66
+ } catch {
67
+ throw new Error(`Invalid Codex configuration: ${configPath}`);
68
+ }
69
+ const agents = config.agents;
70
+ if (agents === undefined) return null;
71
+ if (!agents || typeof agents !== "object" || Array.isArray(agents))
72
+ throw new Error(`Expected an agents table in ${configPath}`);
73
+ const limit = (agents as Record<string, unknown>)
74
+ .max_concurrent_threads_per_session;
75
+ if (limit === undefined) return null;
76
+ if (
77
+ typeof limit !== "bigint" ||
78
+ limit < 1n ||
79
+ limit > BigInt(Number.MAX_SAFE_INTEGER)
80
+ )
81
+ throw new Error(
82
+ `Expected a positive integer for agents.max_concurrent_threads_per_session in ${configPath}`,
83
+ );
84
+ return Number(limit);
85
+ }
@@ -0,0 +1,102 @@
1
+ const MAX_EVENT_STREAM_BYTES = 8 * 1024 * 1024;
2
+
3
+ export class CodexEventError extends Error {
4
+ constructor(message: string) {
5
+ super(message);
6
+ this.name = "CodexEventError";
7
+ }
8
+ }
9
+
10
+ function record(value: unknown): value is Record<string, unknown> {
11
+ return value !== null && typeof value === "object" && !Array.isArray(value);
12
+ }
13
+
14
+ function usage(
15
+ value: unknown,
16
+ ): { inputTokens: number; outputTokens: number } | null {
17
+ if (!record(value)) return null;
18
+ const input = value.input_tokens;
19
+ const output = value.output_tokens;
20
+ if (
21
+ typeof input !== "number" ||
22
+ !Number.isSafeInteger(input) ||
23
+ input < 0 ||
24
+ typeof output !== "number" ||
25
+ !Number.isSafeInteger(output) ||
26
+ output < 0
27
+ )
28
+ return null;
29
+ return { inputTokens: input, outputTokens: output };
30
+ }
31
+
32
+ export interface CodexEventSummary {
33
+ threadId: string;
34
+ complete: boolean;
35
+ finalMessage: string | null;
36
+ inputTokens: number | null;
37
+ outputTokens: number | null;
38
+ usageComplete: boolean;
39
+ }
40
+
41
+ /** Parse one bounded `codex exec --json` turn. A partial stream is never success. */
42
+ export function summarizeCodexEvents(
43
+ stream: string,
44
+ exitCode: number,
45
+ ): CodexEventSummary {
46
+ if (Buffer.byteLength(stream, "utf8") > MAX_EVENT_STREAM_BYTES)
47
+ throw new CodexEventError("Codex event stream exceeds 8 MiB");
48
+ let threadId: string | null = null;
49
+ let completed = false;
50
+ let failed = false;
51
+ let finalMessage: string | null = null;
52
+ let tokenUsage: ReturnType<typeof usage> = null;
53
+ let usageComplete = false;
54
+ for (const line of stream.split("\n")) {
55
+ if (!line.trim()) continue;
56
+ let parsed: unknown;
57
+ try {
58
+ parsed = JSON.parse(line);
59
+ } catch {
60
+ throw new CodexEventError("Codex event stream contains invalid JSONL");
61
+ }
62
+ if (!record(parsed) || typeof parsed.type !== "string")
63
+ throw new CodexEventError("Codex event stream contains an invalid event");
64
+ if (parsed.type === "thread.started") {
65
+ if (
66
+ threadId !== null ||
67
+ typeof parsed.thread_id !== "string" ||
68
+ !parsed.thread_id
69
+ )
70
+ throw new CodexEventError(
71
+ "Codex event stream has an invalid thread start",
72
+ );
73
+ threadId = parsed.thread_id;
74
+ }
75
+ if (parsed.type === "item.completed" && record(parsed.item)) {
76
+ if (parsed.item.type === "agent_message" && !completed) {
77
+ finalMessage =
78
+ typeof parsed.item.text === "string" ? parsed.item.text : null;
79
+ }
80
+ }
81
+ if (parsed.type === "turn.completed") {
82
+ if (!threadId || completed || failed)
83
+ throw new CodexEventError(
84
+ "Codex event stream has an invalid turn completion",
85
+ );
86
+ completed = true;
87
+ usageComplete = usage(parsed.usage) !== null;
88
+ tokenUsage = usage(parsed.usage);
89
+ }
90
+ if (parsed.type === "turn.failed") failed = true;
91
+ }
92
+ if (!threadId)
93
+ throw new CodexEventError("Codex event stream has no thread start");
94
+ return {
95
+ threadId,
96
+ complete: exitCode === 0 && completed && !failed,
97
+ finalMessage,
98
+ inputTokens: tokenUsage?.inputTokens ?? null,
99
+ outputTokens: tokenUsage?.outputTokens ?? null,
100
+ usageComplete: exitCode === 0 && completed && !failed && usageComplete,
101
+ };
102
+ }