@harness-fe/runtime 3.0.1 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/capture.ts CHANGED
@@ -1,110 +1,140 @@
1
1
  /**
2
- * Console + network + error capture. Monkey-patches in place once on init.
2
+ * CaptureStore thin adapter on top of `@harness-fe/sandbox`.
3
3
  *
4
- * Network capture covers fetch + XMLHttpRequest. Body capture is opt-in
5
- * per-request to keep memory bounded.
4
+ * Sandbox does the actual browser-API patching (fetch / xhr / ws / storage /
5
+ * navigation / console / errors / globals / indexeddb) and exposes a unified
6
+ * observer + interceptor surface. This module only:
7
+ * 1. Installs sandbox with the runtime's `onEvent` callback wired in.
8
+ * 2. Adapts each `SandboxEvent` into the harness-fe protocol shape
9
+ * (`NetworkEntry` / `WsEntry` / `StorageEntry` / `ConsoleEntry` /
10
+ * `ErrorEntry`) so the daemon's existing ingestion + tail tools keep
11
+ * working unchanged.
12
+ * 3. Mirrors events into bounded `RingBuffer`s so the runtime's
13
+ * `console.tail` / `network.tail` / `ws.tail` / `storage.tail` MCP tool
14
+ * handlers have something to read.
15
+ *
16
+ * Identity / interceptor / reentry safety all live in sandbox.
6
17
  */
7
18
 
8
19
  import type {
9
20
  ConsoleEntry,
10
21
  ErrorEntry,
11
22
  NetworkEntry,
23
+ WsEntry,
24
+ StorageEntry,
25
+ NavigationEntry,
26
+ GlobalsEntry,
27
+ IndexedDbEntry,
12
28
  } from '@harness-fe/protocol';
29
+ import {
30
+ installSandbox,
31
+ type SandboxEvent,
32
+ type SandboxHandle,
33
+ type FetchReqObservation,
34
+ type FetchResObservation,
35
+ type WsObservation,
36
+ type StorageObservation,
37
+ type ConsoleObservation,
38
+ type ErrorObservation,
39
+ type NavigationObservation,
40
+ type GlobalsObservation,
41
+ type IndexedDbObservation,
42
+ } from '@harness-fe/sandbox';
13
43
  import { RingBuffer } from './buffer.js';
14
- import { installFetchPatch } from './fetchPatch.js';
15
- import { installXhrPatch } from './xhrPatch.js';
16
44
 
17
45
  const CONSOLE_CAP = 500;
18
46
  const NETWORK_CAP = 200;
19
47
  const ERROR_CAP = 200;
48
+ const WS_CAP = 200;
49
+ const STORAGE_CAP = 200;
50
+ const NAVIGATION_CAP = 100;
51
+ const GLOBALS_CAP = 200;
52
+ const INDEXEDDB_CAP = 200;
20
53
 
21
54
  export class CaptureStore {
22
55
  readonly console = new RingBuffer<ConsoleEntry>(CONSOLE_CAP);
23
56
  readonly network = new RingBuffer<NetworkEntry>(NETWORK_CAP);
24
57
  readonly errors = new RingBuffer<ErrorEntry>(ERROR_CAP);
58
+ readonly ws = new RingBuffer<WsEntry>(WS_CAP);
59
+ readonly storage = new RingBuffer<StorageEntry>(STORAGE_CAP);
60
+ readonly navigation = new RingBuffer<NavigationEntry>(NAVIGATION_CAP);
61
+ readonly globals = new RingBuffer<GlobalsEntry>(GLOBALS_CAP);
62
+ readonly indexeddb = new RingBuffer<IndexedDbEntry>(INDEXEDDB_CAP);
63
+
64
+ private handle?: SandboxHandle;
25
65
 
26
- private installed = false;
27
- private fetchDispose?: () => void;
28
- private xhrDispose?: () => void;
29
-
30
- install(onEvent: (name: string, payload: unknown) => void): void {
31
- if (this.installed) return;
32
- this.installed = true;
33
- this.installConsole(onEvent);
34
- this.installFetch(onEvent);
35
- this.installXhr(onEvent);
36
- this.installErrors(onEvent);
66
+ install(
67
+ onEvent: (name: string, payload: unknown) => void,
68
+ opts: { daemonUrl?: string } = {},
69
+ ): void {
70
+ if (this.handle) return;
71
+ const selfUrls = opts.daemonUrl ? [opts.daemonUrl] : undefined;
72
+ this.handle = installSandbox({
73
+ selfUrls,
74
+ onEvent: (e) => this.adapt(e, onEvent),
75
+ });
37
76
  }
38
77
 
39
78
  dispose(): void {
40
- this.fetchDispose?.();
41
- this.fetchDispose = undefined;
42
- this.xhrDispose?.();
43
- this.xhrDispose = undefined;
44
- this.installed = false;
79
+ this.handle?.dispose();
80
+ this.handle = undefined;
45
81
  }
46
82
 
47
- private installConsole(onEvent: (name: string, payload: unknown) => void): void {
48
- const methods: Array<ConsoleEntry['level']> = ['log', 'info', 'warn', 'error', 'debug'];
49
- for (const level of methods) {
50
- const original = console[level].bind(console);
51
- console[level] = (...args: unknown[]) => {
52
- const entry: ConsoleEntry = {
53
- ts: Date.now(),
54
- level,
55
- args: args.map(safeClone),
56
- };
83
+ private adapt(e: SandboxEvent, onEvent: (name: string, payload: unknown) => void): void {
84
+ switch (e.source) {
85
+ case 'fetch':
86
+ case 'xhr': {
87
+ const entry = adaptFetchLike(e);
88
+ this.network.push(entry);
89
+ onEvent('network', entry);
90
+ return;
91
+ }
92
+ case 'ws': {
93
+ const entry = adaptWs(e);
94
+ this.ws.push(entry);
95
+ onEvent('ws', entry);
96
+ return;
97
+ }
98
+ case 'storage': {
99
+ const entry = adaptStorage(e);
100
+ if (entry) {
101
+ this.storage.push(entry);
102
+ onEvent('storage', entry);
103
+ }
104
+ return;
105
+ }
106
+ case 'console': {
107
+ const entry = adaptConsole(e);
57
108
  this.console.push(entry);
58
109
  onEvent('console', entry);
59
- original(...args);
60
- };
110
+ return;
111
+ }
112
+ case 'errors': {
113
+ const entry = adaptError(e);
114
+ this.errors.push(entry);
115
+ onEvent('error', entry);
116
+ return;
117
+ }
118
+ case 'navigation': {
119
+ const entry = adaptNavigation(e);
120
+ this.navigation.push(entry);
121
+ onEvent('navigation', entry);
122
+ return;
123
+ }
124
+ case 'globals': {
125
+ const entry = adaptGlobals(e);
126
+ this.globals.push(entry);
127
+ onEvent('globals', entry);
128
+ return;
129
+ }
130
+ case 'indexeddb': {
131
+ const entry = adaptIndexedDb(e);
132
+ this.indexeddb.push(entry);
133
+ onEvent('indexeddb', entry);
134
+ return;
135
+ }
61
136
  }
62
137
  }
63
-
64
- private installFetch(onEvent: (name: string, payload: unknown) => void): void {
65
- this.fetchDispose = installFetchPatch({
66
- onEntry: (entry) => {
67
- this.network.push(entry);
68
- onEvent('network', entry);
69
- },
70
- });
71
- }
72
-
73
- private installXhr(onEvent: (name: string, payload: unknown) => void): void {
74
- this.xhrDispose = installXhrPatch({
75
- onEntry: (entry) => {
76
- this.network.push(entry);
77
- onEvent('network', entry);
78
- },
79
- });
80
- }
81
-
82
- private installErrors(onEvent: (name: string, payload: unknown) => void): void {
83
- if (typeof window === 'undefined') return;
84
- window.addEventListener('error', (e: ErrorEvent) => {
85
- const entry: ErrorEntry = {
86
- ts: Date.now(),
87
- message: e.message,
88
- stack: e.error?.stack,
89
- source: e.filename ? `${e.filename}:${e.lineno}:${e.colno}` : undefined,
90
- };
91
- this.errors.push(entry);
92
- onEvent('error', entry);
93
- });
94
- window.addEventListener('unhandledrejection', (e: PromiseRejectionEvent) => {
95
- const reason: unknown = e.reason;
96
- const message =
97
- reason instanceof Error ? reason.message : String(reason ?? 'unhandled rejection');
98
- const stack = reason instanceof Error ? reason.stack : undefined;
99
- const entry: ErrorEntry = {
100
- ts: Date.now(),
101
- message: `Unhandled: ${message}`,
102
- stack,
103
- };
104
- this.errors.push(entry);
105
- onEvent('error', entry);
106
- });
107
- }
108
138
  }
109
139
 
110
140
  let captureStoreSingleton: CaptureStore | undefined;
@@ -113,14 +143,130 @@ export function getCaptureStore(): CaptureStore {
113
143
  return captureStoreSingleton;
114
144
  }
115
145
 
116
- function safeClone(value: unknown): unknown {
117
- if (value === null) return null;
118
- if (typeof value === 'object') {
119
- try {
120
- return JSON.parse(JSON.stringify(value));
121
- } catch {
122
- return String(value);
123
- }
146
+ // ────────────────────────────────────────────────────────────────────
147
+ // SandboxEvent harness-fe protocol entry adapters
148
+ // ────────────────────────────────────────────────────────────────────
149
+
150
+ function adaptFetchLike(e: SandboxEvent & { source: 'fetch' | 'xhr' }): NetworkEntry {
151
+ const d = e.data as FetchReqObservation | FetchResObservation;
152
+ if (e.kind === 'req') {
153
+ const r = d as FetchReqObservation;
154
+ return {
155
+ ts: e.ts,
156
+ id: r.id,
157
+ phase: 'req',
158
+ method: r.method,
159
+ url: r.url,
160
+ requestHeaders: r.headers,
161
+ requestBody: r.body,
162
+ requestBodyTruncated: r.bodyTruncated || undefined,
163
+ initiator: e.initiator,
164
+ };
124
165
  }
125
- return value;
166
+ const r = d as FetchResObservation;
167
+ return {
168
+ ts: e.ts,
169
+ id: r.id,
170
+ phase: 'res',
171
+ method: r.method,
172
+ url: r.url,
173
+ status: r.status,
174
+ durationMs: r.durationMs,
175
+ responseHeaders: r.headers,
176
+ responseBody: r.body,
177
+ responseBodyTruncated: r.bodyTruncated || undefined,
178
+ error: r.error,
179
+ initiator: e.initiator,
180
+ };
181
+ }
182
+
183
+ function adaptWs(e: SandboxEvent & { source: 'ws' }): WsEntry {
184
+ const d = e.data as WsObservation;
185
+ return {
186
+ ts: e.ts,
187
+ id: d.id,
188
+ phase: d.phase,
189
+ url: d.url,
190
+ protocols: d.protocols,
191
+ payload: d.payload,
192
+ payloadTruncated: d.payloadTruncated,
193
+ code: d.code,
194
+ reason: d.reason,
195
+ wasClean: d.wasClean,
196
+ initiator: e.initiator,
197
+ };
198
+ }
199
+
200
+ function adaptStorage(e: SandboxEvent & { source: 'storage' }): StorageEntry | null {
201
+ const d = e.data as StorageObservation;
202
+ // Sandbox emits a 'get' op; harness-fe protocol storage only models set/remove/clear.
203
+ if (d.op === 'get') return null;
204
+ return {
205
+ ts: e.ts,
206
+ op: d.op,
207
+ which: d.which,
208
+ key: d.key,
209
+ value: d.value,
210
+ crossTab: d.crossTab,
211
+ initiator: e.initiator,
212
+ };
213
+ }
214
+
215
+ function adaptConsole(e: SandboxEvent & { source: 'console' }): ConsoleEntry {
216
+ const d = e.data as ConsoleObservation;
217
+ return {
218
+ ts: e.ts,
219
+ level: d.level,
220
+ args: d.args,
221
+ };
222
+ }
223
+
224
+ function adaptError(e: SandboxEvent & { source: 'errors' }): ErrorEntry {
225
+ const d = e.data as ErrorObservation;
226
+ return {
227
+ ts: e.ts,
228
+ message: d.message,
229
+ stack: d.stack,
230
+ source: d.source,
231
+ };
232
+ }
233
+
234
+ function adaptNavigation(e: SandboxEvent & { source: 'navigation' }): NavigationEntry {
235
+ const d = e.data as NavigationObservation;
236
+ return {
237
+ ts: e.ts,
238
+ kind: d.kind,
239
+ url: d.url,
240
+ state: d.state,
241
+ replace: d.replace,
242
+ initiator: e.initiator,
243
+ };
244
+ }
245
+
246
+ function adaptGlobals(e: SandboxEvent & { source: 'globals' }): GlobalsEntry {
247
+ const d = e.data as GlobalsObservation;
248
+ return {
249
+ ts: e.ts,
250
+ op: d.op,
251
+ key: d.key,
252
+ value: d.value,
253
+ previousValue: d.previousValue,
254
+ initiator: e.initiator,
255
+ };
256
+ }
257
+
258
+ function adaptIndexedDb(e: SandboxEvent & { source: 'indexeddb' }): IndexedDbEntry {
259
+ const d = e.data as IndexedDbObservation;
260
+ return {
261
+ ts: e.ts,
262
+ op: d.op,
263
+ db: d.db,
264
+ version: d.version,
265
+ store: d.store,
266
+ key: d.key,
267
+ value: d.value,
268
+ success: d.success,
269
+ error: d.error,
270
+ initiator: e.initiator,
271
+ };
126
272
  }
package/src/client.ts CHANGED
@@ -163,7 +163,11 @@ export class RuntimeClient {
163
163
 
164
164
 
165
165
  start(): void {
166
- this.ctx.capture.install((name, payload) => this.sendEvent(name, payload));
166
+ const daemonUrl = this.opts.mcpUrl ?? `ws://127.0.0.1:${DEFAULT_WS_PORT}`;
167
+ this.ctx.capture.install(
168
+ (name, payload) => this.sendEvent(name, payload),
169
+ { daemonUrl },
170
+ );
167
171
  this.recorder.start();
168
172
  this.connect();
169
173
  }
package/src/commands.ts CHANGED
@@ -305,21 +305,208 @@ export const commandHandlers: Record<string, CommandHandler> = {
305
305
  },
306
306
 
307
307
  [COMMAND.CONSOLE_TAIL]: async (raw, ctx) => {
308
- const args = raw as { n: number };
309
- return { entries: ctx.capture.console.tail(args.n) };
308
+ const args = raw as TailArgs & { level?: string };
309
+ const all = ctx.capture.console.tail(args.n ?? 20);
310
+ return { entries: filterTail(all, args, (e) => {
311
+ if (args.level && e.level !== args.level) return undefined;
312
+ return JSON.stringify({ level: e.level, args: e.args });
313
+ }) };
310
314
  },
311
315
 
312
316
  [COMMAND.NETWORK_TAIL]: async (raw, ctx) => {
313
- const args = raw as { n: number };
314
- return { entries: ctx.capture.network.tail(args.n) };
317
+ const args = raw as TailArgs & {
318
+ urlContains?: string;
319
+ method?: string;
320
+ statusCode?: number;
321
+ };
322
+ const all = ctx.capture.network.tail(args.n ?? 20);
323
+ return { entries: filterTail(all, args, (e) => {
324
+ if (args.urlContains && !e.url.includes(args.urlContains)) return undefined;
325
+ if (args.method && e.method.toUpperCase() !== args.method.toUpperCase()) return undefined;
326
+ if (args.statusCode !== undefined && e.status !== args.statusCode) return undefined;
327
+ return JSON.stringify({ url: e.url, method: e.method, requestBody: e.requestBody, responseBody: e.responseBody });
328
+ }) };
315
329
  },
316
330
 
317
331
  [COMMAND.ERRORS_TAIL]: async (raw, ctx) => {
318
- const args = raw as { n: number };
319
- return { entries: ctx.capture.errors.tail(args.n) };
332
+ const args = raw as TailArgs;
333
+ const all = ctx.capture.errors.tail(args.n ?? 20);
334
+ return { entries: filterTail(all, args, (e) =>
335
+ JSON.stringify({ message: e.message, stack: e.stack, source: e.source }),
336
+ ) };
337
+ },
338
+
339
+ [COMMAND.WS_TAIL]: async (raw, ctx) => {
340
+ const args = raw as TailArgs & { phase?: string };
341
+ const all = ctx.capture.ws.tail(args.n ?? 20);
342
+ return { entries: filterTail(all, args, (e) => {
343
+ if (args.phase && e.phase !== args.phase) return undefined;
344
+ return JSON.stringify({ url: e.url, payload: e.payload, reason: e.reason });
345
+ }) };
346
+ },
347
+
348
+ [COMMAND.NETWORK_WAIT_FOR]: async (raw, ctx) => {
349
+ const args = raw as {
350
+ urlContains?: string;
351
+ urlRegex?: string;
352
+ method?: string;
353
+ statusCode?: number;
354
+ timeoutMs?: number;
355
+ };
356
+ const timeoutMs = args.timeoutMs ?? 10_000;
357
+ const deadline = Date.now() + timeoutMs;
358
+ const regex = args.urlRegex ? safeRegex(args.urlRegex) : undefined;
359
+ // Anchor on the existing buffer head so we only consider new requests
360
+ // (otherwise an old matching entry would resolve immediately).
361
+ const baselineLen = ctx.capture.network.size();
362
+ while (Date.now() < deadline) {
363
+ const all = ctx.capture.network.tail(500);
364
+ const newOnes = all.slice(Math.max(0, all.length - (ctx.capture.network.size() - baselineLen)));
365
+ for (const e of newOnes) {
366
+ if (args.urlContains && !e.url.includes(args.urlContains)) continue;
367
+ if (regex && !regex.test(e.url)) continue;
368
+ if (args.method && e.method.toUpperCase() !== args.method.toUpperCase()) continue;
369
+ if (args.statusCode !== undefined && e.status !== args.statusCode) continue;
370
+ return { ok: true, entry: e, after: Date.now() };
371
+ }
372
+ await new Promise((r) => setTimeout(r, 50));
373
+ }
374
+ throw new Error(`network.wait_for: no matching request within ${timeoutMs}ms`);
375
+ },
376
+
377
+ [COMMAND.NETWORK_WAIT_FOR_IDLE]: async (raw, ctx) => {
378
+ const args = raw as { idleMs?: number; timeoutMs?: number };
379
+ const idleMs = args.idleMs ?? 500;
380
+ const timeoutMs = args.timeoutMs ?? 10_000;
381
+ const deadline = Date.now() + timeoutMs;
382
+ let lastSize = ctx.capture.network.size();
383
+ let stableSince = Date.now();
384
+ while (Date.now() < deadline) {
385
+ const currentSize = ctx.capture.network.size();
386
+ if (currentSize !== lastSize) {
387
+ lastSize = currentSize;
388
+ stableSince = Date.now();
389
+ } else if (Date.now() - stableSince >= idleMs) {
390
+ return { ok: true, idleFor: Date.now() - stableSince, after: Date.now() };
391
+ }
392
+ await new Promise((r) => setTimeout(r, 50));
393
+ }
394
+ throw new Error(`network.wait_for_idle: never quiet for ${idleMs}ms within ${timeoutMs}ms`);
395
+ },
396
+
397
+ [COMMAND.NETWORK_GET]: async (raw, ctx) => {
398
+ const args = raw as { reqId: string };
399
+ // Return both req + res entries for this id (one or both may exist).
400
+ const all = ctx.capture.network.tail(200);
401
+ const matches = all.filter((e) => e.id === args.reqId);
402
+ return { entries: matches, found: matches.length > 0 };
403
+ },
404
+
405
+ [COMMAND.WS_GET]: async (raw, ctx) => {
406
+ const args = raw as { wsId: string };
407
+ const all = ctx.capture.ws.tail(200);
408
+ const matches = all.filter((e) => e.id === args.wsId);
409
+ return { entries: matches, found: matches.length > 0 };
410
+ },
411
+
412
+ [COMMAND.STORAGE_TAIL]: async (raw, ctx) => {
413
+ const args = raw as TailArgs & {
414
+ which?: string;
415
+ op?: string;
416
+ key?: string;
417
+ };
418
+ const all = ctx.capture.storage.tail(args.n ?? 20);
419
+ return { entries: filterTail(all, args, (e) => {
420
+ if (args.which && e.which !== args.which) return undefined;
421
+ if (args.op && e.op !== args.op) return undefined;
422
+ if (args.key && e.key !== args.key) return undefined;
423
+ return JSON.stringify({ op: e.op, which: e.which, key: e.key, value: e.value });
424
+ }) };
425
+ },
426
+
427
+ [COMMAND.NAVIGATION_TAIL]: async (raw, ctx) => {
428
+ const args = raw as TailArgs & { kind?: string };
429
+ const all = ctx.capture.navigation.tail(args.n ?? 20);
430
+ return { entries: filterTail(all, args, (e) => {
431
+ if (args.kind && e.kind !== args.kind) return undefined;
432
+ return JSON.stringify({ kind: e.kind, url: e.url, replace: e.replace });
433
+ }) };
434
+ },
435
+
436
+ [COMMAND.GLOBALS_TAIL]: async (raw, ctx) => {
437
+ const args = raw as TailArgs & { op?: string; key?: string };
438
+ const all = ctx.capture.globals.tail(args.n ?? 20);
439
+ return { entries: filterTail(all, args, (e) => {
440
+ if (args.op && e.op !== args.op) return undefined;
441
+ if (args.key && e.key !== args.key) return undefined;
442
+ return JSON.stringify({ op: e.op, key: e.key, value: e.value });
443
+ }) };
444
+ },
445
+
446
+ [COMMAND.INDEXEDDB_TAIL]: async (raw, ctx) => {
447
+ const args = raw as TailArgs & { op?: string; store?: string; db?: string };
448
+ const all = ctx.capture.indexeddb.tail(args.n ?? 20);
449
+ return { entries: filterTail(all, args, (e) => {
450
+ if (args.op && e.op !== args.op) return undefined;
451
+ if (args.store && e.store !== args.store) return undefined;
452
+ if (args.db && e.db !== args.db) return undefined;
453
+ return JSON.stringify({ op: e.op, store: e.store, key: e.key });
454
+ }) };
320
455
  },
321
456
  };
322
457
 
458
+ interface TailArgs {
459
+ n?: number;
460
+ filter?: string;
461
+ match?: 'contains' | 'regex';
462
+ }
463
+
464
+ /**
465
+ * Apply caller-supplied filtering to a tail() result. `pickHaystack` returns
466
+ * the string to match against (or `undefined` to drop the entry due to a
467
+ * type-specific narrow like `level` / `urlContains`). The shared `filter`
468
+ * string then runs as substring (default) or regex against the haystack.
469
+ */
470
+ function safeRegex(source: string): RegExp | undefined {
471
+ try {
472
+ return new RegExp(source, 'i');
473
+ } catch {
474
+ return undefined;
475
+ }
476
+ }
477
+
478
+ function filterTail<T>(
479
+ items: T[],
480
+ args: TailArgs,
481
+ pickHaystack: (item: T) => string | undefined,
482
+ ): T[] {
483
+ const filter = args.filter?.trim();
484
+ const useRegex = args.match === 'regex';
485
+ let regex: RegExp | undefined;
486
+ if (filter && useRegex) {
487
+ try {
488
+ regex = new RegExp(filter, 'i');
489
+ } catch {
490
+ // Invalid regex: fall back to substring match rather than throwing.
491
+ regex = undefined;
492
+ }
493
+ }
494
+ const out: T[] = [];
495
+ for (const item of items) {
496
+ const haystack = pickHaystack(item);
497
+ if (haystack === undefined) continue;
498
+ if (filter) {
499
+ if (regex) {
500
+ if (!regex.test(haystack)) continue;
501
+ } else {
502
+ if (!haystack.toLowerCase().includes(filter.toLowerCase())) continue;
503
+ }
504
+ }
505
+ out.push(item);
506
+ }
507
+ return out;
508
+ }
509
+
323
510
  function truncate(s: string, n: number): string {
324
511
  if (s.length <= n) return s;
325
512
  return `${s.slice(0, n)}… (truncated, total ${s.length} chars)`;