@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,15 @@
1
1
  import { mkdir, writeFile } from "node:fs/promises";
2
2
  import { dirname, join } from "node:path";
3
+ import { gunzipSync } from "node:zlib";
3
4
  import { allocateNativeState } from "./native-transcript-state";
4
- import { fixtureParts } from "./preparation";
5
+ import { fixtureParts } from "./fixtures/preparation";
5
6
  import { isRecord, isUnknownArray } from "./value-guards";
6
7
  import { sha256 } from "./identity";
7
8
 
9
+ const MAX_NATIVE_BYTES = 5 * 1024 * 1024;
10
+ type NativeFormat =
11
+ "sevro.native-transcripts.v1" | "sevro.native-transcripts.v2";
12
+
8
13
  export async function materializeNativeTranscriptView(
9
14
  bytes: Uint8Array,
10
15
  ): Promise<string> {
@@ -12,19 +17,33 @@ export async function materializeNativeTranscriptView(
12
17
  const bundle = nativeBundle(value);
13
18
  const root = await allocateNativeState("view-");
14
19
  await writeFile(join(root, "index.json"), bytes, { flag: "wx", mode: 0o600 });
20
+ const filesRoot = join(root, bundle.host);
21
+ let remainingBytes = MAX_NATIVE_BYTES;
15
22
  for (const item of bundle.files)
16
- await materializeNativeFile(root, bundle.host, item);
23
+ remainingBytes -= await materializeNativeFile(
24
+ filesRoot,
25
+ item,
26
+ bundle.format,
27
+ remainingBytes,
28
+ );
17
29
  return root;
18
30
  }
19
31
 
20
32
  function nativeBundle(value: unknown) {
21
- if (!isRecord(value) || value.format !== "sevro.native-transcripts.v1")
33
+ if (!isRecord(value) || !nativeFormat(value.format))
22
34
  throw new Error("invalid native transcript bundle");
23
35
  if (!nativeHost(value.host))
24
36
  throw new Error("invalid native transcript host");
25
37
  if (!isUnknownArray(value.files))
26
38
  throw new Error("invalid native transcript files");
27
- return { host: value.host, files: value.files };
39
+ return { host: value.host, files: value.files, format: value.format };
40
+ }
41
+
42
+ function nativeFormat(value: unknown): value is NativeFormat {
43
+ return (
44
+ value === "sevro.native-transcripts.v1" ||
45
+ value === "sevro.native-transcripts.v2"
46
+ );
28
47
  }
29
48
 
30
49
  function nativeHost(value: unknown): value is "codex" | "claude" {
@@ -33,19 +52,39 @@ function nativeHost(value: unknown): value is "codex" | "claude" {
33
52
 
34
53
  async function materializeNativeFile(
35
54
  root: string,
36
- host: string,
37
55
  value: unknown,
38
- ): Promise<void> {
39
- if (
40
- !isRecord(value) ||
41
- typeof value.path !== "string" ||
42
- typeof value.bytesBase64 !== "string"
43
- )
56
+ format: NativeFormat,
57
+ remainingBytes: number,
58
+ ): Promise<number> {
59
+ if (!isRecord(value) || typeof value.path !== "string")
44
60
  throw new Error("invalid native transcript file");
45
- const path = join(root, host, ...fixtureParts(value.path));
46
- const bytes = Buffer.from(value.bytesBase64, "base64");
61
+ const path = join(root, ...fixtureParts(value.path));
62
+ const bytes = nativeFileBytes(value, format, remainingBytes);
63
+ if (bytes.length > remainingBytes)
64
+ throw new Error("native transcript byte limit exceeded");
47
65
  if (sha256(bytes) !== value.sha256)
48
66
  throw new Error("native transcript hash mismatch");
49
67
  await mkdir(dirname(path), { recursive: true, mode: 0o700 });
50
68
  await writeFile(path, bytes, { flag: "wx", mode: 0o600 });
69
+ return bytes.length;
70
+ }
71
+
72
+ function nativeFileBytes(
73
+ value: Record<string, unknown>,
74
+ format: NativeFormat,
75
+ remainingBytes: number,
76
+ ): Buffer {
77
+ const encoded =
78
+ format === "sevro.native-transcripts.v1"
79
+ ? value.bytesBase64
80
+ : value.gzipBase64;
81
+ if (typeof encoded !== "string")
82
+ throw new Error("invalid native transcript file");
83
+ const bytes = Buffer.from(encoded, "base64");
84
+ if (format === "sevro.native-transcripts.v1") return bytes;
85
+ try {
86
+ return gunzipSync(bytes, { maxOutputLength: Math.max(1, remainingBytes) });
87
+ } catch (cause) {
88
+ throw new Error("invalid native transcript gzip", { cause });
89
+ }
51
90
  }
@@ -1,10 +1,15 @@
1
1
  import { lstat, readdir, readFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
+ import { gzipSync } from "node:zlib";
3
4
  import { sha256 } from "./identity";
4
- import type { HostResult } from "./evaluation-types";
5
- import type { RuntimeRole } from "./runtime-state";
6
- import type { RuntimePolicy } from "./runtime-config";
5
+ import { HostExecutionFailure, type HostResult } from "./evaluation/types";
6
+ import type { RuntimeRole } from "./runtime/state";
7
+ import type { RuntimePolicy } from "./runtime/config";
7
8
  import { isRecord } from "./value-guards";
9
+ import {
10
+ rethrowDiagnosticPersistenceError,
11
+ type HostDiagnostics,
12
+ } from "./host-diagnostics";
8
13
 
9
14
  export function candidateTranscriptRoots(root: string | undefined): string[] {
10
15
  return root ? [root] : [];
@@ -47,23 +52,37 @@ export async function failedNativeTranscripts(
47
52
  policy: RuntimePolicy | undefined,
48
53
  root: string,
49
54
  host: "codex" | "claude",
50
- role?: RuntimeRole,
55
+ role: RuntimeRole | undefined,
56
+ diagnostics: HostDiagnostics,
51
57
  ): Promise<HostResult> {
52
- if (policy?.nativeTranscripts !== true) throw cause;
53
- const result: HostResult = {
54
- finalMessage: null,
55
- complete: false,
56
- executionFailed: true,
57
- actualCondition: "passive",
58
- };
59
- await retainNativeTranscripts(result, root, host, role);
58
+ rethrowDiagnosticPersistenceError(cause);
59
+ requireFailureCapture(cause, policy, diagnostics);
60
+ const result: HostResult =
61
+ cause instanceof HostExecutionFailure
62
+ ? cause.result
63
+ : {
64
+ finalMessage: null,
65
+ complete: false,
66
+ executionFailed: true,
67
+ actualCondition: "passive",
68
+ };
69
+ await diagnostics.retain(result, cause);
70
+ await retainEnabledNativeTranscripts(result, policy, root, host, role);
60
71
  return result;
61
72
  }
62
73
 
74
+ function requireFailureCapture(
75
+ cause: unknown,
76
+ policy: RuntimePolicy | undefined,
77
+ diagnostics: HostDiagnostics,
78
+ ): void {
79
+ if (policy?.nativeTranscripts !== true && !diagnostics.enabled) throw cause;
80
+ }
81
+
63
82
  const MAX_FILES = 4096;
64
83
  const MAX_BYTES = 5 * 1024 * 1024;
65
84
  const MAX_ENCODED_FILES_BYTES = 7 * 1024 * 1024;
66
- type NativeFile = { path: string; sha256: string; bytesBase64: string };
85
+ type NativeFile = { path: string; sha256: string; gzipBase64: string };
67
86
  type Capture = {
68
87
  files: NativeFile[];
69
88
  bytes: number;
@@ -95,7 +114,7 @@ export async function retainNativeTranscripts(
95
114
  observeCaptureRoot(capture, rootSessionId, host);
96
115
  const completeness = captureCompleteness(capture, result);
97
116
  const bundle = {
98
- format: "sevro.native-transcripts.v1",
117
+ format: "sevro.native-transcripts.v2",
99
118
  host,
100
119
  role,
101
120
  rootSessionId,
@@ -208,7 +227,7 @@ async function captureFile(
208
227
  const file = {
209
228
  path,
210
229
  sha256: sha256(bytes),
211
- bytesBase64: bytes.toString("base64"),
230
+ gzipBase64: gzipSync(bytes).toString("base64"),
212
231
  };
213
232
  const issue = nativeFileIssue(path, bytes);
214
233
  if (issue && !capture.issues.includes(issue)) capture.issues.push(issue);
package/src/paths.ts ADDED
@@ -0,0 +1,22 @@
1
+ import { isAbsolute, relative, sep } from "node:path";
2
+
3
+ /** Lexical containment includes the root itself; callers resolve symlinks first. */
4
+ export function insideRoot(root: string, path: string): boolean {
5
+ const child = relative(root, path);
6
+ return (
7
+ child === "" ||
8
+ (child !== ".." && !child.startsWith(`..${sep}`) && !isAbsolute(child))
9
+ );
10
+ }
11
+
12
+ /** Keep outermost roots in input order, retaining the first equivalent spelling. */
13
+ export function minimalRoots(roots: string[]): string[] {
14
+ return roots.filter(
15
+ (root, index) =>
16
+ !roots.some(
17
+ (other, otherIndex) =>
18
+ insideRoot(other, root) &&
19
+ (!insideRoot(root, other) || otherIndex < index),
20
+ ),
21
+ );
22
+ }
@@ -0,0 +1,150 @@
1
+ export type ProcessInterruptionReason = "timeout" | "cancelled";
2
+
3
+ interface DeadlineOptions {
4
+ timeoutMs: number;
5
+ signal?: AbortSignal;
6
+ interruption: (reason: ProcessInterruptionReason) => Error;
7
+ }
8
+
9
+ /** Keep the deadline active until the caller has finished failure cleanup. */
10
+ export class ProcessDeadline {
11
+ private timer: ReturnType<typeof setTimeout> | undefined;
12
+ private cancel: (() => void) | undefined;
13
+
14
+ constructor(private readonly options: DeadlineOptions) {}
15
+
16
+ wait<T>(completed: Promise<T> | (() => Promise<T>)): Promise<T> {
17
+ const timeout = this.timeout();
18
+ const aborted = this.aborted();
19
+ const completion =
20
+ typeof completed === "function" ? completed() : completed;
21
+ return Promise.race([completion, timeout, aborted]);
22
+ }
23
+
24
+ private timeout(): Promise<never> {
25
+ return new Promise((_resolve, reject) => {
26
+ this.timer = setTimeout(() => {
27
+ reject(this.options.interruption("timeout"));
28
+ }, this.options.timeoutMs);
29
+ });
30
+ }
31
+
32
+ private aborted(): Promise<never> {
33
+ return new Promise((_resolve, reject) => {
34
+ const signal = this.options.signal;
35
+ if (!signal) return;
36
+ this.cancel = () => {
37
+ reject(this.options.interruption("cancelled"));
38
+ };
39
+ if (signal.aborted) this.cancel();
40
+ else signal.addEventListener("abort", this.cancel, { once: true });
41
+ });
42
+ }
43
+
44
+ dispose(): void {
45
+ if (this.timer) clearTimeout(this.timer);
46
+ if (this.cancel)
47
+ this.options.signal?.removeEventListener("abort", this.cancel);
48
+ }
49
+ }
50
+
51
+ interface OwnedProcess {
52
+ pid: number;
53
+ kill: (signal: "SIGKILL") => unknown;
54
+ }
55
+
56
+ interface TerminationOptions {
57
+ alwaysGroup?: boolean;
58
+ fallbackError?: "ignore" | "propagate";
59
+ }
60
+
61
+ function killGroup(pid: number): boolean {
62
+ try {
63
+ process.kill(-pid, "SIGKILL");
64
+ return true;
65
+ } catch {
66
+ // The owned process group may already be gone.
67
+ return false;
68
+ }
69
+ }
70
+
71
+ function useProcessGroup(options: TerminationOptions): boolean {
72
+ return options.alwaysGroup === true || process.platform !== "win32";
73
+ }
74
+
75
+ function killOwnedProcess(
76
+ proc: OwnedProcess,
77
+ options: TerminationOptions,
78
+ ): void {
79
+ if (options.fallbackError === "propagate") {
80
+ proc.kill("SIGKILL");
81
+ return;
82
+ }
83
+ try {
84
+ proc.kill("SIGKILL");
85
+ } catch {
86
+ // The owned process may already have exited.
87
+ }
88
+ }
89
+
90
+ /** Only use for children spawned in a new, runner-owned process group. */
91
+ export function terminateOwnedProcessGroup(
92
+ proc: OwnedProcess,
93
+ options: TerminationOptions = {},
94
+ ): void {
95
+ if (useProcessGroup(options) && killGroup(proc.pid)) return;
96
+ killOwnedProcess(proc, options);
97
+ }
98
+
99
+ type RejectedCapture = {
100
+ overflow: "reject";
101
+ exceeded: () => Error;
102
+ };
103
+
104
+ interface CaptureOptions {
105
+ limit: number;
106
+ observe?: (bytes: Uint8Array) => void;
107
+ cancelOnError?: boolean;
108
+ }
109
+
110
+ type ByteCaptureOptions = CaptureOptions &
111
+ (RejectedCapture | { overflow: "truncate" });
112
+
113
+ function retainedChunk(
114
+ bytes: Uint8Array,
115
+ size: number,
116
+ options: ByteCaptureOptions,
117
+ ): Uint8Array {
118
+ if (options.overflow === "reject" && size + bytes.byteLength > options.limit)
119
+ throw options.exceeded();
120
+ return bytes.subarray(0, Math.max(0, options.limit - size));
121
+ }
122
+
123
+ /** Truncation retains a byte prefix while still draining the entire stream. */
124
+ export async function captureBoundedBytes(
125
+ stream: ReadableStream<Uint8Array>,
126
+ options: ByteCaptureOptions,
127
+ ): Promise<{ bytes: Uint8Array; truncated: boolean }> {
128
+ const chunks: Uint8Array[] = [];
129
+ let size = 0;
130
+ let truncated = false;
131
+ for await (const bytes of stream.values({
132
+ preventCancel: !options.cancelOnError,
133
+ })) {
134
+ options.observe?.(bytes);
135
+ const retained = retainedChunk(bytes, size, options);
136
+ if (retained.byteLength) chunks.push(retained);
137
+ size += retained.byteLength;
138
+ truncated ||= retained.byteLength !== bytes.byteLength;
139
+ }
140
+ return { bytes: Buffer.concat(chunks), truncated };
141
+ }
142
+
143
+ /** Reject oversize output before strictly decoding the complete UTF-8 stream. */
144
+ export async function captureBoundedText(
145
+ stream: ReadableStream<Uint8Array>,
146
+ options: CaptureOptions & RejectedCapture,
147
+ ): Promise<string> {
148
+ const { bytes } = await captureBoundedBytes(stream, options);
149
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
150
+ }
package/src/provenance.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  import type { Stats } from "node:fs";
2
2
  import { lstat, readFile, readdir, readlink, realpath } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
- import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { dirname, join, relative, resolve, sep } from "node:path";
5
+ import { insideRoot } from "./paths";
5
6
  import { pathToFileURL } from "node:url";
6
7
  import packageJson from "../package.json";
7
8
  import { hashJson, sha256 } from "./identity";
9
+ import { insideRuntimeRoot } from "./runtime/paths";
8
10
 
9
11
  const MAX_DIRTY_BYTES = 32 * 1024 * 1024;
10
12
  const MAX_BUILD_BYTES = 64 * 1024 * 1024;
@@ -147,7 +149,10 @@ function git(root: string, ...arguments_: string[]): Buffer {
147
149
  return Buffer.from(result.stdout);
148
150
  }
149
151
 
150
- async function dirtyPatchDigest(gitRoot: string): Promise<string | null> {
152
+ async function dirtyPatchDigest(
153
+ gitRoot: string,
154
+ excludedRoots: string[],
155
+ ): Promise<string | null> {
151
156
  const trackedPatch = git(gitRoot, "diff", "--binary", "HEAD", "--");
152
157
  const untrackedPaths = git(
153
158
  gitRoot,
@@ -159,6 +164,12 @@ async function dirtyPatchDigest(gitRoot: string): Promise<string | null> {
159
164
  .toString("utf8")
160
165
  .split("\0")
161
166
  .filter(Boolean)
167
+ .filter(
168
+ (path) =>
169
+ !excludedRoots.some((root) =>
170
+ insideRuntimeRoot(root, resolve(gitRoot, path)),
171
+ ),
172
+ )
162
173
  .sort();
163
174
  let dirtyBytes = trackedPatch.byteLength;
164
175
  const untracked: { path: string; sha256: string }[] = [];
@@ -175,8 +186,7 @@ async function dirtyPatchDigest(gitRoot: string): Promise<string | null> {
175
186
  }
176
187
 
177
188
  function requireSafeUntrackedPath(root: string, absolute: string): void {
178
- const path = relative(root, absolute);
179
- if (isAbsolute(path) || path === ".." || path.startsWith(`..${sep}`))
189
+ if (!insideRoot(root, absolute))
180
190
  throw new Error("Git checkout has an unsafe untracked path");
181
191
  }
182
192
  async function untrackedIdentityBytes(
@@ -209,7 +219,7 @@ export async function checkoutProvenance(root: string, buildDigest: string) {
209
219
  source: "checkout" as const,
210
220
  root: pathToFileURL(canonicalRoot).href,
211
221
  revision,
212
- dirtyPatchDigest: await dirtyPatchDigest(canonicalRoot),
222
+ dirtyPatchDigest: await dirtyPatchDigest(canonicalRoot, []),
213
223
  buildDigest,
214
224
  };
215
225
  }
@@ -249,7 +259,10 @@ async function emptyTemporaryGitPlaceholder(
249
259
  );
250
260
  }
251
261
 
252
- export async function projectProvenance(root: string) {
262
+ export async function projectProvenance(
263
+ root: string,
264
+ excludedRoots?: string[],
265
+ ) {
253
266
  const canonicalRoot = await realpath(root);
254
267
  const gitEntryRoot = await enclosingGitRoot(canonicalRoot);
255
268
  if (!gitEntryRoot)
@@ -279,10 +292,17 @@ export async function projectProvenance(root: string) {
279
292
  return {
280
293
  root: pathToFileURL(canonicalRoot).href,
281
294
  revision,
282
- dirtyPatchDigest: await dirtyPatchDigest(gitRoot),
295
+ dirtyPatchDigest: await projectDirtyDigest(gitRoot, excludedRoots),
283
296
  };
284
297
  }
285
298
 
299
+ async function projectDirtyDigest(
300
+ root: string,
301
+ excludedRoots: string[] | undefined,
302
+ ) {
303
+ return dirtyPatchDigest(root, excludedRoots ?? []);
304
+ }
305
+
286
306
  export async function runnerProvenance(
287
307
  buildDigest: string,
288
308
  checkoutRoot?: string,