@reticlehq/browser 2.13.1 → 2.14.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.
Files changed (196) hide show
  1. package/dist/actions/actions-dom.d.ts +0 -20
  2. package/dist/actions/actions-dom.js +0 -66
  3. package/dist/actions/actions-press.d.ts +10 -0
  4. package/dist/actions/actions-press.js +77 -0
  5. package/dist/actions/actions.d.ts +0 -89
  6. package/dist/actions/actions.js +24 -358
  7. package/dist/actions/appeared-text.d.ts +0 -20
  8. package/dist/actions/appeared-text.js +0 -36
  9. package/dist/actions/danger-context.d.ts +3 -0
  10. package/dist/actions/danger-context.js +39 -0
  11. package/dist/actions/scroll.d.ts +0 -9
  12. package/dist/actions/scroll.js +0 -7
  13. package/dist/actions/synthetic-input.d.ts +0 -7
  14. package/dist/actions/synthetic-input.js +0 -27
  15. package/dist/actions/value-input.d.ts +0 -30
  16. package/dist/actions/value-input.js +0 -33
  17. package/dist/commands/commands.d.ts +0 -2
  18. package/dist/commands/commands.js +0 -67
  19. package/dist/connect-options.d.ts +2 -96
  20. package/dist/dom/a11y.d.ts +0 -29
  21. package/dist/dom/a11y.js +1 -88
  22. package/dist/dom/auto-anchor.d.ts +0 -40
  23. package/dist/dom/auto-anchor.js +0 -33
  24. package/dist/dom/chart.d.ts +0 -61
  25. package/dist/dom/chart.js +0 -60
  26. package/dist/dom/desktop-capture.d.ts +0 -30
  27. package/dist/dom/desktop-capture.js +0 -72
  28. package/dist/dom/dom-ignore.d.ts +0 -30
  29. package/dist/dom/dom-ignore.js +0 -47
  30. package/dist/dom/occlusion.d.ts +0 -7
  31. package/dist/dom/occlusion.js +0 -7
  32. package/dist/dom/query.d.ts +0 -14
  33. package/dist/dom/query.js +48 -214
  34. package/dist/dom/realm.d.ts +0 -20
  35. package/dist/dom/realm.js +0 -28
  36. package/dist/dom/refs.d.ts +0 -76
  37. package/dist/dom/refs.js +2 -133
  38. package/dist/dom/shadow-registry.d.ts +0 -33
  39. package/dist/dom/shadow-registry.js +2 -72
  40. package/dist/dom/snapshot.d.ts +2 -43
  41. package/dist/dom/snapshot.js +26 -151
  42. package/dist/dom/source.d.ts +0 -44
  43. package/dist/dom/source.js +0 -45
  44. package/dist/dom/theme.d.ts +0 -37
  45. package/dist/dom/theme.js +2 -52
  46. package/dist/dom/virtualized.d.ts +0 -17
  47. package/dist/dom/virtualized.js +0 -27
  48. package/dist/edit-epoch.d.ts +0 -22
  49. package/dist/edit-epoch.js +0 -52
  50. package/dist/index.js +1 -23
  51. package/dist/observers/animation.d.ts +0 -1
  52. package/dist/observers/animation.js +0 -3
  53. package/dist/observers/blind-spots.d.ts +0 -26
  54. package/dist/observers/blind-spots.js +2 -61
  55. package/dist/observers/console.d.ts +1 -2
  56. package/dist/observers/console.js +41 -30
  57. package/dist/observers/context-open.d.ts +1 -11
  58. package/dist/observers/context-open.js +10 -14
  59. package/dist/observers/departure.d.ts +3 -0
  60. package/dist/observers/departure.js +52 -0
  61. package/dist/observers/dialogs.d.ts +2 -0
  62. package/dist/observers/dialogs.js +39 -0
  63. package/dist/observers/dom.js +0 -44
  64. package/dist/observers/download.d.ts +1 -2
  65. package/dist/observers/download.js +38 -55
  66. package/dist/observers/focus.d.ts +0 -6
  67. package/dist/observers/focus.js +0 -7
  68. package/dist/observers/frames.d.ts +0 -9
  69. package/dist/observers/frames.js +1 -28
  70. package/dist/observers/health.d.ts +0 -5
  71. package/dist/observers/health.js +1 -33
  72. package/dist/observers/install-all.d.ts +2 -0
  73. package/dist/observers/install-all.js +26 -27
  74. package/dist/observers/ipc.d.ts +0 -25
  75. package/dist/observers/ipc.js +0 -65
  76. package/dist/observers/navigation.d.ts +2 -0
  77. package/dist/observers/navigation.js +33 -0
  78. package/dist/observers/network-body.d.ts +5 -28
  79. package/dist/observers/network-body.js +11 -108
  80. package/dist/observers/network-redact.d.ts +1 -20
  81. package/dist/observers/network-redact.js +1 -80
  82. package/dist/observers/network-stream.d.ts +0 -21
  83. package/dist/observers/network-stream.js +1 -44
  84. package/dist/observers/network.d.ts +2 -26
  85. package/dist/observers/network.js +177 -294
  86. package/dist/observers/perf.d.ts +0 -10
  87. package/dist/observers/perf.js +0 -16
  88. package/dist/observers/route.d.ts +1 -2
  89. package/dist/observers/route.js +13 -19
  90. package/dist/observers/scroll.d.ts +0 -1
  91. package/dist/observers/scroll.js +0 -9
  92. package/dist/observers/sdk-failure.d.ts +1 -9
  93. package/dist/observers/sdk-failure.js +1 -30
  94. package/dist/observers/state.d.ts +1 -11
  95. package/dist/observers/state.js +13 -41
  96. package/dist/observers/storage.d.ts +0 -14
  97. package/dist/observers/storage.js +10 -34
  98. package/dist/observers/types.d.ts +0 -15
  99. package/dist/observers/types.js +0 -14
  100. package/dist/patching/capture-method.d.ts +0 -24
  101. package/dist/patching/capture-method.js +0 -26
  102. package/dist/presenter/overlay.d.ts +0 -1
  103. package/dist/presenter/overlay.js +0 -1
  104. package/dist/presenter/presenter-brand.d.ts +0 -19
  105. package/dist/presenter/presenter-brand.js +0 -21
  106. package/dist/presenter/presenter-config.d.ts +1 -105
  107. package/dist/presenter/presenter-config.js +2 -91
  108. package/dist/presenter/presenter-controls.d.ts +0 -29
  109. package/dist/presenter/presenter-controls.js +3 -131
  110. package/dist/presenter/presenter-dock-layout.d.ts +0 -3
  111. package/dist/presenter/presenter-dock-layout.js +0 -4
  112. package/dist/presenter/presenter-drag.d.ts +2 -24
  113. package/dist/presenter/presenter-drag.js +0 -26
  114. package/dist/presenter/presenter-effects.d.ts +0 -9
  115. package/dist/presenter/presenter-effects.js +0 -9
  116. package/dist/presenter/presenter-glow.d.ts +0 -21
  117. package/dist/presenter/presenter-glow.js +1 -27
  118. package/dist/presenter/presenter-heroicons-data.d.ts +0 -8
  119. package/dist/presenter/presenter-heroicons-data.js +0 -8
  120. package/dist/presenter/presenter-hud-chrome.d.ts +0 -10
  121. package/dist/presenter/presenter-hud-chrome.js +0 -10
  122. package/dist/presenter/presenter-icons.d.ts +0 -6
  123. package/dist/presenter/presenter-icons.js +0 -6
  124. package/dist/presenter/presenter-log.d.ts +0 -38
  125. package/dist/presenter/presenter-log.js +1 -45
  126. package/dist/presenter/presenter-report-copy.d.ts +5 -66
  127. package/dist/presenter/presenter-report-copy.js +5 -66
  128. package/dist/presenter/presenter-report-styles.d.ts +0 -6
  129. package/dist/presenter/presenter-report-styles.js +6 -6
  130. package/dist/presenter/presenter-report.d.ts +4 -7
  131. package/dist/presenter/presenter-report.js +18 -76
  132. package/dist/presenter/presenter-run-state.d.ts +0 -7
  133. package/dist/presenter/presenter-run-state.js +0 -5
  134. package/dist/presenter/presenter-settings-styles.d.ts +0 -1
  135. package/dist/presenter/presenter-settings-styles.js +0 -1
  136. package/dist/presenter/presenter-settings.d.ts +1 -23
  137. package/dist/presenter/presenter-settings.js +1 -31
  138. package/dist/presenter/presenter-shell-styles.js +2 -5
  139. package/dist/presenter/presenter-shell.d.ts +2 -18
  140. package/dist/presenter/presenter-shell.js +1 -73
  141. package/dist/presenter/presenter-styles.d.ts +0 -9
  142. package/dist/presenter/presenter-styles.js +0 -9
  143. package/dist/presenter/presenter-tally.d.ts +0 -5
  144. package/dist/presenter/presenter-tally.js +0 -4
  145. package/dist/presenter/presenter-test-helpers.d.ts +0 -2
  146. package/dist/presenter/presenter-test-helpers.js +0 -2
  147. package/dist/presenter/presenter-verbs.js +0 -7
  148. package/dist/presenter/presenter-workspace.d.ts +0 -4
  149. package/dist/presenter/presenter-workspace.js +1 -8
  150. package/dist/presenter/presenter.d.ts +0 -60
  151. package/dist/presenter/presenter.js +4 -118
  152. package/dist/recorder/recorder-styles.d.ts +0 -6
  153. package/dist/recorder/recorder-styles.js +0 -6
  154. package/dist/recorder/recorder.d.ts +0 -34
  155. package/dist/recorder/recorder.js +0 -49
  156. package/dist/registry/adapters.d.ts +0 -10
  157. package/dist/registry/adapters.js +0 -10
  158. package/dist/registry/auto-testids.d.ts +0 -19
  159. package/dist/registry/auto-testids.js +1 -21
  160. package/dist/registry/capabilities.d.ts +0 -31
  161. package/dist/registry/capabilities.js +1 -46
  162. package/dist/registry/commit-and-signal.d.ts +0 -6
  163. package/dist/registry/commit-and-signal.js +1 -7
  164. package/dist/registry/domains.d.ts +0 -8
  165. package/dist/registry/domains.js +0 -7
  166. package/dist/registry/emitter.d.ts +0 -14
  167. package/dist/registry/emitter.js +0 -10
  168. package/dist/registry/store-adapters.d.ts +0 -186
  169. package/dist/registry/store-adapters.js +0 -163
  170. package/dist/registry/stores.d.ts +0 -33
  171. package/dist/registry/stores.js +0 -59
  172. package/dist/reticle-presenter-helpers.d.ts +0 -13
  173. package/dist/reticle-presenter-helpers.js +0 -14
  174. package/dist/reticle.d.ts +1 -52
  175. package/dist/reticle.js +20 -145
  176. package/dist/review/annotator-styles.js +0 -7
  177. package/dist/review/annotator.d.ts +2 -13
  178. package/dist/review/annotator.js +0 -40
  179. package/dist/review/mark-anchor.d.ts +0 -11
  180. package/dist/review/mark-anchor.js +0 -8
  181. package/dist/security/serialization.d.ts +0 -20
  182. package/dist/security/serialization.js +1 -66
  183. package/dist/session-continuity.d.ts +0 -16
  184. package/dist/session-continuity.js +1 -20
  185. package/dist/timers/clock.d.ts +1 -14
  186. package/dist/timers/clock.js +59 -60
  187. package/dist/timers/native-console.js +0 -4
  188. package/dist/timers/native-timers.d.ts +0 -25
  189. package/dist/timers/native-timers.js +3 -33
  190. package/dist/transport/transport.d.ts +0 -51
  191. package/dist/transport/transport.js +3 -129
  192. package/dist/transport/unreachable-message.d.ts +0 -17
  193. package/dist/transport/unreachable-message.js +0 -17
  194. package/dist/util/captured-method.d.ts +0 -35
  195. package/dist/util/captured-method.js +0 -35
  196. package/package.json +2 -2
@@ -1,32 +1,9 @@
1
1
  import { BlindSpotKind, EventType, IPC_URL_SCHEME, IpcStatus, NetInitiator, RETICLE_IPC_GLOBAL, RETICLE_TAURI_CAPTURE_COMMAND, } from '@reticlehq/core';
2
2
  import { observeSafely } from './types.js';
3
- /**
4
- * How Tauri v2 reports a command's verdict. The transport answers HTTP 200 whether the Rust command
5
- * returned Ok or Err — the verdict is in this response header, which Tauri explicitly CORS-exposes so
6
- * JS may read it. Without translating it, every failed command is recorded as a successful request.
7
- */
8
3
  const TAURI_RESPONSE_HEADER_NAME = 'Tauri-Response';
9
4
  const TAURI_RESPONSE_ERROR = 'error';
10
- /** Rust/IPC vocabulary rather than HTTP's, so the text never claims a transport status it lacks. */
11
5
  const IPC_STATUS_TEXT = { OK: 'Ok', ERROR: 'Err' };
12
- /**
13
- * Tauri's IPC endpoint. The last path segment is the command name.
14
- *
15
- * TWO spellings, because Tauri v2 uses a different one per platform and matching only the first is a
16
- * silent false green on Windows: macOS/Linux get the custom scheme `ipc://localhost/archive_todo`,
17
- * while Windows (and Android) need a real http origin and get
18
- * `http://ipc.localhost/archive_todo` — the hostname `desktop-doctor` already tells people to put in
19
- * their CSP. Unmatched, a Windows `invoke` was recorded as an ordinary HTTP request, so the
20
- * `Tauri-Response` translation never ran and every command that returned `Err` was banked as a
21
- * successful 200. `.localhost` is reserved for loopback by RFC 6761, so this can never match a
22
- * remote host.
23
- */
24
6
  const TAURI_IPC_URL = /^(?:ipc:\/\/[^/]*|https?:\/\/ipc\.localhost(?::\d+)?)\/(.+)$/;
25
- /**
26
- * Fields to override on a NET_REQUEST that turned out to be a Tauri IPC call, or null when the
27
- * request was ordinary HTTP. Pure — the network observer calls this with the response header it
28
- * already holds, so IPC knowledge stays in this file.
29
- */
30
7
  export function ipcNetOverrides(url, header) {
31
8
  const match = TAURI_IPC_URL.exec(url);
32
9
  const command = match?.[1];
@@ -34,53 +11,23 @@ export function ipcNetOverrides(url, header) {
34
11
  return undefined;
35
12
  const ok = header(TAURI_RESPONSE_HEADER_NAME) !== TAURI_RESPONSE_ERROR;
36
13
  return {
37
- // Normalize `ipc://localhost/archive_todo` to `ipc://archive_todo`, so a Tauri command and an
38
- // Electron channel read identically to the agent and to a saved flow's assertions.
39
14
  url: `${IPC_URL_SCHEME}${command}`,
40
15
  initiator: NetInitiator.IPC,
41
16
  method: NetInitiator.IPC,
42
17
  ok,
43
18
  status: ok ? IpcStatus.OK : IpcStatus.ERROR,
44
- // Overwrite the TRANSPORT's statusText. Tauri's fetch genuinely answered 200/"OK", so letting it
45
- // through beside a synthetic 500 produced a record that contradicted itself — `status: 500,
46
- // statusText: "OK"` reads as nonsense to anyone seeing it cold. The record must tell one story,
47
- // and the story that matters is the command's verdict, not the pipe it travelled down.
48
19
  statusText: ok ? IPC_STATUS_TEXT.OK : IPC_STATUS_TEXT.ERROR,
49
20
  };
50
21
  }
51
- /**
52
- * Is this fetch Reticle's OWN desktop call rather than the app's?
53
- *
54
- * Tauri has no preload stage, so `reticle_screenshot` reaches the shell the only way it can: an
55
- * `invoke` — and an invoke IS a fetch to the `ipc://` protocol, straight through the patch the
56
- * network observer installed. So the observer recorded its own screenshots as app IPC calls, and
57
- * because `IPC` counts as a mutating method, three concurrent captures inside one action window came
58
- * back to the agent as `duplicate-request`: "the same write fired 3 times", against an app that had
59
- * never made the call. Accusing a correct app of a double submit is the most damaging thing a
60
- * verification tool can do, and here the tool was the one submitting.
61
- *
62
- * Electron never had this problem — its preload captures through the UNPATCHED invoke for exactly
63
- * this reason — and the bridge's own WebSocket is skipped on the same principle (`isBridgeSocket`).
64
- * The capture command is the SDK's whole Tauri-side surface, so matching it is the whole rule.
65
- */
66
22
  export function isReticleOwnIpc(url) {
67
23
  return TAURI_IPC_URL.exec(url)?.[1] === RETICLE_TAURI_CAPTURE_COMMAND;
68
24
  }
69
- /**
70
- * Is this page an Electron renderer? Read off the user agent, which Electron stamps and which is
71
- * available whether or not the preload ran — the whole point is to recognise a renderer that has
72
- * NOT been instrumented.
73
- */
74
25
  function isElectronRenderer() {
75
26
  return navigator.userAgent.includes('Electron');
76
27
  }
77
28
  export function installIpc(emit, options = {}) {
78
29
  const channel = window[RETICLE_IPC_GLOBAL];
79
30
  if (typeof channel?.subscribe !== 'function') {
80
- // No shim — but on Electron that is not "nothing to observe", it is "everything unobserved".
81
- // Tauri needs no shim (its invoke travels as a fetch) and a web page has no IPC at all, so only
82
- // Electron gets the declaration. Saying nothing here is what let a missing preload line read as
83
- // a clean, empty network view for a whole app.
84
31
  if (isElectronRenderer())
85
32
  emit(EventType.BLIND_SPOT, { kind: BlindSpotKind.UNOBSERVED_IPC, count: 1 });
86
33
  return () => undefined;
@@ -97,13 +44,7 @@ export function installIpc(emit, options = {}) {
97
44
  });
98
45
  return;
99
46
  }
100
- // A one-way `send` that did not throw has NO verdict — the renderer never learns whether the
101
- // main process handled it. Emitting `ok/status` anyway would manufacture the success nobody
102
- // reported, so both are omitted and `oneWay` says why. `reticle_network { ok }` then matches
103
- // such a record in neither direction, which is the honest answer to "did it work".
104
47
  const verdictless = true === record.oneWay && record.ok === undefined;
105
- // Declared, not inferred: the server cannot tell "no verdict" from "not yet settled" without
106
- // being told, and a send with nothing to observe must not read as a clean green.
107
48
  if (verdictless)
108
49
  emit(EventType.BLIND_SPOT, { kind: BlindSpotKind.VERDICTLESS_SEND, count: 1 });
109
50
  const ok = true === record.ok;
@@ -116,9 +57,6 @@ export function installIpc(emit, options = {}) {
116
57
  durationMs: record.durationMs ?? 0,
117
58
  initiator: NetInitiator.IPC,
118
59
  ...(record.error === undefined ? {} : { error: record.error }),
119
- // Size travels ALWAYS, bodies only on opt-in. The split matters: the verdict layer reports "a
120
- // write returned ok and its payload went unread" from the size alone, and it cannot infer that
121
- // from an absent field — an unread payload and an empty one would look identical.
122
60
  ...(record.responseSize === undefined ? {} : { responseSize: record.responseSize }),
123
61
  ...(true === options.captureBodies
124
62
  ? {
@@ -130,9 +68,6 @@ export function installIpc(emit, options = {}) {
130
68
  });
131
69
  });
132
70
  });
133
- // A REAL removal, so teardown leaves the app as it found it. The preload's `invoke` patch stays
134
- // deliberately — it is a pass-through with no effect once no subscriber remains, and un-patching
135
- // mid-flight would strand any call already inside the wrapper.
136
71
  return () => {
137
72
  channel.unsubscribe?.(token);
138
73
  };
@@ -0,0 +1,2 @@
1
+ import type { Emit, Teardown } from './types.js';
2
+ export declare function installNavigation(emit: Emit): Teardown;
@@ -0,0 +1,33 @@
1
+ import { EventType, NetInitiator } from '@reticlehq/core';
2
+ const REPORTED_TYPES = new Set(['navigate', 'reload', 'back_forward']);
3
+ function navigationEntry() {
4
+ try {
5
+ const [first] = performance.getEntriesByType('navigation');
6
+ return first;
7
+ }
8
+ catch {
9
+ return undefined;
10
+ }
11
+ }
12
+ export function installNavigation(emit) {
13
+ const entry = navigationEntry();
14
+ const type = entry?.type;
15
+ const url = entry?.name;
16
+ if (entry === undefined || 'string' !== typeof url || 0 === url.length)
17
+ return () => undefined;
18
+ if ('string' !== typeof type || !REPORTED_TYPES.has(type))
19
+ return () => undefined;
20
+ const status = entry.responseStatus;
21
+ const known = 'number' === typeof status && status > 0;
22
+ emit(EventType.NET_REQUEST, {
23
+ id: `doc-${String(Math.round(entry.duration ?? 0))}`,
24
+ method: 'GET',
25
+ url,
26
+ initiator: NetInitiator.DOCUMENT,
27
+ durationMs: Math.round(entry.duration ?? 0),
28
+ ...(known ? { status, ok: status < 400 } : {}),
29
+ navigationType: type,
30
+ ...('number' === typeof entry.transferSize ? { transferSize: entry.transferSize } : {}),
31
+ });
32
+ return () => undefined;
33
+ }
@@ -1,34 +1,11 @@
1
- /**
2
- * Is this response body still arriving after the request itself resolved?
3
- *
4
- * STRUCTURAL, not a content-type allowlist. The first version listed `x-component|x-ndjson|
5
- * application/stream` — which is to say "Next.js RSC, plus two guesses". It closed the exact defect
6
- * it was written against and left every other streamed response as invisible as before: a chunked
7
- * `application/json` from a token API, a streamed HTML document, an export assembled server-side.
8
- * Fixing one app's symptom and calling the class solved is how a false green comes back wearing
9
- * different clothes.
10
- *
11
- * The real signal is on the wire: a response with a BODY and no `content-length` is being sent
12
- * chunked, which is the definition of "the server is still writing". `content-length` present means
13
- * the size was known up front and the body cannot grow.
14
- *
15
- * Deliberately independent of content type, with one exception above for streams that never end.
16
- */
17
1
  export declare function isStreamingBody(contentType: string | null, contentLength: string | null): boolean;
18
- /**
19
- * Resolve with the body text, or `undefined` if it takes too long.
20
- *
21
- * The app is already awaiting our patched fetch, so any unbounded read here is a hang in the host app.
22
- * Losing an observation is always preferable to freezing the page being observed.
23
- */
24
2
  export declare function withBodyDeadline(read: Promise<string>): Promise<string | undefined>;
25
3
  export declare function isCapturableType(contentType: string | null): boolean;
26
- /**
27
- * Redact + cap a body for the agent transcript. JSON is parsed and run through the same
28
- * sanitizeForTransport redaction used for state (sensitive keys -> [REDACTED]); every other text body
29
- * (and JSON that didn't parse) goes through redactText so form/plain-text credentials can't leak.
30
- * Returns the (possibly truncated) body plus whether it was cut.
31
- */
4
+ export declare function shouldCaptureErrorBody(status: number, enabled: boolean): boolean;
5
+ export declare function projectErrorBody(rawText: string, contentType: string | null): {
6
+ body: string;
7
+ truncated: boolean;
8
+ };
32
9
  export declare function projectBody(rawText: string, contentType: string | null): {
33
10
  body: string;
34
11
  truncated: boolean;
@@ -1,74 +1,16 @@
1
- /**
2
- * Response/request BODY projection for the network observer.
3
- *
4
- * Split out of network.ts, which had grown to hold URL redaction, body projection, fetch, XHR, SSE,
5
- * WebSocket and sendBeacon at once. This module owns exactly one question: given a body, what is safe
6
- * and affordable to report?
7
- */
8
1
  import { REDACTED_VALUE } from '@reticlehq/core';
9
2
  import { isSensitiveKey, safeStringify, scrubKnownSecrets } from '../security/serialization.js';
10
3
  import { nativeClearTimeout, nativeSetTimeout } from '../timers/native-timers.js';
11
- /** Only text-like bodies are worth capturing; binary (images/fonts/octet-stream) is skipped. */
12
4
  const CAPTURABLE_CONTENT = /application\/json|text\/|application\/xml|x-www-form-urlencoded|graphql/i;
13
- /**
14
- * Content types that are STREAMS, never complete bodies.
15
- *
16
- * `text/event-stream` matches `text/` in CAPTURABLE_CONTENT, and body capture awaited
17
- * `res.clone.text` BEFORE returning the response to the app. A clone of a stream only settles when
18
- * the stream ends — and an SSE stream does not end — so the app's `await fetch...)` never resolved.
19
- * Enabling body capture silently hung every streaming endpoint (SSE, and the chunked `application/json`
20
- * every token-streaming model API uses), leaving a permanently loading UI with no reason to suspect the
21
- * observability SDK. Never read these; the frame observer covers SSE properly.
22
- */
23
5
  const STREAMING_CONTENT = /event-stream|x-ndjson|application\/stream/i;
24
- /**
25
- * Content types that stay OPEN by design — watching them would make every first settle pay the full
26
- * watch bound for a stream doing exactly its job. Real SSE is covered by the EventSource observer.
27
- */
28
6
  const ENDLESS_BY_DESIGN = /event-stream/i;
29
- /**
30
- * Is this response body still arriving after the request itself resolved?
31
- *
32
- * STRUCTURAL, not a content-type allowlist. The first version listed `x-component|x-ndjson|
33
- * application/stream` — which is to say "Next.js RSC, plus two guesses". It closed the exact defect
34
- * it was written against and left every other streamed response as invisible as before: a chunked
35
- * `application/json` from a token API, a streamed HTML document, an export assembled server-side.
36
- * Fixing one app's symptom and calling the class solved is how a false green comes back wearing
37
- * different clothes.
38
- *
39
- * The real signal is on the wire: a response with a BODY and no `content-length` is being sent
40
- * chunked, which is the definition of "the server is still writing". `content-length` present means
41
- * the size was known up front and the body cannot grow.
42
- *
43
- * Deliberately independent of content type, with one exception above for streams that never end.
44
- */
45
7
  export function isStreamingBody(contentType, contentLength) {
46
8
  if (contentType !== null && ENDLESS_BY_DESIGN.test(contentType))
47
9
  return false;
48
10
  return null === contentLength;
49
11
  }
50
- /**
51
- * Longest the SDK will wait on a response-body clone before emitting without it.
52
- *
53
- * Two layers protect the app. `text/event-stream` and friends are skipped outright, which covers the
54
- * common streaming case at zero cost. This deadline is the backstop for a body that streams WITHOUT
55
- * announcing it in its content type — chunked `application/json` from a token-streaming API. Gating on
56
- * `content-length` instead was tried and rejected: plenty of complete responses omit it (gzip, HTTP/2),
57
- * so that would silently stop capturing bodies for ordinary apps. A bounded half-second on a rare path
58
- * beats an unbounded hang, and beats losing capture everywhere.
59
- */
60
12
  const BODY_READ_TIMEOUT_MS = 500;
61
- /**
62
- * Resolve with the body text, or `undefined` if it takes too long.
63
- *
64
- * The app is already awaiting our patched fetch, so any unbounded read here is a hang in the host app.
65
- * Losing an observation is always preferable to freezing the page being observed.
66
- */
67
13
  export async function withBodyDeadline(read) {
68
- // The timer is CLEARED on the winning path. Left uncleared, every captured body leaves a live timer
69
- // and a retained closure behind — harmless individually, but `boundedFrame` in this same package
70
- // already races-with-cleanup correctly, so not reusing that shape was an oversight rather than a
71
- // choice. It also keeps test workers from being held open by pending handles.
72
14
  let timer;
73
15
  try {
74
16
  return await Promise.race([
@@ -90,79 +32,40 @@ export function isCapturableType(contentType) {
90
32
  return false;
91
33
  return CAPTURABLE_CONTENT.test(contentType);
92
34
  }
93
- /**
94
- * Per-body character cap. Bodies ride the same ring buffer as the DOM/route/console timeline, so an
95
- * uncapped body could evict the whole behavioral history; the small cap keeps a few large responses
96
- * from starving the timeline (the separate-budget concern in the scalability audit).
97
- */
98
35
  const MAX_BODY_CHARS = 8192;
99
- /**
100
- * Hard bound on how much of a body is ever PARSED or SCANNED, independent of how much is reported.
101
- *
102
- * Only twice the output cap, and that ceiling is measured rather than guessed. The redaction pass uses
103
- * `[A-Za-z0-9_.-]+` followed by a delimiter, which backtracks quadratically on a long run without one.
104
- * Timed on this machine: 8 KB → 36 ms, 16 KB → 136 ms, 64 KB → 2067 ms. A first attempt at 8× the
105
- * output cap therefore still froze the main thread for seconds, so the bound is set where the cost
106
- * stays bounded AND redaction keeps enough context to catch a secret straddling the reported edge.
107
- * Scanning far past the output cap is wasted work in any case — only 8 KB is ever reported.
108
- */
109
36
  const MAX_BODY_SCAN_CHARS = MAX_BODY_CHARS * 2;
110
- /**
111
- * An `Authorization: Bearer …` / `Basic …` credential. The token side requires credential shape — 16+
112
- * chars AND at least one digit or symbol — so ordinary prose ("Basic subscription includes…", "Bearer
113
- * capacity exceeded") is NOT mangled, only actual opaque tokens are.
114
- */
115
37
  const AUTH_SCHEME_TOKEN = /\b(Bearer|Basic)\s+(?=[A-Za-z0-9._~+/=-]{16,})(?=[A-Za-z0-9._~+/=-]*[\d._~+/=-])[A-Za-z0-9._~+/=-]+/gi;
116
- /**
117
- * Redact credentials in ANY text body (form-urlencoded, text/plain, xml, or JSON that failed to parse).
118
- * sanitizeForTransport only redacts KEYS inside a parsed object, so a `password=secret` form post or a
119
- * `Bearer <token>` string would otherwise ship verbatim. Redacts the value of any `key=value` / `key: value`
120
- * pair whose key is sensitive, plus credential-shaped auth-scheme tokens.
121
- */
122
38
  function redactText(text) {
123
39
  return (text
124
- // Auth-scheme tokens FIRST: `Authorization: Bearer <token>` — the key/value rule below would
125
- // otherwise consume just "Bearer" as Authorization's value (it stops at whitespace) and leave the
126
- // token behind, so the scheme rule must run before it.
127
40
  .replace(AUTH_SCHEME_TOKEN, (_m, scheme) => `${scheme} ${REDACTED_VALUE}`)
128
41
  .replace(/([A-Za-z0-9_.-]+)(\s*[=:]\s*"?)([^&\s,;"}]+)/g, (match, key, sep) => isSensitiveKey(key) ? `${key}${sep}${REDACTED_VALUE}` : match));
129
42
  }
130
- /**
131
- * Redact + cap a body for the agent transcript. JSON is parsed and run through the same
132
- * sanitizeForTransport redaction used for state (sensitive keys -> [REDACTED]); every other text body
133
- * (and JSON that didn't parse) goes through redactText so form/plain-text credentials can't leak.
134
- * Returns the (possibly truncated) body plus whether it was cut.
135
- */
43
+ const ERROR_BODY_CHARS = 4096;
44
+ export function shouldCaptureErrorBody(status, enabled) {
45
+ return enabled && status >= 400;
46
+ }
47
+ export function projectErrorBody(rawText, contentType) {
48
+ const projected = projectBody(rawText, contentType);
49
+ if (projected.body.length <= ERROR_BODY_CHARS)
50
+ return projected;
51
+ return { body: projected.body.slice(0, ERROR_BODY_CHARS), truncated: true };
52
+ }
136
53
  export function projectBody(rawText, contentType) {
137
- // Bound the INPUT before any parsing or scanning. The cap used to be applied only at the end, so a
138
- // large body was JSON.parsed in full, deep-walked, re-stringified, and swept by two global regexes
139
- // before all but 8 KB of the result was thrown away — seconds of synchronous main-thread work for a
140
- // fixed-size output. A 30 MB CSV export or a big HTML error page froze the app, which is the same
141
- // class of host-app damage as the streaming hang, with the SDK still the cause.
142
- //
143
- // The slice is generous (a multiple of the output cap) so redaction still sees enough context to
144
- // recognise a secret that straddles the boundary, while the work stays bounded regardless of body size.
145
54
  const oversized = rawText.length > MAX_BODY_SCAN_CHARS;
146
55
  const text = oversized ? rawText.slice(0, MAX_BODY_SCAN_CHARS) : rawText;
147
56
  let out;
148
57
  if (contentType !== null && /json|graphql/i.test(contentType)) {
149
58
  try {
150
- // safeStringify already sanitizes internally — the extra sanitizeForTransport here re-walked
151
- // every captured JSON body a second time (~21% of the per-response cost) for an identical result.
152
59
  out = safeStringify(JSON.parse(text));
153
60
  }
154
61
  catch {
155
- out = redactText(text); // looked like JSON but wasn't — still redact key/value + auth tokens
62
+ out = redactText(text);
156
63
  }
157
64
  }
158
65
  else {
159
66
  out = redactText(text);
160
67
  }
161
- // Key-based redaction can't see a secret sitting in a VALUE under a benign key — scan the projected
162
- // text for high-confidence secret shapes (JWTs, provider keys) as a backstop, JSON or not.
163
68
  out = scrubKnownSecrets(out);
164
- // Truncated if the projection overflows the cap OR the input itself was clipped above — a body the
165
- // SDK never fully read must not be reported as complete.
166
69
  const truncated = oversized || out.length > MAX_BODY_CHARS;
167
70
  return { body: out.length > MAX_BODY_CHARS ? out.slice(0, MAX_BODY_CHARS) : out, truncated };
168
71
  }
@@ -1,20 +1 @@
1
- /**
2
- * Redact credential-bearing values so they don't leak into the agent transcript / flow / run
3
- * artifacts: query params (`?access_token=…`, signed-URL keys) via the shared `isSensitiveKey` regex,
4
- * AND path-embedded tokens (`/reset/<token>`, `/invite/<token>`) that live in the path, not the query.
5
- * The hash is preserved and the URL is returned byte-for-byte when nothing matched.
6
- */
7
- export declare function redactUrl(raw: string): string;
8
- /**
9
- * Displayed URL plus, when redaction rewrote it, the raw request for graders.
10
- *
11
- * `url` is what the agent reads. `urlRaw` exists only so `urlContains` can still match a public
12
- * path segment that the heuristic rewrote (`/auth/token/refresh-context`). Omitted when nothing
13
- * changed, so an ordinary request pays nothing.
14
- */
15
- export declare function netUrlFields(raw: string): {
16
- url: string;
17
- } | {
18
- url: string;
19
- urlRaw: string;
20
- };
1
+ export { netUrlFields, redactUrl } from '@reticlehq/core';
@@ -1,80 +1 @@
1
- import { REDACTED_VALUE, URL_RAW } from '@reticlehq/core';
2
- import { isSensitiveKey } from '../security/serialization.js';
3
- /** A path segment name that is typically followed by a single-use secret token in the NEXT segment. */
4
- const SENSITIVE_PATH_SEGMENT = /^(reset|verify|verification|confirm|activate|invite|magic|magiclink|token|key|oauth|unsubscribe|password)$/i;
5
- /** Only mask a following segment that looks token-like — short ids/words (`reset/form`) are left alone. */
6
- const PATH_TOKEN_MIN_LENGTH = 12;
7
- /**
8
- * Redact credential-bearing values so they don't leak into the agent transcript / flow / run
9
- * artifacts: query params (`?access_token=…`, signed-URL keys) via the shared `isSensitiveKey` regex,
10
- * AND path-embedded tokens (`/reset/<token>`, `/invite/<token>`) that live in the path, not the query.
11
- * The hash is preserved and the URL is returned byte-for-byte when nothing matched.
12
- */
13
- export function redactUrl(raw) {
14
- const hashStart = raw.indexOf('#');
15
- const hash = -1 === hashStart ? '' : raw.slice(hashStart);
16
- const beforeHash = -1 === hashStart ? raw : raw.slice(0, hashStart);
17
- const queryStart = beforeHash.indexOf('?');
18
- const pathPart = -1 === queryStart ? beforeHash : beforeHash.slice(0, queryStart);
19
- const query = -1 === queryStart ? '' : beforeHash.slice(queryStart + 1);
20
- let changed = false;
21
- // Credentials in the authority (`scheme://user:pass@host`) never belong in a transcript.
22
- // Match to the LAST `@` before the path (`[^/]*@`, greedy), not the first — a password containing
23
- // `@` (`user:p@ss@host`) otherwise left its tail (`ss@host`) in the clear.
24
- let authority = pathPart;
25
- const userinfo = /^([a-z][a-z0-9+.-]*:\/\/)[^/]*@/i.exec(pathPart);
26
- if (userinfo !== null) {
27
- authority = `${userinfo[1] ?? ''}${REDACTED_VALUE}@${pathPart.slice(userinfo[0].length)}`;
28
- changed = true;
29
- }
30
- let newQuery = query;
31
- if (query !== '') {
32
- const params = new URLSearchParams(query);
33
- let queryChanged = false;
34
- for (const key of [...params.keys()]) {
35
- if (isSensitiveKey(key)) {
36
- params.set(key, REDACTED_VALUE);
37
- queryChanged = true;
38
- }
39
- }
40
- if (queryChanged) {
41
- newQuery = params.toString();
42
- changed = true;
43
- }
44
- }
45
- const segments = authority.split('/');
46
- for (let i = 0; i + 1 < segments.length; i++) {
47
- const name = segments[i];
48
- const next = segments[i + 1];
49
- if (name !== undefined &&
50
- next !== undefined &&
51
- next.length >= PATH_TOKEN_MIN_LENGTH &&
52
- SENSITIVE_PATH_SEGMENT.test(name)) {
53
- segments[i + 1] = REDACTED_VALUE;
54
- changed = true;
55
- }
56
- }
57
- // OAuth implicit flow puts the access_token in the FRAGMENT (`#access_token=…`), and hash-routers carry
58
- // `?token=…` in the hash — redact sensitive params there too, leaving plain anchors (`#section`) alone.
59
- let newHash = hash;
60
- if (hash.length > 1) {
61
- newHash = hash.replace(/([A-Za-z0-9_.-]+)=([^&\s]+)/g, (m, key) => isSensitiveKey(key) ? `${key}=${REDACTED_VALUE}` : m);
62
- if (newHash !== hash)
63
- changed = true;
64
- }
65
- if (!changed)
66
- return raw;
67
- const queryOut = -1 === queryStart ? '' : `?${newQuery}`;
68
- return `${segments.join('/')}${queryOut}${newHash}`;
69
- }
70
- /**
71
- * Displayed URL plus, when redaction rewrote it, the raw request for graders.
72
- *
73
- * `url` is what the agent reads. `urlRaw` exists only so `urlContains` can still match a public
74
- * path segment that the heuristic rewrote (`/auth/token/refresh-context`). Omitted when nothing
75
- * changed, so an ordinary request pays nothing.
76
- */
77
- export function netUrlFields(raw) {
78
- const url = redactUrl(raw);
79
- return url === raw ? { url } : { url, [URL_RAW]: raw };
80
- }
1
+ export { netUrlFields, redactUrl } from '@reticlehq/core';
@@ -1,23 +1,2 @@
1
1
  import type { Emit } from './types.js';
2
- /**
3
- * Watch a STREAMED response body to its end, so `settled` cannot pass while the server is still
4
- * writing.
5
- *
6
- * `fetch` resolves at HEADERS. On a Next.js App Router page the RSC payload's NET_REQUEST landed
7
- * 16 ms in, the shell rendered at 3013, and the Suspense boundary's rows arrived at 3902 — 889 ms
8
- * in which NOTHING was observed. Quiet is exactly what settle tests for, so it passed with the
9
- * fallback still on screen and zero rows rendered: a green on every streaming boundary.
10
- *
11
- * STRUCTURAL, not an allowlist: a response with a body and no `content-length` is being sent
12
- * chunked, which is the wire's own way of saying the server is still writing. An earlier version
13
- * gated on a content-type list containing `x-component` — "Next.js RSC, plus two guesses" — which
14
- * closed the one defect it was written against and left every other streamed response as invisible
15
- * as before. The clone is DRAINED as it arrives rather than buffered, so watching costs a second
16
- * read of bytes already in flight, not a second copy held in memory.
17
- *
18
- * BOUNDED both ways: an endless stream (SSE over fetch, a live feed) must not hold `settled` open
19
- * forever, so the watch gives up after STREAM_WATCH_MS and closes the record anyway, marked
20
- * `gaveUp`. That is the safe direction — a settle that eventually proceeds beats one that never
21
- * does — and it says which happened rather than pretending the stream ended.
22
- */
23
2
  export declare function watchStreamedBody(emit: Emit, res: Response, id: string, url: string, contentType: string | null, contentLength: string | null): void;
@@ -1,46 +1,8 @@
1
- /**
2
- * Watching a STREAMED response body to its end, so `settled` cannot pass mid-stream.
3
- *
4
- * Split out of network.ts, which was already at its line cap. One question: when did this response
5
- * actually finish arriving?
6
- */
7
1
  import { EventType, StreamDirection, StreamTransport } from '@reticlehq/core';
8
2
  import { isStreamingBody } from './network-body.js';
9
3
  import { nativeClearTimeout, nativeSetTimeout } from '../timers/native-timers.js';
10
- /**
11
- * Longest a streamed response body is watched before the record is closed anyway.
12
- *
13
- * An endless stream must never hold `settled` open forever. Ten seconds is longer than any
14
- * page-render stream and short enough that a genuinely endless one costs one settle window, not the
15
- * session.
16
- */
17
4
  const STREAM_WATCH_MS = 10_000;
18
- /**
19
- * Watch a STREAMED response body to its end, so `settled` cannot pass while the server is still
20
- * writing.
21
- *
22
- * `fetch` resolves at HEADERS. On a Next.js App Router page the RSC payload's NET_REQUEST landed
23
- * 16 ms in, the shell rendered at 3013, and the Suspense boundary's rows arrived at 3902 — 889 ms
24
- * in which NOTHING was observed. Quiet is exactly what settle tests for, so it passed with the
25
- * fallback still on screen and zero rows rendered: a green on every streaming boundary.
26
- *
27
- * STRUCTURAL, not an allowlist: a response with a body and no `content-length` is being sent
28
- * chunked, which is the wire's own way of saying the server is still writing. An earlier version
29
- * gated on a content-type list containing `x-component` — "Next.js RSC, plus two guesses" — which
30
- * closed the one defect it was written against and left every other streamed response as invisible
31
- * as before. The clone is DRAINED as it arrives rather than buffered, so watching costs a second
32
- * read of bytes already in flight, not a second copy held in memory.
33
- *
34
- * BOUNDED both ways: an endless stream (SSE over fetch, a live feed) must not hold `settled` open
35
- * forever, so the watch gives up after STREAM_WATCH_MS and closes the record anyway, marked
36
- * `gaveUp`. That is the safe direction — a settle that eventually proceeds beats one that never
37
- * does — and it says which happened rather than pretending the stream ended.
38
- */
39
5
  export function watchStreamedBody(emit, res, id, url, contentType, contentLength) {
40
- // `=== null` is not enough: jsdom's Response has no `body` at all, and older/polyfilled
41
- // implementations return undefined. Reaching for `.getReader()` on that threw an unhandled
42
- // rejection out of a detached promise — an observability layer throwing into the host page, from
43
- // code the host never called. Duck-type the reader instead of trusting the shape.
44
6
  if (!isStreamingBody(contentType, contentLength))
45
7
  return;
46
8
  if (typeof res.body?.getReader !== 'function')
@@ -50,7 +12,7 @@ export function watchStreamedBody(emit, res, id, url, contentType, contentLength
50
12
  clone = res.clone();
51
13
  }
52
14
  catch {
53
- return; // already consumed or locked — the app owns it; observing it would take it away
15
+ return;
54
16
  }
55
17
  const body = clone.body;
56
18
  if (null === body)
@@ -64,10 +26,6 @@ export function watchStreamedBody(emit, res, id, url, contentType, contentLength
64
26
  void (async () => {
65
27
  const reader = body.getReader();
66
28
  let gaveUp = false;
67
- // The deadline must be a TIMER, not a check after each read. An idle stream parks inside
68
- // `read()` waiting for a chunk that may never come, so a post-read check never runs and the
69
- // record never closes — which would hold `settled` open forever on exactly the endless streams
70
- // the bound exists to protect against.
71
29
  let timer;
72
30
  const expired = new Promise((resolve) => {
73
31
  timer = nativeSetTimeout(() => resolve('expired'), STREAM_WATCH_MS);
@@ -85,7 +43,6 @@ export function watchStreamedBody(emit, res, id, url, contentType, contentLength
85
43
  }
86
44
  }
87
45
  catch {
88
- /* aborted or errored — the stream is over either way, which is what we report */
89
46
  }
90
47
  finally {
91
48
  if (timer !== undefined)
@@ -1,41 +1,17 @@
1
- import type { Emit, Teardown } from './types.js';
1
+ import { type Emit, type Teardown } from './types.js';
2
2
  import { redactUrl, netUrlFields } from './network-redact.js';
3
3
  export { redactUrl, netUrlFields };
4
- /** Config for the network observer. Body capture is OFF by default and dev-only opt-in. */
5
- /**
6
- * Reinterpret a completed request's fields from its response.
7
- *
8
- * The seam exists so this observer stays ignorant of desktop IPC. A Tauri `invoke` arrives here as an
9
- * ordinary fetch to an `ipc://` protocol, and the header that says whether the Rust command
10
- * succeeded means nothing to a network observer — so the knowledge lives in the IPC observer and is
11
- * passed IN, rather than this module importing it. Returns undefined to leave the record untouched.
12
- */
13
4
  type NetResponseReinterpreter = (url: string, header: (name: string) => string | null) => Record<string, unknown> | undefined;
14
5
  interface NetworkOptions {
15
- /** Capture request/response bodies (text-like content only, redacted, per-body capped). */
16
6
  captureBodies?: boolean;
17
- /** Optional hook that reinterprets a completed request — see NetResponseReinterpreter. */
7
+ captureErrorBodies?: boolean;
18
8
  reinterpret?: NetResponseReinterpreter;
19
- /**
20
- * Requests Reticle itself makes, which must never reach the app's evidence.
21
- *
22
- * Same seam and same reason as `reinterpret`: this observer knows nothing about desktop IPC, and
23
- * the SDK's own Tauri screenshot travels as an ordinary fetch through this very patch. Passed in
24
- * rather than imported, so the knowledge stays in the IPC observer.
25
- */
26
9
  ignore?: (url: string) => boolean;
27
10
  }
28
- /** Pure: the first real app-code frame in a stack string, capped. Exported for unit testing. */
29
11
  export declare function firstAppFrame(stack: string | undefined): string | undefined;
30
- /** The timing fields we lift from a resource entry — TTFB is the perf signal a duration alone hides. */
31
12
  interface NetTiming {
32
13
  ttfbMs?: number;
33
14
  transferSize?: number;
34
15
  }
35
- /**
36
- * Pure: derive TTFB + transfer size from a PerformanceResourceTiming entry. TTFB = responseStart -
37
- * requestStart; cross-origin responses zero those (Timing-Allow-Origin), so we omit rather than report a
38
- * bogus 0. Exported for unit testing (jsdom doesn't post real resource entries).
39
- */
40
16
  export declare function extractTiming(entry: PerformanceResourceTiming | undefined): NetTiming;
41
17
  export declare function installNetwork(emit: Emit, opts?: NetworkOptions): Teardown;