@hraness/direct 0.7.5
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.
- package/LICENSE +21 -0
- package/README.md +436 -0
- package/dist/core/index.js +162 -0
- package/dist/index-1csg00w4.js +1167 -0
- package/dist/index-6mdfd2ey.js +464 -0
- package/dist/index-7n1h75n6.js +616 -0
- package/dist/index.js +232 -0
- package/dist/react.js +32 -0
- package/dist/testing/index.js +1069 -0
- package/dist/tooling/bombadil.js +2117 -0
- package/dist/tooling/browser-verification-entry.js +1499 -0
- package/dist/tooling/bundle-boundary.js +119 -0
- package/dist/web.js +605 -0
- package/package.json +179 -0
- package/skills/direct/AGENTS.md +13 -0
- package/skills/direct/SKILL.md +49 -0
- package/skills/direct/agents/openai.yaml +4 -0
- package/skills/direct/references/adoption.md +131 -0
- package/skills/direct/references/install.md +91 -0
- package/skills/direct/references/verification.md +247 -0
- package/src/core/coverage.ts +336 -0
- package/src/core/definition.ts +378 -0
- package/src/core/effects.ts +88 -0
- package/src/core/fixture.ts +185 -0
- package/src/core/ids.ts +77 -0
- package/src/core/index.ts +13 -0
- package/src/core/json-value.ts +7 -0
- package/src/core/json.ts +593 -0
- package/src/core/query.ts +230 -0
- package/src/core/reason.ts +16 -0
- package/src/core/resource.ts +10 -0
- package/src/core/result.ts +19 -0
- package/src/core/runtime.ts +229 -0
- package/src/core/scenario.ts +149 -0
- package/src/core/store.ts +784 -0
- package/src/index.ts +51 -0
- package/src/react.ts +54 -0
- package/src/testing/activity.ts +228 -0
- package/src/testing/coverage-binding.ts +99 -0
- package/src/testing/evidence.ts +59 -0
- package/src/testing/index.ts +22 -0
- package/src/testing/manifest.ts +559 -0
- package/src/testing/probe.ts +446 -0
- package/src/testing/scripted-transport.ts +775 -0
- package/src/testing/session.ts +525 -0
- package/src/tooling/bombadil-campaign.ts +288 -0
- package/src/tooling/bombadil-internal.d.ts +46 -0
- package/src/tooling/bombadil-runner.ts +1424 -0
- package/src/tooling/bombadil.ts +27 -0
- package/src/tooling/browser-verification-entry.ts +32 -0
- package/src/tooling/browser-verification.ts +916 -0
- package/src/tooling/bundle-boundary.ts +159 -0
- package/src/web/browser-bridge.ts +296 -0
- package/src/web/browser.ts +277 -0
- package/src/web/fetch-firewall.ts +251 -0
- package/src/web.ts +27 -0
|
@@ -0,0 +1,1424 @@
|
|
|
1
|
+
import { createReadStream } from "node:fs";
|
|
2
|
+
import { readFile, realpath, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
4
|
+
import process from "node:process";
|
|
5
|
+
import { createInterface } from "node:readline";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
parseDirectProbeSnapshot,
|
|
9
|
+
parseDirectSessionManifest,
|
|
10
|
+
} from "@hraness/direct/testing";
|
|
11
|
+
import {
|
|
12
|
+
FIXTURE_QUERY_KEY,
|
|
13
|
+
SCENARIO_QUERY_KEY,
|
|
14
|
+
} from "@hraness/direct";
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
acquireVerificationServer,
|
|
18
|
+
canAutomaticallyStartLocalServer,
|
|
19
|
+
createArtifactRun,
|
|
20
|
+
normalizeRootHttpOrigin,
|
|
21
|
+
renderUnknown,
|
|
22
|
+
spawnVerificationServer,
|
|
23
|
+
stopVerificationServer,
|
|
24
|
+
tail,
|
|
25
|
+
writeJsonAtomically,
|
|
26
|
+
type ManagedVerificationServer,
|
|
27
|
+
type ServerLease,
|
|
28
|
+
} from "./browser-verification.js";
|
|
29
|
+
|
|
30
|
+
const EXPECTED_BOMBADIL_VERSION = "0.7.2";
|
|
31
|
+
const DEFAULT_TIME_LIMIT_SECONDS = 20;
|
|
32
|
+
const MIN_TIME_LIMIT_SECONDS = 12;
|
|
33
|
+
const MAX_TIME_LIMIT_SECONDS = 300;
|
|
34
|
+
const DEFAULT_STARTUP_TIMEOUT_MS = 60_000;
|
|
35
|
+
const MAX_STARTUP_TIMEOUT_MS = 120_000;
|
|
36
|
+
const LOG_LIMIT = 24_000;
|
|
37
|
+
const ARTIFACT_SCHEMA = "direct.bombadil-run/v1";
|
|
38
|
+
const SCENARIO_PATTERN = /^[a-z][a-z0-9]*(?:[._/-][a-z0-9]+)*$/u;
|
|
39
|
+
const ARTIFACT_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
|
40
|
+
const ENVIRONMENT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/u;
|
|
41
|
+
const QUERY_PARAMETER_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_.-]*$/u;
|
|
42
|
+
const PROTOTYPE_PROPERTY_NAMES = new Set(["__proto__", "constructor", "prototype"]);
|
|
43
|
+
const TRACE_MAX_BYTES = 64 * 1024 * 1024;
|
|
44
|
+
const TRACE_MAX_LINE_BYTES = 16 * 1024 * 1024;
|
|
45
|
+
const TRACE_MAX_LINES = 10_000;
|
|
46
|
+
const TRACE_MAX_SNAPSHOTS_PER_LINE = 4_096;
|
|
47
|
+
const RANDOM_RUN_OVERHEAD_MS = 30_000;
|
|
48
|
+
const REPLAY_WALL_CLOCK_TIMEOUT_MS = MAX_TIME_LIMIT_SECONDS * 1_000 + RANDOM_RUN_OVERHEAD_MS;
|
|
49
|
+
const PROCESS_TERMINATION_GRACE_MS = 5_000;
|
|
50
|
+
const MIN_PROCESS_OUTPUT_DRAIN_MS = 500;
|
|
51
|
+
const SERVER_OUTPUT_TIMEOUT_MS = 3_000;
|
|
52
|
+
const DIRECT_BROWSER_BRIDGE_SCHEMA = "direct.browser-bridge/v2";
|
|
53
|
+
const TRACE_LINE_KEYS = new Set(["action", "snapshots", "state", "timestamp", "violations"]);
|
|
54
|
+
const TRACE_SNAPSHOT_KEYS = new Set(["index", "name", "time", "value"]);
|
|
55
|
+
const DIRECT_OBSERVATION_KEYS = new Set([
|
|
56
|
+
"activationHash",
|
|
57
|
+
"activeRoute",
|
|
58
|
+
"activeScenario",
|
|
59
|
+
"activeSource",
|
|
60
|
+
"bridgePresent",
|
|
61
|
+
"bridgeSchema",
|
|
62
|
+
"catalogHash",
|
|
63
|
+
"contractValid",
|
|
64
|
+
"isQuiescent",
|
|
65
|
+
"manifest",
|
|
66
|
+
"probe",
|
|
67
|
+
"violations",
|
|
68
|
+
"violationsValid",
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
export interface DirectBombadilServerConfig {
|
|
72
|
+
readonly command: readonly string[];
|
|
73
|
+
readonly cwd: string;
|
|
74
|
+
readonly env?: Readonly<Record<string, string | undefined>>;
|
|
75
|
+
readonly readinessPath?: `/${string}`;
|
|
76
|
+
readonly startupTimeoutMs?: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface DirectBombadilFuzzConfig {
|
|
80
|
+
readonly artifactName: string;
|
|
81
|
+
readonly baseUrl: string;
|
|
82
|
+
readonly entryPath?: `/${string}`;
|
|
83
|
+
readonly expectedRoute: string;
|
|
84
|
+
readonly label: string;
|
|
85
|
+
readonly repositoryRoot: string;
|
|
86
|
+
readonly scenario: string;
|
|
87
|
+
readonly specificationPath: string;
|
|
88
|
+
readonly targetQuery?: Readonly<Record<string, string>>;
|
|
89
|
+
readonly server: DirectBombadilServerConfig;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type DirectBombadilFuzzArguments =
|
|
93
|
+
| { readonly kind: "help" }
|
|
94
|
+
| {
|
|
95
|
+
readonly kind: "run";
|
|
96
|
+
readonly baseUrl: string;
|
|
97
|
+
readonly replayPath: string | null;
|
|
98
|
+
readonly timeLimitSeconds: number;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export type DirectBombadilFuzzResult =
|
|
102
|
+
| { readonly kind: "help" }
|
|
103
|
+
| {
|
|
104
|
+
readonly kind: "run";
|
|
105
|
+
readonly artifactDirectory: string;
|
|
106
|
+
readonly manifestPath: string;
|
|
107
|
+
readonly status: "passed";
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export interface DirectBombadilInvocation {
|
|
111
|
+
readonly abortSignal?: AbortSignal;
|
|
112
|
+
readonly command: readonly string[];
|
|
113
|
+
readonly cwd: string;
|
|
114
|
+
readonly outputPath: string;
|
|
115
|
+
readonly targetUrl: string;
|
|
116
|
+
readonly terminationGraceMs?: number;
|
|
117
|
+
readonly wallClockTimeoutMs: number;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
interface BombadilProcessResult {
|
|
121
|
+
readonly exitCode: number;
|
|
122
|
+
readonly stderr: string;
|
|
123
|
+
readonly stdout: string;
|
|
124
|
+
readonly termination: "aborted" | "timeout" | null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
interface TraceDirectObservation {
|
|
128
|
+
readonly activationHash: string;
|
|
129
|
+
readonly activeRoute: string;
|
|
130
|
+
readonly activeScenario: string;
|
|
131
|
+
readonly activeSource: string;
|
|
132
|
+
readonly bridgePresent: boolean;
|
|
133
|
+
readonly bridgeSchema: string;
|
|
134
|
+
readonly catalogHash: string;
|
|
135
|
+
readonly contractValid: boolean;
|
|
136
|
+
readonly isQuiescent: boolean;
|
|
137
|
+
readonly manifest: unknown;
|
|
138
|
+
readonly probe: unknown;
|
|
139
|
+
readonly violations: readonly number[];
|
|
140
|
+
readonly violationsValid: boolean;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface DirectBombadilTraceBinding {
|
|
144
|
+
readonly activationHash: string;
|
|
145
|
+
readonly catalogHash: string;
|
|
146
|
+
readonly route: string;
|
|
147
|
+
readonly scenario: string;
|
|
148
|
+
readonly source: "scenario" | "fixture";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface DirectBombadilTraceAttestation {
|
|
152
|
+
readonly schema: "direct.bombadil-trace-attestation/v1";
|
|
153
|
+
readonly catalogHash: string;
|
|
154
|
+
readonly final: DirectBombadilTraceBinding & { readonly isQuiescent: true };
|
|
155
|
+
readonly initial: DirectBombadilTraceBinding;
|
|
156
|
+
readonly invalidObservationCount: number;
|
|
157
|
+
readonly observationCount: number;
|
|
158
|
+
readonly validObservationCount: number;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface DirectBombadilRunnerDependencies {
|
|
162
|
+
readonly acquireServer: typeof acquireVerificationServer;
|
|
163
|
+
readonly now: () => Date;
|
|
164
|
+
readonly runBombadil: (
|
|
165
|
+
invocation: DirectBombadilInvocation,
|
|
166
|
+
) => Promise<BombadilProcessResult>;
|
|
167
|
+
readonly serverOutputTimeoutMs: number;
|
|
168
|
+
readonly spawnServer: (options: {
|
|
169
|
+
readonly command: readonly string[];
|
|
170
|
+
readonly cwd: string;
|
|
171
|
+
readonly env?: Readonly<Record<string, string | undefined>>;
|
|
172
|
+
}) => ManagedVerificationServer;
|
|
173
|
+
readonly stopServer: typeof stopVerificationServer;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
interface ProcessSignalEmitter {
|
|
177
|
+
readonly once: (
|
|
178
|
+
signal: NodeJS.Signals,
|
|
179
|
+
listener: (signal: NodeJS.Signals) => void,
|
|
180
|
+
) => unknown;
|
|
181
|
+
readonly removeListener: (
|
|
182
|
+
signal: NodeJS.Signals,
|
|
183
|
+
listener: (signal: NodeJS.Signals) => void,
|
|
184
|
+
) => unknown;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
interface ValidatedConfig extends DirectBombadilFuzzConfig {
|
|
188
|
+
readonly artifactRoot: string;
|
|
189
|
+
readonly baseUrl: string;
|
|
190
|
+
readonly bombadilExecutable: string;
|
|
191
|
+
readonly entryPath: `/${string}`;
|
|
192
|
+
readonly port: string;
|
|
193
|
+
readonly targetQuery: Readonly<Record<string, string>>;
|
|
194
|
+
readonly server: DirectBombadilServerConfig & {
|
|
195
|
+
readonly readinessPath: `/${string}`;
|
|
196
|
+
readonly startupTimeoutMs: number;
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function readOptionValue(
|
|
201
|
+
arguments_: readonly string[],
|
|
202
|
+
index: number,
|
|
203
|
+
option: string,
|
|
204
|
+
): { readonly index: number; readonly value: string } {
|
|
205
|
+
const value = arguments_[index + 1];
|
|
206
|
+
if (value === undefined || value.startsWith("-")) {
|
|
207
|
+
throw new Error(`${option} requires a value`);
|
|
208
|
+
}
|
|
209
|
+
return { index: index + 1, value };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function parseTimeLimit(value: string): number {
|
|
213
|
+
const match = /^([1-9][0-9]*)s$/u.exec(value);
|
|
214
|
+
if (match === null) {
|
|
215
|
+
throw new Error("--time-limit must be a whole number of seconds such as 20s");
|
|
216
|
+
}
|
|
217
|
+
const seconds = Number(match[1]);
|
|
218
|
+
if (
|
|
219
|
+
!Number.isSafeInteger(seconds)
|
|
220
|
+
|| seconds < MIN_TIME_LIMIT_SECONDS
|
|
221
|
+
|| seconds > MAX_TIME_LIMIT_SECONDS
|
|
222
|
+
) {
|
|
223
|
+
throw new Error(
|
|
224
|
+
`--time-limit must be between ${String(MIN_TIME_LIMIT_SECONDS)}s and ${String(MAX_TIME_LIMIT_SECONDS)}s`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
return seconds;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function bombadilNativeBinary(repositoryRoot: string): string {
|
|
231
|
+
let binary: string;
|
|
232
|
+
if (process.platform === "darwin" && process.arch === "arm64") {
|
|
233
|
+
binary = "bombadil-darwin-arm64";
|
|
234
|
+
} else if (process.platform === "linux" && process.arch === "x64") {
|
|
235
|
+
binary = "bombadil-linux-x64";
|
|
236
|
+
} else if (process.platform === "linux" && process.arch === "arm64") {
|
|
237
|
+
binary = "bombadil-linux-arm64";
|
|
238
|
+
} else {
|
|
239
|
+
throw new Error(`Bombadil 0.7.2 does not support ${process.platform}-${process.arch}`);
|
|
240
|
+
}
|
|
241
|
+
return join(
|
|
242
|
+
repositoryRoot,
|
|
243
|
+
"node_modules",
|
|
244
|
+
"@antithesishq",
|
|
245
|
+
"bombadil",
|
|
246
|
+
"binaries",
|
|
247
|
+
binary,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function requireLocalRootHttpOrigin(value: string): string {
|
|
252
|
+
const baseUrl = normalizeRootHttpOrigin(value);
|
|
253
|
+
const url = new URL(baseUrl);
|
|
254
|
+
if (!canAutomaticallyStartLocalServer(baseUrl)) {
|
|
255
|
+
throw new Error("--base-url must use HTTP on 127.0.0.1 or localhost");
|
|
256
|
+
}
|
|
257
|
+
if (url.port === "") {
|
|
258
|
+
throw new Error("--base-url must include an explicit local server port");
|
|
259
|
+
}
|
|
260
|
+
if (Number(url.port) < 1) {
|
|
261
|
+
throw new Error("--base-url port must be between 1 and 65535");
|
|
262
|
+
}
|
|
263
|
+
return baseUrl;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function hasControlCharacters(value: string): boolean {
|
|
267
|
+
for (const character of value) {
|
|
268
|
+
const code = character.charCodeAt(0);
|
|
269
|
+
if (code < 32 || code === 127) return true;
|
|
270
|
+
}
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
|
|
275
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function hasExactKeys(
|
|
279
|
+
value: Readonly<Record<string, unknown>>,
|
|
280
|
+
expected: ReadonlySet<string>,
|
|
281
|
+
): boolean {
|
|
282
|
+
const keys = Object.keys(value);
|
|
283
|
+
return keys.length === expected.size && keys.every((key) => expected.has(key));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function parseTraceDirectObservation(value: unknown): TraceDirectObservation {
|
|
287
|
+
if (!isRecord(value) || !hasExactKeys(value, DIRECT_OBSERVATION_KEYS)) {
|
|
288
|
+
throw new Error("Bombadil trace has an invalid named direct observation");
|
|
289
|
+
}
|
|
290
|
+
const stringKeys = [
|
|
291
|
+
"activationHash",
|
|
292
|
+
"activeRoute",
|
|
293
|
+
"activeScenario",
|
|
294
|
+
"activeSource",
|
|
295
|
+
"bridgeSchema",
|
|
296
|
+
"catalogHash",
|
|
297
|
+
] as const;
|
|
298
|
+
for (const key of stringKeys) {
|
|
299
|
+
if (typeof value[key] !== "string") {
|
|
300
|
+
throw new Error(`Bombadil trace direct observation has an invalid ${key}`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
const booleanKeys = [
|
|
304
|
+
"bridgePresent",
|
|
305
|
+
"contractValid",
|
|
306
|
+
"isQuiescent",
|
|
307
|
+
"violationsValid",
|
|
308
|
+
] as const;
|
|
309
|
+
for (const key of booleanKeys) {
|
|
310
|
+
if (typeof value[key] !== "boolean") {
|
|
311
|
+
throw new Error(`Bombadil trace direct observation has an invalid ${key}`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
if (
|
|
315
|
+
!Array.isArray(value.violations)
|
|
316
|
+
|| !value.violations.every((candidate) =>
|
|
317
|
+
typeof candidate === "number"
|
|
318
|
+
&& Number.isSafeInteger(candidate)
|
|
319
|
+
&& candidate >= 0
|
|
320
|
+
)
|
|
321
|
+
) {
|
|
322
|
+
throw new Error("Bombadil trace direct observation has invalid violation counters");
|
|
323
|
+
}
|
|
324
|
+
const activationHash = value.activationHash;
|
|
325
|
+
const activeRoute = value.activeRoute;
|
|
326
|
+
const activeScenario = value.activeScenario;
|
|
327
|
+
const activeSource = value.activeSource;
|
|
328
|
+
const bridgePresent = value.bridgePresent;
|
|
329
|
+
const bridgeSchema = value.bridgeSchema;
|
|
330
|
+
const catalogHash = value.catalogHash;
|
|
331
|
+
const contractValid = value.contractValid;
|
|
332
|
+
const isQuiescent = value.isQuiescent;
|
|
333
|
+
const violationsValid = value.violationsValid;
|
|
334
|
+
if (
|
|
335
|
+
typeof activationHash !== "string"
|
|
336
|
+
|| typeof activeRoute !== "string"
|
|
337
|
+
|| typeof activeScenario !== "string"
|
|
338
|
+
|| typeof activeSource !== "string"
|
|
339
|
+
|| typeof bridgePresent !== "boolean"
|
|
340
|
+
|| typeof bridgeSchema !== "string"
|
|
341
|
+
|| typeof catalogHash !== "string"
|
|
342
|
+
|| typeof contractValid !== "boolean"
|
|
343
|
+
|| typeof isQuiescent !== "boolean"
|
|
344
|
+
|| typeof violationsValid !== "boolean"
|
|
345
|
+
) {
|
|
346
|
+
throw new Error("Bombadil trace direct observation could not be narrowed");
|
|
347
|
+
}
|
|
348
|
+
return {
|
|
349
|
+
activationHash,
|
|
350
|
+
activeRoute,
|
|
351
|
+
activeScenario,
|
|
352
|
+
activeSource,
|
|
353
|
+
bridgePresent,
|
|
354
|
+
bridgeSchema,
|
|
355
|
+
catalogHash,
|
|
356
|
+
contractValid,
|
|
357
|
+
isQuiescent,
|
|
358
|
+
manifest: value.manifest,
|
|
359
|
+
probe: value.probe,
|
|
360
|
+
violations: value.violations,
|
|
361
|
+
violationsValid,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
type ExactTraceDirectObservation = DirectBombadilTraceBinding & {
|
|
366
|
+
readonly isQuiescent: boolean;
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
function exactTraceDirectObservation(
|
|
370
|
+
observation: TraceDirectObservation,
|
|
371
|
+
): ExactTraceDirectObservation | null {
|
|
372
|
+
if (!observation.bridgePresent) {
|
|
373
|
+
if (
|
|
374
|
+
observation.bridgeSchema !== ""
|
|
375
|
+
|| observation.manifest !== null
|
|
376
|
+
|| observation.probe !== null
|
|
377
|
+
|| observation.contractValid
|
|
378
|
+
|| observation.violationsValid
|
|
379
|
+
|| observation.isQuiescent
|
|
380
|
+
|| observation.activationHash !== ""
|
|
381
|
+
|| observation.activeRoute !== ""
|
|
382
|
+
|| observation.activeScenario !== ""
|
|
383
|
+
|| observation.activeSource !== ""
|
|
384
|
+
|| observation.catalogHash !== ""
|
|
385
|
+
|| observation.violations.length !== 0
|
|
386
|
+
) {
|
|
387
|
+
throw new Error("Bombadil trace has a malformed bridge-absent Direct observation");
|
|
388
|
+
}
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
if (observation.bridgeSchema !== DIRECT_BROWSER_BRIDGE_SCHEMA) {
|
|
392
|
+
throw new Error("Bombadil trace Direct bridge schema is invalid");
|
|
393
|
+
}
|
|
394
|
+
const manifest = parseDirectSessionManifest(observation.manifest);
|
|
395
|
+
if (!manifest.ok) {
|
|
396
|
+
throw new Error(`Bombadil trace Direct manifest is invalid: ${manifest.error.message}`);
|
|
397
|
+
}
|
|
398
|
+
const probe = parseDirectProbeSnapshot(observation.probe);
|
|
399
|
+
if (!probe.ok) {
|
|
400
|
+
throw new Error(`Bombadil trace Direct probe is invalid: ${probe.error.message}`);
|
|
401
|
+
}
|
|
402
|
+
if (manifest.value.active.activationHash !== probe.value.activationHash) {
|
|
403
|
+
throw new Error("Bombadil trace Direct manifest and probe activation hashes differ");
|
|
404
|
+
}
|
|
405
|
+
const violationValues = Object.values(probe.value.violations);
|
|
406
|
+
if (
|
|
407
|
+
!observation.contractValid
|
|
408
|
+
|| !observation.violationsValid
|
|
409
|
+
|| observation.catalogHash !== manifest.value.catalogHash
|
|
410
|
+
|| observation.activationHash !== manifest.value.active.activationHash
|
|
411
|
+
|| observation.activeRoute !== manifest.value.active.route
|
|
412
|
+
|| observation.activeScenario !== manifest.value.active.scenario
|
|
413
|
+
|| observation.activeSource !== manifest.value.active.source
|
|
414
|
+
|| observation.isQuiescent !== probe.value.isQuiescent
|
|
415
|
+
|| observation.violations.length !== violationValues.length
|
|
416
|
+
|| observation.violations.some((value, index) => value !== violationValues[index])
|
|
417
|
+
) {
|
|
418
|
+
throw new Error("Bombadil trace Direct summary does not match its exact manifest and probe");
|
|
419
|
+
}
|
|
420
|
+
return {
|
|
421
|
+
activationHash: manifest.value.active.activationHash,
|
|
422
|
+
catalogHash: manifest.value.catalogHash,
|
|
423
|
+
route: manifest.value.active.route,
|
|
424
|
+
scenario: manifest.value.active.scenario,
|
|
425
|
+
source: manifest.value.active.source,
|
|
426
|
+
isQuiescent: probe.value.isQuiescent,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function parseTraceLine(line: string, lineNumber: number): TraceDirectObservation {
|
|
431
|
+
let input: unknown;
|
|
432
|
+
try {
|
|
433
|
+
input = JSON.parse(line) as unknown;
|
|
434
|
+
} catch {
|
|
435
|
+
throw new Error(`Bombadil trace line ${String(lineNumber)} is not valid JSON`);
|
|
436
|
+
}
|
|
437
|
+
if (!isRecord(input) || !hasExactKeys(input, TRACE_LINE_KEYS)) {
|
|
438
|
+
throw new Error(`Bombadil trace line ${String(lineNumber)} has an invalid 0.7.2 envelope`);
|
|
439
|
+
}
|
|
440
|
+
if (
|
|
441
|
+
!Number.isSafeInteger(input.timestamp)
|
|
442
|
+
|| typeof input.timestamp !== "number"
|
|
443
|
+
|| input.timestamp < 0
|
|
444
|
+
|| !Array.isArray(input.snapshots)
|
|
445
|
+
|| input.snapshots.length > TRACE_MAX_SNAPSHOTS_PER_LINE
|
|
446
|
+
|| !Array.isArray(input.violations)
|
|
447
|
+
) {
|
|
448
|
+
throw new Error(`Bombadil trace line ${String(lineNumber)} has invalid state fields`);
|
|
449
|
+
}
|
|
450
|
+
const snapshots = input.snapshots as unknown[];
|
|
451
|
+
const directSnapshots = snapshots.filter((snapshot): snapshot is Readonly<Record<string, unknown>> =>
|
|
452
|
+
isRecord(snapshot) && snapshot.name === "direct"
|
|
453
|
+
);
|
|
454
|
+
if (directSnapshots.length !== 1) {
|
|
455
|
+
throw new Error(`Bombadil trace line ${String(lineNumber)} must contain one named direct snapshot`);
|
|
456
|
+
}
|
|
457
|
+
const snapshot = directSnapshots[0];
|
|
458
|
+
if (
|
|
459
|
+
snapshot === undefined
|
|
460
|
+
|| !hasExactKeys(snapshot, TRACE_SNAPSHOT_KEYS)
|
|
461
|
+
|| !Number.isSafeInteger(snapshot.index)
|
|
462
|
+
|| !Number.isSafeInteger(snapshot.time)
|
|
463
|
+
|| (snapshot.index as number) < 0
|
|
464
|
+
|| (snapshot.time as number) < 0
|
|
465
|
+
) {
|
|
466
|
+
throw new Error(`Bombadil trace line ${String(lineNumber)} has an invalid direct snapshot`);
|
|
467
|
+
}
|
|
468
|
+
return parseTraceDirectObservation(snapshot.value);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** Exact post-run proof over Bombadil 0.7.2's bounded JSONL trace. */
|
|
472
|
+
export async function attestDirectBombadilTrace(options: {
|
|
473
|
+
readonly expectedRoute: string;
|
|
474
|
+
readonly expectedScenario: string;
|
|
475
|
+
readonly tracePath: string;
|
|
476
|
+
}): Promise<DirectBombadilTraceAttestation> {
|
|
477
|
+
const metadata = await stat(options.tracePath).catch(() => null);
|
|
478
|
+
if (metadata === null || !metadata.isFile() || metadata.size === 0) {
|
|
479
|
+
throw new Error("Bombadil did not produce a nonempty trace.jsonl");
|
|
480
|
+
}
|
|
481
|
+
if (metadata.size > TRACE_MAX_BYTES) {
|
|
482
|
+
throw new Error(`Bombadil trace exceeds ${String(TRACE_MAX_BYTES)} bytes`);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const stream = createReadStream(options.tracePath, { encoding: "utf8" });
|
|
486
|
+
const lines = createInterface({ input: stream, crlfDelay: Infinity });
|
|
487
|
+
let observationCount = 0;
|
|
488
|
+
let invalidObservationCount = 0;
|
|
489
|
+
let validObservationCount = 0;
|
|
490
|
+
let initial: DirectBombadilTraceBinding | null = null;
|
|
491
|
+
let final: ExactTraceDirectObservation | null = null;
|
|
492
|
+
let finalWasInvalid = false;
|
|
493
|
+
try {
|
|
494
|
+
for await (const line of lines) {
|
|
495
|
+
observationCount += 1;
|
|
496
|
+
if (observationCount > TRACE_MAX_LINES) {
|
|
497
|
+
throw new Error(`Bombadil trace exceeds ${String(TRACE_MAX_LINES)} lines`);
|
|
498
|
+
}
|
|
499
|
+
if (Buffer.byteLength(line, "utf8") > TRACE_MAX_LINE_BYTES) {
|
|
500
|
+
throw new Error(`Bombadil trace line ${String(observationCount)} is too large`);
|
|
501
|
+
}
|
|
502
|
+
const observation = parseTraceLine(line, observationCount);
|
|
503
|
+
const exact = exactTraceDirectObservation(observation);
|
|
504
|
+
if (exact === null) {
|
|
505
|
+
if (initial !== null) {
|
|
506
|
+
throw new Error("Bombadil trace lost the Direct bridge after exact activation");
|
|
507
|
+
}
|
|
508
|
+
invalidObservationCount += 1;
|
|
509
|
+
finalWasInvalid = true;
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
validObservationCount += 1;
|
|
513
|
+
final = exact;
|
|
514
|
+
finalWasInvalid = false;
|
|
515
|
+
if (initial === null) {
|
|
516
|
+
if (
|
|
517
|
+
exact.source !== "scenario"
|
|
518
|
+
|| exact.scenario !== options.expectedScenario
|
|
519
|
+
|| exact.route !== options.expectedRoute
|
|
520
|
+
) {
|
|
521
|
+
throw new Error("Bombadil trace first valid Direct activation does not match the requested scenario and route");
|
|
522
|
+
}
|
|
523
|
+
initial = {
|
|
524
|
+
activationHash: exact.activationHash,
|
|
525
|
+
catalogHash: exact.catalogHash,
|
|
526
|
+
route: exact.route,
|
|
527
|
+
scenario: exact.scenario,
|
|
528
|
+
source: exact.source,
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
if (exact.source !== "scenario") {
|
|
532
|
+
throw new Error("Bombadil trace left scenario activation during the run");
|
|
533
|
+
}
|
|
534
|
+
if (
|
|
535
|
+
exact.scenario !== initial.scenario
|
|
536
|
+
|| exact.route !== initial.route
|
|
537
|
+
|| exact.activationHash !== initial.activationHash
|
|
538
|
+
) {
|
|
539
|
+
throw new Error("Bombadil trace Direct activation changed during the run");
|
|
540
|
+
}
|
|
541
|
+
if (exact.catalogHash !== initial.catalogHash) {
|
|
542
|
+
throw new Error("Bombadil trace Direct catalog changed during the run");
|
|
543
|
+
}
|
|
544
|
+
if (observation.violations.some((value) => value !== 0)) {
|
|
545
|
+
throw new Error("Bombadil trace contains a nonzero Direct violation counter");
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
} finally {
|
|
549
|
+
lines.close();
|
|
550
|
+
stream.destroy();
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (initial === null || final === null) {
|
|
554
|
+
throw new Error("Bombadil trace never reached a valid Direct contract");
|
|
555
|
+
}
|
|
556
|
+
if (finalWasInvalid) {
|
|
557
|
+
throw new Error("Bombadil trace ended without an installed valid Direct bridge");
|
|
558
|
+
}
|
|
559
|
+
if (!final.isQuiescent) {
|
|
560
|
+
throw new Error("Bombadil trace final Direct observation is not quiescent");
|
|
561
|
+
}
|
|
562
|
+
return {
|
|
563
|
+
schema: "direct.bombadil-trace-attestation/v1",
|
|
564
|
+
catalogHash: initial.catalogHash,
|
|
565
|
+
initial,
|
|
566
|
+
final: {
|
|
567
|
+
activationHash: final.activationHash,
|
|
568
|
+
catalogHash: final.catalogHash,
|
|
569
|
+
route: final.route,
|
|
570
|
+
scenario: final.scenario,
|
|
571
|
+
source: final.source,
|
|
572
|
+
isQuiescent: true,
|
|
573
|
+
},
|
|
574
|
+
observationCount,
|
|
575
|
+
invalidObservationCount,
|
|
576
|
+
validObservationCount,
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
export function parseDirectBombadilFuzzArguments(
|
|
581
|
+
arguments_: readonly string[],
|
|
582
|
+
defaultBaseUrl: string,
|
|
583
|
+
): DirectBombadilFuzzArguments {
|
|
584
|
+
let baseUrl = defaultBaseUrl;
|
|
585
|
+
let timeLimitSeconds = DEFAULT_TIME_LIMIT_SECONDS;
|
|
586
|
+
let replayPath: string | null = null;
|
|
587
|
+
let receivedBaseUrl = false;
|
|
588
|
+
let receivedTimeLimit = false;
|
|
589
|
+
let receivedReplay = false;
|
|
590
|
+
|
|
591
|
+
for (let index = 0; index < arguments_.length; index += 1) {
|
|
592
|
+
const argument = arguments_[index];
|
|
593
|
+
if (argument === undefined) continue;
|
|
594
|
+
if (argument === "--help" || argument === "-h") return { kind: "help" };
|
|
595
|
+
|
|
596
|
+
if (argument === "--base-url" || argument.startsWith("--base-url=")) {
|
|
597
|
+
if (receivedBaseUrl) throw new Error("--base-url may be provided only once");
|
|
598
|
+
receivedBaseUrl = true;
|
|
599
|
+
if (argument === "--base-url") {
|
|
600
|
+
const next = readOptionValue(arguments_, index, "--base-url");
|
|
601
|
+
baseUrl = next.value;
|
|
602
|
+
index = next.index;
|
|
603
|
+
} else {
|
|
604
|
+
baseUrl = argument.slice("--base-url=".length);
|
|
605
|
+
}
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if (argument === "--time-limit" || argument.startsWith("--time-limit=")) {
|
|
610
|
+
if (receivedTimeLimit) throw new Error("--time-limit may be provided only once");
|
|
611
|
+
receivedTimeLimit = true;
|
|
612
|
+
let value: string;
|
|
613
|
+
if (argument === "--time-limit") {
|
|
614
|
+
const next = readOptionValue(arguments_, index, "--time-limit");
|
|
615
|
+
value = next.value;
|
|
616
|
+
index = next.index;
|
|
617
|
+
} else {
|
|
618
|
+
value = argument.slice("--time-limit=".length);
|
|
619
|
+
}
|
|
620
|
+
timeLimitSeconds = parseTimeLimit(value);
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (argument === "--replay" || argument.startsWith("--replay=")) {
|
|
625
|
+
if (receivedReplay) throw new Error("--replay may be provided only once");
|
|
626
|
+
receivedReplay = true;
|
|
627
|
+
if (argument === "--replay") {
|
|
628
|
+
const next = readOptionValue(arguments_, index, "--replay");
|
|
629
|
+
replayPath = next.value;
|
|
630
|
+
index = next.index;
|
|
631
|
+
} else {
|
|
632
|
+
replayPath = argument.slice("--replay=".length);
|
|
633
|
+
}
|
|
634
|
+
if (replayPath.length === 0) throw new Error("--replay requires a value");
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
throw new Error(`Unknown argument at position ${String(index + 1)}`);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
if (receivedReplay && receivedTimeLimit) {
|
|
642
|
+
throw new Error("--replay and --time-limit cannot be used together");
|
|
643
|
+
}
|
|
644
|
+
return {
|
|
645
|
+
kind: "run",
|
|
646
|
+
baseUrl: requireLocalRootHttpOrigin(baseUrl),
|
|
647
|
+
replayPath,
|
|
648
|
+
timeLimitSeconds,
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function isWithin(root: string, candidate: string): boolean {
|
|
653
|
+
const path = relative(root, candidate);
|
|
654
|
+
return path === "" || (!path.startsWith("..") && !isAbsolute(path));
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function validateReadinessPath(value: string): asserts value is `/${string}` {
|
|
658
|
+
if (!value.startsWith("/") || value.startsWith("//")) {
|
|
659
|
+
throw new Error("server.readinessPath must be an origin-relative path");
|
|
660
|
+
}
|
|
661
|
+
const url = new URL(value, "http://127.0.0.1");
|
|
662
|
+
if (url.origin !== "http://127.0.0.1" || url.hash !== "") {
|
|
663
|
+
throw new Error("server.readinessPath must stay on the server origin without a fragment");
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function validateEntryPath(value: string): asserts value is `/${string}` {
|
|
668
|
+
if (!value.startsWith("/") || value.startsWith("//")) {
|
|
669
|
+
throw new Error("entryPath must be an origin-relative path");
|
|
670
|
+
}
|
|
671
|
+
const url = new URL(value, "http://127.0.0.1");
|
|
672
|
+
if (
|
|
673
|
+
url.origin !== "http://127.0.0.1"
|
|
674
|
+
|| url.hash !== ""
|
|
675
|
+
|| url.search !== ""
|
|
676
|
+
|| url.pathname !== value
|
|
677
|
+
) {
|
|
678
|
+
throw new Error("entryPath must be a normalized path without a query or fragment");
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function validateTargetQuery(value: unknown): Readonly<Record<string, string>> {
|
|
683
|
+
if (!isRecord(value)) {
|
|
684
|
+
throw new Error("targetQuery must be an object of string query parameters");
|
|
685
|
+
}
|
|
686
|
+
const entries = Object.entries(value);
|
|
687
|
+
if (entries.length > 16) {
|
|
688
|
+
throw new Error("targetQuery may contain at most 16 parameters");
|
|
689
|
+
}
|
|
690
|
+
const validated: Record<string, string> = {};
|
|
691
|
+
for (const [name, queryValue] of [...entries].sort(([left], [right]) =>
|
|
692
|
+
left.localeCompare(right)
|
|
693
|
+
)) {
|
|
694
|
+
if (
|
|
695
|
+
name.length === 0
|
|
696
|
+
|| name.length > 128
|
|
697
|
+
|| !QUERY_PARAMETER_NAME_PATTERN.test(name)
|
|
698
|
+
|| PROTOTYPE_PROPERTY_NAMES.has(name)
|
|
699
|
+
|| hasControlCharacters(name)
|
|
700
|
+
|| name === SCENARIO_QUERY_KEY
|
|
701
|
+
|| name === FIXTURE_QUERY_KEY
|
|
702
|
+
) {
|
|
703
|
+
throw new Error("targetQuery contains an invalid or reserved parameter name");
|
|
704
|
+
}
|
|
705
|
+
if (
|
|
706
|
+
typeof queryValue !== "string"
|
|
707
|
+
|| queryValue.length > 2_048
|
|
708
|
+
|| hasControlCharacters(queryValue)
|
|
709
|
+
) {
|
|
710
|
+
throw new Error(`targetQuery ${name} must be a bounded string without control characters`);
|
|
711
|
+
}
|
|
712
|
+
validated[name] = queryValue;
|
|
713
|
+
}
|
|
714
|
+
return Object.freeze(validated);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
export function validateDirectBombadilFuzzConfig(
|
|
718
|
+
config: DirectBombadilFuzzConfig,
|
|
719
|
+
baseUrlOverride?: string,
|
|
720
|
+
): ValidatedConfig {
|
|
721
|
+
const repositoryRoot = resolve(config.repositoryRoot);
|
|
722
|
+
if (!isAbsolute(config.repositoryRoot) || repositoryRoot !== config.repositoryRoot) {
|
|
723
|
+
throw new Error("repositoryRoot must be an absolute normalized path");
|
|
724
|
+
}
|
|
725
|
+
if (!ARTIFACT_NAME_PATTERN.test(config.artifactName)) {
|
|
726
|
+
throw new Error("artifactName must be a safe lowercase kebab identifier");
|
|
727
|
+
}
|
|
728
|
+
if (
|
|
729
|
+
config.label.trim().length === 0
|
|
730
|
+
|| config.label.length > 160
|
|
731
|
+
|| hasControlCharacters(config.label)
|
|
732
|
+
) {
|
|
733
|
+
throw new Error("label must contain 1-160 visible characters");
|
|
734
|
+
}
|
|
735
|
+
if (
|
|
736
|
+
config.scenario.length > 120
|
|
737
|
+
|| !SCENARIO_PATTERN.test(config.scenario)
|
|
738
|
+
) {
|
|
739
|
+
throw new Error("scenario must be a valid Direct scenario identifier");
|
|
740
|
+
}
|
|
741
|
+
if (
|
|
742
|
+
config.expectedRoute.trim().length === 0
|
|
743
|
+
|| config.expectedRoute.length > 256
|
|
744
|
+
|| hasControlCharacters(config.expectedRoute)
|
|
745
|
+
) {
|
|
746
|
+
throw new Error("expectedRoute must contain 1-256 visible characters");
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const specificationPath = resolve(config.specificationPath);
|
|
750
|
+
const serverCwd = resolve(config.server.cwd);
|
|
751
|
+
if (!isAbsolute(config.specificationPath) || !isWithin(repositoryRoot, specificationPath)) {
|
|
752
|
+
throw new Error("specificationPath must be an absolute path inside repositoryRoot");
|
|
753
|
+
}
|
|
754
|
+
if (!/\.[cm]?[jt]sx?$/u.test(specificationPath)) {
|
|
755
|
+
throw new Error("specificationPath must name a JavaScript or TypeScript specification");
|
|
756
|
+
}
|
|
757
|
+
if (!isAbsolute(config.server.cwd) || !isWithin(repositoryRoot, serverCwd)) {
|
|
758
|
+
throw new Error("server.cwd must be an absolute path inside repositoryRoot");
|
|
759
|
+
}
|
|
760
|
+
if (config.server.command.length === 0) {
|
|
761
|
+
throw new Error("server.command must contain at least one argument");
|
|
762
|
+
}
|
|
763
|
+
if (config.server.command.filter((argument) => argument === "{port}").length !== 1) {
|
|
764
|
+
throw new Error("server.command must contain exactly one literal {port} token");
|
|
765
|
+
}
|
|
766
|
+
for (const argument of config.server.command) {
|
|
767
|
+
if (argument.length === 0 || argument.includes("\0")) {
|
|
768
|
+
throw new Error("server.command arguments must be nonempty strings without null bytes");
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
for (const [name, value] of Object.entries(config.server.env ?? {})) {
|
|
772
|
+
if (!ENVIRONMENT_NAME_PATTERN.test(name)) {
|
|
773
|
+
throw new Error("server.env contains an invalid environment variable name");
|
|
774
|
+
}
|
|
775
|
+
if (value !== undefined && (typeof value !== "string" || value.includes("\0"))) {
|
|
776
|
+
throw new Error(`server.env ${name} must be a string without null bytes`);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
const readinessPath = config.server.readinessPath ?? "/";
|
|
781
|
+
validateReadinessPath(readinessPath);
|
|
782
|
+
const entryPath = config.entryPath ?? "/";
|
|
783
|
+
validateEntryPath(entryPath);
|
|
784
|
+
const targetQuery = validateTargetQuery(config.targetQuery ?? {});
|
|
785
|
+
const startupTimeoutMs = config.server.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS;
|
|
786
|
+
if (
|
|
787
|
+
!Number.isSafeInteger(startupTimeoutMs)
|
|
788
|
+
|| startupTimeoutMs < 1_000
|
|
789
|
+
|| startupTimeoutMs > MAX_STARTUP_TIMEOUT_MS
|
|
790
|
+
) {
|
|
791
|
+
throw new Error(
|
|
792
|
+
`server.startupTimeoutMs must be an integer between 1000 and ${String(MAX_STARTUP_TIMEOUT_MS)}`,
|
|
793
|
+
);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
const baseUrl = requireLocalRootHttpOrigin(baseUrlOverride ?? config.baseUrl);
|
|
797
|
+
const port = new URL(baseUrl).port;
|
|
798
|
+
return {
|
|
799
|
+
...config,
|
|
800
|
+
repositoryRoot,
|
|
801
|
+
specificationPath,
|
|
802
|
+
baseUrl,
|
|
803
|
+
artifactRoot: join(repositoryRoot, "artifacts", "direct-bombadil", config.artifactName),
|
|
804
|
+
bombadilExecutable: bombadilNativeBinary(repositoryRoot),
|
|
805
|
+
entryPath,
|
|
806
|
+
port,
|
|
807
|
+
targetQuery,
|
|
808
|
+
server: {
|
|
809
|
+
...config.server,
|
|
810
|
+
cwd: serverCwd,
|
|
811
|
+
readinessPath,
|
|
812
|
+
startupTimeoutMs,
|
|
813
|
+
},
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function resolveReplayPath(repositoryRoot: string, replayPath: string | null): string | null {
|
|
818
|
+
if (replayPath === null) return null;
|
|
819
|
+
const resolved = resolve(repositoryRoot, replayPath);
|
|
820
|
+
if (!isWithin(repositoryRoot, resolved) || !resolved.endsWith(".jsonl")) {
|
|
821
|
+
throw new Error("--replay must name a .jsonl trace inside repositoryRoot");
|
|
822
|
+
}
|
|
823
|
+
return resolved;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
export function createDirectBombadilInvocation(options: {
|
|
827
|
+
readonly baseUrl: string;
|
|
828
|
+
readonly bombadilExecutable: string;
|
|
829
|
+
readonly entryPath?: `/${string}`;
|
|
830
|
+
readonly outputPath: string;
|
|
831
|
+
readonly replayPath: string | null;
|
|
832
|
+
readonly repositoryRoot: string;
|
|
833
|
+
readonly scenario: string;
|
|
834
|
+
readonly specificationPath: string;
|
|
835
|
+
readonly targetQuery?: Readonly<Record<string, string>>;
|
|
836
|
+
readonly timeLimitSeconds: number;
|
|
837
|
+
}): DirectBombadilInvocation {
|
|
838
|
+
const target = new URL(options.entryPath ?? "/", `${options.baseUrl}/`);
|
|
839
|
+
target.searchParams.set(SCENARIO_QUERY_KEY, options.scenario);
|
|
840
|
+
for (const [name, value] of Object.entries(options.targetQuery ?? {})) {
|
|
841
|
+
target.searchParams.set(name, value);
|
|
842
|
+
}
|
|
843
|
+
const command = [
|
|
844
|
+
options.bombadilExecutable,
|
|
845
|
+
"browser",
|
|
846
|
+
"test",
|
|
847
|
+
target.href,
|
|
848
|
+
options.specificationPath,
|
|
849
|
+
"--output-path",
|
|
850
|
+
options.outputPath,
|
|
851
|
+
"--headless",
|
|
852
|
+
"--instrument-javascript=",
|
|
853
|
+
];
|
|
854
|
+
if (options.replayPath === null) {
|
|
855
|
+
command.push(
|
|
856
|
+
"--exit-on-violation",
|
|
857
|
+
"--time-limit",
|
|
858
|
+
`${String(options.timeLimitSeconds)}s`,
|
|
859
|
+
);
|
|
860
|
+
} else {
|
|
861
|
+
command.push("--reproduce", options.replayPath);
|
|
862
|
+
}
|
|
863
|
+
return {
|
|
864
|
+
command,
|
|
865
|
+
cwd: options.repositoryRoot,
|
|
866
|
+
outputPath: options.outputPath,
|
|
867
|
+
targetUrl: target.href,
|
|
868
|
+
wallClockTimeoutMs: options.replayPath === null
|
|
869
|
+
? options.timeLimitSeconds * 1_000 + RANDOM_RUN_OVERHEAD_MS
|
|
870
|
+
: REPLAY_WALL_CLOCK_TIMEOUT_MS,
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
interface StreamCapture {
|
|
875
|
+
readonly result: Promise<string>;
|
|
876
|
+
readonly stop: () => void;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function captureStream(
|
|
880
|
+
stream: ReadableStream<Uint8Array>,
|
|
881
|
+
maximumLength = LOG_LIMIT,
|
|
882
|
+
): StreamCapture {
|
|
883
|
+
let stopCapture!: () => void;
|
|
884
|
+
const stopped = new Promise<void>((resolveStopped) => {
|
|
885
|
+
stopCapture = resolveStopped;
|
|
886
|
+
});
|
|
887
|
+
const result = (async (): Promise<string> => {
|
|
888
|
+
const reader = stream.getReader();
|
|
889
|
+
const decoder = new TextDecoder();
|
|
890
|
+
let output = "";
|
|
891
|
+
for (;;) {
|
|
892
|
+
const next = await Promise.race([
|
|
893
|
+
reader.read().then(
|
|
894
|
+
(chunk) => ({ kind: "chunk" as const, chunk }),
|
|
895
|
+
(error: unknown) => ({ kind: "error" as const, error }),
|
|
896
|
+
),
|
|
897
|
+
stopped.then(() => ({ kind: "stopped" as const })),
|
|
898
|
+
]);
|
|
899
|
+
if (next.kind === "stopped") {
|
|
900
|
+
void reader.cancel().catch(() => undefined);
|
|
901
|
+
return tail(`${output}${decoder.decode()}`, maximumLength);
|
|
902
|
+
}
|
|
903
|
+
if (next.kind === "error") throw next.error;
|
|
904
|
+
if (next.chunk.done) return tail(`${output}${decoder.decode()}`, maximumLength);
|
|
905
|
+
output = tail(
|
|
906
|
+
`${output}${decoder.decode(next.chunk.value, { stream: true })}`,
|
|
907
|
+
maximumLength,
|
|
908
|
+
);
|
|
909
|
+
}
|
|
910
|
+
})();
|
|
911
|
+
return { result, stop: stopCapture };
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function signalProcessGroup(
|
|
915
|
+
process_: ReturnType<typeof Bun.spawn>,
|
|
916
|
+
signal: "SIGKILL" | "SIGTERM",
|
|
917
|
+
): void {
|
|
918
|
+
try {
|
|
919
|
+
process.kill(-process_.pid, signal);
|
|
920
|
+
} catch {
|
|
921
|
+
if (process_.exitCode === null) process_.kill(signal);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
async function terminateProcessGroup(
|
|
926
|
+
process_: ReturnType<typeof Bun.spawn>,
|
|
927
|
+
graceMs: number,
|
|
928
|
+
): Promise<void> {
|
|
929
|
+
signalProcessGroup(process_, "SIGTERM");
|
|
930
|
+
await Bun.sleep(graceMs);
|
|
931
|
+
// The group may still contain descendants after its leader exits on TERM.
|
|
932
|
+
signalProcessGroup(process_, "SIGKILL");
|
|
933
|
+
await Promise.race([process_.exited.then(() => undefined), Bun.sleep(graceMs)]);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
export async function runBombadilNativeProcess(
|
|
937
|
+
invocation: DirectBombadilInvocation,
|
|
938
|
+
): Promise<BombadilProcessResult> {
|
|
939
|
+
const process_ = Bun.spawn([...invocation.command], {
|
|
940
|
+
cwd: invocation.cwd,
|
|
941
|
+
detached: true,
|
|
942
|
+
env: { ...process.env, NO_COLOR: "1" },
|
|
943
|
+
stdin: "ignore",
|
|
944
|
+
stdout: "pipe",
|
|
945
|
+
stderr: "pipe",
|
|
946
|
+
});
|
|
947
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
948
|
+
let abortListener: (() => void) | undefined;
|
|
949
|
+
const timeoutPromise = new Promise<"timeout">((resolveTimeout) => {
|
|
950
|
+
timeout = setTimeout(() => resolveTimeout("timeout"), invocation.wallClockTimeoutMs);
|
|
951
|
+
});
|
|
952
|
+
const abortPromise = new Promise<"aborted">((resolveAbort) => {
|
|
953
|
+
if (invocation.abortSignal === undefined) return;
|
|
954
|
+
if (invocation.abortSignal.aborted) {
|
|
955
|
+
resolveAbort("aborted");
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
abortListener = () => resolveAbort("aborted");
|
|
959
|
+
invocation.abortSignal.addEventListener("abort", abortListener, { once: true });
|
|
960
|
+
});
|
|
961
|
+
try {
|
|
962
|
+
const stdoutCapture = captureStream(process_.stdout);
|
|
963
|
+
const stderrCapture = captureStream(process_.stderr);
|
|
964
|
+
const outputPromise = Promise.all([stdoutCapture.result, stderrCapture.result]);
|
|
965
|
+
const outcome = await Promise.race([
|
|
966
|
+
process_.exited.then((exitCode) => ({ kind: "exited" as const, exitCode })),
|
|
967
|
+
timeoutPromise.then(() => ({ kind: "timeout" as const })),
|
|
968
|
+
abortPromise.then(() => ({ kind: "aborted" as const })),
|
|
969
|
+
]);
|
|
970
|
+
const terminationGraceMs = invocation.terminationGraceMs
|
|
971
|
+
?? PROCESS_TERMINATION_GRACE_MS;
|
|
972
|
+
if (outcome.kind === "exited") {
|
|
973
|
+
// The native leader is done. Any member left in its group is stale and
|
|
974
|
+
// may otherwise keep inherited output pipes open indefinitely.
|
|
975
|
+
signalProcessGroup(process_, "SIGKILL");
|
|
976
|
+
} else {
|
|
977
|
+
await terminateProcessGroup(
|
|
978
|
+
process_,
|
|
979
|
+
terminationGraceMs,
|
|
980
|
+
);
|
|
981
|
+
}
|
|
982
|
+
const outputSettled = await Promise.race([
|
|
983
|
+
outputPromise.then(
|
|
984
|
+
() => true,
|
|
985
|
+
() => true,
|
|
986
|
+
),
|
|
987
|
+
Bun.sleep(Math.max(terminationGraceMs, MIN_PROCESS_OUTPUT_DRAIN_MS)).then(() => false),
|
|
988
|
+
]);
|
|
989
|
+
if (!outputSettled) {
|
|
990
|
+
stdoutCapture.stop();
|
|
991
|
+
stderrCapture.stop();
|
|
992
|
+
}
|
|
993
|
+
const [stdout, stderr] = await outputPromise;
|
|
994
|
+
return {
|
|
995
|
+
exitCode: outcome.kind === "exited" ? outcome.exitCode : process_.exitCode ?? 137,
|
|
996
|
+
stderr,
|
|
997
|
+
stdout,
|
|
998
|
+
termination: outcome.kind === "exited" ? null : outcome.kind,
|
|
999
|
+
};
|
|
1000
|
+
} finally {
|
|
1001
|
+
if (timeout !== undefined) clearTimeout(timeout);
|
|
1002
|
+
if (abortListener !== undefined) {
|
|
1003
|
+
invocation.abortSignal?.removeEventListener("abort", abortListener);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
const defaultDependencies: DirectBombadilRunnerDependencies = {
|
|
1009
|
+
acquireServer: acquireVerificationServer,
|
|
1010
|
+
now: () => new Date(),
|
|
1011
|
+
runBombadil: runBombadilNativeProcess,
|
|
1012
|
+
serverOutputTimeoutMs: SERVER_OUTPUT_TIMEOUT_MS,
|
|
1013
|
+
spawnServer: spawnVerificationServer,
|
|
1014
|
+
stopServer: stopVerificationServer,
|
|
1015
|
+
};
|
|
1016
|
+
|
|
1017
|
+
async function readServerOutputBounded(
|
|
1018
|
+
server: ManagedVerificationServer,
|
|
1019
|
+
timeoutMs: number,
|
|
1020
|
+
): Promise<string> {
|
|
1021
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
1022
|
+
const timeoutPromise = new Promise<{ readonly kind: "timeout" }>((resolveTimeout) => {
|
|
1023
|
+
timeout = setTimeout(() => resolveTimeout({ kind: "timeout" }), timeoutMs);
|
|
1024
|
+
});
|
|
1025
|
+
try {
|
|
1026
|
+
const outcome = await Promise.race([
|
|
1027
|
+
server.output.then(
|
|
1028
|
+
(output) => ({ kind: "output" as const, output }),
|
|
1029
|
+
(error: unknown) => ({ kind: "error" as const, error }),
|
|
1030
|
+
),
|
|
1031
|
+
timeoutPromise,
|
|
1032
|
+
]);
|
|
1033
|
+
if (outcome.kind === "timeout") {
|
|
1034
|
+
throw new Error(
|
|
1035
|
+
`Verification server output did not settle within ${String(timeoutMs)}ms after cleanup`,
|
|
1036
|
+
);
|
|
1037
|
+
}
|
|
1038
|
+
if (outcome.kind === "error") {
|
|
1039
|
+
throw outcome.error instanceof Error
|
|
1040
|
+
? outcome.error
|
|
1041
|
+
: new Error(renderUnknown(outcome.error));
|
|
1042
|
+
}
|
|
1043
|
+
return tail(outcome.output, LOG_LIMIT);
|
|
1044
|
+
} finally {
|
|
1045
|
+
if (timeout !== undefined) clearTimeout(timeout);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
async function requireRegularFile(path: string, label: string): Promise<void> {
|
|
1050
|
+
let metadata;
|
|
1051
|
+
try {
|
|
1052
|
+
metadata = await stat(path);
|
|
1053
|
+
} catch {
|
|
1054
|
+
throw new Error(`${label} does not exist at its configured path`);
|
|
1055
|
+
}
|
|
1056
|
+
if (!metadata.isFile()) throw new Error(`${label} must be a regular file`);
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
async function requireDirectory(path: string, label: string): Promise<void> {
|
|
1060
|
+
let metadata;
|
|
1061
|
+
try {
|
|
1062
|
+
metadata = await stat(path);
|
|
1063
|
+
} catch {
|
|
1064
|
+
throw new Error(`${label} does not exist at its configured path`);
|
|
1065
|
+
}
|
|
1066
|
+
if (!metadata.isDirectory()) throw new Error(`${label} must be a directory`);
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
async function resolveExistingRealPath(path: string, label: string): Promise<string> {
|
|
1070
|
+
try {
|
|
1071
|
+
return await realpath(path);
|
|
1072
|
+
} catch {
|
|
1073
|
+
throw new Error(`${label} does not exist at its configured path`);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
async function resolveConfinedRealPath(options: {
|
|
1078
|
+
readonly candidate: string;
|
|
1079
|
+
readonly kind: "directory" | "file";
|
|
1080
|
+
readonly label: string;
|
|
1081
|
+
readonly repositoryRoot: string;
|
|
1082
|
+
}): Promise<string> {
|
|
1083
|
+
const resolved = await resolveExistingRealPath(options.candidate, options.label);
|
|
1084
|
+
if (!isWithin(options.repositoryRoot, resolved)) {
|
|
1085
|
+
throw new Error(`${options.label} resolves outside repositoryRoot`);
|
|
1086
|
+
}
|
|
1087
|
+
if (options.kind === "directory") {
|
|
1088
|
+
await requireDirectory(resolved, options.label);
|
|
1089
|
+
} else {
|
|
1090
|
+
await requireRegularFile(resolved, options.label);
|
|
1091
|
+
}
|
|
1092
|
+
return resolved;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
async function resolveDirectBombadilRealPaths(
|
|
1096
|
+
config: ValidatedConfig,
|
|
1097
|
+
replayPath: string | null,
|
|
1098
|
+
): Promise<{ readonly config: ValidatedConfig; readonly replayPath: string | null }> {
|
|
1099
|
+
const repositoryRoot = await resolveExistingRealPath(
|
|
1100
|
+
config.repositoryRoot,
|
|
1101
|
+
"repositoryRoot",
|
|
1102
|
+
);
|
|
1103
|
+
await requireDirectory(repositoryRoot, "repositoryRoot");
|
|
1104
|
+
const specificationPath = await resolveConfinedRealPath({
|
|
1105
|
+
candidate: config.specificationPath,
|
|
1106
|
+
kind: "file",
|
|
1107
|
+
label: "specificationPath",
|
|
1108
|
+
repositoryRoot,
|
|
1109
|
+
});
|
|
1110
|
+
if (!/\.[cm]?[jt]sx?$/u.test(specificationPath)) {
|
|
1111
|
+
throw new Error("specificationPath must resolve to a JavaScript or TypeScript file");
|
|
1112
|
+
}
|
|
1113
|
+
const serverCwd = await resolveConfinedRealPath({
|
|
1114
|
+
candidate: config.server.cwd,
|
|
1115
|
+
kind: "directory",
|
|
1116
|
+
label: "server.cwd",
|
|
1117
|
+
repositoryRoot,
|
|
1118
|
+
});
|
|
1119
|
+
const resolvedReplayPath = replayPath === null
|
|
1120
|
+
? null
|
|
1121
|
+
: await resolveConfinedRealPath({
|
|
1122
|
+
candidate: replayPath,
|
|
1123
|
+
kind: "file",
|
|
1124
|
+
label: "--replay",
|
|
1125
|
+
repositoryRoot,
|
|
1126
|
+
});
|
|
1127
|
+
if (resolvedReplayPath !== null && !resolvedReplayPath.endsWith(".jsonl")) {
|
|
1128
|
+
throw new Error("--replay must resolve to a .jsonl trace inside repositoryRoot");
|
|
1129
|
+
}
|
|
1130
|
+
return {
|
|
1131
|
+
config: {
|
|
1132
|
+
...config,
|
|
1133
|
+
repositoryRoot,
|
|
1134
|
+
specificationPath,
|
|
1135
|
+
artifactRoot: join(
|
|
1136
|
+
repositoryRoot,
|
|
1137
|
+
"artifacts",
|
|
1138
|
+
"direct-bombadil",
|
|
1139
|
+
config.artifactName,
|
|
1140
|
+
),
|
|
1141
|
+
bombadilExecutable: bombadilNativeBinary(repositoryRoot),
|
|
1142
|
+
server: { ...config.server, cwd: serverCwd },
|
|
1143
|
+
},
|
|
1144
|
+
replayPath: resolvedReplayPath,
|
|
1145
|
+
};
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
async function readExactBombadilVersion(repositoryRoot: string): Promise<string> {
|
|
1149
|
+
const packagePath = join(
|
|
1150
|
+
repositoryRoot,
|
|
1151
|
+
"node_modules",
|
|
1152
|
+
"@antithesishq",
|
|
1153
|
+
"bombadil",
|
|
1154
|
+
"package.json",
|
|
1155
|
+
);
|
|
1156
|
+
let input: unknown;
|
|
1157
|
+
try {
|
|
1158
|
+
input = JSON.parse(await readFile(packagePath, "utf8")) as unknown;
|
|
1159
|
+
} catch {
|
|
1160
|
+
throw new Error("The root Bombadil package metadata is missing or malformed");
|
|
1161
|
+
}
|
|
1162
|
+
if (
|
|
1163
|
+
typeof input !== "object"
|
|
1164
|
+
|| input === null
|
|
1165
|
+
|| Array.isArray(input)
|
|
1166
|
+
|| Reflect.get(input, "version") !== EXPECTED_BOMBADIL_VERSION
|
|
1167
|
+
) {
|
|
1168
|
+
throw new Error(`The root Bombadil package must be exactly ${EXPECTED_BOMBADIL_VERSION}`);
|
|
1169
|
+
}
|
|
1170
|
+
return EXPECTED_BOMBADIL_VERSION;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
function helpText(defaultBaseUrl: string): string {
|
|
1174
|
+
return [
|
|
1175
|
+
"Usage: bun fuzz-browser.ts [options]",
|
|
1176
|
+
"",
|
|
1177
|
+
` --base-url <url> Local server root (default: ${defaultBaseUrl})`,
|
|
1178
|
+
` --time-limit <Ns> Random exploration limit, 12-300s (default: ${String(DEFAULT_TIME_LIMIT_SECONDS)}s)`,
|
|
1179
|
+
" --replay <trace> Reproduce a repository-local trace.jsonl",
|
|
1180
|
+
" -h, --help Show this help",
|
|
1181
|
+
].join("\n");
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
/** Runs one bounded diagnostic Bombadil campaign and always releases its server lease. */
|
|
1185
|
+
export async function runDirectBombadilFuzz(
|
|
1186
|
+
config: DirectBombadilFuzzConfig,
|
|
1187
|
+
arguments_: readonly string[] = process.argv.slice(2),
|
|
1188
|
+
dependencyOverrides: Partial<DirectBombadilRunnerDependencies> = {},
|
|
1189
|
+
): Promise<DirectBombadilFuzzResult> {
|
|
1190
|
+
const parsed = parseDirectBombadilFuzzArguments(arguments_, config.baseUrl);
|
|
1191
|
+
if (parsed.kind === "help") {
|
|
1192
|
+
process.stdout.write(`${helpText(config.baseUrl)}\n`);
|
|
1193
|
+
return { kind: "help" };
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
const lexicalConfig = validateDirectBombadilFuzzConfig(config, parsed.baseUrl);
|
|
1197
|
+
const lexicalReplayPath = resolveReplayPath(
|
|
1198
|
+
lexicalConfig.repositoryRoot,
|
|
1199
|
+
parsed.replayPath,
|
|
1200
|
+
);
|
|
1201
|
+
const resolvedPaths = await resolveDirectBombadilRealPaths(
|
|
1202
|
+
lexicalConfig,
|
|
1203
|
+
lexicalReplayPath,
|
|
1204
|
+
);
|
|
1205
|
+
const validated = resolvedPaths.config;
|
|
1206
|
+
const replayPath = resolvedPaths.replayPath;
|
|
1207
|
+
const dependencies = { ...defaultDependencies, ...dependencyOverrides };
|
|
1208
|
+
const generatedAt = dependencies.now();
|
|
1209
|
+
const artifactRun = await createArtifactRun({
|
|
1210
|
+
artifactRoot: validated.artifactRoot,
|
|
1211
|
+
generatedAt: generatedAt.toISOString(),
|
|
1212
|
+
});
|
|
1213
|
+
const outputPath = join(artifactRun.runDirectory, "bombadil");
|
|
1214
|
+
const tracePath = join(outputPath, "trace.jsonl");
|
|
1215
|
+
const abortController = new AbortController();
|
|
1216
|
+
const invocation = createDirectBombadilInvocation({
|
|
1217
|
+
baseUrl: validated.baseUrl,
|
|
1218
|
+
bombadilExecutable: validated.bombadilExecutable,
|
|
1219
|
+
entryPath: validated.entryPath,
|
|
1220
|
+
outputPath,
|
|
1221
|
+
replayPath,
|
|
1222
|
+
repositoryRoot: validated.repositoryRoot,
|
|
1223
|
+
scenario: validated.scenario,
|
|
1224
|
+
specificationPath: validated.specificationPath,
|
|
1225
|
+
targetQuery: validated.targetQuery,
|
|
1226
|
+
timeLimitSeconds: parsed.timeLimitSeconds,
|
|
1227
|
+
});
|
|
1228
|
+
const abortableInvocation = { ...invocation, abortSignal: abortController.signal };
|
|
1229
|
+
const serverCommand = validated.server.command.map((argument) =>
|
|
1230
|
+
argument === "{port}" ? validated.port : argument
|
|
1231
|
+
);
|
|
1232
|
+
|
|
1233
|
+
let bombadilVersion: string | null = null;
|
|
1234
|
+
let lease: ServerLease | null = null;
|
|
1235
|
+
let ownedServer: ManagedVerificationServer | null = null;
|
|
1236
|
+
let processResult: BombadilProcessResult | null = null;
|
|
1237
|
+
let attestation: DirectBombadilTraceAttestation | null = null;
|
|
1238
|
+
let attestationFailure: unknown = null;
|
|
1239
|
+
let rawTracePath: string | null = null;
|
|
1240
|
+
let serverOutput = "";
|
|
1241
|
+
let serverOutputFailure: unknown = null;
|
|
1242
|
+
let failure: unknown = null;
|
|
1243
|
+
let interruptedSignal: NodeJS.Signals | null = null;
|
|
1244
|
+
const interrupt = (signal: NodeJS.Signals): void => {
|
|
1245
|
+
interruptedSignal ??= signal;
|
|
1246
|
+
abortController.abort();
|
|
1247
|
+
if (ownedServer?.exitCode() === null) ownedServer.terminate();
|
|
1248
|
+
};
|
|
1249
|
+
const interruptSignals = ["SIGINT", "SIGTERM"] as const;
|
|
1250
|
+
// @types/bun augments Node's process events and has changed this overload
|
|
1251
|
+
// across patch releases. Bind the stable signal subset used by this runner.
|
|
1252
|
+
const processSignals = process as unknown as ProcessSignalEmitter;
|
|
1253
|
+
for (const signal of interruptSignals) processSignals.once(signal, interrupt);
|
|
1254
|
+
try {
|
|
1255
|
+
try {
|
|
1256
|
+
await requireRegularFile(validated.bombadilExecutable, "The root Bombadil executable");
|
|
1257
|
+
bombadilVersion = await readExactBombadilVersion(validated.repositoryRoot);
|
|
1258
|
+
if (abortController.signal.aborted) throw new Error("Bombadil fuzzing was interrupted");
|
|
1259
|
+
|
|
1260
|
+
lease = await dependencies.acquireServer({
|
|
1261
|
+
baseUrl: validated.baseUrl,
|
|
1262
|
+
label: validated.label,
|
|
1263
|
+
readinessPath: validated.server.readinessPath,
|
|
1264
|
+
reuseExistingLocalServer: false,
|
|
1265
|
+
startupTimeoutMs: validated.server.startupTimeoutMs,
|
|
1266
|
+
startServer: () => {
|
|
1267
|
+
ownedServer = dependencies.spawnServer({
|
|
1268
|
+
command: serverCommand,
|
|
1269
|
+
cwd: validated.server.cwd,
|
|
1270
|
+
...(validated.server.env === undefined ? {} : { env: validated.server.env }),
|
|
1271
|
+
});
|
|
1272
|
+
return ownedServer;
|
|
1273
|
+
},
|
|
1274
|
+
});
|
|
1275
|
+
let processFailure: unknown = null;
|
|
1276
|
+
try {
|
|
1277
|
+
processResult = await dependencies.runBombadil(abortableInvocation);
|
|
1278
|
+
} catch (error) {
|
|
1279
|
+
processFailure = error;
|
|
1280
|
+
}
|
|
1281
|
+
const traceMetadata = await stat(tracePath).catch(() => null);
|
|
1282
|
+
if (traceMetadata?.isFile() === true && traceMetadata.size > 0) {
|
|
1283
|
+
rawTracePath = tracePath;
|
|
1284
|
+
}
|
|
1285
|
+
try {
|
|
1286
|
+
attestation = await attestDirectBombadilTrace({
|
|
1287
|
+
expectedRoute: validated.expectedRoute,
|
|
1288
|
+
expectedScenario: validated.scenario,
|
|
1289
|
+
tracePath,
|
|
1290
|
+
});
|
|
1291
|
+
} catch (error) {
|
|
1292
|
+
attestationFailure = error;
|
|
1293
|
+
}
|
|
1294
|
+
if (processFailure !== null) {
|
|
1295
|
+
throw processFailure instanceof Error
|
|
1296
|
+
? processFailure
|
|
1297
|
+
: new Error(renderUnknown(processFailure));
|
|
1298
|
+
}
|
|
1299
|
+
if (processResult === null) throw new Error("Bombadil did not return a process result");
|
|
1300
|
+
if (processResult.termination === "timeout") {
|
|
1301
|
+
throw new Error(
|
|
1302
|
+
`Bombadil exceeded its ${String(invocation.wallClockTimeoutMs)}ms wall-clock limit`,
|
|
1303
|
+
);
|
|
1304
|
+
}
|
|
1305
|
+
if (processResult.termination === "aborted") {
|
|
1306
|
+
throw new Error("Bombadil process was interrupted");
|
|
1307
|
+
}
|
|
1308
|
+
if (processResult.exitCode !== 0) {
|
|
1309
|
+
throw new Error(`Bombadil exited with status ${String(processResult.exitCode)}`);
|
|
1310
|
+
}
|
|
1311
|
+
if (attestationFailure !== null) {
|
|
1312
|
+
throw attestationFailure instanceof Error
|
|
1313
|
+
? attestationFailure
|
|
1314
|
+
: new Error(renderUnknown(attestationFailure));
|
|
1315
|
+
}
|
|
1316
|
+
} catch (error) {
|
|
1317
|
+
failure = error;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
const serverToStop = lease?.source === "started" ? lease.server : ownedServer;
|
|
1321
|
+
if (serverToStop !== null) {
|
|
1322
|
+
try {
|
|
1323
|
+
await dependencies.stopServer(serverToStop);
|
|
1324
|
+
} catch (error) {
|
|
1325
|
+
failure ??= error;
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
const serverAfterRun = ownedServer as ManagedVerificationServer | null;
|
|
1329
|
+
if (serverAfterRun !== null) {
|
|
1330
|
+
try {
|
|
1331
|
+
serverOutput = await readServerOutputBounded(
|
|
1332
|
+
serverAfterRun,
|
|
1333
|
+
dependencies.serverOutputTimeoutMs,
|
|
1334
|
+
);
|
|
1335
|
+
} catch (error) {
|
|
1336
|
+
serverOutputFailure = error;
|
|
1337
|
+
failure ??= error;
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
} finally {
|
|
1341
|
+
for (const signal of interruptSignals) {
|
|
1342
|
+
processSignals.removeListener(signal, interrupt);
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
const capturedSignal = interruptedSignal as NodeJS.Signals | null;
|
|
1346
|
+
if (capturedSignal !== null && failure === null) {
|
|
1347
|
+
failure = new Error(`Bombadil fuzzing was interrupted by ${capturedSignal}`);
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
const completedAt = dependencies.now();
|
|
1351
|
+
const status = failure === null ? "passed" : "failed";
|
|
1352
|
+
const logPath = join(artifactRun.runDirectory, "bombadil.log");
|
|
1353
|
+
const serverLogPath = join(artifactRun.runDirectory, "server.log");
|
|
1354
|
+
const record = {
|
|
1355
|
+
schema: ARTIFACT_SCHEMA,
|
|
1356
|
+
evidenceClass: "diagnostic-fuzz",
|
|
1357
|
+
artifactName: validated.artifactName,
|
|
1358
|
+
label: validated.label,
|
|
1359
|
+
status,
|
|
1360
|
+
generatedAt: generatedAt.toISOString(),
|
|
1361
|
+
completedAt: completedAt.toISOString(),
|
|
1362
|
+
durationMs: Math.max(0, completedAt.getTime() - generatedAt.getTime()),
|
|
1363
|
+
scenario: validated.scenario,
|
|
1364
|
+
expectedRoute: validated.expectedRoute,
|
|
1365
|
+
baseUrl: validated.baseUrl,
|
|
1366
|
+
entryPath: validated.entryPath,
|
|
1367
|
+
targetQuery: validated.targetQuery,
|
|
1368
|
+
targetUrl: invocation.targetUrl,
|
|
1369
|
+
specificationPath: validated.specificationPath,
|
|
1370
|
+
replayPath,
|
|
1371
|
+
timeLimitSeconds: replayPath === null ? parsed.timeLimitSeconds : null,
|
|
1372
|
+
serverSource: lease?.source ?? null,
|
|
1373
|
+
bombadil: {
|
|
1374
|
+
version: bombadilVersion,
|
|
1375
|
+
executable: validated.bombadilExecutable,
|
|
1376
|
+
exitCode: processResult?.exitCode ?? null,
|
|
1377
|
+
termination: processResult?.termination ?? null,
|
|
1378
|
+
outputPath,
|
|
1379
|
+
rawTracePath,
|
|
1380
|
+
tracePath: attestation === null ? null : tracePath,
|
|
1381
|
+
logPath,
|
|
1382
|
+
},
|
|
1383
|
+
server: {
|
|
1384
|
+
logPath: serverLogPath,
|
|
1385
|
+
logPresent: serverOutput.length > 0,
|
|
1386
|
+
outputFailure: serverOutputFailure === null ? null : renderUnknown(serverOutputFailure),
|
|
1387
|
+
},
|
|
1388
|
+
attestation,
|
|
1389
|
+
attestationFailure: attestationFailure === null ? null : renderUnknown(attestationFailure),
|
|
1390
|
+
initialDirect: attestation?.initial ?? null,
|
|
1391
|
+
interruptedSignal: capturedSignal,
|
|
1392
|
+
failure: failure === null ? null : renderUnknown(failure),
|
|
1393
|
+
} as const;
|
|
1394
|
+
const log = [processResult?.stdout ?? "", processResult?.stderr ?? ""]
|
|
1395
|
+
.filter((part) => part.length > 0)
|
|
1396
|
+
.join("\n");
|
|
1397
|
+
try {
|
|
1398
|
+
await writeFile(logPath, `${log}${log.length > 0 ? "\n" : ""}`, "utf8");
|
|
1399
|
+
await writeFile(
|
|
1400
|
+
serverLogPath,
|
|
1401
|
+
`${serverOutput}${serverOutput.length > 0 ? "\n" : ""}`,
|
|
1402
|
+
"utf8",
|
|
1403
|
+
);
|
|
1404
|
+
await writeJsonAtomically(join(artifactRun.runDirectory, "run.json"), record);
|
|
1405
|
+
await writeJsonAtomically(artifactRun.manifestPath, record);
|
|
1406
|
+
|
|
1407
|
+
const summary = `${status === "passed" ? "PASS" : "FAIL"} ${validated.label}; artifacts: ${artifactRun.runDirectory}; log: ${logPath}`;
|
|
1408
|
+
(status === "passed" ? process.stdout : process.stderr).write(`${summary}\n`);
|
|
1409
|
+
|
|
1410
|
+
if (failure !== null) {
|
|
1411
|
+
throw failure instanceof Error ? failure : new Error(renderUnknown(failure));
|
|
1412
|
+
}
|
|
1413
|
+
return {
|
|
1414
|
+
kind: "run",
|
|
1415
|
+
artifactDirectory: artifactRun.runDirectory,
|
|
1416
|
+
manifestPath: artifactRun.manifestPath,
|
|
1417
|
+
status: "passed",
|
|
1418
|
+
};
|
|
1419
|
+
} finally {
|
|
1420
|
+
if (capturedSignal !== null) {
|
|
1421
|
+
process.kill(process.pid, capturedSignal);
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
}
|