@reticlehq/server 3.8.1 → 3.9.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 (231) hide show
  1. package/LICENSE +201 -104
  2. package/NOTICE +5 -0
  3. package/README.md +1 -1
  4. package/bin/launch.js +13 -0
  5. package/bin/package.json +3 -0
  6. package/bin/reticle.js +66 -8
  7. package/dist/chromium-launch-options.d.ts +25 -0
  8. package/dist/chromium-launch-options.js +21 -1
  9. package/dist/command/cli/adhoc-verdict.js +34 -7
  10. package/dist/command/cli/answers/gate-exit.d.ts +6 -2
  11. package/dist/command/cli/answers/gate-exit.js +12 -2
  12. package/dist/command/cli/ci-hint.d.ts +44 -0
  13. package/dist/command/cli/ci-hint.js +72 -0
  14. package/dist/command/cli/cli-doctor.d.ts +6 -1
  15. package/dist/command/cli/cli-doctor.js +45 -4
  16. package/dist/command/cli/cli-flow-commands.d.ts +7 -1
  17. package/dist/command/cli/cli-flow-commands.js +39 -17
  18. package/dist/command/cli/cli-kill.d.ts +21 -0
  19. package/dist/command/cli/cli-kill.js +46 -3
  20. package/dist/command/cli/cli-parse.d.ts +5 -2
  21. package/dist/command/cli/cli-parse.js +10 -5
  22. package/dist/command/cli/cli-usage.js +3 -2
  23. package/dist/command/cli/cli-verify.d.ts +6 -1
  24. package/dist/command/cli/cli-verify.js +32 -13
  25. package/dist/command/cli/doctor/browser/chromium-hint.d.ts +6 -1
  26. package/dist/command/cli/doctor/browser/chromium-hint.js +11 -5
  27. package/dist/command/cli/doctor/doctor-fix.d.ts +44 -0
  28. package/dist/command/cli/doctor/doctor-fix.js +89 -0
  29. package/dist/command/cli/doctor/doctor-launcher-line.d.ts +23 -0
  30. package/dist/command/cli/doctor/doctor-launcher-line.js +60 -0
  31. package/dist/command/cli/doctor/doctor-rows.d.ts +4 -0
  32. package/dist/command/cli/doctor/doctor-rows.js +4 -0
  33. package/dist/command/cli/doctor-repairs.d.ts +64 -0
  34. package/dist/command/cli/doctor-repairs.js +214 -0
  35. package/dist/command/cli/drive/drive-command.js +3 -7
  36. package/dist/command/cli/first-use-checks.d.ts +41 -0
  37. package/dist/command/cli/first-use-checks.js +127 -0
  38. package/dist/command/cli/first-verdict-node.d.ts +3 -0
  39. package/dist/command/cli/first-verdict-node.js +189 -0
  40. package/dist/command/cli/first-verdict.d.ts +126 -0
  41. package/dist/command/cli/first-verdict.js +267 -0
  42. package/dist/command/cli/launch/sdk-version-match.d.ts +3 -2
  43. package/dist/command/cli/launch/sdk-version-match.js +3 -2
  44. package/dist/command/cli/lifecycle/daemon-lifecycle.js +6 -5
  45. package/dist/command/cli/mcp-command.js +13 -4
  46. package/dist/command/cli/ports/port-holder.d.ts +2 -0
  47. package/dist/command/cli/ports/port-holder.js +11 -3
  48. package/dist/command/cli/setup-mcp-cli.d.ts +27 -1
  49. package/dist/command/cli/setup-mcp-cli.js +98 -23
  50. package/dist/command/cli/status/status-lines.d.ts +1 -0
  51. package/dist/command/cli/status/status-lines.js +50 -17
  52. package/dist/command/cli/status-fields.js +17 -3
  53. package/dist/command/cli/try-command.d.ts +7 -0
  54. package/dist/command/cli/try-command.js +11 -3
  55. package/dist/command/cli.js +70 -33
  56. package/dist/command/daemon/binding/daemon-status-probe.d.ts +3 -1
  57. package/dist/command/daemon/binding/daemon-status-probe.js +4 -2
  58. package/dist/command/daemon/daemon-argv.d.ts +9 -0
  59. package/dist/command/daemon/daemon-argv.js +11 -0
  60. package/dist/command/daemon/daemon.d.ts +10 -3
  61. package/dist/command/daemon/daemon.js +17 -6
  62. package/dist/command/daemon/lifetime/daemon-exit.d.ts +24 -0
  63. package/dist/command/daemon/lifetime/daemon-exit.js +39 -0
  64. package/dist/command/daemon/lifetime/death-cause.d.ts +15 -0
  65. package/dist/command/daemon/lifetime/death-cause.js +130 -0
  66. package/dist/command/daemon/lifetime/heartbeat.d.ts +7 -15
  67. package/dist/command/daemon/lifetime/heartbeat.js +4 -13
  68. package/dist/command/daemon/port-state.d.ts +14 -0
  69. package/dist/command/daemon/port-state.js +31 -0
  70. package/dist/command/setup/agent-configs.d.ts +2 -0
  71. package/dist/command/setup/agent-configs.js +42 -15
  72. package/dist/command/setup/agent-next-steps.d.ts +77 -0
  73. package/dist/command/setup/agent-next-steps.js +284 -0
  74. package/dist/command/setup/agent-writer.js +11 -6
  75. package/dist/command/setup/init/init-host.js +2 -0
  76. package/dist/command/setup/init/init-runtime.d.ts +6 -1
  77. package/dist/command/setup/init/init-runtime.js +57 -21
  78. package/dist/command/setup/mcp-launcher.d.ts +3 -0
  79. package/dist/command/setup/mcp-launcher.js +11 -0
  80. package/dist/command/setup/setup-command.js +30 -12
  81. package/dist/command/setup/setup-mcp.d.ts +8 -0
  82. package/dist/command/setup/setup-mcp.js +47 -20
  83. package/dist/command/setup/start-dev-server.d.ts +8 -0
  84. package/dist/command/setup/start-dev-server.js +43 -0
  85. package/dist/command/version/identity/server-version.d.ts +2 -0
  86. package/dist/command/version/identity/server-version.js +2 -0
  87. package/dist/command/version/version-skew.d.ts +7 -0
  88. package/dist/command/version/version-skew.js +13 -2
  89. package/dist/features/license/license.d.ts +11 -71
  90. package/dist/features/license/license.js +43 -136
  91. package/dist/features/visual/desktop-capture.d.ts +33 -0
  92. package/dist/features/visual/desktop-capture.js +94 -0
  93. package/dist/features/visual/step-shots.js +139 -35
  94. package/dist/features/visual/visual-tools.d.ts +0 -18
  95. package/dist/features/visual/visual-tools.js +2 -81
  96. package/dist/import-graph.js +5 -6
  97. package/dist/index.d.ts +3 -1
  98. package/dist/index.js +14 -5
  99. package/dist/judgement/runs/run-ledger.d.ts +7 -5
  100. package/dist/judgement/runs/run-ledger.js +98 -69
  101. package/dist/judgement/runs/verification-sync.d.ts +6 -3
  102. package/dist/judgement/runs/verification-sync.js +21 -10
  103. package/dist/language/flows/flow-store-for-session.js +7 -5
  104. package/dist/language/flows/recording/tape/recordings.d.ts +2 -13
  105. package/dist/language/flows/recording/tape/recordings.js +41 -44
  106. package/dist/language/flows/replay.d.ts +17 -7
  107. package/dist/language/flows/replay.js +95 -44
  108. package/dist/launch-chromium.d.ts +1 -0
  109. package/dist/launch-chromium.js +49 -12
  110. package/dist/memory/cloud/sync-screenshots.js +6 -1
  111. package/dist/portal/bridge/bridge.d.ts +13 -4
  112. package/dist/portal/bridge/bridge.js +10 -9
  113. package/dist/portal/input/drive-error.d.ts +6 -0
  114. package/dist/portal/input/drive-error.js +9 -0
  115. package/dist/portal/input/network-mock.js +3 -2
  116. package/dist/portal/input/real-input-press.d.ts +34 -0
  117. package/dist/portal/input/real-input-press.js +150 -0
  118. package/dist/portal/input/real-input.d.ts +21 -23
  119. package/dist/portal/input/real-input.js +153 -194
  120. package/dist/portal/input/viewport-tools.js +6 -3
  121. package/dist/portal/pool/browser-pool.d.ts +5 -1
  122. package/dist/portal/pool/browser-pool.js +32 -38
  123. package/dist/portal/pool/context-permissions.d.ts +20 -17
  124. package/dist/portal/pool/context-permissions.js +63 -1
  125. package/dist/portal/pool/csp-widen.d.ts +62 -0
  126. package/dist/portal/pool/csp-widen.js +132 -0
  127. package/dist/portal/pool/lease-page-error.d.ts +29 -0
  128. package/dist/portal/pool/lease-page-error.js +18 -0
  129. package/dist/portal/pool/page-error-journal.d.ts +57 -0
  130. package/dist/portal/pool/page-error-journal.js +181 -0
  131. package/dist/portal/pool/playwright-launcher.js +21 -8
  132. package/dist/portal/pool/pool-contract.d.ts +22 -0
  133. package/dist/portal/pool/pool-lease.d.ts +35 -2
  134. package/dist/portal/pool/pool-lease.js +23 -0
  135. package/dist/portal/pool/zero-install.js +69 -1
  136. package/dist/portal/session/dev-server/dev-command.d.ts +8 -0
  137. package/dist/portal/session/dev-server/dev-command.js +56 -8
  138. package/dist/portal/session/no-session-diagnosis.js +10 -3
  139. package/dist/portal/session/no-session-next-action.d.ts +12 -0
  140. package/dist/portal/session/no-session-next-action.js +44 -4
  141. package/dist/portal/session/no-session-watch.js +3 -1
  142. package/dist/portal/session/page-commands/undeclared-command.d.ts +9 -3
  143. package/dist/portal/session/page-commands/undeclared-command.js +16 -7
  144. package/dist/portal/session/recorded-gaps.d.ts +1 -1
  145. package/dist/portal/session/recorded-gaps.js +5 -2
  146. package/dist/portal/session/session-manager.js +40 -21
  147. package/dist/portal/session/session.js +4 -3
  148. package/dist/portal/session/zero-install-first-call.d.ts +20 -1
  149. package/dist/portal/session/zero-install-first-call.js +34 -2
  150. package/dist/start-wiring.d.ts +29 -2
  151. package/dist/start-wiring.js +73 -9
  152. package/dist/surface/mcp/mcp-proxy.js +10 -2
  153. package/dist/surface/mcp/mcp.js +5 -3
  154. package/dist/surface/mcp/peer/agents-connected.d.ts +6 -0
  155. package/dist/surface/mcp/peer/agents-connected.js +58 -0
  156. package/dist/surface/mcp/proxy/proxy-daemon-probe.js +17 -5
  157. package/dist/surface/tools/act/act-preflight.js +37 -17
  158. package/dist/surface/tools/act/act-target.js +3 -1
  159. package/dist/surface/tools/act/after-match.d.ts +7 -0
  160. package/dist/surface/tools/act/after-match.js +33 -0
  161. package/dist/surface/tools/act/resolve-target.d.ts +2 -0
  162. package/dist/surface/tools/act/resolve-target.js +35 -8
  163. package/dist/surface/tools/act/sequence-grade.d.ts +3 -1
  164. package/dist/surface/tools/act/sequence-grade.js +8 -1
  165. package/dist/surface/tools/act-sequence-tool.js +3 -0
  166. package/dist/surface/tools/act-tools.js +12 -12
  167. package/dist/surface/tools/assert/assert-verdict.js +28 -1
  168. package/dist/surface/tools/browser-tools.js +75 -7
  169. package/dist/surface/tools/dynamic-tools.js +2 -1
  170. package/dist/surface/tools/empty-session-list.d.ts +2 -0
  171. package/dist/surface/tools/empty-session-list.js +4 -2
  172. package/dist/surface/tools/error-recovery.js +43 -22
  173. package/dist/surface/tools/invoke-tool.js +27 -20
  174. package/dist/surface/tools/lease-hint.d.ts +7 -0
  175. package/dist/surface/tools/lease-hint.js +10 -3
  176. package/dist/surface/tools/lease-readiness.d.ts +21 -0
  177. package/dist/surface/tools/lease-readiness.js +47 -0
  178. package/dist/surface/tools/lease-seed.d.ts +2 -0
  179. package/dist/surface/tools/lease-seed.js +2 -1
  180. package/dist/surface/tools/lease-tools.js +43 -15
  181. package/dist/surface/tools/real-input-attempt.js +70 -31
  182. package/dist/surface/tools/repeat-hint.d.ts +40 -0
  183. package/dist/surface/tools/repeat-hint.js +171 -0
  184. package/dist/surface/tools/run-report.d.ts +2 -2
  185. package/dist/surface/tools/run-report.js +27 -15
  186. package/dist/surface/tools/tool-kit.d.ts +12 -0
  187. package/dist/surface/tools/tool-kit.js +7 -0
  188. package/dist/surface/tools/tools.js +26 -13
  189. package/dist/telemetry/argument-shape.js +16 -7
  190. package/dist/telemetry/cli-telemetry.d.ts +10 -1
  191. package/dist/telemetry/cli-telemetry.js +15 -0
  192. package/dist/telemetry/init-telemetry.d.ts +2 -1
  193. package/dist/telemetry/init-telemetry.js +47 -2
  194. package/dist/telemetry/install-trace.js +23 -4
  195. package/dist/telemetry/onboarding-funnel.d.ts +19 -2
  196. package/dist/telemetry/onboarding-funnel.js +25 -3
  197. package/dist/telemetry/proxy-telemetry.d.ts +2 -1
  198. package/dist/telemetry/proxy-telemetry.js +2 -2
  199. package/dist/telemetry/session-metrics.d.ts +1 -1
  200. package/dist/telemetry/session-metrics.js +67 -27
  201. package/dist/telemetry/telemetry.d.ts +3 -1
  202. package/dist/telemetry/telemetry.js +60 -28
  203. package/dist/telemetry/tool-refused.d.ts +2 -2
  204. package/dist/telemetry/tool-refused.js +9 -2
  205. package/docs/architecture.md +3 -6
  206. package/docs/cli/doctor.mdx +41 -2
  207. package/docs/cli/help.mdx +4 -2
  208. package/docs/cli/license.mdx +4 -5
  209. package/docs/cli/setup.mdx +13 -2
  210. package/docs/cli/status.mdx +4 -0
  211. package/docs/cli.mdx +1 -1
  212. package/docs/deploy-checks.md +25 -0
  213. package/docs/desktop.mdx +1 -1
  214. package/docs/enterprise.md +15 -20
  215. package/docs/environment.mdx +1 -0
  216. package/docs/first-drive.md +1 -1
  217. package/docs/install-manual.mdx +16 -1
  218. package/docs/packages/engine.mdx +1 -1
  219. package/docs/packages/react.mdx +14 -2
  220. package/docs/packages/server.mdx +1 -1
  221. package/docs/packages/tauri.mdx +1 -1
  222. package/docs/packages/test.mdx +1 -1
  223. package/docs/packages/vite-plugin.mdx +1 -0
  224. package/docs/packages.mdx +1 -1
  225. package/docs/platform-integration.md +1 -1
  226. package/docs/skill.md +1 -1
  227. package/docs/telemetry.md +4 -2
  228. package/package.json +14 -10
  229. package/LICENSE-ENTERPRISE +0 -37
  230. package/dist/features/ee/audit-log.d.ts +0 -14
  231. package/dist/features/ee/audit-log.js +0 -11
@@ -14,10 +14,11 @@ import { log } from '../../log.js';
14
14
  import { closeLeaseTab, LEASE_CLOSE_TIMED_OUT, settledWithin } from './bounded-close.js';
15
15
  import { seedStorageInto, targetOriginOf } from './storage-seed.js';
16
16
  import { installLeaseMarker, leaseMarkerOf } from './lease-marker-stamp.js';
17
- import { grantLeasePermissions, readNotificationPermission } from './context-permissions.js';
17
+ import { grantLeasePermissions, grantLocalNetworkAccess, readNotificationPermission, widenLeaseCsp, } from './context-permissions.js';
18
18
  import { readViewport } from './lease-emulation.js';
19
19
  import { NotHtmlDocumentError, READER_NOT_HTML } from './zero-install.js';
20
- import { DEFAULT_LEASE_TTL_MS, DEFAULT_NAV_TIMEOUT_MS, INIT_SCRIPT_UNSUPPORTED, MAX_REMEMBERED_REAPED_LEASES, NO_HEADED_BROWSER, ORPHANED_LEASE_GRACE_MS, pageLoading, PoolAtCapacityError, } from './pool-lease.js';
20
+ import { DEFAULT_LEASE_TTL_MS, DEFAULT_NAV_TIMEOUT_MS, INIT_SCRIPT_UNSUPPORTED, MAX_REMEMBERED_REAPED_LEASES, NO_HEADED_BROWSER, ORPHANED_LEASE_GRACE_MS, navStatusOf, pageLoading, PoolAtCapacityError, } from './pool-lease.js';
21
+ import { listenForPageErrors, reportLeaseClosed, } from './lease-page-error.js';
21
22
  export { DEFAULT_LEASE_TTL_MS, NO_HEADED_BROWSER, ORPHANED_LEASE_GRACE_MS, PoolAtCapacityError, } from './pool-lease.js';
22
23
  /**
23
24
  * Owns a headless browser (and, when asked, a headed one) and leases isolated contexts out of them,
@@ -30,6 +31,8 @@ export class BrowserPool {
30
31
  #now;
31
32
  #ttl;
32
33
  #zeroInstall;
34
+ #daemonOrigin;
35
+ #pageErrorHooks;
33
36
  #navTimeout;
34
37
  #headless;
35
38
  #headed;
@@ -73,6 +76,8 @@ export class BrowserPool {
73
76
  this.#ttl = opts.leaseTtlMs ?? DEFAULT_LEASE_TTL_MS;
74
77
  this.#navTimeout = opts.navTimeoutMs ?? DEFAULT_NAV_TIMEOUT_MS;
75
78
  this.#zeroInstall = opts.zeroInstallScript;
79
+ this.#daemonOrigin = opts.daemonOrigin;
80
+ this.#pageErrorHooks = { onPageError: opts.onPageError, onLeaseClosed: opts.onLeaseClosed };
76
81
  }
77
82
  /** The TTL configured for leases — how long an untouched lease lives before the reaper reclaims it. */
78
83
  leaseTtlMs() {
@@ -109,17 +114,9 @@ export class BrowserPool {
109
114
  return undefined;
110
115
  }
111
116
  for (const [leaseId, lease] of this.#active) {
112
- if (lease.owner !== owner) {
113
- continue;
114
- }
115
- let leaseOrigin;
116
- try {
117
- leaseOrigin = new URL(lease.url).origin;
118
- }
119
- catch {
120
- continue;
121
- }
122
- if (leaseOrigin !== origin) {
117
+ if (lease.owner !== owner ||
118
+ !URL.canParse(lease.url) ||
119
+ new URL(lease.url).origin !== origin) {
123
120
  continue;
124
121
  }
125
122
  for (const [alias, id] of this.#aliases) {
@@ -247,15 +244,21 @@ export class BrowserPool {
247
244
  return undefined;
248
245
  }
249
246
  this.touch(sessionId);
247
+ delete lease.captureError;
250
248
  try {
251
249
  return await lease.page.screenshot(opts);
252
250
  }
253
- catch {
254
- // A capture that fails is not a broken lease. Report "could not capture" and leave the lease
255
- // usable — the alternative is losing a working context to one bad frame.
251
+ catch (error) {
252
+ // A capture that fails is not a broken lease. Keep the reason and leave the lease usable,
253
+ // so the screenshot error says why the browser capture failed.
254
+ lease.captureError = error instanceof Error ? error.message : String(error);
256
255
  return undefined;
257
256
  }
258
257
  }
258
+ /** The reason {@link screenshotLease} last failed for this session, if it did. */
259
+ leaseCaptureError(sessionId) {
260
+ return this.#active.get(this.#leaseIdOf(sessionId))?.captureError;
261
+ }
259
262
  /** The URL a lease was opened on — the one that carries its Reticle identity. */
260
263
  leaseUrl(sessionId) {
261
264
  return this.#active.get(this.#leaseIdOf(sessionId))?.url;
@@ -516,10 +519,10 @@ export class BrowserPool {
516
519
  if (opts.permissions !== undefined) {
517
520
  await grantLeasePermissions(context, opts.permissions, url);
518
521
  }
522
+ await grantLocalNetworkAccess(context, url);
523
+ const csp = await widenLeaseCsp(context, url, this.#daemonOrigin);
519
524
  const page = await context.newPage();
520
- // A caller-supplied init script is a stub of the bindings a desktop shell injects, and it has
521
- // to exist before the app's first module runs. Installed here — before goto — so the very
522
- // first document gets it, and kept for the lease (see hostStub) so a reload keeps it too.
525
+ // A desktop-shell stub must exist before the app's first module: added before goto, and kept.
523
526
  let hostStub;
524
527
  if (opts.initScript !== undefined) {
525
528
  const added = await page.addInitScript?.(opts.initScript);
@@ -562,6 +565,8 @@ export class BrowserPool {
562
565
  }
563
566
  void dialog.dismiss();
564
567
  });
568
+ // Listener on BEFORE goto — an import-time throw lands during it (#1966).
569
+ listenForPageErrors(page, sessionId, this.#pageErrorHooks);
565
570
  const marker = leaseMarkerOf(url);
566
571
  if (marker?.session === sessionId) {
567
572
  await installLeaseMarker(page, sessionId, targetOriginOf(url), marker.project);
@@ -593,24 +598,7 @@ export class BrowserPool {
593
598
  // Foreground it: a page opened beside other tabs can start hidden (#1351). Best-effort — a tab
594
599
  // that stays hidden is reported by name at the lease, which failing here would lose.
595
600
  await page.bringToFront?.().catch(() => undefined);
596
- let navStatus;
597
- if (null !== navRes && 'object' === typeof navRes && 'status' in navRes) {
598
- const s = navRes.status;
599
- if ('function' === typeof s) {
600
- try {
601
- const code = s.call(navRes);
602
- if ('number' === typeof code) {
603
- navStatus = code;
604
- }
605
- }
606
- catch {
607
- // ignore
608
- }
609
- }
610
- else if ('number' === typeof s) {
611
- navStatus = s;
612
- }
613
- }
601
+ const navStatus = navStatusOf(navRes);
614
602
  this.#active.set(sessionId, {
615
603
  headed: slot.headed,
616
604
  context,
@@ -620,6 +608,7 @@ export class BrowserPool {
620
608
  touchedAt: this.#now(),
621
609
  permissions: opts.permissions ?? [],
622
610
  ignoresHTTPSErrors: true === opts.ignoreHTTPSErrors,
611
+ csp,
623
612
  ...(pending === undefined ? {} : { dialFailureUrl: pending }),
624
613
  ...(pendingDialogMessage === undefined ? {} : { lastDialogMessage: pendingDialogMessage }),
625
614
  ...(hostStub === undefined || opts.initScript === undefined
@@ -632,7 +621,7 @@ export class BrowserPool {
632
621
  ...(navStatus !== undefined ? { navStatus } : {}),
633
622
  release: () => this.#release(sessionId),
634
623
  injectReader: async () => {
635
- const script = this.#zeroInstall?.();
624
+ const script = this.#zeroInstall?.(url);
636
625
  if (script === undefined || page.evaluate === undefined) {
637
626
  return false;
638
627
  }
@@ -703,6 +692,7 @@ export class BrowserPool {
703
692
  return;
704
693
  } // already released or lost to a crash
705
694
  this.#active.delete(sessionId);
695
+ reportLeaseClosed(this.#pageErrorHooks, sessionId);
706
696
  // Drop any alias pointing here, or the map grows for the life of the daemon and a later lease
707
697
  // reusing that registered name would resolve to a context that is already closed.
708
698
  for (const [registered, leaseId] of this.#aliases) {
@@ -863,6 +853,10 @@ export class BrowserPool {
863
853
  waiter();
864
854
  } // let them re-claim + relaunch
865
855
  }
856
+ /** The origin added to this lease's page CSP `connect-src`, or undefined if none was (#2016). */
857
+ cspWidenedFor(sessionId) {
858
+ return this.#active.get(this.#leaseIdOf(sessionId))?.csp.widenedFor;
859
+ }
866
860
  /** Whether this lease's context skips certificate checks. A context cannot change it later. */
867
861
  ignoresHTTPSErrors(sessionId) {
868
862
  return true === this.#active.get(this.#leaseIdOf(sessionId))?.ignoresHTTPSErrors;
@@ -1,20 +1,3 @@
1
- /**
2
- * Browser permissions on a leased context: granted before the first navigation, and read back where
3
- * the browser can contradict the grant.
4
- *
5
- * Never changed on a lease that is already open. Acquire hands an origin's live lease to whoever asks
6
- * for that origin, so the lease may be another agent's tab mid-flow, and nothing here knows whose it
7
- * is. A different set means a fresh lease.
8
- *
9
- * Each grant is scoped to the origin the lease was opened on, so a third-party frame the app embeds
10
- * is not handed the same grant as the app.
11
- *
12
- * Notifications are why the read-back exists. Playwright's headless shell — what the pool launches
13
- * when Playwright's own Chromium is installed — has no notification support: after a grant,
14
- * `navigator.permissions.query` reports `granted` while `Notification.permission` still reads
15
- * `denied`. A page that gates its UI on the second never sees the grant, so a lease that reported
16
- * "granted" there would be reporting something the page cannot see.
17
- */
18
1
  import type { PooledContext, PooledPage } from './pool-contract.js';
19
2
  /** The permission whose grant the headless shell does not pass on to `Notification.permission`. */
20
3
  export declare const NOTIFICATIONS_PERMISSION = "notifications";
@@ -31,6 +14,19 @@ export declare class LeasePermissionError extends Error {
31
14
  }
32
15
  /** Grant `permissions` on the origin of `url`. Nothing to grant is a no-op, not a refusal. */
33
16
  export declare function grantLeasePermissions(context: PooledContext, permissions: readonly string[], url: string): Promise<void>;
17
+ /** The origin of `url` when the page is not on loopback, else undefined (also for no url). */
18
+ export declare function offLoopbackOrigin(url: string | undefined): string | undefined;
19
+ /** Playwright's name for Chrome's Local Network Access grant (local + loopback network). */
20
+ export declare const LOCAL_NETWORK_ACCESS = "local-network-access";
21
+ /**
22
+ * Let a page that is not on loopback dial the bridge on loopback. Chromium blocks that unless a
23
+ * person grants Local Network Access, and a leased browser has no person, so a lease on a hosted
24
+ * preview loaded, signed in, and answered `sdk_never_dialled`. A loopback page needs no grant.
25
+ *
26
+ * ponytail: best effort. A Playwright older than the permission name refuses it; the lease then
27
+ * opens as before, and a page that cannot dial still reports `sdk_never_dialled`.
28
+ */
29
+ export declare function grantLocalNetworkAccess(context: PooledContext, url: string): Promise<void>;
34
30
  /**
35
31
  * What the page's `Notification.permission` says, or undefined when it cannot be asked.
36
32
  *
@@ -39,3 +35,10 @@ export declare function grantLeasePermissions(context: PooledContext, permission
39
35
  * instead of holding the caller.
40
36
  */
41
37
  export declare function readNotificationPermission(page: PooledPage): Promise<string | undefined>;
38
+ /**
39
+ * On a lease off loopback, add `daemonOrigin` to the page CSP `connect-src` (#2016). The returned
40
+ * record gets `widenedFor` once a response was really widened, so the lease can say so.
41
+ */
42
+ export declare function widenLeaseCsp(context: PooledContext, url: string, daemonOrigin: string | undefined): Promise<{
43
+ widenedFor?: string;
44
+ }>;
@@ -1,3 +1,21 @@
1
+ /**
2
+ * Browser permissions on a leased context: granted before the first navigation, and read back where
3
+ * the browser can contradict the grant.
4
+ *
5
+ * Never changed on a lease that is already open. Acquire hands an origin's live lease to whoever asks
6
+ * for that origin, so the lease may be another agent's tab mid-flow, and nothing here knows whose it
7
+ * is. A different set means a fresh lease.
8
+ *
9
+ * Each grant is scoped to the origin the lease was opened on, so a third-party frame the app embeds
10
+ * is not handed the same grant as the app.
11
+ *
12
+ * Notifications are why the read-back exists. Playwright's headless shell — what the pool launches
13
+ * when Playwright's own Chromium is installed — has no notification support: after a grant,
14
+ * `navigator.permissions.query` reports `granted` while `Notification.permission` still reads
15
+ * `denied`. A page that gates its UI on the second never sees the grant, so a lease that reported
16
+ * "granted" there would be reporting something the page cannot see.
17
+ */
18
+ import { isLoopbackHostname } from '@reticlehq/core';
1
19
  /** The permission whose grant the headless shell does not pass on to `Notification.permission`. */
2
20
  export const NOTIFICATIONS_PERMISSION = 'notifications';
3
21
  /** Evaluated in the leased page. A string, because the pool's page surface evaluates scripts. */
@@ -19,8 +37,9 @@ function reasonOf(err) {
19
37
  }
20
38
  /** Grant `permissions` on the origin of `url`. Nothing to grant is a no-op, not a refusal. */
21
39
  export async function grantLeasePermissions(context, permissions, url) {
22
- if (0 === permissions.length)
40
+ if (0 === permissions.length) {
23
41
  return;
42
+ }
24
43
  if (context.grantPermissions === undefined) {
25
44
  throw new LeasePermissionError('this browser context cannot grant permissions');
26
45
  }
@@ -31,6 +50,36 @@ export async function grantLeasePermissions(context, permissions, url) {
31
50
  throw new LeasePermissionError(`could not grant ${permissions.join(', ')}: ${reasonOf(err)}`);
32
51
  }
33
52
  }
53
+ /** The origin of `url` when the page is not on loopback, else undefined (also for no url). */
54
+ export function offLoopbackOrigin(url) {
55
+ if (url === undefined || !URL.canParse(url)) {
56
+ return undefined;
57
+ }
58
+ const target = new URL(url);
59
+ return isLoopbackHostname(target.hostname) ? undefined : target.origin;
60
+ }
61
+ /** Playwright's name for Chrome's Local Network Access grant (local + loopback network). */
62
+ export const LOCAL_NETWORK_ACCESS = 'local-network-access';
63
+ /**
64
+ * Let a page that is not on loopback dial the bridge on loopback. Chromium blocks that unless a
65
+ * person grants Local Network Access, and a leased browser has no person, so a lease on a hosted
66
+ * preview loaded, signed in, and answered `sdk_never_dialled`. A loopback page needs no grant.
67
+ *
68
+ * ponytail: best effort. A Playwright older than the permission name refuses it; the lease then
69
+ * opens as before, and a page that cannot dial still reports `sdk_never_dialled`.
70
+ */
71
+ export async function grantLocalNetworkAccess(context, url) {
72
+ const origin = offLoopbackOrigin(url);
73
+ if (context.grantPermissions === undefined || origin === undefined) {
74
+ return;
75
+ }
76
+ try {
77
+ await context.grantPermissions([LOCAL_NETWORK_ACCESS], { origin });
78
+ }
79
+ catch {
80
+ // An unknown permission name on an older Playwright: open the lease without it.
81
+ }
82
+ }
34
83
  /**
35
84
  * What the page's `Notification.permission` says, or undefined when it cannot be asked.
36
85
  *
@@ -57,3 +106,16 @@ export async function readNotificationPermission(page) {
57
106
  clearTimeout(timer);
58
107
  }
59
108
  }
109
+ /**
110
+ * On a lease off loopback, add `daemonOrigin` to the page CSP `connect-src` (#2016). The returned
111
+ * record gets `widenedFor` once a response was really widened, so the lease can say so.
112
+ */
113
+ export async function widenLeaseCsp(context, url, daemonOrigin) {
114
+ const widening = {};
115
+ if (daemonOrigin !== undefined && offLoopbackOrigin(url) !== undefined) {
116
+ await context.widenCsp?.(daemonOrigin, () => {
117
+ widening.widenedFor = daemonOrigin;
118
+ });
119
+ }
120
+ return widening;
121
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Let the zero-install reader dial the daemon from a page whose CSP does not allow it (#2016).
3
+ *
4
+ * The page keeps its own Content-Security-Policy. The rewrite adds one source, the daemon's
5
+ * WebSocket origin, to `connect-src`, and changes nothing else. A bypass of page CSP would let a
6
+ * lease pass where a real user hits a CSP block, which is a false green.
7
+ *
8
+ * The rewrite changes the page that the agent verifies, so it reports each change (`changed`, and
9
+ * the `onWidened` call), and the lease result says `cspWidenedFor`.
10
+ */
11
+ /** The result of a rewrite: the new text, and whether it differs from the text that came in. */
12
+ export interface CspRewrite {
13
+ policy: string;
14
+ changed: boolean;
15
+ }
16
+ /**
17
+ * Add `origin` to the `connect-src` of each policy in `policy`. A comma joins several policies,
18
+ * and a browser enforces all of them, so each one gets the origin. A policy with no `connect-src`
19
+ * and no `default-src` already allows the dial, and stays as it is.
20
+ */
21
+ export declare function allowDaemonInCsp(policy: string, origin: string): CspRewrite;
22
+ /**
23
+ * Apply `allowDaemonInCsp` to each `<meta http-equiv="Content-Security-Policy">` in `html`.
24
+ * ponytail: reads the attribute text as it is; an entity-encoded quote inside it stays encoded.
25
+ */
26
+ export declare function allowDaemonInHtml(html: string, origin: string): {
27
+ html: string;
28
+ changed: boolean;
29
+ };
30
+ /** The part of a Playwright `Route` the rewrite uses. A real `Route` satisfies it. */
31
+ export interface CspRoute<R extends CspResponse = CspResponse> {
32
+ request(): {
33
+ resourceType(): string;
34
+ frame(): {
35
+ parentFrame(): unknown;
36
+ };
37
+ };
38
+ fallback(): Promise<void>;
39
+ fetch(opts: {
40
+ maxRedirects: number;
41
+ }): Promise<R>;
42
+ fulfill(opts: {
43
+ response: R;
44
+ headers?: Record<string, string>;
45
+ body?: string;
46
+ }): Promise<void>;
47
+ }
48
+ /** The part of a Playwright `APIResponse` the rewrite uses. */
49
+ export interface CspResponse {
50
+ headers(): Record<string, string>;
51
+ headersArray(): {
52
+ name: string;
53
+ value: string;
54
+ }[];
55
+ text(): Promise<string>;
56
+ }
57
+ /**
58
+ * A route handler for a lease context: on the top document, add `origin` to its CSP header and
59
+ * CSP meta tags, and call `onWidened` when that changed anything. Every other request goes on.
60
+ * `Content-Security-Policy-Report-Only` blocks nothing, so it stays as the server sent it.
61
+ */
62
+ export declare function widenDocumentCsp<R extends CspResponse>(route: CspRoute<R>, origin: string, onWidened: () => void): Promise<void>;
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Let the zero-install reader dial the daemon from a page whose CSP does not allow it (#2016).
3
+ *
4
+ * The page keeps its own Content-Security-Policy. The rewrite adds one source, the daemon's
5
+ * WebSocket origin, to `connect-src`, and changes nothing else. A bypass of page CSP would let a
6
+ * lease pass where a real user hits a CSP block, which is a false green.
7
+ *
8
+ * The rewrite changes the page that the agent verifies, so it reports each change (`changed`, and
9
+ * the `onWidened` call), and the lease result says `cspWidenedFor`.
10
+ */
11
+ const CONNECT_SRC = 'connect-src';
12
+ const DEFAULT_SRC = 'default-src';
13
+ const NONE_SOURCE = "'none'";
14
+ const CSP_HEADER = 'content-security-policy';
15
+ const DOCUMENT = 'document';
16
+ const tokensOf = (directive) => directive.trim().split(/\s+/);
17
+ const nameOf = (directive) => (tokensOf(directive)[0] ?? '').toLowerCase();
18
+ /** `name` plus its sources plus `origin`. `'none'` goes, because it is valid only alone. */
19
+ function withOrigin(name, sources, origin) {
20
+ const kept = sources.filter((s) => s.toLowerCase() !== NONE_SOURCE);
21
+ return [name, ...kept, origin].join(' ');
22
+ }
23
+ /** One policy (no commas). Only the first `connect-src` counts; a browser ignores a repeat. */
24
+ function allowInOnePolicy(policy, origin) {
25
+ const directives = policy.split(';');
26
+ const connect = directives.findIndex((d) => nameOf(d) === CONNECT_SRC);
27
+ if (connect !== -1) {
28
+ const [name = CONNECT_SRC, ...sources] = tokensOf(directives[connect] ?? '');
29
+ if (sources.includes(origin)) {
30
+ return policy;
31
+ }
32
+ const lead = /^\s*/.exec(directives[connect] ?? '')?.[0] ?? '';
33
+ directives[connect] = `${lead}${withOrigin(name, sources, origin)}`;
34
+ return directives.join(';');
35
+ }
36
+ const fallback = directives.find((d) => nameOf(d) === DEFAULT_SRC);
37
+ if (fallback === undefined) {
38
+ return policy;
39
+ }
40
+ const head = policy.replace(/[\s;]+$/, '');
41
+ return `${head}; ${withOrigin(CONNECT_SRC, tokensOf(fallback).slice(1), origin)}`;
42
+ }
43
+ /**
44
+ * Add `origin` to the `connect-src` of each policy in `policy`. A comma joins several policies,
45
+ * and a browser enforces all of them, so each one gets the origin. A policy with no `connect-src`
46
+ * and no `default-src` already allows the dial, and stays as it is.
47
+ */
48
+ export function allowDaemonInCsp(policy, origin) {
49
+ const next = policy
50
+ .split(',')
51
+ .map((one) => allowInOnePolicy(one, origin))
52
+ .join(',');
53
+ return { policy: next, changed: next !== policy };
54
+ }
55
+ const META_TAG = /<meta\b[^>]*>/gi;
56
+ const CSP_EQUIV = /\bhttp-equiv\s*=\s*["']?content-security-policy["']?(?=[\s/>])/i;
57
+ const CONTENT_ATTR = /(\bcontent\s*=\s*)("([^"]*)"|'([^']*)')/i;
58
+ /**
59
+ * Apply `allowDaemonInCsp` to each `<meta http-equiv="Content-Security-Policy">` in `html`.
60
+ * ponytail: reads the attribute text as it is; an entity-encoded quote inside it stays encoded.
61
+ */
62
+ export function allowDaemonInHtml(html, origin) {
63
+ const next = html.replace(META_TAG, (tag) => {
64
+ if (!CSP_EQUIV.test(tag)) {
65
+ return tag;
66
+ }
67
+ return tag.replace(CONTENT_ATTR, (_all, prefix, quoted) => {
68
+ const quote = quoted.charAt(0);
69
+ const widened = allowDaemonInCsp(quoted.slice(1, -1), origin).policy;
70
+ return `${prefix}${quote}${widened}${quote}`;
71
+ });
72
+ });
73
+ return { html: next, changed: next !== html };
74
+ }
75
+ function isTopDocument(route) {
76
+ const request = route.request();
77
+ if (request.resourceType() !== DOCUMENT) {
78
+ return false;
79
+ }
80
+ try {
81
+ return null === request.frame().parentFrame();
82
+ }
83
+ catch {
84
+ // A request with no frame (a service worker's own fetch) is no page the reader runs in.
85
+ return false;
86
+ }
87
+ }
88
+ /**
89
+ * A route handler for a lease context: on the top document, add `origin` to its CSP header and
90
+ * CSP meta tags, and call `onWidened` when that changed anything. Every other request goes on.
91
+ * `Content-Security-Policy-Report-Only` blocks nothing, so it stays as the server sent it.
92
+ */
93
+ export async function widenDocumentCsp(route, origin, onWidened) {
94
+ if (!isTopDocument(route)) {
95
+ await route.fallback();
96
+ return;
97
+ }
98
+ let response;
99
+ try {
100
+ // No redirects followed here: a redirect goes back to the browser, so the page URL stays true.
101
+ response = await route.fetch({ maxRedirects: 0 });
102
+ }
103
+ catch {
104
+ await route.fallback();
105
+ return;
106
+ }
107
+ const policies = response
108
+ .headersArray()
109
+ .filter((h) => h.name.toLowerCase() === CSP_HEADER)
110
+ .map((h) => h.value);
111
+ const header = allowDaemonInCsp(policies.join(', '), origin);
112
+ const isHtml = /html/i.test(response.headers()['content-type'] ?? '');
113
+ const meta = isHtml ? allowDaemonInHtml(await response.text(), origin) : undefined;
114
+ if (!header.changed && true !== meta?.changed) {
115
+ await route.fulfill({ response });
116
+ return;
117
+ }
118
+ const headers = { ...response.headers() };
119
+ if (header.changed) {
120
+ headers[CSP_HEADER] = header.policy;
121
+ }
122
+ if (true === meta?.changed) {
123
+ // The new body is plain text: the old encoding and length describe a body that is gone.
124
+ delete headers['content-encoding'];
125
+ delete headers['content-length'];
126
+ await route.fulfill({ response, headers, body: meta.html });
127
+ }
128
+ else {
129
+ await route.fulfill({ response, headers });
130
+ }
131
+ onWidened();
132
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The page-error side of a lease: listen for the throws the SDK will never see, and tell the
3
+ * wiring when the lease is gone.
4
+ *
5
+ * An app whose module graph throws during the first navigation fires its `error` event while the
6
+ * page still has no Reticle SDK in it — the zero-install reader is only evaluated after `goto`
7
+ * resolves — so nothing on the page records it, and a blank page reported as a bare console-error
8
+ * count (#1966). The server's Playwright handle is attached before the navigation, so it is the
9
+ * one witness that cannot miss the throw. This module owns that wiring for `BrowserPool`; the
10
+ * journal that buffers and journals what arrives lives in `page-error-journal.ts`.
11
+ */
12
+ import type { PooledPage, PooledPageError } from './pool-contract.js';
13
+ /** The page-error hooks `BrowserPoolOptions` carries, forwarded here verbatim. */
14
+ export interface LeasePageErrorHooks {
15
+ onPageError?: ((leaseId: string, error: PooledPageError) => void) | undefined;
16
+ onLeaseClosed?: ((leaseId: string) => void) | undefined;
17
+ }
18
+ /**
19
+ * Install the lease's uncaught-error listener. Call BEFORE the first navigation — the listener
20
+ * timing is the whole fix: an import-time throw lands during `goto`, and one attached after it
21
+ * reads a broken page as healthy. Forwarded with the lease id, because the session for this lease
22
+ * usually does not exist yet, so the sink buffers.
23
+ */
24
+ export declare function listenForPageErrors(page: PooledPage, leaseId: string, hooks: LeasePageErrorHooks | undefined): void;
25
+ /**
26
+ * Report a closed lease — manual release, reaper and crash reclaim all funnel through the pool's
27
+ * one release path. The wiring owns whether anything was kept FOR that lease and drops it.
28
+ */
29
+ export declare function reportLeaseClosed(hooks: LeasePageErrorHooks | undefined, leaseId: string): void;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Install the lease's uncaught-error listener. Call BEFORE the first navigation — the listener
3
+ * timing is the whole fix: an import-time throw lands during `goto`, and one attached after it
4
+ * reads a broken page as healthy. Forwarded with the lease id, because the session for this lease
5
+ * usually does not exist yet, so the sink buffers.
6
+ */
7
+ export function listenForPageErrors(page, leaseId, hooks) {
8
+ page.onPageError?.((error) => {
9
+ hooks?.onPageError?.(leaseId, error);
10
+ });
11
+ }
12
+ /**
13
+ * Report a closed lease — manual release, reaper and crash reclaim all funnel through the pool's
14
+ * one release path. The wiring owns whether anything was kept FOR that lease and drops it.
15
+ */
16
+ export function reportLeaseClosed(hooks, leaseId) {
17
+ hooks?.onLeaseClosed?.(leaseId);
18
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Journal the uncaught page errors the SDK was never installed in time to see.
3
+ *
4
+ * An app whose module graph throws during the first navigation fires its `error` event while the
5
+ * page still has no Reticle SDK in it — the zero-install reader is only evaluated after `goto`
6
+ * resolves. The agent then saw a blank page and a bare console-error count, and had to re-run the
7
+ * app under `--enable-logging` to learn why (#1966). The pool's Playwright handle is attached
8
+ * before the navigation, so the server is the one witness that cannot miss the throw.
9
+ *
10
+ * The pool hands errors here with the lease id. The session for that lease usually does not exist
11
+ * yet, so errors buffer per lease and flush the moment the session connects
12
+ * (`bridge.attachSessionCreate`). One already connected means the error is journaled at once.
13
+ *
14
+ * The daemon runs for days, so both maps are told to forget: a lease that is released or reaped
15
+ * has its buffer dropped (`onLeaseClosed`), and a session that has ended has its dedupe memory
16
+ * dropped (`onSessionEnd`). Without those, every failed lease and every ended session leaves an
17
+ * entry behind forever.
18
+ *
19
+ * Deduplication: when the SDK IS installed it reports the same throw as `error.uncaught` — with
20
+ * the browser's `Uncaught ` prefix on the message, which Playwright's spelling lacks. Both
21
+ * spellings fold into one identity (`uncaughtIdentity`), and whichever source is seen first
22
+ * suppresses the other's copy. A journal write is deferred by a short window first, so an SDK
23
+ * event racing the page error lands inside the window and wins. The reverse race — our copy
24
+ * first, the SDK's right after — can still journal both; the SDK's own event is authoritative and
25
+ * only the redundant second copy is cost, not wrong evidence.
26
+ */
27
+ import { type ReticleEvent } from '@reticlehq/core';
28
+ import type { PooledPageError } from './pool-contract.js';
29
+ /** The session surface the journal needs. The real `Session` satisfies this structurally. */
30
+ export interface JournalableSession {
31
+ readonly id: string;
32
+ readonly url?: string;
33
+ pushEvent(event: ReticleEvent): void;
34
+ eventsSince(cursor: number): ReticleEvent[];
35
+ onEvent(listener: (event: ReticleEvent) => void): () => void;
36
+ }
37
+ export interface PageErrorJournal {
38
+ /** The pool's sink — hand it to `BrowserPoolOptions.onPageError`. */
39
+ onPoolPageError(leaseId: string, error: PooledPageError): void;
40
+ /** The bridge hook — register with `bridge.attachSessionCreate`. */
41
+ onSessionCreate(session: JournalableSession): void;
42
+ /** The pool's release hook — register with `BrowserPoolOptions.onLeaseClosed`. */
43
+ onLeaseClosed(leaseId: string): void;
44
+ /** The bridge hook — register with `bridge.attachSessionEnd`. */
45
+ onSessionEnd(sessionId: string): void;
46
+ }
47
+ /**
48
+ * Fold the two spellings of one throw into one identity. Chromium prefixes a window-reported
49
+ * uncaught error with `Uncaught ` and often the error class (`Uncaught TypeError: ...`); the
50
+ * Playwright `pageerror` message is the bare exception text. Everything after the first line is a
51
+ * stack fragment and never matches, so it is dropped.
52
+ */
53
+ export declare function uncaughtIdentity(rawMessage: string): string;
54
+ export declare function makePageErrorJournal(deps: {
55
+ resolveSession: (leaseId: string) => JournalableSession | undefined;
56
+ sleep?: (ms: number) => Promise<void>;
57
+ }): PageErrorJournal;