[GH-ISSUE #2166] fix(browser): attached scenarios block service workers despite an explicit allow policy #713

Closed
opened 2026-10-15 18:27:38 +00:00 by bsakaguchi · 0 comments
Owner

Originally created by @coygeek on GitHub (Oct 12, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/2166

Summary

An attached browser scenario that explicitly allows service workers cannot capture a page already controlled by one. The public CLI rejects the supported request before navigation. If the attached page is uncontrolled but its origin has a registration scoped elsewhere, the same request succeeds while removing that registration and disabling further registration in the current document. Both behaviors reproduce in the checkout build and published package.

This affects capture of the caller-selected external browser and its existing worker state. Launching another browser activates a worker successfully with the same policy, but uses a separate owned profile and does not preserve that required result.

Steps to reproduce

Use the measured environment below, an installed Chrome-family executable, and the public REA entrypoint. Save the following as reproduce.mjs in a disposable directory with playwright-core available. It starts only a disposable headless browser and a local HTTP fixture, creates one active registration, and executes the public CLI for an uncontrolled page and then a controlled page. The fixture does not use credentials or a remote application. PLAYWRIGHT_MODULE optionally names an already-installed Playwright Core module; otherwise the ordinary package import is used.

import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { spawn, execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { once } from 'node:events';
import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const { chromium } = await import(process.env.PLAYWRIGHT_MODULE ?? 'playwright-core');
const [executable, entrypoint] = process.argv.slice(2);
assert(executable && entrypoint, 'Supply browser executable and REA scripts/rea.mjs');
const exec = promisify(execFile);
const directory = await mkdtemp(join(tmpdir(), 'worker-policy-check-'));
await mkdir(join(directory, 'home'));
const environment = { ...process.env, HOME: join(directory, 'home') };
const server = createServer((request, response) => {
  response.setHeader('Cache-Control', 'no-store');
  if (request.url === '/sw.js') {
    response.setHeader('Content-Type', 'application/javascript');
    response.end("self.addEventListener('install',()=>self.skipWaiting()); self.addEventListener('activate',e=>e.waitUntil(self.clients.claim()));");
  } else {
    response.setHeader('Content-Type', 'text/html');
    response.end('<!doctype html><p>Local worker fixture</p>');
  }
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const origin = `http://127.0.0.1:${server.address().port}`;
let processHandle, browser;
const deadline = setTimeout(() => processHandle?.kill('SIGTERM'), 30000);
try {
  const profile = join(directory, 'profile');
  processHandle = spawn(executable, [
    '--headless=new', '--remote-debugging-address=127.0.0.1',
    '--remote-debugging-port=0', `--user-data-dir=${profile}`,
    '--no-first-run', '--no-default-browser-check',
    '--disable-background-networking', '--disable-component-update',
    '--disable-sync', '--no-proxy-server', '--password-store=basic',
    '--use-mock-keychain', 'about:blank'
  ], { env: environment, stdio: 'ignore' });
  let port;
  for (let attempt = 0; attempt < 100; attempt++) {
    try { port = (await readFile(join(profile, 'DevToolsActivePort'), 'utf8')).split('\n')[0]; break; }
    catch { await new Promise(resolve => setTimeout(resolve, 50)); }
  }
  assert(port, 'Browser debugging endpoint must be ready');
  const endpoint = `http://127.0.0.1:${port}`;
  browser = await chromium.connectOverCDP(endpoint);
  const context = browser.contexts()[0];
  context.setDefaultNavigationTimeout(5000);
  const inspect = page => page.evaluate(async () => ({
    controller: Boolean(navigator.serviceWorker.controller),
    registrations: (await navigator.serviceWorker.getRegistrations()).length
  }));
  for (const kind of ['uncontrolled', 'controlled']) {
    const page = await context.newPage();
    const url = `${origin}/${kind === 'controlled' ? 'scope' : 'outside'}/`;
    await page.goto(url);
    await page.evaluate(async () => {
      const registration = await navigator.serviceWorker.register('/sw.js', { scope: '/scope/' });
      if (!registration.active) await new Promise(resolve => {
        const worker = registration.installing ?? registration.waiting;
        worker.addEventListener('statechange', () => { if (worker.state === 'activated') resolve(); });
      });
    });
    if (kind === 'controlled') await page.waitForFunction(() => Boolean(navigator.serviceWorker.controller), undefined, { timeout: 3000 });
    const before = await inspect(page);
    const session = await context.newCDPSession(page);
    const { targetInfo } = await session.send('Target.getTargetInfo');
    await session.detach();
    const input = join(directory, 'scenario.json');
    await writeFile(input, JSON.stringify({
      browser: { mode: 'connect', cdp_endpoint: endpoint, target_id: targetInfo.targetId },
      start_url: { url }, environment: { service_workers: 'allow' },
      actions: [{ step_id: 'settle', action: 'wait_for_timeout', duration_ms: 100 }]
    }));
    let output, exit;
    try {
      output = (await exec(process.execPath, [entrypoint, 'capture-browser-scenario', input, '--json'], { env: environment, timeout: 8000 })).stdout;
      exit = 0;
    } catch (error) { output = error.stdout; exit = error.code; }
    const result = JSON.parse(output);
    const after = await inspect(page);
    const registrationResult = kind === 'uncontrolled' ? await page.evaluate(async () => {
      try { await navigator.serviceWorker.register('/sw.js', { scope: '/scope/' }); return 'resolved'; }
      catch (error) { return error.message; }
    }) : null;
    console.log(JSON.stringify({ case: kind, before, exit, after,
      reason: result.details?.reason ?? null, registrationResult,
      cleanup: result.normalized_result?.browser.cleanup ?? null }));
    await page.close();
  }
} finally {
  clearTimeout(deadline);
  await browser?.close();
  if (processHandle && processHandle.exitCode === null && processHandle.signalCode === null) {
    const exited = once(processHandle, 'exit'); processHandle.kill('SIGTERM'); await exited;
  }
  await new Promise(resolve => server.close(resolve));
  await rm(directory, { recursive: true, force: true });
}

Run it against a built checkout or the installed package's entrypoint:

node reproduce.mjs /absolute/path/to/chromium /absolute/path/to/rea-agents/scripts/rea.mjs

The local verification executed these fixture operations with a fresh profile for each probe and inspected the selected page independently after REA exited. The known signal is the rejected controlled allow capture and the successful uncontrolled allow capture that changes registration count from one to zero. Do not run this against a user profile because the affected implementation removes registrations.

Expected behavior

The scenario contract permits callers to select environment.service_workers: "allow" when the application requires service workers. That setting applies to a supported connect request as well as a launch request. An allowed attached scenario should capture its selected page while retaining its existing service-worker registrations and ordinary registration behavior; an existing controller alone should not reject this allowed request. The default and explicit block policies must continue to block service workers.

Actual behavior

Both tested builds produced these observations:

uncontrolled allow:
  before: controller=false, registrations=1
  CLI exit: 0
  cleanup: disconnected-external
  after: controller=false, registrations=0
  register(): rejects "service workers are blocked by scenario policy"

controlled allow:
  before: controller=true, registrations=1
  CLI exit: 1
  public code: target_unavailable
  details.reason: target_not_allowed
  after: controller=true, registrations=1

The controlled case rejects before scenario navigation and leaves that registration intact. The uncontrolled case completes the action and disconnects from the still-running external browser, but its registration is gone and the current document's registration method still rejects. Reloading that uncontrolled page restores the ordinary method, and recreating the registration succeeds. This demonstrates bounded recovery, rather than permanent profile loss.

In the checkout controls, explicit block and the omitted default produce the same rejection on a controlled page and the same removal/blocking on an uncontrolled page. Both builds' launch allow controls activate a service worker, while launch block controls create no worker. Launch captures report a provider-owned browser and terminated-owned-process cleanup. These controls distinguish the ignored attached allow setting from invalid input, an unavailable browser, or a service-worker fixture that cannot activate.

Runtime or environment

  • Checkout baseline: c22111eca2c4bc84a084f12711f405b3e7422a40, prepared compiled runtime with no verifier source changes.
  • Published baseline: independently installed rea-agents@6.4.0; the same fixture was executed through its packaged public CLI.
  • Measured runtime: Node.js 24.21.0, supported by the package engines; this is not the contributor-pinned .nvmrc runtime.
  • Browser and host: Google Chrome 154.0.8037.99, Playwright Core 1.58.2, macOS 27.0.1, arm64, headless, isolated profile.
  • Area and interface: browser scenario capture through the public CLI; shared MCP schema and provider call path inspected but no runtime MCP call executed.
  • Target and earliest stage: benign loopback HTTP page and its own service worker; attached-page initialization before the initial navigation.

Evidence

  • Expected source: docs/browser-scenario-contract.md:3-17 documents the CLI/MCP request and service-worker opt-in without a connect exception; src/domain/browserScenarioValues.ts:78-128 accepts both modes and defaults service workers to block. The browser ownership contract is at docs/browser-scenario-contract.md:133-139 and :202-208.
  • Failure source: real public CLI results and independent browser readback shown above; src/browser/PlaywrightScenarioSession.ts:53-81 rejects an existing controller or installs the block whenever mode is connect, without checking the accepted service-worker setting. The corresponding packaged dist/browser/PlaywrightScenarioSession.js:32-52 contains the same behavior.
  • Evidence provenance: observed
  • Local verification: reproduced
  • Reproduction completeness: complete

Static call-path inspection supplements the observed failure. src/cliBrowserScenarioCommands.ts:32-64 validates the request and constructs the production provider; src/composition/browserScenario.ts:6-10 binds it to the real session; src/browser/PlaywrightScenarioBrowser.ts:406-449 attaches and resolves the selected target before initialization. The controller check precedes navigation. The block unregisters the current origin's registrations, injects a navigation script, and replaces the registration method in the current document. The launch path forwards the selected policy at src/browser/PlaywrightScenarioBrowser.ts:506.

Restoration check

Repeat the same fixture with allow. The controlled page should produce a scenario capture and retain its controller and registration after disconnection. The uncontrolled page should retain its registration and permit another ordinary registration after capture. The browser and selected page must remain externally owned and alive.

Retain both explicit block and omitted-default controls. An already controlled attached page must still follow the existing blocked-policy rejection rather than silently running a controlled worker. An uncontrolled attached page under either blocked policy must still remove existing registrations and prevent registration during the scenario, including its initial navigation. Keep the launch controls passing with an active worker for allow and no worker for block. Success must restore the service-worker choice without changing which browser/profile the connect request captures.

Triage assessment

  • Impact level: P1
  • Assessment status: supported
  • Impact basis: The primary browser scenario workflow cannot observe the caller-selected external page with its existing active service worker despite a valid documented opt-in. The public request fails before capture for controlled pages; uncontrolled pages instead lose their registration and run under the wrong policy. The profile mutation is recoverable in the tested fixture, so this assessment does not claim permanent data loss or an all-input failure.
  • Workaround status: none
  • Workaround basis: The supported launch alternative was observed to use a new provider-owned profile, which cannot preserve the selected external page and worker state. Reloading and registering again recovers an uncontrolled fixture, but a controlled page still fails the next allowed capture. No inspected public connect option preserves the required result while bypassing the unconditional block.
Originally created by @coygeek on GitHub (Oct 12, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/2166 ## Summary An attached browser scenario that explicitly allows service workers cannot capture a page already controlled by one. The public CLI rejects the supported request before navigation. If the attached page is uncontrolled but its origin has a registration scoped elsewhere, the same request succeeds while removing that registration and disabling further registration in the current document. Both behaviors reproduce in the checkout build and published package. This affects capture of the caller-selected external browser and its existing worker state. Launching another browser activates a worker successfully with the same policy, but uses a separate owned profile and does not preserve that required result. ## Steps to reproduce Use the measured environment below, an installed Chrome-family executable, and the public REA entrypoint. Save the following as `reproduce.mjs` in a disposable directory with `playwright-core` available. It starts only a disposable headless browser and a local HTTP fixture, creates one active registration, and executes the public CLI for an uncontrolled page and then a controlled page. The fixture does not use credentials or a remote application. `PLAYWRIGHT_MODULE` optionally names an already-installed Playwright Core module; otherwise the ordinary package import is used. ```javascript import assert from 'node:assert/strict'; import { createServer } from 'node:http'; import { spawn, execFile } from 'node:child_process'; import { promisify } from 'node:util'; import { once } from 'node:events'; import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; const { chromium } = await import(process.env.PLAYWRIGHT_MODULE ?? 'playwright-core'); const [executable, entrypoint] = process.argv.slice(2); assert(executable && entrypoint, 'Supply browser executable and REA scripts/rea.mjs'); const exec = promisify(execFile); const directory = await mkdtemp(join(tmpdir(), 'worker-policy-check-')); await mkdir(join(directory, 'home')); const environment = { ...process.env, HOME: join(directory, 'home') }; const server = createServer((request, response) => { response.setHeader('Cache-Control', 'no-store'); if (request.url === '/sw.js') { response.setHeader('Content-Type', 'application/javascript'); response.end("self.addEventListener('install',()=>self.skipWaiting()); self.addEventListener('activate',e=>e.waitUntil(self.clients.claim()));"); } else { response.setHeader('Content-Type', 'text/html'); response.end('<!doctype html><p>Local worker fixture</p>'); } }); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); const origin = `http://127.0.0.1:${server.address().port}`; let processHandle, browser; const deadline = setTimeout(() => processHandle?.kill('SIGTERM'), 30000); try { const profile = join(directory, 'profile'); processHandle = spawn(executable, [ '--headless=new', '--remote-debugging-address=127.0.0.1', '--remote-debugging-port=0', `--user-data-dir=${profile}`, '--no-first-run', '--no-default-browser-check', '--disable-background-networking', '--disable-component-update', '--disable-sync', '--no-proxy-server', '--password-store=basic', '--use-mock-keychain', 'about:blank' ], { env: environment, stdio: 'ignore' }); let port; for (let attempt = 0; attempt < 100; attempt++) { try { port = (await readFile(join(profile, 'DevToolsActivePort'), 'utf8')).split('\n')[0]; break; } catch { await new Promise(resolve => setTimeout(resolve, 50)); } } assert(port, 'Browser debugging endpoint must be ready'); const endpoint = `http://127.0.0.1:${port}`; browser = await chromium.connectOverCDP(endpoint); const context = browser.contexts()[0]; context.setDefaultNavigationTimeout(5000); const inspect = page => page.evaluate(async () => ({ controller: Boolean(navigator.serviceWorker.controller), registrations: (await navigator.serviceWorker.getRegistrations()).length })); for (const kind of ['uncontrolled', 'controlled']) { const page = await context.newPage(); const url = `${origin}/${kind === 'controlled' ? 'scope' : 'outside'}/`; await page.goto(url); await page.evaluate(async () => { const registration = await navigator.serviceWorker.register('/sw.js', { scope: '/scope/' }); if (!registration.active) await new Promise(resolve => { const worker = registration.installing ?? registration.waiting; worker.addEventListener('statechange', () => { if (worker.state === 'activated') resolve(); }); }); }); if (kind === 'controlled') await page.waitForFunction(() => Boolean(navigator.serviceWorker.controller), undefined, { timeout: 3000 }); const before = await inspect(page); const session = await context.newCDPSession(page); const { targetInfo } = await session.send('Target.getTargetInfo'); await session.detach(); const input = join(directory, 'scenario.json'); await writeFile(input, JSON.stringify({ browser: { mode: 'connect', cdp_endpoint: endpoint, target_id: targetInfo.targetId }, start_url: { url }, environment: { service_workers: 'allow' }, actions: [{ step_id: 'settle', action: 'wait_for_timeout', duration_ms: 100 }] })); let output, exit; try { output = (await exec(process.execPath, [entrypoint, 'capture-browser-scenario', input, '--json'], { env: environment, timeout: 8000 })).stdout; exit = 0; } catch (error) { output = error.stdout; exit = error.code; } const result = JSON.parse(output); const after = await inspect(page); const registrationResult = kind === 'uncontrolled' ? await page.evaluate(async () => { try { await navigator.serviceWorker.register('/sw.js', { scope: '/scope/' }); return 'resolved'; } catch (error) { return error.message; } }) : null; console.log(JSON.stringify({ case: kind, before, exit, after, reason: result.details?.reason ?? null, registrationResult, cleanup: result.normalized_result?.browser.cleanup ?? null })); await page.close(); } } finally { clearTimeout(deadline); await browser?.close(); if (processHandle && processHandle.exitCode === null && processHandle.signalCode === null) { const exited = once(processHandle, 'exit'); processHandle.kill('SIGTERM'); await exited; } await new Promise(resolve => server.close(resolve)); await rm(directory, { recursive: true, force: true }); } ``` Run it against a built checkout or the installed package's entrypoint: ```bash node reproduce.mjs /absolute/path/to/chromium /absolute/path/to/rea-agents/scripts/rea.mjs ``` The local verification executed these fixture operations with a fresh profile for each probe and inspected the selected page independently after REA exited. The known signal is the rejected controlled `allow` capture and the successful uncontrolled `allow` capture that changes registration count from one to zero. Do not run this against a user profile because the affected implementation removes registrations. ## Expected behavior The scenario contract permits callers to select `environment.service_workers: "allow"` when the application requires service workers. That setting applies to a supported connect request as well as a launch request. An allowed attached scenario should capture its selected page while retaining its existing service-worker registrations and ordinary registration behavior; an existing controller alone should not reject this allowed request. The default and explicit `block` policies must continue to block service workers. ## Actual behavior Both tested builds produced these observations: ```text uncontrolled allow: before: controller=false, registrations=1 CLI exit: 0 cleanup: disconnected-external after: controller=false, registrations=0 register(): rejects "service workers are blocked by scenario policy" controlled allow: before: controller=true, registrations=1 CLI exit: 1 public code: target_unavailable details.reason: target_not_allowed after: controller=true, registrations=1 ``` The controlled case rejects before scenario navigation and leaves that registration intact. The uncontrolled case completes the action and disconnects from the still-running external browser, but its registration is gone and the current document's registration method still rejects. Reloading that uncontrolled page restores the ordinary method, and recreating the registration succeeds. This demonstrates bounded recovery, rather than permanent profile loss. In the checkout controls, explicit `block` and the omitted default produce the same rejection on a controlled page and the same removal/blocking on an uncontrolled page. Both builds' launch `allow` controls activate a service worker, while launch `block` controls create no worker. Launch captures report a provider-owned browser and terminated-owned-process cleanup. These controls distinguish the ignored attached `allow` setting from invalid input, an unavailable browser, or a service-worker fixture that cannot activate. ## Runtime or environment - Checkout baseline: `c22111eca2c4bc84a084f12711f405b3e7422a40`, prepared compiled runtime with no verifier source changes. - Published baseline: independently installed `rea-agents@6.4.0`; the same fixture was executed through its packaged public CLI. - Measured runtime: Node.js `24.21.0`, supported by the package engines; this is not the contributor-pinned `.nvmrc` runtime. - Browser and host: Google Chrome `154.0.8037.99`, Playwright Core `1.58.2`, macOS `27.0.1`, arm64, headless, isolated profile. - Area and interface: browser scenario capture through the public CLI; shared MCP schema and provider call path inspected but no runtime MCP call executed. - Target and earliest stage: benign loopback HTTP page and its own service worker; attached-page initialization before the initial navigation. ## Evidence - Expected source: `docs/browser-scenario-contract.md:3-17` documents the CLI/MCP request and service-worker opt-in without a connect exception; `src/domain/browserScenarioValues.ts:78-128` accepts both modes and defaults service workers to block. The browser ownership contract is at `docs/browser-scenario-contract.md:133-139` and `:202-208`. - Failure source: real public CLI results and independent browser readback shown above; `src/browser/PlaywrightScenarioSession.ts:53-81` rejects an existing controller or installs the block whenever mode is connect, without checking the accepted service-worker setting. The corresponding packaged `dist/browser/PlaywrightScenarioSession.js:32-52` contains the same behavior. - Evidence provenance: observed - Local verification: reproduced - Reproduction completeness: complete Static call-path inspection supplements the observed failure. `src/cliBrowserScenarioCommands.ts:32-64` validates the request and constructs the production provider; `src/composition/browserScenario.ts:6-10` binds it to the real session; `src/browser/PlaywrightScenarioBrowser.ts:406-449` attaches and resolves the selected target before initialization. The controller check precedes navigation. The block unregisters the current origin's registrations, injects a navigation script, and replaces the registration method in the current document. The launch path forwards the selected policy at `src/browser/PlaywrightScenarioBrowser.ts:506`. ## Restoration check Repeat the same fixture with `allow`. The controlled page should produce a scenario capture and retain its controller and registration after disconnection. The uncontrolled page should retain its registration and permit another ordinary registration after capture. The browser and selected page must remain externally owned and alive. Retain both explicit `block` and omitted-default controls. An already controlled attached page must still follow the existing blocked-policy rejection rather than silently running a controlled worker. An uncontrolled attached page under either blocked policy must still remove existing registrations and prevent registration during the scenario, including its initial navigation. Keep the launch controls passing with an active worker for `allow` and no worker for `block`. Success must restore the service-worker choice without changing which browser/profile the connect request captures. ## Triage assessment - Impact level: P1 - Assessment status: supported - Impact basis: The primary browser scenario workflow cannot observe the caller-selected external page with its existing active service worker despite a valid documented opt-in. The public request fails before capture for controlled pages; uncontrolled pages instead lose their registration and run under the wrong policy. The profile mutation is recoverable in the tested fixture, so this assessment does not claim permanent data loss or an all-input failure. - Workaround status: none - Workaround basis: The supported launch alternative was observed to use a new provider-owned profile, which cannot preserve the selected external page and worker state. Reloading and registering again recovers an uncontrolled fixture, but a controlled page still fails the next allowed capture. No inspected public connect option preserves the required result while bypassing the unconditional block.
bsakaguchi 2026-10-15 18:27:38 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#713
No description provided.