Skip to content

Isolate experimental Lightpanda on current main - #28

Draft
figitaki wants to merge 10 commits into
mainfrom
carey/lightpanda-host
Draft

figitaki wants to merge 10 commits into
mainfrom
carey/lightpanda-host

Conversation

@figitaki

@figitaki figitaki commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

The original Lightpanda prototype launched the browser inside the broker process environment. This update brings the experiment onto main 794d8642 and replaces that launch path with an externally provisioned, one-shot worker container connected through a private Unix socket.

The worker has no broker credentials or state, accepts one client, and is retired on disconnect, startup failure, crash or expiry. The offline examples use separate network namespaces, no networking, read-only roots, no capabilities and only a dedicated IPC directory. The broker receives no container-runtime or Kubernetes privileges. A startup gate rejects missing CDP document identity or isolated-world semantics instead of falling back to page-controlled evidence.

Main's caller ownership, scoped pending approvals, target binding, per-write authorization and redaction remain in place. SDK cancellation now reaches the fill path and is checked before export and each plaintext CDP write; cancelling a Lightpanda request closes its worker. The browser receives only intended approved field values, never Turnkey credentials or export bundles.

Validation:

  • Typecheck, formatting check and broker build pass.
  • Full local regression suite: 168 passed, no failures. The dedicated Lightpanda container tests are run separately.
  • Pinned Lightpanda 1.0.0 in two separate offline containers: 14 assertions covering real observation semantics, page prototype tampering, mock approval, synthetic injection, redaction, cancellation after approval, wrong target/destination and exclusive attachment.
  • Active worker expiry and forced container crash: both invalidate the session and refuse reconnect.
  • Unit coverage includes environment allowlisting, malformed/oversized transport frames, private socket permissions, failed attachment, startup crash, cancellation and plaintext-write suppression.

This remains a draft offline experiment: one worker per broker, no pool or reconnect, no Internet browsing configuration, no Kubernetes deployment, and no real-secret setup. Mock approval is not Turnkey quorum. The compatibility gate and these tests are not a browser sandbox audit; deployment controls and any destination egress require separate review. See docs/LIGHTPANDA.md and examples/lightpanda/ for the boundary and reproducible tests.

History preserves the original PR commit as an ancestor, with its in-broker implementation explicitly superseded; the branch update is a normal fast-forward, not a force-push. No merge to main or image publication is included.

SBM_BROWSER=lightpanda runs each session's browser in its own
`lightpanda serve` process on a random loopback port. The broker passes
--cdp-max-connections 1, so no other CDP client can attach after it
connects. It also loads iframes, turns off the metrics endpoint, and
turns off telemetry.

Lightpanda's default page has no browser context, so hosts can now open
the session's page themselves (BrowserHost.openPage).

The existing e2e suite passes on Lightpanda 1.0.0. THREAT-MODEL.md
says what changes: there is no sandbox, and CDP is a TCP port instead
of a pipe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@figitaki
figitaki force-pushed the carey/lightpanda-host branch from 9f86d56 to 46f42d6 Compare October 15, 2026 21:20
figitaki and others added 9 commits October 16, 2026 16:37
PR #26 committed node_modules as a symlink to an absolute path on one
machine. Everywhere else it is a dangling link, and in the original
checkout it points at itself, so bun install and bun test fail there.

The ignore rule was node_modules/ with a trailing slash, which matches
only directories. A symlink with that name is a file to git, so it was
not ignored. The rule is now node_modules, which covers both.

The image build copies only package.json, bun.lock, src and
tsconfig.json, so CI was never affected.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…ts (#37)

* Build browser-secrets target observations from the live page

Add src/browser/observe.ts. It builds the TargetObservation that
@turnkey/browser-secrets authorize decides on. Frame, document, URL and
origin come from Page.getFrameTree on the broker's CDP session. Shadow-root
mode and selector matches are read in an isolated world, so page script
cannot fake them. A selector the browser rejects records false. Detached
and unknown elements are left out, so authorize refuses them.

BrowserSession gets a host-owned session ID that changes on each browser
start.

The tests run the observation builder against the fixture pages, and run
the browser-secrets conformance fixture cases that the fixture pages can
serve against live observations.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Authorize fills with browser-secrets instead of BindingPolicy

fill_secret and await_fill now decide with @turnkey/browser-secrets
authorize, on an observation of the live page, in place of
BindingPolicy.assertAllowed and its elementMatches callback. Request shape
and binding checks run before the browser is touched. Each target is
authorized again immediately before its write, and isSameDestination
requires the tab, document and element that were checked. await_fill also
requires the page it checked before the approval wait.

Secret refs come from the package's createSecretRef, so both backends
parse sbm:* bindings with the shared parser. bindingError is now a code
such as invalid_fields instead of a sentence.

A refused fill returns FillDeniedError: the package's fixed message for
the deny reason, the reason code, and the element uid. It no longer
includes the page URL or the bound origin. The e2e and HTTP tests now
check the reason code, because the message text changed.

Remove src/broker/binding.ts and parseBinding from mock-secrets.ts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Redact through the browser-secrets RedactionRegistry

Every agent session now gets the @turnkey/browser-secrets
RedactionRegistry, and server.ts scrubs each tool result with
registry.scrub(). The broker's own registry is no longer used.

- Before any write, injectResolved registers redactionEntries(ref, value):
  the plaintext and, for an sbm:fields secret, each payload part.
- Each target is registered with its document-scoped identity (browser
  session, tab, frame, document, element) from the decision made just
  before its write. inject.ts no longer touches the registry.
- The snapshot tags and elides fields with isRegisteredTarget,
  findSecretIds and findMatches, and reads the document identity from
  the browser.

A behavior change: values shorter than four characters, such as a
three-digit CVC, are no longer text-scanned. The filled field is still
redacted by its target tag. The redaction tests now run on the package
registry and record this change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Remove the broker's own redaction modules and URLPattern shim

Nothing imports src/redaction/registry.ts or src/redaction/scrub.ts after
the move to @turnkey/browser-secrets, so delete them. The package brings
URLPattern (global or polyfill), so src/urlpattern.d.ts goes too. The
import script checks a URL pattern with the package's parseBinding.

docs/DESIGN.md now describes the observe-and-authorize fill flow and the
package registry.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Pin @turnkey/browser-secrets to 0.0.1-alpha.0

The first release of the package is an alpha, so the dependency is an
exact version. A caret range would not follow alpha bumps.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Resolve @turnkey/browser-secrets 0.0.1-alpha.0 from npm

The package is on npm now, so the lockfile records the registry
version instead of nothing. `bun install --frozen-lockfile` in the
image build needed this entry.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* Serve MCP 2026-07-28 with browser handles bound to the caller

open_browser returns a server-minted browser_id (192 random bits).
Every browser tool takes an optional browser_id and acts in that
browser. close_browser or the idle timeout ends it. A handle holds its
own browser, redaction registry, element uids and pending fills, so
fill_secret parks a fill on the handle and await_fill redeems it from
any connection.

- A handle is bound to the endpoint, principal (iss, sub) and OAuth
  client that opened it, the same binding a session has. For anyone
  else it gives the same "Unknown browser_id" as an id never minted.
- The 2026-07-28 leg (createMcpHandler) replaces rejectModern. A modern
  request has no browser of its own, so browser tools need browser_id.
- 2025 sessions keep their own browser for calls without browser_id,
  and can use handles too. This is for the Arcade gateway, which opens
  a session for every tool call.
- Handles get their own copy of the session caps and the session idle
  timeout. A busy handle is not swept.
- At a session limit, the caller's own least recently used session
  with no running browser and no request in flight is closed first, so
  a session-per-call client does not hit its limit after a few calls.

Refs EMG-288

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document browser handles and the 2026-07-28 leg

HOSTED.md describes both protocol eras, the new Browser handles
section, the session reclaim rule, and the limits handles share with
sessions. The tool reference and skill cover open_browser,
close_browser and browser_id.

Refs EMG-288

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Update the README layout table for browser-secrets and auth

src/redaction/ moved into @turnkey/browser-secrets, and src/auth/ had
no row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Fix the open Dependabot alerts on the MCP TypeScript SDK.

- @modelcontextprotocol/server 2.2.0 -> 2.3.1 (runtime):
  GHSA-jm63-cq47-j5jp, GHSA-46jr-4wx8-v453, GHSA-rvq5-wwqv-78pq
- @modelcontextprotocol/client 2.2.0 -> 2.3.1 (dev):
  GHSA-6prh-2h8m-c8cw

Both are direct dependencies. @modelcontextprotocol/core moves to
2.3.1 with them in bun.lock.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Kargo's NewestBuild strategy orders images by created time. Images built on
the same base carried the base image's created time, so Kargo kept the first
one and did not promote later builds.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Kargo's NewestBuild strategy reads the org.opencontainers.image.created label
when it is set. The oven/bun base image sets that label, so every broker image
built on it reported the base image's time, and Kargo kept promoting
main-eaeea99. This replaces the SOURCE_DATE_EPOCH change, which Kargo ignores.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@figitaki figitaki changed the title Add an experimental Lightpanda browser host Isolate experimental Lightpanda on current main Oct 23, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant