@bjoernrochel/sevro 0.1.0-rc.7 → 1.0.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.
Files changed (230) hide show
  1. package/CONTRIBUTING.md +22 -6
  2. package/README.md +5 -1
  3. package/docs/README.md +19 -15
  4. package/docs/architecture.md +138 -17
  5. package/docs/assets/README.md +5 -4
  6. package/docs/builtin-graders-v1.md +17 -3
  7. package/docs/codex-host.md +155 -49
  8. package/docs/contracts.md +2 -1
  9. package/docs/creating-cases.md +9 -2
  10. package/docs/development-cli.md +187 -19
  11. package/docs/documentation-quality.md +12 -2
  12. package/docs/evaluation-workflow.md +110 -0
  13. package/docs/extension-protocol-v1.md +26 -3
  14. package/docs/getting-started.md +23 -2
  15. package/docs/guide-evaluation.md +33 -1
  16. package/docs/identity-v1.md +25 -2
  17. package/docs/installing.md +11 -11
  18. package/docs/native-controls.md +76 -5
  19. package/docs/native-hosts.md +196 -7
  20. package/docs/reading-results.md +102 -0
  21. package/docs/recipes.md +489 -0
  22. package/docs/releases.md +31 -12
  23. package/docs/report-v1.md +29 -1
  24. package/docs/results-v1.md +141 -4
  25. package/docs/run-settings-v1.md +147 -0
  26. package/docs/runtime-v1.md +307 -18
  27. package/docs/troubleshooting.md +12 -12
  28. package/docs/typescript-quality.md +89 -26
  29. package/examples/recipes/candidate.ts +26 -0
  30. package/examples/recipes/cases/coding-change.json +21 -0
  31. package/examples/recipes/cases/host-tools.json +21 -0
  32. package/examples/recipes/cases/mise.json +21 -0
  33. package/examples/recipes/cases/node.json +21 -0
  34. package/examples/recipes/cases/python-uv.json +21 -0
  35. package/examples/recipes/cases/semantic.json +17 -0
  36. package/examples/recipes/coding-change/check.ts +23 -0
  37. package/examples/recipes/extension-artifacts.ts +65 -0
  38. package/examples/recipes/extension-methods.ts +174 -0
  39. package/examples/recipes/extension.ts +37 -0
  40. package/examples/recipes/repository-skill/.agents/skills/startup-guide/SKILL.md +7 -0
  41. package/examples/recipes/repository-skill/.claude/skills/startup-guide/SKILL.md +7 -0
  42. package/examples/recipes/repository-skill/README.md +3 -0
  43. package/examples/recipes/run-inputs.ts +188 -0
  44. package/examples/recipes/run-options.ts +131 -0
  45. package/examples/recipes/run.ts +63 -0
  46. package/examples/recipes/runtime/empty.json +1 -0
  47. package/examples/recipes/runtime/goals.json +1 -0
  48. package/examples/recipes/runtime/mise.json +6 -0
  49. package/examples/recipes/runtime/path.json +1 -0
  50. package/examples/recipes/runtime/plugins.json +4 -0
  51. package/examples/recipes/runtime/python-uv.json +10 -0
  52. package/examples/recipes/semantic-judge.ts +29 -0
  53. package/examples/templates/coding/adapter.ts +17 -0
  54. package/examples/templates/coding/case.json +15 -0
  55. package/examples/templates/repository-skill/.agents/skills/startup-guide/SKILL.md +6 -0
  56. package/examples/templates/repository-skill/extension-data.ts +64 -0
  57. package/examples/templates/repository-skill/extension.ts +31 -0
  58. package/examples/templates/response/adapter.ts +12 -0
  59. package/examples/templates/response/case.json +13 -0
  60. package/package.json +11 -5
  61. package/schemas/report-v1.schema.json +9 -0
  62. package/schemas/run-evidence-v1.schema.json +2 -1
  63. package/schemas/runtime-v1.schema.json +63 -7
  64. package/src/advisory.ts +2 -2
  65. package/src/candidate-artifacts.ts +206 -0
  66. package/src/candidate-codex-state.ts +185 -0
  67. package/src/case-validation.ts +73 -50
  68. package/src/cli/cancellation.ts +20 -0
  69. package/src/cli/command.ts +87 -0
  70. package/src/cli/completion.ts +15 -0
  71. package/src/cli/demo.ts +60 -0
  72. package/src/cli/evaluation.ts +55 -0
  73. package/src/cli/extension.ts +79 -0
  74. package/src/cli/help.ts +198 -0
  75. package/src/cli/hosts.ts +55 -0
  76. package/src/cli/init-settings.ts +30 -0
  77. package/src/cli/init.ts +111 -0
  78. package/src/{cli-invocation.ts → cli/invocation.ts} +214 -218
  79. package/src/cli/loading.ts +151 -0
  80. package/src/cli/preparation.ts +154 -0
  81. package/src/cli/presentation.ts +93 -0
  82. package/src/cli/progress.ts +6 -0
  83. package/src/cli/run-manifest.ts +128 -0
  84. package/src/cli/run-options.ts +403 -0
  85. package/src/cli/run-overrides.ts +69 -0
  86. package/src/cli/settings.ts +154 -0
  87. package/src/cli/summary.ts +43 -0
  88. package/src/cli.ts +13 -591
  89. package/src/contract-types.ts +94 -0
  90. package/src/engine.ts +13 -5
  91. package/src/evaluation/advisory.ts +150 -0
  92. package/src/{evaluation-check-plan.ts → evaluation/check-plan.ts} +103 -67
  93. package/src/evaluation/collection.ts +88 -0
  94. package/src/evaluation/context.ts +10 -0
  95. package/src/{evaluation-grading-host.ts → evaluation/grading-host.ts} +5 -5
  96. package/src/evaluation/grading.ts +235 -0
  97. package/src/{evaluation-invocation-plan.ts → evaluation/invocation-plan.ts} +147 -58
  98. package/src/{evaluation-plan.ts → evaluation/plan.ts} +208 -141
  99. package/src/evaluation/progress.ts +19 -0
  100. package/src/evaluation/resources.ts +241 -0
  101. package/src/evaluation/run.ts +206 -0
  102. package/src/{evaluation-semantic.ts → evaluation/semantic.ts} +133 -115
  103. package/src/evaluation/trial.ts +800 -0
  104. package/src/evaluation/types.ts +207 -0
  105. package/src/evidence/host-results.ts +85 -0
  106. package/src/evidence/retained-artifacts.ts +18 -0
  107. package/src/evidence/trial-writer.ts +203 -0
  108. package/src/evidence/usage.ts +17 -0
  109. package/src/extension-client.ts +34 -96
  110. package/src/extension-session.ts +24 -48
  111. package/src/{advisory-fixture.ts → fixtures/advisory.ts} +15 -76
  112. package/src/{fixture-bin.ts → fixtures/bin.ts} +1 -1
  113. package/src/{fixture-cleanup.ts → fixtures/cleanup.ts} +13 -7
  114. package/src/fixtures/create.ts +183 -0
  115. package/src/{generated-fixture.ts → fixtures/generated.ts} +10 -53
  116. package/src/{git-hooks.ts → fixtures/git-hooks.ts} +1 -1
  117. package/src/{preparation.ts → fixtures/preparation.ts} +6 -16
  118. package/src/{repository-fixture.ts → fixtures/repository.ts} +30 -64
  119. package/src/{fixture-setup.ts → fixtures/setup.ts} +33 -51
  120. package/src/{fixture-tools.ts → fixtures/tools.ts} +1 -1
  121. package/src/fixtures/workspace-state.ts +212 -0
  122. package/src/generated/cli-result.d.cts +2 -1
  123. package/src/generated/extension.d.cts +2 -1
  124. package/src/generated/report.cjs +1 -1
  125. package/src/generated/report.d.cts +2 -1
  126. package/src/generated/run-evidence.cjs +1 -1
  127. package/src/generated/run-evidence.d.cts +2 -1
  128. package/src/generated/runtime.cjs +1 -1
  129. package/src/generated/runtime.d.cts +2 -1
  130. package/src/git.ts +93 -0
  131. package/src/graders/git-head.ts +29 -84
  132. package/src/graders/shell.ts +57 -68
  133. package/src/host-diagnostic-redaction.ts +52 -0
  134. package/src/host-diagnostics.ts +331 -0
  135. package/src/hosts/claude-continuation.ts +57 -40
  136. package/src/hosts/claude-credential.ts +160 -63
  137. package/src/hosts/claude-events.ts +34 -18
  138. package/src/hosts/claude-keychain.ts +78 -21
  139. package/src/hosts/claude-login-recovery.ts +85 -0
  140. package/src/hosts/claude-login-refresh.ts +178 -0
  141. package/src/hosts/claude-login-store.ts +162 -0
  142. package/src/hosts/claude-login.ts +107 -0
  143. package/src/hosts/claude-native-goal.ts +15 -12
  144. package/src/hosts/claude-process.ts +10 -50
  145. package/src/hosts/claude-refresh-lock.ts +81 -0
  146. package/src/hosts/claude-repository-invocation.ts +9 -34
  147. package/src/hosts/claude-settings.ts +91 -14
  148. package/src/hosts/claude-skill-activations.ts +169 -0
  149. package/src/hosts/claude-state.ts +46 -16
  150. package/src/hosts/claude-tool-calls.ts +13 -10
  151. package/src/hosts/claude.ts +122 -73
  152. package/src/hosts/codex-app-server.ts +78 -52
  153. package/src/hosts/codex-configuration.ts +5 -10
  154. package/src/hosts/codex-exec-controls.ts +516 -0
  155. package/src/hosts/codex-marketplace.ts +12 -10
  156. package/src/hosts/codex-native-calls.ts +15 -122
  157. package/src/hosts/codex-process.ts +10 -54
  158. package/src/hosts/codex-profile.ts +50 -21
  159. package/src/hosts/codex-session-files.ts +1 -9
  160. package/src/hosts/codex-state.ts +141 -19
  161. package/src/hosts/codex.ts +221 -179
  162. package/src/hosts/isolation-common.ts +15 -11
  163. package/src/hosts/isolation-roots.ts +13 -20
  164. package/src/hosts/linux-sandbox.ts +27 -3
  165. package/src/hosts/mac-sandbox.ts +111 -18
  166. package/src/hosts/native-controls.ts +72 -22
  167. package/src/hosts/native-request.ts +55 -0
  168. package/src/hosts/process-lifecycle.ts +221 -45
  169. package/src/hosts/protected-worktrees.ts +1 -1
  170. package/src/hosts/skill-hook-catalog.ts +131 -0
  171. package/src/hosts/skill-hook-handler.ts +374 -0
  172. package/src/hosts/skill-hooks.ts +577 -0
  173. package/src/instrumentation.ts +7 -8
  174. package/src/native-transcript-state.ts +22 -3
  175. package/src/native-transcript-view.ts +52 -13
  176. package/src/native-transcripts.ts +34 -15
  177. package/src/paths.ts +22 -0
  178. package/src/process-lifecycle.ts +150 -0
  179. package/src/provenance.ts +27 -7
  180. package/src/prune.ts +300 -0
  181. package/src/report.ts +63 -36
  182. package/src/resolved-case.ts +1 -1
  183. package/src/results-lock.ts +42 -0
  184. package/src/results.ts +14 -18
  185. package/src/run-owner.ts +4 -4
  186. package/src/{runtime-config.ts → runtime/config.ts} +182 -44
  187. package/src/{runtime-discovery-apple.ts → runtime/discovery-apple.ts} +3 -3
  188. package/src/{runtime-discovery-providers.ts → runtime/discovery-providers.ts} +3 -9
  189. package/src/{runtime-discovery.ts → runtime/discovery.ts} +4 -4
  190. package/src/{runtime-hook-processes.ts → runtime/hook-processes.ts} +1 -1
  191. package/src/{runtime-hooks.ts → runtime/hooks.ts} +33 -22
  192. package/src/{runtime-paths.ts → runtime/paths.ts} +6 -18
  193. package/src/{runtime-query.ts → runtime/query.ts} +5 -5
  194. package/src/{runtime-seeds.ts → runtime/seeds.ts} +3 -3
  195. package/src/runtime/shared-cache.ts +130 -0
  196. package/src/{runtime-state.ts → runtime/state.ts} +28 -11
  197. package/src/schema-types.ts +73 -161
  198. package/src/shell.ts +4 -0
  199. package/src/transcript-retention.ts +146 -0
  200. package/src/value-guards.ts +22 -0
  201. package/docs/assets/sevro-logo.png +0 -0
  202. package/docs/assets/sevro-symbol.svg +0 -1
  203. package/docs/assets/sevro-wordmark.svg +0 -1
  204. package/docs/cache-ownership-validation.md +0 -49
  205. package/docs/claude-continuation-validation.md +0 -47
  206. package/docs/concurrency-validation.md +0 -86
  207. package/docs/configuration-root-validation.md +0 -70
  208. package/docs/engine-slice.md +0 -114
  209. package/docs/evidence/runtime-startup-30/README.md +0 -185
  210. package/docs/evidence/runtime-startup-30/after.json +0 -319
  211. package/docs/evidence/runtime-startup-30/baseline-timeout.jsonl +0 -2
  212. package/docs/evidence/runtime-startup-30/before.json +0 -403
  213. package/docs/evidence/runtime-startup-30/consumer-smoke.json +0 -63
  214. package/docs/evidence/runtime-startup-30/full-policy-diagnostic.jsonl +0 -3
  215. package/docs/evidence/runtime-startup-30/minimal-policy-diagnostic.jsonl +0 -3
  216. package/docs/evidence/runtime-startup-30/plugin-template-assessment.json +0 -20
  217. package/docs/evidence/runtime-startup-30/seedless-timeout.jsonl +0 -2
  218. package/docs/evidence.md +0 -17
  219. package/docs/fixture-cleanup-validation.md +0 -70
  220. package/docs/guide-verification.json +0 -239
  221. package/src/evaluation-context.ts +0 -257
  222. package/src/evaluation-fixture.ts +0 -313
  223. package/src/evaluation-run.ts +0 -198
  224. package/src/evaluation-trial.ts +0 -946
  225. package/src/evaluation-types.ts +0 -149
  226. package/src/hosts/claude-nested-skills.ts +0 -272
  227. package/src/hosts/codex-command-output.ts +0 -510
  228. package/src/hosts/codex-executor-command.ts +0 -291
  229. package/src/hosts/codex-skill-reads.ts +0 -609
  230. /package/src/{trial-scheduler.ts → evaluation/scheduler.ts} +0 -0
@@ -1,10 +1,12 @@
1
- import { isRecord, isStringArray } from "./value-guards";
2
- import { isMissingFile } from "./fixture-tools";
1
+ import { isRecord, isStringArray } from "../value-guards";
2
+ import { isMissingFile } from "./tools";
3
3
  import { lstat, mkdir, realpath, stat, writeFile } from "node:fs/promises";
4
- import { isAbsolute, join, relative, sep } from "node:path";
4
+ import { isAbsolute, join } from "node:path";
5
+ import { insideRoot } from "../paths";
6
+ import { runGit } from "../git";
5
7
  import { fileURLToPath } from "node:url";
6
- import { canonicalJson } from "./identity";
7
- import { prepareFixtureBin } from "./fixture-bin";
8
+ import { canonicalJson } from "../identity";
9
+ import { prepareFixtureBin } from "./bin";
8
10
  import { prepareGitHooks } from "./git-hooks";
9
11
  import type { PreparationSources } from "./preparation";
10
12
  import { fixtureParts } from "./preparation";
@@ -167,53 +169,16 @@ export interface RepositorySource {
167
169
  revision: string;
168
170
  }
169
171
 
170
- async function boundedText(
171
- stream: ReadableStream<Uint8Array>,
172
- ): Promise<string> {
173
- const chunks: Uint8Array[] = [];
174
- let size = 0;
175
- for await (const chunk of stream) {
176
- size += chunk.byteLength;
177
- if (size > MAX_GIT_OUTPUT_BYTES)
178
- throw new Error("repository fixture Git output is too large");
179
- chunks.push(chunk);
180
- }
181
- return new TextDecoder("utf-8", { fatal: true }).decode(
182
- Buffer.concat(chunks),
183
- );
184
- }
185
-
186
172
  async function git(args: string[], cwd: string): Promise<string> {
187
- const proc = Bun.spawn(["git", ...args], {
173
+ const stdout = await runGit({
188
174
  cwd,
189
- stdout: "pipe",
190
- stderr: "pipe",
191
- env: {
192
- ...process.env,
193
- GIT_CONFIG_NOSYSTEM: "1",
194
- GIT_CONFIG_GLOBAL: process.platform === "win32" ? "NUL" : "/dev/null",
195
- GIT_TERMINAL_PROMPT: "0",
196
- GIT_AUTHOR_NAME: "Sevro Fixture",
197
- GIT_AUTHOR_EMAIL: "fixture@sevro.invalid",
198
- GIT_COMMITTER_NAME: "Sevro Fixture",
199
- GIT_COMMITTER_EMAIL: "fixture@sevro.invalid",
200
- GIT_AUTHOR_DATE: "2000-01-01T00:00:00+00:00",
201
- GIT_COMMITTER_DATE: "2000-01-01T00:00:00+00:00",
202
- },
175
+ args,
176
+ identity: "fixture",
177
+ label: "repository fixture",
178
+ maxOutputBytes: MAX_GIT_OUTPUT_BYTES,
179
+ validateUtf8: true,
203
180
  });
204
- try {
205
- const [stdout, , code] = await Promise.all([
206
- boundedText(proc.stdout),
207
- boundedText(proc.stderr),
208
- proc.exited,
209
- ]);
210
- if (code !== 0) throw new Error("repository fixture Git operation failed");
211
- return stdout.trim();
212
- } catch (error) {
213
- proc.kill("SIGKILL");
214
- await proc.exited;
215
- throw error;
216
- }
181
+ return new TextDecoder("utf-8", { fatal: true }).decode(stdout).trim();
217
182
  }
218
183
 
219
184
  /** Resolve a declared local repository without accepting a dirty source tree. */
@@ -261,8 +226,7 @@ async function canonicalRepositorySource(selected: {
261
226
  }
262
227
  }
263
228
  function requireContainedRepository(root: string, path: string): void {
264
- const child = relative(root, path);
265
- if (child === ".." || child.startsWith(`..${sep}`) || isAbsolute(child))
229
+ if (!insideRoot(root, path))
266
230
  throw new Error("repository source escapes its declared root");
267
231
  }
268
232
  async function repositoryCommit(path: string): Promise<string> {
@@ -272,10 +236,18 @@ async function repositoryCommit(path: string): Promise<string> {
272
236
  const revision = await git(["rev-parse", "--verify", "HEAD^{commit}"], path);
273
237
  if (!/^[a-f0-9]{40,64}$/.test(revision))
274
238
  throw new Error("repository source has no valid commit");
275
- if (await git(["status", "--porcelain=v1", "--untracked-files=all"], path))
239
+ if (
240
+ await git(
241
+ [
242
+ "status",
243
+ "--porcelain=v1",
244
+ "--untracked-files=all",
245
+ "--ignore-submodules=none",
246
+ ],
247
+ path,
248
+ )
249
+ )
276
250
  throw new Error("repository source has uncommitted changes");
277
- if (await git(["submodule", "status"], path))
278
- throw new Error("repository submodules are not supported");
279
251
  return revision;
280
252
  }
281
253
  function requireRepositorySnapshot(
@@ -306,6 +278,7 @@ export async function cloneRepositorySource(
306
278
  "--local",
307
279
  "--no-hardlinks",
308
280
  "--no-checkout",
281
+ "--no-recurse-submodules",
309
282
  "--quiet",
310
283
  current.path,
311
284
  workspace,
@@ -375,25 +348,18 @@ export async function applyRepositoryOverlay(
375
348
  });
376
349
  await stageRepositoryOverlay(fixture, workspace, Object.keys(files));
377
350
  }
378
- function disabledHooksArgs(): string[] {
379
- return [
380
- "-c",
381
- `core.hooksPath=${process.platform === "win32" ? "NUL" : "/dev/null"}`,
382
- ];
383
- }
384
351
  async function stageRepositoryOverlay(
385
352
  fixture: RepositoryFixture,
386
353
  workspace: string,
387
354
  paths: string[],
388
355
  ): Promise<void> {
389
- const noHooks = disabledHooksArgs();
390
356
  if (fixture.commitFiles) {
391
- await git([...noHooks, "add", "--", ...paths], workspace);
357
+ await git(["add", "--", ...paths], workspace);
392
358
  await git(
393
- [...noHooks, "commit", "--quiet", "-m", "Add evaluation scaffolding"],
359
+ ["commit", "--quiet", "-m", "Add evaluation scaffolding"],
394
360
  workspace,
395
361
  );
396
362
  }
397
363
  if (fixture.staged?.length)
398
- await git([...noHooks, "add", "--", ...fixture.staged], workspace);
364
+ await git(["add", "--", ...fixture.staged], workspace);
399
365
  }
@@ -1,7 +1,12 @@
1
- import { mkdtemp, rm } from "node:fs/promises";
1
+ import { mkdir, mkdtemp, rm } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
3
  import { delimiter, isAbsolute, join } from "node:path";
4
- import { isRecord } from "./value-guards";
4
+ import { isRecord } from "../value-guards";
5
+ import { requireCandidateRuntime } from "../candidate-artifacts";
6
+ import {
7
+ ProcessDeadline,
8
+ terminateOwnedProcessGroup,
9
+ } from "../process-lifecycle";
5
10
 
6
11
  const MAX_COMMAND_BYTES = 64 * 1024;
7
12
  const MAX_ENV_BYTES = 16 * 1024;
@@ -103,27 +108,12 @@ export function prepareFixtureSetup(value: unknown): FixtureSetup | null {
103
108
  return { command, environment };
104
109
  }
105
110
 
106
- function stopProcess(proc: Bun.Subprocess): void {
107
- if (process.platform !== "win32") {
108
- try {
109
- process.kill(-proc.pid, "SIGKILL");
110
- return;
111
- } catch {
112
- // The process group may already be gone.
113
- }
114
- }
115
- try {
116
- proc.kill("SIGKILL");
117
- } catch {
118
- // The process already exited.
119
- }
120
- }
121
-
122
111
  interface FixtureSetupOptions {
123
112
  workspace: string;
124
113
  projectRoot: string;
125
114
  fixtureBinDir?: string;
126
115
  signal?: AbortSignal;
116
+ candidateRuntimeRoot?: string;
127
117
  }
128
118
 
129
119
  function setupPath(directory: string | undefined): string {
@@ -146,7 +136,9 @@ function executionEnvironment(
146
136
  );
147
137
  return {
148
138
  PATH: setupPath(options.fixtureBinDir),
149
- HOME: home,
139
+ HOME: options.candidateRuntimeRoot
140
+ ? join(options.candidateRuntimeRoot, "home")
141
+ : home,
150
142
  TMPDIR: home,
151
143
  LANG: process.env.LANG ?? "C",
152
144
  GIT_CONFIG_GLOBAL: process.platform === "win32" ? "NUL" : "/dev/null",
@@ -164,13 +156,23 @@ function executionEnvironment(
164
156
 
165
157
  class FixtureSetupExecution {
166
158
  private proc: Bun.Subprocess | undefined;
167
- private timer: ReturnType<typeof setTimeout> | undefined;
168
- private cancel: (() => void) | undefined;
159
+ private readonly deadline: ProcessDeadline;
169
160
  constructor(
170
161
  private readonly setup: FixtureSetup,
171
162
  private readonly options: FixtureSetupOptions,
172
163
  private readonly home: string,
173
- ) {}
164
+ ) {
165
+ this.deadline = new ProcessDeadline({
166
+ timeoutMs: TIMEOUT_MS,
167
+ signal: options.signal,
168
+ interruption: (reason) =>
169
+ new Error(
170
+ reason === "timeout"
171
+ ? "fixture setup timed out"
172
+ : "fixture setup cancelled",
173
+ ),
174
+ });
175
+ }
174
176
 
175
177
  async run(): Promise<void> {
176
178
  this.proc = Bun.spawn(this.setup.command, {
@@ -181,44 +183,18 @@ class FixtureSetupExecution {
181
183
  stdout: "ignore",
182
184
  stderr: "ignore",
183
185
  });
184
- const code = await Promise.race([
185
- this.proc.exited,
186
- this.timeout(),
187
- this.aborted(),
188
- ]);
186
+ const code = await this.deadline.wait(this.proc.exited);
189
187
  if (code !== 0) throw new Error(`fixture setup failed (${code})`);
190
188
  }
191
189
 
192
- private timeout(): Promise<never> {
193
- return new Promise((_resolve, reject) => {
194
- this.timer = setTimeout(() => {
195
- reject(new Error("fixture setup timed out"));
196
- }, TIMEOUT_MS);
197
- });
198
- }
199
-
200
- private aborted(): Promise<never> {
201
- return new Promise((_resolve, reject) => {
202
- const signal = this.options.signal;
203
- if (!signal) return;
204
- this.cancel = () => {
205
- reject(new Error("fixture setup cancelled"));
206
- };
207
- if (signal.aborted) this.cancel();
208
- else signal.addEventListener("abort", this.cancel, { once: true });
209
- });
210
- }
211
-
212
190
  async stop(): Promise<void> {
213
191
  if (!this.proc) return;
214
- stopProcess(this.proc);
192
+ terminateOwnedProcessGroup(this.proc);
215
193
  await this.proc.exited;
216
194
  }
217
195
 
218
196
  async dispose(): Promise<void> {
219
- if (this.timer) clearTimeout(this.timer);
220
- if (this.cancel)
221
- this.options.signal?.removeEventListener("abort", this.cancel);
197
+ this.deadline.dispose();
222
198
  await rm(this.home, { recursive: true, force: true });
223
199
  }
224
200
  }
@@ -229,6 +205,12 @@ export async function runFixtureSetup(
229
205
  options: FixtureSetupOptions,
230
206
  ): Promise<void> {
231
207
  if (options.signal?.aborted) throw new Error("fixture setup cancelled");
208
+ await requireCandidateRuntime(options.candidateRuntimeRoot);
209
+ if (options.candidateRuntimeRoot)
210
+ await mkdir(join(options.candidateRuntimeRoot, "home"), {
211
+ recursive: true,
212
+ mode: 0o700,
213
+ });
232
214
  const home = await mkdtemp(join(tmpdir(), "sevro-setup-"));
233
215
  const execution = new FixtureSetupExecution(setup, options, home);
234
216
  try {
@@ -1,6 +1,6 @@
1
1
  import { chmod, lstat, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
- import { isRecord } from "./value-guards";
3
+ import { isRecord } from "../value-guards";
4
4
 
5
5
  export function isMissingFile(error: unknown): boolean {
6
6
  return error instanceof Error && "code" in error && error.code === "ENOENT";
@@ -0,0 +1,212 @@
1
+ import { realpathSync } from "node:fs";
2
+ import type { Stats } from "node:fs";
3
+ import {
4
+ lstat,
5
+ mkdir,
6
+ mkdtemp,
7
+ realpath,
8
+ readFile,
9
+ readdir,
10
+ rm,
11
+ writeFile,
12
+ } from "node:fs/promises";
13
+ import { tmpdir, userInfo } from "node:os";
14
+ import { basename, dirname, join } from "node:path";
15
+ import {
16
+ ownerStatus,
17
+ processIdentity,
18
+ validProcessIdentity,
19
+ type ProcessIdentity,
20
+ } from "../run-owner";
21
+ import { isRecord } from "../value-guards";
22
+ import { isMissingFile } from "./tools";
23
+ import { atomicWriteJson } from "../storage";
24
+ import { clearFixtureContents } from "./cleanup";
25
+
26
+ const RETENTION_MS = 24 * 60 * 60_000;
27
+ interface WorkspaceOwner {
28
+ format: "sevro.workspace-owner.v1";
29
+ owner: ProcessIdentity;
30
+ state: "active" | "retained";
31
+ timestamp: number;
32
+ }
33
+
34
+ /** One stable boundary protects both existing and subsequently allocated peers. */
35
+ export function workspaceStatePath(): string {
36
+ const base = process.platform === "linux" ? "/var/tmp" : tmpdir();
37
+ return join(realpathSync(base), "sevro-workspaces-private");
38
+ }
39
+
40
+ export async function workspaceStateParent(): Promise<string> {
41
+ const root = workspaceStatePath();
42
+ await mkdir(root, { recursive: true, mode: 0o700 });
43
+ const info = await lstat(root);
44
+ if (
45
+ !info.isDirectory() ||
46
+ info.uid !== userInfo().uid ||
47
+ (info.mode & 0o077) !== 0
48
+ )
49
+ throw new Error("workspace namespace must be an owned private directory");
50
+ return realpath(root);
51
+ }
52
+
53
+ export async function allocateWorkspace(): Promise<string> {
54
+ const owner = processIdentity(process.pid);
55
+ if (!owner) throw new Error("cannot verify workspace owner identity");
56
+ const parent = await workspaceStateParent();
57
+ await workspaceMetadataDirectory(parent);
58
+ const workspace = await realpath(await mkdtemp(join(parent, "case-")));
59
+ try {
60
+ await writeFile(
61
+ workspaceOwnerPath(workspace),
62
+ JSON.stringify({
63
+ format: "sevro.workspace-owner.v1",
64
+ owner,
65
+ state: "active",
66
+ timestamp: Date.now(),
67
+ }),
68
+ { mode: 0o600, flag: "wx" },
69
+ );
70
+ return workspace;
71
+ } catch (cause) {
72
+ await rm(workspace, { recursive: true, force: true });
73
+ throw cause;
74
+ }
75
+ }
76
+
77
+ async function workspaceMetadataDirectory(parent: string): Promise<string> {
78
+ const root = join(parent, ".owners");
79
+ await mkdir(root, { mode: 0o700, recursive: true });
80
+ const metadata = await lstat(root);
81
+ if (!ownedDirectory(metadata) || (metadata.mode & 0o077) !== 0)
82
+ throw new Error("workspace metadata must be an owned private directory");
83
+ return root;
84
+ }
85
+
86
+ function workspaceOwnerPath(workspace: string): string {
87
+ return join(dirname(workspace), ".owners", `${basename(workspace)}.json`);
88
+ }
89
+
90
+ function validWorkspaceLifecycle(value: Record<string, unknown>): boolean {
91
+ return (
92
+ (value.state === "active" || value.state === "retained") &&
93
+ typeof value.timestamp === "number" &&
94
+ Number.isFinite(value.timestamp)
95
+ );
96
+ }
97
+
98
+ function validWorkspaceOwner(value: unknown): value is WorkspaceOwner {
99
+ return (
100
+ isRecord(value) &&
101
+ value.format === "sevro.workspace-owner.v1" &&
102
+ validWorkspaceLifecycle(value) &&
103
+ validProcessIdentity(value.owner)
104
+ );
105
+ }
106
+
107
+ function privateOwnerFile(info: Stats): boolean {
108
+ return (
109
+ info.isFile() &&
110
+ info.uid === userInfo().uid &&
111
+ info.size <= 4096 &&
112
+ (info.mode & 0o077) === 0
113
+ );
114
+ }
115
+
116
+ async function readWorkspaceOwner(workspace: string): Promise<unknown> {
117
+ const path = workspaceOwnerPath(workspace);
118
+ if (!privateOwnerFile(await lstat(path))) return null;
119
+ return JSON.parse(await readFile(path, "utf8")) as unknown;
120
+ }
121
+
122
+ async function workspaceOwner(
123
+ workspace: string,
124
+ ): Promise<WorkspaceOwner | null> {
125
+ try {
126
+ const value = await readWorkspaceOwner(workspace);
127
+ return validWorkspaceOwner(value) ? value : null;
128
+ } catch {
129
+ // Unreadable or malformed ownership cannot authorize temporary-data deletion.
130
+ return null;
131
+ }
132
+ }
133
+
134
+ export async function retainWorkspace(workspace: string): Promise<void> {
135
+ if (!workspaceBoundaryException(workspaceStatePath(), workspace).length)
136
+ return;
137
+ const record = await workspaceOwner(workspace);
138
+ if (!record || record.state === "retained") return;
139
+ await atomicWriteJson(workspaceOwnerPath(workspace), {
140
+ ...record,
141
+ state: "retained",
142
+ timestamp: Date.now(),
143
+ });
144
+ }
145
+
146
+ export async function removeWorkspace(workspace: string): Promise<void> {
147
+ await rm(workspace, { recursive: true, force: true });
148
+ if (workspaceBoundaryException(workspaceStatePath(), workspace).length)
149
+ await rm(workspaceOwnerPath(workspace), { force: true });
150
+ }
151
+
152
+ async function expireWorkspace(
153
+ workspace: string,
154
+ cutoff: number,
155
+ ): Promise<void> {
156
+ const record = await workspaceOwner(workspace);
157
+ if (!record || !expiredOwner(record, cutoff)) return;
158
+ const info = await lstat(workspace).catch((cause: unknown) => {
159
+ if (isMissingFile(cause)) return null;
160
+ throw cause;
161
+ });
162
+ if (info && !ownedDirectory(info)) return;
163
+ await clearExpiredWorkspace(workspace, info);
164
+ }
165
+
166
+ async function clearExpiredWorkspace(
167
+ workspace: string,
168
+ info: Stats | null,
169
+ ): Promise<void> {
170
+ if (info) await clearFixtureContents(workspace);
171
+ await removeWorkspace(workspace);
172
+ }
173
+
174
+ function ownedDirectory(info: Stats): boolean {
175
+ return info.isDirectory() && info.uid === userInfo().uid;
176
+ }
177
+
178
+ function expiredOwner(record: WorkspaceOwner, cutoff: number): boolean {
179
+ return (
180
+ record.timestamp < cutoff &&
181
+ (record.state === "retained" || ownerStatus(record.owner) === "dead")
182
+ );
183
+ }
184
+
185
+ /** Reclaim only owned inactive temporary data; evidence directories are never scanned. */
186
+ export async function cleanupExpiredWorkspaces(): Promise<void> {
187
+ const parent = await workspaceStateParent();
188
+ const metadata = await workspaceMetadataDirectory(parent);
189
+ const cutoff = Date.now() - RETENTION_MS;
190
+ for (const entry of await readdir(metadata, { withFileTypes: true }))
191
+ if (entry.isFile() && /^case-[a-zA-Z0-9]+\.json$/.test(entry.name)) {
192
+ const workspace = join(parent, entry.name.slice(0, -5));
193
+ await expireWorkspace(workspace, cutoff).catch((cause: unknown) => {
194
+ if (!isMissingFile(cause))
195
+ console.warn(
196
+ `temporary workspace cleanup did not complete: ${workspace}`,
197
+ );
198
+ });
199
+ }
200
+ }
201
+
202
+ /** Only a runner-owned direct child can be an exception to this deny boundary. */
203
+ export function workspaceBoundaryException(
204
+ root: string,
205
+ workspace?: string,
206
+ ): string[] {
207
+ return root === workspaceStatePath() &&
208
+ workspace !== undefined &&
209
+ dirname(workspace) === root
210
+ ? [workspace]
211
+ : [];
212
+ }
@@ -1,3 +1,4 @@
1
1
  // Generated by bun run schemas:generate; do not edit.
2
- declare function validate(value: unknown): boolean;
2
+ import type { ValidateFunction } from "ajv";
3
+ declare const validate: ValidateFunction;
3
4
  export = validate;
@@ -1,3 +1,4 @@
1
1
  // Generated by bun run schemas:generate; do not edit.
2
- declare function validate(value: unknown): boolean;
2
+ import type { ValidateFunction } from "ajv";
3
+ declare const validate: ValidateFunction;
3
4
  export = validate;