@harness-fe/runtime 3.0.1 → 3.1.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/dist/capture.d.ts +36 -1
- package/dist/capture.js +40 -1
- package/dist/client.js +2 -1
- package/dist/commands.js +145 -3
- package/dist/fetchPatch.js +3 -0
- package/dist/initiator.d.ts +20 -0
- package/dist/initiator.js +34 -0
- package/dist/storagePatch.d.ts +23 -0
- package/dist/storagePatch.js +190 -0
- package/dist/wsPatch.d.ts +26 -0
- package/dist/wsPatch.js +172 -0
- package/dist/xhrPatch.js +3 -0
- package/package.json +2 -2
- package/src/capture.ts +47 -1
- package/src/client.ts +5 -1
- package/src/commands.ts +163 -6
- package/src/commandsFilter.test.ts +167 -0
- package/src/commandsNetwork.e2e.test.ts +146 -0
- package/src/fetchPatch.test.ts +14 -0
- package/src/fetchPatch.ts +3 -0
- package/src/initiator.ts +40 -0
- package/src/runtimeClient.e2e.test.ts +246 -0
- package/src/storagePatch.test.ts +137 -0
- package/src/storagePatch.ts +213 -0
- package/src/wsPatch.test.ts +150 -0
- package/src/wsPatch.ts +198 -0
- package/src/xhrPatch.ts +3 -0
package/dist/capture.d.ts
CHANGED
|
@@ -27,6 +27,9 @@ export declare class CaptureStore {
|
|
|
27
27
|
requestBodyTruncated?: boolean | undefined;
|
|
28
28
|
responseBodyTruncated?: boolean | undefined;
|
|
29
29
|
error?: string | undefined;
|
|
30
|
+
initiator?: {
|
|
31
|
+
stack?: string | undefined;
|
|
32
|
+
} | undefined;
|
|
30
33
|
}>;
|
|
31
34
|
readonly errors: RingBuffer<{
|
|
32
35
|
ts: number;
|
|
@@ -34,14 +37,46 @@ export declare class CaptureStore {
|
|
|
34
37
|
stack?: string | undefined;
|
|
35
38
|
source?: string | undefined;
|
|
36
39
|
}>;
|
|
40
|
+
readonly ws: RingBuffer<{
|
|
41
|
+
ts: number;
|
|
42
|
+
id: string;
|
|
43
|
+
phase: "open" | "send" | "recv" | "close";
|
|
44
|
+
url: string;
|
|
45
|
+
protocols?: string[] | undefined;
|
|
46
|
+
payload?: unknown;
|
|
47
|
+
payloadTruncated?: boolean | undefined;
|
|
48
|
+
code?: number | undefined;
|
|
49
|
+
reason?: string | undefined;
|
|
50
|
+
wasClean?: boolean | undefined;
|
|
51
|
+
initiator?: {
|
|
52
|
+
stack?: string | undefined;
|
|
53
|
+
} | undefined;
|
|
54
|
+
}>;
|
|
55
|
+
readonly storage: RingBuffer<{
|
|
56
|
+
op: "set" | "remove" | "clear";
|
|
57
|
+
which: "local" | "session" | "cookie";
|
|
58
|
+
ts: number;
|
|
59
|
+
key?: string | undefined;
|
|
60
|
+
value?: string | undefined;
|
|
61
|
+
crossTab?: boolean | undefined;
|
|
62
|
+
initiator?: {
|
|
63
|
+
stack?: string | undefined;
|
|
64
|
+
} | undefined;
|
|
65
|
+
}>;
|
|
37
66
|
private installed;
|
|
38
67
|
private fetchDispose?;
|
|
39
68
|
private xhrDispose?;
|
|
40
|
-
|
|
69
|
+
private wsDispose?;
|
|
70
|
+
private storageDispose?;
|
|
71
|
+
install(onEvent: (name: string, payload: unknown) => void, opts?: {
|
|
72
|
+
daemonUrl?: string;
|
|
73
|
+
}): void;
|
|
41
74
|
dispose(): void;
|
|
42
75
|
private installConsole;
|
|
43
76
|
private installFetch;
|
|
44
77
|
private installXhr;
|
|
78
|
+
private installWs;
|
|
79
|
+
private installStorage;
|
|
45
80
|
private installErrors;
|
|
46
81
|
}
|
|
47
82
|
export declare function getCaptureStore(): CaptureStore;
|
package/dist/capture.js
CHANGED
|
@@ -7,23 +7,33 @@
|
|
|
7
7
|
import { RingBuffer } from './buffer.js';
|
|
8
8
|
import { installFetchPatch } from './fetchPatch.js';
|
|
9
9
|
import { installXhrPatch } from './xhrPatch.js';
|
|
10
|
+
import { installWsPatch } from './wsPatch.js';
|
|
11
|
+
import { installStoragePatch } from './storagePatch.js';
|
|
10
12
|
const CONSOLE_CAP = 500;
|
|
11
13
|
const NETWORK_CAP = 200;
|
|
12
14
|
const ERROR_CAP = 200;
|
|
15
|
+
const WS_CAP = 200;
|
|
16
|
+
const STORAGE_CAP = 200;
|
|
13
17
|
export class CaptureStore {
|
|
14
18
|
console = new RingBuffer(CONSOLE_CAP);
|
|
15
19
|
network = new RingBuffer(NETWORK_CAP);
|
|
16
20
|
errors = new RingBuffer(ERROR_CAP);
|
|
21
|
+
ws = new RingBuffer(WS_CAP);
|
|
22
|
+
storage = new RingBuffer(STORAGE_CAP);
|
|
17
23
|
installed = false;
|
|
18
24
|
fetchDispose;
|
|
19
25
|
xhrDispose;
|
|
20
|
-
|
|
26
|
+
wsDispose;
|
|
27
|
+
storageDispose;
|
|
28
|
+
install(onEvent, opts = {}) {
|
|
21
29
|
if (this.installed)
|
|
22
30
|
return;
|
|
23
31
|
this.installed = true;
|
|
24
32
|
this.installConsole(onEvent);
|
|
25
33
|
this.installFetch(onEvent);
|
|
26
34
|
this.installXhr(onEvent);
|
|
35
|
+
this.installWs(onEvent, opts.daemonUrl);
|
|
36
|
+
this.installStorage(onEvent);
|
|
27
37
|
this.installErrors(onEvent);
|
|
28
38
|
}
|
|
29
39
|
dispose() {
|
|
@@ -31,6 +41,10 @@ export class CaptureStore {
|
|
|
31
41
|
this.fetchDispose = undefined;
|
|
32
42
|
this.xhrDispose?.();
|
|
33
43
|
this.xhrDispose = undefined;
|
|
44
|
+
this.wsDispose?.();
|
|
45
|
+
this.wsDispose = undefined;
|
|
46
|
+
this.storageDispose?.();
|
|
47
|
+
this.storageDispose = undefined;
|
|
34
48
|
this.installed = false;
|
|
35
49
|
}
|
|
36
50
|
installConsole(onEvent) {
|
|
@@ -65,6 +79,31 @@ export class CaptureStore {
|
|
|
65
79
|
},
|
|
66
80
|
});
|
|
67
81
|
}
|
|
82
|
+
installWs(onEvent, daemonUrl) {
|
|
83
|
+
// Add the daemon URL itself to the denylist so our own bridge
|
|
84
|
+
// connection isn't intercepted (otherwise every event we send
|
|
85
|
+
// would emit a `ws send` that loops back into the outbox).
|
|
86
|
+
const extra = [];
|
|
87
|
+
if (daemonUrl) {
|
|
88
|
+
const escaped = daemonUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
89
|
+
extra.push(new RegExp(`^${escaped}`));
|
|
90
|
+
}
|
|
91
|
+
this.wsDispose = installWsPatch({
|
|
92
|
+
denylist: extra.length > 0 ? [/\/__hfe\//, /sockjs-node/, ...extra] : undefined,
|
|
93
|
+
onEntry: (entry) => {
|
|
94
|
+
this.ws.push(entry);
|
|
95
|
+
onEvent('ws', entry);
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
installStorage(onEvent) {
|
|
100
|
+
this.storageDispose = installStoragePatch({
|
|
101
|
+
onEntry: (entry) => {
|
|
102
|
+
this.storage.push(entry);
|
|
103
|
+
onEvent('storage', entry);
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
}
|
|
68
107
|
installErrors(onEvent) {
|
|
69
108
|
if (typeof window === 'undefined')
|
|
70
109
|
return;
|
package/dist/client.js
CHANGED
|
@@ -111,7 +111,8 @@ export class RuntimeClient {
|
|
|
111
111
|
publishVisitorIdToWindow(this.visitorId);
|
|
112
112
|
}
|
|
113
113
|
start() {
|
|
114
|
-
this.
|
|
114
|
+
const daemonUrl = this.opts.mcpUrl ?? `ws://127.0.0.1:${DEFAULT_WS_PORT}`;
|
|
115
|
+
this.ctx.capture.install((name, payload) => this.sendEvent(name, payload), { daemonUrl });
|
|
115
116
|
this.recorder.start();
|
|
116
117
|
this.connect();
|
|
117
118
|
}
|
package/dist/commands.js
CHANGED
|
@@ -276,17 +276,159 @@ export const commandHandlers = {
|
|
|
276
276
|
},
|
|
277
277
|
[COMMAND.CONSOLE_TAIL]: async (raw, ctx) => {
|
|
278
278
|
const args = raw;
|
|
279
|
-
|
|
279
|
+
const all = ctx.capture.console.tail(args.n ?? 20);
|
|
280
|
+
return { entries: filterTail(all, args, (e) => {
|
|
281
|
+
if (args.level && e.level !== args.level)
|
|
282
|
+
return undefined;
|
|
283
|
+
return JSON.stringify({ level: e.level, args: e.args });
|
|
284
|
+
}) };
|
|
280
285
|
},
|
|
281
286
|
[COMMAND.NETWORK_TAIL]: async (raw, ctx) => {
|
|
282
287
|
const args = raw;
|
|
283
|
-
|
|
288
|
+
const all = ctx.capture.network.tail(args.n ?? 20);
|
|
289
|
+
return { entries: filterTail(all, args, (e) => {
|
|
290
|
+
if (args.urlContains && !e.url.includes(args.urlContains))
|
|
291
|
+
return undefined;
|
|
292
|
+
if (args.method && e.method.toUpperCase() !== args.method.toUpperCase())
|
|
293
|
+
return undefined;
|
|
294
|
+
if (args.statusCode !== undefined && e.status !== args.statusCode)
|
|
295
|
+
return undefined;
|
|
296
|
+
return JSON.stringify({ url: e.url, method: e.method, requestBody: e.requestBody, responseBody: e.responseBody });
|
|
297
|
+
}) };
|
|
284
298
|
},
|
|
285
299
|
[COMMAND.ERRORS_TAIL]: async (raw, ctx) => {
|
|
286
300
|
const args = raw;
|
|
287
|
-
|
|
301
|
+
const all = ctx.capture.errors.tail(args.n ?? 20);
|
|
302
|
+
return { entries: filterTail(all, args, (e) => JSON.stringify({ message: e.message, stack: e.stack, source: e.source })) };
|
|
303
|
+
},
|
|
304
|
+
[COMMAND.WS_TAIL]: async (raw, ctx) => {
|
|
305
|
+
const args = raw;
|
|
306
|
+
const all = ctx.capture.ws.tail(args.n ?? 20);
|
|
307
|
+
return { entries: filterTail(all, args, (e) => {
|
|
308
|
+
if (args.phase && e.phase !== args.phase)
|
|
309
|
+
return undefined;
|
|
310
|
+
return JSON.stringify({ url: e.url, payload: e.payload, reason: e.reason });
|
|
311
|
+
}) };
|
|
312
|
+
},
|
|
313
|
+
[COMMAND.NETWORK_WAIT_FOR]: async (raw, ctx) => {
|
|
314
|
+
const args = raw;
|
|
315
|
+
const timeoutMs = args.timeoutMs ?? 10_000;
|
|
316
|
+
const deadline = Date.now() + timeoutMs;
|
|
317
|
+
const regex = args.urlRegex ? safeRegex(args.urlRegex) : undefined;
|
|
318
|
+
// Anchor on the existing buffer head so we only consider new requests
|
|
319
|
+
// (otherwise an old matching entry would resolve immediately).
|
|
320
|
+
const baselineLen = ctx.capture.network.size();
|
|
321
|
+
while (Date.now() < deadline) {
|
|
322
|
+
const all = ctx.capture.network.tail(500);
|
|
323
|
+
const newOnes = all.slice(Math.max(0, all.length - (ctx.capture.network.size() - baselineLen)));
|
|
324
|
+
for (const e of newOnes) {
|
|
325
|
+
if (args.urlContains && !e.url.includes(args.urlContains))
|
|
326
|
+
continue;
|
|
327
|
+
if (regex && !regex.test(e.url))
|
|
328
|
+
continue;
|
|
329
|
+
if (args.method && e.method.toUpperCase() !== args.method.toUpperCase())
|
|
330
|
+
continue;
|
|
331
|
+
if (args.statusCode !== undefined && e.status !== args.statusCode)
|
|
332
|
+
continue;
|
|
333
|
+
return { ok: true, entry: e, after: Date.now() };
|
|
334
|
+
}
|
|
335
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
336
|
+
}
|
|
337
|
+
throw new Error(`network.wait_for: no matching request within ${timeoutMs}ms`);
|
|
338
|
+
},
|
|
339
|
+
[COMMAND.NETWORK_WAIT_FOR_IDLE]: async (raw, ctx) => {
|
|
340
|
+
const args = raw;
|
|
341
|
+
const idleMs = args.idleMs ?? 500;
|
|
342
|
+
const timeoutMs = args.timeoutMs ?? 10_000;
|
|
343
|
+
const deadline = Date.now() + timeoutMs;
|
|
344
|
+
let lastSize = ctx.capture.network.size();
|
|
345
|
+
let stableSince = Date.now();
|
|
346
|
+
while (Date.now() < deadline) {
|
|
347
|
+
const currentSize = ctx.capture.network.size();
|
|
348
|
+
if (currentSize !== lastSize) {
|
|
349
|
+
lastSize = currentSize;
|
|
350
|
+
stableSince = Date.now();
|
|
351
|
+
}
|
|
352
|
+
else if (Date.now() - stableSince >= idleMs) {
|
|
353
|
+
return { ok: true, idleFor: Date.now() - stableSince, after: Date.now() };
|
|
354
|
+
}
|
|
355
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
356
|
+
}
|
|
357
|
+
throw new Error(`network.wait_for_idle: never quiet for ${idleMs}ms within ${timeoutMs}ms`);
|
|
358
|
+
},
|
|
359
|
+
[COMMAND.NETWORK_GET]: async (raw, ctx) => {
|
|
360
|
+
const args = raw;
|
|
361
|
+
// Return both req + res entries for this id (one or both may exist).
|
|
362
|
+
const all = ctx.capture.network.tail(200);
|
|
363
|
+
const matches = all.filter((e) => e.id === args.reqId);
|
|
364
|
+
return { entries: matches, found: matches.length > 0 };
|
|
365
|
+
},
|
|
366
|
+
[COMMAND.WS_GET]: async (raw, ctx) => {
|
|
367
|
+
const args = raw;
|
|
368
|
+
const all = ctx.capture.ws.tail(200);
|
|
369
|
+
const matches = all.filter((e) => e.id === args.wsId);
|
|
370
|
+
return { entries: matches, found: matches.length > 0 };
|
|
371
|
+
},
|
|
372
|
+
[COMMAND.STORAGE_TAIL]: async (raw, ctx) => {
|
|
373
|
+
const args = raw;
|
|
374
|
+
const all = ctx.capture.storage.tail(args.n ?? 20);
|
|
375
|
+
return { entries: filterTail(all, args, (e) => {
|
|
376
|
+
if (args.which && e.which !== args.which)
|
|
377
|
+
return undefined;
|
|
378
|
+
if (args.op && e.op !== args.op)
|
|
379
|
+
return undefined;
|
|
380
|
+
if (args.key && e.key !== args.key)
|
|
381
|
+
return undefined;
|
|
382
|
+
return JSON.stringify({ op: e.op, which: e.which, key: e.key, value: e.value });
|
|
383
|
+
}) };
|
|
288
384
|
},
|
|
289
385
|
};
|
|
386
|
+
/**
|
|
387
|
+
* Apply caller-supplied filtering to a tail() result. `pickHaystack` returns
|
|
388
|
+
* the string to match against (or `undefined` to drop the entry due to a
|
|
389
|
+
* type-specific narrow like `level` / `urlContains`). The shared `filter`
|
|
390
|
+
* string then runs as substring (default) or regex against the haystack.
|
|
391
|
+
*/
|
|
392
|
+
function safeRegex(source) {
|
|
393
|
+
try {
|
|
394
|
+
return new RegExp(source, 'i');
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
return undefined;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
function filterTail(items, args, pickHaystack) {
|
|
401
|
+
const filter = args.filter?.trim();
|
|
402
|
+
const useRegex = args.match === 'regex';
|
|
403
|
+
let regex;
|
|
404
|
+
if (filter && useRegex) {
|
|
405
|
+
try {
|
|
406
|
+
regex = new RegExp(filter, 'i');
|
|
407
|
+
}
|
|
408
|
+
catch {
|
|
409
|
+
// Invalid regex: fall back to substring match rather than throwing.
|
|
410
|
+
regex = undefined;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
const out = [];
|
|
414
|
+
for (const item of items) {
|
|
415
|
+
const haystack = pickHaystack(item);
|
|
416
|
+
if (haystack === undefined)
|
|
417
|
+
continue;
|
|
418
|
+
if (filter) {
|
|
419
|
+
if (regex) {
|
|
420
|
+
if (!regex.test(haystack))
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
else {
|
|
424
|
+
if (!haystack.toLowerCase().includes(filter.toLowerCase()))
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
out.push(item);
|
|
429
|
+
}
|
|
430
|
+
return out;
|
|
431
|
+
}
|
|
290
432
|
function truncate(s, n) {
|
|
291
433
|
if (s.length <= n)
|
|
292
434
|
return s;
|
package/dist/fetchPatch.js
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* The patch is idempotent (re-install is a no-op) and returns a dispose
|
|
23
23
|
* function that restores the original `window.fetch`.
|
|
24
24
|
*/
|
|
25
|
+
import { captureInitiator } from './initiator.js';
|
|
25
26
|
const DEFAULT_BODY_CAP = 256 * 1024;
|
|
26
27
|
const INTERNAL_FLAG = '__hfeInternal';
|
|
27
28
|
const PATCHED_FLAG = '__hfePatched';
|
|
@@ -56,6 +57,7 @@ export function installFetchPatch(opts) {
|
|
|
56
57
|
const id = generateId();
|
|
57
58
|
const startedAt = performance.now();
|
|
58
59
|
const startedTs = Date.now();
|
|
60
|
+
const initiator = captureInitiator();
|
|
59
61
|
// Emit request record eagerly (req body is read async — second emit
|
|
60
62
|
// updates the record once body is serialized; consumers join by id).
|
|
61
63
|
const reqRecord = {
|
|
@@ -65,6 +67,7 @@ export function installFetchPatch(opts) {
|
|
|
65
67
|
method: meta.method,
|
|
66
68
|
url: meta.url,
|
|
67
69
|
requestHeaders: meta.headers,
|
|
70
|
+
initiator,
|
|
68
71
|
};
|
|
69
72
|
emit(reqRecord);
|
|
70
73
|
cloneRequestBody(input, init, bodyCap).then(({ body, truncated }) => {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Initiator stack capture — answers "who issued this network/storage call?".
|
|
3
|
+
*
|
|
4
|
+
* Called synchronously from inside a patched API (fetch / xhr / ws / storage).
|
|
5
|
+
* `new Error().stack` walks the JS call stack from the V8 perspective:
|
|
6
|
+
* - frame 0: this helper
|
|
7
|
+
* - frame 1: the patched wrapper
|
|
8
|
+
* - frame 2+: caller code
|
|
9
|
+
*
|
|
10
|
+
* We trim the first 2 frames so the returned `stack` starts at the business
|
|
11
|
+
* code that triggered the call. Best-effort: shapes vary across engines, so
|
|
12
|
+
* if the format is unexpected we return the raw stack.
|
|
13
|
+
*
|
|
14
|
+
* Cost: ~0.2–0.5 ms per call on a modern V8. Safe to leave on in development;
|
|
15
|
+
* gated behind NODE_ENV elsewhere so production is unaffected.
|
|
16
|
+
*/
|
|
17
|
+
export interface Initiator {
|
|
18
|
+
stack?: string;
|
|
19
|
+
}
|
|
20
|
+
export declare function captureInitiator(): Initiator;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Initiator stack capture — answers "who issued this network/storage call?".
|
|
3
|
+
*
|
|
4
|
+
* Called synchronously from inside a patched API (fetch / xhr / ws / storage).
|
|
5
|
+
* `new Error().stack` walks the JS call stack from the V8 perspective:
|
|
6
|
+
* - frame 0: this helper
|
|
7
|
+
* - frame 1: the patched wrapper
|
|
8
|
+
* - frame 2+: caller code
|
|
9
|
+
*
|
|
10
|
+
* We trim the first 2 frames so the returned `stack` starts at the business
|
|
11
|
+
* code that triggered the call. Best-effort: shapes vary across engines, so
|
|
12
|
+
* if the format is unexpected we return the raw stack.
|
|
13
|
+
*
|
|
14
|
+
* Cost: ~0.2–0.5 ms per call on a modern V8. Safe to leave on in development;
|
|
15
|
+
* gated behind NODE_ENV elsewhere so production is unaffected.
|
|
16
|
+
*/
|
|
17
|
+
const FRAMES_TO_TRIM = 2;
|
|
18
|
+
export function captureInitiator() {
|
|
19
|
+
const err = new Error();
|
|
20
|
+
const raw = err.stack;
|
|
21
|
+
if (!raw)
|
|
22
|
+
return {};
|
|
23
|
+
const lines = raw.split('\n');
|
|
24
|
+
if (lines.length <= FRAMES_TO_TRIM + 1)
|
|
25
|
+
return { stack: raw };
|
|
26
|
+
// Preserve the "Error" header line + caller frames. Drop the frames
|
|
27
|
+
// representing the helper and the patched wrapper.
|
|
28
|
+
const header = lines[0].startsWith('Error') ? lines[0] : '';
|
|
29
|
+
const callerFrames = lines.slice(FRAMES_TO_TRIM + 1);
|
|
30
|
+
const trimmed = header
|
|
31
|
+
? [header, ...callerFrames].join('\n')
|
|
32
|
+
: callerFrames.join('\n');
|
|
33
|
+
return { stack: trimmed };
|
|
34
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage monkey-patch — captures localStorage / sessionStorage / cookie
|
|
3
|
+
* mutations so agents can answer "who deleted my token?".
|
|
4
|
+
*
|
|
5
|
+
* Safety contract (mirrors fetchPatch / wsPatch):
|
|
6
|
+
* 1. Identity-preserving: replacements use Object.defineProperty so
|
|
7
|
+
* `localStorage.setItem` keeps its prototype membership. Cookie wrapping
|
|
8
|
+
* uses a `document` getter/setter pair on the prototype.
|
|
9
|
+
* 2. Error-isolated: capture failures swallowed; never propagate to callers.
|
|
10
|
+
* 3. No timing or value change: every wrapper calls the original synchronously
|
|
11
|
+
* and returns its result.
|
|
12
|
+
* 4. crossTab events captured via the native `storage` event (no initiator —
|
|
13
|
+
* the mutation happened in another tab so the stack is meaningless here).
|
|
14
|
+
*
|
|
15
|
+
* Idempotent. Returns a dispose function.
|
|
16
|
+
*/
|
|
17
|
+
import type { StorageEntry } from '@harness-fe/protocol';
|
|
18
|
+
export interface StoragePatchOptions {
|
|
19
|
+
onEntry: (entry: StorageEntry) => void;
|
|
20
|
+
/** Per-value byte cap. Default 4 KB — captures small tokens, drops giant blobs. */
|
|
21
|
+
valueCap?: number;
|
|
22
|
+
}
|
|
23
|
+
export declare function installStoragePatch(opts: StoragePatchOptions): () => void;
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage monkey-patch — captures localStorage / sessionStorage / cookie
|
|
3
|
+
* mutations so agents can answer "who deleted my token?".
|
|
4
|
+
*
|
|
5
|
+
* Safety contract (mirrors fetchPatch / wsPatch):
|
|
6
|
+
* 1. Identity-preserving: replacements use Object.defineProperty so
|
|
7
|
+
* `localStorage.setItem` keeps its prototype membership. Cookie wrapping
|
|
8
|
+
* uses a `document` getter/setter pair on the prototype.
|
|
9
|
+
* 2. Error-isolated: capture failures swallowed; never propagate to callers.
|
|
10
|
+
* 3. No timing or value change: every wrapper calls the original synchronously
|
|
11
|
+
* and returns its result.
|
|
12
|
+
* 4. crossTab events captured via the native `storage` event (no initiator —
|
|
13
|
+
* the mutation happened in another tab so the stack is meaningless here).
|
|
14
|
+
*
|
|
15
|
+
* Idempotent. Returns a dispose function.
|
|
16
|
+
*/
|
|
17
|
+
import { captureInitiator } from './initiator.js';
|
|
18
|
+
const PATCHED_FLAG = '__hfeStoragePatched';
|
|
19
|
+
const DEFAULT_VALUE_CAP = 4 * 1024;
|
|
20
|
+
export function installStoragePatch(opts) {
|
|
21
|
+
if (typeof window === 'undefined')
|
|
22
|
+
return () => { };
|
|
23
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
24
|
+
if (window[PATCHED_FLAG])
|
|
25
|
+
return () => { };
|
|
26
|
+
const valueCap = opts.valueCap ?? DEFAULT_VALUE_CAP;
|
|
27
|
+
const emit = (entry) => {
|
|
28
|
+
try {
|
|
29
|
+
opts.onEntry(entry);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
/* swallow */
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
const disposers = [];
|
|
36
|
+
// Patch each storage instance directly — own properties shadow the
|
|
37
|
+
// prototype regardless of how the engine implements them. This works
|
|
38
|
+
// uniformly across real browsers, happy-dom, jsdom, and Electron.
|
|
39
|
+
try {
|
|
40
|
+
disposers.push(patchStorageInstance(window.localStorage, 'local', valueCap, emit));
|
|
41
|
+
}
|
|
42
|
+
catch { /* localStorage may be inaccessible (private mode, etc.) */ }
|
|
43
|
+
try {
|
|
44
|
+
disposers.push(patchStorageInstance(window.sessionStorage, 'session', valueCap, emit));
|
|
45
|
+
}
|
|
46
|
+
catch { /* ignore */ }
|
|
47
|
+
if (typeof document !== 'undefined') {
|
|
48
|
+
const cookieDispose = patchCookie(valueCap, emit);
|
|
49
|
+
if (cookieDispose)
|
|
50
|
+
disposers.push(cookieDispose);
|
|
51
|
+
}
|
|
52
|
+
// Cross-tab storage events.
|
|
53
|
+
const onStorageEvent = (ev) => {
|
|
54
|
+
const which = ev.storageArea === window.sessionStorage ? 'session' : 'local';
|
|
55
|
+
const op = ev.key === null ? 'clear' : ev.newValue === null ? 'remove' : 'set';
|
|
56
|
+
emit({
|
|
57
|
+
ts: Date.now(),
|
|
58
|
+
op,
|
|
59
|
+
which,
|
|
60
|
+
key: ev.key ?? undefined,
|
|
61
|
+
value: ev.newValue !== null ? clip(ev.newValue, valueCap) : undefined,
|
|
62
|
+
crossTab: true,
|
|
63
|
+
});
|
|
64
|
+
};
|
|
65
|
+
window.addEventListener('storage', onStorageEvent);
|
|
66
|
+
disposers.push(() => window.removeEventListener('storage', onStorageEvent));
|
|
67
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
68
|
+
window[PATCHED_FLAG] = true;
|
|
69
|
+
return () => {
|
|
70
|
+
for (const d of disposers) {
|
|
71
|
+
try {
|
|
72
|
+
d();
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
/* ignore */
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
79
|
+
delete window[PATCHED_FLAG];
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Patch Storage.prototype.setItem / removeItem / clear. Both localStorage and
|
|
84
|
+
* sessionStorage share the same prototype, so one patch covers both — we
|
|
85
|
+
* disambiguate at call time via `this === window.sessionStorage`.
|
|
86
|
+
*/
|
|
87
|
+
function patchStorageInstance(storage, kind, valueCap, emit) {
|
|
88
|
+
const origSet = storage.setItem.bind(storage);
|
|
89
|
+
const origRemove = storage.removeItem.bind(storage);
|
|
90
|
+
const origClear = storage.clear.bind(storage);
|
|
91
|
+
Object.defineProperty(storage, 'setItem', {
|
|
92
|
+
configurable: true, writable: true,
|
|
93
|
+
value: (key, value) => {
|
|
94
|
+
emit({ ts: Date.now(), op: 'set', which: kind, key, value: clip(value, valueCap), initiator: captureInitiator() });
|
|
95
|
+
origSet(key, value);
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
Object.defineProperty(storage, 'removeItem', {
|
|
99
|
+
configurable: true, writable: true,
|
|
100
|
+
value: (key) => {
|
|
101
|
+
emit({ ts: Date.now(), op: 'remove', which: kind, key, initiator: captureInitiator() });
|
|
102
|
+
origRemove(key);
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
Object.defineProperty(storage, 'clear', {
|
|
106
|
+
configurable: true, writable: true,
|
|
107
|
+
value: () => {
|
|
108
|
+
emit({ ts: Date.now(), op: 'clear', which: kind, initiator: captureInitiator() });
|
|
109
|
+
origClear();
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
return () => {
|
|
113
|
+
// Restore by replacing the own properties with thin shims that
|
|
114
|
+
// forward to the captured originals. `delete` doesn't reliably
|
|
115
|
+
// expose the prototype method in every engine (happy-dom in
|
|
116
|
+
// particular), so this is the safer reset.
|
|
117
|
+
try {
|
|
118
|
+
Object.defineProperty(storage, 'setItem', {
|
|
119
|
+
configurable: true, writable: true,
|
|
120
|
+
value: (k, v) => origSet(k, v),
|
|
121
|
+
});
|
|
122
|
+
Object.defineProperty(storage, 'removeItem', {
|
|
123
|
+
configurable: true, writable: true,
|
|
124
|
+
value: (k) => origRemove(k),
|
|
125
|
+
});
|
|
126
|
+
Object.defineProperty(storage, 'clear', {
|
|
127
|
+
configurable: true, writable: true,
|
|
128
|
+
value: () => origClear(),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
catch { /* ignore */ }
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
function patchCookie(valueCap, emit) {
|
|
135
|
+
const descriptor = Object.getOwnPropertyDescriptor(Document.prototype, 'cookie');
|
|
136
|
+
if (!descriptor || !descriptor.set || !descriptor.get)
|
|
137
|
+
return undefined;
|
|
138
|
+
const origSet = descriptor.set;
|
|
139
|
+
const origGet = descriptor.get;
|
|
140
|
+
Object.defineProperty(Document.prototype, 'cookie', {
|
|
141
|
+
configurable: true,
|
|
142
|
+
get() {
|
|
143
|
+
return origGet.call(this);
|
|
144
|
+
},
|
|
145
|
+
set(val) {
|
|
146
|
+
const initiator = captureInitiator();
|
|
147
|
+
const { key, value, removed } = parseCookieAssignment(val);
|
|
148
|
+
emit({
|
|
149
|
+
ts: Date.now(),
|
|
150
|
+
op: removed ? 'remove' : 'set',
|
|
151
|
+
which: 'cookie',
|
|
152
|
+
key,
|
|
153
|
+
value: removed ? undefined : value !== undefined ? clip(value, valueCap) : undefined,
|
|
154
|
+
initiator,
|
|
155
|
+
});
|
|
156
|
+
return origSet.call(this, val);
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
return () => {
|
|
160
|
+
Object.defineProperty(Document.prototype, 'cookie', descriptor);
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Cookie writes look like "key=value; Path=/; Expires=...; Max-Age=0".
|
|
165
|
+
* Treat Max-Age=0 or any past Expires as removal. Anything else is set.
|
|
166
|
+
*/
|
|
167
|
+
function parseCookieAssignment(raw) {
|
|
168
|
+
const parts = raw.split(';');
|
|
169
|
+
const head = (parts[0] ?? '').trim();
|
|
170
|
+
const eq = head.indexOf('=');
|
|
171
|
+
const key = eq >= 0 ? head.slice(0, eq) : head;
|
|
172
|
+
const value = eq >= 0 ? head.slice(eq + 1) : undefined;
|
|
173
|
+
let removed = false;
|
|
174
|
+
for (let i = 1; i < parts.length; i++) {
|
|
175
|
+
const seg = parts[i].trim();
|
|
176
|
+
const lower = seg.toLowerCase();
|
|
177
|
+
if (lower === 'max-age=0' || lower === 'max-age=-1')
|
|
178
|
+
removed = true;
|
|
179
|
+
if (lower.startsWith('expires=')) {
|
|
180
|
+
const date = new Date(seg.slice('expires='.length).trim());
|
|
181
|
+
if (!Number.isNaN(date.getTime()) && date.getTime() <= Date.now()) {
|
|
182
|
+
removed = true;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return { key: key || undefined, value, removed };
|
|
187
|
+
}
|
|
188
|
+
function clip(s, cap) {
|
|
189
|
+
return s.length <= cap ? s : `${s.slice(0, cap)}…[+${s.length - cap}B]`;
|
|
190
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket monkey-patch — captures open / send / recv / close frames so
|
|
3
|
+
* agents can see long-lived push channels (IM, presence, sync, kick-out).
|
|
4
|
+
*
|
|
5
|
+
* Safety contract (mirrors fetchPatch):
|
|
6
|
+
* 1. Identity-preserving: replacement is a constructor with the same name,
|
|
7
|
+
* prototype, and CONNECTING/OPEN/CLOSING/CLOSED static fields. Existing
|
|
8
|
+
* `instanceof WebSocket` checks still pass because we extend the original.
|
|
9
|
+
* 2. Error-isolated: capture failures swallowed via safeEmit.
|
|
10
|
+
* 3. No timing or value change: pass-through to native WebSocket; we only
|
|
11
|
+
* observe events and call sites. The original Promise / data flow is
|
|
12
|
+
* untouched.
|
|
13
|
+
* 4. Bounded memory: frame payloads capped at BODY_CAP per send/recv. Binary
|
|
14
|
+
* frames record a `[binary Nb]` marker rather than the bytes.
|
|
15
|
+
*
|
|
16
|
+
* The patch is idempotent. Returns a dispose function that restores
|
|
17
|
+
* `window.WebSocket`.
|
|
18
|
+
*/
|
|
19
|
+
import type { WsEntry } from '@harness-fe/protocol';
|
|
20
|
+
export interface WsPatchOptions {
|
|
21
|
+
onEntry: (entry: WsEntry) => void;
|
|
22
|
+
bodyCap?: number;
|
|
23
|
+
/** URL patterns to skip entirely. Default skips daemon traffic. */
|
|
24
|
+
denylist?: RegExp[];
|
|
25
|
+
}
|
|
26
|
+
export declare function installWsPatch(opts: WsPatchOptions): () => void;
|