From b92994f6b5f8882b6d4835d7b80bcc386a7d1549 Mon Sep 17 00:00:00 2001 From: askalf <263217947+askalf@users.noreply.github.com> Date: Fri, 17 Jul 2026 19:02:21 -0400 Subject: [PATCH] fix(govern): observe({page}) routes through the live-capture path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit captureFromBridge has accepted a caller-owned page since the broker seam (PR #5), but observe()'s hasBridge check only looked for browserWSEndpoint/ browserURL — a bare {page} fell through to captureFromHtml(undefined) instead of reading the live session. Callers holding an authed page (broker checkout, an agent's active browser_use session) can now observe current page state through the live extractor: computed-style hidden detection, shadow roots, no navigation, lifecycle untouched. --- src/govern.mjs | 9 +++++++-- test/broker.test.mjs | 21 ++++++++++++++++++++- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/govern.mjs b/src/govern.mjs index b75481e..3f09a1a 100644 --- a/src/govern.mjs +++ b/src/govern.mjs @@ -87,7 +87,8 @@ export class GovernedBrowser { _log(entry) { this.audit.push({ ...entry }); return entry; } /** - * Perception plane. Accepts either static HTML or a live bridge target, runs + * Perception plane. Accepts static HTML, a live bridge target, or a + * caller-owned `page` (a broker checkout, an agent's active session), runs * it through the firewall, and returns the safe, model-facing view. * @returns {Promise<{observation, detection, decision, safe}>} */ @@ -97,7 +98,11 @@ export class GovernedBrowser { // computed styles resolve class-based hiding. The static parser is the // browserless fallback (CI, offline demos), not a silent override that // would bypass the whole reason the bridge exists on a hostile page. - const hasBridge = input.browserWSEndpoint != null || input.browserURL != null; + // A caller-owned `page` is a bridge target too: captureFromBridge reuses + // it as-is (no navigation when no url/html is given, lifecycle untouched), + // so an agent's CURRENT page state reads through the live extractor — + // without this, observe({page}) fell through to captureFromHtml(undefined). + const hasBridge = input.browserWSEndpoint != null || input.browserURL != null || input.page != null; const observation = hasBridge ? await captureFromBridge(input) : captureFromHtml(input.html, { url: input.url }); diff --git a/test/broker.test.mjs b/test/broker.test.mjs index 085813f..fb4a08e 100644 --- a/test/broker.test.mjs +++ b/test/broker.test.mjs @@ -5,7 +5,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { ContextBroker } from '../src/broker.mjs'; -import { KeeperStub } from '../src/govern.mjs'; +import { GovernedBrowser, KeeperStub } from '../src/govern.mjs'; import { captureFromBridge } from '../src/capture.mjs'; const tick = () => new Promise((r) => setTimeout(r, 0)); @@ -131,3 +131,22 @@ test('captureFromBridge reuses a provided page (broker session) without touching assert.equal(obs.nodes.length, 1); assert.equal(obs.origin, 'https://acme.example'); }); + +test('observe({page}) routes through the live-capture path, not the static parser', async () => { + const page = { + gotos: [], + async goto(u) { this.gotos.push(u); }, + async evaluate() { + return { title: 'Live', nodes: [{ id: 'n0', text: 'hello from the live page', source: 'text', tag: 'p', hidden: false, hiddenReasons: [] }] }; + }, + url: () => 'https://live.example/session', + }; + const gov = new GovernedBrowser(); + const res = await gov.observe({ page }); + // Regression: a bare {page} used to miss the hasBridge check and fall + // through to captureFromHtml(undefined). It must read the LIVE page. + assert.equal(res.observation.capturedBy, 'cdp'); + assert.equal(res.observation.url, 'https://live.example/session'); + assert.deepEqual(page.gotos, []); // current state read as-is, no navigation + assert.ok(res.safe.text.includes('hello from the live page')); +});