@hraness/direct 0.7.6 → 0.7.8

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.
@@ -1393,23 +1393,54 @@ async function stopVerificationServerWithOutput(server, stopTimeoutMs = DEFAULT_
1393
1393
  async function stopVerificationServer(server, stopTimeoutMs = DEFAULT_STOP_TIMEOUT_MS) {
1394
1394
  await stopVerificationServerWithOutput(server, stopTimeoutMs);
1395
1395
  }
1396
+ function verificationServerAcquisitionAbortError() {
1397
+ return new Error("Verification server acquisition was aborted");
1398
+ }
1399
+ function throwIfVerificationServerAcquisitionAborted(signal) {
1400
+ if (signal?.aborted === true)
1401
+ throw verificationServerAcquisitionAbortError();
1402
+ }
1403
+ async function waitForVerificationServerAcquisitionStep(promise, signal) {
1404
+ if (signal === undefined)
1405
+ return await promise;
1406
+ throwIfVerificationServerAcquisitionAborted(signal);
1407
+ let abortListener;
1408
+ const aborted = new Promise((_resolve, reject) => {
1409
+ abortListener = () => reject(verificationServerAcquisitionAbortError());
1410
+ signal.addEventListener("abort", abortListener, { once: true });
1411
+ if (signal.aborted)
1412
+ abortListener();
1413
+ });
1414
+ let value;
1415
+ try {
1416
+ value = await Promise.race([promise, aborted]);
1417
+ } finally {
1418
+ if (abortListener !== undefined)
1419
+ signal.removeEventListener("abort", abortListener);
1420
+ }
1421
+ throwIfVerificationServerAcquisitionAborted(signal);
1422
+ return value;
1423
+ }
1396
1424
  async function acquireVerificationServer(options) {
1397
1425
  const probeTimeoutMs = options.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
1398
1426
  const readinessPath = options.readinessPath ?? "/";
1399
1427
  const isReachable = options.isReachable ?? serverIsReachable;
1400
1428
  const canStartLocally = canAutomaticallyStartLocalServer(options.baseUrl, options.localHosts);
1401
- if (await isReachable(options.baseUrl, probeTimeoutMs, readinessPath)) {
1429
+ throwIfVerificationServerAcquisitionAborted(options.abortSignal);
1430
+ if (await waitForVerificationServerAcquisitionStep(Promise.resolve(isReachable(options.baseUrl, probeTimeoutMs, readinessPath)), options.abortSignal)) {
1402
1431
  if (canStartLocally && options.reuseExistingLocalServer === false) {
1403
1432
  throw new Error(`A local server is already reachable at ${options.baseUrl}; ` + "verification will not reuse a server whose worktree ownership is unknown");
1404
1433
  }
1405
- await Bun.sleep(options.reuseProbeIntervalMs ?? DEFAULT_REUSE_PROBE_INTERVAL_MS);
1406
- if (await isReachable(options.baseUrl, probeTimeoutMs, readinessPath)) {
1434
+ await waitForVerificationServerAcquisitionStep(Bun.sleep(options.reuseProbeIntervalMs ?? DEFAULT_REUSE_PROBE_INTERVAL_MS), options.abortSignal);
1435
+ if (await waitForVerificationServerAcquisitionStep(Promise.resolve(isReachable(options.baseUrl, probeTimeoutMs, readinessPath)), options.abortSignal)) {
1436
+ throwIfVerificationServerAcquisitionAborted(options.abortSignal);
1407
1437
  return { source: "reused" };
1408
1438
  }
1409
1439
  }
1410
1440
  if (!canStartLocally) {
1411
1441
  throw new Error(`No server is reachable at ${options.baseUrl}; automatic startup is limited to local HTTP URLs`);
1412
1442
  }
1443
+ throwIfVerificationServerAcquisitionAborted(options.abortSignal);
1413
1444
  const server = options.startServer();
1414
1445
  let exitedWithCode = null;
1415
1446
  try {
@@ -1420,10 +1451,11 @@ async function acquireVerificationServer(options) {
1420
1451
  exitedWithCode = exitCode;
1421
1452
  break;
1422
1453
  }
1423
- if (await isReachable(options.baseUrl, probeTimeoutMs, readinessPath)) {
1454
+ if (await waitForVerificationServerAcquisitionStep(Promise.resolve(isReachable(options.baseUrl, probeTimeoutMs, readinessPath)), options.abortSignal)) {
1455
+ throwIfVerificationServerAcquisitionAborted(options.abortSignal);
1424
1456
  return { source: "started", server };
1425
1457
  }
1426
- await Bun.sleep(options.pollIntervalMs ?? 200);
1458
+ await waitForVerificationServerAcquisitionStep(Bun.sleep(options.pollIntervalMs ?? 200), options.abortSignal);
1427
1459
  }
1428
1460
  } catch (error) {
1429
1461
  await stopVerificationServer(server);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hraness/direct",
3
- "version": "0.7.6",
3
+ "version": "0.7.8",
4
4
  "description": "A TypeScript harness for deterministic frontend development with repeatable scenarios, local fixtures, and browser verification for coding agents.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -21,9 +21,9 @@ global `direct` CLI.
21
21
  For a new installation, pin the reviewed public release:
22
22
 
23
23
  ```sh
24
- bun add --dev @hraness/direct@0.7.6
24
+ bun add --dev @hraness/direct@0.7.8
25
25
  # or, in an npm project
26
- npm install --save-dev @hraness/direct@0.7.6
26
+ npm install --save-dev @hraness/direct@0.7.8
27
27
  ```
28
28
 
29
29
  The equivalent manifest entry is:
@@ -31,7 +31,7 @@ The equivalent manifest entry is:
31
31
  ```json
32
32
  {
33
33
  "devDependencies": {
34
- "@hraness/direct": "0.7.6"
34
+ "@hraness/direct": "0.7.8"
35
35
  }
36
36
  }
37
37
  ```
@@ -43,8 +43,8 @@ If the task starts with skill installation rather than a loaded skill, install
43
43
  the single repository skill with either command:
44
44
 
45
45
  ```sh
46
- npx skills add hraness/direct
47
- bunx skills add hraness/direct
46
+ npx skills add hraness/direct#v0.7.8
47
+ bunx skills add hraness/direct#v0.7.8
48
48
  ```
49
49
 
50
50
  Restart or reload the agent runner if it does not discover newly installed
@@ -9,6 +9,7 @@ import {
9
9
  extract,
10
10
  weighted,
11
11
  type ActionGenerator,
12
+ type Cell,
12
13
  type Formula,
13
14
  type JSON as BombadilJson,
14
15
  type Tree,
@@ -27,8 +28,38 @@ const DIRECT_BROWSER_BRIDGE_SCHEMA = "direct.browser-bridge/v2";
27
28
  const DIRECT_SESSION_MANIFEST_SCHEMA = "direct.session-manifest/v1";
28
29
  const DIRECT_PROBE_SCHEMA = "direct.probe/v1";
29
30
  const MAX_RAW_CONTRACT_CHARACTERS = 2_000_000;
31
+ const MAX_NAMED_SNAPSHOT_CANONICAL_BYTES = 2 * 1024 * 1024;
32
+ const MAX_NAMED_SNAPSHOT_JSON_DEPTH = 64;
30
33
  const BRIDGE_KEYS = new Set(["manifest", "reset", "schema", "snapshot"]);
31
34
  const UNSAFE_CLICK_INPUT_TYPES = new Set(["image", "reset", "submit"]);
35
+ const UNSAFE_CLICK_LABEL_PHRASES = [
36
+ "clear",
37
+ "close",
38
+ "delete",
39
+ "discard",
40
+ "erase",
41
+ "log out",
42
+ "remove",
43
+ "reset",
44
+ "sign out",
45
+ "unlink",
46
+ ] as const;
47
+ const SNAPSHOT_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_.:/-]*$/u;
48
+ const RESERVED_SNAPSHOT_NAMES = new Set([
49
+ "direct",
50
+ "__proto__",
51
+ "constructor",
52
+ "prototype",
53
+ ]);
54
+ const RESOURCE_LEAK_OPTION_KEYS = new Set(["growthLimit", "metric", "windowMillis"]);
55
+ const RESOURCE_METRICS = [
56
+ "dom_nodes",
57
+ "js_event_listeners",
58
+ "js_heap_total",
59
+ "js_heap_used",
60
+ "layout_objects",
61
+ ] as const;
62
+ const RESOURCE_METRIC_SET = new Set<string>(RESOURCE_METRICS);
32
63
 
33
64
  export interface DirectBombadilObservation {
34
65
  readonly [key: string | number | symbol]: BombadilJson;
@@ -48,12 +79,32 @@ export interface DirectBombadilObservation {
48
79
  }
49
80
 
50
81
  export interface DirectBombadilProperties {
82
+ readonly startupContract: Formula;
51
83
  readonly exactContract: Formula;
52
84
  readonly stableCatalog: Formula;
53
85
  readonly noDeclaredViolations: Formula;
54
86
  readonly eventualQuiescence: Formula;
55
87
  }
56
88
 
89
+ function observationHasExactContract(
90
+ observation: DirectBombadilObservation,
91
+ ): boolean {
92
+ return observation.contractValid
93
+ && observation.activeSource === "scenario"
94
+ && observation.activeScenario.length > 0
95
+ && observation.activeRoute.length > 0
96
+ && observation.activationHash.length > 0
97
+ && observation.catalogHash.length > 0;
98
+ }
99
+
100
+ export type DirectBombadilResourceMetric = (typeof RESOURCE_METRICS)[number];
101
+
102
+ export interface DirectBombadilResourceLeakOptions {
103
+ readonly growthLimit: number;
104
+ readonly metric: DirectBombadilResourceMetric;
105
+ readonly windowMillis: number;
106
+ }
107
+
57
108
  function safeClickAction(action: ActionTemplate): boolean {
58
109
  if (typeof action !== "object" || action === null) return false;
59
110
  const candidate = "Click" in action
@@ -67,11 +118,19 @@ function safeClickAction(action: ActionTemplate): boolean {
67
118
  const inputType = fingerprint.inputType?.toLowerCase() ?? "";
68
119
  const labels = [fingerprint.accessibleName, fingerprint.textContent]
69
120
  .filter((label): label is string => label !== null)
70
- .map((label) => label.trim().toLowerCase());
121
+ .map((label) => label
122
+ .trim()
123
+ .toLowerCase()
124
+ .replace(/[^\p{L}\p{N}]+/gu, " "));
125
+ const hasUnsafeLabel = labels.some((label) => {
126
+ const padded = ` ${label} `;
127
+ return UNSAFE_CLICK_LABEL_PHRASES.some((phrase) => padded.includes(` ${phrase} `));
128
+ });
71
129
  return fingerprint.href === null
72
130
  && tag !== "a"
131
+ && tag !== "label"
73
132
  && fingerprint.role?.toLowerCase() !== "link"
74
- && !labels.includes("reset")
133
+ && !hasUnsafeLabel
75
134
  && !UNSAFE_CLICK_INPUT_TYPES.has(inputType)
76
135
  && (tag !== "button" || inputType === "button");
77
136
  }
@@ -100,7 +159,8 @@ function pruneActionTree<Action>(
100
159
 
101
160
  /**
102
161
  * Builds a browser action generator without reload/history actions or visible
103
- * navigation and submission click targets, keeping Direct continuously bound.
162
+ * navigation, submission, reset, or destructive click targets, keeping Direct
163
+ * continuously bound.
104
164
  */
105
165
  export function createDirectBombadilActions(): ActionGenerator<ActionTemplate> {
106
166
  const safeClicks = actions(() => pruneActionTree(clicks.generate(), safeClickAction) ?? []);
@@ -126,6 +186,72 @@ function hasExactKeys(
126
186
  return keys.length === expected.size && keys.every((key) => expected.has(key));
127
187
  }
128
188
 
189
+ /**
190
+ * Builds Bombadil's documented sliding-window resource growth invariant from
191
+ * the public browser state because 0.7.2 omits its extras module from exports.
192
+ */
193
+ export function createDirectBombadilResourceLeakProperty(
194
+ options: DirectBombadilResourceLeakOptions,
195
+ ): Formula {
196
+ if (!isRecord(options) || !hasExactKeys(options, RESOURCE_LEAK_OPTION_KEYS)) {
197
+ throw new Error("Bombadil resource leak options must contain metric, growthLimit, and windowMillis");
198
+ }
199
+ if (typeof options.metric !== "string" || !RESOURCE_METRIC_SET.has(options.metric)) {
200
+ throw new Error("Bombadil resource leak metric is unsupported");
201
+ }
202
+ if (
203
+ typeof options.growthLimit !== "number"
204
+ || !Number.isFinite(options.growthLimit)
205
+ || options.growthLimit <= 0
206
+ || options.growthLimit > Number.MAX_SAFE_INTEGER
207
+ ) {
208
+ throw new Error("Bombadil resource leak growthLimit must be a positive finite safe number");
209
+ }
210
+ if (
211
+ typeof options.windowMillis !== "number"
212
+ || !Number.isSafeInteger(options.windowMillis)
213
+ || options.windowMillis < 1
214
+ || options.windowMillis > 300_000
215
+ ) {
216
+ throw new Error("Bombadil resource leak windowMillis must be an integer between 1 and 300000");
217
+ }
218
+ const metric = options.metric;
219
+ const samples: Array<{ readonly timestamp: number; readonly value: number }> = [];
220
+ let previousTimestamp = -1;
221
+ const window = extract<BombadilBrowserState, {
222
+ readonly baseline: number;
223
+ readonly valid: boolean;
224
+ readonly value: number;
225
+ }>((state) => {
226
+ const timestamp = state.resources.timestamp * 1_000;
227
+ const value = state.resources[metric];
228
+ if (
229
+ !Number.isFinite(timestamp)
230
+ || timestamp < 0
231
+ || timestamp < previousTimestamp
232
+ || !Number.isFinite(value)
233
+ || value < 0
234
+ ) {
235
+ return { baseline: 0, valid: false, value: 0 };
236
+ }
237
+ previousTimestamp = timestamp;
238
+ samples.push({ timestamp, value });
239
+ const cutoff = timestamp - options.windowMillis;
240
+ while (samples.length > 2 && (samples[1]?.timestamp ?? Number.POSITIVE_INFINITY) <= cutoff) {
241
+ samples.shift();
242
+ }
243
+ return {
244
+ baseline: samples[0]?.value ?? value,
245
+ valid: true,
246
+ value,
247
+ };
248
+ });
249
+ return always(() =>
250
+ window.current.valid
251
+ && window.current.value - window.current.baseline <= options.growthLimit
252
+ );
253
+ }
254
+
129
255
  function invalidObservation(bridgePresent = false): DirectBombadilObservation {
130
256
  return {
131
257
  activationHash: "",
@@ -150,6 +276,99 @@ function boundedJsonClone(value: unknown): BombadilJson | null {
150
276
  return JSON.parse(source) as BombadilJson;
151
277
  }
152
278
 
279
+ function cloneNamedSnapshotJson(
280
+ value: unknown,
281
+ depth = 0,
282
+ ancestors: WeakSet<object> = new WeakSet<object>(),
283
+ ): BombadilJson | undefined {
284
+ if (depth > MAX_NAMED_SNAPSHOT_JSON_DEPTH) return undefined;
285
+ if (value === null || typeof value === "boolean" || typeof value === "string") {
286
+ return value;
287
+ }
288
+ if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
289
+ if (typeof value !== "object") return undefined;
290
+ if (ancestors.has(value)) return undefined;
291
+ ancestors.add(value);
292
+ try {
293
+ if (Array.isArray(value)) {
294
+ const cloned: BombadilJson[] = [];
295
+ for (const entry of value) {
296
+ const child = cloneNamedSnapshotJson(entry, depth + 1, ancestors);
297
+ if (child === undefined) return undefined;
298
+ cloned.push(child);
299
+ }
300
+ return cloned;
301
+ }
302
+ const clonedEntries: [string, BombadilJson][] = [];
303
+ for (const key of Object.keys(value)) {
304
+ const child = cloneNamedSnapshotJson(
305
+ Reflect.get(value, key),
306
+ depth + 1,
307
+ ancestors,
308
+ );
309
+ if (child === undefined) return undefined;
310
+ clonedEntries.push([key, child]);
311
+ }
312
+ return Object.fromEntries(clonedEntries) as BombadilJson;
313
+ } finally {
314
+ ancestors.delete(value);
315
+ }
316
+ }
317
+
318
+ function boundedNamedSnapshotJson(value: unknown): BombadilJson | undefined {
319
+ const cloned = cloneNamedSnapshotJson(value);
320
+ if (cloned === undefined) return undefined;
321
+ const source = JSON.stringify(cloned);
322
+ return new TextEncoder().encode(source).byteLength
323
+ <= MAX_NAMED_SNAPSHOT_CANONICAL_BYTES
324
+ ? cloned
325
+ : undefined;
326
+ }
327
+
328
+ /**
329
+ * Creates a named, bounded JSON extractor that fails closed to an explicit
330
+ * fallback when a page getter throws or returns non-JSON or oversized data.
331
+ */
332
+ export function createDirectBombadilNamedSnapshot<T extends BombadilJson>(options: {
333
+ readonly fallback: T;
334
+ readonly name: string;
335
+ readonly read: (state: BombadilBrowserState) => unknown;
336
+ readonly validate: (value: BombadilJson) => value is T;
337
+ }): Cell<T> {
338
+ if (
339
+ options.name.length === 0
340
+ || options.name.length > 128
341
+ || !SNAPSHOT_NAME_PATTERN.test(options.name)
342
+ || RESERVED_SNAPSHOT_NAMES.has(options.name)
343
+ ) {
344
+ throw new Error(
345
+ "Bombadil snapshot name must be a safe, unreserved 1-128 character identifier",
346
+ );
347
+ }
348
+ const validate = (value: unknown): T | undefined => {
349
+ const owned = boundedNamedSnapshotJson(value);
350
+ return owned !== undefined && options.validate(owned) ? owned : undefined;
351
+ };
352
+ let fallback: T | undefined;
353
+ try {
354
+ fallback = validate(options.fallback);
355
+ } catch {
356
+ fallback = undefined;
357
+ }
358
+ if (fallback === undefined) {
359
+ throw new Error(
360
+ "Bombadil snapshot fallback must be bounded JSON accepted by validate",
361
+ );
362
+ }
363
+ return extract<BombadilBrowserState, T>((state) => {
364
+ try {
365
+ return validate(options.read(state)) ?? fallback;
366
+ } catch {
367
+ return fallback;
368
+ }
369
+ }).named(options.name);
370
+ }
371
+
153
372
  function readNonNegativeCounters(value: unknown): {
154
373
  readonly valid: boolean;
155
374
  readonly values: number[];
@@ -247,39 +466,50 @@ export function readDirectBombadilObservation(
247
466
  }
248
467
  }
249
468
 
250
- /** Builds the four Direct invariants used by Bombadil browser campaigns. */
469
+ /** Builds bounded startup plus strict recurring Direct browser invariants. */
251
470
  export function createDirectBombadilProperties(): DirectBombadilProperties {
252
- const direct = extract<BombadilBrowserState, DirectBombadilObservation>((state) =>
253
- readDirectBombadilObservation(state.window)
254
- ).named("direct");
471
+ let initial: Readonly<{
472
+ activationHash: string;
473
+ activeRoute: string;
474
+ activeScenario: string;
475
+ catalogHash: string;
476
+ }> | null = null;
477
+ const direct = extract<BombadilBrowserState, DirectBombadilObservation>((state) => {
478
+ const observation = readDirectBombadilObservation(state.window);
479
+ if (initial === null && observationHasExactContract(observation)) {
480
+ initial = Object.freeze({
481
+ activationHash: observation.activationHash,
482
+ activeRoute: observation.activeRoute,
483
+ activeScenario: observation.activeScenario,
484
+ catalogHash: observation.catalogHash,
485
+ });
486
+ }
487
+ return observation;
488
+ }).named("direct");
255
489
 
256
- const exactContract = always(
257
- eventually(() =>
258
- direct.current.contractValid
259
- && direct.current.activeSource === "scenario"
260
- && direct.current.activeScenario.length > 0
261
- && direct.current.activeRoute.length > 0
262
- && direct.current.activationHash.length > 0
263
- ).within(10, "seconds"),
264
- );
265
- const stableCatalog = always(
266
- eventually(() =>
267
- direct.current.contractValid
268
- && direct.current.catalogHash.length > 0
269
- ).within(10, "seconds"),
270
- );
271
- const noDeclaredViolations = always(
272
- eventually(() =>
273
- direct.current.contractValid
274
- && direct.current.violationsValid
275
- && direct.current.violations.every((value: number) => value === 0)
276
- ).within(10, "seconds"),
277
- );
490
+ const startupContract = eventually(() => initial !== null).within(10, "seconds");
491
+ const exactContract = always(() => initial === null || (
492
+ observationHasExactContract(direct.current)
493
+ && direct.current.activationHash === initial.activationHash
494
+ && direct.current.activeRoute === initial.activeRoute
495
+ && direct.current.activeScenario === initial.activeScenario
496
+ ));
497
+ const stableCatalog = always(() => initial === null || (
498
+ observationHasExactContract(direct.current)
499
+ && direct.current.catalogHash === initial.catalogHash
500
+ ));
501
+ const noDeclaredViolations = always(() => initial === null || (
502
+ observationHasExactContract(direct.current)
503
+ && direct.current.violationsValid
504
+ && direct.current.violations.every((value: number) => value === 0)
505
+ ));
278
506
  const eventualQuiescence = always(
279
- eventually(() => direct.current.isQuiescent).within(10, "seconds"),
507
+ eventually(() => initial !== null && direct.current.isQuiescent)
508
+ .within(10, "seconds"),
280
509
  );
281
510
 
282
511
  return Object.freeze({
512
+ startupContract,
283
513
  exactContract,
284
514
  stableCatalog,
285
515
  noDeclaredViolations,