@bjoernrochel/sevro 0.1.0-rc.2 → 0.1.0-rc.21

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 (243) hide show
  1. package/CONTRIBUTING.md +170 -0
  2. package/LICENSE +63 -136
  3. package/README.md +83 -66
  4. package/docs/README.md +44 -0
  5. package/docs/acknowledgements.md +14 -0
  6. package/docs/architecture.md +146 -0
  7. package/docs/assets/README.md +27 -0
  8. package/docs/assets/sevro-logo.png +0 -0
  9. package/docs/assets/sevro-symbol.svg +1 -0
  10. package/docs/assets/sevro-wordmark.svg +1 -0
  11. package/docs/builtin-graders-v1.md +41 -13
  12. package/docs/codex-host.md +184 -52
  13. package/docs/contracts.md +19 -0
  14. package/docs/creating-cases.md +139 -0
  15. package/docs/development-cli.md +190 -30
  16. package/docs/documentation-quality.md +61 -0
  17. package/docs/evaluation-workflow.md +110 -0
  18. package/docs/evidence/code-mode-controls-71.md +64 -0
  19. package/docs/evidence/code-mode-programs-85.md +64 -0
  20. package/docs/evidence/codex-state-73/README.md +204 -0
  21. package/docs/evidence/codex-state-73/darrow-migration.patch +182 -0
  22. package/docs/evidence/codex-state-73/results.json +661 -0
  23. package/docs/evidence/local-http-74.md +104 -0
  24. package/docs/evidence/runtime-startup-30/README.md +185 -0
  25. package/docs/evidence/runtime-startup-30/after.json +319 -0
  26. package/docs/evidence/runtime-startup-30/baseline-timeout.jsonl +2 -0
  27. package/docs/evidence/runtime-startup-30/before.json +403 -0
  28. package/docs/evidence/runtime-startup-30/consumer-smoke.json +63 -0
  29. package/docs/evidence/runtime-startup-30/full-policy-diagnostic.jsonl +3 -0
  30. package/docs/evidence/runtime-startup-30/minimal-policy-diagnostic.jsonl +3 -0
  31. package/docs/evidence/runtime-startup-30/plugin-template-assessment.json +20 -0
  32. package/docs/evidence/runtime-startup-30/seedless-timeout.jsonl +2 -0
  33. package/docs/evidence/skill-reads-70/results.json +823 -0
  34. package/docs/evidence.md +21 -0
  35. package/docs/extension-protocol-v1.md +50 -16
  36. package/docs/fixture-cleanup-validation.md +3 -0
  37. package/docs/getting-started.md +102 -0
  38. package/docs/guide-evaluation.md +89 -0
  39. package/docs/guide-verification.json +239 -0
  40. package/docs/identity-v1.md +35 -1
  41. package/docs/installing.md +88 -0
  42. package/docs/licenses/BUSL-1.1-0.1.0-rc.2.txt +159 -0
  43. package/docs/licensing.md +42 -0
  44. package/docs/native-controls.md +76 -5
  45. package/docs/native-hosts.md +228 -0
  46. package/docs/reading-results.md +141 -0
  47. package/docs/recipes.md +489 -0
  48. package/docs/releases.md +81 -40
  49. package/docs/report-v1.md +29 -1
  50. package/docs/results-v1.md +180 -0
  51. package/docs/run-settings-v1.md +147 -0
  52. package/docs/running-evaluations.md +31 -0
  53. package/docs/runtime-v1.md +614 -0
  54. package/docs/specs/repository-guide.md +58 -0
  55. package/docs/troubleshooting.md +30 -0
  56. package/docs/typescript-quality.md +234 -0
  57. package/docs/usability-study.md +114 -0
  58. package/examples/basic/host.ts +2 -2
  59. package/examples/recipes/candidate.ts +26 -0
  60. package/examples/recipes/cases/coding-change.json +21 -0
  61. package/examples/recipes/cases/host-tools.json +21 -0
  62. package/examples/recipes/cases/mise.json +21 -0
  63. package/examples/recipes/cases/node.json +21 -0
  64. package/examples/recipes/cases/python-uv.json +21 -0
  65. package/examples/recipes/cases/semantic.json +17 -0
  66. package/examples/recipes/coding-change/check.ts +23 -0
  67. package/examples/recipes/extension-artifacts.ts +65 -0
  68. package/examples/recipes/extension-methods.ts +174 -0
  69. package/examples/recipes/extension.ts +37 -0
  70. package/examples/recipes/repository-skill/.agents/skills/startup-guide/SKILL.md +7 -0
  71. package/examples/recipes/repository-skill/.claude/skills/startup-guide/SKILL.md +7 -0
  72. package/examples/recipes/repository-skill/README.md +3 -0
  73. package/examples/recipes/run-inputs.ts +188 -0
  74. package/examples/recipes/run-options.ts +131 -0
  75. package/examples/recipes/run.ts +63 -0
  76. package/examples/recipes/runtime/empty.json +1 -0
  77. package/examples/recipes/runtime/goals.json +1 -0
  78. package/examples/recipes/runtime/mise.json +6 -0
  79. package/examples/recipes/runtime/path.json +1 -0
  80. package/examples/recipes/runtime/plugins.json +4 -0
  81. package/examples/recipes/runtime/python-uv.json +10 -0
  82. package/examples/recipes/semantic-judge.ts +29 -0
  83. package/examples/templates/coding/adapter.ts +17 -0
  84. package/examples/templates/coding/case.json +15 -0
  85. package/examples/templates/repository-skill/.agents/skills/startup-guide/SKILL.md +6 -0
  86. package/examples/templates/repository-skill/extension-data.ts +64 -0
  87. package/examples/templates/repository-skill/extension.ts +31 -0
  88. package/examples/templates/response/adapter.ts +12 -0
  89. package/examples/templates/response/case.json +13 -0
  90. package/package.json +39 -6
  91. package/schemas/report-v1.schema.json +9 -0
  92. package/schemas/run-evidence-v1.schema.json +2 -1
  93. package/schemas/runtime-v1.schema.json +193 -0
  94. package/src/advisory.ts +10 -5
  95. package/src/candidate-artifacts.ts +208 -0
  96. package/src/candidate-codex-state.ts +185 -0
  97. package/src/case-validation.ts +105 -0
  98. package/src/cli/cancellation.ts +20 -0
  99. package/src/cli/command.ts +81 -0
  100. package/src/cli/completion.ts +15 -0
  101. package/src/cli/demo.ts +59 -0
  102. package/src/cli/evaluation.ts +55 -0
  103. package/src/cli/extension.ts +79 -0
  104. package/src/cli/help.ts +222 -0
  105. package/src/cli/hosts.ts +59 -0
  106. package/src/cli/init-settings.ts +30 -0
  107. package/src/cli/init.ts +111 -0
  108. package/src/cli/invocation.ts +627 -0
  109. package/src/cli/loading.ts +151 -0
  110. package/src/cli/preparation.ts +163 -0
  111. package/src/cli/presentation.ts +91 -0
  112. package/src/cli/progress.ts +6 -0
  113. package/src/cli/run-manifest.ts +133 -0
  114. package/src/cli/run-options.ts +101 -0
  115. package/src/cli/run-overrides.ts +83 -0
  116. package/src/cli/settings.ts +160 -0
  117. package/src/cli/summary.ts +43 -0
  118. package/src/cli.ts +13 -843
  119. package/src/contract-types.ts +94 -0
  120. package/src/engine.ts +19 -2373
  121. package/src/evaluation/advisory.ts +150 -0
  122. package/src/evaluation/check-plan.ts +510 -0
  123. package/src/evaluation/collection.ts +88 -0
  124. package/src/evaluation/context.ts +10 -0
  125. package/src/evaluation/grading-host.ts +39 -0
  126. package/src/evaluation/grading.ts +258 -0
  127. package/src/evaluation/invocation-plan.ts +548 -0
  128. package/src/evaluation/plan.ts +736 -0
  129. package/src/evaluation/progress.ts +19 -0
  130. package/src/evaluation/resources.ts +241 -0
  131. package/src/evaluation/run.ts +206 -0
  132. package/src/{trial-scheduler.ts → evaluation/scheduler.ts} +3 -1
  133. package/src/evaluation/semantic.ts +312 -0
  134. package/src/evaluation/trial.ts +866 -0
  135. package/src/evaluation/types.ts +207 -0
  136. package/src/evidence/host-results.ts +92 -0
  137. package/src/evidence/retained-artifacts.ts +18 -0
  138. package/src/evidence/trial-writer.ts +203 -0
  139. package/src/evidence/usage.ts +17 -0
  140. package/src/extension-client.ts +228 -148
  141. package/src/extension-session.ts +326 -195
  142. package/src/fixtures/advisory.ts +338 -0
  143. package/src/fixtures/bin.ts +57 -0
  144. package/src/{fixture-cleanup.ts → fixtures/cleanup.ts} +13 -7
  145. package/src/fixtures/create.ts +187 -0
  146. package/src/fixtures/generated.ts +300 -0
  147. package/src/fixtures/git-hooks.ts +24 -0
  148. package/src/fixtures/preparation.ts +270 -0
  149. package/src/{repository-fixture.ts → fixtures/repository.ts} +207 -85
  150. package/src/fixtures/setup.ts +224 -0
  151. package/src/fixtures/tools.ts +88 -0
  152. package/src/fixtures/workspace-state.ts +212 -0
  153. package/src/generated/cli-result.cjs +2 -0
  154. package/src/generated/cli-result.d.cts +3 -0
  155. package/src/generated/extension.cjs +2 -0
  156. package/src/generated/extension.d.cts +3 -0
  157. package/src/generated/report.cjs +2 -0
  158. package/src/generated/report.d.cts +3 -0
  159. package/src/generated/run-evidence.cjs +2 -0
  160. package/src/generated/run-evidence.d.cts +3 -0
  161. package/src/generated/runtime.cjs +2 -0
  162. package/src/generated/runtime.d.cts +3 -0
  163. package/src/graders/artifact.ts +56 -23
  164. package/src/graders/git-head.ts +79 -75
  165. package/src/graders/output.ts +222 -140
  166. package/src/graders/semantic.ts +102 -43
  167. package/src/graders/shell.ts +379 -207
  168. package/src/host-diagnostic-redaction.ts +52 -0
  169. package/src/host-diagnostics.ts +319 -0
  170. package/src/hosts/claude-continuation.ts +63 -18
  171. package/src/hosts/claude-credential.ts +43 -62
  172. package/src/hosts/claude-events.ts +86 -51
  173. package/src/hosts/claude-keychain.ts +59 -0
  174. package/src/hosts/claude-native-goal.ts +57 -33
  175. package/src/hosts/claude-process.ts +111 -0
  176. package/src/hosts/claude-repository-invocation.ts +208 -117
  177. package/src/hosts/claude-settings.ts +240 -21
  178. package/src/hosts/claude-skill-activations.ts +166 -0
  179. package/src/hosts/claude-state.ts +403 -0
  180. package/src/hosts/claude-tool-calls.ts +171 -106
  181. package/src/hosts/claude.ts +561 -451
  182. package/src/hosts/codex-app-server.ts +326 -178
  183. package/src/hosts/codex-configuration.ts +44 -18
  184. package/src/hosts/codex-events.ts +97 -60
  185. package/src/hosts/codex-exec-controls.ts +516 -0
  186. package/src/hosts/codex-invocation.ts +70 -0
  187. package/src/hosts/codex-marketplace.ts +283 -0
  188. package/src/hosts/codex-native-calls.ts +546 -500
  189. package/src/hosts/codex-process.ts +202 -0
  190. package/src/hosts/codex-profile.ts +257 -28
  191. package/src/hosts/codex-session-files.ts +106 -0
  192. package/src/hosts/codex-state.ts +492 -0
  193. package/src/hosts/codex.ts +783 -832
  194. package/src/hosts/isolation-common.ts +94 -0
  195. package/src/hosts/isolation-roots.ts +54 -27
  196. package/src/hosts/isolation.ts +10 -0
  197. package/src/hosts/linux-sandbox.ts +159 -0
  198. package/src/hosts/mac-sandbox.ts +187 -61
  199. package/src/hosts/native-controls.ts +152 -62
  200. package/src/hosts/process-lifecycle.ts +37 -0
  201. package/src/hosts/protected-worktrees.ts +51 -19
  202. package/src/hosts/skill-hook-catalog.ts +131 -0
  203. package/src/hosts/skill-hook-handler.ts +374 -0
  204. package/src/hosts/skill-hooks.ts +580 -0
  205. package/src/hosts/workspace-fingerprint.ts +55 -35
  206. package/src/identity.ts +102 -48
  207. package/src/instrumentation.ts +109 -48
  208. package/src/native-transcript-state.ts +131 -0
  209. package/src/native-transcript-view.ts +90 -0
  210. package/src/native-transcripts.ts +286 -0
  211. package/src/process-lifecycle.ts +150 -0
  212. package/src/provenance.ts +161 -81
  213. package/src/prune.ts +215 -0
  214. package/src/report.ts +172 -140
  215. package/src/resolved-case.ts +33 -0
  216. package/src/results-lock.ts +29 -0
  217. package/src/results.ts +133 -86
  218. package/src/run-owner.ts +154 -63
  219. package/src/runtime/config.ts +456 -0
  220. package/src/runtime/discovery-apple.ts +90 -0
  221. package/src/runtime/discovery-providers.ts +63 -0
  222. package/src/runtime/discovery.ts +70 -0
  223. package/src/runtime/hook-processes.ts +97 -0
  224. package/src/runtime/hooks.ts +476 -0
  225. package/src/runtime/paths.ts +313 -0
  226. package/src/runtime/query.ts +85 -0
  227. package/src/runtime/seeds.ts +134 -0
  228. package/src/runtime/shared-cache.ts +143 -0
  229. package/src/runtime/state.ts +143 -0
  230. package/src/schema-types.ts +198 -0
  231. package/src/schema.ts +15 -19
  232. package/src/transcript-retention.ts +146 -0
  233. package/src/value-guards.ts +20 -0
  234. package/src/advisory-fixture.ts +0 -282
  235. package/src/fixture-bin.ts +0 -87
  236. package/src/fixture-setup.ts +0 -163
  237. package/src/generated-fixture.ts +0 -220
  238. package/src/git-hooks.ts +0 -63
  239. package/src/hosts/claude-nested-skills.ts +0 -218
  240. package/src/hosts/codex-command-output.ts +0 -399
  241. package/src/hosts/codex-executor-command.ts +0 -195
  242. package/src/hosts/codex-skill-reads.ts +0 -391
  243. package/src/preparation.ts +0 -211
@@ -1,22 +1,20 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import Ajv2020 from "ajv/dist/2020.js";
3
- import extensionSchema from "../schemas/extension-v1.schema.json";
2
+ import validExchange from "./generated/extension.cjs";
3
+ import { isRecord, isStringArray } from "./value-guards";
4
+ import {
5
+ captureBoundedBytes,
6
+ ProcessDeadline,
7
+ terminateOwnedProcessGroup,
8
+ } from "./process-lifecycle";
4
9
 
5
10
  const MAX_MESSAGE_BYTES = 8 * 1024 * 1024;
6
11
  const MAX_DIAGNOSTIC_BYTES = 64 * 1024;
7
12
  const DEFAULT_TIMEOUT_MS = 30_000;
8
13
  const PROTOCOL = "sevro.extension.v1";
9
14
 
10
- const ajv = new Ajv2020({
11
- strict: true,
12
- strictRequired: false,
13
- strictTypes: false,
14
- });
15
- const validExchange = ajv.compile(extensionSchema);
16
-
17
15
  export class ExtensionProtocolError extends Error {
18
- constructor(message: string) {
19
- super(message);
16
+ constructor(message: string, options?: ErrorOptions) {
17
+ super(message, options);
20
18
  this.name = "ExtensionProtocolError";
21
19
  }
22
20
  }
@@ -42,163 +40,218 @@ export interface ExchangeOptions {
42
40
  signal?: AbortSignal;
43
41
  }
44
42
 
45
- async function readBounded(
46
- stream: ReadableStream<Uint8Array>,
47
- limit: number,
48
- truncate: boolean,
49
- ): Promise<Uint8Array> {
50
- const reader = stream.getReader();
51
- const chunks: Uint8Array[] = [];
52
- let size = 0;
53
- while (true) {
54
- const { value, done } = await reader.read();
55
- if (done) break;
56
- if (!truncate && size + value.byteLength > limit)
57
- throw new ExtensionProtocolError("extension message exceeds 8 MiB");
58
- const kept = value.subarray(0, Math.max(0, limit - size));
59
- if (kept.byteLength) chunks.push(kept);
60
- size += kept.byteLength;
61
- }
62
- const result = new Uint8Array(size);
63
- let offset = 0;
64
- for (const chunk of chunks) {
65
- result.set(chunk, offset);
66
- offset += chunk.byteLength;
67
- }
68
- return result;
43
+ interface SuccessfulExtensionResponse extends ExtensionResponse {
44
+ result: Record<string, unknown>;
45
+ error?: never;
69
46
  }
70
47
 
71
- function terminate(proc: Bun.Subprocess): void {
72
- if (process.platform !== "win32") {
73
- try {
74
- process.kill(-proc.pid, "SIGKILL");
75
- return;
76
- } catch {
77
- // The group may have exited while the runner was handling the error.
78
- }
79
- }
80
- try {
81
- proc.kill("SIGKILL");
82
- } catch {
83
- // The process already exited.
84
- }
85
- }
86
-
87
- /** Run exactly one request in a fresh extension process. */
88
- export async function exchangeExtension(
89
- command: string[],
90
- request: ExtensionRequest,
91
- options: ExchangeOptions = {},
92
- ): Promise<ExtensionResponse> {
48
+ function validateExtensionCommand(command: string[]): void {
93
49
  if (!command.length || command.some((part) => !part))
94
50
  throw new ExtensionProtocolError(
95
51
  "extension command must be a nonempty argv array",
96
52
  );
97
- if (!validExchange(request))
98
- throw new ExtensionProtocolError("invalid extension request");
99
- if (options.signal?.aborted)
100
- throw new ExtensionProtocolError("extension request cancelled");
101
- const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
102
- if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0)
53
+ }
54
+
55
+ function extensionTimeout(value: number | undefined): number {
56
+ const timeout = value ?? DEFAULT_TIMEOUT_MS;
57
+ if (!Number.isSafeInteger(timeout) || timeout <= 0)
103
58
  throw new ExtensionProtocolError(
104
59
  "extension timeout must be a positive integer",
105
60
  );
61
+ return timeout;
62
+ }
63
+
64
+ function extensionPayload(request: ExtensionRequest): Uint8Array {
106
65
  const payload = new TextEncoder().encode(JSON.stringify(request));
107
66
  if (payload.byteLength > MAX_MESSAGE_BYTES)
108
67
  throw new ExtensionProtocolError("extension request exceeds 8 MiB");
68
+ return payload;
69
+ }
109
70
 
110
- let proc: Bun.Subprocess;
71
+ function spawnExtension(
72
+ command: string[],
73
+ options: ExchangeOptions,
74
+ ): Bun.Subprocess {
111
75
  try {
112
- proc = Bun.spawn(command, {
76
+ return Bun.spawn(command, {
113
77
  cwd: options.cwd,
114
78
  stdin: "pipe",
115
79
  stdout: "pipe",
116
80
  stderr: "pipe",
117
81
  detached: process.platform !== "win32",
118
82
  });
119
- } catch {
120
- throw new ExtensionProtocolError("could not start extension process");
83
+ } catch (cause) {
84
+ throw new ExtensionProtocolError("could not start extension process", {
85
+ cause,
86
+ });
121
87
  }
88
+ }
89
+
90
+ function extensionWriter(proc: Bun.Subprocess) {
91
+ const stdin = proc.stdin;
92
+ if (!stdin || typeof stdin === "number")
93
+ throw new ExtensionProtocolError("extension pipes are unavailable");
94
+ return stdin;
95
+ }
96
+
97
+ function extensionReaders(proc: Bun.Subprocess) {
98
+ const { stdout, stderr } = proc;
99
+ if (
100
+ !stdout ||
101
+ typeof stdout === "number" ||
102
+ !stderr ||
103
+ typeof stderr === "number"
104
+ )
105
+ throw new ExtensionProtocolError("extension pipes are unavailable");
106
+ return { stdout, stderr };
107
+ }
122
108
 
123
- let timer: ReturnType<typeof setTimeout> | undefined;
124
- let cancel: (() => void) | undefined;
109
+ async function writeExtensionRequest(
110
+ stdin: ReturnType<typeof extensionWriter>,
111
+ payload: Uint8Array,
112
+ ): Promise<void> {
113
+ await stdin.write(payload);
114
+ await stdin.end();
115
+ }
116
+
117
+ function hasResponseBody(value: Record<string, unknown>): boolean {
118
+ return Object.hasOwn(value, "result") || Object.hasOwn(value, "error");
119
+ }
120
+
121
+ function assertExtensionResponse(
122
+ value: unknown,
123
+ ): asserts value is ExtensionResponse {
124
+ if (!validExchange(value) || !isRecord(value) || !hasResponseBody(value))
125
+ throw new ExtensionProtocolError("invalid extension response");
126
+ }
127
+
128
+ function parseExtensionResponse(bytes: Uint8Array): ExtensionResponse {
129
+ let response: unknown;
125
130
  try {
126
- const { stdin, stdout, stderr } = proc;
127
- if (
128
- !stdin ||
129
- typeof stdin === "number" ||
130
- !stdout ||
131
- typeof stdout === "number" ||
132
- !stderr ||
133
- typeof stderr === "number"
134
- )
135
- throw new ExtensionProtocolError("extension pipes are unavailable");
136
- const write = (async () => {
137
- await stdin.write(payload);
138
- await stdin.end();
139
- })();
140
- const output = (async () => {
141
- const [responseBytes, _diagnostic, exitCode] = await Promise.all([
142
- readBounded(stdout, MAX_MESSAGE_BYTES, false),
143
- readBounded(stderr, MAX_DIAGNOSTIC_BYTES, true),
144
- proc.exited,
145
- write,
146
- ]);
147
- if (exitCode !== 0)
148
- throw new ExtensionProtocolError(
149
- `extension exited with code ${exitCode}`,
150
- );
151
- let response: unknown;
152
- try {
153
- response = JSON.parse(
154
- new TextDecoder("utf-8", { fatal: true }).decode(responseBytes),
155
- );
156
- } catch {
157
- throw new ExtensionProtocolError(
158
- "extension did not return one UTF-8 JSON response",
159
- );
160
- }
161
- if (!validExchange(response))
162
- throw new ExtensionProtocolError("invalid extension response");
163
- const envelope = response as unknown as ExtensionResponse;
164
- if (
165
- envelope.id !== request.id ||
166
- envelope.protocol !== request.protocol ||
167
- envelope.method !== request.method
168
- )
169
- throw new ExtensionProtocolError(
170
- "extension response does not match request",
171
- );
172
- if (envelope.error)
173
- throw new ExtensionProtocolError(
174
- `extension reported ${envelope.error.code}`,
175
- );
176
- return envelope;
177
- })();
178
- const timeout = new Promise<never>((_resolve, reject) => {
179
- timer = setTimeout(
180
- () => reject(new ExtensionProtocolError("extension timed out")),
181
- timeoutMs,
182
- );
183
- });
184
- const aborted = new Promise<never>((_resolve, reject) => {
185
- if (!options.signal) return;
186
- cancel = () =>
187
- reject(new ExtensionProtocolError("extension request cancelled"));
188
- if (options.signal.aborted) cancel();
189
- else options.signal.addEventListener("abort", cancel, { once: true });
131
+ response = JSON.parse(
132
+ new TextDecoder("utf-8", { fatal: true }).decode(bytes),
133
+ );
134
+ } catch (cause) {
135
+ throw new ExtensionProtocolError(
136
+ "extension did not return one UTF-8 JSON response",
137
+ { cause },
138
+ );
139
+ }
140
+ assertExtensionResponse(response);
141
+ return response;
142
+ }
143
+
144
+ function validateResponseIdentity(
145
+ response: ExtensionResponse,
146
+ request: ExtensionRequest,
147
+ ): void {
148
+ if (
149
+ response.id !== request.id ||
150
+ response.protocol !== request.protocol ||
151
+ response.method !== request.method
152
+ )
153
+ throw new ExtensionProtocolError(
154
+ "extension response does not match request",
155
+ );
156
+ }
157
+
158
+ function assertSuccessfulResponse(
159
+ response: ExtensionResponse,
160
+ ): asserts response is SuccessfulExtensionResponse {
161
+ if (response.error)
162
+ throw new ExtensionProtocolError(
163
+ `extension reported ${response.error.code}`,
164
+ );
165
+ if (response.result === undefined)
166
+ throw new ExtensionProtocolError("invalid extension response");
167
+ }
168
+
169
+ class ExtensionExchange {
170
+ private readonly deadline: ProcessDeadline;
171
+ constructor(
172
+ private readonly proc: Bun.Subprocess,
173
+ options: ExchangeOptions,
174
+ timeoutMs: number,
175
+ ) {
176
+ this.deadline = new ProcessDeadline({
177
+ timeoutMs,
178
+ signal: options.signal,
179
+ interruption: (reason) =>
180
+ new ExtensionProtocolError(
181
+ reason === "timeout"
182
+ ? "extension timed out"
183
+ : "extension request cancelled",
184
+ ),
190
185
  });
191
- return await Promise.race([output, timeout, aborted]);
192
- } catch (error) {
193
- terminate(proc);
194
- if (error instanceof ExtensionProtocolError) throw error;
195
- throw new ExtensionProtocolError("extension transport failed");
196
- } finally {
197
- if (timer) clearTimeout(timer);
198
- if (cancel) options.signal?.removeEventListener("abort", cancel);
186
+ }
187
+
188
+ private async output(
189
+ request: ExtensionRequest,
190
+ payload: Uint8Array,
191
+ ): Promise<SuccessfulExtensionResponse> {
192
+ const stdin = extensionWriter(this.proc);
193
+ const { stdout, stderr } = extensionReaders(this.proc);
194
+ const write = writeExtensionRequest(stdin, payload);
195
+ const [{ bytes }, , exitCode] = await Promise.all([
196
+ captureBoundedBytes(stdout, {
197
+ limit: MAX_MESSAGE_BYTES,
198
+ overflow: "reject",
199
+ exceeded: () =>
200
+ new ExtensionProtocolError("extension message exceeds 8 MiB"),
201
+ }),
202
+ captureBoundedBytes(stderr, {
203
+ limit: MAX_DIAGNOSTIC_BYTES,
204
+ overflow: "truncate",
205
+ }),
206
+ this.proc.exited,
207
+ write,
208
+ ]);
209
+ if (exitCode !== 0)
210
+ throw new ExtensionProtocolError(
211
+ `extension exited with code ${exitCode}`,
212
+ );
213
+ const response = parseExtensionResponse(bytes);
214
+ validateResponseIdentity(response, request);
215
+ assertSuccessfulResponse(response);
216
+ return response;
217
+ }
218
+
219
+ async run(
220
+ request: ExtensionRequest,
221
+ payload: Uint8Array,
222
+ ): Promise<SuccessfulExtensionResponse> {
223
+ try {
224
+ return await this.deadline.wait(this.output(request, payload));
225
+ } catch (cause) {
226
+ terminateOwnedProcessGroup(this.proc);
227
+ if (cause instanceof ExtensionProtocolError) throw cause;
228
+ throw new ExtensionProtocolError("extension transport failed", { cause });
229
+ } finally {
230
+ this.deadline.dispose();
231
+ }
199
232
  }
200
233
  }
201
234
 
235
+ /** Run exactly one request in a fresh extension process. */
236
+ export async function exchangeExtension(
237
+ command: string[],
238
+ request: ExtensionRequest,
239
+ options: ExchangeOptions = {},
240
+ ): Promise<SuccessfulExtensionResponse> {
241
+ validateExtensionCommand(command);
242
+ if (!validExchange(request))
243
+ throw new ExtensionProtocolError("invalid extension request");
244
+ if (options.signal?.aborted)
245
+ throw new ExtensionProtocolError("extension request cancelled");
246
+ const timeout = extensionTimeout(options.timeoutMs);
247
+ const payload = extensionPayload(request);
248
+ return new ExtensionExchange(
249
+ spawnExtension(command, options),
250
+ options,
251
+ timeout,
252
+ ).run(request, payload);
253
+ }
254
+
202
255
  export interface NegotiatedExtension {
203
256
  id: string;
204
257
  version: string;
@@ -208,6 +261,33 @@ export interface NegotiatedExtension {
208
261
  taskVerdictPolicies: string[];
209
262
  }
210
263
 
264
+ function descriptionStrings(value: unknown): string[] {
265
+ if (!isStringArray(value))
266
+ throw new ExtensionProtocolError("invalid extension response");
267
+ return value;
268
+ }
269
+
270
+ function describedIdentity(value: unknown): { id: string; version: string } {
271
+ if (
272
+ !isRecord(value) ||
273
+ typeof value.id !== "string" ||
274
+ typeof value.version !== "string"
275
+ )
276
+ throw new ExtensionProtocolError("invalid extension response");
277
+ return { id: value.id, version: value.version };
278
+ }
279
+
280
+ function descriptionResult(value: Record<string, unknown>) {
281
+ return {
282
+ protocols: descriptionStrings(value.protocols),
283
+ requiredCapabilities: descriptionStrings(value.requiredCapabilities),
284
+ optionalCapabilities: descriptionStrings(value.optionalCapabilities),
285
+ extension: describedIdentity(value.extension),
286
+ graders: descriptionStrings(value.graders),
287
+ taskVerdictPolicies: descriptionStrings(value.taskVerdictPolicies),
288
+ };
289
+ }
290
+
211
291
  /** Discover an extension and select the v1 protocol and available capabilities. */
212
292
  export async function negotiateExtension(
213
293
  command: string[],
@@ -228,30 +308,30 @@ export async function negotiateExtension(
228
308
  },
229
309
  options,
230
310
  );
231
- const result = response.result!;
232
- const offered = result.protocols as string[];
311
+ const result = descriptionResult(response.result);
312
+ const offered = result.protocols;
233
313
  if (!offered.includes(PROTOCOL))
234
314
  throw new ExtensionProtocolError("extension has no compatible protocol");
235
315
  const supported = new Set([
236
316
  ...available.engineCapabilities,
237
317
  ...available.hostCapabilities,
238
318
  ]);
239
- const required = result.requiredCapabilities as string[];
319
+ const required = result.requiredCapabilities;
240
320
  for (const capability of required)
241
321
  if (!supported.has(capability))
242
322
  throw new ExtensionProtocolError(
243
323
  `unsupported required capability: ${capability}`,
244
324
  );
245
- const optional = (result.optionalCapabilities as string[]).filter((item) =>
325
+ const optional = result.optionalCapabilities.filter((item) =>
246
326
  supported.has(item),
247
327
  );
248
- const extension = result.extension as { id: string; version: string };
328
+ const extension = result.extension;
249
329
  return {
250
330
  id: extension.id,
251
331
  version: extension.version,
252
332
  protocol: PROTOCOL,
253
333
  capabilities: [...new Set([...required, ...optional])],
254
- graders: result.graders as string[],
255
- taskVerdictPolicies: result.taskVerdictPolicies as string[],
334
+ graders: result.graders,
335
+ taskVerdictPolicies: result.taskVerdictPolicies,
256
336
  };
257
337
  }