@reticlehq/server 3.8.2 → 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/network-mock.js +3 -2
- package/dist/portal/input/real-input.d.ts +15 -0
- package/dist/portal/input/real-input.js +59 -3
- 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 +10 -10
- 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/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/server.mdx +1 -1
- package/docs/packages/tauri.mdx +1 -1
- package/docs/packages/test.mdx +1 -1
- 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 +13 -9
- 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,11 +14,12 @@ import { DriveError } from './drive-error.js';
|
|
|
14
14
|
import { pressViaKeyboard } from './real-input-press.js';
|
|
15
15
|
import { stampedDriveUrl } from './drive-url-stamp.js';
|
|
16
16
|
import { launchChromium } from '../../launch-chromium.js';
|
|
17
|
+
import { pageViewportOptions } from '../../chromium-launch-options.js';
|
|
17
18
|
import { chromiumLaunchHint, gotoOptions, SCREENSHOT_DETERMINISM, captureWithChromeHidden, } from '../pool/playwright-launcher.js';
|
|
18
19
|
import { BrowserLaunchKind } from '@reticlehq/core/telemetry';
|
|
19
20
|
import { getSessionMetrics } from '../../telemetry/session-metrics.js';
|
|
20
21
|
import { classifyConnectFailure } from '../../telemetry/connect-failure.js';
|
|
21
|
-
import { ActionType, DriveErrorCode, DRIVE_PLAYWRIGHT_MISSING_MSG, ClickButton, clickButtonFromArgs, InputMode, pressModifiersFromArgs, } from '@reticlehq/core';
|
|
22
|
+
import { ActionType, DriveErrorCode, DRIVE_PLAYWRIGHT_MISSING_MSG, ClickButton, clickButtonFromArgs, InputMode, pressModifiersFromArgs, RETICLE_CHROME_PASS_THROUGH_SCRIPT, RETICLE_CHROME_RESTORE_POINTER_SCRIPT, } from '@reticlehq/core';
|
|
22
23
|
import { installNetworkMocks } from './network-mock.js';
|
|
23
24
|
import { attachNetworkDetail } from './network-detail.js';
|
|
24
25
|
import { injectedConnectArgs } from '../pool/zero-install.js';
|
|
@@ -85,6 +86,27 @@ now = Date.now) {
|
|
|
85
86
|
...(heldMs > 0 ? { heldMs } : {}),
|
|
86
87
|
};
|
|
87
88
|
}
|
|
89
|
+
return withChromePassThrough(page, () => pointerGesture(page, action, box, args, sleep));
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Run a real pointer gesture with Reticle's own UI out of the hit test, and put it back after.
|
|
93
|
+
*
|
|
94
|
+
* A real pointer goes to the topmost element under it. When the HUD sat over the app's control, a
|
|
95
|
+
* driven click went to the HUD and the verdict said the app did nothing; a drag ended short (#992).
|
|
96
|
+
* The restore runs whatever the gesture did, so the HUD does not stay unclickable for a person.
|
|
97
|
+
*/
|
|
98
|
+
export async function withChromePassThrough(page, gesture) {
|
|
99
|
+
await page.evaluate(RETICLE_CHROME_PASS_THROUGH_SCRIPT);
|
|
100
|
+
try {
|
|
101
|
+
return await gesture();
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
// A click that navigated takes the old page and its held values with it.
|
|
105
|
+
await page.evaluate(RETICLE_CHROME_RESTORE_POINTER_SCRIPT).catch(() => undefined);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/** The pointer half of `performGesture`: every action that lands at a point. */
|
|
109
|
+
async function pointerGesture(page, action, box, args, sleep) {
|
|
88
110
|
const center = boxCenter(box);
|
|
89
111
|
const { cx, cy } = center;
|
|
90
112
|
const real = (performed) => ({
|
|
@@ -352,6 +374,8 @@ export class CdpRealInputProvider {
|
|
|
352
374
|
}
|
|
353
375
|
}
|
|
354
376
|
const INJECT_CONNECT_WAIT_MS = 8_000;
|
|
377
|
+
/** True on a page whose Reticle SDK has loaded. */
|
|
378
|
+
const SDK_PRESENT = '!!globalThis.__reticleInstance';
|
|
355
379
|
/** The only place the dynamic value import of Playwright lives for the launched (drive) path. */
|
|
356
380
|
export const launchedChromium = async (headless) => {
|
|
357
381
|
let mod;
|
|
@@ -386,6 +410,7 @@ export class LaunchedRealInputProvider {
|
|
|
386
410
|
#injectConnect;
|
|
387
411
|
#storageState;
|
|
388
412
|
#onNetworkDetail;
|
|
413
|
+
#zeroInstallScript;
|
|
389
414
|
#browser;
|
|
390
415
|
#page;
|
|
391
416
|
/** Pages collecting V8 coverage — see js-coverage.ts. */
|
|
@@ -399,10 +424,14 @@ export class LaunchedRealInputProvider {
|
|
|
399
424
|
this.#injectConnect = options.injectConnect;
|
|
400
425
|
this.#storageState = options.storageState;
|
|
401
426
|
this.#onNetworkDetail = options.onNetworkDetail;
|
|
427
|
+
this.#zeroInstallScript = options.zeroInstallScript;
|
|
402
428
|
}
|
|
403
429
|
async navigate() {
|
|
404
430
|
this.#browser = await this.#launch(this.#headless);
|
|
405
|
-
const page = await this.#browser.newPage(
|
|
431
|
+
const page = await this.#browser.newPage({
|
|
432
|
+
...pageViewportOptions(this.#headless),
|
|
433
|
+
...(this.#storageState !== undefined ? { storageState: this.#storageState } : {}),
|
|
434
|
+
});
|
|
406
435
|
this.#page = page;
|
|
407
436
|
// Capture CDP-authoritative response detail into the driven session's journal (best-effort).
|
|
408
437
|
if (this.#onNetworkDetail !== undefined) {
|
|
@@ -420,8 +449,35 @@ export class LaunchedRealInputProvider {
|
|
|
420
449
|
catch (e) {
|
|
421
450
|
throw new DriveError(DriveErrorCode.NAVIGATE_FAILED, e instanceof Error ? e.message : String(e));
|
|
422
451
|
}
|
|
452
|
+
await this.#supplyReader(page);
|
|
423
453
|
await this.#tryInjectConnect(page);
|
|
424
454
|
}
|
|
455
|
+
/**
|
|
456
|
+
* Give a page with no Reticle SDK the zero-install reader, so `verify` on an app with no `init`
|
|
457
|
+
* still gets a session. The wait for the page's own SDK comes first, as on a leased tab, and the
|
|
458
|
+
* script checks the singleton, so a page with an SDK never gets a second one.
|
|
459
|
+
*/
|
|
460
|
+
async #supplyReader(page) {
|
|
461
|
+
const script = this.#zeroInstallScript?.();
|
|
462
|
+
if (script === undefined) {
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
try {
|
|
466
|
+
await page.waitForFunction(SDK_PRESENT, { timeout: INJECT_CONNECT_WAIT_MS });
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
catch {
|
|
470
|
+
// No SDK on the page: supply the reader below.
|
|
471
|
+
}
|
|
472
|
+
try {
|
|
473
|
+
// Also on every later document, so a full navigation keeps the session.
|
|
474
|
+
await page.addInitScript(script);
|
|
475
|
+
await page.evaluate(script);
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
// A page that refuses the reader has no session. verify's no-session guard reports it.
|
|
479
|
+
}
|
|
480
|
+
}
|
|
425
481
|
/**
|
|
426
482
|
* Wait for the page's Reticle singleton to exist, then re-invoke connect with our token + loopback
|
|
427
483
|
* URL so a hosted (non-localhost) preview pairs to our bridge without the app being reconfigured.
|
|
@@ -433,7 +489,7 @@ export class LaunchedRealInputProvider {
|
|
|
433
489
|
return;
|
|
434
490
|
}
|
|
435
491
|
try {
|
|
436
|
-
await page.waitForFunction(
|
|
492
|
+
await page.waitForFunction(SDK_PRESENT, {
|
|
437
493
|
timeout: INJECT_CONNECT_WAIT_MS,
|
|
438
494
|
});
|
|
439
495
|
const arg = JSON.stringify(injectedConnectArgs(opts));
|
|
@@ -83,8 +83,8 @@ export const VIEWPORT_TOOLS = [
|
|
|
83
83
|
},
|
|
84
84
|
outputSchema: {
|
|
85
85
|
applied: z.boolean(),
|
|
86
|
-
width: z.number(),
|
|
87
|
-
height: z.number(),
|
|
86
|
+
width: z.number().optional(),
|
|
87
|
+
height: z.number().optional(),
|
|
88
88
|
observed: z.object({ width: z.number(), height: z.number() }).optional(),
|
|
89
89
|
colorScheme: z.string().optional(),
|
|
90
90
|
reducedMotion: z.string().optional(),
|
|
@@ -97,7 +97,10 @@ export const VIEWPORT_TOOLS = [
|
|
|
97
97
|
if (media !== undefined && args['width'] === undefined && args['height'] === undefined) {
|
|
98
98
|
const session = deps.sessions.resolve(asString(args['sessionId']));
|
|
99
99
|
if (true === (await deps.pool?.emulateLease(session.id, media))) {
|
|
100
|
-
|
|
100
|
+
// No size was asked for, so answer the one the lease has; 0 by 0 reads as a broken
|
|
101
|
+
// viewport (#1902). When the page cannot be read, leave the size out.
|
|
102
|
+
const size = await deps.pool?.viewportOfLease(session.id);
|
|
103
|
+
return { applied: true, ...size, ...media };
|
|
101
104
|
}
|
|
102
105
|
return { ...refused(), recommendation: MEDIA_NEEDS_A_LEASE };
|
|
103
106
|
}
|
|
@@ -13,7 +13,7 @@ import { type SeedStorage } from '@reticlehq/core';
|
|
|
13
13
|
import type { Launcher, PooledMockRule, ScreenshotOptions } from './pool-contract.js';
|
|
14
14
|
import type { RealInputArgs, RealInputResult } from '../input/real-input.js';
|
|
15
15
|
import { type LeaseEmulation, type LeaseMedia } from './lease-emulation.js';
|
|
16
|
-
export type { InitScriptHandle, Launcher, PooledBrowser, PooledContext, PooledCookie, PooledDialog, PooledMockRule, PooledPage, } from './pool-contract.js';
|
|
16
|
+
export type { InitScriptHandle, Launcher, PooledBrowser, PooledContext, PooledCookie, PooledDialog, PooledMockRule, PooledPage, PooledPageError, } from './pool-contract.js';
|
|
17
17
|
import { type BrowserPoolOptions, type Lease } from './pool-lease.js';
|
|
18
18
|
export { DEFAULT_LEASE_TTL_MS, NO_HEADED_BROWSER, ORPHANED_LEASE_GRACE_MS, PoolAtCapacityError, } from './pool-lease.js';
|
|
19
19
|
export type { Lease } from './pool-lease.js';
|
|
@@ -101,6 +101,8 @@ export declare class BrowserPool {
|
|
|
101
101
|
* reaper take it out from under a diff.
|
|
102
102
|
*/
|
|
103
103
|
screenshotLease(sessionId: string, opts?: ScreenshotOptions): Promise<Uint8Array | undefined>;
|
|
104
|
+
/** The reason {@link screenshotLease} last failed for this session, if it did. */
|
|
105
|
+
leaseCaptureError(sessionId: string): string | undefined;
|
|
104
106
|
/** The URL a lease was opened on — the one that carries its Reticle identity. */
|
|
105
107
|
leaseUrl(sessionId: string): string | undefined;
|
|
106
108
|
/**
|
|
@@ -243,6 +245,8 @@ export declare class BrowserPool {
|
|
|
243
245
|
lastDialogMessage(sessionId: string): string | undefined;
|
|
244
246
|
/** Close every context and the browser. Pending waiters are rejected (the pool is terminal now). */
|
|
245
247
|
shutdown(): Promise<void>;
|
|
248
|
+
/** The origin added to this lease's page CSP `connect-src`, or undefined if none was (#2016). */
|
|
249
|
+
cspWidenedFor(sessionId: string): string | undefined;
|
|
246
250
|
/** Whether this lease's context skips certificate checks. A context cannot change it later. */
|
|
247
251
|
ignoresHTTPSErrors(sessionId: string): boolean;
|
|
248
252
|
/** Whether this lease lives in the headed browser. */
|
|
@@ -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
|
+
}
|