@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.
- package/LICENSE +201 -104
- package/NOTICE +5 -0
- package/README.md +1 -1
- package/bin/launch.js +13 -0
- package/bin/package.json +3 -0
- package/bin/reticle.js +66 -8
- package/dist/chromium-launch-options.d.ts +25 -0
- package/dist/chromium-launch-options.js +21 -1
- package/dist/command/cli/adhoc-verdict.js +34 -7
- package/dist/command/cli/answers/gate-exit.d.ts +6 -2
- package/dist/command/cli/answers/gate-exit.js +12 -2
- package/dist/command/cli/ci-hint.d.ts +44 -0
- package/dist/command/cli/ci-hint.js +72 -0
- package/dist/command/cli/cli-doctor.d.ts +6 -1
- package/dist/command/cli/cli-doctor.js +45 -4
- package/dist/command/cli/cli-flow-commands.d.ts +7 -1
- package/dist/command/cli/cli-flow-commands.js +39 -17
- package/dist/command/cli/cli-kill.d.ts +21 -0
- package/dist/command/cli/cli-kill.js +46 -3
- package/dist/command/cli/cli-parse.d.ts +5 -2
- package/dist/command/cli/cli-parse.js +10 -5
- package/dist/command/cli/cli-usage.js +3 -2
- package/dist/command/cli/cli-verify.d.ts +6 -1
- package/dist/command/cli/cli-verify.js +32 -13
- package/dist/command/cli/doctor/browser/chromium-hint.d.ts +6 -1
- package/dist/command/cli/doctor/browser/chromium-hint.js +11 -5
- package/dist/command/cli/doctor/doctor-fix.d.ts +44 -0
- package/dist/command/cli/doctor/doctor-fix.js +89 -0
- package/dist/command/cli/doctor/doctor-launcher-line.d.ts +23 -0
- package/dist/command/cli/doctor/doctor-launcher-line.js +60 -0
- package/dist/command/cli/doctor/doctor-rows.d.ts +4 -0
- package/dist/command/cli/doctor/doctor-rows.js +4 -0
- package/dist/command/cli/doctor-repairs.d.ts +64 -0
- package/dist/command/cli/doctor-repairs.js +214 -0
- package/dist/command/cli/drive/drive-command.js +3 -7
- package/dist/command/cli/first-use-checks.d.ts +41 -0
- package/dist/command/cli/first-use-checks.js +127 -0
- package/dist/command/cli/first-verdict-node.d.ts +3 -0
- package/dist/command/cli/first-verdict-node.js +189 -0
- package/dist/command/cli/first-verdict.d.ts +126 -0
- package/dist/command/cli/first-verdict.js +267 -0
- package/dist/command/cli/launch/sdk-version-match.d.ts +3 -2
- package/dist/command/cli/launch/sdk-version-match.js +3 -2
- package/dist/command/cli/lifecycle/daemon-lifecycle.js +6 -5
- package/dist/command/cli/mcp-command.js +13 -4
- package/dist/command/cli/ports/port-holder.d.ts +2 -0
- package/dist/command/cli/ports/port-holder.js +11 -3
- package/dist/command/cli/setup-mcp-cli.d.ts +27 -1
- package/dist/command/cli/setup-mcp-cli.js +98 -23
- package/dist/command/cli/status/status-lines.d.ts +1 -0
- package/dist/command/cli/status/status-lines.js +50 -17
- package/dist/command/cli/status-fields.js +17 -3
- package/dist/command/cli/try-command.d.ts +7 -0
- package/dist/command/cli/try-command.js +11 -3
- package/dist/command/cli.js +70 -33
- package/dist/command/daemon/binding/daemon-status-probe.d.ts +3 -1
- package/dist/command/daemon/binding/daemon-status-probe.js +4 -2
- package/dist/command/daemon/daemon-argv.d.ts +9 -0
- package/dist/command/daemon/daemon-argv.js +11 -0
- package/dist/command/daemon/daemon.d.ts +10 -3
- package/dist/command/daemon/daemon.js +17 -6
- package/dist/command/daemon/lifetime/daemon-exit.d.ts +24 -0
- package/dist/command/daemon/lifetime/daemon-exit.js +39 -0
- package/dist/command/daemon/lifetime/death-cause.d.ts +15 -0
- package/dist/command/daemon/lifetime/death-cause.js +130 -0
- package/dist/command/daemon/lifetime/heartbeat.d.ts +7 -15
- package/dist/command/daemon/lifetime/heartbeat.js +4 -13
- package/dist/command/daemon/port-state.d.ts +14 -0
- package/dist/command/daemon/port-state.js +31 -0
- package/dist/command/setup/agent-configs.d.ts +2 -0
- package/dist/command/setup/agent-configs.js +42 -15
- package/dist/command/setup/agent-next-steps.d.ts +77 -0
- package/dist/command/setup/agent-next-steps.js +284 -0
- package/dist/command/setup/agent-writer.js +11 -6
- package/dist/command/setup/init/init-host.js +2 -0
- package/dist/command/setup/init/init-runtime.d.ts +6 -1
- package/dist/command/setup/init/init-runtime.js +57 -21
- package/dist/command/setup/mcp-launcher.d.ts +3 -0
- package/dist/command/setup/mcp-launcher.js +11 -0
- package/dist/command/setup/setup-command.js +30 -12
- package/dist/command/setup/setup-mcp.d.ts +8 -0
- package/dist/command/setup/setup-mcp.js +47 -20
- package/dist/command/setup/start-dev-server.d.ts +8 -0
- package/dist/command/setup/start-dev-server.js +43 -0
- package/dist/command/version/identity/server-version.d.ts +2 -0
- package/dist/command/version/identity/server-version.js +2 -0
- package/dist/command/version/version-skew.d.ts +7 -0
- package/dist/command/version/version-skew.js +13 -2
- package/dist/features/license/license.d.ts +11 -71
- package/dist/features/license/license.js +43 -136
- package/dist/features/visual/desktop-capture.d.ts +33 -0
- package/dist/features/visual/desktop-capture.js +94 -0
- package/dist/features/visual/step-shots.js +139 -35
- package/dist/features/visual/visual-tools.d.ts +0 -18
- package/dist/features/visual/visual-tools.js +2 -81
- package/dist/import-graph.js +5 -6
- package/dist/index.d.ts +3 -1
- package/dist/index.js +14 -5
- package/dist/judgement/runs/run-ledger.d.ts +7 -5
- package/dist/judgement/runs/run-ledger.js +98 -69
- package/dist/judgement/runs/verification-sync.d.ts +6 -3
- package/dist/judgement/runs/verification-sync.js +21 -10
- package/dist/language/flows/flow-store-for-session.js +7 -5
- package/dist/language/flows/recording/tape/recordings.d.ts +2 -13
- package/dist/language/flows/recording/tape/recordings.js +41 -44
- package/dist/language/flows/replay.d.ts +17 -7
- package/dist/language/flows/replay.js +95 -44
- package/dist/launch-chromium.d.ts +1 -0
- package/dist/launch-chromium.js +49 -12
- package/dist/memory/cloud/sync-screenshots.js +6 -1
- package/dist/portal/bridge/bridge.d.ts +13 -4
- package/dist/portal/bridge/bridge.js +10 -9
- package/dist/portal/input/drive-error.d.ts +6 -0
- package/dist/portal/input/drive-error.js +9 -0
- package/dist/portal/input/network-mock.js +3 -2
- package/dist/portal/input/real-input-press.d.ts +34 -0
- package/dist/portal/input/real-input-press.js +150 -0
- package/dist/portal/input/real-input.d.ts +21 -23
- package/dist/portal/input/real-input.js +153 -194
- package/dist/portal/input/viewport-tools.js +6 -3
- package/dist/portal/pool/browser-pool.d.ts +5 -1
- package/dist/portal/pool/browser-pool.js +32 -38
- package/dist/portal/pool/context-permissions.d.ts +20 -17
- package/dist/portal/pool/context-permissions.js +63 -1
- package/dist/portal/pool/csp-widen.d.ts +62 -0
- package/dist/portal/pool/csp-widen.js +132 -0
- package/dist/portal/pool/lease-page-error.d.ts +29 -0
- package/dist/portal/pool/lease-page-error.js +18 -0
- package/dist/portal/pool/page-error-journal.d.ts +57 -0
- package/dist/portal/pool/page-error-journal.js +181 -0
- package/dist/portal/pool/playwright-launcher.js +21 -8
- package/dist/portal/pool/pool-contract.d.ts +22 -0
- package/dist/portal/pool/pool-lease.d.ts +35 -2
- package/dist/portal/pool/pool-lease.js +23 -0
- package/dist/portal/pool/zero-install.js +69 -1
- package/dist/portal/session/dev-server/dev-command.d.ts +8 -0
- package/dist/portal/session/dev-server/dev-command.js +56 -8
- package/dist/portal/session/no-session-diagnosis.js +10 -3
- package/dist/portal/session/no-session-next-action.d.ts +12 -0
- package/dist/portal/session/no-session-next-action.js +44 -4
- package/dist/portal/session/no-session-watch.js +3 -1
- package/dist/portal/session/page-commands/undeclared-command.d.ts +9 -3
- package/dist/portal/session/page-commands/undeclared-command.js +16 -7
- package/dist/portal/session/recorded-gaps.d.ts +1 -1
- package/dist/portal/session/recorded-gaps.js +5 -2
- package/dist/portal/session/session-manager.js +40 -21
- package/dist/portal/session/session.js +4 -3
- package/dist/portal/session/zero-install-first-call.d.ts +20 -1
- package/dist/portal/session/zero-install-first-call.js +34 -2
- package/dist/start-wiring.d.ts +29 -2
- package/dist/start-wiring.js +73 -9
- package/dist/surface/mcp/mcp-proxy.js +10 -2
- package/dist/surface/mcp/mcp.js +5 -3
- package/dist/surface/mcp/peer/agents-connected.d.ts +6 -0
- package/dist/surface/mcp/peer/agents-connected.js +58 -0
- package/dist/surface/mcp/proxy/proxy-daemon-probe.js +17 -5
- package/dist/surface/tools/act/act-preflight.js +37 -17
- package/dist/surface/tools/act/act-target.js +3 -1
- package/dist/surface/tools/act/after-match.d.ts +7 -0
- package/dist/surface/tools/act/after-match.js +33 -0
- package/dist/surface/tools/act/resolve-target.d.ts +2 -0
- package/dist/surface/tools/act/resolve-target.js +35 -8
- package/dist/surface/tools/act/sequence-grade.d.ts +3 -1
- package/dist/surface/tools/act/sequence-grade.js +8 -1
- package/dist/surface/tools/act-sequence-tool.js +3 -0
- package/dist/surface/tools/act-tools.js +12 -12
- package/dist/surface/tools/assert/assert-verdict.js +28 -1
- package/dist/surface/tools/browser-tools.js +75 -7
- package/dist/surface/tools/dynamic-tools.js +2 -1
- package/dist/surface/tools/empty-session-list.d.ts +2 -0
- package/dist/surface/tools/empty-session-list.js +4 -2
- package/dist/surface/tools/error-recovery.js +43 -22
- package/dist/surface/tools/invoke-tool.js +27 -20
- package/dist/surface/tools/lease-hint.d.ts +7 -0
- package/dist/surface/tools/lease-hint.js +10 -3
- package/dist/surface/tools/lease-readiness.d.ts +21 -0
- package/dist/surface/tools/lease-readiness.js +47 -0
- package/dist/surface/tools/lease-seed.d.ts +2 -0
- package/dist/surface/tools/lease-seed.js +2 -1
- package/dist/surface/tools/lease-tools.js +43 -15
- package/dist/surface/tools/real-input-attempt.js +70 -31
- package/dist/surface/tools/repeat-hint.d.ts +40 -0
- package/dist/surface/tools/repeat-hint.js +171 -0
- package/dist/surface/tools/run-report.d.ts +2 -2
- package/dist/surface/tools/run-report.js +27 -15
- package/dist/surface/tools/tool-kit.d.ts +12 -0
- package/dist/surface/tools/tool-kit.js +7 -0
- package/dist/surface/tools/tools.js +26 -13
- package/dist/telemetry/argument-shape.js +16 -7
- package/dist/telemetry/cli-telemetry.d.ts +10 -1
- package/dist/telemetry/cli-telemetry.js +15 -0
- package/dist/telemetry/init-telemetry.d.ts +2 -1
- package/dist/telemetry/init-telemetry.js +47 -2
- package/dist/telemetry/install-trace.js +23 -4
- package/dist/telemetry/onboarding-funnel.d.ts +19 -2
- package/dist/telemetry/onboarding-funnel.js +25 -3
- package/dist/telemetry/proxy-telemetry.d.ts +2 -1
- package/dist/telemetry/proxy-telemetry.js +2 -2
- package/dist/telemetry/session-metrics.d.ts +1 -1
- package/dist/telemetry/session-metrics.js +67 -27
- package/dist/telemetry/telemetry.d.ts +3 -1
- package/dist/telemetry/telemetry.js +60 -28
- package/dist/telemetry/tool-refused.d.ts +2 -2
- package/dist/telemetry/tool-refused.js +9 -2
- package/docs/architecture.md +3 -6
- package/docs/cli/doctor.mdx +41 -2
- package/docs/cli/help.mdx +4 -2
- package/docs/cli/license.mdx +4 -5
- package/docs/cli/setup.mdx +13 -2
- package/docs/cli/status.mdx +4 -0
- package/docs/cli.mdx +1 -1
- package/docs/deploy-checks.md +25 -0
- package/docs/desktop.mdx +1 -1
- package/docs/enterprise.md +15 -20
- package/docs/environment.mdx +1 -0
- package/docs/first-drive.md +1 -1
- package/docs/install-manual.mdx +16 -1
- package/docs/packages/engine.mdx +1 -1
- package/docs/packages/react.mdx +14 -2
- package/docs/packages/server.mdx +1 -1
- package/docs/packages/tauri.mdx +1 -1
- package/docs/packages/test.mdx +1 -1
- package/docs/packages/vite-plugin.mdx +1 -0
- package/docs/packages.mdx +1 -1
- package/docs/platform-integration.md +1 -1
- package/docs/skill.md +1 -1
- package/docs/telemetry.md +4 -2
- package/package.json +14 -10
- package/LICENSE-ENTERPRISE +0 -37
- package/dist/features/ee/audit-log.d.ts +0 -14
- 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
|
-
|
|
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.
|
|
255
|
-
//
|
|
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
|
|
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
|
-
|
|
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;
|