Ungoogled Headless Chromium with custom built WebRCT
  • C++ 44.3%
  • JavaScript 15.7%
  • Go 15.5%
  • TypeScript 9.5%
  • Shell 7.1%
  • Other 7.9%
Find a file
triform-admin 44f2e20dec
All checks were successful
CI / Lint (push) Successful in 53s
CodeQL / Analyze go (push) Has been skipped
CodeQL / Analyze javascript-typescript (push) Has been skipped
CI / Docs link check (push) Successful in 11s
CI / Container smoke test (push) Successful in 1m5s
E2E / docker-compose + Playwright (push) Successful in 17s
Merge pull request 'build: the ADM test is built here but runs where audio exists' (#61) from adm/move-out-of-compile-lane into main
2026-07-31 09:34:21 +00:00
.github fix(build): local outside a function killed STEP 7 silently — plus a lint 2026-07-30 20:09:40 +02:00
build build: the ADM test is built here but runs where audio exists 2026-07-31 10:47:18 +02:00
capture Merge remote-tracking branch 'origin/main' into c8/three-unbuilt-tests 2026-07-31 00:59:05 +02:00
client feat(client): extract ChromelessSession — the answerer machine, importable without the demo page (Track 5) 2026-07-30 07:39:23 +02:00
conformance feat(signaling): accept the portal's flat dialect on decode (C4) 2026-07-30 20:24:44 +02:00
docs docs(protocols): write down the signaling envelope, now that there are two dialects 2026-07-30 20:24:44 +02:00
harness test(cv2-wave2): stabilize M5 harness predicates 2026-05-22 16:26:29 +03:00
infra build: the ADM test is built here but runs where audio exists 2026-07-31 10:47:18 +02:00
patches fix(build): the test passthrough was missing two LINK-only overrides 2026-07-30 23:40:52 +02:00
signaling fix(cv2): two silent-failure bugs in Cb.startNativeSession; add Cb.shutdown 2026-07-29 13:22:03 +02:00
tests ci: fix the four defects the first real-image run exposed 2026-07-30 15:06:38 +02:00
tools build: close the two gaps that let a green lane mean nothing 2026-07-30 23:16:31 +02:00
verification R2-fix: non-blocking FAIL in scaffold → SKIPPED-for-verdict (load-bearing UAT) 2026-05-16 19:29:20 +02:00
.gitignore fix: make the repo runnable and honest for people who aren't triform 2026-07-29 13:22:32 +02:00
CLAUDE.md docs: decisions the user must make go through AskUserQuestion, always 2026-07-31 10:41:18 +02:00
CONTRIBUTING.md rename: shallow user-visible rename cb-* / cloud-browser-webrtc → chromeless 2026-05-05 09:20:12 +02:00
LICENSE rename: shallow user-visible rename cb-* / cloud-browser-webrtc → chromeless 2026-05-05 09:20:12 +02:00
Makefile build: close the two gaps that let a green lane mean nothing 2026-07-30 23:16:31 +02:00
PROJECT_BRIEF.md docs: a map, and banners on the docs that describe a deleted architecture 2026-07-30 17:42:41 +02:00
README.md docs: a map, and banners on the docs that describe a deleted architecture 2026-07-30 17:42:41 +02:00

chromeless

A Chromium embedder that streams a real browser over WebRTC, with a native libwebrtc peer running inside the browser process.

A real Chromium runs on a Linux server, its tab is captured straight out of the Viz compositor via FrameSinkVideoCapturer, encoded (x264 / VP9 / NVENC / VAAPI / SVT-AV1), and streamed to a client. Mouse, keyboard, clipboard, file uploads, cursor updates, and audio round-trip through the same peer connection. Useful for browser isolation, agent tooling, embedded co-browsing, and anywhere you need a real Chromium you don't want to run locally.

Status: source-available, in production. This is not a packaged product. There are no published container images, no npm package, and no release tags — you build it yourself. See Building for what that costs.


What you actually get

Component Language What it is
capture/ C++ The product. A Chromium content-embedder (cloud_browser_worker) built out-of-tree against a pinned Chromium release branch. Native browser-process libwebrtc peer, FrameSink capture, encoder factory, input dispatch, five data channels, a custom Cb.* CDP domain.
signaling/ Go WebSocket signaling broker. One session = one client + one browser. JWT auth, TURN credential minting, offer/ICE replay buffers.
client/ TypeScript Browser-side client: answerer state machine, input encoder, cursor/clipboard/file-upload/stats channels, codec negotiation, reconnect. Tested with vitest.
infra/ YAML/shell compose stack, Helm chart, BrowserSession CRDs + controller, TURN issuer.
harness/ mixed Glass-to-glass latency measurement (flashing block + webcam reconciliation).

The browser is always the offerer; the client is the answerer.


Quickstart

You need a cloud_browser_worker image. Nothing here publishes one, so either build it (below) or use one your organization already built.

git clone <this-repo> && cd chromeless

# Build the client bundle once.
( cd client && npm ci && npm run build )

CHROMELESS_IMAGE=my-registry/chromeless:cr7727-abc1234 \
  docker compose -f infra/compose.yaml up

Then open http://localhost:3000.

Driving it from a browser on the host means the in-network DNS name won't resolve, so point the client at the published port:

CHROMELESS_IMAGE=SIGNALING_URL=ws://localhost:8080 \
  docker compose -f infra/compose.yaml up

CHROMELESS_IMAGE is required and has no default — compose fails fast with a message rather than pulling a tag that doesn't exist.

Without CHROMELESS_ICE_SERVERS the peer falls back to public STUN. That is fine for a same-host compose run and will not traverse most NATs; bring your own TURN for anything else.


Building

The browser is a from-source Chromium build. There is no smaller path:

  • 48 hours cold, ~1 hour warm with a populated sccache.
  • Needs a full gclient sync of the Chromium tree plus a many-core builder.
  • Produces cloud_browser_worker + a runtime image via build/Dockerfile.runtime.
build/chromeless-build.sh          # see build/README.md for the 10 steps and knobs

Chromium is pinned to a release branch (currently refs/branch-heads/7727, ≈M147) and rolled roughly every four weeks. Images are tagged cr<chromium-branch>-<repo-sha>, so the tag answers "which Chromium is in here". Pinning policy and the roll procedure: docs/build/chromium-from-source.md §6.

The Go services and the TypeScript client build normally and need none of the above:

( cd signaling && go build ./... && go test ./... )
( cd client && npm ci && npm test )

Architecture

        ┌──────────── User's machine ────────────┐
        │  Browser client — client/              │
        │  RTCPeerConnection (answerer)          │
        │  recv video+audio · send input over DC │
        └───────┬───────────────────────▲────────┘
                │ WebSocket (SDP/ICE)   │ SRTP/DTLS media + DataChannels
                ▼                       │
        ┌──────────────────────┐        │
        │  signaling/  (Go)    │        │
        │  offer + ICE replay  │        │
        └───────┬──────────────┘        │
                │                       │
┌───────────────▼───────────────────────┼──────────────────────────────┐
│  cloud_browser_worker  (capture/)     │                              │
│                                       │                              │
│   Chromium browser process            │                              │
│     ├─ Viz compositor                 │                              │
│     │    └─ FrameSinkVideoCapturer ──▶ video track source ──┐        │
│     ├─ native libwebrtc PeerConnection ◀────────────────────┘        │
│     │    └─ encoder factory: x264 / VP9 / NVENC / VAAPI / SVT-AV1 ───┤
│     ├─ DataChannels: input · stats · cursor · clipboard · files      │
│     └─ CDP: Cb.startSession · Cb.startFrameSinkCapture · …           │
└──────────────────────────────────────────────────────────────────────┘

The peer lives in the browser process, not in a page. There is no streamer web page and no getDisplayMedia — both were removed in the M7 native-peer migration.

Wire protocol. {type, from, data} with six tags: offer, answer, ice, bye, request_renegotiate, probe_result. ice data is an RTCIceCandidateInit or null (end-of-candidates); bye omits data entirely. The authoritative statement is capture/signaling/cb_wire_envelope.h.

Channel protocols — one spec per channel, each independently versioned — live in docs/protocols/.


Configuration

The worker is configured by environment. infra/launch-chromeless.sh translates the friendly names into what the browser process reads:

Set this Becomes Meaning
SIGNALING_URL WEBRTC_SIGNALING_HOST + _TLS ws[s]://host[:port]. Path is ignored — the peer builds its own from the session id.
SESSION_ID WEBRTC_SIGNALING_SESSION_ID Opaque string. Must survive percent-encoding as one path segment.
SIGNALING_TOKEN WEBRTC_SIGNALING_TOKEN Optional JWT, forwarded verbatim as a query param.
CHROMELESS_ICE_SERVERS WEBRTC_ICE_SERVERS JSON array of {urls, username, credential}, or an object with an iceServers array.
CHROMELESS_ICE_TRANSPORT_POLICY WEBRTC_ICE_TRANSPORT_POLICY all (default) or relay.

Setting a WEBRTC_* variable directly always wins, which is how the Kubernetes controller drives it.

Sessions can also be started at runtime over CDP — Cb.startSession (and its deprecated alias Cb.startNativeSession) takes the same settings as parameters, which is what a warm-pool orchestrator uses after restoring a snapshot.


Deploying

  • Kubernetes — the chart at infra/helm/chromeless/ ships BrowserSession / BrowserSessionPool CRDs and a controller that translates annotations into pod env. Point images.* at your own registry; nothing is published to a public one. Required values: the Ed25519 auth pubkey, the TURN shared secret, and TURN URLs.
  • Firecracker / warm snapshots — boot the worker with no signaling env and drive Cb.startSession over CDP once the microVM is restored.
  • Bare dockerinfra/compose.yaml is the reference.

Day-2 material is in docs/operations/; triform-deploy.md there documents one real production cluster and is useful as a worked example, not as a generic guide.


Known limitations

  • WebGL is software-rendered (ANGLE + SwiftShader; Vulkan is off because Xvfb has no Vulkan driver). Simple scenes are fine; heavy WebGL drops below ~5 fps. See docs/research/rendering-matrix.md.
  • WebGPU is unsupported. navigator.gpu exists but requestAdapter() returns null — Dawn needs Vulkan.
  • One tab per session, one session per worker process. Starting a second session in a live process is not yet supported; the process is single-use.
  • Cb.* is not in /json/protocol. The domain is hand-dispatched, so protocol-introspecting CDP clients won't discover it.
  • Hardware encoders are compile-time. NVENC/VAAPI/SVT-AV1 availability is fixed by the build variant; runtime config selects among what was compiled in.

Latency

Latency is the design constraint: <100 ms glass-to-glass on LAN, <200 ms regional. It is measured, not asserted — harness/ points a webcam at the client screen, emits timestamped color transitions, and recovers end-to-end latency from the captured video. Method: harness/latency/README.md. Full acceptance criteria: docs/v1-success-criteria.md.


Prior art

We owe Selkies, Neko, and Kasm significant prior art; each solves an adjacent problem. Selkies is GStreamer-based desktop streaming, not browser-native. Neko is a multi-user "watch together" room around a shared browser. Kasm is a polished commercial product built on VNC/KasmVNC.

chromeless is WebRTC-first, one-Chromium-per-user, capture-from-compositor, and latency-obsessed. If that overlaps with what you need, it may be useful to you.


Repo layout

Path Contents
capture/ The Chromium embedder: browser-process WebRTC peer, FrameSink capture, encoders, input dispatch, Cb.* CDP domain.
signaling/ Go WebSocket signaling broker.
client/ TypeScript browser client + demo page.
infra/ compose stack, Helm chart, CRDs + controller, TURN issuer, lifecycle scripts.
build/ Chromium build orchestration and the runtime image.
patches/ The (small) Chromium patch series.
docs/protocols/ Per-channel wire specs — the most useful docs here.
harness/ Glass-to-glass latency measurement.
tests/ Smoke, integration, e2e, and WebRTC drivers.
verification/ Assertion-gate tooling.

docs/README.md is the map: it groups the documentation by what you're trying to do (integrate / operate / change the browser) and lists, explicitly, which documents are historical.

Historical planning documents (PROJECT_BRIEF.md, the phase-exit reports, the Phase 1 stack audit) describe an earlier architecture — stock Chromium plus getDisplayMedia — that no longer exists. They each carry a status banner saying so. Read them as history.


Contributing

See CONTRIBUTING.md. Two things worth knowing up front:

  • Anything in the hot path wants a harness measurement attached.
  • A change to a documented protocol needs the spec in docs/protocols/ updated in the same change.

License

Apache-2.0 — see LICENSE.

This project builds and distributes a derivative of Chromium, which carries its own licenses (BSD-3-Clause plus the terms of its many third-party dependencies). If you ship binaries produced by build/chromeless-build.sh, those obligations are yours to meet.